mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
17 Commits
v1.0.1
...
908016cbd6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
908016cbd6 | ||
|
|
a5ac9cf81b | ||
|
|
32875042bd | ||
|
|
51fe7aa07e | ||
|
|
db4726a961 | ||
|
|
e13d70248a | ||
|
|
1c4919a3e8 | ||
|
|
18ddadc9ec | ||
|
|
b6dd6938b0 | ||
|
|
b711ac468a | ||
|
|
727d0ef043 | ||
|
|
9344d86332 | ||
|
|
d36b16c213 | ||
|
|
33a7f07558 | ||
|
|
4f599aeced | ||
|
|
30db8c3d1d | ||
|
|
05636712f0 |
20
README.md
20
README.md
@@ -56,34 +56,28 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v1.0.2
|
||||||
|
|
||||||
|
* **Model Download History Tracking** - LoRA Manager now keeps a history of downloaded model versions, allowing it to recognize whether a version has been downloaded before, even if it is no longer currently present in your library.
|
||||||
|
* **Skip Previously Downloaded Model Versions** - Added a new setting, `Skip previously downloaded model versions`, to help avoid downloading model versions you have already downloaded in the past.
|
||||||
|
* **LoRA Stack Combiner Trigger Words Fix** - Fixed an issue where trigger word updates from `LORA_STACK` inputs were not propagated correctly through the LoRA Stack Combiner node.
|
||||||
|
* **CivitAI Example Image Compatibility** - Improved support for CivitAI CDN subdomains so example images load more reliably.
|
||||||
|
|
||||||
### v1.0.1
|
### v1.0.1
|
||||||
|
|
||||||
* **Batch Recipe Import** - Import recipes from multiple URLs or directories simultaneously with optimized concurrency.
|
* **Batch Recipe Import** - Import recipes from multiple URLs or directories simultaneously with optimized concurrency.
|
||||||
|
|
||||||
* **Bulk Download Missing LoRAs** - New bulk action for recipes: select multiple recipes and download all missing LoRAs for the selected recipes in one operation.
|
* **Bulk Download Missing LoRAs** - New bulk action for recipes: select multiple recipes and download all missing LoRAs for the selected recipes in one operation.
|
||||||
|
|
||||||
* **Import-Only Recipe Option** - Save recipe metadata without downloading missing LoRAs, allowing you to save interesting recipes for later and download dependencies when needed.
|
* **Import-Only Recipe Option** - Save recipe metadata without downloading missing LoRAs, allowing you to save interesting recipes for later and download dependencies when needed.
|
||||||
|
|
||||||
* **Editable Recipe Prompts** - Edit recipe prompts directly in the recipe detail modal.
|
* **Editable Recipe Prompts** - Edit recipe prompts directly in the recipe detail modal.
|
||||||
|
|
||||||
* **Checkpoint Loader LM Node** - Behaves like ComfyUI's built-in Load Checkpoint node, with the added ability to load checkpoints from Extra Folder Paths.
|
* **Checkpoint Loader LM Node** - Behaves like ComfyUI's built-in Load Checkpoint node, with the added ability to load checkpoints from Extra Folder Paths.
|
||||||
|
|
||||||
* **UNET Loader LM Node** - Behaves like ComfyUI's built-in Load Diffusion Model node, with support for loading from Extra Folder Paths and GGUF format (requires ComfyUI-GGUF custom node).
|
* **UNET Loader LM Node** - Behaves like ComfyUI's built-in Load Diffusion Model node, with support for loading from Extra Folder Paths and GGUF format (requires ComfyUI-GGUF custom node).
|
||||||
|
|
||||||
* **LoRA Stack Combiner Node** - Merge two LoRA stacks into one. For example: use separate Randomizers for character and style LoRAs, then combine before applying.
|
* **LoRA Stack Combiner Node** - Merge two LoRA stacks into one. For example: use separate Randomizers for character and style LoRAs, then combine before applying.
|
||||||
|
|
||||||
* **LoRA Pool Regex Filtering** - Filter which LoRAs enter the pool using custom regex patterns for include/exclude rules.
|
* **LoRA Pool Regex Filtering** - Filter which LoRAs enter the pool using custom regex patterns for include/exclude rules.
|
||||||
|
|
||||||
* **Dynamic Base Model Types** - Base model types are now fetched dynamically from Civitai API, keeping them synchronized with the latest available models.
|
* **Dynamic Base Model Types** - Base model types are now fetched dynamically from Civitai API, keeping them synchronized with the latest available models.
|
||||||
|
|
||||||
* **Prompt Autocomplete Enhancements** - Tab key acceptance, configurable behavior, and improved multi-word tag matching.
|
* **Prompt Autocomplete Enhancements** - Tab key acceptance, configurable behavior, and improved multi-word tag matching.
|
||||||
|
|
||||||
* **Download Base Model Exclusions** - Exclude specific base models from download operations when you only want certain model types.
|
* **Download Base Model Exclusions** - Exclude specific base models from download operations when you only want certain model types.
|
||||||
|
|
||||||
* **Mature Blur Threshold Setting** - Configure blur levels (`PG13` / `R` / `X` / `XXX`, default `R+`) for mature content previews.
|
* **Mature Blur Threshold Setting** - Configure blur levels (`PG13` / `R` / `X` / `XXX`, default `R+`) for mature content previews.
|
||||||
|
|
||||||
* **Experimental: Nunchaku Qwen LoRA Support** - Experimental support for loading and applying LoRAs to Nunchaku quantized Qwen-Image models.
|
* **Experimental: Nunchaku Qwen LoRA Support** - Experimental support for loading and applying LoRAs to Nunchaku quantized Qwen-Image models.
|
||||||
|
|
||||||
* **Bug Fixes & UX Improvements** - Various fixes for a smoother workflow.
|
* **Bug Fixes & UX Improvements** - Various fixes for a smoother workflow.
|
||||||
|
|
||||||
### v1.0.0
|
### v1.0.0
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "Layout-Einstellungen",
|
"layoutSettings": "Layout-Einstellungen",
|
||||||
"misc": "Verschiedenes",
|
"misc": "Verschiedenes",
|
||||||
"folderSettings": "Standard-Roots",
|
"folderSettings": "Standard-Roots",
|
||||||
|
"recipeSettings": "Rezepte",
|
||||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||||
"priorityTags": "Prioritäts-Tags",
|
"priorityTags": "Prioritäts-Tags",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}"
|
"saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "Bereits heruntergeladene Modellversionen überspringen",
|
||||||
|
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Anzeige-Dichte",
|
"displayDensity": "Anzeige-Dichte",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
||||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"recipesPath": "Rezepte-Speicherpfad",
|
||||||
|
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Rezepte-Speicher wird verschoben...",
|
||||||
"noDefault": "Kein Standard"
|
"noDefault": "Kein Standard"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben",
|
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "An Workflow senden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "Fehler beim Speichern der Basis-Modell-Zuordnungen: {message}",
|
"mappingSaveFailed": "Fehler beim Speichern der Basis-Modell-Zuordnungen: {message}",
|
||||||
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
||||||
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
||||||
|
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
||||||
|
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
||||||
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
||||||
"compactModeToggled": "Kompakt-Modus {state}",
|
"compactModeToggled": "Kompakt-Modus {state}",
|
||||||
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
|
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "Layout Settings",
|
"layoutSettings": "Layout Settings",
|
||||||
"misc": "Miscellaneous",
|
"misc": "Miscellaneous",
|
||||||
"folderSettings": "Default Roots",
|
"folderSettings": "Default Roots",
|
||||||
|
"recipeSettings": "Recipes",
|
||||||
"extraFolderPaths": "Extra Folder Paths",
|
"extraFolderPaths": "Extra Folder Paths",
|
||||||
"downloadPathTemplates": "Download Path Templates",
|
"downloadPathTemplates": "Download Path Templates",
|
||||||
"priorityTags": "Priority Tags",
|
"priorityTags": "Priority Tags",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
},
|
},
|
||||||
"downloadSkipBaseModels": {
|
"downloadSkipBaseModels": {
|
||||||
"label": "Skip downloads for base models",
|
"label": "Skip downloads for base models",
|
||||||
"help": "When a model version uses one of these base models, LoRA Manager will skip the download before any file transfer starts. Applies to all download flows. Only supported base models can be selected here.",
|
"help": "When enabled, versions using the selected base models will be skipped.",
|
||||||
"searchPlaceholder": "Filter base models...",
|
"searchPlaceholder": "Filter base models...",
|
||||||
"empty": "No base models match the current search.",
|
"empty": "No base models match the current search.",
|
||||||
"summary": {
|
"summary": {
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "Unable to save excluded base models: {message}"
|
"saveFailed": "Unable to save excluded base models: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "Skip previously downloaded model versions",
|
||||||
|
"help": "When enabled, versions downloaded before will be skipped."
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Display Density",
|
"displayDensity": "Display Density",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||||
"defaultEmbeddingRoot": "Embedding Root",
|
"defaultEmbeddingRoot": "Embedding Root",
|
||||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
||||||
|
"recipesPath": "Recipes Storage Path",
|
||||||
|
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Migrating recipes storage...",
|
||||||
"noDefault": "No Default"
|
"noDefault": "No Default"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||||
"downloadTemplatesUpdated": "Download path templates updated",
|
"downloadTemplatesUpdated": "Download path templates updated",
|
||||||
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
||||||
|
"recipesPathUpdated": "Recipes storage path updated",
|
||||||
|
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||||
"settingsUpdated": "Settings updated: {setting}",
|
"settingsUpdated": "Settings updated: {setting}",
|
||||||
"compactModeToggled": "Compact Mode {state}",
|
"compactModeToggled": "Compact Mode {state}",
|
||||||
"settingSaveFailed": "Failed to save setting: {message}",
|
"settingSaveFailed": "Failed to save setting: {message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "Configuración de diseño",
|
"layoutSettings": "Configuración de diseño",
|
||||||
"misc": "Varios",
|
"misc": "Varios",
|
||||||
"folderSettings": "Raíces predeterminadas",
|
"folderSettings": "Raíces predeterminadas",
|
||||||
|
"recipeSettings": "Recetas",
|
||||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||||
"priorityTags": "Etiquetas prioritarias",
|
"priorityTags": "Etiquetas prioritarias",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "No se pudieron guardar los modelos base excluidos: {message}"
|
"saveFailed": "No se pudieron guardar los modelos base excluidos: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "Omitir versiones de modelos previamente descargadas",
|
||||||
|
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Densidad de visualización",
|
"displayDensity": "Densidad de visualización",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||||
"defaultEmbeddingRoot": "Raíz de embedding",
|
"defaultEmbeddingRoot": "Raíz de embedding",
|
||||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||||
|
"recipesPath": "Ruta de almacenamiento de recetas",
|
||||||
|
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Migrando el almacenamiento de recetas...",
|
||||||
"noDefault": "Sin predeterminado"
|
"noDefault": "Sin predeterminado"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}",
|
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "Enviar al flujo de trabajo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||||
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
||||||
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
||||||
|
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
||||||
|
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
||||||
"settingsUpdated": "Configuración actualizada: {setting}",
|
"settingsUpdated": "Configuración actualizada: {setting}",
|
||||||
"compactModeToggled": "Modo compacto {state}",
|
"compactModeToggled": "Modo compacto {state}",
|
||||||
"settingSaveFailed": "Error al guardar configuración: {message}",
|
"settingSaveFailed": "Error al guardar configuración: {message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "Paramètres d'affichage",
|
"layoutSettings": "Paramètres d'affichage",
|
||||||
"misc": "Divers",
|
"misc": "Divers",
|
||||||
"folderSettings": "Racines par défaut",
|
"folderSettings": "Racines par défaut",
|
||||||
|
"recipeSettings": "Recipes",
|
||||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||||
"priorityTags": "Étiquettes prioritaires",
|
"priorityTags": "Étiquettes prioritaires",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "Impossible d’enregistrer les modèles de base exclus : {message}"
|
"saveFailed": "Impossible d’enregistrer les modèles de base exclus : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "Ignorer les versions de modèles précédemment téléchargées",
|
||||||
|
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Densité d'affichage",
|
"displayDensity": "Densité d'affichage",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
||||||
"defaultEmbeddingRoot": "Racine Embedding",
|
"defaultEmbeddingRoot": "Racine Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"recipesPath": "Recipes Storage Path",
|
||||||
|
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Migrating recipes storage...",
|
||||||
"noDefault": "Aucun par défaut"
|
"noDefault": "Aucun par défaut"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}",
|
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "Envoyer vers le workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||||
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
||||||
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
||||||
|
"recipesPathUpdated": "Recipes storage path updated",
|
||||||
|
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||||
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
||||||
"compactModeToggled": "Mode compact {state}",
|
"compactModeToggled": "Mode compact {state}",
|
||||||
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
|
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "הגדרות פריסה",
|
"layoutSettings": "הגדרות פריסה",
|
||||||
"misc": "שונות",
|
"misc": "שונות",
|
||||||
"folderSettings": "תיקיות ברירת מחדל",
|
"folderSettings": "תיקיות ברירת מחדל",
|
||||||
|
"recipeSettings": "מתכונים",
|
||||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||||
"priorityTags": "תגיות עדיפות",
|
"priorityTags": "תגיות עדיפות",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}"
|
"saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "דלג על גרסאות מודלים שהורדו בעבר",
|
||||||
|
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "צפיפות תצוגה",
|
"displayDensity": "צפיפות תצוגה",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||||
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
||||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||||
|
"recipesPath": "נתיב אחסון מתכונים",
|
||||||
|
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "מעביר את אחסון המתכונים...",
|
||||||
"noDefault": "אין ברירת מחדל"
|
"noDefault": "אין ברירת מחדל"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}",
|
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "שלח ל-workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||||
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
||||||
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
||||||
|
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
||||||
|
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
||||||
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
||||||
"compactModeToggled": "מצב קומפקטי {state}",
|
"compactModeToggled": "מצב קומפקטי {state}",
|
||||||
"settingSaveFailed": "שמירת ההגדרה נכשלה: {message}",
|
"settingSaveFailed": "שמירת ההגדרה נכשלה: {message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "レイアウト設定",
|
"layoutSettings": "レイアウト設定",
|
||||||
"misc": "その他",
|
"misc": "その他",
|
||||||
"folderSettings": "デフォルトルート",
|
"folderSettings": "デフォルトルート",
|
||||||
|
"recipeSettings": "レシピ",
|
||||||
"extraFolderPaths": "追加フォルダーパス",
|
"extraFolderPaths": "追加フォルダーパス",
|
||||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||||
"priorityTags": "優先タグ",
|
"priorityTags": "優先タグ",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "除外するベースモデルを保存できませんでした: {message}"
|
"saveFailed": "除外するベースモデルを保存できませんでした: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "以前にダウンロードしたモデルバージョンをスキップ",
|
||||||
|
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "表示密度",
|
"displayDensity": "表示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||||
"defaultEmbeddingRoot": "Embeddingルート",
|
"defaultEmbeddingRoot": "Embeddingルート",
|
||||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||||
|
"recipesPath": "レシピ保存先",
|
||||||
|
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "レシピ保存先を移動中...",
|
||||||
"noDefault": "デフォルトなし"
|
"noDefault": "デフォルトなし"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "{otherType} フォルダに移動",
|
"moveToOtherTypeFolder": "{otherType} フォルダに移動",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "ワークフローに送信"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||||
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
||||||
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
||||||
|
"recipesPathUpdated": "レシピ保存先を更新しました",
|
||||||
|
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
||||||
"settingsUpdated": "設定が更新されました:{setting}",
|
"settingsUpdated": "設定が更新されました:{setting}",
|
||||||
"compactModeToggled": "コンパクトモード {state}",
|
"compactModeToggled": "コンパクトモード {state}",
|
||||||
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "레이아웃 설정",
|
"layoutSettings": "레이아웃 설정",
|
||||||
"misc": "기타",
|
"misc": "기타",
|
||||||
"folderSettings": "기본 루트",
|
"folderSettings": "기본 루트",
|
||||||
|
"recipeSettings": "레시피",
|
||||||
"extraFolderPaths": "추가 폴다 경로",
|
"extraFolderPaths": "추가 폴다 경로",
|
||||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||||
"priorityTags": "우선순위 태그",
|
"priorityTags": "우선순위 태그",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
|
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "이전에 다운로드한 모델 버전 건너뛰기",
|
||||||
|
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "표시 밀도",
|
"displayDensity": "표시 밀도",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||||
"defaultEmbeddingRoot": "Embedding 루트",
|
"defaultEmbeddingRoot": "Embedding 루트",
|
||||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||||
|
"recipesPath": "레시피 저장 경로",
|
||||||
|
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "레시피 저장 경로를 이동 중...",
|
||||||
"noDefault": "기본값 없음"
|
"noDefault": "기본값 없음"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "{otherType} 폴더로 이동",
|
"moveToOtherTypeFolder": "{otherType} 폴더로 이동",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "워크플로우로 전송"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||||
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
||||||
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
||||||
|
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
||||||
|
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
||||||
"settingsUpdated": "설정 업데이트됨: {setting}",
|
"settingsUpdated": "설정 업데이트됨: {setting}",
|
||||||
"compactModeToggled": "컴팩트 모드 {state}",
|
"compactModeToggled": "컴팩트 모드 {state}",
|
||||||
"settingSaveFailed": "설정 저장 실패: {message}",
|
"settingSaveFailed": "설정 저장 실패: {message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "Настройки макета",
|
"layoutSettings": "Настройки макета",
|
||||||
"misc": "Разное",
|
"misc": "Разное",
|
||||||
"folderSettings": "Корневые папки",
|
"folderSettings": "Корневые папки",
|
||||||
|
"recipeSettings": "Рецепты",
|
||||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||||
"priorityTags": "Приоритетные теги",
|
"priorityTags": "Приоритетные теги",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "Не удалось сохранить исключённые базовые модели: {message}"
|
"saveFailed": "Не удалось сохранить исключённые базовые модели: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "Пропускать ранее загруженные версии моделей",
|
||||||
|
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "Плотность отображения",
|
"displayDensity": "Плотность отображения",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||||
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"recipesPath": "Путь хранения рецептов",
|
||||||
|
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "Перенос хранилища рецептов...",
|
||||||
"noDefault": "Не задано"
|
"noDefault": "Не задано"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "Переместить в папку {otherType}",
|
"moveToOtherTypeFolder": "Переместить в папку {otherType}",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "Отправить в workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||||
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
||||||
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
||||||
|
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
||||||
|
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
||||||
"settingsUpdated": "Настройки обновлены: {setting}",
|
"settingsUpdated": "Настройки обновлены: {setting}",
|
||||||
"compactModeToggled": "Компактный режим {state}",
|
"compactModeToggled": "Компактный режим {state}",
|
||||||
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
|
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "布局设置",
|
"layoutSettings": "布局设置",
|
||||||
"misc": "其他",
|
"misc": "其他",
|
||||||
"folderSettings": "默认根目录",
|
"folderSettings": "默认根目录",
|
||||||
|
"recipeSettings": "配方",
|
||||||
"extraFolderPaths": "额外文件夹路径",
|
"extraFolderPaths": "额外文件夹路径",
|
||||||
"downloadPathTemplates": "下载路径模板",
|
"downloadPathTemplates": "下载路径模板",
|
||||||
"priorityTags": "优先标签",
|
"priorityTags": "优先标签",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "无法保存已排除的基础模型:{message}"
|
"saveFailed": "无法保存已排除的基础模型:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "跳过已下载的模型版本",
|
||||||
|
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "显示密度",
|
"displayDensity": "显示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目录",
|
"defaultEmbeddingRoot": "Embedding 根目录",
|
||||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||||
|
"recipesPath": "配方存储路径",
|
||||||
|
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "正在迁移配方存储...",
|
||||||
"noDefault": "无默认"
|
"noDefault": "无默认"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
|
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "发送到工作流"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||||
"downloadTemplatesUpdated": "下载路径模板已更新",
|
"downloadTemplatesUpdated": "下载路径模板已更新",
|
||||||
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
||||||
|
"recipesPathUpdated": "配方存储路径已更新",
|
||||||
|
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
||||||
"settingsUpdated": "设置已更新:{setting}",
|
"settingsUpdated": "设置已更新:{setting}",
|
||||||
"compactModeToggled": "紧凑模式 {state}",
|
"compactModeToggled": "紧凑模式 {state}",
|
||||||
"settingSaveFailed": "保存设置失败:{message}",
|
"settingSaveFailed": "保存设置失败:{message}",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"layoutSettings": "版面設定",
|
"layoutSettings": "版面設定",
|
||||||
"misc": "其他",
|
"misc": "其他",
|
||||||
"folderSettings": "預設根目錄",
|
"folderSettings": "預設根目錄",
|
||||||
|
"recipeSettings": "配方",
|
||||||
"extraFolderPaths": "額外資料夾路徑",
|
"extraFolderPaths": "額外資料夾路徑",
|
||||||
"downloadPathTemplates": "下載路徑範本",
|
"downloadPathTemplates": "下載路徑範本",
|
||||||
"priorityTags": "優先標籤",
|
"priorityTags": "優先標籤",
|
||||||
@@ -341,6 +342,10 @@
|
|||||||
"saveFailed": "無法儲存已排除的基礎模型:{message}"
|
"saveFailed": "無法儲存已排除的基礎模型:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"skipPreviouslyDownloadedModelVersions": {
|
||||||
|
"label": "跳過已下載的模型版本",
|
||||||
|
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||||
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"displayDensity": "顯示密度",
|
"displayDensity": "顯示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
@@ -389,6 +394,10 @@
|
|||||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目錄",
|
"defaultEmbeddingRoot": "Embedding 根目錄",
|
||||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||||
|
"recipesPath": "配方儲存路徑",
|
||||||
|
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
||||||
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
|
"recipesPathMigrating": "正在遷移配方儲存...",
|
||||||
"noDefault": "未設定預設"
|
"noDefault": "未設定預設"
|
||||||
},
|
},
|
||||||
"extraFolderPaths": {
|
"extraFolderPaths": {
|
||||||
@@ -827,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
|
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
|
||||||
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
|
"sendToWorkflow": "傳送到工作流"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embeddings": {
|
"embeddings": {
|
||||||
@@ -1625,6 +1634,8 @@
|
|||||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||||
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
||||||
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
||||||
|
"recipesPathUpdated": "配方儲存路徑已更新",
|
||||||
|
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
||||||
"settingsUpdated": "設定已更新:{setting}",
|
"settingsUpdated": "設定已更新:{setting}",
|
||||||
"compactModeToggled": "緊湊模式已{state}",
|
"compactModeToggled": "緊湊模式已{state}",
|
||||||
"settingSaveFailed": "儲存設定失敗:{message}",
|
"settingSaveFailed": "儲存設定失敗:{message}",
|
||||||
|
|||||||
12
py/config.py
12
py/config.py
@@ -134,6 +134,7 @@ class Config:
|
|||||||
self.extra_checkpoints_roots: List[str] = []
|
self.extra_checkpoints_roots: List[str] = []
|
||||||
self.extra_unet_roots: List[str] = []
|
self.extra_unet_roots: List[str] = []
|
||||||
self.extra_embeddings_roots: List[str] = []
|
self.extra_embeddings_roots: List[str] = []
|
||||||
|
self.recipes_path: str = ""
|
||||||
# Scan symbolic links during initialization
|
# Scan symbolic links during initialization
|
||||||
self._initialize_symlink_mappings()
|
self._initialize_symlink_mappings()
|
||||||
|
|
||||||
@@ -652,6 +653,8 @@ class Config:
|
|||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
for root in self.extra_embeddings_roots or []:
|
for root in self.extra_embeddings_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
|
if self.recipes_path:
|
||||||
|
preview_roots.update(self._expand_preview_root(self.recipes_path))
|
||||||
|
|
||||||
for target, link in self._path_mappings.items():
|
for target, link in self._path_mappings.items():
|
||||||
preview_roots.update(self._expand_preview_root(target))
|
preview_roots.update(self._expand_preview_root(target))
|
||||||
@@ -911,9 +914,11 @@ class Config:
|
|||||||
self,
|
self,
|
||||||
folder_paths: Mapping[str, Iterable[str]],
|
folder_paths: Mapping[str, Iterable[str]],
|
||||||
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||||
|
recipes_path: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
self._path_mappings.clear()
|
self._path_mappings.clear()
|
||||||
self._preview_root_paths = set()
|
self._preview_root_paths = set()
|
||||||
|
self.recipes_path = recipes_path if isinstance(recipes_path, str) else ""
|
||||||
|
|
||||||
lora_paths = folder_paths.get("loras", []) or []
|
lora_paths = folder_paths.get("loras", []) or []
|
||||||
checkpoint_paths = folder_paths.get("checkpoints", []) or []
|
checkpoint_paths = folder_paths.get("checkpoints", []) or []
|
||||||
@@ -1169,7 +1174,12 @@ class Config:
|
|||||||
if not isinstance(extra_folder_paths, Mapping):
|
if not isinstance(extra_folder_paths, Mapping):
|
||||||
extra_folder_paths = None
|
extra_folder_paths = None
|
||||||
|
|
||||||
self._apply_library_paths(folder_paths, extra_folder_paths)
|
recipes_path = (
|
||||||
|
str(library_config.get("recipes_path", ""))
|
||||||
|
if isinstance(library_config, Mapping)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
self._apply_library_paths(folder_paths, extra_folder_paths, recipes_path)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)",
|
"Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||||
|
|
||||||
@@ -427,6 +429,75 @@ class ImageSizeExtractor(NodeMetadataExtractor):
|
|||||||
"node_id": node_id
|
"node_id": node_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract LoRA metadata from rgthree Power Lora Loader.
|
||||||
|
|
||||||
|
The node passes LoRAs as dynamic kwargs: LORA_1, LORA_2, ... each containing
|
||||||
|
{'on': bool, 'lora': filename, 'strength': float, 'strengthTwo': float}.
|
||||||
|
"""
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_loras = []
|
||||||
|
for key, value in inputs.items():
|
||||||
|
if not key.upper().startswith('LORA_'):
|
||||||
|
continue
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
if not value.get('on') or not value.get('lora'):
|
||||||
|
continue
|
||||||
|
lora_name = os.path.splitext(os.path.basename(value['lora']))[0]
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": round(float(value.get('strength', 1.0)), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
if active_loras:
|
||||||
|
metadata[LORAS][node_id] = {
|
||||||
|
"lora_list": active_loras,
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TensorRTLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract checkpoint metadata from TensorRT Loader.
|
||||||
|
|
||||||
|
extract() parses the engine filename from 'unet_name' as a best-effort
|
||||||
|
fallback (strips profile suffix after '_$' and counter suffix).
|
||||||
|
|
||||||
|
update() checks if the output MODEL has attachments["source_model"]
|
||||||
|
set by the node (NubeBuster fork) and overrides with the real name.
|
||||||
|
Vanilla TRT doesn't set this — the filename parse stands.
|
||||||
|
"""
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "unet_name" not in inputs:
|
||||||
|
return
|
||||||
|
unet_name = inputs.get("unet_name")
|
||||||
|
# Strip path and extension, then drop the $_profile suffix
|
||||||
|
model_name = os.path.splitext(os.path.basename(unet_name))[0]
|
||||||
|
if "_$" in model_name:
|
||||||
|
model_name = model_name[:model_name.index("_$")]
|
||||||
|
# Strip counter suffix (e.g. _00001_) left by ComfyUI's save path
|
||||||
|
model_name = re.sub(r'_\d+_?$', '', model_name)
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||||
|
return
|
||||||
|
first_output = outputs[0]
|
||||||
|
if not isinstance(first_output, tuple) or len(first_output) < 1:
|
||||||
|
return
|
||||||
|
model = first_output[0]
|
||||||
|
# NubeBuster fork sets attachments["source_model"] on the ModelPatcher
|
||||||
|
source_model = getattr(model, 'attachments', {}).get("source_model")
|
||||||
|
if source_model:
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, source_model)
|
||||||
|
|
||||||
|
|
||||||
class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -577,8 +648,6 @@ class SamplerCustomAdvancedExtractor(BaseSamplerExtractor):
|
|||||||
# Extract latent dimensions
|
# Extract latent dimensions
|
||||||
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
|
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -715,8 +784,11 @@ NODE_EXTRACTORS = {
|
|||||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||||
"LoraLoader": LoraLoaderExtractor,
|
"LoraLoader": LoraLoaderExtractor,
|
||||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||||
|
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||||
|
"TensorRTLoader": TensorRTLoaderExtractor,
|
||||||
# Conditioning
|
# Conditioning
|
||||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||||
|
"CLIPTextEncodeAttentionBias": CLIPTextEncodeExtractor, # From https://github.com/silveroxides/ComfyUI_PromptAttention
|
||||||
"PromptLM": CLIPTextEncodeExtractor,
|
"PromptLM": CLIPTextEncodeExtractor,
|
||||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ from typing import Awaitable, Callable, Dict, List
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
# Use wildcard for CivitAI to support their CDN subdomains (e.g., image-b2.civitai.com)
|
||||||
|
# Security note: This is acceptable because:
|
||||||
|
# 1. CSP img-src only controls image/video loading, not script execution
|
||||||
|
# 2. All *.civitai.com subdomains are controlled by Civitai
|
||||||
|
# 3. Explicit domain list would require constant updates as Civitai adds CDN nodes
|
||||||
REMOTE_MEDIA_SOURCES = (
|
REMOTE_MEDIA_SOURCES = (
|
||||||
"https://image.civitai.com",
|
"https://*.civitai.com",
|
||||||
"https://img.genur.art",
|
"https://img.genur.art",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def relax_csp_for_remote_media(
|
async def relax_csp_for_remote_media(
|
||||||
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
request: web.Request,
|
||||||
|
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
|
||||||
) -> web.StreamResponse:
|
) -> web.StreamResponse:
|
||||||
"""Allow LoRA Manager media previews to load from trusted remote domains.
|
"""Allow LoRA Manager media previews to load from trusted remote domains.
|
||||||
|
|
||||||
@@ -43,7 +49,9 @@ async def relax_csp_for_remote_media(
|
|||||||
directive_order.append(name)
|
directive_order.append(name)
|
||||||
directives[name] = values
|
directives[name] = values
|
||||||
|
|
||||||
def merge_sources(name: str, sources: List[str], defaults: List[str] | None = None) -> None:
|
def merge_sources(
|
||||||
|
name: str, sources: List[str], defaults: List[str] | None = None
|
||||||
|
) -> None:
|
||||||
existing = directives.get(name, list(defaults or []))
|
existing = directives.get(name, list(defaults or []))
|
||||||
|
|
||||||
for source in sources:
|
for source in sources:
|
||||||
|
|||||||
@@ -751,6 +751,7 @@ class ServiceRegistryAdapter:
|
|||||||
get_lora_scanner: Callable[[], Awaitable]
|
get_lora_scanner: Callable[[], Awaitable]
|
||||||
get_checkpoint_scanner: Callable[[], Awaitable]
|
get_checkpoint_scanner: Callable[[], Awaitable]
|
||||||
get_embedding_scanner: Callable[[], Awaitable]
|
get_embedding_scanner: Callable[[], Awaitable]
|
||||||
|
get_downloaded_version_history_service: Callable[[], Awaitable]
|
||||||
|
|
||||||
|
|
||||||
class ModelLibraryHandler:
|
class ModelLibraryHandler:
|
||||||
@@ -764,6 +765,41 @@ class ModelLibraryHandler:
|
|||||||
self._service_registry = service_registry
|
self._service_registry = service_registry
|
||||||
self._metadata_provider_factory = metadata_provider_factory
|
self._metadata_provider_factory = metadata_provider_factory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_model_type(model_type: str | None) -> str | None:
|
||||||
|
if not isinstance(model_type, str):
|
||||||
|
return None
|
||||||
|
normalized = model_type.strip().lower()
|
||||||
|
if normalized in {"lora", "locon", "dora"}:
|
||||||
|
return "lora"
|
||||||
|
if normalized == "checkpoint":
|
||||||
|
return "checkpoint"
|
||||||
|
if normalized in {"embedding", "textualinversion"}:
|
||||||
|
return "embedding"
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_scanner_for_type(self, model_type: str | None):
|
||||||
|
normalized_type = self._normalize_model_type(model_type)
|
||||||
|
if normalized_type == "lora":
|
||||||
|
return normalized_type, await self._service_registry.get_lora_scanner()
|
||||||
|
if normalized_type == "checkpoint":
|
||||||
|
return normalized_type, await self._service_registry.get_checkpoint_scanner()
|
||||||
|
if normalized_type == "embedding":
|
||||||
|
return normalized_type, await self._service_registry.get_embedding_scanner()
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def _get_download_history_service(self):
|
||||||
|
return await self._service_registry.get_downloaded_version_history_service()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _with_downloaded_flag(versions: list[dict]) -> list[dict]:
|
||||||
|
enriched: list[dict] = []
|
||||||
|
for version in versions:
|
||||||
|
entry = dict(version)
|
||||||
|
entry.setdefault("hasBeenDownloaded", True)
|
||||||
|
enriched.append(entry)
|
||||||
|
return enriched
|
||||||
|
|
||||||
async def check_model_exists(self, request: web.Request) -> web.Response:
|
async def check_model_exists(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
model_id_str = request.query.get("modelId")
|
model_id_str = request.query.get("modelId")
|
||||||
@@ -819,11 +855,30 @@ class ModelLibraryHandler:
|
|||||||
exists = True
|
exists = True
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
has_been_downloaded = False
|
||||||
|
history_type = model_type
|
||||||
|
if history_type:
|
||||||
|
has_been_downloaded = await history_service.has_been_downloaded(
|
||||||
|
history_type,
|
||||||
|
model_version_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
|
if await history_service.has_been_downloaded(
|
||||||
|
candidate_type,
|
||||||
|
model_version_id,
|
||||||
|
):
|
||||||
|
has_been_downloaded = True
|
||||||
|
history_type = candidate_type
|
||||||
|
break
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"exists": exists,
|
"exists": exists,
|
||||||
"modelType": model_type if exists else None,
|
"modelType": model_type if exists else history_type,
|
||||||
|
"hasBeenDownloaded": has_been_downloaded,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -841,23 +896,166 @@ class ModelLibraryHandler:
|
|||||||
|
|
||||||
model_type = None
|
model_type = None
|
||||||
versions = []
|
versions = []
|
||||||
|
downloaded_version_ids = []
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
if lora_versions:
|
if lora_versions:
|
||||||
model_type = "lora"
|
model_type = "lora"
|
||||||
versions = lora_versions
|
versions = self._with_downloaded_flag(lora_versions)
|
||||||
|
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||||
|
model_type,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
elif checkpoint_versions:
|
elif checkpoint_versions:
|
||||||
model_type = "checkpoint"
|
model_type = "checkpoint"
|
||||||
versions = checkpoint_versions
|
versions = self._with_downloaded_flag(checkpoint_versions)
|
||||||
|
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||||
|
model_type,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
elif embedding_versions:
|
elif embedding_versions:
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
versions = embedding_versions
|
versions = self._with_downloaded_flag(embedding_versions)
|
||||||
|
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||||
|
model_type,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
|
candidate_downloaded_version_ids = (
|
||||||
|
await history_service.get_downloaded_version_ids(
|
||||||
|
candidate_type,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if candidate_downloaded_version_ids:
|
||||||
|
model_type = candidate_type
|
||||||
|
downloaded_version_ids = candidate_downloaded_version_ids
|
||||||
|
break
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "modelType": model_type, "versions": versions}
|
{
|
||||||
|
"success": True,
|
||||||
|
"modelType": model_type,
|
||||||
|
"versions": versions,
|
||||||
|
"downloadedVersionIds": downloaded_version_ids,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_model_version_download_status(
|
||||||
|
self, request: web.Request
|
||||||
|
) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_type, _ = await self._get_scanner_for_type(request.query.get("modelType"))
|
||||||
|
if not model_type:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelType is required"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
model_version_id_str = request.query.get("modelVersionId")
|
||||||
|
if not model_version_id_str:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing required parameter: modelVersionId"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model_version_id = int(model_version_id_str)
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"modelType": model_type,
|
||||||
|
"modelVersionId": model_version_id,
|
||||||
|
"hasBeenDownloaded": await history_service.has_been_downloaded(
|
||||||
|
model_type,
|
||||||
|
model_version_id,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Failed to get model version download status: %s",
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def set_model_version_download_status(
|
||||||
|
self, request: web.Request
|
||||||
|
) -> web.Response:
|
||||||
|
try:
|
||||||
|
if request.method == "GET":
|
||||||
|
data = request.query
|
||||||
|
else:
|
||||||
|
data = await request.json()
|
||||||
|
model_type, _ = await self._get_scanner_for_type(data.get("modelType"))
|
||||||
|
if not model_type:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelType is required"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_version_id = int(data.get("modelVersionId"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
downloaded = data.get("downloaded")
|
||||||
|
if isinstance(downloaded, str):
|
||||||
|
normalized_downloaded = downloaded.strip().lower()
|
||||||
|
if normalized_downloaded in {"true", "1"}:
|
||||||
|
downloaded = True
|
||||||
|
elif normalized_downloaded in {"false", "0"}:
|
||||||
|
downloaded = False
|
||||||
|
|
||||||
|
if not isinstance(downloaded, bool):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter downloaded must be a boolean"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
if downloaded:
|
||||||
|
model_id = data.get("modelId")
|
||||||
|
file_path = data.get("filePath")
|
||||||
|
await history_service.mark_downloaded(
|
||||||
|
model_type,
|
||||||
|
model_version_id,
|
||||||
|
model_id=model_id,
|
||||||
|
source="manual",
|
||||||
|
file_path=file_path if isinstance(file_path, str) else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await history_service.mark_not_downloaded(model_type, model_version_id)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"modelType": model_type,
|
||||||
|
"modelVersionId": model_version_id,
|
||||||
|
"hasBeenDownloaded": downloaded,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Failed to set model version download status: %s",
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
model_id_str = request.query.get("modelId")
|
model_id_str = request.query.get("modelId")
|
||||||
@@ -896,18 +1094,8 @@ class ModelLibraryHandler:
|
|||||||
model_name = response.get("name", "")
|
model_name = response.get("name", "")
|
||||||
model_type = response.get("type", "").lower()
|
model_type = response.get("type", "").lower()
|
||||||
|
|
||||||
scanner = None
|
normalized_type, scanner = await self._get_scanner_for_type(model_type)
|
||||||
normalized_type = None
|
if not normalized_type:
|
||||||
if model_type in {"lora", "locon", "dora"}:
|
|
||||||
scanner = await self._service_registry.get_lora_scanner()
|
|
||||||
normalized_type = "lora"
|
|
||||||
elif model_type == "checkpoint":
|
|
||||||
scanner = await self._service_registry.get_checkpoint_scanner()
|
|
||||||
normalized_type = "checkpoint"
|
|
||||||
elif model_type == "textualinversion":
|
|
||||||
scanner = await self._service_registry.get_embedding_scanner()
|
|
||||||
normalized_type = "embedding"
|
|
||||||
else:
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -925,8 +1113,14 @@ class ModelLibraryHandler:
|
|||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
local_versions = await scanner.get_model_versions_by_id(model_id)
|
local_versions = await scanner.get_model_versions_by_id(model_id)
|
||||||
local_version_ids = {version["versionId"] for version in local_versions}
|
local_version_ids = {version["versionId"] for version in local_versions}
|
||||||
|
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||||
|
normalized_type,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
downloaded_version_id_set = set(downloaded_version_ids)
|
||||||
|
|
||||||
enriched_versions = []
|
enriched_versions = []
|
||||||
for version in versions:
|
for version in versions:
|
||||||
@@ -939,6 +1133,7 @@ class ModelLibraryHandler:
|
|||||||
if version.get("images")
|
if version.get("images")
|
||||||
else None,
|
else None,
|
||||||
"inLibrary": version_id in local_version_ids,
|
"inLibrary": version_id in local_version_ids,
|
||||||
|
"hasBeenDownloaded": version_id in downloaded_version_id_set,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1007,6 +1202,33 @@ class ModelLibraryHandler:
|
|||||||
}
|
}
|
||||||
|
|
||||||
versions: list[dict] = []
|
versions: list[dict] = []
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
model_ids: list[int] = []
|
||||||
|
for model in models:
|
||||||
|
try:
|
||||||
|
model_ids.append(int(model.get("id")))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
lora_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||||
|
"lora",
|
||||||
|
model_ids,
|
||||||
|
)
|
||||||
|
checkpoint_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||||
|
"checkpoint",
|
||||||
|
model_ids,
|
||||||
|
)
|
||||||
|
embedding_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||||
|
"embedding",
|
||||||
|
model_ids,
|
||||||
|
)
|
||||||
|
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
||||||
|
"lora": lora_downloaded,
|
||||||
|
"locon": lora_downloaded,
|
||||||
|
"dora": lora_downloaded,
|
||||||
|
"checkpoint": checkpoint_downloaded,
|
||||||
|
"textualinversion": embedding_downloaded,
|
||||||
|
}
|
||||||
for model in models:
|
for model in models:
|
||||||
if not isinstance(model, dict):
|
if not isinstance(model, dict):
|
||||||
continue
|
continue
|
||||||
@@ -1061,6 +1283,8 @@ class ModelLibraryHandler:
|
|||||||
in_library = await scanner.check_model_version_exists(
|
in_library = await scanner.check_model_version_exists(
|
||||||
version_id_int
|
version_id_int
|
||||||
)
|
)
|
||||||
|
downloaded_versions = downloaded_version_map.get(model_type, {})
|
||||||
|
downloaded_version_ids = downloaded_versions.get(model_id_int, set())
|
||||||
|
|
||||||
versions.append(
|
versions.append(
|
||||||
{
|
{
|
||||||
@@ -1073,6 +1297,7 @@ class ModelLibraryHandler:
|
|||||||
"baseModel": version.get("baseModel"),
|
"baseModel": version.get("baseModel"),
|
||||||
"thumbnailUrl": thumbnail_url,
|
"thumbnailUrl": thumbnail_url,
|
||||||
"inLibrary": in_library,
|
"inLibrary": in_library,
|
||||||
|
"hasBeenDownloaded": version_id_int in downloaded_version_ids,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1655,6 +1880,8 @@ class MiscHandlerSet:
|
|||||||
"update_node_widget": self.node_registry.update_node_widget,
|
"update_node_widget": self.node_registry.update_node_widget,
|
||||||
"get_registry": self.node_registry.get_registry,
|
"get_registry": self.node_registry.get_registry,
|
||||||
"check_model_exists": self.model_library.check_model_exists,
|
"check_model_exists": self.model_library.check_model_exists,
|
||||||
|
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
||||||
|
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
||||||
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
||||||
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
||||||
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
||||||
@@ -1679,4 +1906,5 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
|
|||||||
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
||||||
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
||||||
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
||||||
|
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET",
|
||||||
|
"/api/lm/model-version-download-status",
|
||||||
|
"get_model_version_download_status",
|
||||||
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST",
|
||||||
|
"/api/lm/model-version-download-status",
|
||||||
|
"set_model_version_download_status",
|
||||||
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET",
|
||||||
|
"/api/lm/set-model-version-download-status",
|
||||||
|
"set_model_version_download_status",
|
||||||
|
),
|
||||||
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"POST", "/api/lm/download-metadata-archive", "download_metadata_archive"
|
"POST", "/api/lm/download-metadata-archive", "download_metadata_archive"
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ class DownloadManager:
|
|||||||
"""Get the checkpoint scanner from registry"""
|
"""Get the checkpoint scanner from registry"""
|
||||||
return await ServiceRegistry.get_checkpoint_scanner()
|
return await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
|
||||||
|
async def _has_been_downloaded(self, model_type: str, model_version_id: int) -> bool:
|
||||||
|
try:
|
||||||
|
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||||
|
return await history_service.has_been_downloaded(model_type, model_version_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to read download history for %s version %s: %s",
|
||||||
|
model_type,
|
||||||
|
model_version_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
async def download_from_civitai(
|
async def download_from_civitai(
|
||||||
self,
|
self,
|
||||||
model_id: int = None,
|
model_id: int = None,
|
||||||
@@ -355,6 +368,57 @@ class DownloadManager:
|
|||||||
"error": f'Model type "{model_type_from_info}" is not supported for download',
|
"error": f'Model type "{model_type_from_info}" is not supported for download',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolved_version_id = model_version_id
|
||||||
|
raw_version_id = version_info.get("id")
|
||||||
|
if resolved_version_id is None and raw_version_id is not None:
|
||||||
|
try:
|
||||||
|
resolved_version_id = int(raw_version_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
resolved_version_id = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
get_settings_manager().get_skip_previously_downloaded_model_versions()
|
||||||
|
and resolved_version_id is not None
|
||||||
|
and await self._has_been_downloaded(model_type, resolved_version_id)
|
||||||
|
):
|
||||||
|
file_name = ""
|
||||||
|
files = version_info.get("files")
|
||||||
|
if isinstance(files, list):
|
||||||
|
primary_file = next(
|
||||||
|
(
|
||||||
|
file_info
|
||||||
|
for file_info in files
|
||||||
|
if isinstance(file_info, dict) and file_info.get("primary")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
selected_file = primary_file
|
||||||
|
if selected_file is None:
|
||||||
|
selected_file = next(
|
||||||
|
(file_info for file_info in files if isinstance(file_info, dict)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if isinstance(selected_file, dict):
|
||||||
|
raw_file_name = selected_file.get("name", "")
|
||||||
|
if isinstance(raw_file_name, str):
|
||||||
|
file_name = raw_file_name.strip()
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"Skipped download for '{file_name or version_info.get('name') or f'model_version:{resolved_version_id}'}' "
|
||||||
|
f"because version {resolved_version_id} was already downloaded before"
|
||||||
|
)
|
||||||
|
logger.info(message)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"skipped": True,
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "previously_downloaded_version",
|
||||||
|
"message": message,
|
||||||
|
"model_version_id": resolved_version_id,
|
||||||
|
"file_name": file_name,
|
||||||
|
"download_id": download_id,
|
||||||
|
}
|
||||||
|
|
||||||
excluded_base_models = get_settings_manager().get_download_skip_base_models()
|
excluded_base_models = get_settings_manager().get_download_skip_base_models()
|
||||||
base_model_value = version_info.get("baseModel", "")
|
base_model_value = version_info.get("baseModel", "")
|
||||||
if (
|
if (
|
||||||
@@ -640,6 +704,13 @@ class DownloadManager:
|
|||||||
or version_info.get("modelId")
|
or version_info.get("modelId")
|
||||||
or (version_info.get("model") or {}).get("id")
|
or (version_info.get("model") or {}).get("id")
|
||||||
)
|
)
|
||||||
|
await self._record_downloaded_version_history(
|
||||||
|
model_type,
|
||||||
|
resolved_model_id,
|
||||||
|
version_info,
|
||||||
|
model_version_id,
|
||||||
|
save_path,
|
||||||
|
)
|
||||||
await self._sync_downloaded_version(
|
await self._sync_downloaded_version(
|
||||||
model_type,
|
model_type,
|
||||||
resolved_model_id,
|
resolved_model_id,
|
||||||
@@ -669,6 +740,55 @@ class DownloadManager:
|
|||||||
}
|
}
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
async def _record_downloaded_version_history(
|
||||||
|
self,
|
||||||
|
model_type: str,
|
||||||
|
model_id_value,
|
||||||
|
version_info: Dict,
|
||||||
|
fallback_version_id=None,
|
||||||
|
file_path: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping download history sync; failed to acquire history service: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if history_service is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
resolved_model_id = model_id_value
|
||||||
|
if resolved_model_id is None:
|
||||||
|
resolved_model_id = version_info.get("modelId")
|
||||||
|
if resolved_model_id is None:
|
||||||
|
model_info = version_info.get("model")
|
||||||
|
if isinstance(model_info, dict):
|
||||||
|
resolved_model_id = model_info.get("id")
|
||||||
|
|
||||||
|
version_id = version_info.get("id")
|
||||||
|
if version_id is None:
|
||||||
|
version_id = fallback_version_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
await history_service.mark_downloaded(
|
||||||
|
model_type,
|
||||||
|
int(version_id),
|
||||||
|
model_id=int(resolved_model_id) if resolved_model_id is not None else None,
|
||||||
|
source="download",
|
||||||
|
file_path=file_path,
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping download history sync; invalid identifiers model=%s version=%s",
|
||||||
|
resolved_model_id,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to sync download history for %s: %s", model_type, exc)
|
||||||
|
|
||||||
async def _sync_downloaded_version(
|
async def _sync_downloaded_version(
|
||||||
self,
|
self,
|
||||||
model_type: str,
|
model_type: str,
|
||||||
|
|||||||
313
py/services/downloaded_version_history_service.py
Normal file
313
py/services/downloaded_version_history_service.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from typing import Iterable, Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
from ..utils.cache_paths import get_cache_base_dir
|
||||||
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_model_type(model_type: str | None) -> Optional[str]:
|
||||||
|
if not isinstance(model_type, str):
|
||||||
|
return None
|
||||||
|
normalized = model_type.strip().lower()
|
||||||
|
if normalized in {"lora", "locon", "dora"}:
|
||||||
|
return "lora"
|
||||||
|
if normalized == "checkpoint":
|
||||||
|
return "checkpoint"
|
||||||
|
if normalized in {"embedding", "textualinversion"}:
|
||||||
|
return "embedding"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_int(value) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_database_path() -> str:
|
||||||
|
base_dir = get_cache_base_dir(create=True)
|
||||||
|
history_dir = os.path.join(base_dir, "download_history")
|
||||||
|
os.makedirs(history_dir, exist_ok=True)
|
||||||
|
return os.path.join(history_dir, "downloaded_versions.sqlite")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadedVersionHistoryService:
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS downloaded_model_versions (
|
||||||
|
model_type TEXT NOT NULL,
|
||||||
|
version_id INTEGER NOT NULL,
|
||||||
|
model_id INTEGER,
|
||||||
|
first_seen_at REAL NOT NULL,
|
||||||
|
last_seen_at REAL NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
last_file_path TEXT,
|
||||||
|
last_library_name TEXT,
|
||||||
|
is_deleted_override INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (model_type, version_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
|
||||||
|
ON downloaded_model_versions(model_type, model_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
|
||||||
|
self._db_path = db_path or _resolve_database_path()
|
||||||
|
self._settings = settings_manager or get_settings_manager()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._schema_initialized = False
|
||||||
|
self._ensure_directory()
|
||||||
|
self._initialize_schema()
|
||||||
|
|
||||||
|
def _ensure_directory(self) -> None:
|
||||||
|
directory = os.path.dirname(self._db_path)
|
||||||
|
if directory:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _initialize_schema(self) -> None:
|
||||||
|
if self._schema_initialized:
|
||||||
|
return
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.executescript(self._SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
self._schema_initialized = True
|
||||||
|
|
||||||
|
def get_database_path(self) -> str:
|
||||||
|
return self._db_path
|
||||||
|
|
||||||
|
def _get_active_library_name(self) -> str | None:
|
||||||
|
try:
|
||||||
|
value = self._settings.get_active_library_name()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
async def mark_downloaded(
|
||||||
|
self,
|
||||||
|
model_type: str,
|
||||||
|
version_id: int,
|
||||||
|
*,
|
||||||
|
model_id: int | None = None,
|
||||||
|
source: str = "manual",
|
||||||
|
file_path: str | None = None,
|
||||||
|
library_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
normalized_type = _normalize_model_type(model_type)
|
||||||
|
normalized_version_id = _normalize_int(version_id)
|
||||||
|
normalized_model_id = _normalize_int(model_id)
|
||||||
|
if normalized_type is None or normalized_version_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_library_name = library_name or self._get_active_library_name()
|
||||||
|
timestamp = time.time()
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO downloaded_model_versions (
|
||||||
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
|
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||||
|
last_seen_at = excluded.last_seen_at,
|
||||||
|
source = excluded.source,
|
||||||
|
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||||
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
|
is_deleted_override = 0
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
normalized_type,
|
||||||
|
normalized_version_id,
|
||||||
|
normalized_model_id,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
source,
|
||||||
|
file_path,
|
||||||
|
active_library_name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
async def mark_downloaded_bulk(
|
||||||
|
self,
|
||||||
|
model_type: str,
|
||||||
|
records: Sequence[Mapping[str, object]],
|
||||||
|
*,
|
||||||
|
source: str = "scan",
|
||||||
|
library_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
normalized_type = _normalize_model_type(model_type)
|
||||||
|
if normalized_type is None or not records:
|
||||||
|
return
|
||||||
|
|
||||||
|
timestamp = time.time()
|
||||||
|
active_library_name = library_name or self._get_active_library_name()
|
||||||
|
payload: list[tuple[object, ...]] = []
|
||||||
|
for record in records:
|
||||||
|
version_id = _normalize_int(record.get("version_id"))
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
payload.append(
|
||||||
|
(
|
||||||
|
normalized_type,
|
||||||
|
version_id,
|
||||||
|
_normalize_int(record.get("model_id")),
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
source,
|
||||||
|
record.get("file_path"),
|
||||||
|
active_library_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO downloaded_model_versions (
|
||||||
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
|
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||||
|
last_seen_at = excluded.last_seen_at,
|
||||||
|
source = excluded.source,
|
||||||
|
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||||
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
|
is_deleted_override = 0
|
||||||
|
""",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
||||||
|
normalized_type = _normalize_model_type(model_type)
|
||||||
|
normalized_version_id = _normalize_int(version_id)
|
||||||
|
if normalized_type is None or normalized_version_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
timestamp = time.time()
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO downloaded_model_versions (
|
||||||
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
|
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||||
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
|
last_seen_at = excluded.last_seen_at,
|
||||||
|
source = excluded.source,
|
||||||
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
|
is_deleted_override = 1
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
normalized_type,
|
||||||
|
normalized_version_id,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
self._get_active_library_name(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||||
|
normalized_type = _normalize_model_type(model_type)
|
||||||
|
normalized_version_id = _normalize_int(version_id)
|
||||||
|
if normalized_type is None or normalized_version_id is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT is_deleted_override
|
||||||
|
FROM downloaded_model_versions
|
||||||
|
WHERE model_type = ? AND version_id = ?
|
||||||
|
""",
|
||||||
|
(normalized_type, normalized_version_id),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row) and not bool(row["is_deleted_override"])
|
||||||
|
|
||||||
|
async def get_downloaded_version_ids(
|
||||||
|
self, model_type: str, model_id: int
|
||||||
|
) -> list[int]:
|
||||||
|
normalized_type = _normalize_model_type(model_type)
|
||||||
|
normalized_model_id = _normalize_int(model_id)
|
||||||
|
if normalized_type is None or normalized_model_id is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT version_id
|
||||||
|
FROM downloaded_model_versions
|
||||||
|
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||||
|
ORDER BY version_id ASC
|
||||||
|
""",
|
||||||
|
(normalized_type, normalized_model_id),
|
||||||
|
).fetchall()
|
||||||
|
return [int(row["version_id"]) for row in rows]
|
||||||
|
|
||||||
|
async def get_downloaded_version_ids_bulk(
|
||||||
|
self, model_type: str, model_ids: Iterable[int]
|
||||||
|
) -> dict[int, set[int]]:
|
||||||
|
normalized_type = _normalize_model_type(model_type)
|
||||||
|
if normalized_type is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
normalized_model_ids = sorted(
|
||||||
|
{
|
||||||
|
value
|
||||||
|
for value in (_normalize_int(model_id) for model_id in model_ids)
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not normalized_model_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
placeholders = ", ".join(["?"] * len(normalized_model_ids))
|
||||||
|
params: list[object] = [normalized_type, *normalized_model_ids]
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
with self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT model_id, version_id
|
||||||
|
FROM downloaded_model_versions
|
||||||
|
WHERE model_type = ?
|
||||||
|
AND model_id IN ({placeholders})
|
||||||
|
AND is_deleted_override = 0
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result: dict[int, set[int]] = {}
|
||||||
|
for row in rows:
|
||||||
|
model_id = _normalize_int(row["model_id"])
|
||||||
|
version_id = _normalize_int(row["version_id"])
|
||||||
|
if model_id is None or version_id is None:
|
||||||
|
continue
|
||||||
|
result.setdefault(model_id, set()).add(version_id)
|
||||||
|
return result
|
||||||
@@ -411,6 +411,7 @@ class ModelScanner:
|
|||||||
if scan_result:
|
if scan_result:
|
||||||
await self._apply_scan_result(scan_result)
|
await self._apply_scan_result(scan_result)
|
||||||
await self._save_persistent_cache(scan_result)
|
await self._save_persistent_cache(scan_result)
|
||||||
|
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||||
|
|
||||||
# Send final progress update
|
# Send final progress update
|
||||||
await ws_manager.broadcast_init_progress({
|
await ws_manager.broadcast_init_progress({
|
||||||
@@ -516,6 +517,7 @@ class ModelScanner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
await self._apply_scan_result(scan_result)
|
await self._apply_scan_result(scan_result)
|
||||||
|
await self._sync_download_history(adjusted_raw_data, source='scan')
|
||||||
|
|
||||||
await ws_manager.broadcast_init_progress({
|
await ws_manager.broadcast_init_progress({
|
||||||
'stage': 'loading_cache',
|
'stage': 'loading_cache',
|
||||||
@@ -576,6 +578,7 @@ class ModelScanner:
|
|||||||
excluded_models=list(self._excluded_models)
|
excluded_models=list(self._excluded_models)
|
||||||
)
|
)
|
||||||
await self._save_persistent_cache(snapshot)
|
await self._save_persistent_cache(snapshot)
|
||||||
|
await self._sync_download_history(snapshot.raw_data, source='scan')
|
||||||
def _count_model_files(self) -> int:
|
def _count_model_files(self) -> int:
|
||||||
"""Count all model files with supported extensions in all roots
|
"""Count all model files with supported extensions in all roots
|
||||||
|
|
||||||
@@ -704,6 +707,7 @@ class ModelScanner:
|
|||||||
scan_result = await self._gather_model_data()
|
scan_result = await self._gather_model_data()
|
||||||
await self._apply_scan_result(scan_result)
|
await self._apply_scan_result(scan_result)
|
||||||
await self._save_persistent_cache(scan_result)
|
await self._save_persistent_cache(scan_result)
|
||||||
|
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||||
@@ -1101,6 +1105,49 @@ class ModelScanner:
|
|||||||
|
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
|
async def _sync_download_history(
|
||||||
|
self,
|
||||||
|
raw_data: List[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
) -> None:
|
||||||
|
records: List[Dict[str, Any]] = []
|
||||||
|
for item in raw_data or []:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
continue
|
||||||
|
civitai = item.get('civitai')
|
||||||
|
if not isinstance(civitai, Mapping):
|
||||||
|
continue
|
||||||
|
|
||||||
|
version_id = civitai.get('id')
|
||||||
|
if version_id in (None, ''):
|
||||||
|
continue
|
||||||
|
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
'version_id': version_id,
|
||||||
|
'model_id': civitai.get('modelId'),
|
||||||
|
'file_path': item.get('file_path'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||||
|
await history_service.mark_downloaded_bulk(
|
||||||
|
self.model_type,
|
||||||
|
records,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"%s Scanner: Failed to sync download history: %s",
|
||||||
|
self.model_type.capitalize(),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
async def _gather_model_data(
|
async def _gather_model_data(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .service_registry import ServiceRegistry
|
|||||||
from .lora_scanner import LoraScanner
|
from .lora_scanner import LoraScanner
|
||||||
from .metadata_service import get_default_metadata_provider
|
from .metadata_service import get_default_metadata_provider
|
||||||
from .checkpoint_scanner import CheckpointScanner
|
from .checkpoint_scanner import CheckpointScanner
|
||||||
|
from .settings_manager import get_settings_manager
|
||||||
from .recipes.errors import RecipeNotFoundError
|
from .recipes.errors import RecipeNotFoundError
|
||||||
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
@@ -1090,6 +1091,14 @@ class RecipeScanner:
|
|||||||
@property
|
@property
|
||||||
def recipes_dir(self) -> str:
|
def recipes_dir(self) -> str:
|
||||||
"""Get path to recipes directory"""
|
"""Get path to recipes directory"""
|
||||||
|
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
|
||||||
|
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
|
||||||
|
recipes_dir = os.path.abspath(
|
||||||
|
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
|
||||||
|
)
|
||||||
|
os.makedirs(recipes_dir, exist_ok=True)
|
||||||
|
return recipes_dir
|
||||||
|
|
||||||
if not config.loras_roots:
|
if not config.loras_roots:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,28 @@ class ServiceRegistry:
|
|||||||
logger.debug(f"Created and registered {service_name}")
|
logger.debug(f"Created and registered {service_name}")
|
||||||
return service
|
return service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_downloaded_version_history_service(cls):
|
||||||
|
"""Get or create the downloaded-version history service."""
|
||||||
|
|
||||||
|
service_name = "downloaded_version_history_service"
|
||||||
|
|
||||||
|
if service_name in cls._services:
|
||||||
|
return cls._services[service_name]
|
||||||
|
|
||||||
|
async with cls._get_lock(service_name):
|
||||||
|
if service_name in cls._services:
|
||||||
|
return cls._services[service_name]
|
||||||
|
|
||||||
|
from .downloaded_version_history_service import (
|
||||||
|
DownloadedVersionHistoryService,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DownloadedVersionHistoryService()
|
||||||
|
cls._services[service_name] = service
|
||||||
|
logger.debug(f"Created and registered {service_name}")
|
||||||
|
return service
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_civarchive_client(cls):
|
async def get_civarchive_client(cls):
|
||||||
"""Get or create CivArchive client instance"""
|
"""Get or create CivArchive client instance"""
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -70,6 +71,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"default_checkpoint_root": "",
|
"default_checkpoint_root": "",
|
||||||
"default_unet_root": "",
|
"default_unet_root": "",
|
||||||
"default_embedding_root": "",
|
"default_embedding_root": "",
|
||||||
|
"recipes_path": "",
|
||||||
"base_model_path_mappings": {},
|
"base_model_path_mappings": {},
|
||||||
"download_path_templates": {},
|
"download_path_templates": {},
|
||||||
"folder_paths": {},
|
"folder_paths": {},
|
||||||
@@ -91,6 +93,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"update_flag_strategy": "same_base",
|
"update_flag_strategy": "same_base",
|
||||||
"auto_organize_exclusions": [],
|
"auto_organize_exclusions": [],
|
||||||
"metadata_refresh_skip_paths": [],
|
"metadata_refresh_skip_paths": [],
|
||||||
|
"skip_previously_downloaded_model_versions": False,
|
||||||
"download_skip_base_models": [],
|
"download_skip_base_models": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +256,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||||
default_unet_root=merged.get("default_unet_root"),
|
default_unet_root=merged.get("default_unet_root"),
|
||||||
default_embedding_root=merged.get("default_embedding_root"),
|
default_embedding_root=merged.get("default_embedding_root"),
|
||||||
|
recipes_path=merged.get("recipes_path"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
merged["active_library"] = library_name
|
merged["active_library"] = library_name
|
||||||
@@ -314,6 +318,10 @@ class SettingsManager:
|
|||||||
self.settings["download_skip_base_models"] = []
|
self.settings["download_skip_base_models"] = []
|
||||||
inserted_defaults = True
|
inserted_defaults = True
|
||||||
|
|
||||||
|
if "skip_previously_downloaded_model_versions" not in self.settings:
|
||||||
|
self.settings["skip_previously_downloaded_model_versions"] = False
|
||||||
|
inserted_defaults = True
|
||||||
|
|
||||||
had_mature_level = "mature_blur_level" in self.settings
|
had_mature_level = "mature_blur_level" in self.settings
|
||||||
raw_mature_level = self.settings.get("mature_blur_level")
|
raw_mature_level = self.settings.get("mature_blur_level")
|
||||||
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
|
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
|
||||||
@@ -377,6 +385,7 @@ class SettingsManager:
|
|||||||
),
|
),
|
||||||
default_unet_root=self.settings.get("default_unet_root", ""),
|
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||||
|
recipes_path=self.settings.get("recipes_path", ""),
|
||||||
)
|
)
|
||||||
libraries = {library_name: library_payload}
|
libraries = {library_name: library_payload}
|
||||||
self.settings["libraries"] = libraries
|
self.settings["libraries"] = libraries
|
||||||
@@ -424,6 +433,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||||
default_unet_root=data.get("default_unet_root"),
|
default_unet_root=data.get("default_unet_root"),
|
||||||
default_embedding_root=data.get("default_embedding_root"),
|
default_embedding_root=data.get("default_embedding_root"),
|
||||||
|
recipes_path=data.get("recipes_path"),
|
||||||
metadata=data.get("metadata"),
|
metadata=data.get("metadata"),
|
||||||
base=data,
|
base=data,
|
||||||
)
|
)
|
||||||
@@ -470,6 +480,7 @@ class SettingsManager:
|
|||||||
self.settings["default_embedding_root"] = active_library.get(
|
self.settings["default_embedding_root"] = active_library.get(
|
||||||
"default_embedding_root", ""
|
"default_embedding_root", ""
|
||||||
)
|
)
|
||||||
|
self.settings["recipes_path"] = active_library.get("recipes_path", "")
|
||||||
|
|
||||||
if save:
|
if save:
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
@@ -486,6 +497,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
base: Optional[Mapping[str, Any]] = None,
|
base: Optional[Mapping[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -524,6 +536,11 @@ class SettingsManager:
|
|||||||
else:
|
else:
|
||||||
payload.setdefault("default_embedding_root", "")
|
payload.setdefault("default_embedding_root", "")
|
||||||
|
|
||||||
|
if recipes_path is not None:
|
||||||
|
payload["recipes_path"] = recipes_path
|
||||||
|
else:
|
||||||
|
payload.setdefault("recipes_path", "")
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
merged_meta = dict(payload.get("metadata", {}))
|
merged_meta = dict(payload.get("metadata", {}))
|
||||||
merged_meta.update(metadata)
|
merged_meta.update(metadata)
|
||||||
@@ -625,6 +642,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
recipes_path: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
libraries = self.settings.get("libraries", {})
|
libraries = self.settings.get("libraries", {})
|
||||||
active_name = self.settings.get("active_library")
|
active_name = self.settings.get("active_library")
|
||||||
@@ -674,6 +692,10 @@ class SettingsManager:
|
|||||||
library["default_embedding_root"] = default_embedding_root
|
library["default_embedding_root"] = default_embedding_root
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
if recipes_path is not None and library.get("recipes_path") != recipes_path:
|
||||||
|
library["recipes_path"] = recipes_path
|
||||||
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
library.setdefault("created_at", self._current_timestamp())
|
library.setdefault("created_at", self._current_timestamp())
|
||||||
library["updated_at"] = self._current_timestamp()
|
library["updated_at"] = self._current_timestamp()
|
||||||
@@ -937,7 +959,9 @@ class SettingsManager:
|
|||||||
extra_folder_paths=defaults.get("extra_folder_paths", {}),
|
extra_folder_paths=defaults.get("extra_folder_paths", {}),
|
||||||
default_lora_root=defaults.get("default_lora_root"),
|
default_lora_root=defaults.get("default_lora_root"),
|
||||||
default_checkpoint_root=defaults.get("default_checkpoint_root"),
|
default_checkpoint_root=defaults.get("default_checkpoint_root"),
|
||||||
|
default_unet_root=defaults.get("default_unet_root"),
|
||||||
default_embedding_root=defaults.get("default_embedding_root"),
|
default_embedding_root=defaults.get("default_embedding_root"),
|
||||||
|
recipes_path=defaults.get("recipes_path"),
|
||||||
)
|
)
|
||||||
defaults["libraries"] = {library_name: default_library}
|
defaults["libraries"] = {library_name: default_library}
|
||||||
defaults["active_library"] = library_name
|
defaults["active_library"] = library_name
|
||||||
@@ -1090,6 +1114,17 @@ class SettingsManager:
|
|||||||
self._save_settings()
|
self._save_settings()
|
||||||
return base_models
|
return base_models
|
||||||
|
|
||||||
|
def get_skip_previously_downloaded_model_versions(self) -> bool:
|
||||||
|
value = self.settings.get("skip_previously_downloaded_model_versions", False)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
normalized = False
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
self.settings["skip_previously_downloaded_model_versions"] = normalized
|
||||||
|
self._save_settings()
|
||||||
|
return normalized
|
||||||
|
|
||||||
def get_extra_folder_paths(self) -> Dict[str, List[str]]:
|
def get_extra_folder_paths(self) -> Dict[str, List[str]]:
|
||||||
"""Get extra folder paths for the active library.
|
"""Get extra folder paths for the active library.
|
||||||
|
|
||||||
@@ -1220,6 +1255,193 @@ class SettingsManager:
|
|||||||
"""Get setting value"""
|
"""Get setting value"""
|
||||||
return self.settings.get(key, default)
|
return self.settings.get(key, default)
|
||||||
|
|
||||||
|
def _normalize_recipes_path_value(self, value: Any) -> str:
|
||||||
|
"""Return a normalized absolute recipes path or an empty string."""
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = "" if value is None else str(value)
|
||||||
|
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return os.path.abspath(os.path.normpath(os.path.expanduser(stripped)))
|
||||||
|
|
||||||
|
def _get_effective_recipes_dir(self, recipes_path: Optional[str] = None) -> str:
|
||||||
|
"""Resolve the effective recipes directory for the active library."""
|
||||||
|
|
||||||
|
normalized_custom = self._normalize_recipes_path_value(
|
||||||
|
self.settings.get("recipes_path", "")
|
||||||
|
if recipes_path is None
|
||||||
|
else recipes_path
|
||||||
|
)
|
||||||
|
if normalized_custom:
|
||||||
|
return normalized_custom
|
||||||
|
|
||||||
|
folder_paths = self.settings.get("folder_paths", {})
|
||||||
|
configured_lora_roots = []
|
||||||
|
if isinstance(folder_paths, Mapping):
|
||||||
|
raw_lora_roots = folder_paths.get("loras", [])
|
||||||
|
if isinstance(raw_lora_roots, Sequence) and not isinstance(
|
||||||
|
raw_lora_roots, (str, bytes)
|
||||||
|
):
|
||||||
|
configured_lora_roots = [
|
||||||
|
path
|
||||||
|
for path in raw_lora_roots
|
||||||
|
if isinstance(path, str) and path.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
if configured_lora_roots:
|
||||||
|
lora_root = sorted(configured_lora_roots, key=str.casefold)[0]
|
||||||
|
return os.path.abspath(os.path.join(lora_root, "recipes"))
|
||||||
|
|
||||||
|
config_lora_roots = [
|
||||||
|
path
|
||||||
|
for path in getattr(config, "loras_roots", []) or []
|
||||||
|
if isinstance(path, str) and path.strip()
|
||||||
|
]
|
||||||
|
if not config_lora_roots:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return os.path.abspath(
|
||||||
|
os.path.join(sorted(config_lora_roots, key=str.casefold)[0], "recipes")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_recipes_storage_path(self, normalized_path: str) -> None:
|
||||||
|
"""Ensure the recipes storage target is usable before saving it."""
|
||||||
|
|
||||||
|
if not normalized_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists(normalized_path) and not os.path.isdir(normalized_path):
|
||||||
|
raise ValueError("Recipes path must point to a directory")
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(normalized_path, exist_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Unable to create recipes directory: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd, probe_path = tempfile.mkstemp(
|
||||||
|
prefix=".lora-manager-recipes-", dir=normalized_path
|
||||||
|
)
|
||||||
|
os.close(fd)
|
||||||
|
os.remove(probe_path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Recipes path is not writable: {exc}") from exc
|
||||||
|
|
||||||
|
def _migrate_recipes_directory(self, source_dir: str, target_dir: str) -> None:
|
||||||
|
"""Move existing recipe files to a new recipes root and rewrite JSON paths."""
|
||||||
|
|
||||||
|
source = os.path.abspath(os.path.normpath(source_dir)) if source_dir else ""
|
||||||
|
target = os.path.abspath(os.path.normpath(target_dir)) if target_dir else ""
|
||||||
|
if not source or not target or source == target:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(source):
|
||||||
|
os.makedirs(target, exist_ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists(target) and not os.path.isdir(target):
|
||||||
|
raise ValueError("Recipes path must point to a directory")
|
||||||
|
|
||||||
|
try:
|
||||||
|
common_root = os.path.commonpath([source, target])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Invalid recipes path change") from exc
|
||||||
|
|
||||||
|
if common_root == source:
|
||||||
|
raise ValueError("Recipes path cannot be moved into a nested directory")
|
||||||
|
|
||||||
|
planned_recipe_updates: Dict[str, Dict[str, Any]] = {}
|
||||||
|
file_pairs: List[Tuple[str, str]] = []
|
||||||
|
|
||||||
|
for root, _, files in os.walk(source):
|
||||||
|
for filename in files:
|
||||||
|
source_path = os.path.normpath(os.path.join(root, filename))
|
||||||
|
relative_path = os.path.relpath(source_path, source)
|
||||||
|
target_path = os.path.normpath(os.path.join(target, relative_path))
|
||||||
|
file_pairs.append((source_path, target_path))
|
||||||
|
|
||||||
|
if not filename.endswith(".recipe.json"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(source_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unable to read recipe metadata during migration: {source_path}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = payload.get("file_path")
|
||||||
|
if isinstance(file_path, str) and file_path.strip():
|
||||||
|
normalized_file_path = os.path.abspath(
|
||||||
|
os.path.normpath(os.path.expanduser(file_path))
|
||||||
|
)
|
||||||
|
source_candidates = [source]
|
||||||
|
real_source = os.path.abspath(
|
||||||
|
os.path.normpath(os.path.realpath(source_dir))
|
||||||
|
)
|
||||||
|
if real_source not in source_candidates:
|
||||||
|
source_candidates.append(real_source)
|
||||||
|
|
||||||
|
rewritten = False
|
||||||
|
for source_candidate in source_candidates:
|
||||||
|
try:
|
||||||
|
file_common_root = os.path.commonpath(
|
||||||
|
[normalized_file_path, source_candidate]
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if file_common_root != source_candidate:
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_relative_path = os.path.relpath(
|
||||||
|
normalized_file_path, source_candidate
|
||||||
|
)
|
||||||
|
payload["file_path"] = os.path.normpath(
|
||||||
|
os.path.join(target, image_relative_path)
|
||||||
|
)
|
||||||
|
rewritten = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not rewritten and source_candidates:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping recipe file_path rewrite during migration for %s",
|
||||||
|
normalized_file_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
planned_recipe_updates[target_path] = payload
|
||||||
|
|
||||||
|
for _, target_path in file_pairs:
|
||||||
|
if os.path.exists(target_path):
|
||||||
|
raise ValueError(
|
||||||
|
f"Recipes path already contains conflicting file: {target_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(target, exist_ok=True)
|
||||||
|
|
||||||
|
for source_path, target_path in file_pairs:
|
||||||
|
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||||
|
shutil.move(source_path, target_path)
|
||||||
|
|
||||||
|
for target_path, payload in planned_recipe_updates.items():
|
||||||
|
with open(target_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(source, topdown=False):
|
||||||
|
if dirs or files:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.rmdir(root)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def set(self, key: str, value: Any) -> None:
|
def set(self, key: str, value: Any) -> None:
|
||||||
"""Set setting value and save"""
|
"""Set setting value and save"""
|
||||||
if key == "auto_organize_exclusions":
|
if key == "auto_organize_exclusions":
|
||||||
@@ -1230,6 +1452,12 @@ class SettingsManager:
|
|||||||
value = self.normalize_download_skip_base_models(value)
|
value = self.normalize_download_skip_base_models(value)
|
||||||
elif key == "mature_blur_level":
|
elif key == "mature_blur_level":
|
||||||
value = self.normalize_mature_blur_level(value)
|
value = self.normalize_mature_blur_level(value)
|
||||||
|
elif key == "recipes_path":
|
||||||
|
current_recipes_dir = self._get_effective_recipes_dir()
|
||||||
|
value = self._normalize_recipes_path_value(value)
|
||||||
|
target_recipes_dir = self._get_effective_recipes_dir(value)
|
||||||
|
self._validate_recipes_storage_path(target_recipes_dir)
|
||||||
|
self._migrate_recipes_directory(current_recipes_dir, target_recipes_dir)
|
||||||
self.settings[key] = value
|
self.settings[key] = value
|
||||||
portable_switch_pending = False
|
portable_switch_pending = False
|
||||||
if key == "use_portable_settings" and isinstance(value, bool):
|
if key == "use_portable_settings" and isinstance(value, bool):
|
||||||
@@ -1247,9 +1475,13 @@ class SettingsManager:
|
|||||||
self._update_active_library_entry(default_unet_root=str(value))
|
self._update_active_library_entry(default_unet_root=str(value))
|
||||||
elif key == "default_embedding_root":
|
elif key == "default_embedding_root":
|
||||||
self._update_active_library_entry(default_embedding_root=str(value))
|
self._update_active_library_entry(default_embedding_root=str(value))
|
||||||
|
elif key == "recipes_path":
|
||||||
|
self._update_active_library_entry(recipes_path=str(value))
|
||||||
elif key == "model_name_display":
|
elif key == "model_name_display":
|
||||||
self._notify_model_name_display_change(value)
|
self._notify_model_name_display_change(value)
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
if key == "recipes_path":
|
||||||
|
self._notify_library_change(self.get_active_library_name())
|
||||||
if portable_switch_pending:
|
if portable_switch_pending:
|
||||||
self._finalize_portable_switch()
|
self._finalize_portable_switch()
|
||||||
|
|
||||||
@@ -1559,6 +1791,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -1602,6 +1835,11 @@ class SettingsManager:
|
|||||||
if default_embedding_root is not None
|
if default_embedding_root is not None
|
||||||
else existing.get("default_embedding_root")
|
else existing.get("default_embedding_root")
|
||||||
),
|
),
|
||||||
|
recipes_path=(
|
||||||
|
recipes_path
|
||||||
|
if recipes_path is not None
|
||||||
|
else existing.get("recipes_path")
|
||||||
|
),
|
||||||
metadata=metadata if metadata is not None else existing.get("metadata"),
|
metadata=metadata if metadata is not None else existing.get("metadata"),
|
||||||
base=existing,
|
base=existing,
|
||||||
)
|
)
|
||||||
@@ -1629,6 +1867,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: str = "",
|
default_checkpoint_root: str = "",
|
||||||
default_unet_root: str = "",
|
default_unet_root: str = "",
|
||||||
default_embedding_root: str = "",
|
default_embedding_root: str = "",
|
||||||
|
recipes_path: str = "",
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -1646,6 +1885,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
|
recipes_path=recipes_path,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
activate=activate,
|
activate=activate,
|
||||||
)
|
)
|
||||||
@@ -1705,6 +1945,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
recipes_path: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update folder paths for the active library."""
|
"""Update folder paths for the active library."""
|
||||||
|
|
||||||
@@ -1717,6 +1958,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
|
recipes_path=recipes_path,
|
||||||
activate=True,
|
activate=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ def _normalize_commercial_values(value: Any) -> Sequence[str]:
|
|||||||
|
|
||||||
def _split_aggregate(value_str: str) -> list[str]:
|
def _split_aggregate(value_str: str) -> list[str]:
|
||||||
stripped = value_str.strip()
|
stripped = value_str.strip()
|
||||||
looks_aggregate = "," in stripped or (stripped.startswith("{") and stripped.endswith("}"))
|
looks_aggregate = "," in stripped or (
|
||||||
|
stripped.startswith("{") and stripped.endswith("}")
|
||||||
|
)
|
||||||
if not looks_aggregate:
|
if not looks_aggregate:
|
||||||
return [value_str]
|
return [value_str]
|
||||||
|
|
||||||
@@ -141,14 +143,18 @@ def build_license_flags(payload: Mapping[str, Any] | None) -> int:
|
|||||||
return flags
|
return flags
|
||||||
|
|
||||||
|
|
||||||
def resolve_license_info(model_data: Mapping[str, Any] | None) -> tuple[Dict[str, Any], int]:
|
def resolve_license_info(
|
||||||
|
model_data: Mapping[str, Any] | None,
|
||||||
|
) -> tuple[Dict[str, Any], int]:
|
||||||
"""Return normalized license payload and its encoded bitset."""
|
"""Return normalized license payload and its encoded bitset."""
|
||||||
|
|
||||||
payload = resolve_license_payload(model_data)
|
payload = resolve_license_payload(model_data)
|
||||||
return payload, build_license_flags(payload)
|
return payload, build_license_flags(payload)
|
||||||
|
|
||||||
|
|
||||||
def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -> tuple[str | None, bool]:
|
def rewrite_preview_url(
|
||||||
|
source_url: str | None, media_type: str | None = None
|
||||||
|
) -> tuple[str | None, bool]:
|
||||||
"""Rewrite Civitai preview URLs to use optimized renditions.
|
"""Rewrite Civitai preview URLs to use optimized renditions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -168,7 +174,12 @@ def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return source_url, False
|
return source_url, False
|
||||||
|
|
||||||
if parsed.netloc.lower() != "image.civitai.com":
|
hostname = parsed.hostname
|
||||||
|
if hostname is None:
|
||||||
|
return source_url, False
|
||||||
|
|
||||||
|
hostname = hostname.lower()
|
||||||
|
if hostname == "civitai.com" or not hostname.endswith(".civitai.com"):
|
||||||
return source_url, False
|
return source_url, False
|
||||||
|
|
||||||
replacement = "/width=450,optimized=true"
|
replacement = "/width=450,optimized=true"
|
||||||
|
|||||||
@@ -292,6 +292,80 @@ class UsageStats:
|
|||||||
if LORAS in metadata and isinstance(metadata[LORAS], dict):
|
if LORAS in metadata and isinstance(metadata[LORAS], dict):
|
||||||
await self._process_loras(metadata[LORAS], today)
|
await self._process_loras(metadata[LORAS], today)
|
||||||
|
|
||||||
|
def _increment_usage_counter(self, category: str, stat_key: str, today_date: str) -> None:
|
||||||
|
"""Increment usage counters for a resolved stats key."""
|
||||||
|
if stat_key not in self.stats[category]:
|
||||||
|
self.stats[category][stat_key] = {
|
||||||
|
"total": 0,
|
||||||
|
"history": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.stats[category][stat_key]["total"] += 1
|
||||||
|
|
||||||
|
if today_date not in self.stats[category][stat_key]["history"]:
|
||||||
|
self.stats[category][stat_key]["history"][today_date] = 0
|
||||||
|
self.stats[category][stat_key]["history"][today_date] += 1
|
||||||
|
|
||||||
|
def _normalize_model_lookup_name(self, model_name: str) -> str:
|
||||||
|
"""Normalize a model reference to its base filename without extension."""
|
||||||
|
return os.path.splitext(os.path.basename(model_name))[0]
|
||||||
|
|
||||||
|
async def _find_cached_checkpoint_entry(self, checkpoint_scanner, model_name: str):
|
||||||
|
"""Best-effort lookup for a checkpoint cache entry by filename/model name."""
|
||||||
|
get_cached_data = getattr(checkpoint_scanner, "get_cached_data", None)
|
||||||
|
if not callable(get_cached_data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache = await get_cached_data()
|
||||||
|
raw_data = getattr(cache, "raw_data", None)
|
||||||
|
if not isinstance(raw_data, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_name = self._normalize_model_lookup_name(model_name)
|
||||||
|
for entry in raw_data:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for candidate_key in ("file_name", "model_name", "file_path"):
|
||||||
|
candidate_value = entry.get(candidate_key)
|
||||||
|
if not candidate_value or not isinstance(candidate_value, str):
|
||||||
|
continue
|
||||||
|
if self._normalize_model_lookup_name(candidate_value) == normalized_name:
|
||||||
|
return entry
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _resolve_checkpoint_hash(self, checkpoint_scanner, model_name: str):
|
||||||
|
"""Resolve a checkpoint hash, calculating pending hashes on demand when needed."""
|
||||||
|
model_filename = self._normalize_model_lookup_name(model_name)
|
||||||
|
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
|
||||||
|
if model_hash:
|
||||||
|
return model_hash
|
||||||
|
|
||||||
|
cached_entry = await self._find_cached_checkpoint_entry(checkpoint_scanner, model_name)
|
||||||
|
if not cached_entry:
|
||||||
|
logger.warning(f"No hash found for checkpoint '{model_filename}', skipping usage tracking")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cached_hash = cached_entry.get("sha256")
|
||||||
|
if cached_hash:
|
||||||
|
return cached_hash
|
||||||
|
|
||||||
|
if cached_entry.get("hash_status") == "pending":
|
||||||
|
calculate_hash = getattr(checkpoint_scanner, "calculate_hash_for_model", None)
|
||||||
|
file_path = cached_entry.get("file_path")
|
||||||
|
if callable(calculate_hash) and file_path:
|
||||||
|
calculated_hash = await calculate_hash(file_path)
|
||||||
|
if calculated_hash:
|
||||||
|
return calculated_hash
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to calculate pending hash for checkpoint '{model_filename}', skipping usage tracking"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.warning(f"No hash found for checkpoint '{model_filename}', skipping usage tracking")
|
||||||
|
return None
|
||||||
|
|
||||||
async def _process_checkpoints(self, models_data, today_date):
|
async def _process_checkpoints(self, models_data, today_date):
|
||||||
"""Process checkpoint models from metadata"""
|
"""Process checkpoint models from metadata"""
|
||||||
try:
|
try:
|
||||||
@@ -312,26 +386,11 @@ class UsageStats:
|
|||||||
if not model_name:
|
if not model_name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Clean up filename (remove extension if present)
|
model_hash = await self._resolve_checkpoint_hash(checkpoint_scanner, model_name)
|
||||||
model_filename = os.path.splitext(os.path.basename(model_name))[0]
|
if not model_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
# Get hash for this checkpoint
|
self._increment_usage_counter("checkpoints", model_hash, today_date)
|
||||||
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
|
|
||||||
if model_hash:
|
|
||||||
# Update stats for this checkpoint with date tracking
|
|
||||||
if model_hash not in self.stats["checkpoints"]:
|
|
||||||
self.stats["checkpoints"][model_hash] = {
|
|
||||||
"total": 0,
|
|
||||||
"history": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Increment total count
|
|
||||||
self.stats["checkpoints"][model_hash]["total"] += 1
|
|
||||||
|
|
||||||
# Increment today's count
|
|
||||||
if today_date not in self.stats["checkpoints"][model_hash]["history"]:
|
|
||||||
self.stats["checkpoints"][model_hash]["history"][today_date] = 0
|
|
||||||
self.stats["checkpoints"][model_hash]["history"][today_date] += 1
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing checkpoint usage: {e}", exc_info=True)
|
logger.error(f"Error processing checkpoint usage: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -360,21 +419,11 @@ class UsageStats:
|
|||||||
|
|
||||||
# Get hash for this LoRA
|
# Get hash for this LoRA
|
||||||
lora_hash = lora_scanner.get_hash_by_filename(lora_name)
|
lora_hash = lora_scanner.get_hash_by_filename(lora_name)
|
||||||
if lora_hash:
|
if not lora_hash:
|
||||||
# Update stats for this LoRA with date tracking
|
logger.warning(f"No hash found for LoRA '{lora_name}', skipping usage tracking")
|
||||||
if lora_hash not in self.stats["loras"]:
|
continue
|
||||||
self.stats["loras"][lora_hash] = {
|
|
||||||
"total": 0,
|
|
||||||
"history": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Increment total count
|
self._increment_usage_counter("loras", lora_hash, today_date)
|
||||||
self.stats["loras"][lora_hash]["total"] += 1
|
|
||||||
|
|
||||||
# Increment today's count
|
|
||||||
if today_date not in self.stats["loras"][lora_hash]["history"]:
|
|
||||||
self.stats["loras"][lora_hash]["history"][today_date] = 0
|
|
||||||
self.stats["loras"][lora_hash]["history"][today_date] += 1
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
|
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -1036,6 +1036,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 860px) {
|
||||||
|
#recipeModal .modal-content {
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-modal-header {
|
||||||
|
padding-bottom: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-modal-header h2 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
max-height: 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-tags-container {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-top-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-preview-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-gen-params {
|
||||||
|
height: auto;
|
||||||
|
max-height: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-gen-params h3 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-params-container {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-content {
|
||||||
|
max-height: 90px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-params {
|
||||||
|
margin-top: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-bottom-section {
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-section-header {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.badge-container {
|
.badge-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export class SettingsManager {
|
|||||||
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
||||||
);
|
);
|
||||||
|
|
||||||
|
merged.skip_previously_downloaded_model_versions =
|
||||||
|
backendSettings?.skip_previously_downloaded_model_versions
|
||||||
|
?? defaults.skip_previously_downloaded_model_versions;
|
||||||
|
|
||||||
merged.download_skip_base_models = this.normalizeDownloadSkipBaseModels(
|
merged.download_skip_base_models = this.normalizeDownloadSkipBaseModels(
|
||||||
backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models
|
backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models
|
||||||
);
|
);
|
||||||
@@ -762,6 +766,11 @@ export class SettingsManager {
|
|||||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipesPathInput = document.getElementById('recipesPath');
|
||||||
|
if (recipesPathInput) {
|
||||||
|
recipesPathInput.value = state.global.settings.recipes_path || '';
|
||||||
|
}
|
||||||
|
|
||||||
const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions');
|
const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions');
|
||||||
if (autoOrganizeExclusionsInput) {
|
if (autoOrganizeExclusionsInput) {
|
||||||
const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
|
const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
|
||||||
@@ -836,6 +845,12 @@ export class SettingsManager {
|
|||||||
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
|
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
|
||||||
|
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
|
||||||
|
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
|
||||||
|
state.global.settings.skip_previously_downloaded_model_versions || false;
|
||||||
|
}
|
||||||
|
|
||||||
// Set optimize example images setting
|
// Set optimize example images setting
|
||||||
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
|
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
|
||||||
if (optimizeExampleImagesCheckbox) {
|
if (optimizeExampleImagesCheckbox) {
|
||||||
@@ -2454,6 +2469,7 @@ export class SettingsManager {
|
|||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
const value = element.value.trim(); // Trim whitespace
|
const value = element.value.trim(); // Trim whitespace
|
||||||
|
const shouldShowLoading = settingKey === 'recipes_path';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if value has changed from existing value
|
// Check if value has changed from existing value
|
||||||
@@ -2462,6 +2478,12 @@ export class SettingsManager {
|
|||||||
return; // No change, exit early
|
return; // No change, exit early
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
state.loadingManager?.showSimpleLoading(
|
||||||
|
translate('settings.folderSettings.recipesPathMigrating', {}, 'Migrating recipes...')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// For username and password, handle empty values specially
|
// For username and password, handle empty values specially
|
||||||
if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
|
if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
|
||||||
// Remove from state instead of setting to empty string
|
// Remove from state instead of setting to empty string
|
||||||
@@ -2487,12 +2509,27 @@ export class SettingsManager {
|
|||||||
await this.saveSetting(settingKey, value);
|
await this.saveSetting(settingKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
state.loadingManager?.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingKey === 'recipes_path') {
|
||||||
|
showToast('toast.settings.recipesPathUpdated', {}, 'success');
|
||||||
|
} else {
|
||||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
state.loadingManager?.hide();
|
||||||
|
}
|
||||||
|
if (settingKey === 'recipes_path') {
|
||||||
|
showToast('toast.settings.recipesPathSaveFailed', { message: error.message }, 'error');
|
||||||
|
} else {
|
||||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async saveLanguageSetting() {
|
async saveLanguageSetting() {
|
||||||
const element = document.getElementById('languageSelect');
|
const element = document.getElementById('languageSelect');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
default_lora_root: '',
|
default_lora_root: '',
|
||||||
default_checkpoint_root: '',
|
default_checkpoint_root: '',
|
||||||
default_embedding_root: '',
|
default_embedding_root: '',
|
||||||
|
recipes_path: '',
|
||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: {},
|
download_path_templates: {},
|
||||||
example_images_path: '',
|
example_images_path: '',
|
||||||
@@ -38,6 +39,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
hide_early_access_updates: false,
|
hide_early_access_updates: false,
|
||||||
auto_organize_exclusions: [],
|
auto_organize_exclusions: [],
|
||||||
metadata_refresh_skip_paths: [],
|
metadata_refresh_skip_paths: [],
|
||||||
|
skip_previously_downloaded_model_versions: false,
|
||||||
download_skip_base_models: [],
|
download_skip_base_models: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = Optimizati
|
|||||||
try {
|
try {
|
||||||
const url = new URL(sourceUrl);
|
const url = new URL(sourceUrl);
|
||||||
|
|
||||||
// Check if it's a CivitAI image domain
|
// Check if it's a CivitAI CDN domain (supports all subdomains like image-b2.civitai.com)
|
||||||
if (url.hostname.toLowerCase() !== 'image.civitai.com') {
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
if (hostname === 'civitai.com' || !hostname.endsWith('.civitai.com')) {
|
||||||
return [sourceUrl, false];
|
return [sourceUrl, false];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +113,8 @@ export function isCivitaiUrl(url) {
|
|||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
return parsed.hostname.toLowerCase() === 'image.civitai.com';
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
return hostname.endsWith('.civitai.com') && hostname !== 'civitai.com';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,6 +530,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe Settings -->
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>{{ t('settings.sections.recipeSettings') }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="recipesPath">
|
||||||
|
{{ t('settings.folderSettings.recipesPath') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.folderSettings.recipesPathHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="text-input-wrapper">
|
||||||
|
<input type="text" id="recipesPath"
|
||||||
|
placeholder="{{ t('settings.folderSettings.recipesPathPlaceholder') }}"
|
||||||
|
onblur="settingsManager.saveInputSetting('recipesPath', 'recipes_path')"
|
||||||
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extra Folder Paths -->
|
<!-- Extra Folder Paths -->
|
||||||
@@ -735,6 +761,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="skipPreviouslyDownloadedModelVersions">
|
||||||
|
{{ t('settings.skipPreviouslyDownloadedModelVersions.label') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.skipPreviouslyDownloadedModelVersions.help') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="skipPreviouslyDownloadedModelVersions"
|
||||||
|
onchange="settingsManager.saveToggleSetting('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
if (key === 'loramanager.autocomplete_append_comma') {
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (key === 'loramanager.autocomplete_accept_key') {
|
if (key === 'loramanager.autocomplete_accept_key') {
|
||||||
return 'both';
|
return 'both';
|
||||||
}
|
}
|
||||||
@@ -188,6 +191,59 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats duplicate commas and extra spaces when the textarea loses focus', async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'foo bar, , baz ,, qux';
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const inputListener = vi.fn();
|
||||||
|
input.addEventListener('input', inputListener);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input,'prompt', { showPreview: false });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(input.value).toBe('foo bar, baz, qux');
|
||||||
|
expect(inputListener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips blur formatting when autocomplete auto format is disabled', async () => {
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_accept_key') {
|
||||||
|
return 'both';
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.tag_space_replacement') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'foo bar, , baz ,, qux';
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const inputListener = vi.fn();
|
||||||
|
input.addEventListener('input', inputListener);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input,'prompt', { showPreview: false });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(input.value).toBe('foo bar, , baz ,, qux');
|
||||||
|
expect(inputListener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts the selected suggestion with Enter', async () => {
|
it('accepts the selected suggestion with Enter', async () => {
|
||||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
|
||||||
@@ -275,6 +331,9 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
if (key === 'loramanager.autocomplete_append_comma') {
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (key === 'loramanager.autocomplete_accept_key') {
|
if (key === 'loramanager.autocomplete_accept_key') {
|
||||||
return 'tab_only';
|
return 'tab_only';
|
||||||
}
|
}
|
||||||
@@ -322,6 +381,9 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
if (key === 'loramanager.autocomplete_append_comma') {
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (key === 'loramanager.autocomplete_accept_key') {
|
if (key === 'loramanager.autocomplete_accept_key') {
|
||||||
return 'enter_only';
|
return 'enter_only';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ vi.mock('../../../static/js/state/index.js', () => {
|
|||||||
},
|
},
|
||||||
createDefaultSettings: () => ({
|
createDefaultSettings: () => ({
|
||||||
language: 'en',
|
language: 'en',
|
||||||
|
skip_previously_downloaded_model_versions: false,
|
||||||
download_skip_base_models: [],
|
download_skip_base_models: [],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -117,6 +118,7 @@ describe('SettingsManager download skip base models UI', () => {
|
|||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
state.global.settings = {
|
state.global.settings = {
|
||||||
|
skip_previously_downloaded_model_versions: false,
|
||||||
download_skip_base_models: [],
|
download_skip_base_models: [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -150,4 +152,31 @@ describe('SettingsManager download skip base models UI', () => {
|
|||||||
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(0);
|
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(0);
|
||||||
expect(document.getElementById('downloadSkipBaseModelsEmpty').hidden).toBe(false);
|
expect(document.getElementById('downloadSkipBaseModelsEmpty').hidden).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('initializes the previously-downloaded-version toggle from settings', () => {
|
||||||
|
document.body.innerHTML = '<input id="skipPreviouslyDownloadedModelVersions" type="checkbox" />';
|
||||||
|
state.global.settings.skip_previously_downloaded_model_versions = true;
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
manager.loadSettingsToUI();
|
||||||
|
|
||||||
|
expect(document.getElementById('skipPreviouslyDownloadedModelVersions').checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves the previously-downloaded-version toggle with the expected setting key', async () => {
|
||||||
|
document.body.innerHTML = '<input id="skipPreviouslyDownloadedModelVersions" type="checkbox" checked />';
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
manager.applyFrontendSettings = vi.fn();
|
||||||
|
|
||||||
|
await manager.saveToggleSetting(
|
||||||
|
'skipPreviouslyDownloadedModelVersions',
|
||||||
|
'skip_previously_downloaded_model_versions',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalledWith(
|
||||||
|
'skip_previously_downloaded_model_versions',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -205,4 +205,58 @@ describe('SettingsManager library controls', () => {
|
|||||||
expect(select.value).toBe('alpha');
|
expect(select.value).toBe('alpha');
|
||||||
expect(activateSpy).not.toHaveBeenCalled();
|
expect(activateSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads recipes_path into the settings input', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.id = 'recipesPath';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
isAvailable: false,
|
||||||
|
isEnabled: false,
|
||||||
|
databaseSize: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
state.global.settings = {
|
||||||
|
recipes_path: '/custom/recipes',
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.loadSettingsToUI();
|
||||||
|
|
||||||
|
expect(input.value).toBe('/custom/recipes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading while saving recipes_path', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.id = 'recipesPath';
|
||||||
|
input.value = '/custom/recipes';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
|
||||||
|
state.global.settings = {
|
||||||
|
recipes_path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.saveInputSetting('recipesPath', 'recipes_path');
|
||||||
|
|
||||||
|
expect(state.loadingManager.showSimpleLoading).toHaveBeenCalledWith(
|
||||||
|
'Migrating recipes...'
|
||||||
|
);
|
||||||
|
expect(state.loadingManager.hide).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToast).toHaveBeenCalledWith(
|
||||||
|
'toast.settings.recipesPathUpdated',
|
||||||
|
{},
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,6 +94,37 @@ describe('civitaiUtils', () => {
|
|||||||
expect(wasRewritten).toBe(false);
|
expect(wasRewritten).toBe(false);
|
||||||
expect(rewritten).toBe('not-a-valid-url');
|
expect(rewritten).toBe('not-a-valid-url');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should rewrite URLs from CivitAI CDN subdomains', () => {
|
||||||
|
const originalUrl = 'https://image-b2.civitai.com/file/civitai-media-cache/original=true/sample.png';
|
||||||
|
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
|
||||||
|
|
||||||
|
expect(wasRewritten).toBe(true);
|
||||||
|
expect(rewritten).toBe('https://image-b2.civitai.com/file/civitai-media-cache/width=450,optimized=true/sample.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs with explicit port numbers', () => {
|
||||||
|
const originalUrl = 'https://image.civitai.com:443/checkpoints/original=true/test.png';
|
||||||
|
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
|
||||||
|
|
||||||
|
expect(wasRewritten).toBe(true);
|
||||||
|
// JavaScript URL.toString() removes default HTTPS port (443)
|
||||||
|
expect(rewritten).toBe('https://image.civitai.com/checkpoints/width=450,optimized=true/test.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case-insensitive hostnames', () => {
|
||||||
|
const testCases = [
|
||||||
|
'https://IMAGE.CIVITAI.COM/original=true/test.png',
|
||||||
|
'https://Image.Civitai.Com/original=true/test.png',
|
||||||
|
'https://image-b2.CIVITAI.com/original=true/test.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const url of testCases) {
|
||||||
|
const [rewritten, wasRewritten] = rewriteCivitaiUrl(url, 'image', OptimizationMode.THUMBNAIL);
|
||||||
|
expect(wasRewritten).toBe(true);
|
||||||
|
expect(rewritten).toContain('width=450,optimized=true');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getOptimizedUrl', () => {
|
describe('getOptimizedUrl', () => {
|
||||||
@@ -157,6 +188,23 @@ describe('civitaiUtils', () => {
|
|||||||
expect(isCivitaiUrl('https://image.civitai.com/')).toBe(true);
|
expect(isCivitaiUrl('https://image.civitai.com/')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return true for CivitAI CDN subdomains', () => {
|
||||||
|
expect(isCivitaiUrl('https://image-b2.civitai.com/file/test.png')).toBe(true);
|
||||||
|
expect(isCivitaiUrl('https://image-b3.civitai.com/test.jpg')).toBe(true);
|
||||||
|
expect(isCivitaiUrl('https://cdn.civitai.com/test.png')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for CivitAI URLs with explicit ports', () => {
|
||||||
|
expect(isCivitaiUrl('https://image.civitai.com:443/test.png')).toBe(true);
|
||||||
|
expect(isCivitaiUrl('https://image-b2.civitai.com:443/file/test.jpg')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case-insensitive hostnames', () => {
|
||||||
|
expect(isCivitaiUrl('https://IMAGE.CIVITAI.COM/test.png')).toBe(true);
|
||||||
|
expect(isCivitaiUrl('https://Image.Civitai.Com/test.png')).toBe(true);
|
||||||
|
expect(isCivitaiUrl('https://image-b2.CIVITAI.com/test.png')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return false for non-CivitAI URLs', () => {
|
it('should return false for non-CivitAI URLs', () => {
|
||||||
expect(isCivitaiUrl('https://example.com/image.jpg')).toBe(false);
|
expect(isCivitaiUrl('https://example.com/image.jpg')).toBe(false);
|
||||||
expect(isCivitaiUrl('https://civitai.com/image.jpg')).toBe(false);
|
expect(isCivitaiUrl('https://civitai.com/image.jpg')).toBe(false);
|
||||||
|
|||||||
151
tests/frontend/utils/loraChainTraversal.test.js
Normal file
151
tests/frontend/utils/loraChainTraversal.test.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { APP_MODULE, UTILS_MODULE } = vi.hoisted(() => ({
|
||||||
|
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
|
||||||
|
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: {
|
||||||
|
graph: null,
|
||||||
|
registerExtension: vi.fn(),
|
||||||
|
ui: {
|
||||||
|
settings: {
|
||||||
|
getSettingValue: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("LoRA chain traversal", () => {
|
||||||
|
let collectActiveLorasFromChain;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
({ collectActiveLorasFromChain } = await import(UTILS_MODULE));
|
||||||
|
});
|
||||||
|
|
||||||
|
function createGraph(nodes, links) {
|
||||||
|
const graph = {
|
||||||
|
_nodes: nodes,
|
||||||
|
links,
|
||||||
|
getNodeById(id) {
|
||||||
|
return nodes.find((node) => node.id === id) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
node.graph = graph;
|
||||||
|
});
|
||||||
|
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("aggregates active LoRAs through a combiner with multiple LORA_STACK inputs", () => {
|
||||||
|
const randomizerA = {
|
||||||
|
id: 1,
|
||||||
|
comfyClass: "Lora Randomizer (LoraManager)",
|
||||||
|
mode: 0,
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
name: "loras",
|
||||||
|
value: [
|
||||||
|
{ name: "Alpha", active: true },
|
||||||
|
{ name: "Ignored", active: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
const randomizerB = {
|
||||||
|
id: 2,
|
||||||
|
comfyClass: "Lora Randomizer (LoraManager)",
|
||||||
|
mode: 0,
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
name: "loras",
|
||||||
|
value: [{ name: "Beta", active: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
const combiner = {
|
||||||
|
id: 3,
|
||||||
|
comfyClass: "Lora Stack Combiner (LoraManager)",
|
||||||
|
mode: 0,
|
||||||
|
widgets: [],
|
||||||
|
inputs: [
|
||||||
|
{ name: "lora_stack_a", type: "LORA_STACK", link: 11 },
|
||||||
|
{ name: "lora_stack_b", type: "LORA_STACK", link: 12 },
|
||||||
|
],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
const loader = {
|
||||||
|
id: 4,
|
||||||
|
comfyClass: "Lora Loader (LoraManager)",
|
||||||
|
mode: 0,
|
||||||
|
widgets: [],
|
||||||
|
inputs: [{ name: "lora_stack", type: "LORA_STACK", link: 13 }],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
createGraph(
|
||||||
|
[randomizerA, randomizerB, combiner, loader],
|
||||||
|
{
|
||||||
|
11: { origin_id: 1, target_id: 3 },
|
||||||
|
12: { origin_id: 2, target_id: 3 },
|
||||||
|
13: { origin_id: 3, target_id: 4 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = collectActiveLorasFromChain(loader);
|
||||||
|
|
||||||
|
expect([...result]).toEqual(["Alpha", "Beta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops propagation when the combiner is inactive", () => {
|
||||||
|
const randomizer = {
|
||||||
|
id: 1,
|
||||||
|
comfyClass: "Lora Randomizer (LoraManager)",
|
||||||
|
mode: 0,
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
name: "loras",
|
||||||
|
value: [{ name: "Alpha", active: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
const combiner = {
|
||||||
|
id: 2,
|
||||||
|
comfyClass: "Lora Stack Combiner (LoraManager)",
|
||||||
|
mode: 2,
|
||||||
|
widgets: [],
|
||||||
|
inputs: [{ name: "lora_stack_a", type: "LORA_STACK", link: 21 }],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
const loader = {
|
||||||
|
id: 3,
|
||||||
|
comfyClass: "Lora Loader (LoraManager)",
|
||||||
|
mode: 0,
|
||||||
|
widgets: [],
|
||||||
|
inputs: [{ name: "lora_stack", type: "LORA_STACK", link: 22 }],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
createGraph(
|
||||||
|
[randomizer, combiner, loader],
|
||||||
|
{
|
||||||
|
21: { origin_id: 1, target_id: 2 },
|
||||||
|
22: { origin_id: 2, target_id: 3 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = collectActiveLorasFromChain(loader);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,6 +98,85 @@ def test_metadata_processor_extracts_generation_params(populated_registry, monke
|
|||||||
assert isinstance(value, str)
|
assert isinstance(value, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attention_bias_clip_text_encode_prompts_are_collected(metadata_registry, monkeypatch):
|
||||||
|
import types
|
||||||
|
|
||||||
|
prompt_graph = {
|
||||||
|
"encode_pos": {
|
||||||
|
"class_type": "CLIPTextEncodeAttentionBias",
|
||||||
|
"inputs": {"text": "A <big dog=1.25> on a hill", "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"encode_neg": {
|
||||||
|
"class_type": "CLIPTextEncodeAttentionBias",
|
||||||
|
"inputs": {"text": "low quality", "clip": ["clip", 0]},
|
||||||
|
},
|
||||||
|
"sampler": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"seed": types.SimpleNamespace(seed=123),
|
||||||
|
"steps": 20,
|
||||||
|
"cfg": 7.0,
|
||||||
|
"sampler_name": "Euler",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1.0,
|
||||||
|
"positive": ["encode_pos", 0],
|
||||||
|
"negative": ["encode_neg", 0],
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prompt = SimpleNamespace(original_prompt=prompt_graph)
|
||||||
|
|
||||||
|
pos_conditioning = object()
|
||||||
|
neg_conditioning = object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
|
||||||
|
|
||||||
|
metadata_registry.start_collection("prompt-attention")
|
||||||
|
metadata_registry.set_current_prompt(prompt)
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_pos",
|
||||||
|
"CLIPTextEncodeAttentionBias",
|
||||||
|
{"text": "A <big dog=1.25> on a hill"},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_pos", "CLIPTextEncodeAttentionBias", [(pos_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"encode_neg",
|
||||||
|
"CLIPTextEncodeAttentionBias",
|
||||||
|
{"text": "low quality"},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
metadata_registry.update_node_execution(
|
||||||
|
"encode_neg", "CLIPTextEncodeAttentionBias", [(neg_conditioning,)]
|
||||||
|
)
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"sampler",
|
||||||
|
"KSampler",
|
||||||
|
{
|
||||||
|
"seed": types.SimpleNamespace(seed=123),
|
||||||
|
"positive": pos_conditioning,
|
||||||
|
"negative": neg_conditioning,
|
||||||
|
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt-attention")
|
||||||
|
sampler_data = metadata[SAMPLING]["sampler"]
|
||||||
|
prompt_results = MetadataProcessor.match_conditioning_to_prompts(metadata, "sampler")
|
||||||
|
|
||||||
|
assert metadata[PROMPTS]["encode_pos"]["text"] == "A <big dog=1.25> on a hill"
|
||||||
|
assert metadata[PROMPTS]["encode_neg"]["text"] == "low quality"
|
||||||
|
assert sampler_data["node_id"] == "sampler"
|
||||||
|
assert sampler_data["is_sampler"] is True
|
||||||
|
assert prompt_results["prompt"] == "A <big dog=1.25> on a hill"
|
||||||
|
assert prompt_results["negative_prompt"] == "low quality"
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_registry_caches_and_rehydrates(populated_registry):
|
def test_metadata_registry_caches_and_rehydrates(populated_registry):
|
||||||
registry = populated_registry["registry"]
|
registry = populated_registry["registry"]
|
||||||
prompt = populated_registry["prompt"]
|
prompt = populated_registry["prompt"]
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import pytest
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.test_utils import make_mocked_request
|
from aiohttp.test_utils import make_mocked_request
|
||||||
|
|
||||||
from py.middleware.csp_middleware import REMOTE_MEDIA_SOURCES, relax_csp_for_remote_media
|
from py.middleware.csp_middleware import (
|
||||||
|
REMOTE_MEDIA_SOURCES,
|
||||||
|
relax_csp_for_remote_media,
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_CSP = (
|
DEFAULT_CSP = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
@@ -40,7 +43,9 @@ async def _invoke_middleware(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_relax_csp_appends_remote_sources_and_preserves_existing_directives() -> None:
|
async def test_relax_csp_appends_remote_sources_and_preserves_existing_directives() -> (
|
||||||
|
None
|
||||||
|
):
|
||||||
response = await _invoke_middleware("/some-path", web.Response())
|
response = await _invoke_middleware("/some-path", web.Response())
|
||||||
header_value = response.headers.get("Content-Security-Policy")
|
header_value = response.headers.get("Content-Security-Policy")
|
||||||
assert header_value is not None
|
assert header_value is not None
|
||||||
@@ -48,16 +53,17 @@ async def test_relax_csp_appends_remote_sources_and_preserves_existing_directive
|
|||||||
directives = _parse_directives(header_value)
|
directives = _parse_directives(header_value)
|
||||||
|
|
||||||
# Existing directives remain intact
|
# Existing directives remain intact
|
||||||
assert directives["script-src"] == ["'self'", "'unsafe-inline'", "'unsafe-eval'", "blob:"]
|
assert directives["script-src"] == [
|
||||||
|
"'self'",
|
||||||
|
"'unsafe-inline'",
|
||||||
|
"'unsafe-eval'",
|
||||||
|
"blob:",
|
||||||
|
]
|
||||||
assert directives["img-src"][:3] == ["'self'", "data:", "blob:"]
|
assert directives["img-src"][:3] == ["'self'", "data:", "blob:"]
|
||||||
|
|
||||||
# Remote media hosts are added once to the relevant directives
|
# Remote media hosts are added once to the relevant directives
|
||||||
for source in REMOTE_MEDIA_SOURCES:
|
for source in REMOTE_MEDIA_SOURCES:
|
||||||
assert source in directives["img-src"]
|
assert source in directives["img-src"]
|
||||||
|
|
||||||
assert "media-src" in directives
|
|
||||||
assert directives["media-src"][0] == "'self'"
|
|
||||||
for source in REMOTE_MEDIA_SOURCES:
|
|
||||||
assert source in directives["media-src"]
|
assert source in directives["media-src"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# serializer version: 1
|
# serializer version: 1
|
||||||
# name: TestModelLibraryHandlerSnapshots.test_check_model_exists_empty_response
|
# name: TestModelLibraryHandlerSnapshots.test_check_model_exists_empty_response
|
||||||
dict({
|
dict({
|
||||||
|
'downloadedVersionIds': list([
|
||||||
|
]),
|
||||||
'modelType': None,
|
'modelType': None,
|
||||||
'success': True,
|
'success': True,
|
||||||
'versions': list([
|
'versions': list([
|
||||||
|
|||||||
@@ -66,6 +66,27 @@ class FakePromptServer:
|
|||||||
instance = Instance()
|
instance = Instance()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDownloadHistoryService:
|
||||||
|
async def has_been_downloaded(self, _model_type, _version_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_downloaded_version_ids(self, _model_type, _model_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_downloaded_version_ids_bulk(self, _model_type, _model_ids):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def mark_downloaded(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def mark_not_downloaded(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_download_history_service_factory():
|
||||||
|
return FakeDownloadHistoryService()
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsHandlerSnapshots:
|
class TestSettingsHandlerSnapshots:
|
||||||
"""Snapshot tests for SettingsHandler responses."""
|
"""Snapshot tests for SettingsHandler responses."""
|
||||||
|
|
||||||
@@ -223,6 +244,7 @@ class TestModelLibraryHandlerSnapshots:
|
|||||||
get_lora_scanner=scanner_factory,
|
get_lora_scanner=scanner_factory,
|
||||||
get_checkpoint_scanner=scanner_factory,
|
get_checkpoint_scanner=scanner_factory,
|
||||||
get_embedding_scanner=scanner_factory,
|
get_embedding_scanner=scanner_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
),
|
),
|
||||||
metadata_provider_factory=lambda: None,
|
metadata_provider_factory=lambda: None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ from py.routes.misc_routes import MiscRoutes
|
|||||||
|
|
||||||
|
|
||||||
class FakeRequest:
|
class FakeRequest:
|
||||||
def __init__(self, *, json_data=None, query=None):
|
def __init__(self, *, json_data=None, query=None, method="POST"):
|
||||||
self._json_data = json_data or {}
|
self._json_data = json_data or {}
|
||||||
self.query = query or {}
|
self.query = query or {}
|
||||||
|
self.method = method
|
||||||
|
|
||||||
async def json(self):
|
async def json(self):
|
||||||
return self._json_data
|
return self._json_data
|
||||||
@@ -438,6 +439,46 @@ async def fake_metadata_archive_manager_factory():
|
|||||||
return FakeMetadataArchiveManager()
|
return FakeMetadataArchiveManager()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDownloadHistoryService:
|
||||||
|
def __init__(self, downloaded_by_type=None):
|
||||||
|
self.downloaded_by_type = downloaded_by_type or {}
|
||||||
|
self.marked_downloaded: list[tuple] = []
|
||||||
|
self.marked_not_downloaded: list[tuple] = []
|
||||||
|
|
||||||
|
async def has_been_downloaded(self, model_type, version_id):
|
||||||
|
return version_id in self.downloaded_by_type.get(model_type, set())
|
||||||
|
|
||||||
|
async def get_downloaded_version_ids(self, model_type, model_id):
|
||||||
|
entries = self.downloaded_by_type.get(model_type, {})
|
||||||
|
if isinstance(entries, dict):
|
||||||
|
return sorted(entries.get(model_id, set()))
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_downloaded_version_ids_bulk(self, model_type, model_ids):
|
||||||
|
entries = self.downloaded_by_type.get(model_type, {})
|
||||||
|
if not isinstance(entries, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
model_id: set(entries.get(model_id, set()))
|
||||||
|
for model_id in model_ids
|
||||||
|
if model_id in entries
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mark_downloaded(
|
||||||
|
self, model_type, version_id, *, model_id=None, source="manual", file_path=None
|
||||||
|
):
|
||||||
|
self.marked_downloaded.append(
|
||||||
|
(model_type, version_id, model_id, source, file_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_not_downloaded(self, model_type, version_id):
|
||||||
|
self.marked_not_downloaded.append((model_type, version_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_download_history_service_factory():
|
||||||
|
return FakeDownloadHistoryService()
|
||||||
|
|
||||||
|
|
||||||
class RecordingRegistrar:
|
class RecordingRegistrar:
|
||||||
def __init__(self, _app):
|
def __init__(self, _app):
|
||||||
self.registered_mapping = None
|
self.registered_mapping = None
|
||||||
@@ -452,6 +493,7 @@ async def test_misc_routes_bind_produces_expected_handlers():
|
|||||||
get_lora_scanner=fake_scanner_factory,
|
get_lora_scanner=fake_scanner_factory,
|
||||||
get_checkpoint_scanner=fake_scanner_factory,
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
get_embedding_scanner=fake_scanner_factory,
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
)
|
)
|
||||||
|
|
||||||
recorded_registrars = []
|
recorded_registrars = []
|
||||||
@@ -578,6 +620,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
get_lora_scanner=lora_factory,
|
get_lora_scanner=lora_factory,
|
||||||
get_checkpoint_scanner=checkpoint_factory,
|
get_checkpoint_scanner=checkpoint_factory,
|
||||||
get_embedding_scanner=embedding_factory,
|
get_embedding_scanner=embedding_factory,
|
||||||
|
get_downloaded_version_history_service=lambda: fake_download_history_service_factory(),
|
||||||
),
|
),
|
||||||
metadata_provider_factory=provider_factory,
|
metadata_provider_factory=provider_factory,
|
||||||
)
|
)
|
||||||
@@ -600,6 +643,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
"baseModel": "Flux.1",
|
"baseModel": "Flux.1",
|
||||||
"thumbnailUrl": "http://example.com/a1.jpg",
|
"thumbnailUrl": "http://example.com/a1.jpg",
|
||||||
"inLibrary": False,
|
"inLibrary": False,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelId": 1,
|
"modelId": 1,
|
||||||
@@ -611,6 +655,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
"baseModel": "Flux.1",
|
"baseModel": "Flux.1",
|
||||||
"thumbnailUrl": "http://example.com/a2.jpg",
|
"thumbnailUrl": "http://example.com/a2.jpg",
|
||||||
"inLibrary": True,
|
"inLibrary": True,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelId": 2,
|
"modelId": 2,
|
||||||
@@ -622,6 +667,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
"baseModel": None,
|
"baseModel": None,
|
||||||
"thumbnailUrl": "http://example.com/e1.jpg",
|
"thumbnailUrl": "http://example.com/e1.jpg",
|
||||||
"inLibrary": False,
|
"inLibrary": False,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelId": 2,
|
"modelId": 2,
|
||||||
@@ -633,6 +679,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
"baseModel": None,
|
"baseModel": None,
|
||||||
"thumbnailUrl": None,
|
"thumbnailUrl": None,
|
||||||
"inLibrary": True,
|
"inLibrary": True,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelId": 3,
|
"modelId": 3,
|
||||||
@@ -644,6 +691,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
"baseModel": "SDXL",
|
"baseModel": "SDXL",
|
||||||
"thumbnailUrl": None,
|
"thumbnailUrl": None,
|
||||||
"inLibrary": False,
|
"inLibrary": False,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -692,6 +740,7 @@ async def test_get_civitai_user_models_rewrites_civitai_previews():
|
|||||||
get_lora_scanner=fake_scanner_factory,
|
get_lora_scanner=fake_scanner_factory,
|
||||||
get_checkpoint_scanner=fake_scanner_factory,
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
get_embedding_scanner=fake_scanner_factory,
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
),
|
),
|
||||||
metadata_provider_factory=provider_factory,
|
metadata_provider_factory=provider_factory,
|
||||||
)
|
)
|
||||||
@@ -727,6 +776,7 @@ async def test_get_civitai_user_models_requires_username():
|
|||||||
get_lora_scanner=fake_scanner_factory,
|
get_lora_scanner=fake_scanner_factory,
|
||||||
get_checkpoint_scanner=fake_scanner_factory,
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
get_embedding_scanner=fake_scanner_factory,
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
),
|
),
|
||||||
metadata_provider_factory=provider_factory,
|
metadata_provider_factory=provider_factory,
|
||||||
)
|
)
|
||||||
@@ -760,6 +810,7 @@ def test_ensure_handler_mapping_caches_result():
|
|||||||
get_lora_scanner=fake_scanner_factory,
|
get_lora_scanner=fake_scanner_factory,
|
||||||
get_checkpoint_scanner=fake_scanner_factory,
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
get_embedding_scanner=fake_scanner_factory,
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
),
|
),
|
||||||
metadata_provider_factory=fake_metadata_provider_factory,
|
metadata_provider_factory=fake_metadata_provider_factory,
|
||||||
metadata_archive_manager_factory=fake_metadata_archive_manager_factory,
|
metadata_archive_manager_factory=fake_metadata_archive_manager_factory,
|
||||||
@@ -802,6 +853,7 @@ async def test_check_model_exists_returns_local_versions():
|
|||||||
get_lora_scanner=lora_factory,
|
get_lora_scanner=lora_factory,
|
||||||
get_checkpoint_scanner=checkpoint_factory,
|
get_checkpoint_scanner=checkpoint_factory,
|
||||||
get_embedding_scanner=embedding_factory,
|
get_embedding_scanner=embedding_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
),
|
),
|
||||||
metadata_provider_factory=fake_metadata_provider_factory,
|
metadata_provider_factory=fake_metadata_provider_factory,
|
||||||
)
|
)
|
||||||
@@ -811,10 +863,139 @@ async def test_check_model_exists_returns_local_versions():
|
|||||||
|
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
assert payload["modelType"] == "lora"
|
assert payload["modelType"] == "lora"
|
||||||
assert payload["versions"] == versions
|
assert payload["versions"] == [
|
||||||
|
{"versionId": 11, "name": "v1", "fileName": "model-one", "hasBeenDownloaded": True},
|
||||||
|
{"versionId": 12, "name": "v2", "fileName": "model-two", "hasBeenDownloaded": True},
|
||||||
|
]
|
||||||
assert lora_scanner.version_calls == [5]
|
assert lora_scanner.version_calls == [5]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_model_exists_model_id_only_does_not_call_metadata_provider():
|
||||||
|
async def metadata_provider_factory():
|
||||||
|
raise AssertionError("metadata provider should not be called for modelId-only checks")
|
||||||
|
|
||||||
|
handler = ModelLibraryHandler(
|
||||||
|
ServiceRegistryAdapter(
|
||||||
|
get_lora_scanner=fake_scanner_factory,
|
||||||
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
|
),
|
||||||
|
metadata_provider_factory=metadata_provider_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.check_model_exists(FakeRequest(query={"modelId": "5"}))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload == {
|
||||||
|
"success": True,
|
||||||
|
"modelType": None,
|
||||||
|
"versions": [],
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_model_exists_returns_download_history_when_file_missing():
|
||||||
|
history_service = FakeDownloadHistoryService({"checkpoint": {999}})
|
||||||
|
|
||||||
|
async def history_factory():
|
||||||
|
return history_service
|
||||||
|
|
||||||
|
handler = ModelLibraryHandler(
|
||||||
|
ServiceRegistryAdapter(
|
||||||
|
get_lora_scanner=fake_scanner_factory,
|
||||||
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_downloaded_version_history_service=history_factory,
|
||||||
|
),
|
||||||
|
metadata_provider_factory=fake_metadata_provider_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.check_model_exists(
|
||||||
|
FakeRequest(query={"modelId": "5", "modelVersionId": "999"})
|
||||||
|
)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload == {
|
||||||
|
"success": True,
|
||||||
|
"exists": False,
|
||||||
|
"modelType": "checkpoint",
|
||||||
|
"hasBeenDownloaded": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_version_download_status_endpoints():
|
||||||
|
history_service = FakeDownloadHistoryService({"lora": {123}})
|
||||||
|
|
||||||
|
async def history_factory():
|
||||||
|
return history_service
|
||||||
|
|
||||||
|
handler = ModelLibraryHandler(
|
||||||
|
ServiceRegistryAdapter(
|
||||||
|
get_lora_scanner=fake_scanner_factory,
|
||||||
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_downloaded_version_history_service=history_factory,
|
||||||
|
),
|
||||||
|
metadata_provider_factory=fake_metadata_provider_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
get_response = await handler.get_model_version_download_status(
|
||||||
|
FakeRequest(query={"modelType": "lora", "modelVersionId": "123"})
|
||||||
|
)
|
||||||
|
get_payload = json.loads(get_response.text)
|
||||||
|
assert get_payload == {
|
||||||
|
"success": True,
|
||||||
|
"modelType": "lora",
|
||||||
|
"modelVersionId": 123,
|
||||||
|
"hasBeenDownloaded": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
set_response = await handler.set_model_version_download_status(
|
||||||
|
FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"modelType": "checkpoint",
|
||||||
|
"modelVersionId": 456,
|
||||||
|
"modelId": 78,
|
||||||
|
"downloaded": True,
|
||||||
|
"filePath": "/tmp/model.safetensors",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
set_payload = json.loads(set_response.text)
|
||||||
|
assert set_payload == {
|
||||||
|
"success": True,
|
||||||
|
"modelType": "checkpoint",
|
||||||
|
"modelVersionId": 456,
|
||||||
|
"hasBeenDownloaded": True,
|
||||||
|
}
|
||||||
|
assert history_service.marked_downloaded == [
|
||||||
|
("checkpoint", 456, 78, "manual", "/tmp/model.safetensors")
|
||||||
|
]
|
||||||
|
|
||||||
|
set_get_response = await handler.set_model_version_download_status(
|
||||||
|
FakeRequest(
|
||||||
|
method="GET",
|
||||||
|
query={
|
||||||
|
"modelType": "embedding",
|
||||||
|
"modelVersionId": "789",
|
||||||
|
"modelId": "12",
|
||||||
|
"downloaded": "false",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
set_get_payload = json.loads(set_get_response.text)
|
||||||
|
assert set_get_payload == {
|
||||||
|
"success": True,
|
||||||
|
"modelType": "embedding",
|
||||||
|
"modelVersionId": 789,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_create_handler_set_uses_provided_dependencies():
|
def test_create_handler_set_uses_provided_dependencies():
|
||||||
recorded_handlers: list[dict] = []
|
recorded_handlers: list[dict] = []
|
||||||
|
|
||||||
@@ -845,6 +1026,7 @@ def test_create_handler_set_uses_provided_dependencies():
|
|||||||
get_lora_scanner=fake_scanner_factory,
|
get_lora_scanner=fake_scanner_factory,
|
||||||
get_checkpoint_scanner=fake_scanner_factory,
|
get_checkpoint_scanner=fake_scanner_factory,
|
||||||
get_embedding_scanner=fake_scanner_factory,
|
get_embedding_scanner=fake_scanner_factory,
|
||||||
|
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||||
),
|
),
|
||||||
metadata_provider_factory=fake_metadata_provider_factory,
|
metadata_provider_factory=fake_metadata_provider_factory,
|
||||||
metadata_archive_manager_factory=fake_metadata_archive_manager_factory,
|
metadata_archive_manager_factory=fake_metadata_archive_manager_factory,
|
||||||
|
|||||||
@@ -113,6 +113,78 @@ async def test_config_updates_preview_roots_after_switch(tmp_path):
|
|||||||
assert decoded.replace("\\", "/").endswith("model.webp")
|
assert decoded.replace("\\", "/").endswith("model.webp")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_handler_allows_custom_recipes_path(tmp_path):
|
||||||
|
lora_root = tmp_path / "library"
|
||||||
|
lora_root.mkdir()
|
||||||
|
recipes_root = tmp_path / "recipes_storage"
|
||||||
|
recipes_root.mkdir()
|
||||||
|
preview_file = recipes_root / "recipe.webp"
|
||||||
|
preview_file.write_bytes(b"preview")
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.apply_library_settings(
|
||||||
|
{
|
||||||
|
"folder_paths": {
|
||||||
|
"loras": [str(lora_root)],
|
||||||
|
"checkpoints": [],
|
||||||
|
"unet": [],
|
||||||
|
"embeddings": [],
|
||||||
|
},
|
||||||
|
"recipes_path": str(recipes_root),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.is_preview_path_allowed(str(preview_file))
|
||||||
|
|
||||||
|
handler = PreviewHandler(config=config)
|
||||||
|
encoded_path = urllib.parse.quote(str(preview_file), safe="")
|
||||||
|
request = make_mocked_request("GET", f"/api/lm/previews?path={encoded_path}")
|
||||||
|
|
||||||
|
response = await handler.serve_preview(request)
|
||||||
|
|
||||||
|
assert isinstance(response, web.FileResponse)
|
||||||
|
assert response.status == 200
|
||||||
|
assert Path(response._path) == preview_file
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preview_handler_allows_symlinked_recipes_path(tmp_path):
|
||||||
|
lora_root = tmp_path / "library"
|
||||||
|
lora_root.mkdir()
|
||||||
|
real_recipes_root = tmp_path / "real_recipes"
|
||||||
|
real_recipes_root.mkdir()
|
||||||
|
symlink_recipes_root = tmp_path / "linked_recipes"
|
||||||
|
symlink_recipes_root.symlink_to(real_recipes_root, target_is_directory=True)
|
||||||
|
|
||||||
|
preview_file = real_recipes_root / "recipe.webp"
|
||||||
|
preview_file.write_bytes(b"preview")
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.apply_library_settings(
|
||||||
|
{
|
||||||
|
"folder_paths": {
|
||||||
|
"loras": [str(lora_root)],
|
||||||
|
"checkpoints": [],
|
||||||
|
"unet": [],
|
||||||
|
"embeddings": [],
|
||||||
|
},
|
||||||
|
"recipes_path": str(symlink_recipes_root),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
symlink_preview_path = symlink_recipes_root / "recipe.webp"
|
||||||
|
assert config.is_preview_path_allowed(str(symlink_preview_path))
|
||||||
|
|
||||||
|
handler = PreviewHandler(config=config)
|
||||||
|
encoded_path = urllib.parse.quote(str(symlink_preview_path), safe="")
|
||||||
|
request = make_mocked_request("GET", f"/api/lm/previews?path={encoded_path}")
|
||||||
|
|
||||||
|
response = await handler.serve_preview(request)
|
||||||
|
|
||||||
|
assert isinstance(response, web.FileResponse)
|
||||||
|
assert response.status == 200
|
||||||
|
assert Path(response._path) == preview_file.resolve()
|
||||||
|
|
||||||
|
|
||||||
def test_is_preview_path_allowed_case_insensitive_on_windows(tmp_path):
|
def test_is_preview_path_allowed_case_insensitive_on_windows(tmp_path):
|
||||||
"""Test that preview path validation is case-insensitive on Windows.
|
"""Test that preview path validation is case-insensitive on Windows.
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ def isolate_settings(monkeypatch, tmp_path):
|
|||||||
"embedding": "{base_model}/{first_tag}",
|
"embedding": "{base_model}/{first_tag}",
|
||||||
},
|
},
|
||||||
"base_model_path_mappings": {"BaseModel": "MappedModel"},
|
"base_model_path_mappings": {"BaseModel": "MappedModel"},
|
||||||
|
"skip_previously_downloaded_model_versions": False,
|
||||||
"download_skip_base_models": [],
|
"download_skip_base_models": [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -454,7 +455,7 @@ async def test_download_skips_excluded_base_model(monkeypatch, scanners, metadat
|
|||||||
|
|
||||||
metadata_provider.get_model_version = AsyncMock(
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
return_value={
|
return_value={
|
||||||
"id": 42,
|
"id": 99,
|
||||||
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||||
"baseModel": "SDXL 1.0",
|
"baseModel": "SDXL 1.0",
|
||||||
"creator": {"username": "Author"},
|
"creator": {"username": "Author"},
|
||||||
@@ -490,3 +491,104 @@ async def test_download_skips_excluded_base_model(monkeypatch, scanners, metadat
|
|||||||
assert "file.safetensors" in result["message"]
|
assert "file.safetensors" in result["message"]
|
||||||
execute_download.assert_not_called()
|
execute_download.assert_not_called()
|
||||||
assert manager._active_downloads[result["download_id"]]["status"] == "skipped"
|
assert manager._active_downloads[result["download_id"]]["status"] == "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_skips_previously_downloaded_version(monkeypatch, scanners, metadata_provider):
|
||||||
|
manager = DownloadManager()
|
||||||
|
get_settings_manager().settings["skip_previously_downloaded_model_versions"] = True
|
||||||
|
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"id": 42,
|
||||||
|
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||||
|
"baseModel": "SDXL 1.0",
|
||||||
|
"creator": {"username": "Author"},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||||
|
"name": "file.safetensors",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
history_service = AsyncMock()
|
||||||
|
history_service.has_been_downloaded = AsyncMock(return_value=True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry,
|
||||||
|
"get_downloaded_version_history_service",
|
||||||
|
AsyncMock(return_value=history_service),
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_download = AsyncMock()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=99,
|
||||||
|
use_default_paths=True,
|
||||||
|
progress_callback=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["skipped"] is True
|
||||||
|
assert result["status"] == "skipped"
|
||||||
|
assert result["reason"] == "previously_downloaded_version"
|
||||||
|
assert result["model_version_id"] == 99
|
||||||
|
assert result["file_name"] == "file.safetensors"
|
||||||
|
history_service.has_been_downloaded.assert_awaited_once_with("lora", 99)
|
||||||
|
execute_download.assert_not_called()
|
||||||
|
assert manager._active_downloads[result["download_id"]]["status"] == "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_proceeds_when_history_skip_disabled(monkeypatch, scanners, metadata_provider):
|
||||||
|
manager = DownloadManager()
|
||||||
|
get_settings_manager().settings["skip_previously_downloaded_model_versions"] = False
|
||||||
|
|
||||||
|
metadata_provider.get_model_version = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"id": 42,
|
||||||
|
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||||
|
"baseModel": "SDXL 1.0",
|
||||||
|
"creator": {"username": "Author"},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||||
|
"name": "file.safetensors",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
history_service = AsyncMock()
|
||||||
|
history_service.has_been_downloaded = AsyncMock(return_value=True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry,
|
||||||
|
"get_downloaded_version_history_service",
|
||||||
|
AsyncMock(return_value=history_service),
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=99,
|
||||||
|
use_default_paths=True,
|
||||||
|
progress_callback=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result.get("skipped") is not True
|
||||||
|
history_service.has_been_downloaded.assert_not_called()
|
||||||
|
execute_download.assert_awaited_once()
|
||||||
|
|||||||
70
tests/services/test_downloaded_version_history_service.py
Normal file
70
tests/services/test_downloaded_version_history_service.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.downloaded_version_history_service import (
|
||||||
|
DownloadedVersionHistoryService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DummySettings:
|
||||||
|
def get_active_library_name(self) -> str:
|
||||||
|
return "alpha"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_history_roundtrip_and_manual_override(tmp_path: Path) -> None:
|
||||||
|
db_path = tmp_path / "download-history.sqlite"
|
||||||
|
service = DownloadedVersionHistoryService(
|
||||||
|
str(db_path),
|
||||||
|
settings_manager=DummySettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.mark_downloaded(
|
||||||
|
"lora",
|
||||||
|
101,
|
||||||
|
model_id=11,
|
||||||
|
source="scan",
|
||||||
|
file_path="/models/a.safetensors",
|
||||||
|
)
|
||||||
|
assert await service.has_been_downloaded("lora", 101) is True
|
||||||
|
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||||
|
|
||||||
|
await service.mark_not_downloaded("lora", 101)
|
||||||
|
assert await service.has_been_downloaded("lora", 101) is False
|
||||||
|
assert await service.get_downloaded_version_ids("lora", 11) == []
|
||||||
|
|
||||||
|
await service.mark_downloaded(
|
||||||
|
"lora",
|
||||||
|
101,
|
||||||
|
model_id=11,
|
||||||
|
source="download",
|
||||||
|
file_path="/models/a.safetensors",
|
||||||
|
)
|
||||||
|
assert await service.has_been_downloaded("lora", 101) is True
|
||||||
|
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_history_bulk_lookup(tmp_path: Path) -> None:
|
||||||
|
db_path = tmp_path / "download-history.sqlite"
|
||||||
|
service = DownloadedVersionHistoryService(
|
||||||
|
str(db_path),
|
||||||
|
settings_manager=DummySettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.mark_downloaded_bulk(
|
||||||
|
"checkpoint",
|
||||||
|
[
|
||||||
|
{"model_id": 5, "version_id": 501, "file_path": "/m/one.safetensors"},
|
||||||
|
{"model_id": 5, "version_id": 502, "file_path": "/m/two.safetensors"},
|
||||||
|
{"model_id": 6, "version_id": 601, "file_path": "/m/three.safetensors"},
|
||||||
|
],
|
||||||
|
source="scan",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await service.get_downloaded_version_ids("checkpoint", 5) == [501, 502]
|
||||||
|
assert await service.get_downloaded_version_ids_bulk("checkpoint", [5, 6, 7]) == {
|
||||||
|
5: {501, 502},
|
||||||
|
6: {601},
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import pytest
|
|||||||
|
|
||||||
from py.config import config
|
from py.config import config
|
||||||
from py.services.recipe_scanner import RecipeScanner
|
from py.services.recipe_scanner import RecipeScanner
|
||||||
|
from py.services import settings_manager as settings_manager_module
|
||||||
from py.utils.utils import calculate_recipe_fingerprint
|
from py.utils.utils import calculate_recipe_fingerprint
|
||||||
|
|
||||||
|
|
||||||
@@ -72,12 +73,56 @@ class StubLoraScanner:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def recipe_scanner(tmp_path: Path, monkeypatch):
|
def recipe_scanner(tmp_path: Path, monkeypatch):
|
||||||
RecipeScanner._instance = None
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
|
||||||
stub = StubLoraScanner()
|
stub = StubLoraScanner()
|
||||||
scanner = RecipeScanner(lora_scanner=stub)
|
scanner = RecipeScanner(lora_scanner=stub)
|
||||||
asyncio.run(scanner.refresh_cache(force=True))
|
asyncio.run(scanner.refresh_cache(force=True))
|
||||||
yield scanner, stub
|
yield scanner, stub
|
||||||
RecipeScanner._instance = None
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
custom_recipes = tmp_path / "custom" / ".." / "custom_recipes"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.settings_manager.ensure_settings_file",
|
||||||
|
lambda logger=None: str(settings_path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "loras-root")])
|
||||||
|
|
||||||
|
manager = settings_manager_module.get_settings_manager()
|
||||||
|
manager.set("recipes_path", str(custom_recipes))
|
||||||
|
|
||||||
|
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
|
||||||
|
resolved = scanner.recipes_dir
|
||||||
|
|
||||||
|
assert resolved == str((tmp_path / "custom_recipes").resolve())
|
||||||
|
assert Path(resolved).is_dir()
|
||||||
|
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipes_dir_falls_back_to_first_lora_root(tmp_path: Path, monkeypatch):
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "loras_roots", [str(tmp_path / "alpha")])
|
||||||
|
|
||||||
|
scanner = RecipeScanner(lora_scanner=StubLoraScanner())
|
||||||
|
resolved = scanner.recipes_dir
|
||||||
|
|
||||||
|
assert resolved == str(tmp_path / "alpha" / "recipes")
|
||||||
|
assert Path(resolved).is_dir()
|
||||||
|
|
||||||
|
RecipeScanner._instance = None
|
||||||
|
settings_manager_module.reset_settings_manager()
|
||||||
|
|
||||||
|
|
||||||
async def test_add_recipe_during_concurrent_reads(recipe_scanner):
|
async def test_add_recipe_during_concurrent_reads(recipe_scanner):
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ def test_migrate_sanitizes_legacy_libraries(tmp_path, monkeypatch):
|
|||||||
assert payload["default_lora_root"] == ""
|
assert payload["default_lora_root"] == ""
|
||||||
assert payload["default_checkpoint_root"] == ""
|
assert payload["default_checkpoint_root"] == ""
|
||||||
assert payload["default_embedding_root"] == ""
|
assert payload["default_embedding_root"] == ""
|
||||||
|
assert payload["recipes_path"] == ""
|
||||||
assert manager.get_active_library_name() == "legacy"
|
assert manager.get_active_library_name() == "legacy"
|
||||||
|
|
||||||
|
|
||||||
@@ -507,12 +508,14 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
|
|||||||
"default_lora_root": "/loras",
|
"default_lora_root": "/loras",
|
||||||
"default_checkpoint_root": "/ckpt",
|
"default_checkpoint_root": "/ckpt",
|
||||||
"default_embedding_root": "/embed",
|
"default_embedding_root": "/embed",
|
||||||
|
"recipes_path": "/loras/recipes",
|
||||||
},
|
},
|
||||||
"studio": {
|
"studio": {
|
||||||
"folder_paths": {"loras": ["/studio"]},
|
"folder_paths": {"loras": ["/studio"]},
|
||||||
"default_lora_root": "/studio",
|
"default_lora_root": "/studio",
|
||||||
"default_checkpoint_root": "/studio_ckpt",
|
"default_checkpoint_root": "/studio_ckpt",
|
||||||
"default_embedding_root": "/studio_embed",
|
"default_embedding_root": "/studio_embed",
|
||||||
|
"recipes_path": "/studio/custom-recipes",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"active_library": "studio",
|
"active_library": "studio",
|
||||||
@@ -521,6 +524,7 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
|
|||||||
"default_lora_root": "/loras",
|
"default_lora_root": "/loras",
|
||||||
"default_checkpoint_root": "/ckpt",
|
"default_checkpoint_root": "/ckpt",
|
||||||
"default_embedding_root": "/embed",
|
"default_embedding_root": "/embed",
|
||||||
|
"recipes_path": "/loras/recipes",
|
||||||
}
|
}
|
||||||
|
|
||||||
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
||||||
@@ -530,14 +534,17 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
|
|||||||
assert manager.get("default_lora_root") == "/studio"
|
assert manager.get("default_lora_root") == "/studio"
|
||||||
assert manager.get("default_checkpoint_root") == "/studio_ckpt"
|
assert manager.get("default_checkpoint_root") == "/studio_ckpt"
|
||||||
assert manager.get("default_embedding_root") == "/studio_embed"
|
assert manager.get("default_embedding_root") == "/studio_embed"
|
||||||
|
assert manager.get("recipes_path") == "/studio/custom-recipes"
|
||||||
|
|
||||||
# Drift the top-level values again and ensure activate_library repairs them
|
# Drift the top-level values again and ensure activate_library repairs them
|
||||||
manager.settings["folder_paths"] = {"loras": ["/loras"]}
|
manager.settings["folder_paths"] = {"loras": ["/loras"]}
|
||||||
manager.settings["default_lora_root"] = "/loras"
|
manager.settings["default_lora_root"] = "/loras"
|
||||||
|
manager.settings["recipes_path"] = "/loras/recipes"
|
||||||
manager.activate_library("studio")
|
manager.activate_library("studio")
|
||||||
|
|
||||||
assert manager.get("folder_paths")["loras"] == ["/studio"]
|
assert manager.get("folder_paths")["loras"] == ["/studio"]
|
||||||
assert manager.get("default_lora_root") == "/studio"
|
assert manager.get("default_lora_root") == "/studio"
|
||||||
|
assert manager.get("recipes_path") == "/studio/custom-recipes"
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatch):
|
def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatch):
|
||||||
@@ -554,6 +561,7 @@ def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatc
|
|||||||
"default_lora_root": "",
|
"default_lora_root": "",
|
||||||
"default_checkpoint_root": "",
|
"default_checkpoint_root": "",
|
||||||
"default_embedding_root": "",
|
"default_embedding_root": "",
|
||||||
|
"recipes_path": "",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"active_library": "default",
|
"active_library": "default",
|
||||||
@@ -589,6 +597,177 @@ def test_upsert_library_creates_entry_and_activates(manager, tmp_path):
|
|||||||
assert str(lora_dir).replace(os.sep, "/") in normalized_stored_paths
|
assert str(lora_dir).replace(os.sep, "/") in normalized_stored_paths
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_recipes_path_updates_active_library_entry(manager, tmp_path):
|
||||||
|
recipes_dir = tmp_path / "custom" / "recipes"
|
||||||
|
|
||||||
|
manager.set("recipes_path", str(recipes_dir))
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == str(recipes_dir.resolve())
|
||||||
|
assert (
|
||||||
|
manager.get_libraries()["default"]["recipes_path"]
|
||||||
|
== str(recipes_dir.resolve())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_recipes_path_migrates_existing_recipe_files(manager, tmp_path):
|
||||||
|
lora_root = tmp_path / "loras"
|
||||||
|
old_recipes_dir = lora_root / "recipes" / "nested"
|
||||||
|
old_recipes_dir.mkdir(parents=True)
|
||||||
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||||
|
|
||||||
|
recipe_id = "recipe-1"
|
||||||
|
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
old_image_path.write_bytes(b"image-bytes")
|
||||||
|
old_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(old_image_path),
|
||||||
|
"title": "Recipe 1",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_recipes_dir = tmp_path / "custom_recipes"
|
||||||
|
manager.set("recipes_path", str(new_recipes_dir))
|
||||||
|
|
||||||
|
migrated_image_path = new_recipes_dir / "nested" / f"{recipe_id}.webp"
|
||||||
|
migrated_json_path = new_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == str(new_recipes_dir.resolve())
|
||||||
|
assert migrated_image_path.read_bytes() == b"image-bytes"
|
||||||
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||||
|
assert not old_image_path.exists()
|
||||||
|
assert not old_json_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_recipes_path_migrates_files_to_default_location(manager, tmp_path):
|
||||||
|
lora_root = tmp_path / "loras"
|
||||||
|
custom_recipes_dir = tmp_path / "custom_recipes"
|
||||||
|
old_recipes_dir = custom_recipes_dir / "nested"
|
||||||
|
old_recipes_dir.mkdir(parents=True)
|
||||||
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||||
|
manager.settings["recipes_path"] = str(custom_recipes_dir)
|
||||||
|
|
||||||
|
recipe_id = "recipe-2"
|
||||||
|
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
old_image_path.write_bytes(b"image-bytes")
|
||||||
|
old_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(old_image_path),
|
||||||
|
"title": "Recipe 2",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.set("recipes_path", "")
|
||||||
|
|
||||||
|
fallback_recipes_dir = lora_root / "recipes"
|
||||||
|
migrated_image_path = fallback_recipes_dir / "nested" / f"{recipe_id}.webp"
|
||||||
|
migrated_json_path = fallback_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == ""
|
||||||
|
assert migrated_image_path.read_bytes() == b"image-bytes"
|
||||||
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||||
|
assert not old_image_path.exists()
|
||||||
|
assert not old_json_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_recipes_path_back_to_parent_directory_is_allowed(manager, tmp_path):
|
||||||
|
lora_root = tmp_path / "loras"
|
||||||
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||||
|
|
||||||
|
source_recipes_dir = lora_root / "recipes" / "custom"
|
||||||
|
source_recipes_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
recipe_id = "recipe-parent"
|
||||||
|
old_image_path = source_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
old_json_path = source_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
old_image_path.write_bytes(b"parent-bytes")
|
||||||
|
old_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(old_image_path),
|
||||||
|
"title": "Recipe Parent",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.settings["recipes_path"] = str(source_recipes_dir)
|
||||||
|
manager.set("recipes_path", str(lora_root / "recipes"))
|
||||||
|
|
||||||
|
migrated_image_path = lora_root / "recipes" / f"{recipe_id}.webp"
|
||||||
|
migrated_json_path = lora_root / "recipes" / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == str((lora_root / "recipes").resolve())
|
||||||
|
assert migrated_image_path.read_bytes() == b"parent-bytes"
|
||||||
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||||
|
assert not old_image_path.exists()
|
||||||
|
assert not old_json_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_recipes_path_rewrites_symlinked_recipe_metadata(manager, tmp_path):
|
||||||
|
real_recipes_dir = tmp_path / "real_recipes"
|
||||||
|
real_recipes_dir.mkdir()
|
||||||
|
symlink_recipes_dir = tmp_path / "linked_recipes"
|
||||||
|
symlink_recipes_dir.symlink_to(real_recipes_dir, target_is_directory=True)
|
||||||
|
|
||||||
|
manager.settings["recipes_path"] = str(symlink_recipes_dir)
|
||||||
|
manager.set("folder_paths", {"loras": [str(tmp_path / "loras")]})
|
||||||
|
|
||||||
|
recipe_id = "recipe-symlink"
|
||||||
|
old_image_path = real_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
old_json_path = real_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
old_image_path.write_bytes(b"symlink-bytes")
|
||||||
|
old_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(old_image_path),
|
||||||
|
"title": "Recipe Symlink",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_recipes_dir = tmp_path / "migrated_recipes"
|
||||||
|
manager.set("recipes_path", str(new_recipes_dir))
|
||||||
|
|
||||||
|
migrated_image_path = new_recipes_dir / f"{recipe_id}.webp"
|
||||||
|
migrated_json_path = new_recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
assert migrated_image_path.read_bytes() == b"symlink-bytes"
|
||||||
|
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated_payload["file_path"] == str(migrated_image_path)
|
||||||
|
assert not old_image_path.exists()
|
||||||
|
assert not old_json_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_recipes_path_rejects_file_target(manager, tmp_path):
|
||||||
|
lora_root = tmp_path / "loras"
|
||||||
|
lora_root.mkdir()
|
||||||
|
manager.set("folder_paths", {"loras": [str(lora_root)]})
|
||||||
|
|
||||||
|
target_file = tmp_path / "not_a_directory"
|
||||||
|
target_file.write_text("blocked", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="directory"):
|
||||||
|
manager.set("recipes_path", str(target_file))
|
||||||
|
|
||||||
|
assert manager.get("recipes_path") == ""
|
||||||
|
|
||||||
|
|
||||||
def test_extra_folder_paths_stored_separately(manager, tmp_path):
|
def test_extra_folder_paths_stored_separately(manager, tmp_path):
|
||||||
lora_dir = tmp_path / "loras"
|
lora_dir = tmp_path / "loras"
|
||||||
extra_dir = tmp_path / "extra_loras"
|
extra_dir = tmp_path / "extra_loras"
|
||||||
@@ -829,3 +1008,14 @@ def test_setting_download_skip_base_models_normalizes_string_input(manager):
|
|||||||
manager.set("download_skip_base_models", "SDXL 1.0, Pony; Invalid\nSDXL 1.0")
|
manager.set("download_skip_base_models", "SDXL 1.0, Pony; Invalid\nSDXL 1.0")
|
||||||
|
|
||||||
assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"]
|
assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_previously_downloaded_model_versions_defaults_false(manager):
|
||||||
|
assert manager.get_skip_previously_downloaded_model_versions() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_previously_downloaded_model_versions_coerces_string_input(manager):
|
||||||
|
manager.settings["skip_previously_downloaded_model_versions"] = "true"
|
||||||
|
|
||||||
|
assert manager.get_skip_previously_downloaded_model_versions() is True
|
||||||
|
assert manager.settings["skip_previously_downloaded_model_versions"] is True
|
||||||
|
|||||||
144
tests/utils/test_civitai_utils_rewrite.py
Normal file
144
tests/utils/test_civitai_utils_rewrite.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Tests for CivitAI URL utilities."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.utils.civitai_utils import rewrite_preview_url
|
||||||
|
|
||||||
|
|
||||||
|
class TestRewritePreviewUrl:
|
||||||
|
"""Test cases for rewrite_preview_url function."""
|
||||||
|
|
||||||
|
def test_handles_none_input(self):
|
||||||
|
"""Should return (None, False) for None input."""
|
||||||
|
result, was_rewritten = rewrite_preview_url(None)
|
||||||
|
assert result is None
|
||||||
|
assert was_rewritten is False
|
||||||
|
|
||||||
|
def test_handles_empty_string(self):
|
||||||
|
"""Should return (empty_string, False) for empty input."""
|
||||||
|
result, was_rewritten = rewrite_preview_url("")
|
||||||
|
assert result == ""
|
||||||
|
assert was_rewritten is False
|
||||||
|
|
||||||
|
def test_handles_invalid_url(self):
|
||||||
|
"""Should return original URL and False for invalid URLs."""
|
||||||
|
invalid_url = "not-a-valid-url"
|
||||||
|
result, was_rewritten = rewrite_preview_url(invalid_url)
|
||||||
|
assert result == invalid_url
|
||||||
|
assert was_rewritten is False
|
||||||
|
|
||||||
|
def test_handles_url_without_scheme(self):
|
||||||
|
"""Should return original URL and False for URLs without scheme."""
|
||||||
|
url = "image.civitai.com/something"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url)
|
||||||
|
assert result == url
|
||||||
|
assert was_rewritten is False
|
||||||
|
|
||||||
|
def test_returns_false_for_non_civitai_domains(self):
|
||||||
|
"""Should not rewrite URLs from other domains."""
|
||||||
|
url = "https://example.com/image.jpg"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url)
|
||||||
|
assert result == url
|
||||||
|
assert was_rewritten is False
|
||||||
|
|
||||||
|
def test_returns_false_for_main_civitai_domain(self):
|
||||||
|
"""Should not rewrite URLs from main civitai.com domain."""
|
||||||
|
url = "https://civitai.com/images/123"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url)
|
||||||
|
assert result == url
|
||||||
|
assert was_rewritten is False
|
||||||
|
|
||||||
|
def test_rewrites_image_civitai_com_urls(self):
|
||||||
|
"""Should rewrite URLs from image.civitai.com."""
|
||||||
|
url = "https://image.civitai.com/checkpoints/original=true"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||||
|
assert (
|
||||||
|
result == "https://image.civitai.com/checkpoints/width=450,optimized=true"
|
||||||
|
)
|
||||||
|
assert was_rewritten is True
|
||||||
|
|
||||||
|
def test_rewrites_subdomain_civitai_urls(self):
|
||||||
|
"""Should rewrite URLs from CivitAI CDN subdomains like image-b2.civitai.com."""
|
||||||
|
url = "https://image-b2.civitai.com/file/civitai-media-cache/original=true/sample.png"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||||
|
assert (
|
||||||
|
result
|
||||||
|
== "https://image-b2.civitai.com/file/civitai-media-cache/width=450,optimized=true/sample.png"
|
||||||
|
)
|
||||||
|
assert was_rewritten is True
|
||||||
|
|
||||||
|
def test_rewrites_multiple_subdomains(self):
|
||||||
|
"""Should rewrite URLs from various CivitAI subdomains."""
|
||||||
|
test_cases = [
|
||||||
|
"https://image-b3.civitai.com/original=true/test.jpg",
|
||||||
|
"https://cdn.civitai.com/original=true/test.png",
|
||||||
|
]
|
||||||
|
for url in test_cases:
|
||||||
|
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||||
|
assert was_rewritten is True
|
||||||
|
assert "width=450,optimized=true" in result
|
||||||
|
|
||||||
|
def test_handles_urls_with_explicit_port(self):
|
||||||
|
"""Should correctly handle URLs with explicit port numbers."""
|
||||||
|
url = "https://image.civitai.com:443/checkpoints/original=true"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||||
|
assert was_rewritten is True
|
||||||
|
assert "width=450,optimized=true" in result
|
||||||
|
# Port is preserved in the URL (this is acceptable behavior)
|
||||||
|
assert ":443" in result
|
||||||
|
|
||||||
|
def test_rewrites_video_urls_with_transcode(self):
|
||||||
|
"""Should rewrite video URLs with transcode parameter."""
|
||||||
|
url = "https://image.civitai.com/videos/original=true/sample.mp4"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url, "video")
|
||||||
|
assert (
|
||||||
|
result
|
||||||
|
== "https://image.civitai.com/videos/transcode=true,width=450,optimized=true/sample.mp4"
|
||||||
|
)
|
||||||
|
assert was_rewritten is True
|
||||||
|
|
||||||
|
def test_video_rewrite_uses_case_insensitive_type(self):
|
||||||
|
"""Should handle video type case-insensitively."""
|
||||||
|
url = "https://image.civitai.com/original=true/test.mp4"
|
||||||
|
result1, was1 = rewrite_preview_url(url, "VIDEO")
|
||||||
|
result2, was2 = rewrite_preview_url(url, "Video")
|
||||||
|
assert was1 is True
|
||||||
|
assert was2 is True
|
||||||
|
assert "transcode=true" in result1
|
||||||
|
assert "transcode=true" in result2
|
||||||
|
|
||||||
|
def test_returns_original_when_no_original_true_in_path(self):
|
||||||
|
"""Should not rewrite URLs that don't contain /original=true."""
|
||||||
|
url = "https://image.civitai.com/checkpoints/optimized=true"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url)
|
||||||
|
assert result == url
|
||||||
|
assert was_rewritten is False
|
||||||
|
|
||||||
|
def test_preserves_path_structure_after_rewrite(self):
|
||||||
|
"""Should maintain path structure after rewriting."""
|
||||||
|
url = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.png"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||||
|
assert was_rewritten is True
|
||||||
|
assert result.startswith(
|
||||||
|
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/"
|
||||||
|
)
|
||||||
|
assert result.endswith("/12345.png")
|
||||||
|
|
||||||
|
def test_defaults_to_image_mode_when_media_type_is_none(self):
|
||||||
|
"""Should use image optimization when media_type is None."""
|
||||||
|
url = "https://image.civitai.com/original=true/test.png"
|
||||||
|
result, was_rewritten = rewrite_preview_url(url, None)
|
||||||
|
assert was_rewritten is True
|
||||||
|
assert "transcode=true" not in result
|
||||||
|
assert "width=450,optimized=true" in result
|
||||||
|
|
||||||
|
def test_case_insensitive_hostname_matching(self):
|
||||||
|
"""Should handle case-insensitive hostname matching."""
|
||||||
|
test_cases = [
|
||||||
|
"https://IMAGE.CIVITAI.COM/original=true/test.png",
|
||||||
|
"https://Image.Civitai.Com/original=true/test.png",
|
||||||
|
"https://image-b2.CIVITAI.com/original=true/test.png",
|
||||||
|
]
|
||||||
|
for url in test_cases:
|
||||||
|
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||||
|
assert was_rewritten is True, f"Failed for URL: {url}"
|
||||||
@@ -152,3 +152,67 @@ async def test_usage_stats_background_processor_handles_pending_prompts(tmp_path
|
|||||||
assert stats.stats["loras"]["lora-hash"]["history"][today] == 1
|
assert stats.stats["loras"]["lora-hash"]["history"][today] == 1
|
||||||
|
|
||||||
await _finalize_usage_stats(tasks)
|
await _finalize_usage_stats(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_usage_stats_calculates_pending_checkpoint_hash_on_demand(tmp_path, monkeypatch):
|
||||||
|
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
metadata_payload = {
|
||||||
|
"models": {
|
||||||
|
"1": {"type": "checkpoint", "name": "pending_model.safetensors"},
|
||||||
|
},
|
||||||
|
"loras": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpoint_cache = SimpleNamespace(
|
||||||
|
raw_data=[
|
||||||
|
{
|
||||||
|
"file_name": "pending_model",
|
||||||
|
"model_name": "pending_model",
|
||||||
|
"file_path": "/models/pending_model.safetensors",
|
||||||
|
"sha256": "",
|
||||||
|
"hash_status": "pending",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
checkpoint_scanner = SimpleNamespace(
|
||||||
|
get_hash_by_filename=lambda name: None,
|
||||||
|
get_cached_data=AsyncMock(return_value=checkpoint_cache),
|
||||||
|
calculate_hash_for_model=AsyncMock(return_value="resolved-hash"),
|
||||||
|
)
|
||||||
|
lora_scanner = SimpleNamespace(get_hash_by_filename=lambda name: None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=checkpoint_scanner))
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner))
|
||||||
|
|
||||||
|
await stats._process_metadata(metadata_payload)
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
checkpoint_scanner.calculate_hash_for_model.assert_awaited_once_with("/models/pending_model.safetensors")
|
||||||
|
assert stats.stats["checkpoints"]["resolved-hash"]["history"][today] == 1
|
||||||
|
|
||||||
|
await _finalize_usage_stats(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_usage_stats_skips_name_fallback_for_missing_lora_hash(tmp_path, monkeypatch):
|
||||||
|
stats, tasks, _ = _prepare_usage_stats(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
metadata_payload = {
|
||||||
|
"models": {},
|
||||||
|
"loras": {
|
||||||
|
"2": {"lora_list": [{"name": "missing_lora"}]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpoint_scanner = SimpleNamespace(get_hash_by_filename=lambda name: None)
|
||||||
|
lora_scanner = SimpleNamespace(get_hash_by_filename=lambda name: None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", AsyncMock(return_value=checkpoint_scanner))
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner))
|
||||||
|
|
||||||
|
await stats._process_metadata(metadata_payload)
|
||||||
|
|
||||||
|
assert stats.stats["loras"] == {}
|
||||||
|
assert not any(key.startswith("name:") for key in stats.stats["loras"])
|
||||||
|
|
||||||
|
await _finalize_usage_stats(tasks)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { LoraPoolConfig, RandomizerConfig, CyclerConfig } from './composabl
|
|||||||
import {
|
import {
|
||||||
setupModeChangeHandler,
|
setupModeChangeHandler,
|
||||||
createModeChangeCallback,
|
createModeChangeCallback,
|
||||||
LORA_PROVIDER_NODE_TYPES
|
LORA_CHAIN_NODE_TYPES
|
||||||
} from './mode-change-handler'
|
} from './mode-change-handler'
|
||||||
|
|
||||||
const LORA_POOL_WIDGET_MIN_WIDTH = 500
|
const LORA_POOL_WIDGET_MIN_WIDTH = 500
|
||||||
@@ -755,8 +755,8 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register mode change handlers for LoRA provider nodes
|
// Register mode change handlers for LORA_STACK chain nodes
|
||||||
if (LORA_PROVIDER_NODE_TYPES.includes(comfyClass)) {
|
if (LORA_CHAIN_NODE_TYPES.includes(comfyClass)) {
|
||||||
const originalOnNodeCreated = nodeType.prototype.onNodeCreated
|
const originalOnNodeCreated = nodeType.prototype.onNodeCreated
|
||||||
|
|
||||||
nodeType.prototype.onNodeCreated = function () {
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
|
|||||||
@@ -18,7 +18,22 @@ export const LORA_PROVIDER_NODE_TYPES = [
|
|||||||
"Lora Cycler (LoraManager)",
|
"Lora Cycler (LoraManager)",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nodes that do not own LoRA state themselves, but merge or forward LORA_STACK
|
||||||
|
* inputs so downstream trigger-word updates must traverse through them.
|
||||||
|
*/
|
||||||
|
export const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
||||||
|
"Lora Stack Combiner (LoraManager)",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const LORA_CHAIN_NODE_TYPES = [
|
||||||
|
...LORA_PROVIDER_NODE_TYPES,
|
||||||
|
...LORA_STACK_AGGREGATOR_NODE_TYPES,
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type LoraProviderNodeType = typeof LORA_PROVIDER_NODE_TYPES[number];
|
export type LoraProviderNodeType = typeof LORA_PROVIDER_NODE_TYPES[number];
|
||||||
|
export type LoraStackAggregatorNodeType = typeof LORA_STACK_AGGREGATOR_NODE_TYPES[number];
|
||||||
|
export type LoraChainNodeType = typeof LORA_CHAIN_NODE_TYPES[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a node class is a LoRA provider node.
|
* Check if a node class is a LoRA provider node.
|
||||||
@@ -27,6 +42,16 @@ export function isLoraProviderNode(comfyClass: string): comfyClass is LoraProvid
|
|||||||
return LORA_PROVIDER_NODE_TYPES.includes(comfyClass as LoraProviderNodeType);
|
return LORA_PROVIDER_NODE_TYPES.includes(comfyClass as LoraProviderNodeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLoraStackAggregatorNode(
|
||||||
|
comfyClass: string
|
||||||
|
): comfyClass is LoraStackAggregatorNodeType {
|
||||||
|
return LORA_STACK_AGGREGATOR_NODE_TYPES.includes(comfyClass as LoraStackAggregatorNodeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoraChainNode(comfyClass: string): comfyClass is LoraChainNodeType {
|
||||||
|
return LORA_CHAIN_NODE_TYPES.includes(comfyClass as LoraChainNodeType);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract active LoRA filenames from a node based on its type.
|
* Extract active LoRA filenames from a node based on its type.
|
||||||
*
|
*
|
||||||
@@ -40,6 +65,10 @@ export function getActiveLorasFromNodeByType(node: any): Set<string> {
|
|||||||
return extractFromCyclerConfig(node);
|
return extractFromCyclerConfig(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoraStackAggregatorNode(comfyClass)) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
// Default: use lorasWidget (works for Stacker and Randomizer)
|
// Default: use lorasWidget (works for Stacker and Randomizer)
|
||||||
return extractFromLorasWidget(node);
|
return extractFromLorasWidget(node);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { app } from "../../scripts/app.js";
|
|||||||
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
|
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
|
||||||
import {
|
import {
|
||||||
getAutocompleteAppendCommaPreference,
|
getAutocompleteAppendCommaPreference,
|
||||||
|
getAutocompleteAutoFormatPreference,
|
||||||
getAutocompleteAcceptKeyPreference,
|
getAutocompleteAcceptKeyPreference,
|
||||||
getPromptTagAutocompletePreference,
|
getPromptTagAutocompletePreference,
|
||||||
getTagSpaceReplacementPreference,
|
getTagSpaceReplacementPreference,
|
||||||
@@ -122,6 +123,32 @@ function formatAutocompleteInsertion(text = '') {
|
|||||||
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
|
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAutocompleteSegment(segment = '') {
|
||||||
|
return segment.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAutocompleteTextOnBlur(text = '') {
|
||||||
|
if (typeof text !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
if (!line.trim()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedSegments = line
|
||||||
|
.split(',')
|
||||||
|
.map(normalizeAutocompleteSegment)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return cleanedSegments.join(', ');
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAcceptAutocompleteKey(key) {
|
function shouldAcceptAutocompleteKey(key) {
|
||||||
const mode = getAutocompleteAcceptKeyPreference();
|
const mode = getAutocompleteAcceptKeyPreference();
|
||||||
|
|
||||||
@@ -481,6 +508,14 @@ class AutoComplete {
|
|||||||
|
|
||||||
// Handle focus out to hide dropdown
|
// Handle focus out to hide dropdown
|
||||||
this.onBlur = () => {
|
this.onBlur = () => {
|
||||||
|
if (getAutocompleteAutoFormatPreference()) {
|
||||||
|
const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value);
|
||||||
|
if (formattedValue !== this.inputElement.value) {
|
||||||
|
this.inputElement.value = formattedValue;
|
||||||
|
this.inputElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delay hiding to allow for clicks on dropdown items
|
// Delay hiding to allow for clicks on dropdown items
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.hide();
|
this.hide();
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const PROMPT_TAG_AUTOCOMPLETE_DEFAULT = true;
|
|||||||
const AUTOCOMPLETE_APPEND_COMMA_SETTING_ID = "loramanager.autocomplete_append_comma";
|
const AUTOCOMPLETE_APPEND_COMMA_SETTING_ID = "loramanager.autocomplete_append_comma";
|
||||||
const AUTOCOMPLETE_APPEND_COMMA_DEFAULT = true;
|
const AUTOCOMPLETE_APPEND_COMMA_DEFAULT = true;
|
||||||
|
|
||||||
|
const AUTOCOMPLETE_AUTO_FORMAT_SETTING_ID = "loramanager.autocomplete_auto_format";
|
||||||
|
const AUTOCOMPLETE_AUTO_FORMAT_DEFAULT = true;
|
||||||
|
|
||||||
const AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID = "loramanager.autocomplete_accept_key";
|
const AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID = "loramanager.autocomplete_accept_key";
|
||||||
const AUTOCOMPLETE_ACCEPT_KEY_DEFAULT = "both";
|
const AUTOCOMPLETE_ACCEPT_KEY_DEFAULT = "both";
|
||||||
const AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH = "Tab or Enter";
|
const AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH = "Tab or Enter";
|
||||||
@@ -192,6 +195,32 @@ const getAutocompleteAppendCommaPreference = (() => {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const getAutocompleteAutoFormatPreference = (() => {
|
||||||
|
let settingsUnavailableLogged = false;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const settingManager = app?.extensionManager?.setting;
|
||||||
|
if (!settingManager || typeof settingManager.get !== "function") {
|
||||||
|
if (!settingsUnavailableLogged) {
|
||||||
|
console.warn("LoRA Manager: settings API unavailable, using default autocomplete auto format setting.");
|
||||||
|
settingsUnavailableLogged = true;
|
||||||
|
}
|
||||||
|
return AUTOCOMPLETE_AUTO_FORMAT_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = settingManager.get(AUTOCOMPLETE_AUTO_FORMAT_SETTING_ID);
|
||||||
|
return value ?? AUTOCOMPLETE_AUTO_FORMAT_DEFAULT;
|
||||||
|
} catch (error) {
|
||||||
|
if (!settingsUnavailableLogged) {
|
||||||
|
console.warn("LoRA Manager: unable to read autocomplete auto format setting, using default.", error);
|
||||||
|
settingsUnavailableLogged = true;
|
||||||
|
}
|
||||||
|
return AUTOCOMPLETE_AUTO_FORMAT_DEFAULT;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
const getAutocompleteAcceptKeyPreference = (() => {
|
const getAutocompleteAcceptKeyPreference = (() => {
|
||||||
let settingsUnavailableLogged = false;
|
let settingsUnavailableLogged = false;
|
||||||
|
|
||||||
@@ -375,6 +404,14 @@ app.registerExtension({
|
|||||||
tooltip: "When enabled, accepted autocomplete suggestions append ', ' to the inserted text.",
|
tooltip: "When enabled, accepted autocomplete suggestions append ', ' to the inserted text.",
|
||||||
category: ["LoRA Manager", "Autocomplete", "Append comma"],
|
category: ["LoRA Manager", "Autocomplete", "Append comma"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: AUTOCOMPLETE_AUTO_FORMAT_SETTING_ID,
|
||||||
|
name: "Auto format autocomplete text on blur",
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: AUTOCOMPLETE_AUTO_FORMAT_DEFAULT,
|
||||||
|
tooltip: "When enabled, leaving an autocomplete textarea removes duplicate commas and collapses unnecessary spaces.",
|
||||||
|
category: ["LoRA Manager", "Autocomplete", "Auto Format"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID,
|
id: AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID,
|
||||||
name: "Autocomplete accept key",
|
name: "Autocomplete accept key",
|
||||||
@@ -505,6 +542,7 @@ export {
|
|||||||
getWheelSensitivity,
|
getWheelSensitivity,
|
||||||
getAutoPathCorrectionPreference,
|
getAutoPathCorrectionPreference,
|
||||||
getAutocompleteAppendCommaPreference,
|
getAutocompleteAppendCommaPreference,
|
||||||
|
getAutocompleteAutoFormatPreference,
|
||||||
getAutocompleteAcceptKeyPreference,
|
getAutocompleteAcceptKeyPreference,
|
||||||
getPromptTagAutocompletePreference,
|
getPromptTagAutocompletePreference,
|
||||||
getTagSpaceReplacementPreference,
|
getTagSpaceReplacementPreference,
|
||||||
|
|||||||
@@ -10,10 +10,27 @@ export const LORA_PROVIDER_NODE_TYPES = [
|
|||||||
"Lora Cycler (LoraManager)",
|
"Lora Cycler (LoraManager)",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
||||||
|
"Lora Stack Combiner (LoraManager)",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LORA_CHAIN_NODE_TYPES = [
|
||||||
|
...LORA_PROVIDER_NODE_TYPES,
|
||||||
|
...LORA_STACK_AGGREGATOR_NODE_TYPES,
|
||||||
|
];
|
||||||
|
|
||||||
export function isLoraProviderNode(comfyClass) {
|
export function isLoraProviderNode(comfyClass) {
|
||||||
return LORA_PROVIDER_NODE_TYPES.includes(comfyClass);
|
return LORA_PROVIDER_NODE_TYPES.includes(comfyClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLoraStackAggregatorNode(comfyClass) {
|
||||||
|
return LORA_STACK_AGGREGATOR_NODE_TYPES.includes(comfyClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoraChainNode(comfyClass) {
|
||||||
|
return LORA_CHAIN_NODE_TYPES.includes(comfyClass);
|
||||||
|
}
|
||||||
|
|
||||||
function isMapLike(collection) {
|
function isMapLike(collection) {
|
||||||
return collection && typeof collection.entries === "function" && typeof collection.values === "function";
|
return collection && typeof collection.entries === "function" && typeof collection.values === "function";
|
||||||
}
|
}
|
||||||
@@ -245,16 +262,20 @@ export function hideWidgetForGood(node, widget, suffix = "") {
|
|||||||
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
|
// Update pattern to match both formats: <lora:name:model_strength> or <lora:name:model_strength:clip_strength>
|
||||||
export const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\d\.]+))?>/g;
|
export const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)(?::([-\d\.]+))?>/g;
|
||||||
|
|
||||||
// Get connected Lora Stacker nodes that feed into the current node
|
function isLoraStackInput(input) {
|
||||||
export function getConnectedInputStackers(node) {
|
return input?.type === "LORA_STACK";
|
||||||
const connectedStackers = [];
|
}
|
||||||
|
|
||||||
|
// Get connected LORA_STACK chain nodes that feed into the current node
|
||||||
|
export function getConnectedInputLoraChainNodes(node) {
|
||||||
|
const connectedNodes = [];
|
||||||
|
|
||||||
if (!node?.inputs) {
|
if (!node?.inputs) {
|
||||||
return connectedStackers;
|
return connectedNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const input of node.inputs) {
|
for (const input of node.inputs) {
|
||||||
if (input.name !== "lora_stack" || !input.link) {
|
if (!isLoraStackInput(input) || !input.link) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,12 +285,12 @@ export function getConnectedInputStackers(node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sourceNode = node.graph?.getNodeById?.(link.origin_id);
|
const sourceNode = node.graph?.getNodeById?.(link.origin_id);
|
||||||
if (sourceNode && isLoraProviderNode(sourceNode.comfyClass)) {
|
if (sourceNode && isLoraChainNode(sourceNode.comfyClass)) {
|
||||||
connectedStackers.push(sourceNode);
|
connectedNodes.push(sourceNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connectedStackers;
|
return connectedNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get connected TriggerWord Toggle nodes that receive output from the current node
|
// Get connected TriggerWord Toggle nodes that receive output from the current node
|
||||||
@@ -314,6 +335,11 @@ export function getActiveLorasFromNode(node) {
|
|||||||
return activeLoraNames;
|
return activeLoraNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aggregator nodes do not own LoRA state directly; they only forward upstream stacks.
|
||||||
|
if (isLoraStackAggregatorNode(node.comfyClass)) {
|
||||||
|
return activeLoraNames;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Lora Stacker and Lora Randomizer (lorasWidget)
|
// Handle Lora Stacker and Lora Randomizer (lorasWidget)
|
||||||
let lorasWidget = node.lorasWidget;
|
let lorasWidget = node.lorasWidget;
|
||||||
if (!lorasWidget && node.widgets) {
|
if (!lorasWidget && node.widgets) {
|
||||||
@@ -348,14 +374,18 @@ export function collectActiveLorasFromChain(node, visited = new Set()) {
|
|||||||
// Mode 2 is Never, Mode 4 is Bypass
|
// Mode 2 is Never, Mode 4 is Bypass
|
||||||
const isNodeActive = node.mode === undefined || node.mode === 0 || node.mode === 3;
|
const isNodeActive = node.mode === undefined || node.mode === 0 || node.mode === 3;
|
||||||
|
|
||||||
// Get active loras from current node only if node is active
|
if (!isNodeActive) {
|
||||||
const allActiveLoraNames = isNodeActive ? getActiveLorasFromNode(node) : new Set();
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
// Get connected input stackers and collect their active loras
|
// Get active loras from current node only if node is active
|
||||||
const inputStackers = getConnectedInputStackers(node);
|
const allActiveLoraNames = getActiveLorasFromNode(node);
|
||||||
for (const stacker of inputStackers) {
|
|
||||||
const stackerLoras = collectActiveLorasFromChain(stacker, visited);
|
// Get connected input LORA_STACK chain nodes and collect their active loras
|
||||||
stackerLoras.forEach(name => allActiveLoraNames.add(name));
|
const inputChainNodes = getConnectedInputLoraChainNodes(node);
|
||||||
|
for (const chainNode of inputChainNodes) {
|
||||||
|
const upstreamLoras = collectActiveLorasFromChain(chainNode, visited);
|
||||||
|
upstreamLoras.forEach(name => allActiveLoraNames.add(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
return allActiveLoraNames;
|
return allActiveLoraNames;
|
||||||
@@ -819,8 +849,8 @@ export function updateDownstreamLoaders(startNode, visited = new Set()) {
|
|||||||
collectActiveLorasFromChain(targetNode);
|
collectActiveLorasFromChain(targetNode);
|
||||||
updateConnectedTriggerWords(targetNode, allActiveLoraNames);
|
updateConnectedTriggerWords(targetNode, allActiveLoraNames);
|
||||||
}
|
}
|
||||||
// If target is another LoRA provider node, recursively check its outputs
|
// If target is another LORA_STACK chain node, recursively check its outputs
|
||||||
else if (targetNode && isLoraProviderNode(targetNode.comfyClass)) {
|
else if (targetNode && isLoraChainNode(targetNode.comfyClass)) {
|
||||||
updateDownstreamLoaders(targetNode, visited);
|
updateDownstreamLoaders(targetNode, visited);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14938,11 +14938,24 @@ const LORA_PROVIDER_NODE_TYPES$1 = [
|
|||||||
"Lora Randomizer (LoraManager)",
|
"Lora Randomizer (LoraManager)",
|
||||||
"Lora Cycler (LoraManager)"
|
"Lora Cycler (LoraManager)"
|
||||||
];
|
];
|
||||||
|
const LORA_STACK_AGGREGATOR_NODE_TYPES$1 = [
|
||||||
|
"Lora Stack Combiner (LoraManager)"
|
||||||
|
];
|
||||||
|
const LORA_CHAIN_NODE_TYPES$1 = [
|
||||||
|
...LORA_PROVIDER_NODE_TYPES$1,
|
||||||
|
...LORA_STACK_AGGREGATOR_NODE_TYPES$1
|
||||||
|
];
|
||||||
|
function isLoraStackAggregatorNode$1(comfyClass) {
|
||||||
|
return LORA_STACK_AGGREGATOR_NODE_TYPES$1.includes(comfyClass);
|
||||||
|
}
|
||||||
function getActiveLorasFromNodeByType(node) {
|
function getActiveLorasFromNodeByType(node) {
|
||||||
const comfyClass = node == null ? void 0 : node.comfyClass;
|
const comfyClass = node == null ? void 0 : node.comfyClass;
|
||||||
if (comfyClass === "Lora Cycler (LoraManager)") {
|
if (comfyClass === "Lora Cycler (LoraManager)") {
|
||||||
return extractFromCyclerConfig(node);
|
return extractFromCyclerConfig(node);
|
||||||
}
|
}
|
||||||
|
if (isLoraStackAggregatorNode$1(comfyClass)) {
|
||||||
|
return /* @__PURE__ */ new Set();
|
||||||
|
}
|
||||||
return extractFromLorasWidget(node);
|
return extractFromLorasWidget(node);
|
||||||
}
|
}
|
||||||
function extractFromLorasWidget(node) {
|
function extractFromLorasWidget(node) {
|
||||||
@@ -15002,8 +15015,18 @@ const LORA_PROVIDER_NODE_TYPES = [
|
|||||||
"Lora Randomizer (LoraManager)",
|
"Lora Randomizer (LoraManager)",
|
||||||
"Lora Cycler (LoraManager)"
|
"Lora Cycler (LoraManager)"
|
||||||
];
|
];
|
||||||
function isLoraProviderNode(comfyClass) {
|
const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
||||||
return LORA_PROVIDER_NODE_TYPES.includes(comfyClass);
|
"Lora Stack Combiner (LoraManager)"
|
||||||
|
];
|
||||||
|
const LORA_CHAIN_NODE_TYPES = [
|
||||||
|
...LORA_PROVIDER_NODE_TYPES,
|
||||||
|
...LORA_STACK_AGGREGATOR_NODE_TYPES
|
||||||
|
];
|
||||||
|
function isLoraStackAggregatorNode(comfyClass) {
|
||||||
|
return LORA_STACK_AGGREGATOR_NODE_TYPES.includes(comfyClass);
|
||||||
|
}
|
||||||
|
function isLoraChainNode(comfyClass) {
|
||||||
|
return LORA_CHAIN_NODE_TYPES.includes(comfyClass);
|
||||||
}
|
}
|
||||||
function isMapLike(collection) {
|
function isMapLike(collection) {
|
||||||
return collection && typeof collection.entries === "function" && typeof collection.values === "function";
|
return collection && typeof collection.entries === "function" && typeof collection.values === "function";
|
||||||
@@ -15041,14 +15064,17 @@ function getLinkFromGraph(graph, linkId) {
|
|||||||
}
|
}
|
||||||
return graph.links[linkId] || null;
|
return graph.links[linkId] || null;
|
||||||
}
|
}
|
||||||
function getConnectedInputStackers(node) {
|
function isLoraStackInput(input) {
|
||||||
|
return (input == null ? void 0 : input.type) === "LORA_STACK";
|
||||||
|
}
|
||||||
|
function getConnectedInputLoraChainNodes(node) {
|
||||||
var _a2, _b;
|
var _a2, _b;
|
||||||
const connectedStackers = [];
|
const connectedNodes = [];
|
||||||
if (!(node == null ? void 0 : node.inputs)) {
|
if (!(node == null ? void 0 : node.inputs)) {
|
||||||
return connectedStackers;
|
return connectedNodes;
|
||||||
}
|
}
|
||||||
for (const input of node.inputs) {
|
for (const input of node.inputs) {
|
||||||
if (input.name !== "lora_stack" || !input.link) {
|
if (!isLoraStackInput(input) || !input.link) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const link = getLinkFromGraph(node.graph, input.link);
|
const link = getLinkFromGraph(node.graph, input.link);
|
||||||
@@ -15056,11 +15082,11 @@ function getConnectedInputStackers(node) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const sourceNode = (_b = (_a2 = node.graph) == null ? void 0 : _a2.getNodeById) == null ? void 0 : _b.call(_a2, link.origin_id);
|
const sourceNode = (_b = (_a2 = node.graph) == null ? void 0 : _a2.getNodeById) == null ? void 0 : _b.call(_a2, link.origin_id);
|
||||||
if (sourceNode && isLoraProviderNode(sourceNode.comfyClass)) {
|
if (sourceNode && isLoraChainNode(sourceNode.comfyClass)) {
|
||||||
connectedStackers.push(sourceNode);
|
connectedNodes.push(sourceNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return connectedStackers;
|
return connectedNodes;
|
||||||
}
|
}
|
||||||
function getConnectedTriggerToggleNodes(node) {
|
function getConnectedTriggerToggleNodes(node) {
|
||||||
var _a2, _b, _c;
|
var _a2, _b, _c;
|
||||||
@@ -15095,6 +15121,9 @@ function getActiveLorasFromNode(node) {
|
|||||||
}
|
}
|
||||||
return activeLoraNames;
|
return activeLoraNames;
|
||||||
}
|
}
|
||||||
|
if (isLoraStackAggregatorNode(node.comfyClass)) {
|
||||||
|
return activeLoraNames;
|
||||||
|
}
|
||||||
let lorasWidget = node.lorasWidget;
|
let lorasWidget = node.lorasWidget;
|
||||||
if (!lorasWidget && node.widgets) {
|
if (!lorasWidget && node.widgets) {
|
||||||
lorasWidget = node.widgets.find((w2) => w2.name === "loras");
|
lorasWidget = node.widgets.find((w2) => w2.name === "loras");
|
||||||
@@ -15118,11 +15147,14 @@ function collectActiveLorasFromChain(node, visited = /* @__PURE__ */ new Set())
|
|||||||
}
|
}
|
||||||
visited.add(nodeKey);
|
visited.add(nodeKey);
|
||||||
const isNodeActive2 = node.mode === void 0 || node.mode === 0 || node.mode === 3;
|
const isNodeActive2 = node.mode === void 0 || node.mode === 0 || node.mode === 3;
|
||||||
const allActiveLoraNames = isNodeActive2 ? getActiveLorasFromNode(node) : /* @__PURE__ */ new Set();
|
if (!isNodeActive2) {
|
||||||
const inputStackers = getConnectedInputStackers(node);
|
return /* @__PURE__ */ new Set();
|
||||||
for (const stacker of inputStackers) {
|
}
|
||||||
const stackerLoras = collectActiveLorasFromChain(stacker, visited);
|
const allActiveLoraNames = getActiveLorasFromNode(node);
|
||||||
stackerLoras.forEach((name) => allActiveLoraNames.add(name));
|
const inputChainNodes = getConnectedInputLoraChainNodes(node);
|
||||||
|
for (const chainNode of inputChainNodes) {
|
||||||
|
const upstreamLoras = collectActiveLorasFromChain(chainNode, visited);
|
||||||
|
upstreamLoras.forEach((name) => allActiveLoraNames.add(name));
|
||||||
}
|
}
|
||||||
return allActiveLoraNames;
|
return allActiveLoraNames;
|
||||||
}
|
}
|
||||||
@@ -15191,7 +15223,7 @@ function updateDownstreamLoaders(startNode, visited = /* @__PURE__ */ new Set())
|
|||||||
if (targetNode && targetNode.comfyClass === "Lora Loader (LoraManager)") {
|
if (targetNode && targetNode.comfyClass === "Lora Loader (LoraManager)") {
|
||||||
const allActiveLoraNames = collectActiveLorasFromChain(targetNode);
|
const allActiveLoraNames = collectActiveLorasFromChain(targetNode);
|
||||||
updateConnectedTriggerWords(targetNode, allActiveLoraNames);
|
updateConnectedTriggerWords(targetNode, allActiveLoraNames);
|
||||||
} else if (targetNode && isLoraProviderNode(targetNode.comfyClass)) {
|
} else if (targetNode && isLoraChainNode(targetNode.comfyClass)) {
|
||||||
updateDownstreamLoaders(targetNode, visited);
|
updateDownstreamLoaders(targetNode, visited);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15784,7 +15816,7 @@ app$1.registerExtension({
|
|||||||
return originalConfigure == null ? void 0 : originalConfigure.apply(this, arguments);
|
return originalConfigure == null ? void 0 : originalConfigure.apply(this, arguments);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (LORA_PROVIDER_NODE_TYPES$1.includes(comfyClass)) {
|
if (LORA_CHAIN_NODE_TYPES$1.includes(comfyClass)) {
|
||||||
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
nodeType.prototype.onNodeCreated = function() {
|
nodeType.prototype.onNodeCreated = function() {
|
||||||
originalOnNodeCreated == null ? void 0 : originalOnNodeCreated.apply(this, arguments);
|
originalOnNodeCreated == null ? void 0 : originalOnNodeCreated.apply(this, arguments);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user