mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-09 09:56:44 -03:00
Compare commits
28 Commits
1817142a7b
...
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fd2b43d6 | ||
|
|
c53f44e7ef | ||
|
|
ae7bfdb517 | ||
|
|
68bf8442eb | ||
|
|
605fbf4117 | ||
|
|
406d5fea6a | ||
|
|
af2146f96c | ||
|
|
bdc8dec860 | ||
|
|
c4fa1631ee | ||
|
|
506d763dc2 | ||
|
|
a2cd09b619 | ||
|
|
cdd77029b6 | ||
|
|
439679e15f | ||
|
|
2640258902 | ||
|
|
b910388d54 | ||
|
|
083de395b1 | ||
|
|
4514ca94b7 | ||
|
|
62247bdd87 | ||
|
|
6d0d9600a7 | ||
|
|
70cd3f4e1b | ||
|
|
a95c518b30 | ||
|
|
ba1800095e | ||
|
|
39c083db79 | ||
|
|
55e9e4bb6f | ||
|
|
0253d001e6 | ||
|
|
9998da3241 | ||
|
|
6666a72775 | ||
|
|
5f1bd894b9 |
58
README.md
58
README.md
@@ -56,6 +56,27 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v1.0.5
|
||||||
|
|
||||||
|
* **Excluded Models Management View** - Added a new global-menu view for excluded models, with actions to restore them or delete them permanently.
|
||||||
|
* **Fix for `401 Unauthorized` Downloads** - Fixed an issue where some `civitai.red` downloads could lose authentication during redirect and fail with `401 Unauthorized`.
|
||||||
|
|
||||||
|
### v1.0.4
|
||||||
|
|
||||||
|
* **Civitai Domain Split Support** - Added support for `civitai.com` and `civitai.red` model URLs and recipe/image URLs across import, analysis, and download flows.
|
||||||
|
* **Civitai API Host Migration** - Updated core Civitai API requests to use `civitai.red` for compatibility with Civitai's current API host.
|
||||||
|
* **Configurable Civitai View Host** - Added a setting to choose which Civitai site opens by default for model, search, and view links.
|
||||||
|
* **401 Unauthorized Reminder** - Some users have reported `401 Unauthorized` errors. If you run into this, try generating a new API key on `civitai.red` and updating it in LoRA Manager settings.
|
||||||
|
|
||||||
|
### v1.0.3
|
||||||
|
|
||||||
|
* **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations.
|
||||||
|
* **Wildcard Support for LM Text/Prompt Nodes** - The LM `Text` node and `Prompt` node now support the new `/wildcard` command, with runtime wildcard expansion and support for dynamic prompt syntax for more flexible prompt construction.
|
||||||
|
* **System Diagnostics ("Doctor")** - Added a new diagnostics feature to help surface environment and setup issues more clearly.
|
||||||
|
* **User-State Backup Support** - Added backup support for user state, with accompanying UI and clearer backup scope messaging in Settings.
|
||||||
|
* **Downloaded Status Visibility** - Added clearer downloaded-status UX so previously downloaded model versions are easier to recognize.
|
||||||
|
* **Autocomplete Performance Improvements** - Fixed autocomplete performance issues to reduce tag-search overhead and improve responsiveness.
|
||||||
|
|
||||||
### v1.0.2
|
### 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.
|
* **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.
|
||||||
@@ -101,7 +122,7 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
|
|||||||
|
|
||||||
### v0.9.14
|
### v0.9.14
|
||||||
* **LoRA Cycler Node** - Introduced a new LoRA Cycler node that enables iteration through specified LoRAs with support for repeat count and pause iteration functionality. Refer to the new "Lora Cycler" template workflow for concrete example.
|
* **LoRA Cycler Node** - Introduced a new LoRA Cycler node that enables iteration through specified LoRAs with support for repeat count and pause iteration functionality. Refer to the new "Lora Cycler" template workflow for concrete example.
|
||||||
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/char` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
|
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/character` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
|
||||||
* **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability.
|
* **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability.
|
||||||
|
|
||||||
### v0.9.12
|
### v0.9.12
|
||||||
@@ -253,6 +274,41 @@ pip install -r requirements.txt
|
|||||||
- Paste into the Lora Loader node's text input
|
- Paste into the Lora Loader node's text input
|
||||||
- The node will automatically apply preset strength and trigger words
|
- The node will automatically apply preset strength and trigger words
|
||||||
|
|
||||||
|
### Wildcards for TextLM / PromptLM
|
||||||
|
|
||||||
|
`Text (LoraManager)` and `Prompt (LoraManager)` support `/wildcard` autocomplete plus runtime wildcard expansion.
|
||||||
|
|
||||||
|
- Wildcard files live in `{settings folder}/wildcards/`
|
||||||
|
- When you type `/wildcard` and no wildcard files exist yet, the autocomplete dropdown shows the exact folder path and lets you open it
|
||||||
|
- Supported formats: `.txt`, `.yaml`, `.yml`, `.json`
|
||||||
|
|
||||||
|
Format rules:
|
||||||
|
|
||||||
|
- `wildcards/animals/cat.txt` becomes `__animals/cat__`
|
||||||
|
- `.txt` files use one option per line
|
||||||
|
- YAML / JSON files use nested keys that end in string arrays
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
# wildcards/color.txt
|
||||||
|
red
|
||||||
|
blue
|
||||||
|
green
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it as `__color__`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# wildcards/colors.yaml
|
||||||
|
palette:
|
||||||
|
warm:
|
||||||
|
- red
|
||||||
|
- orange
|
||||||
|
```
|
||||||
|
|
||||||
|
Use it as `__palette/warm__`.
|
||||||
|
|
||||||
### Filename Format Patterns for Save Image Node
|
### Filename Format Patterns for Save Image Node
|
||||||
|
|
||||||
The Save Image Node supports dynamic filename generation using pattern codes. You can customize how your images are named using the following format patterns:
|
The Save Image Node supports dynamic filename generation using pattern codes. You can customize how your images are named using the following format patterns:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -175,6 +175,9 @@
|
|||||||
"success": "{count} Rezepte erfolgreich repariert.",
|
"success": "{count} Rezepte erfolgreich repariert.",
|
||||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||||
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "Ausgeschlossene Modelle verwalten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Civitai API Key",
|
"civitaiApiKey": "Civitai API Key",
|
||||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Civitai-Host",
|
||||||
|
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com (nur SFW)",
|
||||||
|
"red": "civitai.red (uneingeschränkt)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "Civitai-Host-Einstellung verfügbar",
|
||||||
|
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
|
||||||
|
"openSettings": "Einstellungen öffnen"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "Einstellungsordner öffnen",
|
"label": "Einstellungsordner öffnen",
|
||||||
"tooltip": "Den Ordner mit der settings.json öffnen",
|
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "In Ordner verschieben",
|
"moveToFolder": "In Ordner verschieben",
|
||||||
"repairMetadata": "Metadaten reparieren",
|
"repairMetadata": "Metadaten reparieren",
|
||||||
"excludeModel": "Modell ausschließen",
|
"excludeModel": "Modell ausschließen",
|
||||||
|
"restoreModel": "Modell wiederherstellen",
|
||||||
"deleteModel": "Modell löschen",
|
"deleteModel": "Modell löschen",
|
||||||
"shareRecipe": "Rezept teilen",
|
"shareRecipe": "Rezept teilen",
|
||||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "Early Access",
|
"earlyAccess": "Early Access",
|
||||||
"earlyAccessTooltip": "Early Access erforderlich",
|
"earlyAccessTooltip": "Early Access erforderlich",
|
||||||
"inLibrary": "In Bibliothek",
|
"inLibrary": "In Bibliothek",
|
||||||
|
"downloaded": "Heruntergeladen",
|
||||||
|
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "in {count}d"
|
"days": "in {count}d"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "Aktuelle Version",
|
"current": "Geöffnete Version",
|
||||||
|
"currentTooltip": "Das ist die Version, mit der dieses Modal geöffnet wurde",
|
||||||
"inLibrary": "In der Bibliothek",
|
"inLibrary": "In der Bibliothek",
|
||||||
|
"inLibraryTooltip": "Diese Version befindet sich in Ihrer lokalen Bibliothek",
|
||||||
|
"downloaded": "Heruntergeladen",
|
||||||
|
"downloadedTooltip": "Diese Version wurde bereits heruntergeladen, befindet sich aber derzeit nicht in Ihrer Bibliothek",
|
||||||
"newer": "Neuere Version",
|
"newer": "Neuere Version",
|
||||||
|
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
||||||
"earlyAccess": "Früher Zugriff",
|
"earlyAccess": "Früher Zugriff",
|
||||||
"ignored": "Ignoriert"
|
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||||
|
"ignored": "Ignoriert",
|
||||||
|
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
|
"downloadTooltip": "Diese Version herunterladen",
|
||||||
|
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
"deleteTooltip": "Diese lokale Version löschen",
|
||||||
"ignore": "Ignorieren",
|
"ignore": "Ignorieren",
|
||||||
"unignore": "Ignorierung aufheben",
|
"unignore": "Ignorierung aufheben",
|
||||||
|
"ignoreTooltip": "Update-Benachrichtigungen für diese Version ignorieren",
|
||||||
|
"unignoreTooltip": "Update-Benachrichtigungen für diese Version fortsetzen",
|
||||||
|
"viewVersionOnCivitai": "Version auf Civitai anzeigen",
|
||||||
"earlyAccessTooltip": "Erfordert Early-Access-Kauf",
|
"earlyAccessTooltip": "Erfordert Early-Access-Kauf",
|
||||||
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||||
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "Fehler beim Löschen von {type}: {message}",
|
"deleteFailed": "Fehler beim Löschen von {type}: {message}",
|
||||||
"excludeSuccess": "{type} erfolgreich ausgeschlossen",
|
"excludeSuccess": "{type} erfolgreich ausgeschlossen",
|
||||||
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
|
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
|
||||||
|
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
|
||||||
|
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
|
||||||
"fileNameUpdated": "Dateiname erfolgreich aktualisiert",
|
"fileNameUpdated": "Dateiname erfolgreich aktualisiert",
|
||||||
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
||||||
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "Successfully repaired {count} recipes.",
|
"success": "Successfully repaired {count} recipes.",
|
||||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||||
"error": "Recipe repair failed: {message}"
|
"error": "Recipe repair failed: {message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "Manage Excluded Models"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Civitai API Key",
|
"civitaiApiKey": "Civitai API Key",
|
||||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Civitai host",
|
||||||
|
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com (SFW)",
|
||||||
|
"red": "civitai.red (unrestricted)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "Civitai host preference available",
|
||||||
|
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
||||||
|
"openSettings": "Open Settings"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "Open settings folder",
|
"label": "Open settings folder",
|
||||||
"tooltip": "Open folder containing settings.json",
|
"tooltip": "Open folder containing settings.json",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "Move to Folder",
|
"moveToFolder": "Move to Folder",
|
||||||
"repairMetadata": "Repair metadata",
|
"repairMetadata": "Repair metadata",
|
||||||
"excludeModel": "Exclude Model",
|
"excludeModel": "Exclude Model",
|
||||||
|
"restoreModel": "Restore Model",
|
||||||
"deleteModel": "Delete Model",
|
"deleteModel": "Delete Model",
|
||||||
"shareRecipe": "Share Recipe",
|
"shareRecipe": "Share Recipe",
|
||||||
"viewAllLoras": "View All LoRAs",
|
"viewAllLoras": "View All LoRAs",
|
||||||
@@ -685,9 +702,9 @@
|
|||||||
"title": "Import a recipe from image or URL",
|
"title": "Import a recipe from image or URL",
|
||||||
"urlLocalPath": "URL / Local Path",
|
"urlLocalPath": "URL / Local Path",
|
||||||
"uploadImage": "Upload Image",
|
"uploadImage": "Upload Image",
|
||||||
"urlSectionDescription": "Input a Civitai image URL or local file path to import as a recipe.",
|
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
|
||||||
"imageUrlOrPath": "Image URL or File Path:",
|
"imageUrlOrPath": "Image URL or File Path:",
|
||||||
"urlPlaceholder": "https://civitai.com/images/... or C:/path/to/image.png",
|
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
|
||||||
"fetchImage": "Fetch Image",
|
"fetchImage": "Fetch Image",
|
||||||
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
|
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
|
||||||
"selectImage": "Select Image",
|
"selectImage": "Select Image",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "Early Access",
|
"earlyAccess": "Early Access",
|
||||||
"earlyAccessTooltip": "Early access required",
|
"earlyAccessTooltip": "Early access required",
|
||||||
"inLibrary": "In Library",
|
"inLibrary": "In Library",
|
||||||
|
"downloaded": "Downloaded",
|
||||||
|
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||||
"alreadyInLibrary": "Already in Library",
|
"alreadyInLibrary": "Already in Library",
|
||||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1088,9 +1107,9 @@
|
|||||||
},
|
},
|
||||||
"proceedText": "Only proceed if you're sure this is what you want.",
|
"proceedText": "Only proceed if you're sure this is what you want.",
|
||||||
"urlLabel": "Civitai Model URL:",
|
"urlLabel": "Civitai Model URL:",
|
||||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||||
"helpText": {
|
"helpText": {
|
||||||
"title": "Paste any Civitai model URL. Supported formats:",
|
"title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
|
||||||
"format1": "https://civitai.com/models/649516",
|
"format1": "https://civitai.com/models/649516",
|
||||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "in {count}d"
|
"days": "in {count}d"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "Current Version",
|
"current": "Opened Version",
|
||||||
|
"currentTooltip": "This is the version you opened this modal from",
|
||||||
"inLibrary": "In Library",
|
"inLibrary": "In Library",
|
||||||
|
"inLibraryTooltip": "This version exists in your local library",
|
||||||
|
"downloaded": "Downloaded",
|
||||||
|
"downloadedTooltip": "This version was downloaded before, but is not currently in your library",
|
||||||
"newer": "Newer Version",
|
"newer": "Newer Version",
|
||||||
|
"newerTooltip": "This version is newer than your latest local version",
|
||||||
"earlyAccess": "Early Access",
|
"earlyAccess": "Early Access",
|
||||||
"ignored": "Ignored"
|
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||||
|
"ignored": "Ignored",
|
||||||
|
"ignoredTooltip": "Update notifications are disabled for this version"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"downloadTooltip": "Download this version",
|
||||||
|
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"deleteTooltip": "Delete this local version",
|
||||||
"ignore": "Ignore",
|
"ignore": "Ignore",
|
||||||
"unignore": "Unignore",
|
"unignore": "Unignore",
|
||||||
|
"ignoreTooltip": "Ignore update notifications for this version",
|
||||||
|
"unignoreTooltip": "Resume update notifications for this version",
|
||||||
|
"viewVersionOnCivitai": "View version on Civitai",
|
||||||
"earlyAccessTooltip": "Requires early access purchase",
|
"earlyAccessTooltip": "Requires early access purchase",
|
||||||
"resumeModelUpdates": "Resume updates for this model",
|
"resumeModelUpdates": "Resume updates for this model",
|
||||||
"ignoreModelUpdates": "Ignore updates for this model",
|
"ignoreModelUpdates": "Ignore updates for this model",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "Failed to delete {type}: {message}",
|
"deleteFailed": "Failed to delete {type}: {message}",
|
||||||
"excludeSuccess": "{type} excluded successfully",
|
"excludeSuccess": "{type} excluded successfully",
|
||||||
"excludeFailed": "Failed to exclude {type}: {message}",
|
"excludeFailed": "Failed to exclude {type}: {message}",
|
||||||
|
"restoreSuccess": "{type} restored successfully",
|
||||||
|
"restoreFailed": "Failed to restore {type}: {message}",
|
||||||
"fileNameUpdated": "File name updated successfully",
|
"fileNameUpdated": "File name updated successfully",
|
||||||
"fileRenameFailed": "Failed to rename file: {error}",
|
"fileRenameFailed": "Failed to rename file: {error}",
|
||||||
"previewUpdated": "Preview updated successfully",
|
"previewUpdated": "Preview updated successfully",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "Se repararon con éxito {count} recetas.",
|
"success": "Se repararon con éxito {count} recetas.",
|
||||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||||
"error": "Error al reparar recetas: {message}"
|
"error": "Error al reparar recetas: {message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "Gestionar modelos excluidos"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Clave API de Civitai",
|
"civitaiApiKey": "Clave API de Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Host de Civitai",
|
||||||
|
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com (solo SFW)",
|
||||||
|
"red": "civitai.red (sin restricciones)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "Preferencia de host de Civitai disponible",
|
||||||
|
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
|
||||||
|
"openSettings": "Abrir ajustes"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "Abrir carpeta de ajustes",
|
"label": "Abrir carpeta de ajustes",
|
||||||
"tooltip": "Abrir la carpeta que contiene settings.json",
|
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "Mover a carpeta",
|
"moveToFolder": "Mover a carpeta",
|
||||||
"repairMetadata": "Reparar metadatos",
|
"repairMetadata": "Reparar metadatos",
|
||||||
"excludeModel": "Excluir modelo",
|
"excludeModel": "Excluir modelo",
|
||||||
|
"restoreModel": "Restaurar modelo",
|
||||||
"deleteModel": "Eliminar modelo",
|
"deleteModel": "Eliminar modelo",
|
||||||
"shareRecipe": "Compartir receta",
|
"shareRecipe": "Compartir receta",
|
||||||
"viewAllLoras": "Ver todos los LoRAs",
|
"viewAllLoras": "Ver todos los LoRAs",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "Acceso temprano",
|
"earlyAccess": "Acceso temprano",
|
||||||
"earlyAccessTooltip": "Acceso temprano requerido",
|
"earlyAccessTooltip": "Acceso temprano requerido",
|
||||||
"inLibrary": "En la biblioteca",
|
"inLibrary": "En la biblioteca",
|
||||||
|
"downloaded": "Descargado",
|
||||||
|
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||||
"alreadyInLibrary": "Ya en la biblioteca",
|
"alreadyInLibrary": "Ya en la biblioteca",
|
||||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "en {count}d"
|
"days": "en {count}d"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "Versión actual",
|
"current": "Versión abierta",
|
||||||
|
"currentTooltip": "Es la versión con la que abriste este modal",
|
||||||
"inLibrary": "En la biblioteca",
|
"inLibrary": "En la biblioteca",
|
||||||
|
"inLibraryTooltip": "Esta versión existe en tu biblioteca local",
|
||||||
|
"downloaded": "Descargado",
|
||||||
|
"downloadedTooltip": "Esta versión se descargó antes, pero ahora no está en tu biblioteca",
|
||||||
"newer": "Versión más reciente",
|
"newer": "Versión más reciente",
|
||||||
|
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
||||||
"earlyAccess": "Acceso temprano",
|
"earlyAccess": "Acceso temprano",
|
||||||
"ignored": "Ignorada"
|
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||||
|
"ignored": "Ignorada",
|
||||||
|
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
|
"downloadTooltip": "Descargar esta versión",
|
||||||
|
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
|
"deleteTooltip": "Eliminar esta versión local",
|
||||||
"ignore": "Ignorar",
|
"ignore": "Ignorar",
|
||||||
"unignore": "Dejar de ignorar",
|
"unignore": "Dejar de ignorar",
|
||||||
|
"ignoreTooltip": "Ignorar las notificaciones de actualización de esta versión",
|
||||||
|
"unignoreTooltip": "Reanudar las notificaciones de actualización de esta versión",
|
||||||
|
"viewVersionOnCivitai": "Ver versión en Civitai",
|
||||||
"earlyAccessTooltip": "Requiere compra de acceso temprano",
|
"earlyAccessTooltip": "Requiere compra de acceso temprano",
|
||||||
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||||
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "Error al eliminar {type}: {message}",
|
"deleteFailed": "Error al eliminar {type}: {message}",
|
||||||
"excludeSuccess": "{type} excluido exitosamente",
|
"excludeSuccess": "{type} excluido exitosamente",
|
||||||
"excludeFailed": "Error al excluir {type}: {message}",
|
"excludeFailed": "Error al excluir {type}: {message}",
|
||||||
|
"restoreSuccess": "{type} restaurado correctamente",
|
||||||
|
"restoreFailed": "No se pudo restaurar {type}: {message}",
|
||||||
"fileNameUpdated": "Nombre de archivo actualizado exitosamente",
|
"fileNameUpdated": "Nombre de archivo actualizado exitosamente",
|
||||||
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
||||||
"previewUpdated": "Vista previa actualizada exitosamente",
|
"previewUpdated": "Vista previa actualizada exitosamente",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "{count} recettes réparées avec succès.",
|
"success": "{count} recettes réparées avec succès.",
|
||||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||||
"error": "Échec de la réparation des recettes : {message}"
|
"error": "Échec de la réparation des recettes : {message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "Gérer les modèles exclus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Clé API Civitai",
|
"civitaiApiKey": "Clé API Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Hôte Civitai",
|
||||||
|
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com (SFW uniquement)",
|
||||||
|
"red": "civitai.red (sans restriction)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "Préférence d’hôte Civitai disponible",
|
||||||
|
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
|
||||||
|
"openSettings": "Ouvrir les paramètres"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "Ouvrir le dossier des paramètres",
|
"label": "Ouvrir le dossier des paramètres",
|
||||||
"tooltip": "Ouvrir le dossier contenant settings.json",
|
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "Déplacer vers un dossier",
|
"moveToFolder": "Déplacer vers un dossier",
|
||||||
"repairMetadata": "Réparer les métadonnées",
|
"repairMetadata": "Réparer les métadonnées",
|
||||||
"excludeModel": "Exclure le modèle",
|
"excludeModel": "Exclure le modèle",
|
||||||
|
"restoreModel": "Restaurer le modèle",
|
||||||
"deleteModel": "Supprimer le modèle",
|
"deleteModel": "Supprimer le modèle",
|
||||||
"shareRecipe": "Partager la recipe",
|
"shareRecipe": "Partager la recipe",
|
||||||
"viewAllLoras": "Voir tous les LoRAs",
|
"viewAllLoras": "Voir tous les LoRAs",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "Accès anticipé",
|
"earlyAccess": "Accès anticipé",
|
||||||
"earlyAccessTooltip": "Accès anticipé requis",
|
"earlyAccessTooltip": "Accès anticipé requis",
|
||||||
"inLibrary": "Dans la bibliothèque",
|
"inLibrary": "Dans la bibliothèque",
|
||||||
|
"downloaded": "Téléchargé",
|
||||||
|
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "dans {count}j"
|
"days": "dans {count}j"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "Version actuelle",
|
"current": "Version ouverte",
|
||||||
|
"currentTooltip": "C'est la version à partir de laquelle cette fenêtre a été ouverte",
|
||||||
"inLibrary": "Dans la bibliothèque",
|
"inLibrary": "Dans la bibliothèque",
|
||||||
|
"inLibraryTooltip": "Cette version existe dans votre bibliothèque locale",
|
||||||
|
"downloaded": "Téléchargé",
|
||||||
|
"downloadedTooltip": "Cette version a déjà été téléchargée, mais n'est pas actuellement dans votre bibliothèque",
|
||||||
"newer": "Version plus récente",
|
"newer": "Version plus récente",
|
||||||
|
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
||||||
"earlyAccess": "Accès anticipé",
|
"earlyAccess": "Accès anticipé",
|
||||||
"ignored": "Ignorée"
|
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||||
|
"ignored": "Ignorée",
|
||||||
|
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
|
"downloadTooltip": "Télécharger cette version",
|
||||||
|
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
|
"deleteTooltip": "Supprimer cette version locale",
|
||||||
"ignore": "Ignorer",
|
"ignore": "Ignorer",
|
||||||
"unignore": "Ne plus ignorer",
|
"unignore": "Ne plus ignorer",
|
||||||
|
"ignoreTooltip": "Ignorer les notifications de mise à jour pour cette version",
|
||||||
|
"unignoreTooltip": "Reprendre les notifications de mise à jour pour cette version",
|
||||||
|
"viewVersionOnCivitai": "Voir la version sur Civitai",
|
||||||
"earlyAccessTooltip": "Nécessite l'achat de l'accès anticipé",
|
"earlyAccessTooltip": "Nécessite l'achat de l'accès anticipé",
|
||||||
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||||
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "Échec de la suppression de {type} : {message}",
|
"deleteFailed": "Échec de la suppression de {type} : {message}",
|
||||||
"excludeSuccess": "{type} exclu avec succès",
|
"excludeSuccess": "{type} exclu avec succès",
|
||||||
"excludeFailed": "Échec de l'exclusion de {type} : {message}",
|
"excludeFailed": "Échec de l'exclusion de {type} : {message}",
|
||||||
|
"restoreSuccess": "{type} restauré avec succès",
|
||||||
|
"restoreFailed": "Échec de la restauration de {type} : {message}",
|
||||||
"fileNameUpdated": "Nom de fichier mis à jour avec succès",
|
"fileNameUpdated": "Nom de fichier mis à jour avec succès",
|
||||||
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
||||||
"previewUpdated": "Aperçu mis à jour avec succès",
|
"previewUpdated": "Aperçu mis à jour avec succès",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "תוקנו בהצלחה {count} מתכונים.",
|
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||||
"error": "תיקון המתכונים נכשל: {message}"
|
"error": "תיקון המתכונים נכשל: {message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "ניהול מודלים מוחרגים"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "מפתח API של Civitai",
|
"civitaiApiKey": "מפתח API של Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "מארח Civitai",
|
||||||
|
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com (SFW בלבד)",
|
||||||
|
"red": "civitai.red (ללא הגבלות)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "העדפת מארח Civitai זמינה",
|
||||||
|
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
|
||||||
|
"openSettings": "פתח הגדרות"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "פתח תיקיית הגדרות",
|
"label": "פתח תיקיית הגדרות",
|
||||||
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "העבר לתיקייה",
|
"moveToFolder": "העבר לתיקייה",
|
||||||
"repairMetadata": "תיקון מטא-דאטה",
|
"repairMetadata": "תיקון מטא-דאטה",
|
||||||
"excludeModel": "החרג מודל",
|
"excludeModel": "החרג מודל",
|
||||||
|
"restoreModel": "שחזור מודל",
|
||||||
"deleteModel": "מחק מודל",
|
"deleteModel": "מחק מודל",
|
||||||
"shareRecipe": "שתף מתכון",
|
"shareRecipe": "שתף מתכון",
|
||||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "גישה מוקדמת",
|
"earlyAccess": "גישה מוקדמת",
|
||||||
"earlyAccessTooltip": "נדרשת גישה מוקדמת",
|
"earlyAccessTooltip": "נדרשת גישה מוקדמת",
|
||||||
"inLibrary": "בספרייה",
|
"inLibrary": "בספרייה",
|
||||||
|
"downloaded": "הורד",
|
||||||
|
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||||
"alreadyInLibrary": "כבר בספרייה",
|
"alreadyInLibrary": "כבר בספרייה",
|
||||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "בעוד {count} ימים"
|
"days": "בעוד {count} ימים"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "גרסה נוכחית",
|
"current": "גרסה שנפתחה",
|
||||||
|
"currentTooltip": "זוהי הגרסה שממנה נפתח החלון הזה",
|
||||||
"inLibrary": "בספרייה",
|
"inLibrary": "בספרייה",
|
||||||
|
"inLibraryTooltip": "גרסה זו קיימת בספרייה המקומית שלך",
|
||||||
|
"downloaded": "הורד",
|
||||||
|
"downloadedTooltip": "גרסה זו הורדה בעבר, אך אינה נמצאת כרגע בספרייה שלך",
|
||||||
"newer": "גרסה חדשה יותר",
|
"newer": "גרסה חדשה יותר",
|
||||||
|
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
||||||
"earlyAccess": "גישה מוקדמת",
|
"earlyAccess": "גישה מוקדמת",
|
||||||
"ignored": "התעלם"
|
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||||
|
"ignored": "התעלם",
|
||||||
|
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "הורדה",
|
"download": "הורדה",
|
||||||
|
"downloadTooltip": "הורד את הגרסה הזו",
|
||||||
|
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||||
"delete": "מחיקה",
|
"delete": "מחיקה",
|
||||||
|
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||||
"ignore": "התעלם",
|
"ignore": "התעלם",
|
||||||
"unignore": "בטל התעלמות",
|
"unignore": "בטל התעלמות",
|
||||||
|
"ignoreTooltip": "התעלם מהתראות העדכון עבור גרסה זו",
|
||||||
|
"unignoreTooltip": "חזור לקבל התראות עדכון עבור גרסה זו",
|
||||||
|
"viewVersionOnCivitai": "הצג את הגרסה ב-Civitai",
|
||||||
"earlyAccessTooltip": "נדרש רכישת גישה מוקדמת",
|
"earlyAccessTooltip": "נדרש רכישת גישה מוקדמת",
|
||||||
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
|
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
|
||||||
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
|
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "מחיקת {type} נכשלה: {message}",
|
"deleteFailed": "מחיקת {type} נכשלה: {message}",
|
||||||
"excludeSuccess": "{type} הוחרג בהצלחה",
|
"excludeSuccess": "{type} הוחרג בהצלחה",
|
||||||
"excludeFailed": "החרגת {type} נכשלה: {message}",
|
"excludeFailed": "החרגת {type} נכשלה: {message}",
|
||||||
|
"restoreSuccess": "{type} שוחזר בהצלחה",
|
||||||
|
"restoreFailed": "שחזור {type} נכשל: {message}",
|
||||||
"fileNameUpdated": "שם הקובץ עודכן בהצלחה",
|
"fileNameUpdated": "שם הקובץ עודכן בהצלחה",
|
||||||
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
||||||
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "{count} 件のレシピを正常に修復しました。",
|
"success": "{count} 件のレシピを正常に修復しました。",
|
||||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||||
"error": "レシピの修復に失敗しました: {message}"
|
"error": "レシピの修復に失敗しました: {message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "除外モデルを管理"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Civitai APIキー",
|
"civitaiApiKey": "Civitai APIキー",
|
||||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Civitai ホスト",
|
||||||
|
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com(SFW のみ)",
|
||||||
|
"red": "civitai.red(制限なし)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "Civitai ホスト設定を利用できます",
|
||||||
|
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||||
|
"openSettings": "設定を開く"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "設定フォルダーを開く",
|
"label": "設定フォルダーを開く",
|
||||||
"tooltip": "settings.json を含むフォルダーを開きます",
|
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "フォルダに移動",
|
"moveToFolder": "フォルダに移動",
|
||||||
"repairMetadata": "メタデータを修復",
|
"repairMetadata": "メタデータを修復",
|
||||||
"excludeModel": "モデルを除外",
|
"excludeModel": "モデルを除外",
|
||||||
|
"restoreModel": "モデルを復元",
|
||||||
"deleteModel": "モデルを削除",
|
"deleteModel": "モデルを削除",
|
||||||
"shareRecipe": "レシピを共有",
|
"shareRecipe": "レシピを共有",
|
||||||
"viewAllLoras": "すべてのLoRAを表示",
|
"viewAllLoras": "すべてのLoRAを表示",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "アーリーアクセス",
|
"earlyAccess": "アーリーアクセス",
|
||||||
"earlyAccessTooltip": "アーリーアクセスが必要",
|
"earlyAccessTooltip": "アーリーアクセスが必要",
|
||||||
"inLibrary": "ライブラリ内",
|
"inLibrary": "ライブラリ内",
|
||||||
|
"downloaded": "ダウンロード済み",
|
||||||
|
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||||
"alreadyInLibrary": "既にライブラリ内",
|
"alreadyInLibrary": "既にライブラリ内",
|
||||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "{count}日後"
|
"days": "{count}日後"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "現在のバージョン",
|
"current": "開いたバージョン",
|
||||||
|
"currentTooltip": "このモーダルを開くために選択したバージョンです",
|
||||||
"inLibrary": "ライブラリにあります",
|
"inLibrary": "ライブラリにあります",
|
||||||
|
"inLibraryTooltip": "このバージョンはローカルライブラリに存在します",
|
||||||
|
"downloaded": "ダウンロード済み",
|
||||||
|
"downloadedTooltip": "このバージョンは以前ダウンロードされましたが、現在はライブラリにありません",
|
||||||
"newer": "新しいバージョン",
|
"newer": "新しいバージョン",
|
||||||
|
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
||||||
"earlyAccess": "早期アクセス",
|
"earlyAccess": "早期アクセス",
|
||||||
"ignored": "無視中"
|
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||||
|
"ignored": "無視中",
|
||||||
|
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
|
"downloadTooltip": "このバージョンをダウンロード",
|
||||||
|
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
|
"deleteTooltip": "このローカルバージョンを削除",
|
||||||
"ignore": "無視",
|
"ignore": "無視",
|
||||||
"unignore": "無視を解除",
|
"unignore": "無視を解除",
|
||||||
|
"ignoreTooltip": "このバージョンの更新通知を無視",
|
||||||
|
"unignoreTooltip": "このバージョンの更新通知を再開",
|
||||||
|
"viewVersionOnCivitai": "Civitai でバージョンを表示",
|
||||||
"earlyAccessTooltip": "早期アクセス購入が必要",
|
"earlyAccessTooltip": "早期アクセス購入が必要",
|
||||||
"resumeModelUpdates": "このモデルの更新を再開",
|
"resumeModelUpdates": "このモデルの更新を再開",
|
||||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "{type}の削除に失敗しました:{message}",
|
"deleteFailed": "{type}の削除に失敗しました:{message}",
|
||||||
"excludeSuccess": "{type}が正常に除外されました",
|
"excludeSuccess": "{type}が正常に除外されました",
|
||||||
"excludeFailed": "{type}の除外に失敗しました:{message}",
|
"excludeFailed": "{type}の除外に失敗しました:{message}",
|
||||||
|
"restoreSuccess": "{type}を復元しました",
|
||||||
|
"restoreFailed": "{type}の復元に失敗しました: {message}",
|
||||||
"fileNameUpdated": "ファイル名が正常に更新されました",
|
"fileNameUpdated": "ファイル名が正常に更新されました",
|
||||||
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
||||||
"previewUpdated": "プレビューが正常に更新されました",
|
"previewUpdated": "プレビューが正常に更新されました",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||||
"error": "레시피 복구 실패: {message}"
|
"error": "레시피 복구 실패: {message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "제외된 모델 관리"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Civitai API 키",
|
"civitaiApiKey": "Civitai API 키",
|
||||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Civitai 호스트",
|
||||||
|
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com(SFW 전용)",
|
||||||
|
"red": "civitai.red(무제한)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "Civitai 호스트 기본 설정 사용 가능",
|
||||||
|
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||||
|
"openSettings": "설정 열기"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "설정 폴더 열기",
|
"label": "설정 폴더 열기",
|
||||||
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "폴더로 이동",
|
"moveToFolder": "폴더로 이동",
|
||||||
"repairMetadata": "메타데이터 복구",
|
"repairMetadata": "메타데이터 복구",
|
||||||
"excludeModel": "모델 제외",
|
"excludeModel": "모델 제외",
|
||||||
|
"restoreModel": "모델 복원",
|
||||||
"deleteModel": "모델 삭제",
|
"deleteModel": "모델 삭제",
|
||||||
"shareRecipe": "레시피 공유",
|
"shareRecipe": "레시피 공유",
|
||||||
"viewAllLoras": "모든 LoRA 보기",
|
"viewAllLoras": "모든 LoRA 보기",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "얼리 액세스",
|
"earlyAccess": "얼리 액세스",
|
||||||
"earlyAccessTooltip": "얼리 액세스 필요",
|
"earlyAccessTooltip": "얼리 액세스 필요",
|
||||||
"inLibrary": "라이브러리에 있음",
|
"inLibrary": "라이브러리에 있음",
|
||||||
|
"downloaded": "다운로드됨",
|
||||||
|
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "{count}일 후"
|
"days": "{count}일 후"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "현재 버전",
|
"current": "열린 버전",
|
||||||
|
"currentTooltip": "이 모달을 열 때 사용한 버전입니다",
|
||||||
"inLibrary": "라이브러리에 있음",
|
"inLibrary": "라이브러리에 있음",
|
||||||
|
"inLibraryTooltip": "이 버전은 로컬 라이브러리에 있습니다",
|
||||||
|
"downloaded": "다운로드됨",
|
||||||
|
"downloadedTooltip": "이 버전은 이전에 다운로드되었지만 현재는 라이브러리에 없습니다",
|
||||||
"newer": "최신 버전",
|
"newer": "최신 버전",
|
||||||
|
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
||||||
"earlyAccess": "얼리 액세스",
|
"earlyAccess": "얼리 액세스",
|
||||||
"ignored": "무시됨"
|
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||||
|
"ignored": "무시됨",
|
||||||
|
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
|
"downloadTooltip": "이 버전 다운로드",
|
||||||
|
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
|
"deleteTooltip": "이 로컬 버전 삭제",
|
||||||
"ignore": "무시",
|
"ignore": "무시",
|
||||||
"unignore": "무시 해제",
|
"unignore": "무시 해제",
|
||||||
|
"ignoreTooltip": "이 버전의 업데이트 알림 무시",
|
||||||
|
"unignoreTooltip": "이 버전의 업데이트 알림 다시 받기",
|
||||||
|
"viewVersionOnCivitai": "Civitai에서 버전 보기",
|
||||||
"earlyAccessTooltip": "얼리 액세스 구매 필요",
|
"earlyAccessTooltip": "얼리 액세스 구매 필요",
|
||||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "{type} 삭제 실패: {message}",
|
"deleteFailed": "{type} 삭제 실패: {message}",
|
||||||
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
|
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
|
||||||
"excludeFailed": "{type} 제외 실패: {message}",
|
"excludeFailed": "{type} 제외 실패: {message}",
|
||||||
|
"restoreSuccess": "{type} 복원 완료",
|
||||||
|
"restoreFailed": "{type} 복원 실패: {message}",
|
||||||
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
|
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
|
||||||
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
||||||
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "Успешно восстановлено {count} рецептов.",
|
"success": "Успешно восстановлено {count} рецептов.",
|
||||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||||
"error": "Ошибка восстановления рецептов: {message}"
|
"error": "Ошибка восстановления рецептов: {message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "Управление исключёнными моделями"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Ключ API Civitai",
|
"civitaiApiKey": "Ключ API Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Хост Civitai",
|
||||||
|
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com (только SFW)",
|
||||||
|
"red": "civitai.red (без ограничений)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "Доступна настройка хоста Civitai",
|
||||||
|
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
|
||||||
|
"openSettings": "Открыть настройки"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "Открыть папку настроек",
|
"label": "Открыть папку настроек",
|
||||||
"tooltip": "Открыть папку, содержащую settings.json",
|
"tooltip": "Открыть папку, содержащую settings.json",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "Переместить в папку",
|
"moveToFolder": "Переместить в папку",
|
||||||
"repairMetadata": "Восстановить метаданные",
|
"repairMetadata": "Восстановить метаданные",
|
||||||
"excludeModel": "Исключить модель",
|
"excludeModel": "Исключить модель",
|
||||||
|
"restoreModel": "Восстановить модель",
|
||||||
"deleteModel": "Удалить модель",
|
"deleteModel": "Удалить модель",
|
||||||
"shareRecipe": "Поделиться рецептом",
|
"shareRecipe": "Поделиться рецептом",
|
||||||
"viewAllLoras": "Посмотреть все LoRAs",
|
"viewAllLoras": "Посмотреть все LoRAs",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "Ранний доступ",
|
"earlyAccess": "Ранний доступ",
|
||||||
"earlyAccessTooltip": "Требуется ранний доступ",
|
"earlyAccessTooltip": "Требуется ранний доступ",
|
||||||
"inLibrary": "В библиотеке",
|
"inLibrary": "В библиотеке",
|
||||||
|
"downloaded": "Загружено",
|
||||||
|
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||||
"alreadyInLibrary": "Уже в библиотеке",
|
"alreadyInLibrary": "Уже в библиотеке",
|
||||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "через {count}д"
|
"days": "через {count}д"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "Текущая версия",
|
"current": "Открытая версия",
|
||||||
|
"currentTooltip": "Это версия, с которой было открыто это окно",
|
||||||
"inLibrary": "В библиотеке",
|
"inLibrary": "В библиотеке",
|
||||||
|
"inLibraryTooltip": "Эта версия есть в вашей локальной библиотеке",
|
||||||
|
"downloaded": "Загружено",
|
||||||
|
"downloadedTooltip": "Эта версия уже загружалась, но сейчас отсутствует в вашей библиотеке",
|
||||||
"newer": "Более новая версия",
|
"newer": "Более новая версия",
|
||||||
|
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
||||||
"earlyAccess": "Ранний доступ",
|
"earlyAccess": "Ранний доступ",
|
||||||
"ignored": "Игнорируется"
|
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||||
|
"ignored": "Игнорируется",
|
||||||
|
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
|
"downloadTooltip": "Скачать эту версию",
|
||||||
|
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
|
"deleteTooltip": "Удалить эту локальную версию",
|
||||||
"ignore": "Игнорировать",
|
"ignore": "Игнорировать",
|
||||||
"unignore": "Перестать игнорировать",
|
"unignore": "Перестать игнорировать",
|
||||||
|
"ignoreTooltip": "Игнорировать уведомления об обновлениях для этой версии",
|
||||||
|
"unignoreTooltip": "Возобновить уведомления об обновлениях для этой версии",
|
||||||
|
"viewVersionOnCivitai": "Посмотреть версию на Civitai",
|
||||||
"earlyAccessTooltip": "Требуется покупка раннего доступа",
|
"earlyAccessTooltip": "Требуется покупка раннего доступа",
|
||||||
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||||
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "Не удалось удалить {type}: {message}",
|
"deleteFailed": "Не удалось удалить {type}: {message}",
|
||||||
"excludeSuccess": "{type} успешно исключен",
|
"excludeSuccess": "{type} успешно исключен",
|
||||||
"excludeFailed": "Не удалось исключить {type}: {message}",
|
"excludeFailed": "Не удалось исключить {type}: {message}",
|
||||||
|
"restoreSuccess": "{type} успешно восстановлен",
|
||||||
|
"restoreFailed": "Не удалось восстановить {type}: {message}",
|
||||||
"fileNameUpdated": "Имя файла успешно обновлено",
|
"fileNameUpdated": "Имя файла успешно обновлено",
|
||||||
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
||||||
"previewUpdated": "Превью успешно обновлено",
|
"previewUpdated": "Превью успешно обновлено",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "成功修复了 {count} 个配方。",
|
"success": "成功修复了 {count} 个配方。",
|
||||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||||
"error": "配方修复失败:{message}"
|
"error": "配方修复失败:{message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "管理已排除的模型"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Civitai API 密钥",
|
"civitaiApiKey": "Civitai API 密钥",
|
||||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Civitai 站点",
|
||||||
|
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com(仅 SFW)",
|
||||||
|
"red": "civitai.red(无限制)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "已提供 Civitai 站点偏好设置",
|
||||||
|
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||||
|
"openSettings": "打开设置"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "打开设置文件夹",
|
"label": "打开设置文件夹",
|
||||||
"tooltip": "打开包含 settings.json 的文件夹",
|
"tooltip": "打开包含 settings.json 的文件夹",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "移动到文件夹",
|
"moveToFolder": "移动到文件夹",
|
||||||
"repairMetadata": "修复元数据",
|
"repairMetadata": "修复元数据",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
|
"restoreModel": "恢复模型",
|
||||||
"deleteModel": "删除模型",
|
"deleteModel": "删除模型",
|
||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
"viewAllLoras": "查看所有 LoRA",
|
"viewAllLoras": "查看所有 LoRA",
|
||||||
@@ -685,9 +702,9 @@
|
|||||||
"title": "从图片或 URL 导入配方",
|
"title": "从图片或 URL 导入配方",
|
||||||
"urlLocalPath": "URL / 本地路径",
|
"urlLocalPath": "URL / 本地路径",
|
||||||
"uploadImage": "上传图片",
|
"uploadImage": "上传图片",
|
||||||
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。",
|
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL,或本地文件路径以导入为配方。",
|
||||||
"imageUrlOrPath": "图片 URL 或文件路径:",
|
"imageUrlOrPath": "图片 URL 或文件路径:",
|
||||||
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
|
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
|
||||||
"fetchImage": "获取图片",
|
"fetchImage": "获取图片",
|
||||||
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
|
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
|
||||||
"selectImage": "选择图片",
|
"selectImage": "选择图片",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "早期访问",
|
"earlyAccess": "早期访问",
|
||||||
"earlyAccessTooltip": "需要早期访问权限",
|
"earlyAccessTooltip": "需要早期访问权限",
|
||||||
"inLibrary": "已在库中",
|
"inLibrary": "已在库中",
|
||||||
|
"downloaded": "已下载",
|
||||||
|
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||||
"alreadyInLibrary": "已存在于库中",
|
"alreadyInLibrary": "已存在于库中",
|
||||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1088,9 +1107,9 @@
|
|||||||
},
|
},
|
||||||
"proceedText": "仅在你确定需要此操作时继续。",
|
"proceedText": "仅在你确定需要此操作时继续。",
|
||||||
"urlLabel": "Civitai 模型 URL:",
|
"urlLabel": "Civitai 模型 URL:",
|
||||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||||
"helpText": {
|
"helpText": {
|
||||||
"title": "粘贴任意 Civitai 模型 URL。支持格式:",
|
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式:",
|
||||||
"format1": "https://civitai.com/models/649516",
|
"format1": "https://civitai.com/models/649516",
|
||||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "{count}天后"
|
"days": "{count}天后"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "当前版本",
|
"current": "已打开版本",
|
||||||
|
"currentTooltip": "这是你用来打开此弹窗的版本",
|
||||||
"inLibrary": "已在库中",
|
"inLibrary": "已在库中",
|
||||||
|
"inLibraryTooltip": "此版本已存在于你的本地库中",
|
||||||
|
"downloaded": "已下载",
|
||||||
|
"downloadedTooltip": "此版本之前下载过,但当前不在你的本地库中",
|
||||||
"newer": "较新的版本",
|
"newer": "较新的版本",
|
||||||
|
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||||
"earlyAccess": "抢先体验",
|
"earlyAccess": "抢先体验",
|
||||||
"ignored": "已忽略"
|
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||||
|
"ignored": "已忽略",
|
||||||
|
"ignoredTooltip": "此版本已关闭更新通知"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
|
"downloadTooltip": "下载此版本",
|
||||||
|
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
|
"deleteTooltip": "删除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
"unignore": "取消忽略",
|
"unignore": "取消忽略",
|
||||||
|
"ignoreTooltip": "忽略此版本的更新通知",
|
||||||
|
"unignoreTooltip": "恢复此版本的更新通知",
|
||||||
|
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||||
"earlyAccessTooltip": "需要购买抢先体验",
|
"earlyAccessTooltip": "需要购买抢先体验",
|
||||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||||
"ignoreModelUpdates": "忽略该模型的更新",
|
"ignoreModelUpdates": "忽略该模型的更新",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "删除 {type} 失败:{message}",
|
"deleteFailed": "删除 {type} 失败:{message}",
|
||||||
"excludeSuccess": "{type} 排除成功",
|
"excludeSuccess": "{type} 排除成功",
|
||||||
"excludeFailed": "排除 {type} 失败:{message}",
|
"excludeFailed": "排除 {type} 失败:{message}",
|
||||||
|
"restoreSuccess": "{type} 已成功恢复",
|
||||||
|
"restoreFailed": "恢复 {type} 失败:{message}",
|
||||||
"fileNameUpdated": "文件名更新成功",
|
"fileNameUpdated": "文件名更新成功",
|
||||||
"fileRenameFailed": "重命名文件失败:{error}",
|
"fileRenameFailed": "重命名文件失败:{error}",
|
||||||
"previewUpdated": "预览图片更新成功",
|
"previewUpdated": "预览图片更新成功",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"success": "成功修復 {count} 個配方。",
|
"success": "成功修復 {count} 個配方。",
|
||||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||||
"error": "配方修復失敗:{message}"
|
"error": "配方修復失敗:{message}"
|
||||||
|
},
|
||||||
|
"manageExcludedModels": {
|
||||||
|
"label": "管理已排除的模型"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -250,6 +253,19 @@
|
|||||||
"civitaiApiKey": "Civitai API 金鑰",
|
"civitaiApiKey": "Civitai API 金鑰",
|
||||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||||
|
"civitaiHost": {
|
||||||
|
"label": "Civitai 站點",
|
||||||
|
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||||
|
"options": {
|
||||||
|
"com": "civitai.com(僅 SFW)",
|
||||||
|
"red": "civitai.red(無限制)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"civitaiHostBanner": {
|
||||||
|
"title": "已提供 Civitai 站點偏好設定",
|
||||||
|
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
|
||||||
|
"openSettings": "開啟設定"
|
||||||
|
},
|
||||||
"openSettingsFileLocation": {
|
"openSettingsFileLocation": {
|
||||||
"label": "開啟設定資料夾",
|
"label": "開啟設定資料夾",
|
||||||
"tooltip": "開啟包含 settings.json 的資料夾",
|
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||||
@@ -667,6 +683,7 @@
|
|||||||
"moveToFolder": "移動到資料夾",
|
"moveToFolder": "移動到資料夾",
|
||||||
"repairMetadata": "修復元數據",
|
"repairMetadata": "修復元數據",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
|
"restoreModel": "還原模型",
|
||||||
"deleteModel": "刪除模型",
|
"deleteModel": "刪除模型",
|
||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
"viewAllLoras": "檢視全部 LoRA",
|
"viewAllLoras": "檢視全部 LoRA",
|
||||||
@@ -957,6 +974,8 @@
|
|||||||
"earlyAccess": "早期存取",
|
"earlyAccess": "早期存取",
|
||||||
"earlyAccessTooltip": "需要早期存取",
|
"earlyAccessTooltip": "需要早期存取",
|
||||||
"inLibrary": "已在庫存",
|
"inLibrary": "已在庫存",
|
||||||
|
"downloaded": "已下載",
|
||||||
|
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||||
"alreadyInLibrary": "已在庫存",
|
"alreadyInLibrary": "已在庫存",
|
||||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1226,17 +1245,30 @@
|
|||||||
"days": "{count}天後"
|
"days": "{count}天後"
|
||||||
},
|
},
|
||||||
"badges": {
|
"badges": {
|
||||||
"current": "目前版本",
|
"current": "已開啟版本",
|
||||||
|
"currentTooltip": "這是你用來開啟此彈窗的版本",
|
||||||
"inLibrary": "已在庫中",
|
"inLibrary": "已在庫中",
|
||||||
|
"inLibraryTooltip": "此版本已存在於你的本地庫中",
|
||||||
|
"downloaded": "已下載",
|
||||||
|
"downloadedTooltip": "此版本之前下載過,但目前不在你的本地庫中",
|
||||||
"newer": "較新版本",
|
"newer": "較新版本",
|
||||||
|
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||||
"earlyAccess": "搶先體驗",
|
"earlyAccess": "搶先體驗",
|
||||||
"ignored": "已忽略"
|
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||||
|
"ignored": "已忽略",
|
||||||
|
"ignoredTooltip": "此版本已關閉更新通知"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
|
"downloadTooltip": "下載此版本",
|
||||||
|
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
|
"deleteTooltip": "刪除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
"unignore": "取消忽略",
|
"unignore": "取消忽略",
|
||||||
|
"ignoreTooltip": "忽略此版本的更新通知",
|
||||||
|
"unignoreTooltip": "恢復此版本的更新通知",
|
||||||
|
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||||
"earlyAccessTooltip": "需要購買搶先體驗",
|
"earlyAccessTooltip": "需要購買搶先體驗",
|
||||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||||
"ignoreModelUpdates": "忽略此模型的更新",
|
"ignoreModelUpdates": "忽略此模型的更新",
|
||||||
@@ -1775,6 +1807,8 @@
|
|||||||
"deleteFailed": "刪除 {type} 失敗:{message}",
|
"deleteFailed": "刪除 {type} 失敗:{message}",
|
||||||
"excludeSuccess": "{type} 已成功排除",
|
"excludeSuccess": "{type} 已成功排除",
|
||||||
"excludeFailed": "排除 {type} 失敗:{message}",
|
"excludeFailed": "排除 {type} 失敗:{message}",
|
||||||
|
"restoreSuccess": "{type} 已成功還原",
|
||||||
|
"restoreFailed": "還原 {type} 失敗:{message}",
|
||||||
"fileNameUpdated": "檔案名稱已成功更新",
|
"fileNameUpdated": "檔案名稱已成功更新",
|
||||||
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
||||||
"previewUpdated": "預覽圖片已成功更新",
|
"previewUpdated": "預覽圖片已成功更新",
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
|
from ..services.wildcard_service import (
|
||||||
|
contains_dynamic_syntax,
|
||||||
|
get_wildcard_service,
|
||||||
|
is_trigger_words_input,
|
||||||
|
)
|
||||||
|
|
||||||
class _AllContainer:
|
|
||||||
"""Container that accepts any key for dynamic input validation."""
|
|
||||||
|
|
||||||
def __contains__(self, item):
|
class _PromptOptionalInputs:
|
||||||
return True
|
"""Lookup that preserves explicit optional inputs and dynamic trigger slots."""
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
|
||||||
return ("STRING", {"forceInput": True})
|
self._explicit_inputs = explicit_inputs
|
||||||
|
|
||||||
|
def __contains__(self, item: object) -> bool:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
return False
|
||||||
|
return item in self._explicit_inputs or is_trigger_words_input(item)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
|
||||||
|
if key in self._explicit_inputs:
|
||||||
|
return self._explicit_inputs[key]
|
||||||
|
if is_trigger_words_input(key):
|
||||||
|
return (
|
||||||
|
"STRING",
|
||||||
|
{
|
||||||
|
"forceInput": True,
|
||||||
|
"tooltip": "Trigger words to prepend. Connect to add more inputs.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise KeyError(key)
|
||||||
|
|
||||||
|
|
||||||
class PromptLM:
|
class PromptLM:
|
||||||
@@ -20,12 +43,19 @@ class PromptLM:
|
|||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||||
"to guide the diffusion model towards generating specific images. "
|
"to guide the diffusion model towards generating specific images. "
|
||||||
"Supports dynamic trigger words inputs."
|
"Supports dynamic trigger words inputs and runtime wildcard expansion."
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
dyn_inputs = {
|
optional_inputs: dict[str, tuple[str, dict[str, Any]]] = {
|
||||||
|
"seed": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"forceInput": True,
|
||||||
|
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
|
||||||
|
},
|
||||||
|
),
|
||||||
"trigger_words1": (
|
"trigger_words1": (
|
||||||
"STRING",
|
"STRING",
|
||||||
{
|
{
|
||||||
@@ -35,10 +65,9 @@ class PromptLM:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bypass validation for dynamic inputs during graph execution
|
|
||||||
stack = inspect.stack()
|
stack = inspect.stack()
|
||||||
if len(stack) > 2 and stack[2].function == "get_input_info":
|
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||||
dyn_inputs = _AllContainer()
|
optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -46,8 +75,8 @@ class PromptLM:
|
|||||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||||
{
|
{
|
||||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||||
"placeholder": "Enter prompt... /char, /artist for quick tag search",
|
"placeholder": "Enter prompt... /character, /artist, /wildcard for quick search",
|
||||||
"tooltip": "The text to be encoded.",
|
"tooltip": "The text to be encoded. Wildcard references inserted with /wildcard are expanded at runtime.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
"clip": (
|
"clip": (
|
||||||
@@ -55,7 +84,7 @@ class PromptLM:
|
|||||||
{"tooltip": "The CLIP model used for encoding the text."},
|
{"tooltip": "The CLIP model used for encoding the text."},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"optional": dyn_inputs,
|
"optional": optional_inputs,
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||||||
@@ -65,18 +94,37 @@ class PromptLM:
|
|||||||
)
|
)
|
||||||
FUNCTION = "encode"
|
FUNCTION = "encode"
|
||||||
|
|
||||||
def encode(self, text: str, clip: Any, **kwargs):
|
@classmethod
|
||||||
# Collect all trigger words from dynamic inputs
|
def IS_CHANGED(
|
||||||
|
cls,
|
||||||
|
text: str,
|
||||||
|
clip: Any | None = None,
|
||||||
|
seed: int | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
):
|
||||||
|
del clip, kwargs
|
||||||
|
if contains_dynamic_syntax(text) and seed is None:
|
||||||
|
return float("NaN")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def encode(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
clip: Any,
|
||||||
|
seed: int | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
):
|
||||||
|
expanded_text = get_wildcard_service().expand_text(text, seed=seed)
|
||||||
|
|
||||||
trigger_words = []
|
trigger_words = []
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
if key.startswith("trigger_words") and value:
|
if is_trigger_words_input(key) and value:
|
||||||
trigger_words.append(value)
|
trigger_words.append(value)
|
||||||
|
|
||||||
# Build final prompt
|
|
||||||
if trigger_words:
|
if trigger_words:
|
||||||
prompt = ", ".join(trigger_words + [text])
|
prompt = ", ".join(trigger_words + [expanded_text])
|
||||||
else:
|
else:
|
||||||
prompt = text
|
prompt = expanded_text
|
||||||
|
|
||||||
from nodes import CLIPTextEncode # type: ignore
|
from nodes import CLIPTextEncode # type: ignore
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
|
||||||
|
|
||||||
|
|
||||||
class TextLM:
|
class TextLM:
|
||||||
"""A simple text node with autocomplete support."""
|
"""A simple text node with autocomplete support."""
|
||||||
|
|
||||||
NAME = "Text (LoraManager)"
|
NAME = "Text (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/utils"
|
CATEGORY = "Lora Manager/utils"
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"A simple text input node with autocomplete support for tags and styles."
|
"A simple text input node with autocomplete support for tags, styles, and wildcard expansion."
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -15,8 +20,17 @@ class TextLM:
|
|||||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||||
{
|
{
|
||||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||||
"placeholder": "Enter text... /char, /artist for quick tag search",
|
"placeholder": "Enter text... /character, /artist, /wildcard for quick search",
|
||||||
"tooltip": "The text output.",
|
"tooltip": "The text output. Wildcard references inserted with /wildcard are expanded at runtime.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"seed": (
|
||||||
|
"INT",
|
||||||
|
{
|
||||||
|
"forceInput": True,
|
||||||
|
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -24,10 +38,14 @@ class TextLM:
|
|||||||
|
|
||||||
RETURN_TYPES = ("STRING",)
|
RETURN_TYPES = ("STRING",)
|
||||||
RETURN_NAMES = ("STRING",)
|
RETURN_NAMES = ("STRING",)
|
||||||
OUTPUT_TOOLTIPS = (
|
OUTPUT_TOOLTIPS = ("The text output.",)
|
||||||
"The text output.",
|
|
||||||
)
|
|
||||||
FUNCTION = "process"
|
FUNCTION = "process"
|
||||||
|
|
||||||
def process(self, text: str):
|
@classmethod
|
||||||
return (text,)
|
def IS_CHANGED(cls, text: str, seed: int | None = None):
|
||||||
|
if contains_dynamic_syntax(text) and seed is None:
|
||||||
|
return float("NaN")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def process(self, text: str, seed: int | None = None):
|
||||||
|
return (get_wildcard_service().expand_text(text, seed=seed),)
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ GEN_PARAM_KEYS = [
|
|||||||
'seed',
|
'seed',
|
||||||
'size',
|
'size',
|
||||||
'clip_skip',
|
'clip_skip',
|
||||||
|
'denoising_strength',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
from .merger import GenParamsMerger
|
from .merger import GenParamsMerger
|
||||||
from .base import RecipeMetadataParser
|
from .base import RecipeMetadataParser
|
||||||
from ..services.metadata_service import get_default_metadata_provider
|
from ..services.metadata_service import get_default_metadata_provider
|
||||||
|
from ..utils.civitai_utils import extract_civitai_image_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,11 +39,12 @@ class RecipeEnricher:
|
|||||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||||
|
|
||||||
# Check if it's a Civitai image URL
|
# Check if it's a Civitai image URL
|
||||||
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
|
image_id = extract_civitai_image_id(str(source_url))
|
||||||
if image_id_match:
|
if image_id:
|
||||||
image_id = image_id_match.group(1)
|
|
||||||
try:
|
try:
|
||||||
image_info = await civitai_client.get_image_info(image_id)
|
image_info = await civitai_client.get_image_info(
|
||||||
|
image_id, source_url=str(source_url)
|
||||||
|
)
|
||||||
if image_info:
|
if image_info:
|
||||||
# Handle nested meta often found in Civitai API responses
|
# Handle nested meta often found in Civitai API responses
|
||||||
raw_meta = image_info.get("meta")
|
raw_meta = image_info.get("meta")
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from .constants import GEN_PARAM_KEYS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GenParamsMerger:
|
class GenParamsMerger:
|
||||||
"""Utility to merge generation parameters from multiple sources with priority."""
|
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||||
|
|
||||||
|
ALLOWED_KEYS = set(GEN_PARAM_KEYS)
|
||||||
|
|
||||||
BLACKLISTED_KEYS = {
|
BLACKLISTED_KEYS = {
|
||||||
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
||||||
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
||||||
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
||||||
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
||||||
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
||||||
"checkpoint", "checksum", "model_checksum"
|
"checkpoint", "checksum", "model_checksum", "raw_metadata",
|
||||||
}
|
}
|
||||||
|
|
||||||
NORMALIZATION_MAPPING = {
|
NORMALIZATION_MAPPING = {
|
||||||
# Civitai specific
|
"cfg": "cfg_scale",
|
||||||
"cfgScale": "cfg_scale",
|
"cfgScale": "cfg_scale",
|
||||||
"clipSkip": "clip_skip",
|
"clipSkip": "clip_skip",
|
||||||
"negativePrompt": "negative_prompt",
|
"negativePrompt": "negative_prompt",
|
||||||
# Case variations
|
|
||||||
"Sampler": "sampler",
|
"Sampler": "sampler",
|
||||||
|
"sampler_name": "sampler",
|
||||||
|
"scheduler": "sampler",
|
||||||
"Steps": "steps",
|
"Steps": "steps",
|
||||||
"Seed": "seed",
|
"Seed": "seed",
|
||||||
"Size": "size",
|
"Size": "size",
|
||||||
@@ -36,63 +42,40 @@ class GenParamsMerger:
|
|||||||
def merge(
|
def merge(
|
||||||
request_params: Optional[Dict[str, Any]] = None,
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
civitai_meta: Optional[Dict[str, Any]] = None,
|
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||||
embedded_metadata: Optional[Dict[str, Any]] = None
|
embedded_metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Merge generation parameters from three sources.
|
Merge generation parameters from three sources.
|
||||||
|
|
||||||
Priority: request_params > civitai_meta > embedded_metadata
|
Priority: request_params > civitai_meta > embedded_metadata
|
||||||
|
|
||||||
Args:
|
|
||||||
request_params: Params provided directly in the import request
|
|
||||||
civitai_meta: Params from Civitai Image API 'meta' field
|
|
||||||
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Merged parameters dictionary
|
|
||||||
"""
|
"""
|
||||||
result = {}
|
result: Dict[str, Any] = {}
|
||||||
|
|
||||||
# 1. Start with embedded metadata (lowest priority)
|
|
||||||
if embedded_metadata:
|
if embedded_metadata:
|
||||||
# If it's a full recipe metadata, we use its gen_params
|
if "gen_params" in embedded_metadata and isinstance(
|
||||||
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
embedded_metadata["gen_params"], dict
|
||||||
|
):
|
||||||
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
||||||
else:
|
else:
|
||||||
# Otherwise assume the dict itself contains gen_params
|
|
||||||
GenParamsMerger._update_normalized(result, embedded_metadata)
|
GenParamsMerger._update_normalized(result, embedded_metadata)
|
||||||
|
|
||||||
# 2. Layer Civitai meta (medium priority)
|
|
||||||
if civitai_meta:
|
if civitai_meta:
|
||||||
GenParamsMerger._update_normalized(result, civitai_meta)
|
GenParamsMerger._update_normalized(result, civitai_meta)
|
||||||
|
|
||||||
# 3. Layer request params (highest priority)
|
|
||||||
if request_params:
|
if request_params:
|
||||||
GenParamsMerger._update_normalized(result, request_params)
|
GenParamsMerger._update_normalized(result, request_params)
|
||||||
|
|
||||||
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
|
return result
|
||||||
final_result = {}
|
|
||||||
for k, v in result.items():
|
|
||||||
if k in GenParamsMerger.BLACKLISTED_KEYS:
|
|
||||||
continue
|
|
||||||
if k in GenParamsMerger.NORMALIZATION_MAPPING:
|
|
||||||
continue
|
|
||||||
final_result[k] = v
|
|
||||||
|
|
||||||
return final_result
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||||
"""Update target dict with normalized keys from source."""
|
"""Update target dict with normalized, persistence-safe keys from source."""
|
||||||
for k, v in source.items():
|
for key, value in source.items():
|
||||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
|
if key in GenParamsMerger.BLACKLISTED_KEYS:
|
||||||
target[normalized_key] = v
|
continue
|
||||||
# Also keep the original key for now if it's not the same,
|
|
||||||
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
|
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||||
# Actually, if we rename it, we should probably NOT keep both in 'target'
|
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||||
# because we want to filter them out at the end anyway.
|
continue
|
||||||
if normalized_key != k:
|
|
||||||
# If we are overwriting an existing snake_case key with a camelCase one's value,
|
target[normalized_key] = value
|
||||||
# that's fine because of the priority order of calls to _update_normalized.
|
|
||||||
pass
|
|
||||||
target[k] = v
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import contextlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
@@ -2410,6 +2411,16 @@ class FileSystemHandler:
|
|||||||
logger.error("Failed to open backup location: %s", exc, exc_info=True)
|
logger.error("Failed to open backup location: %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 open_wildcards_location(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
from ...services.wildcard_service import get_wildcards_dir
|
||||||
|
|
||||||
|
wildcards_dir = get_wildcards_dir(create=True)
|
||||||
|
return await self._open_path(wildcards_dir)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class CustomWordsHandler:
|
class CustomWordsHandler:
|
||||||
"""Handler for autocomplete via TagFTSIndex."""
|
"""Handler for autocomplete via TagFTSIndex."""
|
||||||
@@ -2489,6 +2500,41 @@ class CustomWordsHandler:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class WildcardsHandler:
|
||||||
|
"""Handler for wildcard autocomplete search."""
|
||||||
|
|
||||||
|
def __init__(self, *, service=None) -> None:
|
||||||
|
if service is None:
|
||||||
|
from ...services.wildcard_service import get_wildcard_service
|
||||||
|
|
||||||
|
service = get_wildcard_service()
|
||||||
|
self._service = service
|
||||||
|
|
||||||
|
async def search_wildcards(self, request: web.Request) -> web.Response:
|
||||||
|
"""Search managed wildcard keys for autocomplete."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
search_term = request.query.get("search", "")
|
||||||
|
limit = min(int(request.query.get("limit", "20")), 100)
|
||||||
|
offset = max(0, int(request.query.get("offset", "0")))
|
||||||
|
metadata = self._service.get_metadata(create_dir=True)
|
||||||
|
results = self._service.search_keys(search_term, limit=limit, offset=offset)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"words": results,
|
||||||
|
"meta": {
|
||||||
|
"has_wildcards": metadata.has_wildcards,
|
||||||
|
"wildcards_dir": metadata.wildcards_dir,
|
||||||
|
"supported_formats": list(metadata.supported_formats),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error searching wildcards: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class NodeRegistryHandler:
|
class NodeRegistryHandler:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -2717,6 +2763,7 @@ class MiscHandlerSet:
|
|||||||
backup: BackupHandler,
|
backup: BackupHandler,
|
||||||
filesystem: FileSystemHandler,
|
filesystem: FileSystemHandler,
|
||||||
custom_words: CustomWordsHandler,
|
custom_words: CustomWordsHandler,
|
||||||
|
wildcards: WildcardsHandler,
|
||||||
supporters: SupportersHandler,
|
supporters: SupportersHandler,
|
||||||
doctor: DoctorHandler,
|
doctor: DoctorHandler,
|
||||||
example_workflows: ExampleWorkflowsHandler,
|
example_workflows: ExampleWorkflowsHandler,
|
||||||
@@ -2734,6 +2781,7 @@ class MiscHandlerSet:
|
|||||||
self.backup = backup
|
self.backup = backup
|
||||||
self.filesystem = filesystem
|
self.filesystem = filesystem
|
||||||
self.custom_words = custom_words
|
self.custom_words = custom_words
|
||||||
|
self.wildcards = wildcards
|
||||||
self.supporters = supporters
|
self.supporters = supporters
|
||||||
self.doctor = doctor
|
self.doctor = doctor
|
||||||
self.example_workflows = example_workflows
|
self.example_workflows = example_workflows
|
||||||
@@ -2774,7 +2822,9 @@ class MiscHandlerSet:
|
|||||||
"open_file_location": self.filesystem.open_file_location,
|
"open_file_location": self.filesystem.open_file_location,
|
||||||
"open_settings_location": self.filesystem.open_settings_location,
|
"open_settings_location": self.filesystem.open_settings_location,
|
||||||
"open_backup_location": self.filesystem.open_backup_location,
|
"open_backup_location": self.filesystem.open_backup_location,
|
||||||
|
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||||
"search_custom_words": self.custom_words.search_custom_words,
|
"search_custom_words": self.custom_words.search_custom_words,
|
||||||
|
"search_wildcards": self.wildcards.search_wildcards,
|
||||||
"get_supporters": self.supporters.get_supporters,
|
"get_supporters": self.supporters.get_supporters,
|
||||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
"get_example_workflow": self.example_workflows.get_example_workflow,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ...services.download_coordinator import DownloadCoordinator
|
|||||||
from ...services.metadata_sync_service import MetadataSyncService
|
from ...services.metadata_sync_service import MetadataSyncService
|
||||||
from ...services.model_file_service import ModelMoveService
|
from ...services.model_file_service import ModelMoveService
|
||||||
from ...services.preview_asset_service import PreviewAssetService
|
from ...services.preview_asset_service import PreviewAssetService
|
||||||
|
from ...services.service_registry import ServiceRegistry
|
||||||
from ...services.settings_manager import SettingsManager, get_settings_manager
|
from ...services.settings_manager import SettingsManager, get_settings_manager
|
||||||
from ...services.tag_update_service import TagUpdateService
|
from ...services.tag_update_service import TagUpdateService
|
||||||
from ...services.use_cases import (
|
from ...services.use_cases import (
|
||||||
@@ -64,7 +65,6 @@ class ModelPageView:
|
|||||||
self._settings = settings_service
|
self._settings = settings_service
|
||||||
self._server_i18n = server_i18n
|
self._server_i18n = server_i18n
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
self._app_version = self._get_app_version()
|
|
||||||
|
|
||||||
def _load_supporters(self) -> dict:
|
def _load_supporters(self) -> dict:
|
||||||
"""Load supporters data from JSON file."""
|
"""Load supporters data from JSON file."""
|
||||||
@@ -155,7 +155,7 @@ class ModelPageView:
|
|||||||
"request": request,
|
"request": request,
|
||||||
"folders": [],
|
"folders": [],
|
||||||
"t": self._server_i18n.get_translation,
|
"t": self._server_i18n.get_translation,
|
||||||
"version": self._app_version,
|
"version": self._get_app_version(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if not is_initializing:
|
if not is_initializing:
|
||||||
@@ -224,6 +224,42 @@ class ModelListingHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_excluded_models(self, request: web.Request) -> web.Response:
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
try:
|
||||||
|
params = self._parse_common_params(request)
|
||||||
|
result = await self._service.get_excluded_paginated_data(**params)
|
||||||
|
|
||||||
|
format_start = time.perf_counter()
|
||||||
|
formatted_result = {
|
||||||
|
"items": [
|
||||||
|
await self._service.format_response(item)
|
||||||
|
for item in result["items"]
|
||||||
|
],
|
||||||
|
"total": result["total"],
|
||||||
|
"page": result["page"],
|
||||||
|
"page_size": result["page_size"],
|
||||||
|
"total_pages": result["total_pages"],
|
||||||
|
}
|
||||||
|
format_duration = time.perf_counter() - format_start
|
||||||
|
|
||||||
|
duration = time.perf_counter() - start_time
|
||||||
|
self._logger.debug(
|
||||||
|
"Request for %s/excluded took %.3fs (formatting: %.3fs)",
|
||||||
|
self._service.model_type,
|
||||||
|
duration,
|
||||||
|
format_duration,
|
||||||
|
)
|
||||||
|
return web.json_response(formatted_result)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error retrieving excluded %ss: %s",
|
||||||
|
self._service.model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
def _parse_common_params(self, request: web.Request) -> Dict:
|
def _parse_common_params(self, request: web.Request) -> Dict:
|
||||||
page = int(request.query.get("page", "1"))
|
page = int(request.query.get("page", "1"))
|
||||||
page_size = min(int(request.query.get("page_size", "20")), 100)
|
page_size = min(int(request.query.get("page_size", "20")), 100)
|
||||||
@@ -392,6 +428,21 @@ class ModelManagementHandler:
|
|||||||
self._logger.error("Error excluding model: %s", exc, exc_info=True)
|
self._logger.error("Error excluding model: %s", exc, exc_info=True)
|
||||||
return web.Response(text=str(exc), status=500)
|
return web.Response(text=str(exc), status=500)
|
||||||
|
|
||||||
|
async def unexclude_model(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_path = data.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
return web.Response(text="Model path is required", status=400)
|
||||||
|
|
||||||
|
result = await self._lifecycle_service.unexclude_model(file_path)
|
||||||
|
return web.json_response(result)
|
||||||
|
except ValueError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error restoring model: %s", exc, exc_info=True)
|
||||||
|
return web.Response(text=str(exc), status=500)
|
||||||
|
|
||||||
async def fetch_civitai(self, request: web.Request) -> web.Response:
|
async def fetch_civitai(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -1532,6 +1583,20 @@ class ModelCivitaiHandler:
|
|||||||
|
|
||||||
cache = await self._service.scanner.get_cached_data()
|
cache = await self._service.scanner.get_cached_data()
|
||||||
version_index = cache.version_index
|
version_index = cache.version_index
|
||||||
|
downloaded_version_ids: set[int] = set()
|
||||||
|
try:
|
||||||
|
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||||
|
downloaded_version_ids = set(
|
||||||
|
await history_service.get_downloaded_version_ids(
|
||||||
|
self._service.model_type,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
self._logger.debug(
|
||||||
|
"Failed to load download history for CivitAI versions: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
for version in versions:
|
for version in versions:
|
||||||
version_id = None
|
version_id = None
|
||||||
@@ -1548,6 +1613,9 @@ class ModelCivitaiHandler:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
version["existsLocally"] = cache_entry is not None
|
version["existsLocally"] = cache_entry is not None
|
||||||
|
version["hasBeenDownloaded"] = (
|
||||||
|
version_id in downloaded_version_ids if version_id is not None else False
|
||||||
|
)
|
||||||
if cache_entry and isinstance(cache_entry, Mapping):
|
if cache_entry and isinstance(cache_entry, Mapping):
|
||||||
local_path = cache_entry.get("file_path")
|
local_path = cache_entry.get("file_path")
|
||||||
if local_path:
|
if local_path:
|
||||||
@@ -2266,7 +2334,7 @@ class ModelUpdateHandler:
|
|||||||
self,
|
self,
|
||||||
record,
|
record,
|
||||||
*,
|
*,
|
||||||
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
|
version_context: Optional[Dict[int, Dict[str, Any]]] = None,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
context = version_context or {}
|
context = version_context or {}
|
||||||
# Check user setting for hiding early access versions
|
# Check user setting for hiding early access versions
|
||||||
@@ -2295,7 +2363,7 @@ class ModelUpdateHandler:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_version(
|
def _serialize_version(
|
||||||
version, context: Optional[Dict[str, Optional[str]]]
|
version, context: Optional[Dict[str, Any]]
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
context = context or {}
|
context = context or {}
|
||||||
preview_override = context.get("preview_override")
|
preview_override = context.get("preview_override")
|
||||||
@@ -2329,6 +2397,7 @@ class ModelUpdateHandler:
|
|||||||
"sizeBytes": version.size_bytes,
|
"sizeBytes": version.size_bytes,
|
||||||
"previewUrl": preview_url,
|
"previewUrl": preview_url,
|
||||||
"isInLibrary": version.is_in_library,
|
"isInLibrary": version.is_in_library,
|
||||||
|
"hasBeenDownloaded": bool(context.get("has_been_downloaded", False)),
|
||||||
"shouldIgnore": version.should_ignore,
|
"shouldIgnore": version.should_ignore,
|
||||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||||
"isEarlyAccess": is_early_access,
|
"isEarlyAccess": is_early_access,
|
||||||
@@ -2338,8 +2407,31 @@ class ModelUpdateHandler:
|
|||||||
|
|
||||||
async def _build_version_context(
|
async def _build_version_context(
|
||||||
self, record
|
self, record
|
||||||
) -> Dict[int, Dict[str, Optional[str]]]:
|
) -> Dict[int, Dict[str, Any]]:
|
||||||
context: Dict[int, Dict[str, Optional[str]]] = {}
|
context: Dict[int, Dict[str, Any]] = {}
|
||||||
|
downloaded_version_ids: set[int] = set()
|
||||||
|
try:
|
||||||
|
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||||
|
downloaded_version_ids = set(
|
||||||
|
await history_service.get_downloaded_version_ids(
|
||||||
|
record.model_type,
|
||||||
|
record.model_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
self._logger.debug(
|
||||||
|
"Failed to load download history while building version context: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
for version in record.versions:
|
||||||
|
context[version.version_id] = {
|
||||||
|
"file_path": None,
|
||||||
|
"file_name": None,
|
||||||
|
"preview_override": None,
|
||||||
|
"has_been_downloaded": version.version_id in downloaded_version_ids,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache = await self._service.scanner.get_cached_data()
|
cache = await self._service.scanner.get_cached_data()
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
@@ -2358,16 +2450,21 @@ class ModelUpdateHandler:
|
|||||||
cache_entry = version_index.get(version.version_id)
|
cache_entry = version_index.get(version.version_id)
|
||||||
if isinstance(cache_entry, Mapping):
|
if isinstance(cache_entry, Mapping):
|
||||||
preview = cache_entry.get("preview_url")
|
preview = cache_entry.get("preview_url")
|
||||||
context_entry: Dict[str, Optional[str]] = {
|
context_entry = context.setdefault(
|
||||||
"file_path": cache_entry.get("file_path"),
|
version.version_id,
|
||||||
"file_name": cache_entry.get("file_name"),
|
{
|
||||||
"preview_override": None,
|
"file_path": None,
|
||||||
}
|
"file_name": None,
|
||||||
|
"preview_override": None,
|
||||||
|
"has_been_downloaded": version.version_id in downloaded_version_ids,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
context_entry["file_path"] = cache_entry.get("file_path")
|
||||||
|
context_entry["file_name"] = cache_entry.get("file_name")
|
||||||
if isinstance(preview, str) and preview:
|
if isinstance(preview, str) and preview:
|
||||||
context_entry["preview_override"] = config.get_preview_static_url(
|
context_entry["preview_override"] = config.get_preview_static_url(
|
||||||
preview
|
preview
|
||||||
)
|
)
|
||||||
context[version.version_id] = context_entry
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -2391,8 +2488,10 @@ class ModelHandlerSet:
|
|||||||
return {
|
return {
|
||||||
"handle_models_page": self.page_view.handle,
|
"handle_models_page": self.page_view.handle,
|
||||||
"get_models": self.listing.get_models,
|
"get_models": self.listing.get_models,
|
||||||
|
"get_excluded_models": self.listing.get_excluded_models,
|
||||||
"delete_model": self.management.delete_model,
|
"delete_model": self.management.delete_model,
|
||||||
"exclude_model": self.management.exclude_model,
|
"exclude_model": self.management.exclude_model,
|
||||||
|
"unexclude_model": self.management.unexclude_model,
|
||||||
"fetch_civitai": self.management.fetch_civitai,
|
"fetch_civitai": self.management.fetch_civitai,
|
||||||
"fetch_all_civitai": self.civitai.fetch_all_civitai,
|
"fetch_all_civitai": self.civitai.fetch_all_civitai,
|
||||||
"relink_civitai": self.management.relink_civitai,
|
"relink_civitai": self.management.relink_civitai,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ...services.recipes import (
|
|||||||
RecipeValidationError,
|
RecipeValidationError,
|
||||||
)
|
)
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
from ...recipes.enrichment import RecipeEnricher
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
@@ -756,6 +756,14 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
|
||||||
|
image_url,
|
||||||
|
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||||
|
len(lora_entries),
|
||||||
|
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Initial Metadata Construction
|
# 2. Initial Metadata Construction
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"base_model": params.get("base_model", "") or "",
|
"base_model": params.get("base_model", "") or "",
|
||||||
@@ -1188,13 +1196,15 @@ class RecipeManagementHandler:
|
|||||||
temp_path = temp_file.name
|
temp_path = temp_file.name
|
||||||
download_url = image_url
|
download_url = image_url
|
||||||
image_info = None
|
image_info = None
|
||||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
civitai_image_id = extract_civitai_image_id(image_url)
|
||||||
if civitai_match:
|
if civitai_image_id:
|
||||||
if civitai_client is None:
|
if civitai_client is None:
|
||||||
raise RecipeDownloadError(
|
raise RecipeDownloadError(
|
||||||
"Civitai client unavailable for image download"
|
"Civitai client unavailable for image download"
|
||||||
)
|
)
|
||||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
image_info = await civitai_client.get_image_info(
|
||||||
|
civitai_image_id, source_url=image_url
|
||||||
|
)
|
||||||
if not image_info:
|
if not image_info:
|
||||||
raise RecipeDownloadError(
|
raise RecipeDownloadError(
|
||||||
"Failed to fetch image information from Civitai"
|
"Failed to fetch image information from Civitai"
|
||||||
@@ -1228,7 +1238,7 @@ class RecipeManagementHandler:
|
|||||||
return (
|
return (
|
||||||
file_obj.read(),
|
file_obj.read(),
|
||||||
extension,
|
extension,
|
||||||
image_info.get("meta") if civitai_match and image_info else None,
|
image_info.get("meta") if civitai_image_id and image_info else None,
|
||||||
)
|
)
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||||
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
|
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
|
||||||
|
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||||
|
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||||
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
||||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from .handlers.misc_handlers import (
|
|||||||
SupportersHandler,
|
SupportersHandler,
|
||||||
TrainedWordsHandler,
|
TrainedWordsHandler,
|
||||||
UsageStatsHandler,
|
UsageStatsHandler,
|
||||||
|
WildcardsHandler,
|
||||||
build_service_registry_adapter,
|
build_service_registry_adapter,
|
||||||
)
|
)
|
||||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||||
@@ -130,6 +131,7 @@ class MiscRoutes:
|
|||||||
metadata_provider_factory=self._metadata_provider_factory,
|
metadata_provider_factory=self._metadata_provider_factory,
|
||||||
)
|
)
|
||||||
custom_words = CustomWordsHandler()
|
custom_words = CustomWordsHandler()
|
||||||
|
wildcards = WildcardsHandler()
|
||||||
supporters = SupportersHandler()
|
supporters = SupportersHandler()
|
||||||
doctor = DoctorHandler(settings_service=self._settings)
|
doctor = DoctorHandler(settings_service=self._settings)
|
||||||
example_workflows = ExampleWorkflowsHandler()
|
example_workflows = ExampleWorkflowsHandler()
|
||||||
@@ -148,6 +150,7 @@ class MiscRoutes:
|
|||||||
backup=backup,
|
backup=backup,
|
||||||
filesystem=filesystem,
|
filesystem=filesystem,
|
||||||
custom_words=custom_words,
|
custom_words=custom_words,
|
||||||
|
wildcards=wildcards,
|
||||||
supporters=supporters,
|
supporters=supporters,
|
||||||
doctor=doctor,
|
doctor=doctor,
|
||||||
example_workflows=example_workflows,
|
example_workflows=example_workflows,
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ class RouteDefinition:
|
|||||||
|
|
||||||
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
|
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
|
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
|
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
|
||||||
|
RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
|
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
|
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
|
||||||
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
|
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .model_query import (
|
|||||||
resolve_sub_type,
|
resolve_sub_type,
|
||||||
)
|
)
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
from ..utils.civitai_utils import build_civitai_model_page_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -178,6 +179,57 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
return paginated
|
return paginated
|
||||||
|
|
||||||
|
async def get_excluded_paginated_data(
|
||||||
|
self,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
sort_by: str = "name",
|
||||||
|
search: str = None,
|
||||||
|
fuzzy_search: bool = False,
|
||||||
|
search_options: dict = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict:
|
||||||
|
"""Get paginated excluded model data."""
|
||||||
|
excluded_paths = list(self.scanner.get_excluded_models())
|
||||||
|
excluded_entries: List[Dict[str, Any]] = []
|
||||||
|
stale_paths: List[str] = []
|
||||||
|
|
||||||
|
for file_path in excluded_paths:
|
||||||
|
if not file_path or not os.path.exists(file_path):
|
||||||
|
stale_paths.append(file_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = await self._build_excluded_entry(file_path)
|
||||||
|
if entry:
|
||||||
|
excluded_entries.append(entry)
|
||||||
|
else:
|
||||||
|
stale_paths.append(file_path)
|
||||||
|
|
||||||
|
if stale_paths:
|
||||||
|
current_excluded = getattr(self.scanner, "_excluded_models", None)
|
||||||
|
if isinstance(current_excluded, list):
|
||||||
|
stale_set = set(stale_paths)
|
||||||
|
self.scanner._excluded_models = [
|
||||||
|
path for path in current_excluded if path not in stale_set
|
||||||
|
]
|
||||||
|
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist_current_cache):
|
||||||
|
await persist_current_cache()
|
||||||
|
|
||||||
|
excluded_entries = self._sort_entries(excluded_entries, sort_by)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
excluded_entries = await self._apply_search_filters(
|
||||||
|
excluded_entries,
|
||||||
|
search,
|
||||||
|
fuzzy_search,
|
||||||
|
search_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
paginated = self._paginate(excluded_entries, page, page_size)
|
||||||
|
paginated["items"] = await self._annotate_update_flags(paginated["items"])
|
||||||
|
return paginated
|
||||||
|
|
||||||
async def _fetch_with_usage_sort(self, sort_params):
|
async def _fetch_with_usage_sort(self, sort_params):
|
||||||
"""Fetch data sorted by usage count (desc/asc)."""
|
"""Fetch data sorted by usage count (desc/asc)."""
|
||||||
cache = await self.cache_repository.get_cache()
|
cache = await self.cache_repository.get_cache()
|
||||||
@@ -217,6 +269,62 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
return annotated
|
return annotated
|
||||||
|
|
||||||
|
def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
|
||||||
|
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||||
|
key_name = sort_params.key
|
||||||
|
|
||||||
|
if key_name == "date":
|
||||||
|
key_fn = lambda item: (
|
||||||
|
float(item.get("modified", 0.0) or 0.0),
|
||||||
|
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||||
|
item.get("file_path", "").lower(),
|
||||||
|
)
|
||||||
|
elif key_name == "size":
|
||||||
|
key_fn = lambda item: (
|
||||||
|
int(item.get("size", 0) or 0),
|
||||||
|
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||||
|
item.get("file_path", "").lower(),
|
||||||
|
)
|
||||||
|
elif key_name == "usage":
|
||||||
|
key_fn = lambda item: (
|
||||||
|
int(item.get("usage_count", 0) or 0),
|
||||||
|
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||||
|
item.get("file_path", "").lower(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key_fn = lambda item: (
|
||||||
|
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||||
|
item.get("file_path", "").lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted(data, key=key_fn, reverse=sort_params.order == "desc")
|
||||||
|
|
||||||
|
async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
root_path = self.scanner._find_root_for_file(file_path)
|
||||||
|
if not root_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
|
file_path,
|
||||||
|
self.metadata_class,
|
||||||
|
)
|
||||||
|
if should_skip:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if metadata is None:
|
||||||
|
metadata = await self.scanner._create_default_metadata(file_path)
|
||||||
|
if metadata is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata = self.scanner.adjust_metadata(metadata, file_path, root_path)
|
||||||
|
folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace(
|
||||||
|
os.path.sep, "/"
|
||||||
|
)
|
||||||
|
entry = self.scanner._build_cache_entry(metadata, folder=folder)
|
||||||
|
entry = self.scanner.adjust_cached_entry(entry)
|
||||||
|
entry["exclude"] = True
|
||||||
|
return entry
|
||||||
|
|
||||||
async def _apply_hash_filters(
|
async def _apply_hash_filters(
|
||||||
self, data: List[Dict], hash_filters: Dict
|
self, data: List[Dict], hash_filters: Dict
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
@@ -774,9 +882,12 @@ class BaseModelService(ABC):
|
|||||||
version_id = civitai_data.get("id")
|
version_id = civitai_data.get("id")
|
||||||
|
|
||||||
if model_id:
|
if model_id:
|
||||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
civitai_host = self.settings.get("civitai_host", "civitai.com")
|
||||||
if version_id:
|
civitai_url = build_civitai_model_page_url(
|
||||||
civitai_url += f"?modelVersionId={version_id}"
|
model_id,
|
||||||
|
version_id,
|
||||||
|
host=civitai_host,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"civitai_url": civitai_url,
|
"civitai_url": civitai_url,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class CheckpointService(BaseModelService):
|
|||||||
"notes": checkpoint_data.get("notes", ""),
|
"notes": checkpoint_data.get("notes", ""),
|
||||||
"sub_type": sub_type,
|
"sub_type": sub_type,
|
||||||
"favorite": checkpoint_data.get("favorite", False),
|
"favorite": checkpoint_data.get("favorite", False),
|
||||||
|
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class CivitaiBaseModelService:
|
|||||||
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
|
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
|
||||||
|
|
||||||
# Civitai API endpoint for enums
|
# Civitai API endpoint for enums
|
||||||
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums"
|
CIVITAI_ENUMS_URL = "https://civitai.red/api/v1/enums"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_instance(cls) -> CivitaiBaseModelService:
|
async def get_instance(cls) -> CivitaiBaseModelService:
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ class CivitaiClient:
|
|||||||
return
|
return
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
self.base_url = "https://civitai.com/api/v1"
|
self.base_url = "https://civitai.red/api/v1"
|
||||||
|
|
||||||
|
def _build_image_info_url(self, image_id: str) -> str:
|
||||||
|
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||||
|
|
||||||
async def _make_request(
|
async def _make_request(
|
||||||
self,
|
self,
|
||||||
@@ -190,7 +193,9 @@ class CivitaiClient:
|
|||||||
"""Get all versions of a model with local availability info"""
|
"""Get all versions of a model with local availability info"""
|
||||||
try:
|
try:
|
||||||
success, result = await self._make_request(
|
success, result = await self._make_request(
|
||||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
"GET",
|
||||||
|
f"{self.base_url}/models/{model_id}",
|
||||||
|
use_auth=True,
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
# Also return model type along with versions
|
# Also return model type along with versions
|
||||||
@@ -346,7 +351,9 @@ class CivitaiClient:
|
|||||||
|
|
||||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||||
success, data = await self._make_request(
|
success, data = await self._make_request(
|
||||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
"GET",
|
||||||
|
f"{self.base_url}/models/{model_id}",
|
||||||
|
use_auth=True,
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
return data
|
return data
|
||||||
@@ -358,7 +365,9 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
success, version = await self._make_request(
|
success, version = await self._make_request(
|
||||||
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True
|
"GET",
|
||||||
|
f"{self.base_url}/model-versions/{version_id}",
|
||||||
|
use_auth=True,
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
return version
|
return version
|
||||||
@@ -371,7 +380,9 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
success, version = await self._make_request(
|
success, version = await self._make_request(
|
||||||
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True
|
"GET",
|
||||||
|
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||||
|
use_auth=True,
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
return version
|
return version
|
||||||
@@ -453,13 +464,11 @@ class CivitaiClient:
|
|||||||
try:
|
try:
|
||||||
url = f"{self.base_url}/model-versions/{version_id}"
|
url = f"{self.base_url}/model-versions/{version_id}"
|
||||||
|
|
||||||
logger.debug(f"Resolving DNS for model version info: {url}")
|
logger.debug("Resolving Civitai model version info: %s", url)
|
||||||
success, result = await self._make_request("GET", url, use_auth=True)
|
success, result = await self._make_request("GET", url, use_auth=True)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.debug(
|
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||||
f"Successfully fetched model version info for: {version_id}"
|
|
||||||
)
|
|
||||||
self._remove_comfy_metadata(result)
|
self._remove_comfy_metadata(result)
|
||||||
return result, None
|
return result, None
|
||||||
|
|
||||||
@@ -479,48 +488,58 @@ class CivitaiClient:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return None, error_msg
|
return None, error_msg
|
||||||
|
|
||||||
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
async def get_image_info(
|
||||||
|
self, image_id: str, source_url: str | None = None
|
||||||
|
) -> Optional[Dict]:
|
||||||
"""Fetch image information from Civitai API
|
"""Fetch image information from Civitai API
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_id: The Civitai image ID
|
image_id: The Civitai image ID
|
||||||
|
source_url: Original image page URL. Accepted for caller compatibility;
|
||||||
|
API requests always target ``civitai.red``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[Dict]: The image data or None if not found
|
Optional[Dict]: The image data or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
|
||||||
requested_id = int(image_id)
|
requested_id = int(image_id)
|
||||||
|
url = self._build_image_info_url(image_id)
|
||||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
|
||||||
success, result = await self._make_request("GET", url, use_auth=True)
|
success, result = await self._make_request("GET", url, use_auth=True)
|
||||||
|
|
||||||
if success:
|
if not success:
|
||||||
if result and "items" in result and isinstance(result["items"], list):
|
logger.error(
|
||||||
items = result["items"]
|
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||||
|
image_id,
|
||||||
# First, try to find the item with matching ID
|
result,
|
||||||
for item in items:
|
)
|
||||||
if isinstance(item, dict) and item.get("id") == requested_id:
|
|
||||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
|
||||||
return item
|
|
||||||
|
|
||||||
# No matching ID found - log warning with details about returned items
|
|
||||||
returned_ids = [
|
|
||||||
item.get("id") for item in items
|
|
||||||
if isinstance(item, dict) and "id" in item
|
|
||||||
]
|
|
||||||
logger.warning(
|
|
||||||
f"CivitAI API returned no matching image for requested ID {image_id}. "
|
|
||||||
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
|
|
||||||
f"This may indicate the image was deleted, hidden, or there is a database lag."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.warning(f"No image found with ID: {image_id}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
|
if result and "items" in result and isinstance(result["items"], list):
|
||||||
|
items = result["items"]
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict) and item.get("id") == requested_id:
|
||||||
|
logger.debug(
|
||||||
|
"Successfully fetched image info for ID %s from civitai.red",
|
||||||
|
image_id,
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
returned_ids = [
|
||||||
|
item.get("id")
|
||||||
|
for item in items
|
||||||
|
if isinstance(item, dict) and "id" in item
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"CivitAI API returned no matching image for requested ID %s from civitai.red. Returned %d item(s) with IDs: %s. This may indicate the image was deleted, hidden, or there is a database lag.",
|
||||||
|
image_id,
|
||||||
|
len(items),
|
||||||
|
returned_ids,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.warning("No image found with ID: %s", image_id)
|
||||||
return None
|
return None
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
raise
|
raise
|
||||||
@@ -539,8 +558,12 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f"{self.base_url}/models?username={username}"
|
success, result = await self._make_request(
|
||||||
success, result = await self._make_request("GET", url, use_auth=True)
|
"GET",
|
||||||
|
f"{self.base_url}/models",
|
||||||
|
use_auth=True,
|
||||||
|
params={"username": username},
|
||||||
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
logger.error("Failed to fetch models for %s: %s", username, result)
|
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ with category filtering and enriched results including post counts.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_EMBEDDED_COMMAND_PATTERN = re.compile(r"\s/\w")
|
||||||
class CustomWordsService:
|
class CustomWordsService:
|
||||||
"""Service for autocomplete via TagFTSIndex.
|
"""Service for autocomplete via TagFTSIndex.
|
||||||
|
|
||||||
@@ -77,12 +79,28 @@ class CustomWordsService:
|
|||||||
Returns:
|
Returns:
|
||||||
List of dicts with tag_name, category, and post_count.
|
List of dicts with tag_name, category, and post_count.
|
||||||
"""
|
"""
|
||||||
|
normalized_search = search_term.strip()
|
||||||
|
if not normalized_search:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Prompt widgets should only send the active token, but guard against
|
||||||
|
# accidental full-prompt queries reaching the FTS path.
|
||||||
|
if (
|
||||||
|
"__" in normalized_search
|
||||||
|
or "," in normalized_search
|
||||||
|
or ">" in normalized_search
|
||||||
|
or "\n" in normalized_search
|
||||||
|
or "\r" in normalized_search
|
||||||
|
or _EMBEDDED_COMMAND_PATTERN.search(normalized_search)
|
||||||
|
):
|
||||||
|
logger.debug("Skipping prompt-like custom words query: %s", normalized_search)
|
||||||
|
return []
|
||||||
|
|
||||||
tag_index = self._get_tag_index()
|
tag_index = self._get_tag_index()
|
||||||
if tag_index is not None:
|
if tag_index is not None:
|
||||||
results = tag_index.search(
|
return tag_index.search(
|
||||||
search_term, categories=categories, limit=limit, offset=offset
|
normalized_search, categories=categories, limit=limit, offset=offset
|
||||||
)
|
)
|
||||||
return results
|
|
||||||
|
|
||||||
logger.debug("TagFTSIndex not available, returning empty results")
|
logger.debug("TagFTSIndex not available, returning empty results")
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from ..utils.constants import (
|
|||||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||||
from ..utils.utils import sanitize_folder_name
|
from ..utils.utils import sanitize_folder_name
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
@@ -31,6 +31,11 @@ import tempfile
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||||
|
"https://civitai.com/api/download/",
|
||||||
|
"https://civitai.red/api/download/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DownloadManager:
|
class DownloadManager:
|
||||||
_instance = None
|
_instance = None
|
||||||
@@ -639,7 +644,9 @@ class DownloadManager:
|
|||||||
if mirrors:
|
if mirrors:
|
||||||
for mirror in mirrors:
|
for mirror in mirrors:
|
||||||
if mirror.get("deletedAt") is None and mirror.get("url"):
|
if mirror.get("deletedAt") is None and mirror.get("url"):
|
||||||
download_urls.append(mirror["url"])
|
download_urls.append(
|
||||||
|
normalize_civitai_download_url(mirror["url"])
|
||||||
|
)
|
||||||
|
|
||||||
# When source is 'civarchive', prioritize non-Civitai URLs
|
# When source is 'civarchive', prioritize non-Civitai URLs
|
||||||
# This avoids failed downloads from deleted Civitai models
|
# This avoids failed downloads from deleted Civitai models
|
||||||
@@ -647,18 +654,20 @@ class DownloadManager:
|
|||||||
civitai_urls = [
|
civitai_urls = [
|
||||||
u
|
u
|
||||||
for u in download_urls
|
for u in download_urls
|
||||||
if u.startswith("https://civitai.com/api/download/")
|
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
]
|
]
|
||||||
non_civitai_urls = [
|
non_civitai_urls = [
|
||||||
u
|
u
|
||||||
for u in download_urls
|
for u in download_urls
|
||||||
if not u.startswith("https://civitai.com/api/download/")
|
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
]
|
]
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
download_urls = non_civitai_urls + civitai_urls
|
||||||
else:
|
else:
|
||||||
download_url = file_info.get("downloadUrl")
|
download_url = file_info.get("downloadUrl")
|
||||||
if download_url:
|
if download_url:
|
||||||
download_urls.append(download_url)
|
download_urls.append(
|
||||||
|
normalize_civitai_download_url(download_url)
|
||||||
|
)
|
||||||
|
|
||||||
if not download_urls:
|
if not download_urls:
|
||||||
return {"success": False, "error": "No mirror URL found"}
|
return {"success": False, "error": "No mirror URL found"}
|
||||||
@@ -1133,7 +1142,8 @@ class DownloadManager:
|
|||||||
pause_control.update_stall_timeout(downloader.stall_timeout)
|
pause_control.update_stall_timeout(downloader.stall_timeout)
|
||||||
last_error = None
|
last_error = None
|
||||||
for download_url in download_urls:
|
for download_url in download_urls:
|
||||||
use_auth = download_url.startswith("https://civitai.com/api/download/")
|
download_url = normalize_civitai_download_url(download_url)
|
||||||
|
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
download_kwargs = {
|
download_kwargs = {
|
||||||
"progress_callback": lambda progress, snapshot=None: (
|
"progress_callback": lambda progress, snapshot=None: (
|
||||||
self._handle_download_progress(
|
self._handle_download_progress(
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService):
|
|||||||
"notes": embedding_data.get("notes", ""),
|
"notes": embedding_data.get("notes", ""),
|
||||||
"sub_type": sub_type,
|
"sub_type": sub_type,
|
||||||
"favorite": embedding_data.get("favorite", False),
|
"favorite": embedding_data.get("favorite", False),
|
||||||
|
"exclude": bool(embedding_data.get("exclude", False)),
|
||||||
"update_available": bool(embedding_data.get("update_available", False)),
|
"update_available": bool(embedding_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class LoraService(BaseModelService):
|
|||||||
"usage_tips": lora_data.get("usage_tips", ""),
|
"usage_tips": lora_data.get("usage_tips", ""),
|
||||||
"notes": lora_data.get("notes", ""),
|
"notes": lora_data.get("notes", ""),
|
||||||
"favorite": lora_data.get("favorite", False),
|
"favorite": lora_data.get("favorite", False),
|
||||||
|
"exclude": bool(lora_data.get("exclude", False)),
|
||||||
"update_available": bool(lora_data.get("update_available", False)),
|
"update_available": bool(lora_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(
|
"skip_metadata_refresh": bool(
|
||||||
lora_data.get("skip_metadata_refresh", False)
|
lora_data.get("skip_metadata_refresh", False)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Opti
|
|||||||
|
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||||
|
from ..utils.metadata_manager import MetadataManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -207,11 +208,56 @@ class ModelLifecycleService:
|
|||||||
|
|
||||||
excluded = getattr(self._scanner, "_excluded_models", None)
|
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||||
if isinstance(excluded, list):
|
if isinstance(excluded, list):
|
||||||
excluded.append(file_path)
|
if file_path not in excluded:
|
||||||
|
excluded.append(file_path)
|
||||||
|
|
||||||
|
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist_current_cache):
|
||||||
|
await persist_current_cache()
|
||||||
|
|
||||||
message = f"Model {os.path.basename(file_path)} excluded"
|
message = f"Model {os.path.basename(file_path)} excluded"
|
||||||
return {"success": True, "message": message}
|
return {"success": True, "message": message}
|
||||||
|
|
||||||
|
async def unexclude_model(self, file_path: str) -> Dict[str, object]:
|
||||||
|
"""Restore a previously excluded model to the active cache."""
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
raise ValueError("Model path is required")
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise ValueError("Model file does not exist")
|
||||||
|
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||||
|
metadata_payload = await self._metadata_loader(metadata_path)
|
||||||
|
metadata_payload["exclude"] = False
|
||||||
|
|
||||||
|
await self._metadata_manager.save_metadata(file_path, metadata_payload)
|
||||||
|
|
||||||
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
|
file_path,
|
||||||
|
self._scanner.model_class,
|
||||||
|
)
|
||||||
|
if should_skip:
|
||||||
|
metadata = None
|
||||||
|
if metadata is None:
|
||||||
|
metadata = metadata_payload
|
||||||
|
|
||||||
|
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||||
|
if isinstance(excluded, list):
|
||||||
|
self._scanner._excluded_models = [
|
||||||
|
path for path in excluded if path != file_path
|
||||||
|
]
|
||||||
|
|
||||||
|
await self._scanner.update_single_model_cache(
|
||||||
|
file_path,
|
||||||
|
file_path,
|
||||||
|
metadata,
|
||||||
|
recalculate_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
message = f"Model {os.path.basename(file_path)} restored"
|
||||||
|
return {"success": True, "message": message}
|
||||||
|
|
||||||
async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]:
|
async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]:
|
||||||
"""Delete a collection of models via the scanner bulk operation."""
|
"""Delete a collection of models via the scanner bulk operation."""
|
||||||
|
|
||||||
|
|||||||
@@ -952,6 +952,30 @@ class RecipeScanner:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to update FTS index for recipe: %s", exc)
|
logger.debug("Failed to update FTS index for recipe: %s", exc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_recipe_gen_params(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Return a recipe copy with normalized generation parameter aliases added."""
|
||||||
|
|
||||||
|
normalized_recipe = dict(recipe_data)
|
||||||
|
gen_params = recipe_data.get("gen_params")
|
||||||
|
if not isinstance(gen_params, dict):
|
||||||
|
return normalized_recipe
|
||||||
|
|
||||||
|
normalized_gen_params = dict(gen_params)
|
||||||
|
for key, value in gen_params.items():
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||||
|
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if normalized_gen_params.get(normalized_key) in (None, ""):
|
||||||
|
normalized_gen_params[normalized_key] = value
|
||||||
|
|
||||||
|
normalized_recipe["gen_params"] = normalized_gen_params
|
||||||
|
return normalized_recipe
|
||||||
|
|
||||||
async def _enrich_cache_metadata(self) -> None:
|
async def _enrich_cache_metadata(self) -> None:
|
||||||
"""Perform remote metadata enrichment after the initial scan."""
|
"""Perform remote metadata enrichment after the initial scan."""
|
||||||
|
|
||||||
@@ -1345,6 +1369,7 @@ class RecipeScanner:
|
|||||||
# Ensure gen_params exists
|
# Ensure gen_params exists
|
||||||
if "gen_params" not in recipe_data:
|
if "gen_params" not in recipe_data:
|
||||||
recipe_data["gen_params"] = {}
|
recipe_data["gen_params"] = {}
|
||||||
|
recipe_data = self._normalize_recipe_gen_params(recipe_data)
|
||||||
|
|
||||||
# Update lora information with local paths and availability
|
# Update lora information with local paths and availability
|
||||||
lora_metadata_updated = await self._update_lora_information(recipe_data)
|
lora_metadata_updated = await self._update_lora_information(recipe_data)
|
||||||
@@ -2055,7 +2080,10 @@ class RecipeScanner:
|
|||||||
end_idx = min(start_idx + page_size, total_items)
|
end_idx = min(start_idx + page_size, total_items)
|
||||||
|
|
||||||
# Get paginated items
|
# Get paginated items
|
||||||
paginated_items = filtered_data[start_idx:end_idx]
|
paginated_items = [
|
||||||
|
self._normalize_recipe_gen_params(item)
|
||||||
|
for item in filtered_data[start_idx:end_idx]
|
||||||
|
]
|
||||||
|
|
||||||
# Add inLibrary information and URLs for each recipe
|
# Add inLibrary information and URLs for each recipe
|
||||||
for item in paginated_items:
|
for item in paginated_items:
|
||||||
@@ -2114,8 +2142,18 @@ class RecipeScanner:
|
|||||||
if not recipe:
|
if not recipe:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Prefer the on-disk recipe JSON for fields that are not persisted in the
|
||||||
|
# SQLite cache yet, such as source_path.
|
||||||
|
merged_recipe = self._normalize_recipe_gen_params({**recipe})
|
||||||
|
recipe_json = await self._load_recipe_json(recipe_id)
|
||||||
|
if recipe_json:
|
||||||
|
for field in ("source_path", "checkpoint", "loras", "gen_params"):
|
||||||
|
if field not in recipe_json:
|
||||||
|
merged_recipe.pop(field, None)
|
||||||
|
merged_recipe.update(recipe_json)
|
||||||
|
|
||||||
# Format the recipe with all needed information
|
# Format the recipe with all needed information
|
||||||
formatted_recipe = {**recipe} # Copy all fields
|
formatted_recipe = {**merged_recipe}
|
||||||
|
|
||||||
# Format file path to URL
|
# Format file path to URL
|
||||||
if "file_path" in formatted_recipe:
|
if "file_path" in formatted_recipe:
|
||||||
@@ -2149,6 +2187,30 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return formatted_recipe
|
return formatted_recipe
|
||||||
|
|
||||||
|
async def _load_recipe_json(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Load the raw recipe JSON payload for a recipe ID if it exists."""
|
||||||
|
|
||||||
|
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(recipe_json_path, "r", encoding="utf-8") as f:
|
||||||
|
recipe_data = json.load(f)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to load recipe JSON for %s from %s: %s",
|
||||||
|
recipe_id,
|
||||||
|
recipe_json_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(recipe_data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._normalize_recipe_gen_params(recipe_data)
|
||||||
|
|
||||||
def _format_file_url(self, file_path: str) -> str:
|
def _format_file_url(self, file_path: str) -> str:
|
||||||
"""Format file path as URL for serving in web UI"""
|
"""Format file path as URL for serving in web UI"""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
@@ -14,7 +13,7 @@ import numpy as np
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
RecipeNotFoundError,
|
RecipeNotFoundError,
|
||||||
@@ -104,9 +103,11 @@ class RecipeAnalysisService:
|
|||||||
extension = ".jpg" # Default
|
extension = ".jpg" # Default
|
||||||
|
|
||||||
try:
|
try:
|
||||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
|
civitai_image_id = extract_civitai_image_id(url)
|
||||||
if civitai_match:
|
if civitai_image_id:
|
||||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
image_info = await civitai_client.get_image_info(
|
||||||
|
civitai_image_id, source_url=url
|
||||||
|
)
|
||||||
if not image_info:
|
if not image_info:
|
||||||
raise RecipeDownloadError(
|
raise RecipeDownloadError(
|
||||||
"Failed to fetch image information from Civitai"
|
"Failed to fetch image information from Civitai"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Dict, Iterable, Optional
|
from typing import Any, Dict, Iterable, Optional
|
||||||
|
|
||||||
from ...config import config
|
from ...config import config
|
||||||
|
from ...recipes.constants import GEN_PARAM_KEYS
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||||
|
|
||||||
@@ -90,23 +91,7 @@ class RecipePersistenceService:
|
|||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
||||||
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
||||||
|
gen_params = self._sanitize_gen_params_for_storage(metadata)
|
||||||
gen_params = metadata.get("gen_params") or {}
|
|
||||||
if not gen_params and "raw_metadata" in metadata:
|
|
||||||
raw_metadata = metadata.get("raw_metadata", {})
|
|
||||||
gen_params = {
|
|
||||||
"prompt": raw_metadata.get("prompt", ""),
|
|
||||||
"negative_prompt": raw_metadata.get("negative_prompt", ""),
|
|
||||||
"steps": raw_metadata.get("steps", ""),
|
|
||||||
"sampler": raw_metadata.get("sampler", ""),
|
|
||||||
"cfg_scale": raw_metadata.get("cfg_scale", ""),
|
|
||||||
"seed": raw_metadata.get("seed", ""),
|
|
||||||
"size": raw_metadata.get("size", ""),
|
|
||||||
"clip_skip": raw_metadata.get("clip_skip", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Drop checkpoint duplication from generation parameters to store it only at top level
|
|
||||||
gen_params.pop("checkpoint", None)
|
|
||||||
|
|
||||||
fingerprint = calculate_recipe_fingerprint(loras_data)
|
fingerprint = calculate_recipe_fingerprint(loras_data)
|
||||||
recipe_data: Dict[str, Any] = {
|
recipe_data: Dict[str, Any] = {
|
||||||
@@ -133,6 +118,7 @@ class RecipePersistenceService:
|
|||||||
json_filename = f"{recipe_id}.recipe.json"
|
json_filename = f"{recipe_id}.recipe.json"
|
||||||
json_path = os.path.join(recipes_dir, json_filename)
|
json_path = os.path.join(recipes_dir, json_filename)
|
||||||
json_path = os.path.normpath(json_path)
|
json_path = os.path.normpath(json_path)
|
||||||
|
|
||||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
@@ -152,6 +138,30 @@ class RecipePersistenceService:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_gen_params_for_storage(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
gen_params = metadata.get("gen_params")
|
||||||
|
if isinstance(gen_params, dict) and gen_params:
|
||||||
|
source = gen_params
|
||||||
|
else:
|
||||||
|
source = metadata.get("raw_metadata")
|
||||||
|
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
allowed_keys = set(GEN_PARAM_KEYS)
|
||||||
|
sanitized: dict[str, Any] = {}
|
||||||
|
for key in allowed_keys:
|
||||||
|
if key not in source:
|
||||||
|
continue
|
||||||
|
value = source.get(key)
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
sanitized[key] = value
|
||||||
|
|
||||||
|
sanitized.pop("checkpoint", None)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||||
"""Delete an existing recipe."""
|
"""Delete an existing recipe."""
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
|
|||||||
|
|
||||||
DEFAULT_SETTINGS: Dict[str, Any] = {
|
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||||
"civitai_api_key": "",
|
"civitai_api_key": "",
|
||||||
|
"civitai_host": "civitai.com",
|
||||||
"use_portable_settings": False,
|
"use_portable_settings": False,
|
||||||
"hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB,
|
"hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
|
|||||||
@@ -450,9 +450,9 @@ class TagFTSIndex:
|
|||||||
the tag_name, the result will include a "matched_alias" field.
|
the tag_name, the result will include a "matched_alias" field.
|
||||||
|
|
||||||
Ranking is based on a combination of:
|
Ranking is based on a combination of:
|
||||||
1. FTS5 bm25 relevance score (how well the text matches)
|
1. Exact prefix match boost (tag_name starts with query)
|
||||||
2. Post count (popularity)
|
2. Post count to preserve expected autocomplete ordering
|
||||||
3. Exact prefix match boost (tag_name starts with query)
|
3. FTS5 bm25 relevance score as a deterministic tie-breaker
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The search query string.
|
query: The search query string.
|
||||||
@@ -484,65 +484,17 @@ class TagFTSIndex:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
conn = self._connect(readonly=True)
|
conn = self._connect(readonly=True)
|
||||||
try:
|
try:
|
||||||
# Build the SQL query with bm25 ranking
|
sql, params = self._build_search_statement(
|
||||||
# FTS5 bm25() returns negative scores, lower is better
|
query_lower=query_lower,
|
||||||
# We use -bm25() to get higher=better scores
|
fts_query=fts_query,
|
||||||
# Weights: -100.0 for exact matches, 1.0 for others
|
categories=categories,
|
||||||
# Add LOG10(post_count) weighting to boost popular tags
|
limit=limit,
|
||||||
# Use CASE to boost tag_name prefix matches above alias matches
|
offset=offset,
|
||||||
if categories:
|
)
|
||||||
placeholders = ",".join("?" * len(categories))
|
|
||||||
sql = f"""
|
|
||||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
|
||||||
CASE
|
|
||||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END AS is_tag_name_match,
|
|
||||||
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
|
|
||||||
FROM tag_fts
|
|
||||||
JOIN tags t ON tag_fts.rowid = t.rowid
|
|
||||||
WHERE tag_fts.searchable_text MATCH ?
|
|
||||||
AND t.category IN ({placeholders})
|
|
||||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
"""
|
|
||||||
# Escape special LIKE characters and add wildcard
|
|
||||||
query_escaped = (
|
|
||||||
query_lower.lstrip("/")
|
|
||||||
.replace("\\", "\\\\")
|
|
||||||
.replace("%", "\\%")
|
|
||||||
.replace("_", "\\_")
|
|
||||||
)
|
|
||||||
params = (
|
|
||||||
[query_escaped + "%", fts_query]
|
|
||||||
+ categories
|
|
||||||
+ [limit, offset]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
sql = """
|
|
||||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
|
||||||
CASE
|
|
||||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
|
||||||
ELSE 0
|
|
||||||
END AS is_tag_name_match,
|
|
||||||
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
|
|
||||||
FROM tag_fts
|
|
||||||
JOIN tags t ON tag_fts.rowid = t.rowid
|
|
||||||
WHERE tag_fts.searchable_text MATCH ?
|
|
||||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
"""
|
|
||||||
query_escaped = (
|
|
||||||
query_lower.lstrip("/")
|
|
||||||
.replace("\\", "\\\\")
|
|
||||||
.replace("%", "\\%")
|
|
||||||
.replace("_", "\\_")
|
|
||||||
)
|
|
||||||
params = [query_escaped + "%", fts_query, limit, offset]
|
|
||||||
|
|
||||||
cursor = conn.execute(sql, params)
|
cursor = conn.execute(sql, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
results = []
|
results = []
|
||||||
for row in cursor.fetchall():
|
for row in rows:
|
||||||
result = {
|
result = {
|
||||||
"tag_name": row[0],
|
"tag_name": row[0],
|
||||||
"category": row[1],
|
"category": row[1],
|
||||||
@@ -571,6 +523,62 @@ class TagFTSIndex:
|
|||||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _build_search_statement(
|
||||||
|
self,
|
||||||
|
query_lower: str,
|
||||||
|
fts_query: str,
|
||||||
|
categories: Optional[List[int]],
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
) -> tuple[str, list[object]]:
|
||||||
|
"""Build the SQL statement and params for a tag search."""
|
||||||
|
# Escape special LIKE characters and add wildcard
|
||||||
|
query_escaped = (
|
||||||
|
query_lower.lstrip("/")
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_")
|
||||||
|
)
|
||||||
|
|
||||||
|
# FTS5 bm25() returns negative scores, lower is better.
|
||||||
|
# We use -bm25() to get higher=better scores, but keep post_count as the
|
||||||
|
# primary sort within tag-name prefix matches so autocomplete ordering
|
||||||
|
# remains aligned with the existing popularity-first behavior.
|
||||||
|
if categories:
|
||||||
|
placeholders = ",".join("?" * len(categories))
|
||||||
|
sql = f"""
|
||||||
|
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||||
|
CASE
|
||||||
|
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS is_tag_name_match,
|
||||||
|
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
|
||||||
|
FROM tag_fts
|
||||||
|
CROSS JOIN tags t ON t.rowid = tag_fts.rowid
|
||||||
|
WHERE tag_fts.searchable_text MATCH ?
|
||||||
|
AND t.category IN ({placeholders})
|
||||||
|
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
params = [query_escaped + "%", fts_query] + categories + [limit, offset]
|
||||||
|
else:
|
||||||
|
sql = """
|
||||||
|
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||||
|
CASE
|
||||||
|
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END AS is_tag_name_match,
|
||||||
|
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
|
||||||
|
FROM tag_fts
|
||||||
|
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||||
|
WHERE tag_fts.searchable_text MATCH ?
|
||||||
|
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
params = [query_escaped + "%", fts_query, limit, offset]
|
||||||
|
|
||||||
|
return sql, params
|
||||||
|
|
||||||
def _find_matched_alias(
|
def _find_matched_alias(
|
||||||
self, query: str, tag_name: str, aliases_str: str
|
self, query: str, tag_name: str, aliases_str: str
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|||||||
428
py/services/wildcard_service.py
Normal file
428
py/services/wildcard_service.py
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
"""Managed wildcard loading, search, and text expansion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from ..utils.settings_paths import get_settings_dir
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
|
||||||
|
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
|
||||||
|
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
|
||||||
|
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*([0-9.]+)::")
|
||||||
|
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_wildcard_key(value: str) -> str:
|
||||||
|
return value.replace("\\", "/").strip("/").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_numeric_string(value: str) -> bool:
|
||||||
|
return bool(_NUMERIC_PATTERN.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def contains_dynamic_syntax(text: str) -> bool:
|
||||||
|
"""Return True when text contains supported wildcard or option syntax."""
|
||||||
|
|
||||||
|
return isinstance(text, str) and bool(
|
||||||
|
_WILDCARD_PATTERN.search(text) or _OPTION_PATTERN.search(text)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_wildcards_dir(create: bool = False) -> str:
|
||||||
|
"""Return the managed wildcard directory inside the settings folder."""
|
||||||
|
|
||||||
|
settings_dir = get_settings_dir(create=create)
|
||||||
|
wildcards_dir = os.path.join(settings_dir, "wildcards")
|
||||||
|
if create:
|
||||||
|
os.makedirs(wildcards_dir, exist_ok=True)
|
||||||
|
return wildcards_dir
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WildcardEntry:
|
||||||
|
key: str
|
||||||
|
values_count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WildcardMetadata:
|
||||||
|
has_wildcards: bool
|
||||||
|
wildcards_dir: str
|
||||||
|
supported_formats: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class WildcardService:
|
||||||
|
"""Discover wildcard keys and expand wildcard syntax."""
|
||||||
|
|
||||||
|
_instance: Optional["WildcardService"] = None
|
||||||
|
|
||||||
|
def __new__(cls) -> "WildcardService":
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if getattr(self, "_initialized", False):
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
self._cached_signature: tuple[tuple[str, int, int], ...] | None = None
|
||||||
|
self._wildcard_dict: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls) -> "WildcardService":
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
def search_keys(
|
||||||
|
self, search_term: str, limit: int = 20, offset: int = 0
|
||||||
|
) -> list[str]:
|
||||||
|
"""Search wildcard keys for autocomplete."""
|
||||||
|
|
||||||
|
normalized_term = _normalize_wildcard_key(search_term).strip()
|
||||||
|
if not normalized_term:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ranked: list[tuple[int, str]] = []
|
||||||
|
compact_term = normalized_term.replace("/", "")
|
||||||
|
for key in self.get_wildcard_dict().keys():
|
||||||
|
score = self._score_entry(key, normalized_term, compact_term)
|
||||||
|
if score is not None:
|
||||||
|
ranked.append((score, key))
|
||||||
|
|
||||||
|
ranked.sort(key=lambda item: (-item[0], item[1]))
|
||||||
|
keys = [key for _, key in ranked]
|
||||||
|
return keys[offset : offset + limit]
|
||||||
|
|
||||||
|
def expand_text(self, text: str, seed: int | None = None) -> str:
|
||||||
|
"""Expand wildcard and dynamic prompt syntax for a text value."""
|
||||||
|
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
rng = random.Random(seed) if seed is not None else random.Random()
|
||||||
|
wildcard_dict = self.get_wildcard_dict()
|
||||||
|
if not wildcard_dict:
|
||||||
|
return self._expand_options_only(text, rng)
|
||||||
|
|
||||||
|
current = text
|
||||||
|
remaining_depth = 100
|
||||||
|
|
||||||
|
while remaining_depth > 0:
|
||||||
|
remaining_depth -= 1
|
||||||
|
after_options, options_replaced = self._replace_options(current, rng)
|
||||||
|
current, wildcards_replaced = self._replace_wildcards(
|
||||||
|
after_options, rng, wildcard_dict
|
||||||
|
)
|
||||||
|
if not options_replaced and not wildcards_replaced:
|
||||||
|
break
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
def get_wildcard_dict(self) -> dict[str, list[str]]:
|
||||||
|
signature = self._build_signature()
|
||||||
|
if signature != self._cached_signature:
|
||||||
|
self._wildcard_dict = self._scan_wildcard_dict()
|
||||||
|
self._cached_signature = signature
|
||||||
|
return self._wildcard_dict
|
||||||
|
|
||||||
|
def get_entries(self) -> list[WildcardEntry]:
|
||||||
|
return [
|
||||||
|
WildcardEntry(key=key, values_count=len(values))
|
||||||
|
for key, values in sorted(self.get_wildcard_dict().items())
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_metadata(self, *, create_dir: bool = False) -> WildcardMetadata:
|
||||||
|
wildcards_dir = get_wildcards_dir(create=create_dir)
|
||||||
|
return WildcardMetadata(
|
||||||
|
has_wildcards=bool(self.get_wildcard_dict()),
|
||||||
|
wildcards_dir=wildcards_dir,
|
||||||
|
supported_formats=(".txt", ".yaml", ".yml", ".json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_signature(self) -> tuple[tuple[str, int, int], ...]:
|
||||||
|
root = get_wildcards_dir(create=False)
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
signature: list[tuple[str, int, int]] = []
|
||||||
|
for current_root, _dirs, files in os.walk(root, followlinks=True):
|
||||||
|
for file_name in sorted(files):
|
||||||
|
if not file_name.lower().endswith((".txt", ".yaml", ".yml", ".json")):
|
||||||
|
continue
|
||||||
|
file_path = os.path.join(current_root, file_name)
|
||||||
|
try:
|
||||||
|
stat = os.stat(file_path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
rel_path = os.path.relpath(file_path, root).replace("\\", "/")
|
||||||
|
signature.append((rel_path, int(stat.st_mtime_ns), int(stat.st_size)))
|
||||||
|
signature.sort()
|
||||||
|
return tuple(signature)
|
||||||
|
|
||||||
|
def _scan_wildcard_dict(self) -> dict[str, list[str]]:
|
||||||
|
root = get_wildcards_dir(create=False)
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
collected: dict[str, list[str]] = {}
|
||||||
|
for current_root, _dirs, files in os.walk(root, followlinks=True):
|
||||||
|
for file_name in sorted(files):
|
||||||
|
file_path = os.path.join(current_root, file_name)
|
||||||
|
lower_name = file_name.lower()
|
||||||
|
try:
|
||||||
|
if lower_name.endswith(".txt"):
|
||||||
|
rel_path = os.path.relpath(file_path, root)
|
||||||
|
key = _normalize_wildcard_key(os.path.splitext(rel_path)[0])
|
||||||
|
values = self._read_txt(file_path)
|
||||||
|
if values:
|
||||||
|
collected[key] = values
|
||||||
|
elif lower_name.endswith((".yaml", ".yml")):
|
||||||
|
payload = self._read_yaml(file_path)
|
||||||
|
self._merge_nested_entries(collected, payload)
|
||||||
|
elif lower_name.endswith(".json"):
|
||||||
|
payload = self._read_json(file_path)
|
||||||
|
self._merge_nested_entries(collected, payload)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.warning("Failed to load wildcard file %s: %s", file_path, exc)
|
||||||
|
|
||||||
|
return collected
|
||||||
|
|
||||||
|
def _read_txt(self, file_path: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8", errors="ignore") as handle:
|
||||||
|
return [line.strip() for line in handle.read().splitlines() if line.strip()]
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Failed to read wildcard txt file %s: %s", file_path, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _read_yaml(self, file_path: str) -> Any:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as handle:
|
||||||
|
return yaml.safe_load(handle) or {}
|
||||||
|
|
||||||
|
def _read_json(self, file_path: str) -> Any:
|
||||||
|
with open(file_path, "r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
def _merge_nested_entries(
|
||||||
|
self, collected: dict[str, list[str]], payload: Any
|
||||||
|
) -> None:
|
||||||
|
for key, values in self._flatten_payload(payload):
|
||||||
|
collected[key] = values
|
||||||
|
|
||||||
|
def _flatten_payload(
|
||||||
|
self, payload: Any, prefix: str = ""
|
||||||
|
) -> list[tuple[str, list[str]]]:
|
||||||
|
entries: list[tuple[str, list[str]]] = []
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key, value in payload.items():
|
||||||
|
next_prefix = f"{prefix}/{key}" if prefix else str(key)
|
||||||
|
entries.extend(self._flatten_payload(value, next_prefix))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
if isinstance(payload, list):
|
||||||
|
normalized_prefix = _normalize_wildcard_key(prefix)
|
||||||
|
values = [value.strip() for value in payload if isinstance(value, str) and value.strip()]
|
||||||
|
if normalized_prefix and values:
|
||||||
|
entries.append((normalized_prefix, values))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def _score_entry(
|
||||||
|
self, key: str, normalized_term: str, compact_term: str
|
||||||
|
) -> int | None:
|
||||||
|
key_compact = key.replace("/", "")
|
||||||
|
if key == normalized_term:
|
||||||
|
return 5000
|
||||||
|
if key.startswith(normalized_term):
|
||||||
|
return 4000
|
||||||
|
if f"/{normalized_term}" in key:
|
||||||
|
return 3500
|
||||||
|
if normalized_term in key:
|
||||||
|
return 3000
|
||||||
|
if compact_term and key_compact.startswith(compact_term):
|
||||||
|
return 2500
|
||||||
|
if compact_term and compact_term in key_compact:
|
||||||
|
return 2000
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _expand_options_only(self, text: str, rng: random.Random) -> str:
|
||||||
|
current = text
|
||||||
|
remaining_depth = 100
|
||||||
|
while remaining_depth > 0:
|
||||||
|
remaining_depth -= 1
|
||||||
|
current, replaced = self._replace_options(current, rng)
|
||||||
|
if not replaced:
|
||||||
|
break
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _replace_options(
|
||||||
|
self, text: str, rng: random.Random
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
replaced_any = False
|
||||||
|
|
||||||
|
def replace_option(match: re.Match[str]) -> str:
|
||||||
|
nonlocal replaced_any
|
||||||
|
replacement = self._resolve_option_group(match.group(1), rng)
|
||||||
|
replaced_any = True
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
return _OPTION_PATTERN.sub(replace_option, text), replaced_any
|
||||||
|
|
||||||
|
def _resolve_option_group(self, group_text: str, rng: random.Random) -> str:
|
||||||
|
options = group_text.split("|")
|
||||||
|
multi_select_pattern = options[0].split("$$")
|
||||||
|
select_range: tuple[int, int] | None = None
|
||||||
|
select_separator = " "
|
||||||
|
|
||||||
|
if len(multi_select_pattern) > 1:
|
||||||
|
count_spec = multi_select_pattern[0]
|
||||||
|
range_match = re.match(r"(\d+)(-(\d+))?$", count_spec)
|
||||||
|
shorthand_match = re.match(r"-(\d+)$", count_spec)
|
||||||
|
if range_match:
|
||||||
|
start_text = range_match.group(1)
|
||||||
|
end_text = range_match.group(3)
|
||||||
|
if end_text is not None and _is_numeric_string(start_text) and _is_numeric_string(end_text):
|
||||||
|
select_range = (int(start_text), int(end_text))
|
||||||
|
elif _is_numeric_string(start_text):
|
||||||
|
value = int(start_text)
|
||||||
|
select_range = (value, value)
|
||||||
|
elif shorthand_match:
|
||||||
|
end_text = shorthand_match.group(1)
|
||||||
|
if _is_numeric_string(end_text):
|
||||||
|
select_range = (1, int(end_text))
|
||||||
|
|
||||||
|
if select_range is not None and len(multi_select_pattern) == 2:
|
||||||
|
options[0] = multi_select_pattern[1]
|
||||||
|
elif select_range is not None and len(multi_select_pattern) >= 3:
|
||||||
|
select_separator = multi_select_pattern[1]
|
||||||
|
options[0] = multi_select_pattern[2]
|
||||||
|
|
||||||
|
weighted_options: list[tuple[float, str]] = []
|
||||||
|
for option in options:
|
||||||
|
weight = 1.0
|
||||||
|
parts = option.split("::", 1)
|
||||||
|
if len(parts) == 2 and _is_numeric_string(parts[0].strip()):
|
||||||
|
weight = float(parts[0].strip())
|
||||||
|
weighted_options.append((weight, option))
|
||||||
|
|
||||||
|
if select_range is None:
|
||||||
|
selection_count = 1
|
||||||
|
else:
|
||||||
|
selection_count = rng.randint(select_range[0], select_range[1])
|
||||||
|
|
||||||
|
if selection_count <= 1:
|
||||||
|
return self._strip_weight_prefix(self._weighted_choice(weighted_options, rng))
|
||||||
|
|
||||||
|
selection_count = min(selection_count, len(weighted_options))
|
||||||
|
selected: list[str] = []
|
||||||
|
used_indexes: set[int] = set()
|
||||||
|
while len(selected) < selection_count:
|
||||||
|
picked_index = self._weighted_choice_index(weighted_options, rng)
|
||||||
|
if picked_index in used_indexes:
|
||||||
|
if len(used_indexes) == len(weighted_options):
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
used_indexes.add(picked_index)
|
||||||
|
selected.append(
|
||||||
|
self._strip_weight_prefix(weighted_options[picked_index][1])
|
||||||
|
)
|
||||||
|
|
||||||
|
return select_separator.join(selected)
|
||||||
|
|
||||||
|
def _weighted_choice(
|
||||||
|
self, weighted_options: list[tuple[float, str]], rng: random.Random
|
||||||
|
) -> str:
|
||||||
|
return weighted_options[self._weighted_choice_index(weighted_options, rng)][1]
|
||||||
|
|
||||||
|
def _weighted_choice_index(
|
||||||
|
self, weighted_options: list[tuple[float, str]], rng: random.Random
|
||||||
|
) -> int:
|
||||||
|
total_weight = sum(max(weight, 0.0) for weight, _value in weighted_options)
|
||||||
|
if total_weight <= 0:
|
||||||
|
return rng.randrange(len(weighted_options))
|
||||||
|
|
||||||
|
threshold = rng.uniform(0, total_weight)
|
||||||
|
cumulative = 0.0
|
||||||
|
for index, (weight, _value) in enumerate(weighted_options):
|
||||||
|
cumulative += max(weight, 0.0)
|
||||||
|
if threshold <= cumulative:
|
||||||
|
return index
|
||||||
|
return len(weighted_options) - 1
|
||||||
|
|
||||||
|
def _strip_weight_prefix(self, value: str) -> str:
|
||||||
|
return _WEIGHTED_OPTION_PATTERN.sub("", value, count=1)
|
||||||
|
|
||||||
|
def _replace_wildcards(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
rng: random.Random,
|
||||||
|
wildcard_dict: dict[str, list[str]],
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
replaced_any = False
|
||||||
|
|
||||||
|
def replace_match(match: re.Match[str]) -> str:
|
||||||
|
nonlocal replaced_any
|
||||||
|
replacement = self._resolve_wildcard_match(match.group(1), rng, wildcard_dict)
|
||||||
|
if replacement is None:
|
||||||
|
return match.group(0)
|
||||||
|
replaced_any = True
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
return _WILDCARD_PATTERN.sub(replace_match, text), replaced_any
|
||||||
|
|
||||||
|
def _resolve_wildcard_match(
|
||||||
|
self,
|
||||||
|
raw_key: str,
|
||||||
|
rng: random.Random,
|
||||||
|
wildcard_dict: dict[str, list[str]],
|
||||||
|
) -> str | None:
|
||||||
|
keyword = _normalize_wildcard_key(raw_key)
|
||||||
|
if keyword in wildcard_dict:
|
||||||
|
return rng.choice(wildcard_dict[keyword])
|
||||||
|
|
||||||
|
if "*" in keyword:
|
||||||
|
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
|
||||||
|
compiled = re.compile(f"^{regex_pattern}$")
|
||||||
|
aggregated: list[str] = []
|
||||||
|
for key, values in wildcard_dict.items():
|
||||||
|
if compiled.match(key):
|
||||||
|
aggregated.extend(values)
|
||||||
|
if aggregated:
|
||||||
|
return rng.choice(aggregated)
|
||||||
|
|
||||||
|
if "/" not in keyword:
|
||||||
|
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
|
||||||
|
if fallback_keyword != keyword:
|
||||||
|
return self._resolve_wildcard_match(fallback_keyword, rng, wildcard_dict)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_trigger_words_input(name: str) -> bool:
|
||||||
|
return bool(_TRIGGER_WORD_PATTERN.match(name))
|
||||||
|
|
||||||
|
|
||||||
|
def get_wildcard_service() -> WildcardService:
|
||||||
|
return WildcardService.get_instance()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WildcardService",
|
||||||
|
"WildcardMetadata",
|
||||||
|
"contains_dynamic_syntax",
|
||||||
|
"get_wildcard_service",
|
||||||
|
"get_wildcards_dir",
|
||||||
|
"is_trigger_words_input",
|
||||||
|
]
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any, Dict, Iterable, Mapping, Sequence
|
from typing import Any, Dict, Iterable, Mapping, Sequence
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||||
|
|
||||||
|
|
||||||
|
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
|
||||||
|
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
||||||
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
||||||
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
||||||
"allowNoCredit": True,
|
"allowNoCredit": True,
|
||||||
@@ -17,6 +20,133 @@ _COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
|
|||||||
_COMMERCIAL_SHIFT = 1
|
_COMMERCIAL_SHIFT = 1
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_civitai_page_host(hostname: str | None) -> bool:
|
||||||
|
"""Return whether the hostname is a supported Civitai page domain."""
|
||||||
|
|
||||||
|
if not hostname:
|
||||||
|
return False
|
||||||
|
return hostname.lower() in _SUPPORTED_CIVITAI_PAGE_HOSTS
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_civitai_page_host(hostname: str | None) -> str:
|
||||||
|
"""Return a supported Civitai page host or the default host."""
|
||||||
|
|
||||||
|
if not isinstance(hostname, str):
|
||||||
|
return DEFAULT_CIVITAI_PAGE_HOST
|
||||||
|
|
||||||
|
normalized = hostname.strip().lower()
|
||||||
|
if is_supported_civitai_page_host(normalized):
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
return DEFAULT_CIVITAI_PAGE_HOST
|
||||||
|
|
||||||
|
|
||||||
|
def build_civitai_model_page_url(
|
||||||
|
model_id: str | int | None,
|
||||||
|
version_id: str | int | None = None,
|
||||||
|
*,
|
||||||
|
host: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Build a Civitai model or model-version page URL."""
|
||||||
|
|
||||||
|
normalized_host = normalize_civitai_page_host(host)
|
||||||
|
normalized_model_id = str(model_id).strip() if model_id is not None else ""
|
||||||
|
normalized_version_id = str(version_id).strip() if version_id is not None else ""
|
||||||
|
|
||||||
|
if normalized_model_id:
|
||||||
|
path = f"/models/{normalized_model_id}"
|
||||||
|
query = f"modelVersionId={normalized_version_id}" if normalized_version_id else ""
|
||||||
|
return urlunparse(("https", normalized_host, path, "", query, ""))
|
||||||
|
|
||||||
|
if normalized_version_id:
|
||||||
|
return urlunparse(
|
||||||
|
("https", normalized_host, f"/model-versions/{normalized_version_id}", "", "", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_supported_civitai_page_url(url: str | None):
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if parsed.scheme not in {"http", "https"}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not is_supported_civitai_page_host(parsed.hostname):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def extract_civitai_model_url_parts(
|
||||||
|
url: str | None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Extract model and version identifiers from a supported Civitai model URL."""
|
||||||
|
|
||||||
|
parsed = _parse_supported_civitai_page_url(url)
|
||||||
|
if parsed is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
path_match = re.search(r"/models/(\d+)", parsed.path)
|
||||||
|
if not path_match:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
model_id = path_match.group(1)
|
||||||
|
|
||||||
|
query_params = parse_qs(parsed.query)
|
||||||
|
version_values = query_params.get("modelVersionId") or []
|
||||||
|
version_id = version_values[0] if version_values else None
|
||||||
|
return model_id, version_id
|
||||||
|
|
||||||
|
|
||||||
|
def extract_civitai_image_id(url: str | None) -> str | None:
|
||||||
|
"""Extract the image identifier from a supported Civitai image page URL."""
|
||||||
|
|
||||||
|
parsed = _parse_supported_civitai_page_url(url)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path_match = re.search(r"/images/(\d+)", parsed.path)
|
||||||
|
if not path_match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return path_match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_civitai_download_url(url: str | None) -> str | None:
|
||||||
|
"""Rewrite Civitai download URLs to the canonical authenticated host."""
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return url
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except ValueError:
|
||||||
|
return url
|
||||||
|
|
||||||
|
hostname = parsed.hostname.lower() if parsed.hostname else None
|
||||||
|
if hostname != "civitai.red" or not parsed.path.startswith("/api/download/"):
|
||||||
|
return url
|
||||||
|
|
||||||
|
return urlunparse(parsed._replace(netloc="civitai.com"))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_civitai_page_host(url: str | None) -> str | None:
|
||||||
|
"""Extract the supported Civitai page host from a URL."""
|
||||||
|
|
||||||
|
parsed = _parse_supported_civitai_page_url(url)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return parsed.hostname.lower() if parsed.hostname else None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_commercial_values(value: Any) -> Sequence[str]:
|
def _normalize_commercial_values(value: Any) -> Sequence[str]:
|
||||||
"""Return a normalized list of commercial permissions preserving source values."""
|
"""Return a normalized list of commercial permissions preserving source values."""
|
||||||
|
|
||||||
@@ -199,6 +329,10 @@ def rewrite_preview_url(
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"build_license_flags",
|
"build_license_flags",
|
||||||
|
"extract_civitai_image_id",
|
||||||
|
"extract_civitai_page_host",
|
||||||
|
"extract_civitai_model_url_parts",
|
||||||
|
"is_supported_civitai_page_host",
|
||||||
"resolve_license_payload",
|
"resolve_license_payload",
|
||||||
"resolve_license_info",
|
"resolve_license_info",
|
||||||
"rewrite_preview_url",
|
"rewrite_preview_url",
|
||||||
|
|||||||
@@ -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.2"
|
version = "1.0.5"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
@@ -14,7 +14,8 @@ dependencies = [
|
|||||||
"natsort",
|
"natsort",
|
||||||
"GitPython",
|
"GitPython",
|
||||||
"aiosqlite",
|
"aiosqlite",
|
||||||
"platformdirs"
|
"platformdirs",
|
||||||
|
"pyyaml"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ GitPython
|
|||||||
aiosqlite
|
aiosqlite
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
platformdirs
|
platformdirs
|
||||||
|
pyyaml
|
||||||
|
|||||||
@@ -243,3 +243,58 @@
|
|||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.excluded-view-banner {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
|
||||||
|
var(--card-bg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-view-banner__content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-view-banner__title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-view-banner__back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-view-banner__back:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.excluded-view-banner__content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excluded-view-banner__back {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -680,3 +680,22 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.excluded-model {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-excluded-badge {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in oklab, var(--warning-color, #d97706) 85%, white 15%);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,6 +163,18 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-version-row.is-clickable .version-actions,
|
||||||
|
.model-version-row.is-clickable .version-badges,
|
||||||
|
.model-version-row.is-clickable .version-action,
|
||||||
|
.model-version-row.is-clickable .version-civitai-link {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-version-row.is-clickable .version-action,
|
||||||
|
.model-version-row.is-clickable .version-civitai-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.model-version-row.is-current {
|
.model-version-row.is-current {
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent),
|
box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent),
|
||||||
@@ -217,6 +229,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.versions-tab-version-name {
|
.versions-tab-version-name {
|
||||||
@@ -226,6 +239,27 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-civitai-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-civitai-link:hover,
|
||||||
|
.version-civitai-link:focus-visible {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
background: color-mix(in oklch, var(--lora-accent) 12%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.version-badges {
|
.version-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doctor-status-badge {
|
.doctor-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
@@ -16,7 +19,7 @@
|
|||||||
background: var(--lora-error);
|
background: var(--lora-error);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 18px;
|
line-height: 1;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,27 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloaded-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: color-mix(in oklch, var(--badge-update-bg, #4a90e2) 22%, transparent);
|
||||||
|
color: var(--badge-update-bg, #4a90e2);
|
||||||
|
border: 1px solid color-mix(in oklch, var(--badge-update-bg, #4a90e2) 50%, transparent);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloaded-badge i {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Early Access Badge */
|
/* Early Access Badge */
|
||||||
.early-access-badge {
|
.early-access-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) {
|
|||||||
return {
|
return {
|
||||||
// Base CRUD operations
|
// Base CRUD operations
|
||||||
list: `/api/lm/${modelType}/list`,
|
list: `/api/lm/${modelType}/list`,
|
||||||
|
excluded: `/api/lm/${modelType}/excluded`,
|
||||||
delete: `/api/lm/${modelType}/delete`,
|
delete: `/api/lm/${modelType}/delete`,
|
||||||
exclude: `/api/lm/${modelType}/exclude`,
|
exclude: `/api/lm/${modelType}/exclude`,
|
||||||
|
unexclude: `/api/lm/${modelType}/unexclude`,
|
||||||
rename: `/api/lm/${modelType}/rename`,
|
rename: `/api/lm/${modelType}/rename`,
|
||||||
save: `/api/lm/${modelType}/save-metadata`,
|
save: `/api/lm/${modelType}/save-metadata`,
|
||||||
cancelTask: `/api/lm/${modelType}/cancel-task`,
|
cancelTask: `/api/lm/${modelType}/cancel-task`,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class BaseModelApiClient {
|
|||||||
async fetchModelsPage(page = 1, pageSize = null) {
|
async fetchModelsPage(page = 1, pageSize = null) {
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
||||||
|
const isExcludedView = pageState.viewMode === 'excluded';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = this._buildQueryParams({
|
const params = this._buildQueryParams({
|
||||||
@@ -71,7 +72,10 @@ export class BaseModelApiClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`);
|
const endpoint = isExcludedView
|
||||||
|
? this.apiConfig.endpoints.excluded
|
||||||
|
: this.apiConfig.endpoints.list;
|
||||||
|
const response = await fetch(`${endpoint}?${params}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
|
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
@@ -84,7 +88,7 @@ export class BaseModelApiClient {
|
|||||||
totalPages: data.total_pages,
|
totalPages: data.total_pages,
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
hasMore: page < data.total_pages,
|
hasMore: page < data.total_pages,
|
||||||
folders: data.folders
|
folders: data.folders || []
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -212,6 +216,50 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async unexcludeModel(filePath) {
|
||||||
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading(`Restoring ${this.apiConfig.config.singularName}...`);
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.unexclude, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ file_path: filePath })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to restore ${this.apiConfig.config.singularName}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
state.virtualScroller.removeItemByFilePath(filePath);
|
||||||
|
}
|
||||||
|
showToast(
|
||||||
|
'toast.api.restoreSuccess',
|
||||||
|
{ type: this.apiConfig.config.displayName },
|
||||||
|
'success',
|
||||||
|
`Restored ${this.apiConfig.config.displayName}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(data.error || `Failed to restore ${this.apiConfig.config.singularName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error restoring ${this.apiConfig.config.singularName}:`, error);
|
||||||
|
showToast(
|
||||||
|
'toast.api.restoreFailed',
|
||||||
|
{ type: this.apiConfig.config.singularName, message: error.message },
|
||||||
|
'error',
|
||||||
|
`Failed to restore ${this.apiConfig.config.singularName}: ${error.message}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async renameModelFile(filePath, newFileName) {
|
async renameModelFile(filePath, newFileName) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
||||||
@@ -883,20 +931,21 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
_buildQueryParams(baseParams, pageState) {
|
_buildQueryParams(baseParams, pageState) {
|
||||||
const params = new URLSearchParams(baseParams);
|
const params = new URLSearchParams(baseParams);
|
||||||
|
const isExcludedView = pageState.viewMode === 'excluded';
|
||||||
|
|
||||||
if (pageState.activeFolder !== null) {
|
if (!isExcludedView && pageState.activeFolder !== null) {
|
||||||
params.append('folder', pageState.activeFolder);
|
params.append('folder', pageState.activeFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState.showFavoritesOnly) {
|
if (!isExcludedView && pageState.showFavoritesOnly) {
|
||||||
params.append('favorites_only', 'true');
|
params.append('favorites_only', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState.showUpdateAvailableOnly) {
|
if (!isExcludedView && pageState.showUpdateAvailableOnly) {
|
||||||
params.append('update_available_only', 'true');
|
params.append('update_available_only', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
if (!isExcludedView && this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||||
params.append('first_letter', pageState.activeLetterFilter);
|
params.append('first_letter', pageState.activeLetterFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,7 +967,7 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||||
|
|
||||||
if (pageState.filters) {
|
if (!isExcludedView && pageState.filters) {
|
||||||
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
||||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||||
if (state === 'include') {
|
if (state === 'include') {
|
||||||
@@ -981,7 +1030,9 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._addModelSpecificParams(params, pageState);
|
if (!isExcludedView) {
|
||||||
|
this._addModelSpecificParams(params, pageState);
|
||||||
|
}
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { RecipeCard } from '../components/RecipeCard.js';
|
import { RecipeCard } from '../components/RecipeCard.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
|
||||||
|
|
||||||
const RECIPE_ENDPOINTS = {
|
const RECIPE_ENDPOINTS = {
|
||||||
list: '/api/lm/recipes',
|
list: '/api/lm/recipes',
|
||||||
@@ -31,6 +32,20 @@ export function extractRecipeId(filePath) {
|
|||||||
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchRecipeDetails(recipeId) {
|
||||||
|
if (!recipeId) {
|
||||||
|
throw new Error('Unable to determine recipe ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedRecipeId = encodeURIComponent(recipeId);
|
||||||
|
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch recipes with pagination for virtual scrolling
|
* Fetch recipes with pagination for virtual scrolling
|
||||||
* @param {number} page - Page number to fetch
|
* @param {number} page - Page number to fetch
|
||||||
@@ -61,7 +76,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
// If we have a specific recipe ID to load
|
// If we have a specific recipe ID to load
|
||||||
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||||
// Special case: load specific recipe
|
// Special case: load specific recipe
|
||||||
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
|
const response = await fetch(
|
||||||
|
`${RECIPE_ENDPOINTS.detail}/${encodeURIComponent(pageState.customFilter.recipeId)}`
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||||
@@ -166,10 +183,12 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
const {
|
const {
|
||||||
modelType = 'lora',
|
modelType = 'lora',
|
||||||
updateFolders = false,
|
updateFolders = false,
|
||||||
fetchPageFunction
|
fetchPageFunction,
|
||||||
|
preserveScroll = false
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pageState.isLoading = true;
|
pageState.isLoading = true;
|
||||||
@@ -191,6 +210,10 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = 2; // Next page will be 2
|
pageState.currentPage = 2; // Next page will be 2
|
||||||
|
|
||||||
|
if (scrollSnapshot) {
|
||||||
|
await restoreScrollPosition(scrollSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reloading ${modelType}s:`, error);
|
console.error(`Error reloading ${modelType}s:`, error);
|
||||||
@@ -211,10 +234,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
modelType = 'lora',
|
modelType = 'lora',
|
||||||
resetPage = false,
|
resetPage = false,
|
||||||
updateFolders = false,
|
updateFolders = false,
|
||||||
fetchPageFunction
|
fetchPageFunction,
|
||||||
|
preserveScroll = false
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start loading state
|
// Start loading state
|
||||||
@@ -239,6 +264,10 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = 2; // Next page to load would be 2
|
pageState.currentPage = 2; // Next page to load would be 2
|
||||||
|
|
||||||
|
if (scrollSnapshot) {
|
||||||
|
await restoreScrollPosition(scrollSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading ${modelType}s:`, error);
|
console.error(`Error loading ${modelType}s:`, error);
|
||||||
@@ -254,11 +283,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
* @param {boolean} updateFolders - Whether to update folder tags
|
* @param {boolean} updateFolders - Whether to update folder tags
|
||||||
* @returns {Promise<Object>} The fetch result
|
* @returns {Promise<Object>} The fetch result
|
||||||
*/
|
*/
|
||||||
export async function resetAndReload(updateFolders = false) {
|
export async function resetAndReload(updateFolders = false, options = {}) {
|
||||||
return resetAndReloadWithVirtualScroll({
|
return resetAndReloadWithVirtualScroll({
|
||||||
modelType: 'recipe',
|
modelType: 'recipe',
|
||||||
updateFolders,
|
updateFolders,
|
||||||
fetchPageFunction: fetchRecipesPage
|
fetchPageFunction: fetchRecipesPage,
|
||||||
|
preserveScroll: options.preserveScroll === true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +300,7 @@ export async function syncChanges() {
|
|||||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
state.loadingManager.showSimpleLoading('Syncing changes...');
|
||||||
|
|
||||||
// Simply reload the recipes without rebuilding cache
|
// Simply reload the recipes without rebuilding cache
|
||||||
await resetAndReload();
|
await resetAndReload(false, { preserveScroll: true });
|
||||||
|
|
||||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
showToast('toast.recipes.syncComplete', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -298,7 +328,7 @@ export async function refreshRecipes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After successful cache rebuild, reload the recipes
|
// After successful cache rebuild, reload the recipes
|
||||||
await resetAndReload();
|
await resetAndReload(false, { preserveScroll: true });
|
||||||
|
|
||||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -349,9 +379,10 @@ export function createRecipeCard(recipe) {
|
|||||||
* @param {Object} updates - The metadata updates to apply
|
* @param {Object} updates - The metadata updates to apply
|
||||||
* @returns {Promise<Object>} The updated recipe data
|
* @returns {Promise<Object>} The updated recipe data
|
||||||
*/
|
*/
|
||||||
export async function updateRecipeMetadata(filePath, updates) {
|
export async function updateRecipeMetadata(filePath, updates, options = {}) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||||
|
const listFilePath = options.listFilePath || filePath;
|
||||||
|
|
||||||
// Extract recipeId from filePath (basename without extension)
|
// Extract recipeId from filePath (basename without extension)
|
||||||
const recipeId = extractRecipeId(filePath);
|
const recipeId = extractRecipeId(filePath);
|
||||||
@@ -359,7 +390,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
throw new Error('Unable to determine recipe ID');
|
throw new Error('Unable to determine recipe ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${encodeURIComponent(recipeId)}/update`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -374,7 +405,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
throw new Error(data.error || 'Failed to update recipe');
|
throw new Error(data.error || 'Failed to update recipe');
|
||||||
}
|
}
|
||||||
|
|
||||||
state.virtualScroller.updateSingleItem(filePath, updates);
|
state.virtualScroller.updateSingleItem(listFilePath, updates);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
super.showMenu(x, y, card);
|
super.showMenu(x, y, card);
|
||||||
|
this.updateExcludeMenuItem();
|
||||||
|
|
||||||
// Update the "Move to other root" label based on current model type
|
// Update the "Move to other root" label based on current model type
|
||||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
||||||
@@ -83,6 +84,9 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'restore':
|
||||||
|
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
|||||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showMenu(x, y, card) {
|
||||||
|
super.showMenu(x, y, card);
|
||||||
|
this.updateExcludeMenuItem();
|
||||||
|
}
|
||||||
|
|
||||||
handleMenuAction(action) {
|
handleMenuAction(action) {
|
||||||
// First try to handle with common actions
|
// First try to handle with common actions
|
||||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||||
@@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
|||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'restore':
|
||||||
|
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
|
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
|
||||||
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||||
|
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||||
|
|
||||||
if (isRecipesPage) {
|
if (isRecipesPage) {
|
||||||
@@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
licenseRefreshItem?.classList.add('hidden');
|
licenseRefreshItem?.classList.add('hidden');
|
||||||
downloadExamplesItem?.classList.add('hidden');
|
downloadExamplesItem?.classList.add('hidden');
|
||||||
cleanupExamplesItem?.classList.add('hidden');
|
cleanupExamplesItem?.classList.add('hidden');
|
||||||
|
excludedModelsItem?.classList.add('hidden');
|
||||||
repairRecipesItem?.classList.remove('hidden');
|
repairRecipesItem?.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
modelUpdateItem?.classList.remove('hidden');
|
modelUpdateItem?.classList.remove('hidden');
|
||||||
licenseRefreshItem?.classList.remove('hidden');
|
licenseRefreshItem?.classList.remove('hidden');
|
||||||
downloadExamplesItem?.classList.remove('hidden');
|
downloadExamplesItem?.classList.remove('hidden');
|
||||||
cleanupExamplesItem?.classList.remove('hidden');
|
cleanupExamplesItem?.classList.remove('hidden');
|
||||||
|
excludedModelsItem?.classList.remove('hidden');
|
||||||
repairRecipesItem?.classList.add('hidden');
|
repairRecipesItem?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
console.error('Failed to repair recipes:', error);
|
console.error('Failed to repair recipes:', error);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'manage-excluded-models':
|
||||||
|
this.manageExcludedModels();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Unhandled global context menu action: ${action}`);
|
console.warn(`Unhandled global context menu action: ${action}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
manageExcludedModels() {
|
||||||
|
window.pageControls?.enterExcludedView?.().catch((error) => {
|
||||||
|
console.error('Failed to open excluded models view:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async downloadExampleImages(menuItem) {
|
async downloadExampleImages(menuItem) {
|
||||||
const downloadPath = state?.global?.settings?.example_images_path;
|
const downloadPath = state?.global?.settings?.example_images_path;
|
||||||
if (!downloadPath) {
|
if (!downloadPath) {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showMenu(x, y, card) {
|
||||||
|
super.showMenu(x, y, card);
|
||||||
|
this.updateExcludeMenuItem();
|
||||||
|
}
|
||||||
|
|
||||||
handleMenuAction(action, menuItem) {
|
handleMenuAction(action, menuItem) {
|
||||||
// First try to handle with common actions
|
// First try to handle with common actions
|
||||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||||
@@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'restore':
|
||||||
|
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,43 @@ import { bulkManager } from '../../managers/BulkManager.js';
|
|||||||
import { MODEL_CONFIG } from '../../api/apiConfig.js';
|
import { MODEL_CONFIG } from '../../api/apiConfig.js';
|
||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
||||||
|
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
|
||||||
|
|
||||||
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
||||||
export const ModelContextMenuMixin = {
|
export const ModelContextMenuMixin = {
|
||||||
|
isExcludedView() {
|
||||||
|
return state?.pages?.[state.currentPageType]?.viewMode === 'excluded';
|
||||||
|
},
|
||||||
|
|
||||||
|
updateExcludeMenuItem() {
|
||||||
|
const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]');
|
||||||
|
if (!excludeItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExcludedView = this.isExcludedView();
|
||||||
|
excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude';
|
||||||
|
excludeItem.innerHTML = isExcludedView
|
||||||
|
? `<i class="fas fa-undo"></i> <span>${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}</span>`
|
||||||
|
: `<i class="fas fa-eye-slash"></i> <span>${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}</span>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreExcludedModel(filePath) {
|
||||||
|
const restored = await getModelApiClient().unexcludeModel(filePath);
|
||||||
|
if (!restored) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.pageControls?.exitExcludedView) {
|
||||||
|
await window.pageControls.exitExcludedView();
|
||||||
|
} else {
|
||||||
|
const resetFn = this.resetAndReload || resetAndReload;
|
||||||
|
if (typeof resetFn === 'function') {
|
||||||
|
await resetFn(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// NSFW Selector methods
|
// NSFW Selector methods
|
||||||
initNSFWSelector() {
|
initNSFWSelector() {
|
||||||
if (this._nsfwSelectorInitialized) {
|
if (this._nsfwSelectorInitialized) {
|
||||||
@@ -154,25 +188,7 @@ export const ModelContextMenuMixin = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
extractModelVersionId(url) {
|
extractModelVersionId(url) {
|
||||||
try {
|
return extractCivitaiModelUrlParts(url);
|
||||||
// Handle all three URL formats:
|
|
||||||
// 1. https://civitai.com/models/649516
|
|
||||||
// 2. https://civitai.com/models/649516?modelVersionId=726676
|
|
||||||
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
|
|
||||||
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
|
|
||||||
// Extract model ID from path
|
|
||||||
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
|
|
||||||
const modelId = pathMatch ? pathMatch[1] : null;
|
|
||||||
|
|
||||||
// Extract model version ID from query parameters
|
|
||||||
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
|
||||||
|
|
||||||
return { modelId, modelVersionId };
|
|
||||||
} catch (e) {
|
|
||||||
return { modelId: null, modelVersionId: null };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
parseModelId(value) {
|
parseModelId(value) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Override resetAndReload for recipe context
|
// Override resetAndReload for recipe context
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||||
return resetAndReload();
|
return resetAndReload(false, { preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
|
|||||||
@@ -3,16 +3,105 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow
|
|||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||||
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { downloadManager } from '../managers/DownloadManager.js';
|
import { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
|
|
||||||
|
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||||
|
'prompt',
|
||||||
|
'negative_prompt',
|
||||||
|
'steps',
|
||||||
|
'sampler',
|
||||||
|
'cfg_scale',
|
||||||
|
'seed',
|
||||||
|
'size',
|
||||||
|
'clip_skip',
|
||||||
|
'denoising_strength',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const GEN_PARAM_NORMALIZATION = {
|
||||||
|
cfg: 'cfg_scale',
|
||||||
|
cfgScale: 'cfg_scale',
|
||||||
|
clipSkip: 'clip_skip',
|
||||||
|
negativePrompt: 'negative_prompt',
|
||||||
|
Sampler: 'sampler',
|
||||||
|
sampler_name: 'sampler',
|
||||||
|
scheduler: 'sampler',
|
||||||
|
Steps: 'steps',
|
||||||
|
Seed: 'seed',
|
||||||
|
Size: 'size',
|
||||||
|
Prompt: 'prompt',
|
||||||
|
'Negative prompt': 'negative_prompt',
|
||||||
|
'Cfg scale': 'cfg_scale',
|
||||||
|
'Clip skip': 'clip_skip',
|
||||||
|
'Denoising strength': 'denoising_strength',
|
||||||
|
};
|
||||||
|
|
||||||
class RecipeModal {
|
class RecipeModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.promptEditorState = {};
|
this.promptEditorState = {};
|
||||||
|
this.recipeHydrationRequestId = 0;
|
||||||
|
this.resetLocalEditState();
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createLocalEditState() {
|
||||||
|
return {
|
||||||
|
title: { commitVersion: 0, isDirty: false },
|
||||||
|
tags: { commitVersion: 0, isDirty: false },
|
||||||
|
prompt: { commitVersion: 0, isDirty: false },
|
||||||
|
negative_prompt: { commitVersion: 0, isDirty: false },
|
||||||
|
source_path: { commitVersion: 0, isDirty: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resetLocalEditState() {
|
||||||
|
this.localEditState = this.createLocalEditState();
|
||||||
|
this.sourceUrlEditState = this.localEditState.source_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalEditState(field) {
|
||||||
|
if (!this.localEditState[field]) {
|
||||||
|
this.localEditState[field] = { commitVersion: 0, isDirty: false };
|
||||||
|
}
|
||||||
|
return this.localEditState[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
markFieldDirty(field) {
|
||||||
|
this.getLocalEditState(field).isDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFieldDirty(field) {
|
||||||
|
this.getLocalEditState(field).isDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitField(field) {
|
||||||
|
const fieldState = this.getLocalEditState(field);
|
||||||
|
fieldState.isDirty = false;
|
||||||
|
fieldState.commitVersion += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
captureLocalEditVersions() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(this.localEditState).map(([field, state]) => [
|
||||||
|
field,
|
||||||
|
state.commitVersion,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldPreserveField(field, requestVersions) {
|
||||||
|
const fieldState = this.getLocalEditState(field);
|
||||||
|
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
|
||||||
|
return fieldState.isDirty || fieldState.commitVersion !== requestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFieldCommittedSinceRequest(field, requestVersions) {
|
||||||
|
const fieldState = this.getLocalEditState(field);
|
||||||
|
const requestVersion = requestVersions?.[field] ?? fieldState.commitVersion;
|
||||||
|
return fieldState.commitVersion !== requestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.setupCopyButtons();
|
this.setupCopyButtons();
|
||||||
this.setupPromptEditors();
|
this.setupPromptEditors();
|
||||||
@@ -87,8 +176,10 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
|
const hydratedRecipe = recipe || {};
|
||||||
|
this.resetLocalEditState();
|
||||||
// Store the full recipe for editing
|
// Store the full recipe for editing
|
||||||
this.currentRecipe = recipe;
|
this.currentRecipe = hydratedRecipe;
|
||||||
this.resetPromptEditors();
|
this.resetPromptEditors();
|
||||||
|
|
||||||
// Set modal title with edit icon
|
// Set modal title with edit icon
|
||||||
@@ -96,11 +187,11 @@ class RecipeModal {
|
|||||||
if (modalTitle) {
|
if (modalTitle) {
|
||||||
modalTitle.innerHTML = `
|
modalTitle.innerHTML = `
|
||||||
<div class="editable-content">
|
<div class="editable-content">
|
||||||
<span class="content-text">${recipe.title || 'Recipe Details'}</span>
|
<span class="content-text">${hydratedRecipe.title || 'Recipe Details'}</span>
|
||||||
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
|
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="recipeTitleEditor" class="content-editor">
|
<div id="recipeTitleEditor" class="content-editor">
|
||||||
<input type="text" class="title-input" value="${recipe.title || ''}">
|
<input type="text" class="title-input" value="${hydratedRecipe.title || ''}">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -122,8 +213,9 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the recipe ID for copy syntax API call
|
// Store the recipe ID for copy syntax API call
|
||||||
this.recipeId = recipe.id;
|
this.recipeId = hydratedRecipe.id;
|
||||||
this.filePath = recipe.file_path;
|
this.filePath = hydratedRecipe.file_path;
|
||||||
|
this.listFilePath = hydratedRecipe.file_path;
|
||||||
|
|
||||||
// Set recipe tags if they exist
|
// Set recipe tags if they exist
|
||||||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
||||||
@@ -143,11 +235,11 @@ class RecipeModal {
|
|||||||
|
|
||||||
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
||||||
|
|
||||||
if (recipe.tags && recipe.tags.length > 0) {
|
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
// Limit displayed tags to 5, show a "+X more" button if needed
|
||||||
const maxVisibleTags = 5;
|
const maxVisibleTags = 5;
|
||||||
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
|
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
|
||||||
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
|
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
|
||||||
|
|
||||||
// Add visible tags
|
// Add visible tags
|
||||||
visibleTags.forEach(tag => {
|
visibleTags.forEach(tag => {
|
||||||
@@ -184,7 +276,7 @@ class RecipeModal {
|
|||||||
// Add all tags to tooltip
|
// Add all tags to tooltip
|
||||||
if (tagsTooltipContent) {
|
if (tagsTooltipContent) {
|
||||||
tagsTooltipContent.innerHTML = '';
|
tagsTooltipContent.innerHTML = '';
|
||||||
recipe.tags.forEach(tag => {
|
hydratedRecipe.tags.forEach(tag => {
|
||||||
const tooltipTag = document.createElement('div');
|
const tooltipTag = document.createElement('div');
|
||||||
tooltipTag.className = 'tooltip-tag';
|
tooltipTag.className = 'tooltip-tag';
|
||||||
tooltipTag.textContent = tag;
|
tooltipTag.textContent = tag;
|
||||||
@@ -201,8 +293,8 @@ class RecipeModal {
|
|||||||
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
||||||
|
|
||||||
// Set current tags in the input
|
// Set current tags in the input
|
||||||
if (recipe.tags && recipe.tags.length > 0) {
|
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||||
tagsInput.value = recipe.tags.join(', ');
|
tagsInput.value = hydratedRecipe.tags.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
||||||
@@ -222,49 +314,15 @@ class RecipeModal {
|
|||||||
// Set recipe image
|
// Set recipe image
|
||||||
const mediaContainer = document.getElementById('recipePreviewContainer');
|
const mediaContainer = document.getElementById('recipePreviewContainer');
|
||||||
if (mediaContainer) {
|
if (mediaContainer) {
|
||||||
// Stop any playing video before replacing content
|
this.syncPreviewMedia(hydratedRecipe);
|
||||||
const existingVideo = mediaContainer.querySelector('video');
|
mediaContainer.querySelector('.source-url-container')?.remove();
|
||||||
if (existingVideo) {
|
mediaContainer.querySelector('.source-url-editor')?.remove();
|
||||||
existingVideo.pause();
|
|
||||||
existingVideo.currentTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the container
|
|
||||||
mediaContainer.innerHTML = '';
|
|
||||||
|
|
||||||
// Ensure file_url exists, fallback to file_path if needed
|
|
||||||
const imageUrl = recipe.file_url ||
|
|
||||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
|
||||||
'/loras_static/images/no-preview.png');
|
|
||||||
|
|
||||||
// Check if the file is a video (mp4)
|
|
||||||
const isVideo = imageUrl.toLowerCase().endsWith('.mp4');
|
|
||||||
|
|
||||||
if (isVideo) {
|
|
||||||
const videoElement = document.createElement('video');
|
|
||||||
videoElement.id = 'recipeModalVideo';
|
|
||||||
videoElement.src = imageUrl;
|
|
||||||
videoElement.controls = true;
|
|
||||||
videoElement.autoplay = false;
|
|
||||||
videoElement.loop = true;
|
|
||||||
videoElement.muted = true;
|
|
||||||
videoElement.className = 'recipe-preview-media';
|
|
||||||
videoElement.alt = recipe.title || 'Recipe Preview';
|
|
||||||
mediaContainer.appendChild(videoElement);
|
|
||||||
} else {
|
|
||||||
const imgElement = document.createElement('img');
|
|
||||||
imgElement.id = 'recipeModalImage';
|
|
||||||
imgElement.src = imageUrl;
|
|
||||||
imgElement.className = 'recipe-preview-media';
|
|
||||||
imgElement.alt = recipe.title || 'Recipe Preview';
|
|
||||||
mediaContainer.appendChild(imgElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add source URL container if the recipe has a source_path
|
// Add source URL container if the recipe has a source_path
|
||||||
const sourceUrlContainer = document.createElement('div');
|
const sourceUrlContainer = document.createElement('div');
|
||||||
sourceUrlContainer.className = 'source-url-container';
|
sourceUrlContainer.className = 'source-url-container';
|
||||||
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0;
|
const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0;
|
||||||
const sourceUrl = hasSourceUrl ? recipe.source_path : '';
|
const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : '';
|
||||||
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
|
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
|
||||||
|
|
||||||
sourceUrlContainer.innerHTML = `
|
sourceUrlContainer.innerHTML = `
|
||||||
@@ -293,40 +351,273 @@ class RecipeModal {
|
|||||||
mediaContainer.appendChild(sourceUrlContainer);
|
mediaContainer.appendChild(sourceUrlContainer);
|
||||||
mediaContainer.appendChild(sourceUrlEditor);
|
mediaContainer.appendChild(sourceUrlEditor);
|
||||||
|
|
||||||
// Set up event listeners for source URL functionality
|
// Delay binding slightly so modal layout is stable, but skip if this render was torn down.
|
||||||
|
const sourceUrlContainerRef = sourceUrlContainer;
|
||||||
|
const sourceUrlEditorRef = sourceUrlEditor;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.setupSourceUrlHandlers();
|
this.setupSourceUrlHandlers();
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set generation parameters
|
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||||
|
this.syncResourcesSection(hydratedRecipe);
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
modalManager.showModal('recipeModal');
|
||||||
|
|
||||||
|
if (this.recipeId) {
|
||||||
|
const hydrationRequestId = ++this.recipeHydrationRequestId;
|
||||||
|
const requestEditVersions = this.captureLocalEditVersions();
|
||||||
|
this.hydrateRecipeDetails(
|
||||||
|
this.recipeId,
|
||||||
|
hydrationRequestId,
|
||||||
|
requestEditVersions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
|
||||||
|
try {
|
||||||
|
const fullRecipe = await fetchRecipeDetails(recipeId);
|
||||||
|
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRecipe = { ...this.currentRecipe };
|
||||||
|
|
||||||
|
if (!this.hasFieldCommittedSinceRequest('title', requestEditVersions) && fullRecipe.title !== undefined) {
|
||||||
|
nextRecipe.title = fullRecipe.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasFieldCommittedSinceRequest('tags', requestEditVersions) && fullRecipe.tags !== undefined) {
|
||||||
|
nextRecipe.tags = Array.isArray(fullRecipe.tags) ? [...fullRecipe.tags] : fullRecipe.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasFieldCommittedSinceRequest('source_path', requestEditVersions)) {
|
||||||
|
nextRecipe.source_path = fullRecipe.source_path || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousFilePath = nextRecipe.file_path;
|
||||||
|
if (fullRecipe.file_path !== undefined) {
|
||||||
|
nextRecipe.file_path = fullRecipe.file_path;
|
||||||
|
}
|
||||||
|
if (fullRecipe.file_url !== undefined) {
|
||||||
|
nextRecipe.file_url = fullRecipe.file_url;
|
||||||
|
}
|
||||||
|
if (fullRecipe.preview_url !== undefined) {
|
||||||
|
nextRecipe.preview_url = fullRecipe.preview_url;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
fullRecipe.file_path !== undefined &&
|
||||||
|
fullRecipe.file_path !== previousFilePath &&
|
||||||
|
fullRecipe.file_url === undefined &&
|
||||||
|
fullRecipe.preview_url === undefined
|
||||||
|
) {
|
||||||
|
delete nextRecipe.file_url;
|
||||||
|
delete nextRecipe.preview_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullRecipe.gen_params !== undefined) {
|
||||||
|
const previousGenParams = nextRecipe.gen_params || {};
|
||||||
|
const incomingGenParams = { ...(fullRecipe.gen_params || {}) };
|
||||||
|
for (const [key, value] of Object.entries(previousGenParams)) {
|
||||||
|
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
|
||||||
|
incomingGenParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRecipe.gen_params = incomingGenParams;
|
||||||
|
} else {
|
||||||
|
const previousGenParams = nextRecipe.gen_params || {};
|
||||||
|
const preservedGenParams = {};
|
||||||
|
for (const [key, value] of Object.entries(previousGenParams)) {
|
||||||
|
if (this.hasFieldCommittedSinceRequest(key, requestEditVersions)) {
|
||||||
|
preservedGenParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextRecipe.gen_params = preservedGenParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullRecipe.checkpoint !== undefined) {
|
||||||
|
nextRecipe.checkpoint = fullRecipe.checkpoint;
|
||||||
|
} else {
|
||||||
|
delete nextRecipe.checkpoint;
|
||||||
|
}
|
||||||
|
if (fullRecipe.loras !== undefined) {
|
||||||
|
nextRecipe.loras = Array.isArray(fullRecipe.loras) ? [...fullRecipe.loras] : fullRecipe.loras;
|
||||||
|
} else {
|
||||||
|
delete nextRecipe.loras;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentRecipe = nextRecipe;
|
||||||
|
this.filePath = this.currentRecipe.file_path || this.filePath;
|
||||||
|
|
||||||
|
this.syncHydratedRecipeFields(requestEditVersions);
|
||||||
|
} catch (error) {
|
||||||
|
// Keep the cached recipe visible if hydration fails.
|
||||||
|
console.warn('Failed to hydrate recipe details:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHydratedRecipeFields(requestEditVersions = {}) {
|
||||||
|
this.syncPreviewMedia(this.currentRecipe);
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('title', requestEditVersions)) {
|
||||||
|
this.syncTitleDisplay(this.currentRecipe?.title || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('tags', requestEditVersions)) {
|
||||||
|
this.syncTagsDisplay(this.currentRecipe?.tags || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('prompt', requestEditVersions)) {
|
||||||
|
this.syncPromptField(
|
||||||
|
'prompt',
|
||||||
|
this.currentRecipe?.gen_params?.prompt || '',
|
||||||
|
'No prompt information available'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('negative_prompt', requestEditVersions)) {
|
||||||
|
this.syncPromptField(
|
||||||
|
'negative_prompt',
|
||||||
|
this.currentRecipe?.gen_params?.negative_prompt || '',
|
||||||
|
'No negative prompt information available'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncGenerationParams(this.currentRecipe?.gen_params, { promptFieldsOnly: true });
|
||||||
|
this.syncResourcesSection(this.currentRecipe);
|
||||||
|
|
||||||
|
if (!this.shouldPreserveField('source_path', requestEditVersions)) {
|
||||||
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
|
||||||
|
} else {
|
||||||
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewMediaUrl(recipe = {}) {
|
||||||
|
return recipe.file_url ||
|
||||||
|
recipe.preview_url ||
|
||||||
|
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||||
|
'/loras_static/images/no-preview.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPreviewMedia(recipe = {}) {
|
||||||
|
const mediaContainer = document.getElementById('recipePreviewContainer');
|
||||||
|
if (!mediaContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = this.getPreviewMediaUrl(recipe);
|
||||||
|
const isVideo = previewUrl.toLowerCase().endsWith('.mp4');
|
||||||
|
const expectedElementId = isVideo ? 'recipeModalVideo' : 'recipeModalImage';
|
||||||
|
let previewElement = mediaContainer.querySelector(`#${expectedElementId}`);
|
||||||
|
const existingPreviewElement = mediaContainer.querySelector('.recipe-preview-media');
|
||||||
|
|
||||||
|
if (!previewElement || (existingPreviewElement && existingPreviewElement !== previewElement)) {
|
||||||
|
if (existingPreviewElement?.tagName === 'VIDEO') {
|
||||||
|
const existingVideo = existingPreviewElement;
|
||||||
|
existingVideo.pause();
|
||||||
|
existingVideo.currentTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingPreviewElement?.remove();
|
||||||
|
previewElement = document.createElement(isVideo ? 'video' : 'img');
|
||||||
|
previewElement.id = expectedElementId;
|
||||||
|
previewElement.className = 'recipe-preview-media';
|
||||||
|
mediaContainer.prepend(previewElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
previewElement.src = previewUrl;
|
||||||
|
previewElement.alt = recipe.title || 'Recipe Preview';
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
previewElement.controls = true;
|
||||||
|
previewElement.autoplay = false;
|
||||||
|
previewElement.loop = true;
|
||||||
|
previewElement.muted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetadataUpdateOptions() {
|
||||||
|
return this.listFilePath ? { listFilePath: this.listFilePath } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
syncTitleDisplay(title) {
|
||||||
|
const titleContainer = document.getElementById('recipeModalTitle');
|
||||||
|
if (!titleContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentText = titleContainer.querySelector('.content-text');
|
||||||
|
if (contentText) {
|
||||||
|
contentText.textContent = title || 'Recipe Details';
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleInput = titleContainer.querySelector('.title-input');
|
||||||
|
if (titleInput) {
|
||||||
|
titleInput.value = title || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncTagsDisplay(tags) {
|
||||||
|
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||||
|
if (!tagsContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateTagsDisplay(tagsContainer, tags || []);
|
||||||
|
|
||||||
|
const tagsInput = tagsContainer.querySelector('.tags-input');
|
||||||
|
if (tagsInput) {
|
||||||
|
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPromptField(field, value, placeholder) {
|
||||||
|
const contentId = field === 'prompt' ? 'recipePrompt' : 'recipeNegativePrompt';
|
||||||
|
const editorId = field === 'prompt' ? 'recipePromptEditor' : 'recipeNegativePromptEditor';
|
||||||
|
const inputId = field === 'prompt' ? 'recipePromptInput' : 'recipeNegativePromptInput';
|
||||||
|
|
||||||
|
this.renderPromptContent(document.getElementById(contentId), value, placeholder);
|
||||||
|
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (input) {
|
||||||
|
input.value = value || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncGenerationParams(genParams, options = {}) {
|
||||||
const promptElement = document.getElementById('recipePrompt');
|
const promptElement = document.getElementById('recipePrompt');
|
||||||
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||||||
const otherParamsElement = document.getElementById('recipeOtherParams');
|
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||||||
const promptInput = document.getElementById('recipePromptInput');
|
const promptInput = document.getElementById('recipePromptInput');
|
||||||
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
|
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
|
||||||
|
const promptFieldsOnly = options.promptFieldsOnly === true;
|
||||||
|
const sanitizedGenParams = this.sanitizeGenParams(genParams);
|
||||||
|
|
||||||
if (recipe.gen_params) {
|
if (sanitizedGenParams) {
|
||||||
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
|
if (!promptFieldsOnly) {
|
||||||
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
|
this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available');
|
||||||
|
this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available');
|
||||||
|
|
||||||
if (promptInput) {
|
if (promptInput) {
|
||||||
promptInput.value = recipe.gen_params.prompt || '';
|
promptInput.value = sanitizedGenParams.prompt || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (negativePromptInput) {
|
||||||
|
negativePromptInput.value = sanitizedGenParams.negative_prompt || '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (negativePromptInput) {
|
|
||||||
negativePromptInput.value = recipe.gen_params.negative_prompt || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set other parameters
|
|
||||||
if (otherParamsElement) {
|
if (otherParamsElement) {
|
||||||
// Clear previous params
|
|
||||||
otherParamsElement.innerHTML = '';
|
otherParamsElement.innerHTML = '';
|
||||||
|
|
||||||
// Add all other parameters except prompt and negative_prompt
|
|
||||||
const excludedParams = ['prompt', 'negative_prompt'];
|
const excludedParams = ['prompt', 'negative_prompt'];
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(recipe.gen_params)) {
|
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
||||||
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||||
const paramTag = document.createElement('div');
|
const paramTag = document.createElement('div');
|
||||||
paramTag.className = 'param-tag';
|
paramTag.className = 'param-tag';
|
||||||
@@ -338,22 +629,68 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no other params, show a message
|
|
||||||
if (otherParamsElement.children.length === 0) {
|
if (otherParamsElement.children.length === 0) {
|
||||||
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
// No generation parameters available
|
}
|
||||||
|
|
||||||
|
if (!promptFieldsOnly) {
|
||||||
this.renderPromptContent(promptElement, '', 'No prompt information available');
|
this.renderPromptContent(promptElement, '', 'No prompt information available');
|
||||||
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
|
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
|
||||||
if (promptInput) promptInput.value = '';
|
if (promptInput) promptInput.value = '';
|
||||||
if (negativePromptInput) negativePromptInput.value = '';
|
if (negativePromptInput) negativePromptInput.value = '';
|
||||||
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (otherParamsElement) {
|
||||||
|
otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeGenParams(genParams) {
|
||||||
|
if (!genParams || typeof genParams !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(genParams)) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_GEN_PARAM_KEYS.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(genParams)) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKey = GEN_PARAM_NORMALIZATION[key] || key;
|
||||||
|
if (!ALLOWED_GEN_PARAM_KEYS.has(normalizedKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitized[normalizedKey] === undefined || sanitized[normalizedKey] === null || sanitized[normalizedKey] === '') {
|
||||||
|
sanitized[normalizedKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncResourcesSection(recipe = {}) {
|
||||||
const checkpointContainer = document.getElementById('recipeCheckpoint');
|
const checkpointContainer = document.getElementById('recipeCheckpoint');
|
||||||
const resourceDivider = document.getElementById('recipeResourceDivider');
|
const resourceDivider = document.getElementById('recipeResourceDivider');
|
||||||
|
const lorasListElement = document.getElementById('recipeLorasList');
|
||||||
|
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||||||
|
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
|
||||||
|
|
||||||
if (checkpointContainer) {
|
if (checkpointContainer) {
|
||||||
checkpointContainer.innerHTML = '';
|
checkpointContainer.innerHTML = '';
|
||||||
@@ -364,59 +701,43 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set LoRAs list and count
|
|
||||||
const lorasListElement = document.getElementById('recipeLorasList');
|
|
||||||
const lorasCountElement = document.getElementById('recipeLorasCount');
|
|
||||||
|
|
||||||
// Check all LoRAs status
|
|
||||||
let allLorasAvailable = true;
|
let allLorasAvailable = true;
|
||||||
let missingLorasCount = 0;
|
let missingLorasCount = 0;
|
||||||
let deletedLorasCount = 0;
|
let deletedLorasCount = 0;
|
||||||
|
|
||||||
if (recipe.loras && recipe.loras.length > 0) {
|
loras.forEach(lora => {
|
||||||
recipe.loras.forEach(lora => {
|
if (lora.isDeleted) {
|
||||||
if (lora.isDeleted) {
|
deletedLorasCount++;
|
||||||
deletedLorasCount++;
|
} else if (!lora.inLibrary) {
|
||||||
} else if (!lora.inLibrary) {
|
allLorasAvailable = false;
|
||||||
allLorasAvailable = false;
|
missingLorasCount++;
|
||||||
missingLorasCount++;
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set LoRAs count and status
|
if (lorasCountElement) {
|
||||||
if (lorasCountElement && recipe.loras) {
|
const totalCount = loras.length;
|
||||||
const totalCount = recipe.loras.length;
|
|
||||||
|
|
||||||
// Create status indicator based on LoRA states
|
|
||||||
let statusHTML = '';
|
let statusHTML = '';
|
||||||
if (totalCount > 0) {
|
if (totalCount > 0) {
|
||||||
if (allLorasAvailable && deletedLorasCount === 0) {
|
if (allLorasAvailable && deletedLorasCount === 0) {
|
||||||
// All LoRAs are available
|
|
||||||
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
||||||
} else if (missingLorasCount > 0) {
|
} else if (missingLorasCount > 0) {
|
||||||
// Some LoRAs are missing (prioritize showing missing over deleted)
|
|
||||||
statusHTML = `<div class="recipe-status missing">
|
statusHTML = `<div class="recipe-status missing">
|
||||||
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
|
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
|
||||||
<div class="missing-tooltip">Click to download missing LoRAs</div>
|
<div class="missing-tooltip">Click to download missing LoRAs</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
|
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
|
||||||
// Some LoRAs are deleted but none are missing
|
|
||||||
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
|
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||||
|
|
||||||
// Add event listeners for buttons and status indicators
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Set up click handler for View LoRAs button
|
|
||||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||||
if (viewRecipeLorasBtn) {
|
if (viewRecipeLorasBtn) {
|
||||||
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handler for missing LoRAs status
|
|
||||||
const missingStatus = document.querySelector('.recipe-status.missing');
|
const missingStatus = document.querySelector('.recipe-status.missing');
|
||||||
if (missingStatus && missingLorasCount > 0) {
|
if (missingStatus && missingLorasCount > 0) {
|
||||||
missingStatus.classList.add('clickable');
|
missingStatus.classList.add('clickable');
|
||||||
@@ -425,13 +746,12 @@ class RecipeModal {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
if (lorasListElement && loras.length > 0) {
|
||||||
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
lorasListElement.innerHTML = loras.map(lora => {
|
||||||
const existsLocally = lora.inLibrary;
|
const existsLocally = lora.inLibrary;
|
||||||
const isDeleted = lora.isDeleted;
|
const isDeleted = lora.isDeleted;
|
||||||
const localPath = lora.localPath || '';
|
const localPath = lora.localPath || '';
|
||||||
|
|
||||||
// Create status badge based on LoRA state
|
|
||||||
let localStatus;
|
let localStatus;
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
localStatus = `
|
localStatus = `
|
||||||
@@ -441,7 +761,7 @@ class RecipeModal {
|
|||||||
</div>`;
|
</div>`;
|
||||||
} else if (isDeleted) {
|
} else if (isDeleted) {
|
||||||
localStatus = `
|
localStatus = `
|
||||||
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
|
<div class="deleted-badge reconnectable" data-lora-index="${loras.indexOf(lora)}">
|
||||||
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
||||||
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -452,7 +772,6 @@ class RecipeModal {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if preview is a video
|
|
||||||
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
||||||
const previewMedia = isPreviewVideo ?
|
const previewMedia = isPreviewVideo ?
|
||||||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||||
@@ -460,7 +779,6 @@ class RecipeModal {
|
|||||||
</video>` :
|
</video>` :
|
||||||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
||||||
|
|
||||||
// Determine CSS class based on LoRA state
|
|
||||||
let loraItemClass = 'recipe-lora-item';
|
let loraItemClass = 'recipe-lora-item';
|
||||||
if (existsLocally) {
|
if (existsLocally) {
|
||||||
loraItemClass += ' exists-locally';
|
loraItemClass += ' exists-locally';
|
||||||
@@ -471,7 +789,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
|
<div class="${loraItemClass}" data-lora-index="${loras.indexOf(lora)}">
|
||||||
<div class="recipe-lora-thumbnail">
|
<div class="recipe-lora-thumbnail">
|
||||||
${previewMedia}
|
${previewMedia}
|
||||||
</div>
|
</div>
|
||||||
@@ -485,7 +803,7 @@ class RecipeModal {
|
|||||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
|
<div class="lora-reconnect-container" data-lora-index="${loras.indexOf(lora)}">
|
||||||
<div class="reconnect-instructions">
|
<div class="reconnect-instructions">
|
||||||
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
||||||
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
||||||
@@ -503,15 +821,12 @@ class RecipeModal {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add event listeners for reconnect functionality
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setupReconnectButtons();
|
this.setupReconnectButtons();
|
||||||
this.setupLoraItemsClickable();
|
this.setupLoraItemsClickable();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
|
|
||||||
this.recipeLorasSyntax = '';
|
this.recipeLorasSyntax = '';
|
||||||
|
|
||||||
} else if (lorasListElement) {
|
} else if (lorasListElement) {
|
||||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||||
this.recipeLorasSyntax = '';
|
this.recipeLorasSyntax = '';
|
||||||
@@ -522,9 +837,31 @@ class RecipeModal {
|
|||||||
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
|
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
|
||||||
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
|
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show the modal
|
updateSourceUrlDisplay(sourcePath, options = {}) {
|
||||||
modalManager.showModal('recipeModal');
|
const sourceUrlContainer = document.querySelector('.source-url-container');
|
||||||
|
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
||||||
|
if (!sourceUrlContainer || !sourceUrlEditor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
|
||||||
|
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
|
||||||
|
if (!sourceUrlText || !sourceUrlInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSourcePath = typeof sourcePath === 'string' ? sourcePath.trim() : '';
|
||||||
|
const isValidUrl = normalizedSourcePath.startsWith('http://') || normalizedSourcePath.startsWith('https://');
|
||||||
|
|
||||||
|
sourceUrlText.textContent = normalizedSourcePath || 'No source URL';
|
||||||
|
sourceUrlText.title = normalizedSourcePath
|
||||||
|
? (isValidUrl ? 'Click to open source URL' : 'No valid URL')
|
||||||
|
: 'No valid URL';
|
||||||
|
if (options.forceInputSync || !sourceUrlEditor.classList.contains('active') || !this.sourceUrlEditState.isDirty) {
|
||||||
|
sourceUrlInput.value = normalizedSourcePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title editing methods
|
// Title editing methods
|
||||||
@@ -535,6 +872,7 @@ class RecipeModal {
|
|||||||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||||
editor.classList.add('active');
|
editor.classList.add('active');
|
||||||
const input = editor.querySelector('input');
|
const input = editor.querySelector('input');
|
||||||
|
input.oninput = () => this.markFieldDirty('title');
|
||||||
input.focus();
|
input.focus();
|
||||||
input.select();
|
input.select();
|
||||||
}
|
}
|
||||||
@@ -553,19 +891,23 @@ class RecipeModal {
|
|||||||
titleContainer.querySelector('.content-text').textContent = newTitle;
|
titleContainer.querySelector('.content-text').textContent = newTitle;
|
||||||
|
|
||||||
// Update the recipe on the server
|
// Update the recipe on the server
|
||||||
updateRecipeMetadata(this.filePath, { title: newTitle })
|
updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('toast.recipes.nameUpdated', {}, 'success');
|
showToast('toast.recipes.nameUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.title = newTitle;
|
this.currentRecipe.title = newTitle;
|
||||||
|
this.commitField('title');
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
// Error is handled in the API function
|
// Error is handled in the API function
|
||||||
// Reset the UI if needed
|
// Reset the UI if needed
|
||||||
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
|
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
|
||||||
|
this.clearFieldDirty('title');
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.clearFieldDirty('title');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
@@ -581,6 +923,7 @@ class RecipeModal {
|
|||||||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||||
const input = editor.querySelector('input');
|
const input = editor.querySelector('input');
|
||||||
input.value = this.currentRecipe.title || '';
|
input.value = this.currentRecipe.title || '';
|
||||||
|
this.clearFieldDirty('title');
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
editor.classList.remove('active');
|
editor.classList.remove('active');
|
||||||
@@ -596,6 +939,7 @@ class RecipeModal {
|
|||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||||
editor.classList.add('active');
|
editor.classList.add('active');
|
||||||
const input = editor.querySelector('input');
|
const input = editor.querySelector('input');
|
||||||
|
input.oninput = () => this.markFieldDirty('tags');
|
||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,20 +967,24 @@ class RecipeModal {
|
|||||||
|
|
||||||
if (tagsChanged) {
|
if (tagsChanged) {
|
||||||
// Update the recipe on the server
|
// Update the recipe on the server
|
||||||
updateRecipeMetadata(this.filePath, { tags: newTags })
|
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.tags = newTags;
|
this.currentRecipe.tags = newTags;
|
||||||
|
this.commitField('tags');
|
||||||
|
|
||||||
// Update tags in the UI
|
// Update tags in the UI
|
||||||
this.updateTagsDisplay(tagsContainer, newTags);
|
this.updateTagsDisplay(tagsContainer, newTags);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
// Error is handled in the API function
|
// Error is handled in the API function
|
||||||
|
this.clearFieldDirty('tags');
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.clearFieldDirty('tags');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
@@ -708,6 +1056,7 @@ class RecipeModal {
|
|||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||||
const input = editor.querySelector('input');
|
const input = editor.querySelector('input');
|
||||||
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
||||||
|
this.clearFieldDirty('tags');
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
editor.classList.remove('active');
|
editor.classList.remove('active');
|
||||||
@@ -748,6 +1097,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input) {
|
if (input) {
|
||||||
|
input.addEventListener('input', () => this.markFieldDirty(config.field));
|
||||||
input.addEventListener('keydown', (event) => {
|
input.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -840,9 +1190,10 @@ class RecipeModal {
|
|||||||
|
|
||||||
const currentGenParams = this.currentRecipe.gen_params || {};
|
const currentGenParams = this.currentRecipe.gen_params || {};
|
||||||
const nextValue = input.value.trim() === '' ? '' : input.value;
|
const nextValue = input.value.trim() === '' ? '' : input.value;
|
||||||
const currentValue = currentGenParams[config.field] || '';
|
const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || '';
|
||||||
|
|
||||||
if (nextValue === currentValue) {
|
if (nextValue === currentValue) {
|
||||||
|
this.clearFieldDirty(config.field);
|
||||||
this.hidePromptEditor(config);
|
this.hidePromptEditor(config);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -857,14 +1208,17 @@ class RecipeModal {
|
|||||||
...promptState,
|
...promptState,
|
||||||
isSaving: true,
|
isSaving: true,
|
||||||
};
|
};
|
||||||
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
|
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions());
|
||||||
this.currentRecipe.gen_params = nextGenParams;
|
this.currentRecipe.gen_params = nextGenParams;
|
||||||
this.renderPromptContent(content, nextValue, config.placeholder);
|
this.renderPromptContent(content, nextValue, config.placeholder);
|
||||||
showToast(config.successKey, {}, 'success', config.successFallback);
|
showToast(config.successKey, {}, 'success', config.successFallback);
|
||||||
|
this.commitField(config.field);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.renderPromptContent(content, currentValue, config.placeholder);
|
this.renderPromptContent(content, currentValue, config.placeholder);
|
||||||
input.value = currentValue;
|
input.value = currentValue;
|
||||||
|
this.clearFieldDirty(config.field);
|
||||||
} finally {
|
} finally {
|
||||||
|
this.clearFieldDirty(config.field);
|
||||||
this.hidePromptEditor(config);
|
this.hidePromptEditor(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -872,10 +1226,10 @@ class RecipeModal {
|
|||||||
cancelPromptEdit(config) {
|
cancelPromptEdit(config) {
|
||||||
const input = document.getElementById(config.inputId);
|
const input = document.getElementById(config.inputId);
|
||||||
if (input) {
|
if (input) {
|
||||||
const initialValue = this.promptEditorState[config.field]?.initialValue;
|
input.value = this.currentRecipe?.gen_params?.[config.field] || '';
|
||||||
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearFieldDirty(config.field);
|
||||||
this.hidePromptEditor(config);
|
this.hidePromptEditor(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,11 +1272,16 @@ class RecipeModal {
|
|||||||
sourceUrlInput.focus();
|
sourceUrlInput.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
sourceUrlInput.addEventListener('input', () => {
|
||||||
|
this.sourceUrlEditState.isDirty = true;
|
||||||
|
});
|
||||||
|
|
||||||
// Cancel editing
|
// Cancel editing
|
||||||
sourceUrlCancelBtn.addEventListener('click', () => {
|
sourceUrlCancelBtn.addEventListener('click', () => {
|
||||||
sourceUrlEditor.classList.remove('active');
|
sourceUrlEditor.classList.remove('active');
|
||||||
sourceUrlContainer.classList.remove('hide');
|
sourceUrlContainer.classList.remove('hide');
|
||||||
sourceUrlInput.value = this.currentRecipe.source_path || '';
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '', { forceInputSync: true });
|
||||||
|
this.clearFieldDirty('source_path');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save new source URL
|
// Save new source URL
|
||||||
@@ -930,23 +1289,24 @@ class RecipeModal {
|
|||||||
const newSourceUrl = sourceUrlInput.value.trim();
|
const newSourceUrl = sourceUrlInput.value.trim();
|
||||||
if (newSourceUrl !== this.currentRecipe.source_path) {
|
if (newSourceUrl !== this.currentRecipe.source_path) {
|
||||||
// Update the recipe on the server
|
// Update the recipe on the server
|
||||||
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
|
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Show success toast
|
// Show success toast
|
||||||
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
|
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
|
||||||
|
|
||||||
// Update source URL in the UI
|
// Update source URL in the UI
|
||||||
sourceUrlText.textContent = newSourceUrl || 'No source URL';
|
this.commitField('source_path');
|
||||||
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') ||
|
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
||||||
newSourceUrl.startsWith('https://')) ?
|
|
||||||
'Click to open source URL' : 'No valid URL';
|
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.source_path = newSourceUrl;
|
this.currentRecipe.source_path = newSourceUrl;
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
// Error is handled in the API function
|
// Error is handled in the API function
|
||||||
|
this.clearFieldDirty('source_path');
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.clearFieldDirty('source_path');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide editor
|
// Hide editor
|
||||||
@@ -1286,7 +1646,7 @@ class RecipeModal {
|
|||||||
this.showRecipeDetails(this.currentRecipe);
|
this.showRecipeDetails(this.currentRecipe);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, {
|
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
|
||||||
loras: this.currentRecipe.loras
|
loras: this.currentRecipe.loras
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
import { sidebarManager } from '../SidebarManager.js';
|
import { sidebarManager } from '../SidebarManager.js';
|
||||||
|
|
||||||
@@ -39,7 +39,11 @@ export class PageControls {
|
|||||||
// Initialize favorites filter button state
|
// Initialize favorites filter button state
|
||||||
this.initFavoritesFilter();
|
this.initFavoritesFilter();
|
||||||
|
|
||||||
|
this.initExcludedViewControls();
|
||||||
|
this.syncExcludedViewState();
|
||||||
|
|
||||||
console.log(`PageControls initialized for ${pageType} page`);
|
console.log(`PageControls initialized for ${pageType} page`);
|
||||||
|
window.pageControls = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +60,19 @@ export class PageControls {
|
|||||||
|
|
||||||
// Load sort preference
|
// Load sort preference
|
||||||
this.loadSortPreference();
|
this.loadSortPreference();
|
||||||
|
|
||||||
|
if (!this.pageState.viewMode) {
|
||||||
|
this.pageState.viewMode = 'active';
|
||||||
|
}
|
||||||
|
if (!this.pageState.excludedViewState) {
|
||||||
|
this.pageState.excludedViewState = {
|
||||||
|
sortBy: 'name:asc',
|
||||||
|
search: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!this.pageState.filters?.search) {
|
||||||
|
this.pageState.filters.search = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +134,15 @@ export class PageControls {
|
|||||||
this.initPageSpecificListeners();
|
this.initPageSpecificListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initExcludedViewControls() {
|
||||||
|
const backButton = document.getElementById('excludedViewBackBtn');
|
||||||
|
if (backButton) {
|
||||||
|
backButton.addEventListener('click', async () => {
|
||||||
|
await this.exitExcludedView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize dropdown functionality
|
* Initialize dropdown functionality
|
||||||
*/
|
*/
|
||||||
@@ -334,6 +360,13 @@ export class PageControls {
|
|||||||
* @param {string} sortValue - The sort value to save
|
* @param {string} sortValue - The sort value to save
|
||||||
*/
|
*/
|
||||||
saveSortPreference(sortValue) {
|
saveSortPreference(sortValue) {
|
||||||
|
if (this.pageState.viewMode === 'excluded') {
|
||||||
|
this.pageState.excludedViewState = {
|
||||||
|
...(this.pageState.excludedViewState || {}),
|
||||||
|
sortBy: sortValue,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
setStorageItem(`${this.pageType}_sort`, sortValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,17 +387,7 @@ export class PageControls {
|
|||||||
const civitaiId = metaData.modelId;
|
const civitaiId = metaData.modelId;
|
||||||
const versionId = metaData.id;
|
const versionId = metaData.id;
|
||||||
|
|
||||||
// Build URL
|
openCivitaiByMetadata(civitaiId, versionId, modelName);
|
||||||
if (civitaiId) {
|
|
||||||
let url = `https://civitai.com/models/${civitaiId}`;
|
|
||||||
if (versionId) {
|
|
||||||
url += `?modelVersionId=${versionId}`;
|
|
||||||
}
|
|
||||||
window.open(url, '_blank');
|
|
||||||
} else {
|
|
||||||
// If no ID, try searching by name
|
|
||||||
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -483,6 +506,8 @@ export class PageControls {
|
|||||||
// Update app state
|
// Update app state
|
||||||
this.pageState.showFavoritesOnly = showFavoritesOnly;
|
this.pageState.showFavoritesOnly = showFavoritesOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateActionButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -499,12 +524,17 @@ export class PageControls {
|
|||||||
if (updateFilterBtn) {
|
if (updateFilterBtn) {
|
||||||
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
|
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateActionButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle favorites-only filter and reload models
|
* Toggle favorites-only filter and reload models
|
||||||
*/
|
*/
|
||||||
async toggleFavoritesOnly() {
|
async toggleFavoritesOnly() {
|
||||||
|
if (this.pageState.viewMode === 'excluded') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||||
|
|
||||||
// Toggle the filter state in storage
|
// Toggle the filter state in storage
|
||||||
@@ -531,6 +561,9 @@ export class PageControls {
|
|||||||
* Toggle update-available-only filter and reload models
|
* Toggle update-available-only filter and reload models
|
||||||
*/
|
*/
|
||||||
async toggleUpdateAvailableOnly() {
|
async toggleUpdateAvailableOnly() {
|
||||||
|
if (this.pageState.viewMode === 'excluded') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||||
const storageKey = `show_update_available_only_${this.pageType}`;
|
const storageKey = `show_update_available_only_${this.pageType}`;
|
||||||
const newState = !this.pageState.showUpdateAvailableOnly;
|
const newState = !this.pageState.showUpdateAvailableOnly;
|
||||||
@@ -546,6 +579,234 @@ export class PageControls {
|
|||||||
await this.resetAndReload(true);
|
await this.resetAndReload(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cloneFilters(filters = this.pageState.filters) {
|
||||||
|
return JSON.parse(JSON.stringify(filters || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
buildExcludedFilters(search = '') {
|
||||||
|
return {
|
||||||
|
baseModel: [],
|
||||||
|
tags: {},
|
||||||
|
license: {},
|
||||||
|
modelTypes: [],
|
||||||
|
search,
|
||||||
|
tagLogic: 'any',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilterState(filters) {
|
||||||
|
this.pageState.filters = filters;
|
||||||
|
|
||||||
|
if (window.filterManager) {
|
||||||
|
window.filterManager.filters = window.filterManager.initializeFilters(filters);
|
||||||
|
window.filterManager.updateActiveFiltersCount();
|
||||||
|
if (typeof window.filterManager.updateSelections === 'function') {
|
||||||
|
window.filterManager.updateSelections();
|
||||||
|
}
|
||||||
|
window.filterManager.closeFilterPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActionButtonStates() {
|
||||||
|
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||||
|
if (favoriteFilterBtn) {
|
||||||
|
favoriteFilterBtn.classList.toggle('active', Boolean(this.pageState.showFavoritesOnly));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||||
|
if (updateFilterBtn) {
|
||||||
|
updateFilterBtn.classList.toggle('active', Boolean(this.pageState.showUpdateAvailableOnly));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncExcludedViewState() {
|
||||||
|
const isExcludedView = this.pageState.viewMode === 'excluded';
|
||||||
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const excludedBanner = document.getElementById('excludedViewBanner');
|
||||||
|
const filterButton = document.getElementById('filterButton');
|
||||||
|
const breadcrumbContainer = document.getElementById('breadcrumbContainer');
|
||||||
|
const duplicatesBanner = document.getElementById('duplicatesBanner');
|
||||||
|
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
|
||||||
|
const hiddenSelectors = [
|
||||||
|
'[data-action="fetch"]',
|
||||||
|
'[data-action="download"]',
|
||||||
|
'[data-action="bulk"]',
|
||||||
|
'[data-action="find-duplicates"]',
|
||||||
|
'#favoriteFilterBtn',
|
||||||
|
'.update-filter-group',
|
||||||
|
];
|
||||||
|
const customFilterIndicator = document.getElementById('customFilterIndicator');
|
||||||
|
|
||||||
|
document.body.classList.toggle('excluded-view-active', isExcludedView);
|
||||||
|
excludedBanner?.classList.toggle('hidden', !isExcludedView);
|
||||||
|
breadcrumbContainer?.classList.toggle('hidden', isExcludedView);
|
||||||
|
alphabetBarContainer?.classList.toggle('hidden', isExcludedView);
|
||||||
|
|
||||||
|
if (duplicatesBanner && isExcludedView) {
|
||||||
|
duplicatesBanner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
hiddenSelectors.forEach((selector) => {
|
||||||
|
document.querySelectorAll(selector).forEach((element) => {
|
||||||
|
element.classList.toggle('hidden', isExcludedView);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (customFilterIndicator && isExcludedView) {
|
||||||
|
customFilterIndicator.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterButton) {
|
||||||
|
filterButton.disabled = isExcludedView;
|
||||||
|
filterButton.classList.toggle('hidden', isExcludedView);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFiltersCount = document.getElementById('activeFiltersCount');
|
||||||
|
if (activeFiltersCount && isExcludedView) {
|
||||||
|
activeFiltersCount.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortSelect) {
|
||||||
|
sortSelect.value = this.pageState.sortBy;
|
||||||
|
}
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = this.pageState.filters?.search || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateActionButtonStates();
|
||||||
|
|
||||||
|
if (this.sidebarManager) {
|
||||||
|
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
|
||||||
|
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
||||||
|
console.error('Failed to update sidebar visibility:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspendInteractiveModes() {
|
||||||
|
const snapshot = {
|
||||||
|
bulkMode: Boolean(state.bulkMode),
|
||||||
|
duplicatesMode: Boolean(this.pageState.duplicatesMode),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (snapshot.bulkMode && window.bulkManager?.toggleBulkMode) {
|
||||||
|
window.bulkManager.toggleBulkMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.duplicatesMode && window.modelDuplicatesManager?.exitDuplicateMode) {
|
||||||
|
window.modelDuplicatesManager.exitDuplicateMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreInteractiveModes(snapshot = {}) {
|
||||||
|
if (snapshot.bulkMode && !state.bulkMode && window.bulkManager?.toggleBulkMode) {
|
||||||
|
window.bulkManager.toggleBulkMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.duplicatesMode || this.pageState.duplicatesMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicatesManager = window.modelDuplicatesManager;
|
||||||
|
if (!duplicatesManager) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof duplicatesManager.enterDuplicateMode === 'function' &&
|
||||||
|
Array.isArray(duplicatesManager.duplicateGroups) &&
|
||||||
|
duplicatesManager.duplicateGroups.length > 0) {
|
||||||
|
duplicatesManager.enterDuplicateMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof duplicatesManager.findDuplicates === 'function') {
|
||||||
|
await duplicatesManager.findDuplicates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncCustomFilterIndicator() {
|
||||||
|
const indicator = document.getElementById('customFilterIndicator');
|
||||||
|
if (!indicator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pageState.viewMode === 'excluded') {
|
||||||
|
indicator.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.checkCustomFilters === 'function') {
|
||||||
|
this.checkCustomFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterExcludedView() {
|
||||||
|
if (this.pageState.viewMode === 'excluded') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interactionSnapshot = this.suspendInteractiveModes();
|
||||||
|
|
||||||
|
this.pageState.activeViewSnapshot = {
|
||||||
|
sortBy: this.pageState.sortBy,
|
||||||
|
activeFolder: this.pageState.activeFolder,
|
||||||
|
activeLetterFilter: this.pageState.activeLetterFilter ?? null,
|
||||||
|
showFavoritesOnly: this.pageState.showFavoritesOnly,
|
||||||
|
showUpdateAvailableOnly: this.pageState.showUpdateAvailableOnly,
|
||||||
|
bulkMode: interactionSnapshot.bulkMode,
|
||||||
|
duplicatesMode: interactionSnapshot.duplicatesMode,
|
||||||
|
filters: this.cloneFilters(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const excludedState = this.pageState.excludedViewState || {
|
||||||
|
sortBy: 'name:asc',
|
||||||
|
search: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pageState.viewMode = 'excluded';
|
||||||
|
this.pageState.sortBy = excludedState.sortBy || 'name:asc';
|
||||||
|
this.pageState.currentPage = 1;
|
||||||
|
this.pageState.activeFolder = null;
|
||||||
|
this.pageState.activeLetterFilter = null;
|
||||||
|
this.pageState.showFavoritesOnly = false;
|
||||||
|
this.pageState.showUpdateAvailableOnly = false;
|
||||||
|
|
||||||
|
this.applyFilterState(this.buildExcludedFilters(excludedState.search || ''));
|
||||||
|
this.syncExcludedViewState();
|
||||||
|
await this.resetAndReload(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exitExcludedView() {
|
||||||
|
if (this.pageState.viewMode !== 'excluded') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pageState.excludedViewState = {
|
||||||
|
...(this.pageState.excludedViewState || {}),
|
||||||
|
sortBy: this.pageState.sortBy,
|
||||||
|
search: this.pageState.filters?.search || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = this.pageState.activeViewSnapshot || {};
|
||||||
|
this.pageState.viewMode = 'active';
|
||||||
|
this.pageState.sortBy = snapshot.sortBy || this.convertLegacySortFormat(getStorageItem(`${this.pageType}_sort`) || 'name:asc');
|
||||||
|
this.pageState.currentPage = 1;
|
||||||
|
this.pageState.activeFolder = snapshot.activeFolder ?? getStorageItem(`${this.pageType}_activeFolder`);
|
||||||
|
this.pageState.activeLetterFilter = snapshot.activeLetterFilter ?? null;
|
||||||
|
this.pageState.showFavoritesOnly = Boolean(snapshot.showFavoritesOnly);
|
||||||
|
this.pageState.showUpdateAvailableOnly = Boolean(snapshot.showUpdateAvailableOnly);
|
||||||
|
this.applyFilterState(snapshot.filters || this.buildExcludedFilters(''));
|
||||||
|
this.pageState.activeViewSnapshot = null;
|
||||||
|
|
||||||
|
this.syncExcludedViewState();
|
||||||
|
await this.resetAndReload(true);
|
||||||
|
this.syncCustomFilterIndicator();
|
||||||
|
await this.restoreInteractiveModes(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find duplicate models
|
* Find duplicate models
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -433,10 +433,11 @@ export function createModelCard(model, modelType) {
|
|||||||
card.dataset.usage_count = String(model.usage_count);
|
card.dataset.usage_count = String(model.usage_count);
|
||||||
card.dataset.notes = model.notes || '';
|
card.dataset.notes = model.notes || '';
|
||||||
card.dataset.base_model = model.base_model || 'Unknown';
|
card.dataset.base_model = model.base_model || 'Unknown';
|
||||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||||
const hasUpdateAvailable = Boolean(model.update_available);
|
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
const hasUpdateAvailable = Boolean(model.update_available);
|
||||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||||
|
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||||
|
|
||||||
// To only show usage_count when sorting by usage.
|
// To only show usage_count when sorting by usage.
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
@@ -487,6 +488,9 @@ export function createModelCard(model, modelType) {
|
|||||||
if (model.skip_metadata_refresh) {
|
if (model.skip_metadata_refresh) {
|
||||||
card.classList.add('skip-refresh');
|
card.classList.add('skip-refresh');
|
||||||
}
|
}
|
||||||
|
if (model.exclude) {
|
||||||
|
card.classList.add('excluded-model');
|
||||||
|
}
|
||||||
|
|
||||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||||
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||||
@@ -619,6 +623,11 @@ export function createModelCard(model, modelType) {
|
|||||||
<i class="fas fa-ban"></i>
|
<i class="fas fa-ban"></i>
|
||||||
</span>
|
</span>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
${model.exclude ? `
|
||||||
|
<span class="model-excluded-badge" title="${translate('globalContextMenu.manageExcludedModels.label', {}, 'Excluded Models')}">
|
||||||
|
<i class="fas fa-eye-slash"></i>
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
${actionIcons}
|
${actionIcons}
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { openCivitaiUrl, showToast } from '../../utils/uiHelpers.js';
|
||||||
import { translate } from '../../utils/i18nHelpers.js';
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
||||||
import { formatFileSize } from './utils.js';
|
import { formatFileSize } from './utils.js';
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||||
|
|
||||||
function buildCivitaiVersionUrl(modelId, versionId) {
|
function buildCivitaiVersionUrl(modelId, versionId) {
|
||||||
if (modelId == null || versionId == null) {
|
return buildCivitaiModelUrl(
|
||||||
return null;
|
modelId,
|
||||||
}
|
versionId,
|
||||||
const normalizedModelId = String(modelId).trim();
|
state?.global?.settings?.civitai_host
|
||||||
const normalizedVersionId = String(versionId).trim();
|
);
|
||||||
if (!normalizedModelId || !normalizedVersionId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const encodedModelId = encodeURIComponent(normalizedModelId);
|
|
||||||
const encodedVersionId = encodeURIComponent(normalizedVersionId);
|
|
||||||
return `https://civitai.com/models/${encodedModelId}?modelVersionId=${encodedVersionId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -220,8 +215,31 @@ function buildMetaMarkup(version, options = {}) {
|
|||||||
.join('<span class="version-meta-separator">•</span>');
|
.join('<span class="version-meta-separator">•</span>');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBadge(label, tone) {
|
function buildBadge(label, tone, options = {}) {
|
||||||
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
const attributes = [];
|
||||||
|
if (options.title) {
|
||||||
|
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||||
|
}
|
||||||
|
if (options.ariaLabel) {
|
||||||
|
attributes.push(`aria-label="${escapeHtml(options.ariaLabel)}"`);
|
||||||
|
}
|
||||||
|
const suffix = attributes.length ? ` ${attributes.join(' ')}` : '';
|
||||||
|
return `<span class="version-badge version-badge-${tone}"${suffix}>${escapeHtml(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActionButton(label, variant, action, options = {}) {
|
||||||
|
const attributes = [
|
||||||
|
`class="version-action ${variant}"`,
|
||||||
|
`data-version-action="${escapeHtml(action)}"`,
|
||||||
|
];
|
||||||
|
if (options.title) {
|
||||||
|
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||||
|
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||||
|
}
|
||||||
|
if (options.extraAttributes) {
|
||||||
|
attributes.push(options.extraAttributes);
|
||||||
|
}
|
||||||
|
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||||
@@ -426,23 +444,72 @@ function renderRow(version, options) {
|
|||||||
version.versionId > latestLibraryVersionId;
|
version.versionId > latestLibraryVersionId;
|
||||||
const isEarlyAccess = isEarlyAccessActive(version);
|
const isEarlyAccess = isEarlyAccessActive(version);
|
||||||
const badges = [];
|
const badges = [];
|
||||||
|
const openedBadgeLabel = translate('modals.model.versions.badges.current', {}, 'Opened Version');
|
||||||
|
const inLibraryBadgeLabel = translate('modals.model.versions.badges.inLibrary', {}, 'In Library');
|
||||||
|
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
|
||||||
|
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
|
||||||
|
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access');
|
||||||
|
const ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
|
||||||
|
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
||||||
|
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current'));
|
badges.push(buildBadge(openedBadgeLabel, 'current', {
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.badges.currentTooltip',
|
||||||
|
{},
|
||||||
|
'This is the version you opened this modal from'
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version.isInLibrary) {
|
if (version.isInLibrary) {
|
||||||
badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success'));
|
badges.push(buildBadge(inLibraryBadgeLabel, 'success', {
|
||||||
} else if (isNewer && !version.shouldIgnore) {
|
title: translate(
|
||||||
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
|
'modals.model.versions.badges.inLibraryTooltip',
|
||||||
|
{},
|
||||||
|
'This version exists in your local library'
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!version.isInLibrary && version.hasBeenDownloaded) {
|
||||||
|
badges.push(buildBadge(downloadedBadgeLabel, 'info', {
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.badges.downloadedTooltip',
|
||||||
|
{},
|
||||||
|
'This version was downloaded before, but is not currently in your library'
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!version.isInLibrary && isNewer && !version.shouldIgnore) {
|
||||||
|
badges.push(buildBadge(newerBadgeLabel, 'info', {
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.badges.newerTooltip',
|
||||||
|
{},
|
||||||
|
'This version is newer than your latest local version'
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEarlyAccess) {
|
if (isEarlyAccess) {
|
||||||
badges.push(buildBadge(translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'), 'early-access'));
|
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.badges.earlyAccessTooltip',
|
||||||
|
{},
|
||||||
|
'This version currently requires Civitai early access'
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version.shouldIgnore) {
|
if (version.shouldIgnore) {
|
||||||
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
|
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.badges.ignoredTooltip',
|
||||||
|
{},
|
||||||
|
'Update notifications are disabled for this version'
|
||||||
|
),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download');
|
const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download');
|
||||||
@@ -459,29 +526,82 @@ function renderRow(version, options) {
|
|||||||
if (!version.isInLibrary) {
|
if (!version.isInLibrary) {
|
||||||
// Download button with optional EA bolt icon
|
// Download button with optional EA bolt icon
|
||||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||||
actions.push(
|
actions.push(buildActionButton(
|
||||||
`<button class="version-action version-action-primary" data-version-action="download">${downloadIcon}${escapeHtml(downloadLabel)}</button>`
|
downloadLabel,
|
||||||
);
|
'version-action-primary',
|
||||||
|
'download',
|
||||||
|
{
|
||||||
|
title: isEarlyAccess
|
||||||
|
? translate(
|
||||||
|
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||||
|
{},
|
||||||
|
'Download this early access version from Civitai'
|
||||||
|
)
|
||||||
|
: translate(
|
||||||
|
'modals.model.versions.actions.downloadTooltip',
|
||||||
|
{},
|
||||||
|
'Download this version'
|
||||||
|
),
|
||||||
|
iconMarkup: downloadIcon,
|
||||||
|
}
|
||||||
|
));
|
||||||
} else if (version.filePath) {
|
} else if (version.filePath) {
|
||||||
actions.push(
|
actions.push(buildActionButton(
|
||||||
`<button class="version-action version-action-danger" data-version-action="delete">${escapeHtml(deleteLabel)}</button>`
|
deleteLabel,
|
||||||
);
|
'version-action-danger',
|
||||||
|
'delete',
|
||||||
|
{
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.actions.deleteTooltip',
|
||||||
|
{},
|
||||||
|
'Delete this local version'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
));
|
||||||
}
|
}
|
||||||
actions.push(
|
actions.push(buildActionButton(
|
||||||
`<button class="version-action version-action-ghost" data-version-action="toggle-ignore" data-ignore-state="${
|
ignoreLabel,
|
||||||
version.shouldIgnore ? 'ignored' : 'active'
|
'version-action-ghost',
|
||||||
}">${escapeHtml(ignoreLabel)}</button>`
|
'toggle-ignore',
|
||||||
);
|
{
|
||||||
|
title: version.shouldIgnore
|
||||||
|
? translate(
|
||||||
|
'modals.model.versions.actions.unignoreTooltip',
|
||||||
|
{},
|
||||||
|
'Resume update notifications for this version'
|
||||||
|
)
|
||||||
|
: translate(
|
||||||
|
'modals.model.versions.actions.ignoreTooltip',
|
||||||
|
{},
|
||||||
|
'Ignore update notifications for this version'
|
||||||
|
),
|
||||||
|
extraAttributes: `data-ignore-state="${version.shouldIgnore ? 'ignored' : 'active'}"`,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
const linkTarget = buildCivitaiVersionUrl(
|
const linkTarget = buildCivitaiVersionUrl(
|
||||||
version.modelId || parentModelId,
|
version.modelId || parentModelId,
|
||||||
version.versionId
|
version.versionId
|
||||||
);
|
);
|
||||||
const civitaiTooltip = translate(
|
const civitaiTooltip = translate(
|
||||||
'modals.model.actions.viewOnCivitai',
|
'modals.model.versions.actions.viewVersionOnCivitai',
|
||||||
{},
|
{},
|
||||||
'View on Civitai'
|
'View version on Civitai'
|
||||||
);
|
);
|
||||||
|
const civitaiLinkMarkup = linkTarget
|
||||||
|
? `
|
||||||
|
<a
|
||||||
|
class="version-civitai-link"
|
||||||
|
href="${escapeHtml(linkTarget)}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="${escapeHtml(civitaiTooltip)}"
|
||||||
|
aria-label="${escapeHtml(civitaiTooltip)}"
|
||||||
|
>
|
||||||
|
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
const rowAttributes = [
|
const rowAttributes = [
|
||||||
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
|
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
|
||||||
@@ -489,7 +609,6 @@ function renderRow(version, options) {
|
|||||||
];
|
];
|
||||||
if (linkTarget) {
|
if (linkTarget) {
|
||||||
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
|
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
|
||||||
rowAttributes.push(`title="${escapeHtml(civitaiTooltip)}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -497,7 +616,8 @@ function renderRow(version, options) {
|
|||||||
${renderMediaMarkup(version)}
|
${renderMediaMarkup(version)}
|
||||||
<div class="version-details">
|
<div class="version-details">
|
||||||
<div class="version-title">
|
<div class="version-title">
|
||||||
<span class="versions-tab-version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
|
<span class="versions-tab-version-name">${escapeHtml(versionName)}</span>
|
||||||
|
${civitaiLinkMarkup}
|
||||||
</div>
|
</div>
|
||||||
<div class="version-badges">${badges.join('')}</div>
|
<div class="version-badges">${badges.join('')}</div>
|
||||||
<div class="version-meta">
|
<div class="version-meta">
|
||||||
@@ -1227,9 +1347,17 @@ export function initVersionsTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const row = event.target.closest('.model-version-row.is-clickable');
|
const row = event.target.closest('.model-version-row.is-clickable');
|
||||||
|
const civitaiLink = event.target.closest('.version-civitai-link');
|
||||||
|
if (civitaiLink) {
|
||||||
|
event.preventDefault();
|
||||||
|
openCivitaiUrl(civitaiLink.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.target.closest('button')) {
|
if (event.target.closest('button')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1245,7 +1373,7 @@ export function initVersionsTab({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
openCivitaiUrl(targetUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for extension-triggered refresh requests
|
// Listen for extension-triggered refresh requests
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
|||||||
|
|
||||||
// Refresh recipes list to show newly imported recipes
|
// Refresh recipes list to show newly imported recipes
|
||||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
window.recipeManager.loadRecipes();
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show results step
|
// Show results step
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
|
|
||||||
// Refresh the recipes list to update LoRA status
|
// Refresh the recipes list to update LoRA status
|
||||||
if (window.recipeManager) {
|
if (window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes();
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ export class DoctorManager {
|
|||||||
return document.body?.dataset?.appVersion || '';
|
return document.body?.dataset?.appVersion || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildReloadUrl() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('_lm_reload', Date.now().toString());
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadUi() {
|
||||||
|
window.location.replace(this.buildReloadUrl());
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(isLoading) {
|
setLoading(isLoading) {
|
||||||
if (this.loadingState) {
|
if (this.loadingState) {
|
||||||
this.loadingState.classList.toggle('visible', isLoading);
|
this.loadingState.classList.toggle('visible', isLoading);
|
||||||
@@ -308,7 +318,7 @@ export class DoctorManager {
|
|||||||
await this.repairCache();
|
await this.repairCache();
|
||||||
break;
|
break;
|
||||||
case 'reload-page':
|
case 'reload-page':
|
||||||
window.location.reload();
|
this.reloadUi();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
|||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -197,21 +198,22 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extractModelId(url) {
|
extractModelId(url) {
|
||||||
const versionMatch = url.match(/modelVersionId=(\d+)/i);
|
|
||||||
this.modelVersionId = versionMatch ? versionMatch[1] : null;
|
|
||||||
|
|
||||||
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
|
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
|
||||||
if (civarchiveMatch) {
|
if (civarchiveMatch) {
|
||||||
|
const versionMatch = url.match(/modelVersionId=(\d+)/i);
|
||||||
|
this.modelVersionId = versionMatch ? versionMatch[1] : null;
|
||||||
this.source = 'civarchive';
|
this.source = 'civarchive';
|
||||||
return civarchiveMatch[1];
|
return civarchiveMatch[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const civitaiMatch = url.match(/https?:\/\/(?:www\.)?civitai\.com\/models\/(\d+)/i);
|
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
|
||||||
if (civitaiMatch) {
|
if (modelId) {
|
||||||
|
this.modelVersionId = modelVersionId;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
return civitaiMatch[1];
|
return modelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.modelVersionId = null;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -250,6 +252,7 @@ export class DownloadManager {
|
|||||||
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
||||||
|
|
||||||
const existsLocally = version.existsLocally;
|
const existsLocally = version.existsLocally;
|
||||||
|
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
|
||||||
const localPath = version.localPath;
|
const localPath = version.localPath;
|
||||||
const isEarlyAccess = version.availability === 'EarlyAccess';
|
const isEarlyAccess = version.availability === 'EarlyAccess';
|
||||||
|
|
||||||
@@ -262,11 +265,22 @@ export class DownloadManager {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localStatus = existsLocally ?
|
let localStatus = '';
|
||||||
`<div class="local-badge">
|
if (existsLocally) {
|
||||||
|
localStatus = `<div class="local-badge">
|
||||||
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
|
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
|
||||||
<div class="local-path">${localPath || ''}</div>
|
<div class="local-path">${localPath || ''}</div>
|
||||||
</div>` : '';
|
</div>`;
|
||||||
|
} else if (hasBeenDownloaded) {
|
||||||
|
const downloadedTooltip = translate(
|
||||||
|
'modals.download.downloadedTooltip',
|
||||||
|
{},
|
||||||
|
'Previously downloaded, but it is not currently in your library.'
|
||||||
|
);
|
||||||
|
localStatus = `<div class="downloaded-badge" title="${downloadedTooltip.replace(/"/g, '"')}">
|
||||||
|
<i class="fas fa-history"></i> ${translate('modals.download.downloaded', {}, 'Downloaded')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
||||||
|
|||||||
@@ -802,6 +802,11 @@ export class SettingsManager {
|
|||||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const civitaiHostSelect = document.getElementById('civitaiHost');
|
||||||
|
if (civitaiHostSelect) {
|
||||||
|
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
|
||||||
|
}
|
||||||
|
|
||||||
const recipesPathInput = document.getElementById('recipesPath');
|
const recipesPathInput = document.getElementById('recipesPath');
|
||||||
if (recipesPathInput) {
|
if (recipesPathInput) {
|
||||||
recipesPathInput.value = state.global.settings.recipes_path || '';
|
recipesPathInput.value = state.global.settings.recipes_path || '';
|
||||||
@@ -1443,12 +1448,12 @@ export class SettingsManager {
|
|||||||
|
|
||||||
// Add empty row for new path if no paths exist
|
// Add empty row for new path if no paths exist
|
||||||
if (paths.length === 0) {
|
if (paths.length === 0) {
|
||||||
this.addExtraFolderPathRow(modelType, '');
|
this.addExtraFolderPathRow(modelType, '', false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addExtraFolderPathRow(modelType, path = '') {
|
addExtraFolderPathRow(modelType, path = '', shouldFocus = true) {
|
||||||
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -1472,7 +1477,7 @@ export class SettingsManager {
|
|||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
|
|
||||||
// Focus the input if it's empty (new row)
|
// Focus the input if it's empty (new row)
|
||||||
if (!path) {
|
if (!path && shouldFocus) {
|
||||||
const input = row.querySelector('.extra-folder-path-input');
|
const input = row.querySelector('.extra-folder-path-input');
|
||||||
if (input) {
|
if (input) {
|
||||||
setTimeout(() => input.focus(), 0);
|
setTimeout(() => input.focus(), 0);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class DownloadManager {
|
|||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
// Refresh the recipe
|
||||||
window.recipeManager.loadRecipes();
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -328,16 +328,32 @@ class RecipeManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeLoadRecipesOptions(options = true) {
|
||||||
|
if (typeof options === 'boolean') {
|
||||||
|
return {
|
||||||
|
resetPage: options,
|
||||||
|
preserveScroll: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resetPage: options?.resetPage !== false,
|
||||||
|
preserveScroll: options?.preserveScroll === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// This method is kept for compatibility but now uses virtual scrolling
|
// This method is kept for compatibility but now uses virtual scrolling
|
||||||
async loadRecipes(resetPage = true) {
|
async loadRecipes(options = true) {
|
||||||
// Skip loading if in duplicates mode
|
// Skip loading if in duplicates mode
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
if (pageState.duplicatesMode) {
|
if (pageState.duplicatesMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { resetPage, preserveScroll } = this.normalizeLoadRecipesOptions(options);
|
||||||
|
|
||||||
if (resetPage) {
|
if (resetPage) {
|
||||||
refreshVirtualScroll();
|
await refreshVirtualScroll({ preserveScroll });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
|
|||||||
|
|
||||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||||
civitai_api_key: '',
|
civitai_api_key: '',
|
||||||
|
civitai_host: 'civitai.com',
|
||||||
use_portable_settings: false,
|
use_portable_settings: false,
|
||||||
language: 'en',
|
language: 'en',
|
||||||
show_only_sfw: false,
|
show_only_sfw: false,
|
||||||
@@ -89,7 +90,9 @@ export const state = {
|
|||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: [],
|
||||||
|
search: '',
|
||||||
|
tagLogic: 'any',
|
||||||
},
|
},
|
||||||
bulkMode: false,
|
bulkMode: false,
|
||||||
selectedLoras: new Set(),
|
selectedLoras: new Set(),
|
||||||
@@ -97,6 +100,12 @@ export const state = {
|
|||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
showUpdateAvailableOnly: false,
|
showUpdateAvailableOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
|
viewMode: 'active',
|
||||||
|
excludedViewState: {
|
||||||
|
sortBy: 'name:asc',
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
activeViewSnapshot: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
recipes: {
|
recipes: {
|
||||||
@@ -146,7 +155,9 @@ export const state = {
|
|||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: [],
|
||||||
|
search: '',
|
||||||
|
tagLogic: 'any',
|
||||||
},
|
},
|
||||||
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||||
bulkMode: false,
|
bulkMode: false,
|
||||||
@@ -155,6 +166,12 @@ export const state = {
|
|||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
showUpdateAvailableOnly: false,
|
showUpdateAvailableOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
|
viewMode: 'active',
|
||||||
|
excludedViewState: {
|
||||||
|
sortBy: 'name:asc',
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
activeViewSnapshot: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
@@ -177,7 +194,9 @@ export const state = {
|
|||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: []
|
modelTypes: [],
|
||||||
|
search: '',
|
||||||
|
tagLogic: 'any',
|
||||||
},
|
},
|
||||||
bulkMode: false,
|
bulkMode: false,
|
||||||
selectedModels: new Set(),
|
selectedModels: new Set(),
|
||||||
@@ -185,6 +204,12 @@ export const state = {
|
|||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
showUpdateAvailableOnly: false,
|
showUpdateAvailableOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
|
viewMode: 'active',
|
||||||
|
excludedViewState: {
|
||||||
|
sortBy: 'name:asc',
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
activeViewSnapshot: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,64 @@ export const OptimizationMode = {
|
|||||||
THUMBNAIL: 'thumbnail',
|
THUMBNAIL: 'thumbnail',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
|
||||||
|
|
||||||
|
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
|
||||||
|
'civitai.com',
|
||||||
|
'civitai.red',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function normalizeCivitaiPageHost(hostname) {
|
||||||
|
if (!hostname || typeof hostname !== 'string') {
|
||||||
|
return DEFAULT_CIVITAI_PAGE_HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = hostname.trim().toLowerCase();
|
||||||
|
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_CIVITAI_PAGE_HOST;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCivitaiModelUrl(modelId, versionId = null, host = DEFAULT_CIVITAI_PAGE_HOST) {
|
||||||
|
const normalizedHost = normalizeCivitaiPageHost(host);
|
||||||
|
const normalizedModelId = modelId == null ? '' : String(modelId).trim();
|
||||||
|
const normalizedVersionId = versionId == null ? '' : String(versionId).trim();
|
||||||
|
|
||||||
|
if (normalizedModelId) {
|
||||||
|
const encodedModelId = encodeURIComponent(normalizedModelId);
|
||||||
|
let url = `https://${normalizedHost}/models/${encodedModelId}`;
|
||||||
|
if (normalizedVersionId) {
|
||||||
|
url += `?modelVersionId=${encodeURIComponent(normalizedVersionId)}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedVersionId) {
|
||||||
|
return `https://${normalizedHost}/model-versions/${encodeURIComponent(normalizedVersionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCivitaiSearchUrl(query, host = DEFAULT_CIVITAI_PAGE_HOST) {
|
||||||
|
const normalizedQuery = query == null ? '' : String(query).trim();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHost = normalizeCivitaiPageHost(host);
|
||||||
|
return `https://${normalizedHost}/models?query=${encodeURIComponent(normalizedQuery)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCivitaiUrl({ modelId = null, versionId = null, modelName = null, host = DEFAULT_CIVITAI_PAGE_HOST } = {}) {
|
||||||
|
return (
|
||||||
|
buildCivitaiModelUrl(modelId, versionId, host)
|
||||||
|
|| buildCivitaiSearchUrl(modelName, host)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rewrite Civitai preview URLs to use optimized renditions.
|
* Rewrite Civitai preview URLs to use optimized renditions.
|
||||||
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
|
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
|
||||||
@@ -119,3 +177,50 @@ export function isCivitaiUrl(url) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSupportedCivitaiPageHost(hostname) {
|
||||||
|
if (!hostname) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCivitaiModelUrlParts(url) {
|
||||||
|
if (!url) {
|
||||||
|
return { modelId: null, modelVersionId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
|
||||||
|
return { modelId: null, modelVersionId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
|
||||||
|
const modelId = pathMatch ? pathMatch[1] : null;
|
||||||
|
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
||||||
|
|
||||||
|
return { modelId, modelVersionId };
|
||||||
|
} catch (e) {
|
||||||
|
return { modelId: null, modelVersionId: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCivitaiImageId(url) {
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMatch = parsedUrl.pathname.match(/\/images\/(\d+)/);
|
||||||
|
return pathMatch ? pathMatch[1] : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,43 @@ import { createModelCard, setupModelCardEventDelegation } from '../components/sh
|
|||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { showToast } from './uiHelpers.js';
|
import { showToast } from './uiHelpers.js';
|
||||||
|
|
||||||
|
function getScrollContainer() {
|
||||||
|
return document.querySelector('.page-content');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClampedScrollTop(scrollContainer, scrollTop) {
|
||||||
|
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
|
||||||
|
return Math.min(Math.max(scrollTop, 0), maxScrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForAnimationFrame() {
|
||||||
|
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureScrollPosition() {
|
||||||
|
const scrollContainer = getScrollContainer();
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollContainer,
|
||||||
|
scrollTop: scrollContainer.scrollTop
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreScrollPosition(snapshot) {
|
||||||
|
if (!snapshot?.scrollContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for layout and the scheduled virtual-scroll render to settle.
|
||||||
|
await waitForAnimationFrame();
|
||||||
|
await waitForAnimationFrame();
|
||||||
|
|
||||||
|
snapshot.scrollContainer.scrollTop = getClampedScrollTop(snapshot.scrollContainer, snapshot.scrollTop);
|
||||||
|
}
|
||||||
|
|
||||||
// Function to dynamically import the appropriate card creator based on page type
|
// Function to dynamically import the appropriate card creator based on page type
|
||||||
async function getCardCreator(pageType) {
|
async function getCardCreator(pageType) {
|
||||||
if (pageType === 'recipes') {
|
if (pageType === 'recipes') {
|
||||||
@@ -87,7 +124,7 @@ async function initializeVirtualScroll(pageType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Change this line to get the actual scrolling container
|
// Change this line to get the actual scrolling container
|
||||||
const scrollContainer = document.querySelector('.page-content');
|
const scrollContainer = getScrollContainer();
|
||||||
const gridContainer = scrollContainer.querySelector('.container');
|
const gridContainer = scrollContainer.querySelector('.container');
|
||||||
|
|
||||||
if (!gridContainer) {
|
if (!gridContainer) {
|
||||||
@@ -200,9 +237,16 @@ export function cleanupKeyboardNavigation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export a method to refresh the virtual scroller when filters change
|
// Export a method to refresh the virtual scroller when filters change
|
||||||
export function refreshVirtualScroll() {
|
export async function refreshVirtualScroll(options = {}) {
|
||||||
|
const { preserveScroll = false } = options;
|
||||||
|
|
||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
|
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||||
state.virtualScroller.reset();
|
state.virtualScroller.reset();
|
||||||
state.virtualScroller.initialize();
|
await state.virtualScroller.initialize();
|
||||||
|
|
||||||
|
if (scrollSnapshot) {
|
||||||
|
await restoreScrollPosition(scrollSnapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,66 @@ import { state, getCurrentPageState } from '../state/index.js';
|
|||||||
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||||||
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
|
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
|
||||||
import { eventManager } from './EventManager.js';
|
import { eventManager } from './EventManager.js';
|
||||||
|
import { bannerService } from '../managers/BannerService.js';
|
||||||
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
|
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
|
||||||
|
|
||||||
|
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
|
||||||
|
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
|
||||||
|
|
||||||
|
function getPreferredCivitaiHost() {
|
||||||
|
return normalizeCivitaiPageHost(state?.global?.settings?.civitai_host);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRegisterCivitaiHostInfoBanner() {
|
||||||
|
if (getStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, true);
|
||||||
|
|
||||||
|
bannerService.registerBanner(CIVITAI_HOST_INFO_BANNER_ID, {
|
||||||
|
id: CIVITAI_HOST_INFO_BANNER_ID,
|
||||||
|
title: translate(
|
||||||
|
'settings.civitaiHostBanner.title',
|
||||||
|
{},
|
||||||
|
'Civitai host preference available'
|
||||||
|
),
|
||||||
|
content: translate(
|
||||||
|
'settings.civitaiHostBanner.content',
|
||||||
|
{},
|
||||||
|
'Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.'
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: translate('settings.civitaiHostBanner.openSettings', {}, 'Open Settings'),
|
||||||
|
icon: 'fas fa-cog',
|
||||||
|
action: 'open-settings-modal',
|
||||||
|
type: 'primary',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dismissible: true,
|
||||||
|
priority: 70,
|
||||||
|
onRegister: (bannerElement) => {
|
||||||
|
const button = bannerElement.querySelector('.banner-action[data-action="open-settings-modal"]');
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
modalManager.showModal('settingsModal');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCivitaiUrl(url) {
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeRegisterCivitaiHostInfoBanner();
|
||||||
|
return window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function to copy text to clipboard with fallback for older browsers
|
* Utility function to copy text to clipboard with fallback for older browsers
|
||||||
@@ -184,14 +244,15 @@ function filterByFolder(folderPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
|
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
|
||||||
if (versionId) {
|
const url = buildCivitaiUrl({
|
||||||
// Use model-versions endpoint which auto-redirects to correct model page
|
modelId: civitaiId,
|
||||||
window.open(`https://civitai.com/model-versions/${versionId}`, '_blank');
|
versionId,
|
||||||
} else if (civitaiId) {
|
modelName,
|
||||||
window.open(`https://civitai.com/models/${civitaiId}`, '_blank');
|
host: getPreferredCivitaiHost(),
|
||||||
} else if (modelName) {
|
});
|
||||||
// Fallback: search by name
|
|
||||||
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
|
if (url) {
|
||||||
|
openCivitaiUrl(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,9 @@
|
|||||||
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
||||||
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="manage-excluded-models">
|
||||||
|
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
|
||||||
|
</div>
|
||||||
<div class="context-menu-item" data-action="repair-recipes">
|
<div class="context-menu-item" data-action="repair-recipes">
|
||||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||||
|
<div class="excluded-view-banner__content">
|
||||||
|
<div class="excluded-view-banner__title">
|
||||||
|
<i class="fas fa-eye-slash"></i>
|
||||||
|
<span>{{ t('globalContextMenu.manageExcludedModels.label', default='Excluded Models') }}</span>
|
||||||
|
</div>
|
||||||
|
<button id="excludedViewBackBtn" class="excluded-view-banner__back">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
<span>{{ t('common.actions.back', default='Back') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
|
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
|
||||||
|
|||||||
@@ -114,6 +114,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="civitaiHost">{{ t('settings.civitaiHost.label') }}</label>
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiHost.help') }}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="civitaiHost" onchange="settingsManager.saveSelectSetting('civitaiHost', 'civitai_host')">
|
||||||
|
<option value="civitai.com">{{ t('settings.civitaiHost.options.com') }}</option>
|
||||||
|
<option value="civitai.red">{{ t('settings.civitaiHost.options.red') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Backup -->
|
<!-- Backup -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ const showToastMock = vi.hoisted(() => vi.fn());
|
|||||||
const loadingManagerMock = vi.hoisted(() => ({
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
showSimpleLoading: vi.fn(),
|
showSimpleLoading: vi.fn(),
|
||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const virtualScrollerMock = vi.hoisted(() => ({
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
refreshWithData: vi.fn(),
|
||||||
|
}));
|
||||||
|
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
|
||||||
|
const captureScrollPositionMock = vi.hoisted(() => vi.fn());
|
||||||
|
const restoreScrollPositionMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||||
return {
|
return {
|
||||||
@@ -20,17 +28,41 @@ vi.mock('../../../static/js/state/index.js', () => {
|
|||||||
return {
|
return {
|
||||||
state: {
|
state: {
|
||||||
loadingManager: loadingManagerMock,
|
loadingManager: loadingManagerMock,
|
||||||
|
virtualScroller: virtualScrollerMock,
|
||||||
},
|
},
|
||||||
getCurrentPageState: vi.fn(),
|
getCurrentPageState: getCurrentPageStateMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
|
captureScrollPosition: captureScrollPositionMock,
|
||||||
|
restoreScrollPosition: restoreScrollPositionMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
RecipeSidebarApiClient,
|
||||||
|
fetchRecipeDetails,
|
||||||
|
resetAndReload,
|
||||||
|
syncChanges,
|
||||||
|
updateRecipeMetadata
|
||||||
|
} from '../../../static/js/api/recipeApi.js';
|
||||||
|
|
||||||
describe('RecipeSidebarApiClient bulk operations', () => {
|
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
global.fetch = vi.fn();
|
global.fetch = vi.fn();
|
||||||
|
getCurrentPageStateMock.mockReturnValue({
|
||||||
|
pageSize: 50,
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: true,
|
||||||
|
isLoading: false,
|
||||||
|
sortBy: 'date:desc',
|
||||||
|
showFavoritesOnly: false,
|
||||||
|
activeFolder: null,
|
||||||
|
searchOptions: { recursive: true },
|
||||||
|
customFilter: { active: false },
|
||||||
|
filters: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -111,4 +143,77 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
});
|
});
|
||||||
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('encodes recipe IDs when fetching recipe details', async () => {
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'abc' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchRecipeDetails('recipe#1?name=foo%bar');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe%231%3Fname%3Dfoo%25bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the virtual scroller using the original list path when provided', async () => {
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateRecipeMetadata(
|
||||||
|
'/recipes/new-folder/recipe#1.webp',
|
||||||
|
{ title: 'Updated Title' },
|
||||||
|
{ listFilePath: '/recipes/old-folder/recipe#1.webp' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipe/recipe%231/update',
|
||||||
|
expect.objectContaining({ method: 'PUT' })
|
||||||
|
);
|
||||||
|
expect(virtualScrollerMock.updateSingleItem).toHaveBeenCalledWith(
|
||||||
|
'/recipes/old-folder/recipe#1.webp',
|
||||||
|
{ title: 'Updated Title' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves scroll position for recipe reloads when requested', async () => {
|
||||||
|
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
|
||||||
|
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
items: [{ id: 'recipe-1' }],
|
||||||
|
total: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await resetAndReload(false, { preserveScroll: true });
|
||||||
|
|
||||||
|
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
||||||
|
[{ id: 'recipe-1' }],
|
||||||
|
1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses scroll-preserving reloads for syncChanges', async () => {
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
total_pages: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncChanges();
|
||||||
|
|
||||||
|
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,6 +126,31 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
|
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates duplicate-equivalent query variations before issuing requests', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('Example');
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.value = 'Example';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=Example&limit=100');
|
||||||
|
});
|
||||||
|
|
||||||
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
|
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
|
||||||
fetchApiMock.mockImplementation((url) => {
|
fetchApiMock.mockImplementation((url) => {
|
||||||
if (url.includes('usage-tips-by-path')) {
|
if (url.includes('usage-tips-by-path')) {
|
||||||
@@ -244,6 +269,53 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(inputListener).not.toHaveBeenCalled();
|
expect(inputListener).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the full command list when typing a single slash', async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const commandNames = autoComplete.items.map((item) => item.command);
|
||||||
|
|
||||||
|
expect(commandNames).toContain('/character');
|
||||||
|
expect(commandNames).toContain('/artist');
|
||||||
|
expect(commandNames).toContain('/general');
|
||||||
|
expect(commandNames).toContain('/copyright');
|
||||||
|
expect(commandNames).toContain('/meta');
|
||||||
|
expect(commandNames).toContain('/species');
|
||||||
|
expect(commandNames).toContain('/lore');
|
||||||
|
expect(commandNames).toContain('/emb');
|
||||||
|
expect(commandNames).toContain('/embedding');
|
||||||
|
expect(commandNames).toContain('/wildcard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders every command item when slash opens the command list', async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
const renderedCommands = autoComplete.contentContainer.querySelectorAll('.lm-autocomplete-command-name');
|
||||||
|
|
||||||
|
expect(renderedCommands).toHaveLength(autoComplete.items.length);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts the selected suggestion with Enter', async () => {
|
it('accepts the selected suggestion with Enter', async () => {
|
||||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
|
||||||
@@ -300,6 +372,66 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves manual ArrowDown selection when Tab accepts a suggestion', async () => {
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'loop';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.items = [
|
||||||
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
||||||
|
{ tag_name: 'loop', category: 0, post_count: 500 },
|
||||||
|
];
|
||||||
|
autoComplete.currentSearchTerm = 'loo';
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.isVisible = true;
|
||||||
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
expect(autoComplete.selectedIndex).toBe(1);
|
||||||
|
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves manual ArrowDown selection when Enter accepts a suggestion', async () => {
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'loop';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
autoComplete.searchType = 'custom_words';
|
||||||
|
autoComplete.items = [
|
||||||
|
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
||||||
|
{ tag_name: 'loop', category: 0, post_count: 500 },
|
||||||
|
];
|
||||||
|
autoComplete.currentSearchTerm = 'loo';
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
autoComplete.isVisible = true;
|
||||||
|
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
expect(autoComplete.selectedIndex).toBe(1);
|
||||||
|
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
|
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
|
||||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||||
|
|
||||||
@@ -743,12 +875,12 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate "/char looking to the side" input
|
// Simulate "/character looking to the side" input
|
||||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side');
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
|
||||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
const input = document.createElement('textarea');
|
const input = document.createElement('textarea');
|
||||||
input.value = '/char looking to the side';
|
input.value = '/character looking to the side';
|
||||||
input.selectionStart = input.value.length;
|
input.selectionStart = input.value.length;
|
||||||
input.focus = vi.fn();
|
input.focus = vi.fn();
|
||||||
input.setSelectionRange = vi.fn();
|
input.setSelectionRange = vi.fn();
|
||||||
@@ -766,7 +898,7 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
|
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
|
||||||
autoComplete.items = mockTags;
|
autoComplete.items = mockTags;
|
||||||
autoComplete.selectedIndex = 0;
|
autoComplete.selectedIndex = 0;
|
||||||
autoComplete.currentSearchTerm = '/char looking to the side';
|
autoComplete.currentSearchTerm = '/character looking to the side';
|
||||||
|
|
||||||
await autoComplete.insertSelection('looking_to_the_side');
|
await autoComplete.insertSelection('looking_to_the_side');
|
||||||
|
|
||||||
@@ -1073,6 +1205,253 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('searches wildcard keys when using the /wildcard command', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
words: ['animals/cat'],
|
||||||
|
meta: {
|
||||||
|
has_wildcards: true,
|
||||||
|
wildcards_dir: '/tmp/settings/wildcards',
|
||||||
|
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/wildcard cat';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchApiMock.mockClear();
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=cat&limit=100');
|
||||||
|
expect(autoComplete.searchType).toBe('wildcards');
|
||||||
|
expect(autoComplete.items).toEqual(['animals/cat']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows wildcard onboarding when /wildcard is used before any files exist', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
words: [],
|
||||||
|
meta: {
|
||||||
|
has_wildcards: false,
|
||||||
|
wildcards_dir: '/tmp/settings/wildcards',
|
||||||
|
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/wildcard cat';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(autoComplete.isVisible).toBe(true);
|
||||||
|
expect(autoComplete.items).toHaveLength(1);
|
||||||
|
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
|
||||||
|
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
|
||||||
|
expect(autoComplete.dropdown.textContent).toContain('/tmp/settings/wildcards');
|
||||||
|
expect(autoComplete.dropdown.textContent).toContain('.txt, .yaml, .yml, .json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows wildcard onboarding when only the /wildcard command is entered', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
words: [],
|
||||||
|
meta: {
|
||||||
|
has_wildcards: false,
|
||||||
|
wildcards_dir: '/tmp/settings/wildcards',
|
||||||
|
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard ');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/wildcard ';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=&limit=100');
|
||||||
|
expect(autoComplete.isVisible).toBe(true);
|
||||||
|
expect(autoComplete.items).toHaveLength(1);
|
||||||
|
expect(autoComplete.items[0].type).toBe('wildcard_empty_state');
|
||||||
|
expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a lightweight no-match state when wildcard files exist but search misses', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
words: [],
|
||||||
|
meta: {
|
||||||
|
has_wildcards: true,
|
||||||
|
wildcards_dir: '/tmp/settings/wildcards',
|
||||||
|
supported_formats: ['.txt', '.yaml', '.yml', '.json'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard dragon');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/wildcard dragon';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(autoComplete.items).toHaveLength(1);
|
||||||
|
expect(autoComplete.items[0].type).toBe('wildcard_no_matches');
|
||||||
|
expect(autoComplete.dropdown.textContent).toContain('No wildcard matches');
|
||||||
|
expect(autoComplete.dropdown.textContent).not.toContain('Open wildcards folder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts wildcard references when accepting a /wildcard result', async () => {
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat');
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/wildcard animals/cat';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
input.focus = vi.fn();
|
||||||
|
input.setSelectionRange = vi.fn();
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
autoComplete.searchType = 'wildcards';
|
||||||
|
autoComplete.activeCommand = { type: 'wildcard', label: 'Wildcards' };
|
||||||
|
autoComplete.items = ['animals/cat'];
|
||||||
|
autoComplete.selectedIndex = 0;
|
||||||
|
|
||||||
|
await autoComplete.insertSelection('animals/cat');
|
||||||
|
|
||||||
|
expect(input.value).toBe('__animals/cat__,');
|
||||||
|
expect(input.focus).toHaveBeenCalled();
|
||||||
|
expect(input.setSelectionRange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reopen autocomplete on blur after inserting a wildcard literal', async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '__flower__,';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__,');
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hideSpy = vi.spyOn(autoComplete, 'hide');
|
||||||
|
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(fetchApiMock).not.toHaveBeenCalled();
|
||||||
|
expect(hideSpy).toHaveBeenCalled();
|
||||||
|
expect(autoComplete.isVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a command after a wildcard literal as the active token', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
words: [{ tag_name: 'flower_field', category: 4, post_count: 1234 }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '__flower__ /character f';
|
||||||
|
input.selectionStart = input.value.length;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__ /character f');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input,'prompt', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(autoComplete.getSearchTerm(input.value)).toBe('/character f');
|
||||||
|
});
|
||||||
|
|
||||||
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
|
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
if (key === 'loramanager.autocomplete_append_comma') {
|
if (key === 'loramanager.autocomplete_append_comma') {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
21
tests/frontend/components/modelContextMenuMixin.test.js
Normal file
21
tests/frontend/components/modelContextMenuMixin.test.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
|
||||||
|
|
||||||
|
describe('ModelContextMenuMixin.extractModelVersionId', () => {
|
||||||
|
it('accepts civitai.red model URLs', () => {
|
||||||
|
expect(
|
||||||
|
ModelContextMenuMixin.extractModelVersionId(
|
||||||
|
'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777'
|
||||||
|
)
|
||||||
|
).toEqual({ modelId: '65423', modelVersionId: '777' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects model-like URLs from unsupported hosts', () => {
|
||||||
|
expect(
|
||||||
|
ModelContextMenuMixin.extractModelVersionId(
|
||||||
|
'https://example.com/models/65423?modelVersionId=777'
|
||||||
|
)
|
||||||
|
).toEqual({ modelId: null, modelVersionId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
|
|||||||
|
|
||||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
showToast: vi.fn(),
|
showToast: vi.fn(),
|
||||||
|
openCivitaiUrl: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const stateMock = {
|
const stateMock = {
|
||||||
@@ -349,4 +350,59 @@ describe('ModelVersionsTab media rendering', () => {
|
|||||||
);
|
);
|
||||||
expect(firstBadges).toContain('Newer Version');
|
expect(firstBadges).toContain('Newer Version');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows downloaded badge only for previously downloaded versions that are not in library', async () => {
|
||||||
|
fetchModelUpdateVersions.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
record: {
|
||||||
|
shouldIgnore: false,
|
||||||
|
inLibraryVersionIds: [8],
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
versionId: 9,
|
||||||
|
name: 'History only',
|
||||||
|
baseModel: 'SDXL',
|
||||||
|
previewUrl: '/api/lm/previews/v9.png',
|
||||||
|
sizeBytes: 1024,
|
||||||
|
isInLibrary: false,
|
||||||
|
hasBeenDownloaded: true,
|
||||||
|
shouldIgnore: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
versionId: 8,
|
||||||
|
name: 'Local copy',
|
||||||
|
baseModel: 'SDXL',
|
||||||
|
previewUrl: '/api/lm/previews/v8.png',
|
||||||
|
sizeBytes: 2048,
|
||||||
|
isInLibrary: true,
|
||||||
|
hasBeenDownloaded: true,
|
||||||
|
shouldIgnore: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
|
||||||
|
const controller = initVersionsTab({
|
||||||
|
modalId: 'model-versions-modal',
|
||||||
|
modelType: 'loras',
|
||||||
|
modelId: 654,
|
||||||
|
currentVersionId: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.load();
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll('.model-version-row');
|
||||||
|
const historyBadges = Array.from(rows[0].querySelectorAll('.version-badge')).map(
|
||||||
|
badge => badge.textContent?.trim()
|
||||||
|
);
|
||||||
|
const localBadges = Array.from(rows[1].querySelectorAll('.version-badge')).map(
|
||||||
|
badge => badge.textContent?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(historyBadges).toContain('Downloaded');
|
||||||
|
expect(historyBadges).not.toContain('In Library');
|
||||||
|
expect(localBadges).toContain('In Library');
|
||||||
|
expect(localBadges).not.toContain('Downloaded');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const apiClientMock = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showToastMock = vi.fn();
|
const showToastMock = vi.fn();
|
||||||
|
const openCivitaiByMetadataMock = vi.fn();
|
||||||
const updatePanelPositionsMock = vi.fn();
|
const updatePanelPositionsMock = vi.fn();
|
||||||
const downloadManagerMock = {
|
const downloadManagerMock = {
|
||||||
showDownloadModal: vi.fn(),
|
showDownloadModal: vi.fn(),
|
||||||
@@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
showToast: showToastMock,
|
showToast: showToastMock,
|
||||||
|
openCivitaiByMetadata: openCivitaiByMetadataMock,
|
||||||
updatePanelPositions: updatePanelPositionsMock,
|
updatePanelPositions: updatePanelPositionsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -84,6 +86,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
delete window.bulkManager;
|
||||||
delete window.modelDuplicatesManager;
|
delete window.modelDuplicatesManager;
|
||||||
delete global.fetch;
|
delete global.fetch;
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -112,6 +115,9 @@ function renderControlsDom(pageKey) {
|
|||||||
<button class="clear-filter"></button>
|
<button class="clear-filter"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||||
|
<button id="excludedViewBackBtn">Back</button>
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
@@ -170,6 +176,9 @@ function renderControlsDom(pageKey) {
|
|||||||
<i class="fas fa-times-circle clear-filter"></i>
|
<i class="fas fa-times-circle clear-filter"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="breadcrumbContainer"></div>
|
||||||
|
<div id="duplicatesBanner" style="display: none;"></div>
|
||||||
|
<div class="alphabet-bar-container"></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,4 +583,93 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
|||||||
duplicateButton.click();
|
duplicateButton.click();
|
||||||
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
|
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['loras', 'LorasControls'],
|
||||||
|
['checkpoints', 'CheckpointsControls'],
|
||||||
|
['embeddings', 'EmbeddingsControls'],
|
||||||
|
])('switches %s page into excluded mode and restores state', async (pageKey, exportName) => {
|
||||||
|
renderControlsDom(pageKey);
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState(pageKey);
|
||||||
|
const pageState = stateModule.getCurrentPageState();
|
||||||
|
pageState.filters.search = 'active-search';
|
||||||
|
pageState.showFavoritesOnly = true;
|
||||||
|
pageState.showUpdateAvailableOnly = true;
|
||||||
|
|
||||||
|
const controlsModule = await import('../../../static/js/components/controls/index.js');
|
||||||
|
const ControlsClass = controlsModule[exportName];
|
||||||
|
const controls = new ControlsClass();
|
||||||
|
|
||||||
|
await controls.enterExcludedView();
|
||||||
|
|
||||||
|
expect(pageState.viewMode).toBe('excluded');
|
||||||
|
expect(pageState.filters.search).toBe('');
|
||||||
|
expect(resetAndReloadMock).toHaveBeenLastCalledWith(false);
|
||||||
|
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(false);
|
||||||
|
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(true);
|
||||||
|
expect(document.getElementById('filterButton').disabled).toBe(true);
|
||||||
|
|
||||||
|
pageState.filters.search = 'excluded-search';
|
||||||
|
await controls.exitExcludedView();
|
||||||
|
|
||||||
|
expect(pageState.viewMode).toBe('active');
|
||||||
|
expect(pageState.filters.search).toBe('active-search');
|
||||||
|
expect(pageState.excludedViewState.search).toBe('excluded-search');
|
||||||
|
expect(resetAndReloadMock).toHaveBeenLastCalledWith(true);
|
||||||
|
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(true);
|
||||||
|
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(false);
|
||||||
|
expect(document.getElementById('filterButton').disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suspends bulk and duplicate modes for excluded view and restores custom filter banner on exit', async () => {
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState('loras');
|
||||||
|
const pageState = stateModule.getCurrentPageState();
|
||||||
|
stateModule.state.bulkMode = true;
|
||||||
|
pageState.duplicatesMode = true;
|
||||||
|
|
||||||
|
sessionStorage.setItem('lora_manager_recipe_to_lora_filterLoraHash', 'hash-1');
|
||||||
|
sessionStorage.setItem('lora_manager_filterRecipeName', 'Recipe Filter');
|
||||||
|
|
||||||
|
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
|
||||||
|
|
||||||
|
const toggleBulkMode = vi.fn(() => {
|
||||||
|
stateModule.state.bulkMode = !stateModule.state.bulkMode;
|
||||||
|
});
|
||||||
|
const exitDuplicateMode = vi.fn(() => {
|
||||||
|
pageState.duplicatesMode = false;
|
||||||
|
});
|
||||||
|
const enterDuplicateMode = vi.fn(() => {
|
||||||
|
pageState.duplicatesMode = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.bulkManager = { toggleBulkMode };
|
||||||
|
window.modelDuplicatesManager = {
|
||||||
|
duplicateGroups: [{ hash: 'dup-1', models: [{ file_path: 'a' }, { file_path: 'b' }] }],
|
||||||
|
exitDuplicateMode,
|
||||||
|
enterDuplicateMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controls = new LorasControls();
|
||||||
|
const indicator = document.getElementById('customFilterIndicator');
|
||||||
|
expect(indicator.classList.contains('hidden')).toBe(false);
|
||||||
|
|
||||||
|
await controls.enterExcludedView();
|
||||||
|
|
||||||
|
expect(toggleBulkMode).toHaveBeenCalledTimes(1);
|
||||||
|
expect(exitDuplicateMode).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stateModule.state.bulkMode).toBe(false);
|
||||||
|
expect(pageState.duplicatesMode).toBe(false);
|
||||||
|
expect(indicator.classList.contains('hidden')).toBe(true);
|
||||||
|
|
||||||
|
await controls.exitExcludedView();
|
||||||
|
|
||||||
|
expect(indicator.classList.contains('hidden')).toBe(false);
|
||||||
|
expect(toggleBulkMode).toHaveBeenCalledTimes(2);
|
||||||
|
expect(enterDuplicateMode).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stateModule.state.bulkMode).toBe(true);
|
||||||
|
expect(pageState.duplicatesMode).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,4 +53,26 @@ describe('DoctorManager', () => {
|
|||||||
|
|
||||||
expect(refreshSpy).not.toHaveBeenCalled();
|
expect(refreshSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds a cache-busted reload URL that preserves the current location', () => {
|
||||||
|
renderDoctorFixture();
|
||||||
|
window.history.replaceState({}, '', '/loras?filter=active#details');
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
|
||||||
|
|
||||||
|
const manager = new DoctorManager();
|
||||||
|
|
||||||
|
const url = manager.buildReloadUrl();
|
||||||
|
|
||||||
|
expect(url).toBe('http://localhost:3000/loras?filter=active&_lm_reload=1234567890#details');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates reload-page actions to reloadUi', async () => {
|
||||||
|
renderDoctorFixture();
|
||||||
|
const manager = new DoctorManager();
|
||||||
|
const reloadSpy = vi.spyOn(manager, 'reloadUi').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
await manager.handleAction('reload-page');
|
||||||
|
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
149
tests/frontend/managers/downloadManager.history.test.js
Normal file
149
tests/frontend/managers/downloadManager.history.test.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
DOWNLOAD_MANAGER_MODULE,
|
||||||
|
MODAL_MANAGER_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
STATE_MODULE,
|
||||||
|
LOADING_MANAGER_MODULE,
|
||||||
|
API_FACTORY_MODULE,
|
||||||
|
STORAGE_HELPERS_MODULE,
|
||||||
|
FOLDER_TREE_MANAGER_MODULE,
|
||||||
|
I18N_HELPERS_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||||
|
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||||
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
|
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||||
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||||
|
modalManager: {
|
||||||
|
showModal: vi.fn(),
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: {
|
||||||
|
global: {
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||||
|
LoadingManager: vi.fn(() => ({
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
showDownloadProgress: vi.fn(() => vi.fn()),
|
||||||
|
setStatus: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_FACTORY_MODULE, () => ({
|
||||||
|
getModelApiClient: vi.fn(() => ({
|
||||||
|
apiConfig: {
|
||||||
|
config: {
|
||||||
|
displayName: 'LoRA',
|
||||||
|
singularName: 'lora',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
resetAndReload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||||
|
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||||
|
FolderTreeManager: vi.fn(() => ({
|
||||||
|
clearSelection: vi.fn(),
|
||||||
|
init: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('DownloadManager version history badges', () => {
|
||||||
|
let DownloadManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="urlStep"></div>
|
||||||
|
<div id="versionStep"></div>
|
||||||
|
<div id="versionList"></div>
|
||||||
|
<button id="nextFromVersion"></button>
|
||||||
|
`;
|
||||||
|
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows downloaded badge only for versions missing locally', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
manager.versions = [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
name: 'History only',
|
||||||
|
images: [],
|
||||||
|
files: [{ sizeKB: 2048 }],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
existsLocally: false,
|
||||||
|
hasBeenDownloaded: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
name: 'Still local',
|
||||||
|
images: [],
|
||||||
|
files: [{ sizeKB: 2048 }],
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
existsLocally: true,
|
||||||
|
hasBeenDownloaded: true,
|
||||||
|
localPath: '/models/still-local.safetensors',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
manager.showVersionStep();
|
||||||
|
|
||||||
|
const items = document.querySelectorAll('.version-item');
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
|
||||||
|
expect(items[0].querySelector('.downloaded-badge')?.textContent).toContain('Downloaded');
|
||||||
|
expect(items[0].querySelector('.downloaded-badge')?.getAttribute('title')).toContain(
|
||||||
|
'Previously downloaded, but it is not currently in your library.'
|
||||||
|
);
|
||||||
|
expect(items[0].querySelector('.local-badge')).toBeNull();
|
||||||
|
|
||||||
|
expect(items[1].querySelector('.local-badge')).not.toBeNull();
|
||||||
|
expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors');
|
||||||
|
expect(items[1].querySelector('.downloaded-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts model and version ids from civitai.red URLs', () => {
|
||||||
|
const manager = new DownloadManager();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
manager.extractModelId('https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777')
|
||||||
|
).toBe('65423');
|
||||||
|
expect(manager.modelVersionId).toBe('777');
|
||||||
|
expect(manager.source).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,6 +96,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
delete global.fetch;
|
delete global.fetch;
|
||||||
delete document.hidden;
|
delete document.hidden;
|
||||||
Object.defineProperty(window, 'location', {
|
Object.defineProperty(window, 'location', {
|
||||||
@@ -231,6 +232,51 @@ describe('SettingsManager library controls', () => {
|
|||||||
expect(input.value).toBe('/custom/recipes');
|
expect(input.value).toBe('/custom/recipes');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not autofocus empty extra folder path rows during initial settings load', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="extraFolderPaths-loras"></div>
|
||||||
|
<div id="extraFolderPaths-checkpoints"></div>
|
||||||
|
<div id="extraFolderPaths-unet"></div>
|
||||||
|
<div id="extraFolderPaths-embeddings"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
|
||||||
|
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
|
||||||
|
|
||||||
|
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
|
||||||
|
|
||||||
|
state.global.settings = {
|
||||||
|
extra_folder_paths: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.loadSettingsToUI();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still focuses an extra folder path row when it is added explicitly', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const manager = createManager();
|
||||||
|
document.body.innerHTML = '<div id="extraFolderPaths-embeddings"></div>';
|
||||||
|
|
||||||
|
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus').mockImplementation(() => {});
|
||||||
|
|
||||||
|
manager.addExtraFolderPathRow('embeddings', '');
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows loading while saving recipes_path', async () => {
|
it('shows loading while saving recipes_path', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const getCurrentPageStateMock = vi.fn();
|
|||||||
const getSessionItemMock = vi.fn();
|
const getSessionItemMock = vi.fn();
|
||||||
const removeSessionItemMock = vi.fn();
|
const removeSessionItemMock = vi.fn();
|
||||||
const getStorageItemMock = vi.fn();
|
const getStorageItemMock = vi.fn();
|
||||||
|
const setStorageItemMock = vi.fn();
|
||||||
|
const removeStorageItemMock = vi.fn();
|
||||||
const RecipeContextMenuMock = vi.fn();
|
const RecipeContextMenuMock = vi.fn();
|
||||||
const refreshVirtualScrollMock = vi.fn();
|
const refreshVirtualScrollMock = vi.fn();
|
||||||
const refreshRecipesMock = vi.fn();
|
const refreshRecipesMock = vi.fn();
|
||||||
@@ -53,6 +55,8 @@ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
|||||||
getSessionItem: getSessionItemMock,
|
getSessionItem: getSessionItemMock,
|
||||||
removeSessionItem: removeSessionItemMock,
|
removeSessionItem: removeSessionItemMock,
|
||||||
getStorageItem: getStorageItemMock,
|
getStorageItem: getStorageItemMock,
|
||||||
|
setStorageItem: setStorageItemMock,
|
||||||
|
removeStorageItem: removeStorageItemMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
||||||
@@ -212,6 +216,19 @@ describe('RecipeManager', () => {
|
|||||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports preserve-scroll options while keeping boolean compatibility', async () => {
|
||||||
|
const manager = new RecipeManager();
|
||||||
|
|
||||||
|
await manager.loadRecipes({ preserveScroll: true });
|
||||||
|
expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(1, { preserveScroll: true });
|
||||||
|
|
||||||
|
await manager.loadRecipes(false);
|
||||||
|
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await manager.loadRecipes({ resetPage: true, preserveScroll: false });
|
||||||
|
expect(refreshVirtualScrollMock).toHaveBeenNthCalledWith(2, { preserveScroll: false });
|
||||||
|
});
|
||||||
|
|
||||||
it('proxies duplicate management and refresh helpers', async () => {
|
it('proxies duplicate management and refresh helpers', async () => {
|
||||||
const manager = new RecipeManager();
|
const manager = new RecipeManager();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe('state module', () => {
|
|||||||
|
|
||||||
expect(defaultSettings).toMatchObject({
|
expect(defaultSettings).toMatchObject({
|
||||||
civitai_api_key: '',
|
civitai_api_key: '',
|
||||||
|
civitai_host: 'civitai.com',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
blur_mature_content: true,
|
blur_mature_content: true,
|
||||||
mature_blur_level: 'R'
|
mature_blur_level: 'R'
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_CIVITAI_PAGE_HOST,
|
||||||
|
normalizeCivitaiPageHost,
|
||||||
|
buildCivitaiModelUrl,
|
||||||
|
buildCivitaiSearchUrl,
|
||||||
|
buildCivitaiUrl,
|
||||||
rewriteCivitaiUrl,
|
rewriteCivitaiUrl,
|
||||||
getOptimizedUrl,
|
getOptimizedUrl,
|
||||||
getShowcaseUrl,
|
getShowcaseUrl,
|
||||||
getThumbnailUrl,
|
getThumbnailUrl,
|
||||||
|
extractCivitaiImageId,
|
||||||
|
extractCivitaiModelUrlParts,
|
||||||
isCivitaiUrl,
|
isCivitaiUrl,
|
||||||
|
isSupportedCivitaiPageHost,
|
||||||
OptimizationMode
|
OptimizationMode
|
||||||
} from '../../../static/js/utils/civitaiUtils.js';
|
} from '../../../static/js/utils/civitaiUtils.js';
|
||||||
|
|
||||||
@@ -16,6 +24,47 @@ describe('civitaiUtils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Civitai page URL helpers', () => {
|
||||||
|
it('normalizes invalid hosts to the default page host', () => {
|
||||||
|
expect(DEFAULT_CIVITAI_PAGE_HOST).toBe('civitai.com');
|
||||||
|
expect(normalizeCivitaiPageHost('civitai.red')).toBe('civitai.red');
|
||||||
|
expect(normalizeCivitaiPageHost(' CIVITAI.COM ')).toBe('civitai.com');
|
||||||
|
expect(normalizeCivitaiPageHost('example.com')).toBe('civitai.com');
|
||||||
|
expect(normalizeCivitaiPageHost(null)).toBe('civitai.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds model URLs using the configured host', () => {
|
||||||
|
expect(buildCivitaiModelUrl(123, 456, 'civitai.red')).toBe(
|
||||||
|
'https://civitai.red/models/123?modelVersionId=456'
|
||||||
|
);
|
||||||
|
expect(buildCivitaiModelUrl(123, null, 'civitai.com')).toBe(
|
||||||
|
'https://civitai.com/models/123'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the model-versions endpoint when only a version id is available', () => {
|
||||||
|
expect(buildCivitaiModelUrl(null, 456, 'civitai.red')).toBe(
|
||||||
|
'https://civitai.red/model-versions/456'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds search URLs using the configured host', () => {
|
||||||
|
expect(buildCivitaiSearchUrl('demo model', 'civitai.red')).toBe(
|
||||||
|
'https://civitai.red/models?query=demo%20model'
|
||||||
|
);
|
||||||
|
expect(buildCivitaiSearchUrl('', 'civitai.red')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers model/version URLs and falls back to search URLs', () => {
|
||||||
|
expect(buildCivitaiUrl({ modelId: 321, versionId: 654, host: 'civitai.red' })).toBe(
|
||||||
|
'https://civitai.red/models/321?modelVersionId=654'
|
||||||
|
);
|
||||||
|
expect(buildCivitaiUrl({ modelName: 'search me', host: 'civitai.red' })).toBe(
|
||||||
|
'https://civitai.red/models?query=search%20me'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('rewriteCivitaiUrl', () => {
|
describe('rewriteCivitaiUrl', () => {
|
||||||
it('should rewrite image URLs with /original=true for thumbnail mode', () => {
|
it('should rewrite image URLs with /original=true for thumbnail mode', () => {
|
||||||
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
|
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
|
||||||
@@ -217,4 +266,43 @@ describe('civitaiUtils', () => {
|
|||||||
expect(isCivitaiUrl('not-a-url')).toBe(false);
|
expect(isCivitaiUrl('not-a-url')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isSupportedCivitaiPageHost', () => {
|
||||||
|
it('accepts civitai.com and civitai.red page hosts', () => {
|
||||||
|
expect(isSupportedCivitaiPageHost('civitai.com')).toBe(true);
|
||||||
|
expect(isSupportedCivitaiPageHost('civitai.red')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unrelated hosts', () => {
|
||||||
|
expect(isSupportedCivitaiPageHost('www.civitai.com')).toBe(false);
|
||||||
|
expect(isSupportedCivitaiPageHost('www.civitai.red')).toBe(false);
|
||||||
|
expect(isSupportedCivitaiPageHost('example.com')).toBe(false);
|
||||||
|
expect(isSupportedCivitaiPageHost('')).toBe(false);
|
||||||
|
expect(isSupportedCivitaiPageHost(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCivitaiModelUrlParts', () => {
|
||||||
|
it('extracts model and version ids from civitai.red model URLs', () => {
|
||||||
|
expect(
|
||||||
|
extractCivitaiModelUrlParts('https://civitai.red/models/65423/name?modelVersionId=98765')
|
||||||
|
).toEqual({ modelId: '65423', modelVersionId: '98765' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects model-like URLs from unsupported hosts', () => {
|
||||||
|
expect(
|
||||||
|
extractCivitaiModelUrlParts('https://example.com/models/65423?modelVersionId=98765')
|
||||||
|
).toEqual({ modelId: null, modelVersionId: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractCivitaiImageId', () => {
|
||||||
|
it('extracts image ids from civitai.red image URLs', () => {
|
||||||
|
expect(extractCivitaiImageId('https://civitai.red/images/126920345')).toBe('126920345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects image-like URLs from unsupported hosts', () => {
|
||||||
|
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const {
|
|||||||
STORAGE_MODULE,
|
STORAGE_MODULE,
|
||||||
CONSTANTS_MODULE,
|
CONSTANTS_MODULE,
|
||||||
EVENT_MANAGER_MODULE,
|
EVENT_MANAGER_MODULE,
|
||||||
|
BANNER_SERVICE_MODULE,
|
||||||
|
MODAL_MANAGER_MODULE,
|
||||||
UI_HELPERS_MODULE,
|
UI_HELPERS_MODULE,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
@@ -13,12 +15,16 @@ const {
|
|||||||
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
|
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
|
||||||
EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname,
|
EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname,
|
||||||
|
BANNER_SERVICE_MODULE: new URL('../../../static/js/managers/BannerService.js', import.meta.url).pathname,
|
||||||
|
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const translateMock = vi.fn((key, _params, fallback) => fallback || key);
|
const translateMock = vi.fn((key, _params, fallback) => fallback || key);
|
||||||
const getStorageItemMock = vi.fn();
|
const getStorageItemMock = vi.fn();
|
||||||
const setStorageItemMock = vi.fn();
|
const setStorageItemMock = vi.fn();
|
||||||
|
const registerBannerMock = vi.fn();
|
||||||
|
const showModalMock = vi.fn();
|
||||||
|
|
||||||
vi.mock(I18N_MODULE, () => ({
|
vi.mock(I18N_MODULE, () => ({
|
||||||
translate: translateMock,
|
translate: translateMock,
|
||||||
@@ -50,6 +56,18 @@ vi.mock(EVENT_MANAGER_MODULE, () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock(BANNER_SERVICE_MODULE, () => ({
|
||||||
|
bannerService: {
|
||||||
|
registerBanner: registerBannerMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||||
|
modalManager: {
|
||||||
|
showModal: showModalMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('UI helper DOM utilities', () => {
|
describe('UI helper DOM utilities', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
@@ -57,6 +75,8 @@ describe('UI helper DOM utilities', () => {
|
|||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
getStorageItemMock.mockReset();
|
getStorageItemMock.mockReset();
|
||||||
setStorageItemMock.mockReset();
|
setStorageItemMock.mockReset();
|
||||||
|
registerBannerMock.mockReset();
|
||||||
|
showModalMock.mockReset();
|
||||||
translateMock.mockReset();
|
translateMock.mockReset();
|
||||||
globalThis.requestAnimationFrame = (cb) => cb();
|
globalThis.requestAnimationFrame = (cb) => cb();
|
||||||
});
|
});
|
||||||
@@ -156,4 +176,58 @@ describe('UI helper DOM utilities', () => {
|
|||||||
'#2 (Character Subgraph) Nested Loader',
|
'#2 (Character Subgraph) Nested Loader',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
|
||||||
|
const openSpy = vi.fn();
|
||||||
|
globalThis.window.open = openSpy;
|
||||||
|
|
||||||
|
getStorageItemMock.mockImplementation((key, defaultValue) => {
|
||||||
|
if (key === 'civitai_host_info_banner_seen') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
|
||||||
|
|
||||||
|
openCivitaiByMetadata(123, 456, 'Demo Model');
|
||||||
|
|
||||||
|
expect(setStorageItemMock).toHaveBeenCalledWith('civitai_host_info_banner_seen', true);
|
||||||
|
expect(registerBannerMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
'https://civitai.com/models/123?modelVersionId=456',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the configured red host for fallback searches', async () => {
|
||||||
|
const openSpy = vi.fn();
|
||||||
|
globalThis.window.open = openSpy;
|
||||||
|
|
||||||
|
getStorageItemMock.mockImplementation((key, defaultValue) => {
|
||||||
|
if (key === 'civitai_host_info_banner_seen') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateModule = await import(STATE_MODULE);
|
||||||
|
stateModule.state.global = {
|
||||||
|
settings: {
|
||||||
|
civitai_host: 'civitai.red',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
|
||||||
|
|
||||||
|
openCivitaiByMetadata(null, null, 'Demo Model');
|
||||||
|
|
||||||
|
expect(registerBannerMock).not.toHaveBeenCalled();
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
'https://civitai.red/models?query=Demo%20Model',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
84
tests/nodes/test_prompt_text_wildcards.py
Normal file
84
tests/nodes/test_prompt_text_wildcards.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from py.nodes.prompt import PromptLM
|
||||||
|
from py.nodes.text import TextLM
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_lm_expands_wildcards_before_output(monkeypatch):
|
||||||
|
node = TextLM()
|
||||||
|
|
||||||
|
expand_calls = []
|
||||||
|
|
||||||
|
class StubService:
|
||||||
|
def expand_text(self, text, seed=None):
|
||||||
|
expand_calls.append((text, seed))
|
||||||
|
return "expanded text"
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.nodes.text.get_wildcard_service", lambda: StubService())
|
||||||
|
|
||||||
|
assert node.process("__flower__", seed=9) == ("expanded text",)
|
||||||
|
assert expand_calls == [("__flower__", 9)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_lm_expands_before_appending_trigger_words(monkeypatch):
|
||||||
|
node = PromptLM()
|
||||||
|
|
||||||
|
class StubService:
|
||||||
|
def expand_text(self, text, seed=None):
|
||||||
|
assert text == "__flower__"
|
||||||
|
assert seed == 42
|
||||||
|
return "rose"
|
||||||
|
|
||||||
|
class StubEncoder:
|
||||||
|
def encode(self, clip, prompt):
|
||||||
|
assert clip == "clip"
|
||||||
|
assert prompt == "artist style, rose"
|
||||||
|
return ("conditioning",)
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.nodes.prompt.get_wildcard_service", lambda: StubService())
|
||||||
|
monkeypatch.setattr("nodes.CLIPTextEncode", lambda: StubEncoder(), raising=False)
|
||||||
|
|
||||||
|
result = node.encode("__flower__", "clip", seed=42, trigger_words1="artist style")
|
||||||
|
|
||||||
|
assert result == ("conditioning", "artist style, rose")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_lm_input_types_expose_input_only_seed():
|
||||||
|
input_types = PromptLM.INPUT_TYPES()
|
||||||
|
seed_type, seed_options = input_types["optional"]["seed"]
|
||||||
|
|
||||||
|
assert seed_type == "INT"
|
||||||
|
assert seed_options["forceInput"] is True
|
||||||
|
assert "wildcard generation" in seed_options["tooltip"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_lm_input_types_expose_input_only_seed():
|
||||||
|
input_types = TextLM.INPUT_TYPES()
|
||||||
|
seed_type, seed_options = input_types["optional"]["seed"]
|
||||||
|
|
||||||
|
assert seed_type == "INT"
|
||||||
|
assert seed_options["forceInput"] is True
|
||||||
|
assert "wildcard generation" in seed_options["tooltip"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic():
|
||||||
|
result = TextLM.IS_CHANGED("__flower__", seed=None)
|
||||||
|
|
||||||
|
assert result != result
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_lm_is_changed_keeps_cache_for_seeded_or_static_text():
|
||||||
|
assert TextLM.IS_CHANGED("__flower__", seed=7) is False
|
||||||
|
assert TextLM.IS_CHANGED("plain text", seed=None) is False
|
||||||
|
assert TextLM.IS_CHANGED("{red|blue}", seed=7) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic():
|
||||||
|
result = PromptLM.IS_CHANGED("{red|blue}", clip="clip", seed=None)
|
||||||
|
|
||||||
|
assert result != result
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_lm_is_changed_keeps_cache_for_seeded_or_static_text():
|
||||||
|
assert PromptLM.IS_CHANGED("__flower__", clip="clip", seed=11) is False
|
||||||
|
assert PromptLM.IS_CHANGED("plain text", clip="clip", seed=None) is False
|
||||||
@@ -94,7 +94,7 @@ class DummyDoctorScanner:
|
|||||||
|
|
||||||
class DummyCivitaiClient:
|
class DummyCivitaiClient:
|
||||||
def __init__(self, *, success=True, result=None):
|
def __init__(self, *, success=True, result=None):
|
||||||
self.base_url = 'https://civitai.com/api/v1'
|
self.base_url = 'https://civitai.red/api/v1'
|
||||||
self._success = success
|
self._success = success
|
||||||
self._result = result if result is not None else {'items': []}
|
self._result = result if result is not None else {'items': []}
|
||||||
|
|
||||||
@@ -499,6 +499,38 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
|
|||||||
assert calls == [["xdg-open", str(backup_dir)]]
|
assert calls == [["xdg-open", str(backup_dir)]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch):
|
||||||
|
wildcards_dir = tmp_path / "settings" / "wildcards"
|
||||||
|
|
||||||
|
handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(tmp_path / "settings.json")))
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_popen(args):
|
||||||
|
calls.append(args)
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||||
|
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False)
|
||||||
|
monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.services.wildcard_service.get_wildcards_dir",
|
||||||
|
lambda create=False: str(wildcards_dir.mkdir(parents=True, exist_ok=True) or wildcards_dir)
|
||||||
|
if create
|
||||||
|
else str(wildcards_dir),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.open_wildcards_location(FakeRequest())
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["path"] == str(wildcards_dir)
|
||||||
|
assert wildcards_dir.is_dir()
|
||||||
|
assert calls == [["xdg-open", str(wildcards_dir)]]
|
||||||
|
|
||||||
|
|
||||||
class RecordingRouter:
|
class RecordingRouter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|||||||
66
tests/routes/test_model_page_view.py
Normal file
66
tests/routes/test_model_page_view.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
from py.routes.handlers.model_handlers import ModelPageView
|
||||||
|
|
||||||
|
|
||||||
|
class DummySettings:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class DummyI18n:
|
||||||
|
def __init__(self):
|
||||||
|
self.locale = None
|
||||||
|
|
||||||
|
def set_locale(self, locale):
|
||||||
|
self.locale = locale
|
||||||
|
|
||||||
|
def get_translation(self, key, default=None, **_kwargs):
|
||||||
|
return default or key
|
||||||
|
|
||||||
|
def create_template_filter(self):
|
||||||
|
return lambda key, *_args, **_kwargs: key
|
||||||
|
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self):
|
||||||
|
self._cache = SimpleNamespace()
|
||||||
|
|
||||||
|
async def get_cached_data(self, *_args, **_kwargs):
|
||||||
|
return SimpleNamespace(folders=[])
|
||||||
|
|
||||||
|
|
||||||
|
class DummyService:
|
||||||
|
def __init__(self):
|
||||||
|
self.scanner = DummyScanner()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_model_page_view_reads_version_per_request():
|
||||||
|
template_env = jinja2.Environment(
|
||||||
|
loader=jinja2.DictLoader({"dummy.html": "{{ version }}"}),
|
||||||
|
autoescape=True,
|
||||||
|
)
|
||||||
|
view = ModelPageView(
|
||||||
|
template_env=template_env,
|
||||||
|
template_name="dummy.html",
|
||||||
|
service=DummyService(),
|
||||||
|
settings_service=DummySettings(),
|
||||||
|
server_i18n=DummyI18n(),
|
||||||
|
logger=SimpleNamespace(
|
||||||
|
debug=lambda *_args, **_kwargs: None,
|
||||||
|
error=lambda *_args, **_kwargs: None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
view._get_app_version = lambda: "1.0.2-old"
|
||||||
|
first = await view.handle(SimpleNamespace())
|
||||||
|
|
||||||
|
view._get_app_version = lambda: "1.0.2-new"
|
||||||
|
second = await view.handle(SimpleNamespace())
|
||||||
|
|
||||||
|
assert first.text == "1.0.2-old"
|
||||||
|
assert second.text == "1.0.2-new"
|
||||||
@@ -6,7 +6,8 @@ from types import SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.config import config
|
from py.config import config
|
||||||
from py.routes.handlers.model_handlers import ModelUpdateHandler
|
from py.routes.handlers.model_handlers import ModelCivitaiHandler, ModelUpdateHandler
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
from py.utils.metadata_manager import MetadataManager
|
from py.utils.metadata_manager import MetadataManager
|
||||||
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
|
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
|
||||||
|
|
||||||
@@ -91,7 +92,131 @@ async def test_build_version_context_includes_static_urls():
|
|||||||
|
|
||||||
overrides = await handler._build_version_context(record)
|
overrides = await handler._build_version_context(record)
|
||||||
expected = config.get_preview_static_url("/tmp/previews/example.png")
|
expected = config.get_preview_static_url("/tmp/previews/example.png")
|
||||||
assert overrides == {123: {"file_path": None, "file_name": None, "preview_override": expected}}
|
assert overrides == {
|
||||||
|
123: {
|
||||||
|
"file_path": None,
|
||||||
|
"file_name": None,
|
||||||
|
"preview_override": expected,
|
||||||
|
"has_been_downloaded": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_version_context_includes_download_history(monkeypatch):
|
||||||
|
cache = SimpleNamespace(version_index={})
|
||||||
|
service = DummyService(cache)
|
||||||
|
handler = ModelUpdateHandler(
|
||||||
|
service=service,
|
||||||
|
update_service=SimpleNamespace(),
|
||||||
|
metadata_provider_selector=lambda *_: None,
|
||||||
|
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
)
|
||||||
|
|
||||||
|
class DummyHistoryService:
|
||||||
|
async def get_downloaded_version_ids(self, model_type, model_id):
|
||||||
|
assert model_type == "lora"
|
||||||
|
assert model_id == 42
|
||||||
|
return [123]
|
||||||
|
|
||||||
|
async def fake_history_service_factory():
|
||||||
|
return DummyHistoryService()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry,
|
||||||
|
"get_downloaded_version_history_service",
|
||||||
|
staticmethod(fake_history_service_factory),
|
||||||
|
)
|
||||||
|
|
||||||
|
record = ModelUpdateRecord(
|
||||||
|
model_type="lora",
|
||||||
|
model_id=42,
|
||||||
|
versions=[
|
||||||
|
ModelVersionRecord(
|
||||||
|
version_id=123,
|
||||||
|
name="Downloaded",
|
||||||
|
base_model=None,
|
||||||
|
released_at=None,
|
||||||
|
size_bytes=None,
|
||||||
|
preview_url=None,
|
||||||
|
is_in_library=False,
|
||||||
|
should_ignore=False,
|
||||||
|
),
|
||||||
|
ModelVersionRecord(
|
||||||
|
version_id=124,
|
||||||
|
name="Fresh",
|
||||||
|
base_model=None,
|
||||||
|
released_at=None,
|
||||||
|
size_bytes=None,
|
||||||
|
preview_url=None,
|
||||||
|
is_in_library=False,
|
||||||
|
should_ignore=False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
last_checked_at=None,
|
||||||
|
should_ignore_model=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
overrides = await handler._build_version_context(record)
|
||||||
|
assert overrides[123]["has_been_downloaded"] is True
|
||||||
|
assert overrides[124]["has_been_downloaded"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_civitai_versions_degrades_when_download_history_unavailable(monkeypatch):
|
||||||
|
cache = SimpleNamespace(version_index={})
|
||||||
|
service = DummyService(cache)
|
||||||
|
|
||||||
|
class DummyProvider:
|
||||||
|
async def get_model_versions(self, model_id):
|
||||||
|
assert model_id == "42"
|
||||||
|
return {
|
||||||
|
"type": "lora",
|
||||||
|
"modelVersions": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "Version 7",
|
||||||
|
"files": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_history_service_factory():
|
||||||
|
raise RuntimeError("download history unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ServiceRegistry,
|
||||||
|
"get_downloaded_version_history_service",
|
||||||
|
staticmethod(fake_history_service_factory),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def metadata_provider_factory():
|
||||||
|
return DummyProvider()
|
||||||
|
|
||||||
|
handler = ModelCivitaiHandler(
|
||||||
|
service=service,
|
||||||
|
settings_service=SimpleNamespace(get=lambda *_: False),
|
||||||
|
ws_manager=SimpleNamespace(),
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
metadata_provider_factory=metadata_provider_factory,
|
||||||
|
validate_model_type=lambda *_: True,
|
||||||
|
expected_model_types=lambda: "LoRA",
|
||||||
|
find_model_file=lambda *_: None,
|
||||||
|
metadata_sync=SimpleNamespace(),
|
||||||
|
metadata_refresh_use_case=SimpleNamespace(),
|
||||||
|
metadata_progress_callback=lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_civitai_versions(
|
||||||
|
SimpleNamespace(match_info={"model_id": "42"})
|
||||||
|
)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload[0]["id"] == 7
|
||||||
|
assert payload[0]["existsLocally"] is False
|
||||||
|
assert payload[0]["hasBeenDownloaded"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -274,7 +274,9 @@ class StubCivitaiClient:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.image_info: Dict[str, Any] = {}
|
self.image_info: Dict[str, Any] = {}
|
||||||
|
|
||||||
async def get_image_info(self, image_id: str) -> Optional[Dict[str, Any]]:
|
async def get_image_info(
|
||||||
|
self, image_id: str, source_url: str | None = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
return self.image_info.get(image_id)
|
return self.image_info.get(image_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -635,6 +637,58 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
|
|||||||
assert call["extension"] == ".mp4"
|
assert call["extension"] == ".mp4"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_remote_recipe_supports_civitai_red(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.enrichment.get_default_metadata_provider",
|
||||||
|
fake_get_default_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.civitai.image_info["126920345"] = {
|
||||||
|
"id": 126920345,
|
||||||
|
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
|
||||||
|
"type": "image",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/import-remote",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.red/images/126920345",
|
||||||
|
"name": "Red Recipe",
|
||||||
|
"resources": json.dumps([]),
|
||||||
|
"base_model": "Flux",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert harness.downloader.urls
|
||||||
|
assert "width=450,optimized=true" in harness.downloader.urls[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_analyze_remote_image_supports_civitai_red(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.analysis.result = SimpleNamespace(payload={"loras": []}, status=200)
|
||||||
|
|
||||||
|
response = await harness.client.post(
|
||||||
|
"/api/lm/recipes/analyze-image",
|
||||||
|
json={"url": "https://civitai.red/images/126920345"},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload == {"loras": []}
|
||||||
|
assert harness.analysis.remote_calls == [
|
||||||
|
"https://civitai.red/images/126920345"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
|
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
harness.analysis.raise_for_uploaded = RecipeValidationError(
|
harness.analysis.raise_for_uploaded = RecipeValidationError(
|
||||||
|
|||||||
69
tests/routes/test_wildcard_routes.py
Normal file
69
tests/routes/test_wildcard_routes.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.routes.handlers.misc_handlers import WildcardsHandler
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
def __init__(self, query=None):
|
||||||
|
self.query = query or {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_wildcards_returns_results():
|
||||||
|
class StubService:
|
||||||
|
def get_metadata(self, create_dir=False):
|
||||||
|
assert create_dir is True
|
||||||
|
return SimpleNamespace(
|
||||||
|
has_wildcards=True,
|
||||||
|
wildcards_dir="/tmp/settings/wildcards",
|
||||||
|
supported_formats=(".txt", ".yaml", ".yml", ".json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_keys(self, search_term, limit, offset):
|
||||||
|
assert search_term == "cat"
|
||||||
|
assert limit == 25
|
||||||
|
assert offset == 2
|
||||||
|
return ["animals/cat"]
|
||||||
|
|
||||||
|
handler = WildcardsHandler(service=StubService())
|
||||||
|
response = await handler.search_wildcards(
|
||||||
|
FakeRequest(query={"search": "cat", "limit": "25", "offset": "2"})
|
||||||
|
)
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload == {
|
||||||
|
"success": True,
|
||||||
|
"words": ["animals/cat"],
|
||||||
|
"meta": {
|
||||||
|
"has_wildcards": True,
|
||||||
|
"wildcards_dir": "/tmp/settings/wildcards",
|
||||||
|
"supported_formats": [".txt", ".yaml", ".yml", ".json"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_wildcards_handles_errors():
|
||||||
|
class StubService:
|
||||||
|
def get_metadata(self, create_dir=False):
|
||||||
|
return SimpleNamespace(
|
||||||
|
has_wildcards=False,
|
||||||
|
wildcards_dir="/tmp/settings/wildcards",
|
||||||
|
supported_formats=(".txt",),
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_keys(self, search_term, limit, offset):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
handler = WildcardsHandler(service=StubService())
|
||||||
|
response = await handler.search_wildcards(FakeRequest(query={"search": "cat"}))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert response.status == 500
|
||||||
|
assert payload["error"] == "boom"
|
||||||
@@ -886,3 +886,111 @@ async def test_format_response_defaults_update_flag_false(service_cls, extra_fie
|
|||||||
|
|
||||||
assert "update_available" in formatted
|
assert "update_available" in formatted
|
||||||
assert formatted["update_available"] is False
|
assert formatted["update_available"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_model_civitai_url_uses_default_host():
|
||||||
|
raw_data = [
|
||||||
|
{
|
||||||
|
"file_name": "demo.safetensors",
|
||||||
|
"civitai": {"modelId": 123, "id": 456},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
class CacheStub:
|
||||||
|
def __init__(self, raw_data):
|
||||||
|
self.raw_data = raw_data
|
||||||
|
|
||||||
|
class ScannerStub:
|
||||||
|
def __init__(self, cache):
|
||||||
|
self._cache = cache
|
||||||
|
|
||||||
|
async def get_cached_data(self, *_, **__):
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
service = DummyService(
|
||||||
|
model_type="stub",
|
||||||
|
scanner=ScannerStub(CacheStub(raw_data)),
|
||||||
|
metadata_class=BaseModelMetadata,
|
||||||
|
settings_provider=StubSettings({}),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_model_civitai_url("demo.safetensors")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
|
||||||
|
"model_id": "123",
|
||||||
|
"version_id": "456",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_model_civitai_url_uses_configured_host():
|
||||||
|
raw_data = [
|
||||||
|
{
|
||||||
|
"file_name": "demo.safetensors",
|
||||||
|
"civitai": {"modelId": 123, "id": 456},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
class CacheStub:
|
||||||
|
def __init__(self, raw_data):
|
||||||
|
self.raw_data = raw_data
|
||||||
|
|
||||||
|
class ScannerStub:
|
||||||
|
def __init__(self, cache):
|
||||||
|
self._cache = cache
|
||||||
|
|
||||||
|
async def get_cached_data(self, *_, **__):
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
service = DummyService(
|
||||||
|
model_type="stub",
|
||||||
|
scanner=ScannerStub(CacheStub(raw_data)),
|
||||||
|
metadata_class=BaseModelMetadata,
|
||||||
|
settings_provider=StubSettings({"civitai_host": "civitai.red"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_model_civitai_url("demo.safetensors")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"civitai_url": "https://civitai.red/models/123?modelVersionId=456",
|
||||||
|
"model_id": "123",
|
||||||
|
"version_id": "456",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_model_civitai_url_falls_back_when_host_setting_is_not_a_string():
|
||||||
|
raw_data = [
|
||||||
|
{
|
||||||
|
"file_name": "demo.safetensors",
|
||||||
|
"civitai": {"modelId": 123, "id": 456},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
class CacheStub:
|
||||||
|
def __init__(self, raw_data):
|
||||||
|
self.raw_data = raw_data
|
||||||
|
|
||||||
|
class ScannerStub:
|
||||||
|
def __init__(self, cache):
|
||||||
|
self._cache = cache
|
||||||
|
|
||||||
|
async def get_cached_data(self, *_, **__):
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
service = DummyService(
|
||||||
|
model_type="stub",
|
||||||
|
scanner=ScannerStub(CacheStub(raw_data)),
|
||||||
|
metadata_class=BaseModelMetadata,
|
||||||
|
settings_provider=StubSettings({"civitai_host": True}),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.get_model_civitai_url("demo.safetensors")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
|
||||||
|
"model_id": "123",
|
||||||
|
"version_id": "456",
|
||||||
|
}
|
||||||
|
|||||||
@@ -581,6 +581,7 @@ class TestInputValidation:
|
|||||||
assert service._validate_url("https://example.com/image.png") is True
|
assert service._validate_url("https://example.com/image.png") is True
|
||||||
assert service._validate_url("http://example.com/image.png") is True
|
assert service._validate_url("http://example.com/image.png") is True
|
||||||
assert service._validate_url("https://civitai.com/images/123") is True
|
assert service._validate_url("https://civitai.com/images/123") is True
|
||||||
|
assert service._validate_url("https://civitai.red/images/123") is True
|
||||||
|
|
||||||
def test_validate_invalid_url(self, service):
|
def test_validate_invalid_url(self, service):
|
||||||
assert service._validate_url("not-a-url") is False
|
assert service._validate_url("not-a-url") is False
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ async def test_download_file_uses_downloader(tmp_path, downloader):
|
|||||||
assert downloader.download_calls[0]["use_auth"] is True
|
assert downloader.download_calls[0]["use_auth"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_defaults_to_red_api_host(downloader):
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
assert client.base_url == "https://civitai.red/api/v1"
|
||||||
|
|
||||||
|
|
||||||
async def test_get_model_by_hash_enriches_metadata(monkeypatch, downloader):
|
async def test_get_model_by_hash_enriches_metadata(monkeypatch, downloader):
|
||||||
version_payload = {
|
version_payload = {
|
||||||
"modelId": 123,
|
"modelId": 123,
|
||||||
@@ -530,6 +536,69 @@ async def test_get_image_info_handles_missing(monkeypatch, downloader):
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_image_info_prefers_red_host_for_red_source(monkeypatch, downloader):
|
||||||
|
requested_urls = []
|
||||||
|
|
||||||
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
|
requested_urls.append(url)
|
||||||
|
return True, {"items": [{"id": 124950237, "name": "target"}]}
|
||||||
|
|
||||||
|
downloader.make_request = fake_make_request
|
||||||
|
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
result = await client.get_image_info(
|
||||||
|
"124950237", source_url="https://civitai.red/images/124950237"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"id": 124950237, "name": "target"}
|
||||||
|
assert requested_urls == [
|
||||||
|
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_image_info_uses_red_host_even_for_red_source(monkeypatch, downloader):
|
||||||
|
requested_urls = []
|
||||||
|
|
||||||
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
|
requested_urls.append(url)
|
||||||
|
return True, {"items": [{"id": 124950237, "name": "target"}]}
|
||||||
|
|
||||||
|
downloader.make_request = fake_make_request
|
||||||
|
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
result = await client.get_image_info(
|
||||||
|
"124950237", source_url="https://civitai.red/images/124950237"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"id": 124950237, "name": "target"}
|
||||||
|
assert requested_urls == [
|
||||||
|
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_image_info_does_not_fall_back_after_request_failure(monkeypatch, downloader):
|
||||||
|
requested_urls = []
|
||||||
|
|
||||||
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
|
requested_urls.append(url)
|
||||||
|
return False, "403 forbidden"
|
||||||
|
|
||||||
|
downloader.make_request = fake_make_request
|
||||||
|
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
result = await client.get_image_info(
|
||||||
|
"124950237", source_url="https://civitai.red/images/124950237"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert requested_urls == [
|
||||||
|
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
|
async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
|
||||||
"""When given a non-numeric image ID, return None and log error."""
|
"""When given a non-numeric image ID, return None and log error."""
|
||||||
client = await CivitaiClient.get_instance()
|
client = await CivitaiClient.get_instance()
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ from unittest.mock import AsyncMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.services.download_manager import DownloadManager
|
from py.services.download_manager import (
|
||||||
|
CIVITAI_DOWNLOAD_URL_PREFIXES,
|
||||||
|
DownloadManager,
|
||||||
|
)
|
||||||
from py.services import download_manager
|
from py.services import download_manager
|
||||||
from py.services.service_registry import ServiceRegistry
|
from py.services.service_registry import ServiceRegistry
|
||||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||||
@@ -309,6 +312,67 @@ async def test_execute_download_respects_blur_setting(monkeypatch, tmp_path):
|
|||||||
assert stored_preview and stored_preview.endswith(".jpeg")
|
assert stored_preview and stored_preview.endswith(".jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_download_uses_auth_for_red_civitai_downloads(monkeypatch, tmp_path):
|
||||||
|
manager = DownloadManager()
|
||||||
|
save_dir = tmp_path / "downloads"
|
||||||
|
save_dir.mkdir()
|
||||||
|
target_path = save_dir / "file.safetensors"
|
||||||
|
|
||||||
|
class DummyMetadata:
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self.file_path = str(path)
|
||||||
|
self.sha256 = "sha256"
|
||||||
|
self.file_name = path.stem
|
||||||
|
self.preview_url = None
|
||||||
|
self.preview_nsfw_level = None
|
||||||
|
|
||||||
|
def generate_unique_filename(self, *_args, **_kwargs):
|
||||||
|
return os.path.basename(self.file_path)
|
||||||
|
|
||||||
|
def update_file_info(self, _path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"file_path": self.file_path}
|
||||||
|
|
||||||
|
metadata = DummyMetadata(target_path)
|
||||||
|
recorded_use_auth = []
|
||||||
|
|
||||||
|
class DummyDownloader:
|
||||||
|
stall_timeout = None
|
||||||
|
|
||||||
|
async def download_file(self, url, path, progress_callback=None, use_auth=None, **_kwargs):
|
||||||
|
recorded_use_auth.append((url, use_auth))
|
||||||
|
Path(path).write_bytes(b"model")
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
download_manager, "get_downloader", AsyncMock(return_value=DummyDownloader())
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
|
||||||
|
|
||||||
|
dummy_scanner = SimpleNamespace(add_model_to_cache=AsyncMock(return_value=None))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager._execute_download(
|
||||||
|
download_urls=["https://civitai.red/api/download/models/119514"],
|
||||||
|
save_dir=str(save_dir),
|
||||||
|
metadata=metadata,
|
||||||
|
version_info={"images": []},
|
||||||
|
relative_path="",
|
||||||
|
progress_callback=None,
|
||||||
|
model_type="lora",
|
||||||
|
download_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"success": True}
|
||||||
|
assert recorded_use_auth == [("https://civitai.com/api/download/models/119514", True)]
|
||||||
|
assert "https://civitai.com/api/download/".startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_civarchive_source_uses_civarchive_provider(
|
async def test_civarchive_source_uses_civarchive_provider(
|
||||||
monkeypatch, scanners, tmp_path
|
monkeypatch, scanners, tmp_path
|
||||||
|
|||||||
@@ -1,95 +1,86 @@
|
|||||||
import pytest
|
|
||||||
from py.recipes.merger import GenParamsMerger
|
from py.recipes.merger import GenParamsMerger
|
||||||
|
|
||||||
def test_merge_priority():
|
|
||||||
request_params = {"prompt": "from request", "steps": 20}
|
def test_merge_priority_and_normalization():
|
||||||
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
request_params = {"prompt": "from request", "Steps": 20, "cfg": 7.5}
|
||||||
|
civitai_meta = {"prompt": "from civitai", "cfgScale": 6.5, "negativePrompt": "bad"}
|
||||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
assert merged["prompt"] == "from request"
|
assert merged == {
|
||||||
assert merged["steps"] == 20
|
"prompt": "from request",
|
||||||
assert merged["cfg"] == 7.0
|
"steps": 20,
|
||||||
assert merged["seed"] == 123
|
"cfg_scale": 7.5,
|
||||||
|
"negative_prompt": "bad",
|
||||||
|
"seed": 123,
|
||||||
|
}
|
||||||
|
|
||||||
def test_merge_no_request_params():
|
|
||||||
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
|
||||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
|
||||||
|
|
||||||
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
|
def test_merge_accepts_raw_embedded_metadata():
|
||||||
|
embedded_metadata = {"prompt": "from raw embedded", "seed": 456, "scheduler": "karras"}
|
||||||
assert merged["prompt"] == "from civitai"
|
|
||||||
assert merged["cfg"] == 7.0
|
|
||||||
assert merged["seed"] == 123
|
|
||||||
|
|
||||||
def test_merge_only_embedded():
|
|
||||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
|
||||||
|
|
||||||
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||||
|
|
||||||
assert merged["prompt"] == "from embedded"
|
assert merged == {
|
||||||
assert merged["seed"] == 123
|
"prompt": "from raw embedded",
|
||||||
|
"seed": 456,
|
||||||
|
"sampler": "karras",
|
||||||
|
}
|
||||||
|
|
||||||
def test_merge_raw_embedded():
|
|
||||||
# Test when embedded metadata is just the gen_params themselves
|
|
||||||
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
|
|
||||||
|
|
||||||
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
def test_merge_filters_unknown_and_blacklisted_keys():
|
||||||
|
request_params = {
|
||||||
assert merged["prompt"] == "from raw embedded"
|
"prompt": "test",
|
||||||
assert merged["seed"] == 456
|
"id": "should-be-removed",
|
||||||
|
"checkpoint": "should-not-be-here",
|
||||||
def test_merge_none_values():
|
"raw_metadata": {"prompt": "remove"},
|
||||||
merged = GenParamsMerger.merge(None, None, None)
|
}
|
||||||
assert merged == {}
|
civitai_meta = {
|
||||||
|
"Version": "ComfyUI",
|
||||||
def test_merge_filters_blacklisted_keys():
|
"RNG": "cpu",
|
||||||
request_params = {"prompt": "test", "id": "should-be-removed", "checkpoint": "should-not-be-here"}
|
"cfgScale": 7,
|
||||||
civitai_meta = {"cfg": 7, "url": "remove-me"}
|
"url": "remove-me",
|
||||||
embedded_metadata = {"seed": 123, "hash": "remove-also"}
|
}
|
||||||
|
embedded_metadata = {
|
||||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
"seed": 123,
|
||||||
|
"hash": "remove-also",
|
||||||
assert "prompt" in merged
|
"Discard penultimate sigma": True,
|
||||||
assert "cfg" in merged
|
"eps_scaling_factor": 0.1,
|
||||||
assert "seed" in merged
|
}
|
||||||
assert "id" not in merged
|
|
||||||
assert "url" not in merged
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
assert "hash" not in merged
|
|
||||||
assert "checkpoint" not in merged
|
assert merged == {
|
||||||
|
"prompt": "test",
|
||||||
def test_merge_filters_meta_and_normalizes_keys():
|
"cfg_scale": 7,
|
||||||
|
"seed": 123,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_does_not_keep_original_key_variants():
|
||||||
civitai_meta = {
|
civitai_meta = {
|
||||||
"prompt": "masterpiece",
|
|
||||||
"cfgScale": 5,
|
"cfgScale": 5,
|
||||||
"clipSkip": 2,
|
"clipSkip": 2,
|
||||||
"negativePrompt": "low quality",
|
"negativePrompt": "low quality",
|
||||||
"meta": {"irrelevant": "data"},
|
|
||||||
"Size": "1024x1024",
|
"Size": "1024x1024",
|
||||||
"draft": False,
|
"Denoising strength": 0.35,
|
||||||
"workflow": "txt2img",
|
|
||||||
"civitaiResources": [{"type": "checkpoint"}]
|
|
||||||
}
|
}
|
||||||
request_params = {
|
request_params = {
|
||||||
"cfg_scale": 5.0,
|
"cfg_scale": 5.0,
|
||||||
"clip_skip": "2",
|
"clip_skip": "2",
|
||||||
"Steps": 30
|
|
||||||
}
|
}
|
||||||
|
|
||||||
merged = GenParamsMerger.merge(request_params, civitai_meta)
|
merged = GenParamsMerger.merge(request_params, civitai_meta)
|
||||||
|
|
||||||
assert "meta" not in merged
|
assert merged == {
|
||||||
assert "cfgScale" not in merged
|
"cfg_scale": 5.0,
|
||||||
assert "clipSkip" not in merged
|
"clip_skip": "2",
|
||||||
assert "negativePrompt" not in merged
|
"negative_prompt": "low quality",
|
||||||
assert "Size" not in merged
|
"size": "1024x1024",
|
||||||
assert "draft" not in merged
|
"denoising_strength": 0.35,
|
||||||
assert "workflow" not in merged
|
}
|
||||||
assert "civitaiResources" not in merged
|
|
||||||
|
|
||||||
assert merged["cfg_scale"] == 5.0 # From request_params
|
|
||||||
assert merged["clip_skip"] == "2" # From request_params
|
def test_merge_none_values():
|
||||||
assert merged["negative_prompt"] == "low quality" # Normalized from civitai_meta
|
assert GenParamsMerger.merge(None, None, None) == {}
|
||||||
assert merged["size"] == "1024x1024" # Normalized from civitai_meta
|
|
||||||
assert merged["steps"] == 30 # Normalized from request_params
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
|
|
||||||
from py.services.model_lifecycle_service import ModelLifecycleService
|
from py.services.model_lifecycle_service import ModelLifecycleService
|
||||||
from py.utils.metadata_manager import MetadataManager
|
from py.utils.metadata_manager import MetadataManager
|
||||||
|
from py.utils.models import LoraMetadata
|
||||||
|
|
||||||
|
|
||||||
class DummyCache:
|
class DummyCache:
|
||||||
@@ -445,6 +446,63 @@ async def test_exclude_model_empty_path_raises_error():
|
|||||||
await service.exclude_model("")
|
await service.exclude_model("")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unexclude_model_restores_cache_entry(tmp_path: Path):
|
||||||
|
"""Verify unexclude_model clears exclude metadata and restores cache entry."""
|
||||||
|
model_path = tmp_path / "restored_model.safetensors"
|
||||||
|
model_path.write_bytes(b"content")
|
||||||
|
|
||||||
|
metadata_payload = {
|
||||||
|
"file_name": "restored_model",
|
||||||
|
"model_name": "restored_model",
|
||||||
|
"file_path": str(model_path),
|
||||||
|
"sha256": "abc123",
|
||||||
|
"exclude": True,
|
||||||
|
"tags": ["tag1"],
|
||||||
|
}
|
||||||
|
metadata_path = tmp_path / "restored_model.metadata.json"
|
||||||
|
metadata_path.write_text(json.dumps(metadata_payload))
|
||||||
|
|
||||||
|
class RestoreScanner:
|
||||||
|
def __init__(self):
|
||||||
|
self.model_type = "lora"
|
||||||
|
self.model_class = LoraMetadata
|
||||||
|
self._excluded_models = [str(model_path)]
|
||||||
|
self.updated = []
|
||||||
|
|
||||||
|
async def update_single_model_cache(self, old_path, new_path, metadata, recalculate_type=False):
|
||||||
|
exclude_value = metadata.get("exclude") if isinstance(metadata, dict) else metadata.exclude
|
||||||
|
self.updated.append((old_path, new_path, exclude_value, recalculate_type))
|
||||||
|
|
||||||
|
saved_metadata = []
|
||||||
|
|
||||||
|
class SavingMetadataManager:
|
||||||
|
async def save_metadata(self, path: str, metadata: dict):
|
||||||
|
saved_metadata.append((path, metadata.copy()))
|
||||||
|
await MetadataManager.save_metadata(path, metadata)
|
||||||
|
|
||||||
|
async def metadata_loader(path: str):
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
scanner = RestoreScanner()
|
||||||
|
service = ModelLifecycleService(
|
||||||
|
scanner=scanner,
|
||||||
|
metadata_manager=SavingMetadataManager(),
|
||||||
|
metadata_loader=metadata_loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await service.unexclude_model(str(model_path))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "restored" in result["message"].lower()
|
||||||
|
assert scanner._excluded_models == []
|
||||||
|
assert saved_metadata[0][1]["exclude"] is False
|
||||||
|
assert scanner.updated == [
|
||||||
|
(str(model_path), str(model_path), False, True)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Tests for bulk_delete_models functionality
|
# Tests for bulk_delete_models functionality
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
# We define these here to help with spec= if needed
|
# We define these here to help with spec= if needed
|
||||||
class MockCivitaiClient:
|
class MockCivitaiClient:
|
||||||
async def get_image_info(self, image_id):
|
async def get_image_info(self, image_id, source_url=None):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class MockPersistenceService:
|
class MockPersistenceService:
|
||||||
@@ -119,6 +119,50 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
|||||||
assert "hash" not in checkpoint
|
assert "hash" not in checkpoint
|
||||||
assert "file_name" not in checkpoint
|
assert "file_name" not in checkpoint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "Red Recipe",
|
||||||
|
"source_url": "https://civitai.red/images/12345",
|
||||||
|
"checkpoint": None,
|
||||||
|
"gen_params": {"prompt": ""},
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
|
||||||
|
mock_civitai_client.get_image_info.return_value = {
|
||||||
|
"modelVersionId": 5678,
|
||||||
|
"meta": {"prompt": "from red"},
|
||||||
|
}
|
||||||
|
mock_metadata_provider.get_model_version_info.return_value = (
|
||||||
|
{
|
||||||
|
"id": 5678,
|
||||||
|
"modelId": 1234,
|
||||||
|
"name": "v1.0",
|
||||||
|
"model": {"name": "Full Model Name"},
|
||||||
|
"baseModel": "SDXL 1.0",
|
||||||
|
"images": [{"url": "https://image.url/thumb.jpg"}],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"hashes": {"SHA256": "ABCDEF"},
|
||||||
|
"name": "full_filename.safetensors",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
assert results["repaired"] == 1
|
||||||
|
mock_civitai_client.get_image_info.assert_called_with(
|
||||||
|
"12345", source_url="https://civitai.red/images/12345"
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
|
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
|
||||||
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|||||||
@@ -358,6 +358,188 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
|
|||||||
assert recipe["checkpoint"]["file_name"] == "by-id"
|
assert recipe["checkpoint"]["file_name"] == "by-id"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(scanner.recipes_dir)
|
||||||
|
recipe_id = "hydrate-me"
|
||||||
|
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/hydrate-me.png",
|
||||||
|
"title": "Hydrated Recipe",
|
||||||
|
"source_path": "https://example.com/source",
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "prompt from json",
|
||||||
|
"negative_prompt": "negative from json",
|
||||||
|
},
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner._cache.raw_data = [
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/hydrate-me.png",
|
||||||
|
"title": "Cached Recipe",
|
||||||
|
"folder": "",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
assert recipe is not None
|
||||||
|
assert recipe["title"] == "Hydrated Recipe"
|
||||||
|
assert recipe["source_path"] == "https://example.com/source"
|
||||||
|
assert recipe["gen_params"]["prompt"] == "prompt from json"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_by_id_normalizes_gen_params_aliases_without_dropping_metadata(
|
||||||
|
recipe_scanner,
|
||||||
|
):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(scanner.recipes_dir)
|
||||||
|
recipe_id = "dirty-json-gen-params"
|
||||||
|
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/dirty-json-gen-params.png",
|
||||||
|
"title": "Dirty Recipe",
|
||||||
|
"gen_params": {
|
||||||
|
"Prompt": "prompt from json",
|
||||||
|
"negativePrompt": "negative from json",
|
||||||
|
"cfgScale": 7,
|
||||||
|
"raw_metadata": {"prompt": "nested"},
|
||||||
|
"Version": "ComfyUI",
|
||||||
|
"RNG": "cpu",
|
||||||
|
},
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner._cache.raw_data = [
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/dirty-json-gen-params.png",
|
||||||
|
"title": "Cached Recipe",
|
||||||
|
"folder": "",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {"prompt": "cached prompt", "raw_metadata": {"bad": True}},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
assert recipe is not None
|
||||||
|
assert recipe["gen_params"]["Prompt"] == "prompt from json"
|
||||||
|
assert recipe["gen_params"]["negativePrompt"] == "negative from json"
|
||||||
|
assert recipe["gen_params"]["cfgScale"] == 7
|
||||||
|
assert recipe["gen_params"]["raw_metadata"] == {"prompt": "nested"}
|
||||||
|
assert recipe["gen_params"]["Version"] == "ComfyUI"
|
||||||
|
assert recipe["gen_params"]["RNG"] == "cpu"
|
||||||
|
assert recipe["gen_params"]["prompt"] == "prompt from json"
|
||||||
|
assert recipe["gen_params"]["negative_prompt"] == "negative from json"
|
||||||
|
assert recipe["gen_params"]["cfg_scale"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_by_id_prefers_json_file_path(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(scanner.recipes_dir)
|
||||||
|
recipe_id = "move-me"
|
||||||
|
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/new-location.png",
|
||||||
|
"title": "Moved Recipe",
|
||||||
|
"source_path": "https://example.com/moved",
|
||||||
|
"gen_params": {},
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner._cache.raw_data = [
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/old-location.png",
|
||||||
|
"title": "Cached Title",
|
||||||
|
"folder": "",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
assert recipe is not None
|
||||||
|
assert recipe["file_path"] == "/tmp/new-location.png"
|
||||||
|
assert recipe["title"] == "Moved Recipe"
|
||||||
|
assert recipe["source_path"] == "https://example.com/moved"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_recipe_by_id_drops_deleted_optional_json_fields(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
recipes_dir = Path(scanner.recipes_dir)
|
||||||
|
recipe_id = "drop-optional-fields"
|
||||||
|
recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
recipe_json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/drop-optional-fields.png",
|
||||||
|
"title": "Trimmed Recipe",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
scanner._cache.raw_data = [
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": "/tmp/drop-optional-fields.png",
|
||||||
|
"title": "Cached Recipe",
|
||||||
|
"folder": "",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"source_path": "https://example.com/stale-source",
|
||||||
|
"checkpoint": {"name": "stale-checkpoint.safetensors"},
|
||||||
|
"loras": [{"modelName": "stale-lora"}],
|
||||||
|
"gen_params": {"prompt": "stale prompt"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
recipe = await scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
|
assert recipe is not None
|
||||||
|
assert recipe["title"] == "Trimmed Recipe"
|
||||||
|
assert "source_path" not in recipe
|
||||||
|
assert "checkpoint" not in recipe
|
||||||
|
assert "gen_params" not in recipe
|
||||||
|
assert "loras" not in recipe
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
||||||
scanner, _ = recipe_scanner
|
scanner, _ = recipe_scanner
|
||||||
@@ -401,6 +583,40 @@ async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
|||||||
assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
|
assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_normalizes_gen_params_aliases_without_dropping_metadata(
|
||||||
|
recipe_scanner,
|
||||||
|
):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
await scanner.add_recipe(
|
||||||
|
{
|
||||||
|
"id": "dirty-listing",
|
||||||
|
"file_path": str(Path(config.loras_roots[0]) / "dirty-listing.webp"),
|
||||||
|
"title": "Dirty Listing",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"Prompt": "a beautiful forest landscape",
|
||||||
|
"cfgScale": 7,
|
||||||
|
"Version": "ComfyUI",
|
||||||
|
"raw_metadata": {"bad": True},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
result = await scanner.get_paginated_data(page=1, page_size=10)
|
||||||
|
item = next(entry for entry in result["items"] if entry["id"] == "dirty-listing")
|
||||||
|
|
||||||
|
assert item["gen_params"]["Prompt"] == "a beautiful forest landscape"
|
||||||
|
assert item["gen_params"]["cfgScale"] == 7
|
||||||
|
assert item["gen_params"]["Version"] == "ComfyUI"
|
||||||
|
assert item["gen_params"]["raw_metadata"] == {"bad": True}
|
||||||
|
assert item["gen_params"]["prompt"] == "a beautiful forest landscape"
|
||||||
|
assert item["gen_params"]["cfg_scale"] == 7
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
|
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
|
||||||
scanner, _ = recipe_scanner
|
scanner, _ = recipe_scanner
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user