mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-07 00:46:44 -03:00
Compare commits
37 Commits
32875042bd
...
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 | ||
|
|
1817142a7b | ||
|
|
25fa175aa2 | ||
|
|
39643eb2bc | ||
|
|
4ac78f8aa8 | ||
|
|
0bcca0ba68 | ||
|
|
72f8e0d1be | ||
|
|
85b6c91192 | ||
|
|
908016cbd6 | ||
|
|
a5ac9cf81b |
58
README.md
58
README.md
@@ -56,6 +56,27 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
|
||||
|
||||
## 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
|
||||
|
||||
* **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
|
||||
* **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.
|
||||
|
||||
### v0.9.12
|
||||
@@ -253,6 +274,41 @@ pip install -r requirements.txt
|
||||
- Paste into the Lora Loader node's text input
|
||||
- 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
|
||||
|
||||
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.",
|
||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"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": {
|
||||
"label": "Einstellungsordner öffnen",
|
||||
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "Video-Einstellungen",
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"misc": "Verschiedenes",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Standard-Roots",
|
||||
"recipeSettings": "Rezepte",
|
||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Automatische Backups",
|
||||
"autoEnabledHelp": "Erstellt einmal täglich einen lokalen Schnappschuss und behält die neuesten Schnappschüsse gemäß der Aufbewahrungsrichtlinie.",
|
||||
"retention": "Aufbewahrungsanzahl",
|
||||
"retentionHelp": "Wie viele automatische Schnappschüsse behalten werden, bevor ältere entfernt werden.",
|
||||
"management": "Backup-Verwaltung",
|
||||
"managementHelp": "Exportiere deinen aktuellen Benutzerstatus oder stelle ihn aus einem Backup-Archiv wieder her.",
|
||||
"scopeHelp": "Sichert deine Einstellungen, den Downloadverlauf und den Status der Modellaktualisierung. Modelldateien und neu erzeugbare Caches sind nicht enthalten.",
|
||||
"locationSummary": "Aktueller Backup-Speicherort",
|
||||
"openFolderButton": "Backup-Ordner öffnen",
|
||||
"openFolderSuccess": "Backup-Ordner geöffnet",
|
||||
"openFolderFailed": "Backup-Ordner konnte nicht geöffnet werden",
|
||||
"locationCopied": "Backup-Pfad in die Zwischenablage kopiert: {{path}}",
|
||||
"locationClipboardFallback": "Backup-Pfad: {{path}}",
|
||||
"exportButton": "Backup exportieren",
|
||||
"exportSuccess": "Backup erfolgreich exportiert.",
|
||||
"exportFailed": "Backup konnte nicht exportiert werden: {message}",
|
||||
"importButton": "Backup importieren",
|
||||
"importConfirm": "Dieses Backup importieren und den lokalen Benutzerstatus überschreiben?",
|
||||
"importSuccess": "Backup erfolgreich importiert.",
|
||||
"importFailed": "Backup konnte nicht importiert werden: {message}",
|
||||
"latestSnapshot": "Neuester Schnappschuss",
|
||||
"latestAutoSnapshot": "Neuester automatischer Schnappschuss",
|
||||
"snapshotCount": "Gespeicherte Schnappschüsse",
|
||||
"noneAvailable": "Noch keine Schnappschüsse vorhanden"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Downloads für Basismodelle überspringen",
|
||||
"help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
"restoreModel": "Modell wiederherstellen",
|
||||
"deleteModel": "Modell löschen",
|
||||
"shareRecipe": "Rezept teilen",
|
||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "Early Access",
|
||||
"earlyAccessTooltip": "Early Access erforderlich",
|
||||
"inLibrary": "In Bibliothek",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||
"errors": {
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "in {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Aktuelle Version",
|
||||
"current": "Geöffnete Version",
|
||||
"currentTooltip": "Das ist die Version, mit der dieses Modal geöffnet wurde",
|
||||
"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",
|
||||
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
||||
"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": {
|
||||
"download": "Herunterladen",
|
||||
"downloadTooltip": "Diese Version herunterladen",
|
||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||
"delete": "Löschen",
|
||||
"deleteTooltip": "Diese lokale Version löschen",
|
||||
"ignore": "Ignorieren",
|
||||
"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",
|
||||
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "Fehler beim Löschen von {type}: {message}",
|
||||
"excludeSuccess": "{type} erfolgreich ausgeschlossen",
|
||||
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
|
||||
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
|
||||
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
|
||||
"fileNameUpdated": "Dateiname erfolgreich aktualisiert",
|
||||
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
||||
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "Systemdiagnose",
|
||||
"title": "Doktor",
|
||||
"buttonTitle": "Diagnose und häufige Fehlerbehebungen ausführen",
|
||||
"loading": "Umgebung wird geprüft...",
|
||||
"footer": "Exportiere ein Diagnosepaket, falls das Problem nach der Reparatur weiterhin besteht.",
|
||||
"summary": {
|
||||
"idle": "Führe eine Überprüfung von Einstellungen, Cache-Integrität und UI-Konsistenz durch.",
|
||||
"ok": "Keine aktiven Probleme wurden in der aktuellen Umgebung gefunden.",
|
||||
"warning": "{count} Problem(e) wurden gefunden. Die meisten lassen sich direkt über dieses Panel beheben.",
|
||||
"error": "Bevor die App vollständig fehlerfrei ist, müssen {count} Problem(e) behoben werden."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Gesund",
|
||||
"warning": "Handlungsbedarf",
|
||||
"error": "Aktion erforderlich"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Erneut ausführen",
|
||||
"exportBundle": "Paket exportieren"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
||||
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
||||
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
||||
"exportSuccess": "Diagnosepaket exportiert.",
|
||||
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Anwendungs-Update erkannt",
|
||||
|
||||
102
locales/en.json
102
locales/en.json
@@ -175,6 +175,9 @@
|
||||
"success": "Successfully repaired {count} recipes.",
|
||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||
"error": "Recipe repair failed: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Manage Excluded Models"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"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": {
|
||||
"label": "Open settings folder",
|
||||
"tooltip": "Open folder containing settings.json",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "Video Settings",
|
||||
"layoutSettings": "Layout Settings",
|
||||
"misc": "Miscellaneous",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Default Roots",
|
||||
"recipeSettings": "Recipes",
|
||||
"extraFolderPaths": "Extra Folder Paths",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "Unable to save skip paths: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Automatic backups",
|
||||
"autoEnabledHelp": "Create a local snapshot once per day and keep the latest snapshots according to the retention policy.",
|
||||
"retention": "Retention count",
|
||||
"retentionHelp": "How many automatic snapshots to keep before older ones are pruned.",
|
||||
"management": "Backup management",
|
||||
"managementHelp": "Export your current user state or restore it from a backup archive.",
|
||||
"scopeHelp": "Backs up your settings, download history, and model update state. It does not include model files or rebuildable caches.",
|
||||
"locationSummary": "Current backup location",
|
||||
"openFolderButton": "Open backup folder",
|
||||
"openFolderSuccess": "Opened backup folder",
|
||||
"openFolderFailed": "Failed to open backup folder",
|
||||
"locationCopied": "Backup path copied to clipboard: {{path}}",
|
||||
"locationClipboardFallback": "Backup path: {{path}}",
|
||||
"exportButton": "Export backup",
|
||||
"exportSuccess": "Backup exported successfully.",
|
||||
"exportFailed": "Failed to export backup: {message}",
|
||||
"importButton": "Import backup",
|
||||
"importConfirm": "Import this backup and overwrite local user state?",
|
||||
"importSuccess": "Backup imported successfully.",
|
||||
"importFailed": "Failed to import backup: {message}",
|
||||
"latestSnapshot": "Latest snapshot",
|
||||
"latestAutoSnapshot": "Latest automatic snapshot",
|
||||
"snapshotCount": "Saved snapshots",
|
||||
"noneAvailable": "No snapshots yet"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Skip downloads for base models",
|
||||
"help": "When enabled, versions using the selected base models will be skipped.",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "Move to Folder",
|
||||
"repairMetadata": "Repair metadata",
|
||||
"excludeModel": "Exclude Model",
|
||||
"restoreModel": "Restore Model",
|
||||
"deleteModel": "Delete Model",
|
||||
"shareRecipe": "Share Recipe",
|
||||
"viewAllLoras": "View All LoRAs",
|
||||
@@ -658,9 +702,9 @@
|
||||
"title": "Import a recipe from image or URL",
|
||||
"urlLocalPath": "URL / Local Path",
|
||||
"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:",
|
||||
"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",
|
||||
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
|
||||
"selectImage": "Select Image",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "Early Access",
|
||||
"earlyAccessTooltip": "Early access required",
|
||||
"inLibrary": "In Library",
|
||||
"downloaded": "Downloaded",
|
||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||
"alreadyInLibrary": "Already in Library",
|
||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||
"errors": {
|
||||
@@ -1061,9 +1107,9 @@
|
||||
},
|
||||
"proceedText": "Only proceed if you're sure this is what you want.",
|
||||
"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": {
|
||||
"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",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "in {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Current Version",
|
||||
"current": "Opened Version",
|
||||
"currentTooltip": "This is the version you opened this modal from",
|
||||
"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",
|
||||
"newerTooltip": "This version is newer than your latest local version",
|
||||
"earlyAccess": "Early Access",
|
||||
"ignored": "Ignored"
|
||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||
"ignored": "Ignored",
|
||||
"ignoredTooltip": "Update notifications are disabled for this version"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download this version",
|
||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||
"delete": "Delete",
|
||||
"deleteTooltip": "Delete this local version",
|
||||
"ignore": "Ignore",
|
||||
"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",
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "Failed to delete {type}: {message}",
|
||||
"excludeSuccess": "{type} excluded successfully",
|
||||
"excludeFailed": "Failed to exclude {type}: {message}",
|
||||
"restoreSuccess": "{type} restored successfully",
|
||||
"restoreFailed": "Failed to restore {type}: {message}",
|
||||
"fileNameUpdated": "File name updated successfully",
|
||||
"fileRenameFailed": "Failed to rename file: {error}",
|
||||
"previewUpdated": "Preview updated successfully",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "System diagnostics",
|
||||
"title": "Doctor",
|
||||
"buttonTitle": "Run diagnostics and common fixes",
|
||||
"loading": "Checking environment...",
|
||||
"footer": "Export a diagnostics bundle if the issue still persists after repair.",
|
||||
"summary": {
|
||||
"idle": "Run a health check for settings, cache integrity, and UI consistency.",
|
||||
"ok": "No active issues were found in the current environment.",
|
||||
"warning": "{count} issue(s) were found. Most can be fixed directly from this panel.",
|
||||
"error": "{count} issue(s) need attention before the app is fully healthy."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Healthy",
|
||||
"warning": "Needs Attention",
|
||||
"error": "Action Required"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Run Again",
|
||||
"exportBundle": "Export Bundle"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Failed to load diagnostics: {message}",
|
||||
"repairSuccess": "Cache rebuild completed.",
|
||||
"repairFailed": "Cache rebuild failed: {message}",
|
||||
"exportSuccess": "Diagnostics bundle exported.",
|
||||
"exportFailed": "Failed to export diagnostics bundle: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Application Update Detected",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "Se repararon con éxito {count} recetas.",
|
||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||
"error": "Error al reparar recetas: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API 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": {
|
||||
"label": "Abrir carpeta de ajustes",
|
||||
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "Configuración de video",
|
||||
"layoutSettings": "Configuración de diseño",
|
||||
"misc": "Varios",
|
||||
"backup": "Copias de seguridad",
|
||||
"folderSettings": "Raíces predeterminadas",
|
||||
"recipeSettings": "Recetas",
|
||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Copias de seguridad automáticas",
|
||||
"autoEnabledHelp": "Crea una instantánea local una vez al día y conserva las más recientes según la política de retención.",
|
||||
"retention": "Cantidad de retención",
|
||||
"retentionHelp": "Cuántas instantáneas automáticas conservar antes de eliminar las antiguas.",
|
||||
"management": "Gestión de copias",
|
||||
"managementHelp": "Exporta tu estado de usuario actual o restáuralo desde un archivo de copia de seguridad.",
|
||||
"scopeHelp": "Incluye tu configuración, el historial de descargas y el estado de actualización de los modelos. No incluye los archivos de modelo ni las cachés que se pueden regenerar.",
|
||||
"locationSummary": "Ubicación actual de la copia",
|
||||
"openFolderButton": "Abrir carpeta de copias",
|
||||
"openFolderSuccess": "Carpeta de copias abierta",
|
||||
"openFolderFailed": "No se pudo abrir la carpeta de copias",
|
||||
"locationCopied": "Ruta de la copia copiada al portapapeles: {{path}}",
|
||||
"locationClipboardFallback": "Ruta de la copia: {{path}}",
|
||||
"exportButton": "Exportar copia",
|
||||
"exportSuccess": "Copia exportada correctamente.",
|
||||
"exportFailed": "No se pudo exportar la copia: {message}",
|
||||
"importButton": "Importar copia",
|
||||
"importConfirm": "¿Importar esta copia y sobrescribir el estado local del usuario?",
|
||||
"importSuccess": "Copia importada correctamente.",
|
||||
"importFailed": "No se pudo importar la copia: {message}",
|
||||
"latestSnapshot": "Última instantánea",
|
||||
"latestAutoSnapshot": "Última instantánea automática",
|
||||
"snapshotCount": "Instantáneas guardadas",
|
||||
"noneAvailable": "Aún no hay instantáneas"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Omitir descargas para modelos base",
|
||||
"help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"excludeModel": "Excluir modelo",
|
||||
"restoreModel": "Restaurar modelo",
|
||||
"deleteModel": "Eliminar modelo",
|
||||
"shareRecipe": "Compartir receta",
|
||||
"viewAllLoras": "Ver todos los LoRAs",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"earlyAccessTooltip": "Acceso temprano requerido",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"downloaded": "Descargado",
|
||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||
"alreadyInLibrary": "Ya en la biblioteca",
|
||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||
"errors": {
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "en {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Versión actual",
|
||||
"current": "Versión abierta",
|
||||
"currentTooltip": "Es la versión con la que abriste este modal",
|
||||
"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",
|
||||
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
||||
"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": {
|
||||
"download": "Descargar",
|
||||
"downloadTooltip": "Descargar esta versión",
|
||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||
"delete": "Eliminar",
|
||||
"deleteTooltip": "Eliminar esta versión local",
|
||||
"ignore": "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",
|
||||
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "Error al eliminar {type}: {message}",
|
||||
"excludeSuccess": "{type} excluido exitosamente",
|
||||
"excludeFailed": "Error al excluir {type}: {message}",
|
||||
"restoreSuccess": "{type} restaurado correctamente",
|
||||
"restoreFailed": "No se pudo restaurar {type}: {message}",
|
||||
"fileNameUpdated": "Nombre de archivo actualizado exitosamente",
|
||||
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
||||
"previewUpdated": "Vista previa actualizada exitosamente",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "Diagnósticos del sistema",
|
||||
"title": "Doctor",
|
||||
"buttonTitle": "Ejecutar diagnósticos y correcciones comunes",
|
||||
"loading": "Comprobando el entorno...",
|
||||
"footer": "Exporta un paquete de diagnóstico si el problema persiste después de la reparación.",
|
||||
"summary": {
|
||||
"idle": "Ejecuta una comprobación del estado de la configuración, la integridad de la caché y la coherencia de la interfaz.",
|
||||
"ok": "No se encontraron problemas activos en el entorno actual.",
|
||||
"warning": "Se encontraron {count} problema(s). La mayoría se puede solucionar directamente desde este panel.",
|
||||
"error": "Se encontraron {count} problema(s). Deben atenderse antes de que la aplicación esté completamente saludable."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Saludable",
|
||||
"warning": "Requiere atención",
|
||||
"error": "Se requiere acción"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Ejecutar de nuevo",
|
||||
"exportBundle": "Exportar paquete"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
||||
"repairSuccess": "Reconstrucción de caché completada.",
|
||||
"repairFailed": "Error al reconstruir la caché: {message}",
|
||||
"exportSuccess": "Paquete de diagnósticos exportado.",
|
||||
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Actualización de la aplicación detectada",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count} recettes réparées avec succès.",
|
||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||
"error": "Échec de la réparation des recettes : {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "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",
|
||||
"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": {
|
||||
"label": "Ouvrir le dossier des paramètres",
|
||||
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "Paramètres vidéo",
|
||||
"layoutSettings": "Paramètres d'affichage",
|
||||
"misc": "Divers",
|
||||
"backup": "Sauvegardes",
|
||||
"folderSettings": "Racines par défaut",
|
||||
"recipeSettings": "Recipes",
|
||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Sauvegardes automatiques",
|
||||
"autoEnabledHelp": "Crée un instantané local une fois par jour et conserve les plus récents selon la politique de rétention.",
|
||||
"retention": "Nombre de rétention",
|
||||
"retentionHelp": "Combien d'instantanés automatiques conserver avant de supprimer les plus anciens.",
|
||||
"management": "Gestion des sauvegardes",
|
||||
"managementHelp": "Exporte l'état actuel de l'utilisateur ou restaure-le depuis une archive de sauvegarde.",
|
||||
"scopeHelp": "Inclut vos paramètres, l'historique des téléchargements et l'état des mises à jour des modèles. Les fichiers de modèle et les caches régénérables ne sont pas inclus.",
|
||||
"locationSummary": "Emplacement actuel des sauvegardes",
|
||||
"openFolderButton": "Ouvrir le dossier de sauvegarde",
|
||||
"openFolderSuccess": "Dossier de sauvegarde ouvert",
|
||||
"openFolderFailed": "Impossible d'ouvrir le dossier de sauvegarde",
|
||||
"locationCopied": "Chemin de sauvegarde copié dans le presse-papiers : {{path}}",
|
||||
"locationClipboardFallback": "Chemin de sauvegarde : {{path}}",
|
||||
"exportButton": "Exporter la sauvegarde",
|
||||
"exportSuccess": "Sauvegarde exportée avec succès.",
|
||||
"exportFailed": "Échec de l'export de la sauvegarde : {message}",
|
||||
"importButton": "Importer la sauvegarde",
|
||||
"importConfirm": "Importer cette sauvegarde et écraser l'état local de l'utilisateur ?",
|
||||
"importSuccess": "Sauvegarde importée avec succès.",
|
||||
"importFailed": "Échec de l'import de la sauvegarde : {message}",
|
||||
"latestSnapshot": "Dernier instantané",
|
||||
"latestAutoSnapshot": "Dernier instantané automatique",
|
||||
"snapshotCount": "Instantanés enregistrés",
|
||||
"noneAvailable": "Aucun instantané pour le moment"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Ignorer les téléchargements pour certains modèles de base",
|
||||
"help": "S’applique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
"restoreModel": "Restaurer le modèle",
|
||||
"deleteModel": "Supprimer le modèle",
|
||||
"shareRecipe": "Partager la recipe",
|
||||
"viewAllLoras": "Voir tous les LoRAs",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"earlyAccessTooltip": "Accès anticipé requis",
|
||||
"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",
|
||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||
"errors": {
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "dans {count}j"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
||||
"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": {
|
||||
"download": "Télécharger",
|
||||
"downloadTooltip": "Télécharger cette version",
|
||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||
"delete": "Supprimer",
|
||||
"deleteTooltip": "Supprimer cette version locale",
|
||||
"ignore": "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é",
|
||||
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "Échec de la suppression de {type} : {message}",
|
||||
"excludeSuccess": "{type} exclu avec succès",
|
||||
"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",
|
||||
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
||||
"previewUpdated": "Aperçu mis à jour avec succès",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "Diagnostics système",
|
||||
"title": "Docteur",
|
||||
"buttonTitle": "Lancer les diagnostics et les corrections courantes",
|
||||
"loading": "Vérification de l'environnement...",
|
||||
"footer": "Exportez un lot de diagnostic si le problème persiste après la réparation.",
|
||||
"summary": {
|
||||
"idle": "Lancez une vérification de l'état des paramètres, de l'intégrité du cache et de la cohérence de l'interface.",
|
||||
"ok": "Aucun problème actif n'a été trouvé dans l'environnement actuel.",
|
||||
"warning": "{count} problème(s) ont été trouvés. La plupart peuvent être corrigés directement depuis ce panneau.",
|
||||
"error": "{count} problème(s) nécessitent une attention avant que l'application soit entièrement saine."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Sain",
|
||||
"warning": "Nécessite une attention",
|
||||
"error": "Action requise"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Relancer",
|
||||
"exportBundle": "Exporter le lot"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
||||
"repairSuccess": "Reconstruction du cache terminée.",
|
||||
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
||||
"exportSuccess": "Lot de diagnostics exporté.",
|
||||
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Mise à jour de l'application détectée",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||
"error": "תיקון המתכונים נכשל: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-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": {
|
||||
"label": "פתח תיקיית הגדרות",
|
||||
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "הגדרות וידאו",
|
||||
"layoutSettings": "הגדרות פריסה",
|
||||
"misc": "שונות",
|
||||
"backup": "גיבויים",
|
||||
"folderSettings": "תיקיות ברירת מחדל",
|
||||
"recipeSettings": "מתכונים",
|
||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "גיבויים אוטומטיים",
|
||||
"autoEnabledHelp": "יוצר צילום מצב מקומי פעם ביום ושומר את הצילומים האחרונים לפי מדיניות השמירה.",
|
||||
"retention": "כמות שמירה",
|
||||
"retentionHelp": "כמה צילומי מצב אוטומטיים לשמור לפני שמסירים ישנים.",
|
||||
"management": "ניהול גיבויים",
|
||||
"managementHelp": "ייצא את מצב המשתמש הנוכחי או שחזר אותו מארכיון גיבוי.",
|
||||
"scopeHelp": "כולל את ההגדרות שלך, היסטוריית ההורדות ומצב עדכוני המודלים. אינו כולל קובצי מודל או מטמונים שניתן לשחזר.",
|
||||
"locationSummary": "מיקום הגיבוי הנוכחי",
|
||||
"openFolderButton": "פתח את תיקיית הגיבויים",
|
||||
"openFolderSuccess": "תיקיית הגיבויים נפתחה",
|
||||
"openFolderFailed": "לא ניתן היה לפתוח את תיקיית הגיבויים",
|
||||
"locationCopied": "נתיב הגיבוי הועתק ללוח: {{path}}",
|
||||
"locationClipboardFallback": "נתיב הגיבוי: {{path}}",
|
||||
"exportButton": "ייצא גיבוי",
|
||||
"exportSuccess": "הגיבוי יוצא בהצלחה.",
|
||||
"exportFailed": "נכשל ייצוא הגיבוי: {message}",
|
||||
"importButton": "ייבא גיבוי",
|
||||
"importConfirm": "לייבא את הגיבוי הזה ולדרוס את מצב המשתמש המקומי?",
|
||||
"importSuccess": "הגיבוי יובא בהצלחה.",
|
||||
"importFailed": "נכשל ייבוא הגיבוי: {message}",
|
||||
"latestSnapshot": "צילום המצב האחרון",
|
||||
"latestAutoSnapshot": "צילום המצב האוטומטי האחרון",
|
||||
"snapshotCount": "צילומי מצב שמורים",
|
||||
"noneAvailable": "עדיין אין צילומי מצב"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "דלג על הורדות עבור מודלי בסיס",
|
||||
"help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-דאטה",
|
||||
"excludeModel": "החרג מודל",
|
||||
"restoreModel": "שחזור מודל",
|
||||
"deleteModel": "מחק מודל",
|
||||
"shareRecipe": "שתף מתכון",
|
||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"earlyAccessTooltip": "נדרשת גישה מוקדמת",
|
||||
"inLibrary": "בספרייה",
|
||||
"downloaded": "הורד",
|
||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||
"alreadyInLibrary": "כבר בספרייה",
|
||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||
"errors": {
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "בעוד {count} ימים"
|
||||
},
|
||||
"badges": {
|
||||
"current": "גרסה נוכחית",
|
||||
"current": "גרסה שנפתחה",
|
||||
"currentTooltip": "זוהי הגרסה שממנה נפתח החלון הזה",
|
||||
"inLibrary": "בספרייה",
|
||||
"inLibraryTooltip": "גרסה זו קיימת בספרייה המקומית שלך",
|
||||
"downloaded": "הורד",
|
||||
"downloadedTooltip": "גרסה זו הורדה בעבר, אך אינה נמצאת כרגע בספרייה שלך",
|
||||
"newer": "גרסה חדשה יותר",
|
||||
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"ignored": "התעלם"
|
||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||
"ignored": "התעלם",
|
||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
||||
},
|
||||
"actions": {
|
||||
"download": "הורדה",
|
||||
"downloadTooltip": "הורד את הגרסה הזו",
|
||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||
"delete": "מחיקה",
|
||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||
"ignore": "התעלם",
|
||||
"unignore": "בטל התעלמות",
|
||||
"ignoreTooltip": "התעלם מהתראות העדכון עבור גרסה זו",
|
||||
"unignoreTooltip": "חזור לקבל התראות עדכון עבור גרסה זו",
|
||||
"viewVersionOnCivitai": "הצג את הגרסה ב-Civitai",
|
||||
"earlyAccessTooltip": "נדרש רכישת גישה מוקדמת",
|
||||
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
|
||||
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "מחיקת {type} נכשלה: {message}",
|
||||
"excludeSuccess": "{type} הוחרג בהצלחה",
|
||||
"excludeFailed": "החרגת {type} נכשלה: {message}",
|
||||
"restoreSuccess": "{type} שוחזר בהצלחה",
|
||||
"restoreFailed": "שחזור {type} נכשל: {message}",
|
||||
"fileNameUpdated": "שם הקובץ עודכן בהצלחה",
|
||||
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
||||
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "אבחון מערכת",
|
||||
"title": "דוקטור",
|
||||
"buttonTitle": "הפעלת אבחון ותיקונים נפוצים",
|
||||
"loading": "בודק את הסביבה...",
|
||||
"footer": "ייצא חבילת אבחון אם הבעיה עדיין נמשכת לאחר התיקון.",
|
||||
"summary": {
|
||||
"idle": "הרץ בדיקת תקינות עבור הגדרות, שלמות המטמון ועקביות הממשק.",
|
||||
"ok": "לא נמצאו בעיות פעילות בסביבה הנוכחית.",
|
||||
"warning": "נמצאה/נמצאו {count} בעיה/בעיות. את רובן אפשר לתקן ישירות מלוח זה.",
|
||||
"error": "יש לטפל ב-{count} בעיה/בעיות לפני שהאפליקציה תהיה תקינה לחלוטין."
|
||||
},
|
||||
"status": {
|
||||
"ok": "תקין",
|
||||
"warning": "דורש תשומת לב",
|
||||
"error": "נדרשת פעולה"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "הפעל שוב",
|
||||
"exportBundle": "ייצוא חבילה"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
||||
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "זוהה עדכון יישום",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count} 件のレシピを正常に修復しました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"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": {
|
||||
"label": "設定フォルダーを開く",
|
||||
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "動画設定",
|
||||
"layoutSettings": "レイアウト設定",
|
||||
"misc": "その他",
|
||||
"backup": "バックアップ",
|
||||
"folderSettings": "デフォルトルート",
|
||||
"recipeSettings": "レシピ",
|
||||
"extraFolderPaths": "追加フォルダーパス",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "スキップパスの保存に失敗しました:{message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "自動バックアップ",
|
||||
"autoEnabledHelp": "1日1回ローカルのスナップショットを作成し、保持ポリシーに従って最新のものを残します。",
|
||||
"retention": "保持数",
|
||||
"retentionHelp": "古いものを削除する前に、何件の自動スナップショットを保持するかを指定します。",
|
||||
"management": "バックアップ管理",
|
||||
"managementHelp": "現在のユーザー状態をエクスポートするか、バックアップアーカイブから復元します。",
|
||||
"scopeHelp": "設定、ダウンロード履歴、モデル更新の状態をバックアップします。モデルファイルや再生成できるキャッシュは含まれません。",
|
||||
"locationSummary": "現在のバックアップ場所",
|
||||
"openFolderButton": "バックアップフォルダを開く",
|
||||
"openFolderSuccess": "バックアップフォルダを開きました",
|
||||
"openFolderFailed": "バックアップフォルダを開けませんでした",
|
||||
"locationCopied": "バックアップパスをクリップボードにコピーしました: {{path}}",
|
||||
"locationClipboardFallback": "バックアップパス: {{path}}",
|
||||
"exportButton": "バックアップをエクスポート",
|
||||
"exportSuccess": "バックアップを正常にエクスポートしました。",
|
||||
"exportFailed": "バックアップのエクスポートに失敗しました: {message}",
|
||||
"importButton": "バックアップをインポート",
|
||||
"importConfirm": "このバックアップをインポートして、ローカルのユーザー状態を上書きしますか?",
|
||||
"importSuccess": "バックアップを正常にインポートしました。",
|
||||
"importFailed": "バックアップのインポートに失敗しました: {message}",
|
||||
"latestSnapshot": "最新のスナップショット",
|
||||
"latestAutoSnapshot": "最新の自動スナップショット",
|
||||
"snapshotCount": "保存済みスナップショット",
|
||||
"noneAvailable": "まだスナップショットはありません"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "ベースモデルのダウンロードをスキップ",
|
||||
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"excludeModel": "モデルを除外",
|
||||
"restoreModel": "モデルを復元",
|
||||
"deleteModel": "モデルを削除",
|
||||
"shareRecipe": "レシピを共有",
|
||||
"viewAllLoras": "すべてのLoRAを表示",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "アーリーアクセス",
|
||||
"earlyAccessTooltip": "アーリーアクセスが必要",
|
||||
"inLibrary": "ライブラリ内",
|
||||
"downloaded": "ダウンロード済み",
|
||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||
"alreadyInLibrary": "既にライブラリ内",
|
||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||
"errors": {
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "{count}日後"
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在のバージョン",
|
||||
"current": "開いたバージョン",
|
||||
"currentTooltip": "このモーダルを開くために選択したバージョンです",
|
||||
"inLibrary": "ライブラリにあります",
|
||||
"inLibraryTooltip": "このバージョンはローカルライブラリに存在します",
|
||||
"downloaded": "ダウンロード済み",
|
||||
"downloadedTooltip": "このバージョンは以前ダウンロードされましたが、現在はライブラリにありません",
|
||||
"newer": "新しいバージョン",
|
||||
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
||||
"earlyAccess": "早期アクセス",
|
||||
"ignored": "無視中"
|
||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||
"ignored": "無視中",
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"downloadTooltip": "このバージョンをダウンロード",
|
||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||
"delete": "削除",
|
||||
"deleteTooltip": "このローカルバージョンを削除",
|
||||
"ignore": "無視",
|
||||
"unignore": "無視を解除",
|
||||
"ignoreTooltip": "このバージョンの更新通知を無視",
|
||||
"unignoreTooltip": "このバージョンの更新通知を再開",
|
||||
"viewVersionOnCivitai": "Civitai でバージョンを表示",
|
||||
"earlyAccessTooltip": "早期アクセス購入が必要",
|
||||
"resumeModelUpdates": "このモデルの更新を再開",
|
||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "{type}の削除に失敗しました:{message}",
|
||||
"excludeSuccess": "{type}が正常に除外されました",
|
||||
"excludeFailed": "{type}の除外に失敗しました:{message}",
|
||||
"restoreSuccess": "{type}を復元しました",
|
||||
"restoreFailed": "{type}の復元に失敗しました: {message}",
|
||||
"fileNameUpdated": "ファイル名が正常に更新されました",
|
||||
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
||||
"previewUpdated": "プレビューが正常に更新されました",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "システム診断",
|
||||
"title": "ドクター",
|
||||
"buttonTitle": "診断と一般的な修復を実行",
|
||||
"loading": "環境を確認中...",
|
||||
"footer": "修復後も問題が続く場合は、診断パッケージをエクスポートしてください。",
|
||||
"summary": {
|
||||
"idle": "設定、キャッシュ整合性、UI の一貫性をヘルスチェックします。",
|
||||
"ok": "現在の環境でアクティブな問題は見つかりませんでした。",
|
||||
"warning": "{count} 件の問題が見つかりました。ほとんどはこのパネルから直接修復できます。",
|
||||
"error": "アプリが完全に正常になる前に、{count} 件の問題に対処する必要があります。"
|
||||
},
|
||||
"status": {
|
||||
"ok": "正常",
|
||||
"warning": "要注意",
|
||||
"error": "対応が必要"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "再実行",
|
||||
"exportBundle": "パッケージをエクスポート"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
||||
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "アプリケーション更新が検出されました",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||
"error": "레시피 복구 실패: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"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": {
|
||||
"label": "설정 폴더 열기",
|
||||
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "비디오 설정",
|
||||
"layoutSettings": "레이아웃 설정",
|
||||
"misc": "기타",
|
||||
"backup": "백업",
|
||||
"folderSettings": "기본 루트",
|
||||
"recipeSettings": "레시피",
|
||||
"extraFolderPaths": "추가 폴다 경로",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "자동 백업",
|
||||
"autoEnabledHelp": "하루에 한 번 로컬 스냅샷을 만들고 보존 정책에 따라 최신 스냅샷을 유지합니다.",
|
||||
"retention": "보존 개수",
|
||||
"retentionHelp": "오래된 자동 스냅샷을 삭제하기 전에 몇 개를 유지할지 지정합니다.",
|
||||
"management": "백업 관리",
|
||||
"managementHelp": "현재 사용자 상태를 내보내거나 백업 아카이브에서 복원합니다.",
|
||||
"scopeHelp": "설정, 다운로드 기록, 모델 업데이트 상태를 백업합니다. 모델 파일과 다시 생성할 수 있는 캐시는 포함되지 않습니다.",
|
||||
"locationSummary": "현재 백업 위치",
|
||||
"openFolderButton": "백업 폴더 열기",
|
||||
"openFolderSuccess": "백업 폴더를 열었습니다",
|
||||
"openFolderFailed": "백업 폴더를 열지 못했습니다",
|
||||
"locationCopied": "백업 경로를 클립보드에 복사했습니다: {{path}}",
|
||||
"locationClipboardFallback": "백업 경로: {{path}}",
|
||||
"exportButton": "백업 내보내기",
|
||||
"exportSuccess": "백업을 성공적으로 내보냈습니다.",
|
||||
"exportFailed": "백업 내보내기에 실패했습니다: {message}",
|
||||
"importButton": "백업 가져오기",
|
||||
"importConfirm": "이 백업을 가져와서 로컬 사용자 상태를 덮어쓰시겠습니까?",
|
||||
"importSuccess": "백업을 성공적으로 가져왔습니다.",
|
||||
"importFailed": "백업 가져오기에 실패했습니다: {message}",
|
||||
"latestSnapshot": "최근 스냅샷",
|
||||
"latestAutoSnapshot": "최근 자동 스냅샷",
|
||||
"snapshotCount": "저장된 스냅샷",
|
||||
"noneAvailable": "아직 스냅샷이 없습니다"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "기본 모델 다운로드 건너뛰기",
|
||||
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"excludeModel": "모델 제외",
|
||||
"restoreModel": "모델 복원",
|
||||
"deleteModel": "모델 삭제",
|
||||
"shareRecipe": "레시피 공유",
|
||||
"viewAllLoras": "모든 LoRA 보기",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"earlyAccessTooltip": "얼리 액세스 필요",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"downloaded": "다운로드됨",
|
||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||
"errors": {
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "{count}일 후"
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재 버전",
|
||||
"current": "열린 버전",
|
||||
"currentTooltip": "이 모달을 열 때 사용한 버전입니다",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"inLibraryTooltip": "이 버전은 로컬 라이브러리에 있습니다",
|
||||
"downloaded": "다운로드됨",
|
||||
"downloadedTooltip": "이 버전은 이전에 다운로드되었지만 현재는 라이브러리에 없습니다",
|
||||
"newer": "최신 버전",
|
||||
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"ignored": "무시됨"
|
||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||
"ignored": "무시됨",
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"downloadTooltip": "이 버전 다운로드",
|
||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||
"delete": "삭제",
|
||||
"deleteTooltip": "이 로컬 버전 삭제",
|
||||
"ignore": "무시",
|
||||
"unignore": "무시 해제",
|
||||
"ignoreTooltip": "이 버전의 업데이트 알림 무시",
|
||||
"unignoreTooltip": "이 버전의 업데이트 알림 다시 받기",
|
||||
"viewVersionOnCivitai": "Civitai에서 버전 보기",
|
||||
"earlyAccessTooltip": "얼리 액세스 구매 필요",
|
||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "{type} 삭제 실패: {message}",
|
||||
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
|
||||
"excludeFailed": "{type} 제외 실패: {message}",
|
||||
"restoreSuccess": "{type} 복원 완료",
|
||||
"restoreFailed": "{type} 복원 실패: {message}",
|
||||
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
|
||||
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
||||
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "시스템 진단",
|
||||
"title": "닥터",
|
||||
"buttonTitle": "진단 및 일반적인 수정 실행",
|
||||
"loading": "환경을 확인하는 중...",
|
||||
"footer": "수리 후에도 문제가 계속되면 진단 번들을 내보내세요.",
|
||||
"summary": {
|
||||
"idle": "설정, 캐시 무결성, UI 일관성에 대한 상태 검사를 실행합니다.",
|
||||
"ok": "현재 환경에서 활성 문제를 찾지 못했습니다.",
|
||||
"warning": "{count}개의 문제가 발견되었습니다. 대부분은 이 패널에서 바로 해결할 수 있습니다.",
|
||||
"error": "앱이 완전히 정상 상태가 되기 전에 {count}개의 문제를 처리해야 합니다."
|
||||
},
|
||||
"status": {
|
||||
"ok": "정상",
|
||||
"warning": "주의 필요",
|
||||
"error": "조치 필요"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "다시 실행",
|
||||
"exportBundle": "번들 내보내기"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "진단 로드 실패: {message}",
|
||||
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||
"repairFailed": "캐시 재구성 실패: {message}",
|
||||
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "애플리케이션 업데이트 감지",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "Успешно восстановлено {count} рецептов.",
|
||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||
"error": "Ошибка восстановления рецептов: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API 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": {
|
||||
"label": "Открыть папку настроек",
|
||||
"tooltip": "Открыть папку, содержащую settings.json",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "Настройки видео",
|
||||
"layoutSettings": "Настройки макета",
|
||||
"misc": "Разное",
|
||||
"backup": "Резервные копии",
|
||||
"folderSettings": "Корневые папки",
|
||||
"recipeSettings": "Рецепты",
|
||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "Не удалось сохранить пути для пропуска: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Автоматические резервные копии",
|
||||
"autoEnabledHelp": "Создаёт локальный снимок раз в день и хранит последние снимки согласно политике хранения.",
|
||||
"retention": "Количество хранения",
|
||||
"retentionHelp": "Сколько автоматических снимков сохранять перед удалением старых.",
|
||||
"management": "Управление резервными копиями",
|
||||
"managementHelp": "Экспортируйте текущее состояние пользователя или восстановите его из архива резервной копии.",
|
||||
"scopeHelp": "Резервная копия включает ваши настройки, историю загрузок и состояние обновлений моделей. Файлы моделей и пересоздаваемые кэши не входят.",
|
||||
"locationSummary": "Текущее расположение резервных копий",
|
||||
"openFolderButton": "Открыть папку резервных копий",
|
||||
"openFolderSuccess": "Папка резервных копий открыта",
|
||||
"openFolderFailed": "Не удалось открыть папку резервных копий",
|
||||
"locationCopied": "Путь к резервной копии скопирован в буфер обмена: {{path}}",
|
||||
"locationClipboardFallback": "Путь к резервной копии: {{path}}",
|
||||
"exportButton": "Экспортировать резервную копию",
|
||||
"exportSuccess": "Резервная копия успешно экспортирована.",
|
||||
"exportFailed": "Не удалось экспортировать резервную копию: {message}",
|
||||
"importButton": "Импортировать резервную копию",
|
||||
"importConfirm": "Импортировать эту резервную копию и перезаписать локальное состояние пользователя?",
|
||||
"importSuccess": "Резервная копия успешно импортирована.",
|
||||
"importFailed": "Не удалось импортировать резервную копию: {message}",
|
||||
"latestSnapshot": "Последний снимок",
|
||||
"latestAutoSnapshot": "Последний автоматический снимок",
|
||||
"snapshotCount": "Сохранённые снимки",
|
||||
"noneAvailable": "Снимков пока нет"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Пропускать загрузки для базовых моделей",
|
||||
"help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"excludeModel": "Исключить модель",
|
||||
"restoreModel": "Восстановить модель",
|
||||
"deleteModel": "Удалить модель",
|
||||
"shareRecipe": "Поделиться рецептом",
|
||||
"viewAllLoras": "Посмотреть все LoRAs",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"earlyAccessTooltip": "Требуется ранний доступ",
|
||||
"inLibrary": "В библиотеке",
|
||||
"downloaded": "Загружено",
|
||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||
"alreadyInLibrary": "Уже в библиотеке",
|
||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||
"errors": {
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "через {count}д"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Текущая версия",
|
||||
"current": "Открытая версия",
|
||||
"currentTooltip": "Это версия, с которой было открыто это окно",
|
||||
"inLibrary": "В библиотеке",
|
||||
"inLibraryTooltip": "Эта версия есть в вашей локальной библиотеке",
|
||||
"downloaded": "Загружено",
|
||||
"downloadedTooltip": "Эта версия уже загружалась, но сейчас отсутствует в вашей библиотеке",
|
||||
"newer": "Более новая версия",
|
||||
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"ignored": "Игнорируется"
|
||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||
"ignored": "Игнорируется",
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"downloadTooltip": "Скачать эту версию",
|
||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||
"delete": "Удалить",
|
||||
"deleteTooltip": "Удалить эту локальную версию",
|
||||
"ignore": "Игнорировать",
|
||||
"unignore": "Перестать игнорировать",
|
||||
"ignoreTooltip": "Игнорировать уведомления об обновлениях для этой версии",
|
||||
"unignoreTooltip": "Возобновить уведомления об обновлениях для этой версии",
|
||||
"viewVersionOnCivitai": "Посмотреть версию на Civitai",
|
||||
"earlyAccessTooltip": "Требуется покупка раннего доступа",
|
||||
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "Не удалось удалить {type}: {message}",
|
||||
"excludeSuccess": "{type} успешно исключен",
|
||||
"excludeFailed": "Не удалось исключить {type}: {message}",
|
||||
"restoreSuccess": "{type} успешно восстановлен",
|
||||
"restoreFailed": "Не удалось восстановить {type}: {message}",
|
||||
"fileNameUpdated": "Имя файла успешно обновлено",
|
||||
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
||||
"previewUpdated": "Превью успешно обновлено",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "Системная диагностика",
|
||||
"title": "Доктор",
|
||||
"buttonTitle": "Запустить диагностику и обычные исправления",
|
||||
"loading": "Проверка окружения...",
|
||||
"footer": "Экспортируйте диагностический пакет, если проблема сохраняется после исправления.",
|
||||
"summary": {
|
||||
"idle": "Выполнить проверку настроек, целостности кэша и согласованности интерфейса.",
|
||||
"ok": "В текущем окружении активных проблем не обнаружено.",
|
||||
"warning": "Обнаружено {count} проблем(ы). Большинство можно исправить прямо из этой панели.",
|
||||
"error": "Перед тем как приложение станет полностью исправным, нужно устранить {count} проблем(ы)."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Исправно",
|
||||
"warning": "Требует внимания",
|
||||
"error": "Требуется действие"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Запустить снова",
|
||||
"exportBundle": "Экспортировать пакет"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
||||
"repairSuccess": "Перестройка кэша завершена.",
|
||||
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Обнаружено обновление приложения",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "成功修复了 {count} 个配方。",
|
||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||
"error": "配方修复失败:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"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": {
|
||||
"label": "打开设置文件夹",
|
||||
"tooltip": "打开包含 settings.json 的文件夹",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "视频设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"misc": "其他",
|
||||
"backup": "备份",
|
||||
"folderSettings": "默认根目录",
|
||||
"recipeSettings": "配方",
|
||||
"extraFolderPaths": "额外文件夹路径",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "无法保存跳过路径:{message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "自动备份",
|
||||
"autoEnabledHelp": "每天创建一次本地快照,并按保留策略保留最新快照。",
|
||||
"retention": "保留数量",
|
||||
"retentionHelp": "在删除旧快照之前,要保留多少个自动快照。",
|
||||
"management": "备份管理",
|
||||
"managementHelp": "导出当前用户状态,或从备份归档中恢复。",
|
||||
"scopeHelp": "备份你的设置、下载历史和模型更新状态。不包含模型文件或可重建的缓存。",
|
||||
"locationSummary": "当前备份位置",
|
||||
"openFolderButton": "打开备份文件夹",
|
||||
"openFolderSuccess": "已打开备份文件夹",
|
||||
"openFolderFailed": "无法打开备份文件夹",
|
||||
"locationCopied": "备份路径已复制到剪贴板:{{path}}",
|
||||
"locationClipboardFallback": "备份路径:{{path}}",
|
||||
"exportButton": "导出备份",
|
||||
"exportSuccess": "备份导出成功。",
|
||||
"exportFailed": "备份导出失败:{message}",
|
||||
"importButton": "导入备份",
|
||||
"importConfirm": "导入此备份并覆盖本地用户状态吗?",
|
||||
"importSuccess": "备份导入成功。",
|
||||
"importFailed": "备份导入失败:{message}",
|
||||
"latestSnapshot": "最近快照",
|
||||
"latestAutoSnapshot": "最近自动快照",
|
||||
"snapshotCount": "已保存快照",
|
||||
"noneAvailable": "还没有快照"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "跳过这些基础模型的下载",
|
||||
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "恢复模型",
|
||||
"deleteModel": "删除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "查看所有 LoRA",
|
||||
@@ -658,9 +702,9 @@
|
||||
"title": "从图片或 URL 导入配方",
|
||||
"urlLocalPath": "URL / 本地路径",
|
||||
"uploadImage": "上传图片",
|
||||
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。",
|
||||
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 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": "获取图片",
|
||||
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
|
||||
"selectImage": "选择图片",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "早期访问",
|
||||
"earlyAccessTooltip": "需要早期访问权限",
|
||||
"inLibrary": "已在库中",
|
||||
"downloaded": "已下载",
|
||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||
"alreadyInLibrary": "已存在于库中",
|
||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||
"errors": {
|
||||
@@ -1061,9 +1107,9 @@
|
||||
},
|
||||
"proceedText": "仅在你确定需要此操作时继续。",
|
||||
"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": {
|
||||
"title": "粘贴任意 Civitai 模型 URL。支持格式:",
|
||||
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "{count}天后"
|
||||
},
|
||||
"badges": {
|
||||
"current": "当前版本",
|
||||
"current": "已打开版本",
|
||||
"currentTooltip": "这是你用来打开此弹窗的版本",
|
||||
"inLibrary": "已在库中",
|
||||
"inLibraryTooltip": "此版本已存在于你的本地库中",
|
||||
"downloaded": "已下载",
|
||||
"downloadedTooltip": "此版本之前下载过,但当前不在你的本地库中",
|
||||
"newer": "较新的版本",
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "抢先体验",
|
||||
"ignored": "已忽略"
|
||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已关闭更新通知"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"downloadTooltip": "下载此版本",
|
||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||
"delete": "删除",
|
||||
"deleteTooltip": "删除此本地版本",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"ignoreTooltip": "忽略此版本的更新通知",
|
||||
"unignoreTooltip": "恢复此版本的更新通知",
|
||||
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||
"earlyAccessTooltip": "需要购买抢先体验",
|
||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||
"ignoreModelUpdates": "忽略该模型的更新",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "删除 {type} 失败:{message}",
|
||||
"excludeSuccess": "{type} 排除成功",
|
||||
"excludeFailed": "排除 {type} 失败:{message}",
|
||||
"restoreSuccess": "{type} 已成功恢复",
|
||||
"restoreFailed": "恢复 {type} 失败:{message}",
|
||||
"fileNameUpdated": "文件名更新成功",
|
||||
"fileRenameFailed": "重命名文件失败:{error}",
|
||||
"previewUpdated": "预览图片更新成功",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "系统诊断",
|
||||
"title": "医生",
|
||||
"buttonTitle": "运行诊断并尝试修复常见问题",
|
||||
"loading": "正在检查当前环境...",
|
||||
"footer": "如果修复后问题仍然存在,可以导出诊断包进一步排查。",
|
||||
"summary": {
|
||||
"idle": "检查设置、缓存健康状况和前后端 UI 版本是否一致。",
|
||||
"ok": "当前环境未发现活动问题。",
|
||||
"warning": "发现 {count} 个问题,大多数可以直接在这里处理。",
|
||||
"error": "发现 {count} 个需要尽快处理的问题。"
|
||||
},
|
||||
"status": {
|
||||
"ok": "健康",
|
||||
"warning": "需要关注",
|
||||
"error": "需要处理"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新检查",
|
||||
"exportBundle": "导出诊断包"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "加载诊断结果失败:{message}",
|
||||
"repairSuccess": "缓存重建完成。",
|
||||
"repairFailed": "缓存重建失败:{message}",
|
||||
"exportSuccess": "诊断包已导出。",
|
||||
"exportFailed": "导出诊断包失败:{message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "检测到应用更新",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "成功修復 {count} 個配方。",
|
||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||
"error": "配方修復失敗:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"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": {
|
||||
"label": "開啟設定資料夾",
|
||||
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||
@@ -263,6 +279,7 @@
|
||||
"videoSettings": "影片設定",
|
||||
"layoutSettings": "版面設定",
|
||||
"misc": "其他",
|
||||
"backup": "備份",
|
||||
"folderSettings": "預設根目錄",
|
||||
"recipeSettings": "配方",
|
||||
"extraFolderPaths": "額外資料夾路徑",
|
||||
@@ -324,6 +341,32 @@
|
||||
"saveFailed": "無法儲存跳過路徑:{message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "自動備份",
|
||||
"autoEnabledHelp": "每天建立一次本地快照,並依保留政策保留最新快照。",
|
||||
"retention": "保留數量",
|
||||
"retentionHelp": "在刪除舊快照之前,要保留多少自動快照。",
|
||||
"management": "備份管理",
|
||||
"managementHelp": "匯出目前的使用者狀態,或從備份封存中還原。",
|
||||
"scopeHelp": "備份你的設定、下載歷史與模型更新狀態。不包含模型檔案或可重建的快取。",
|
||||
"locationSummary": "目前備份位置",
|
||||
"openFolderButton": "開啟備份資料夾",
|
||||
"openFolderSuccess": "已開啟備份資料夾",
|
||||
"openFolderFailed": "無法開啟備份資料夾",
|
||||
"locationCopied": "備份路徑已複製到剪貼簿:{{path}}",
|
||||
"locationClipboardFallback": "備份路徑:{{path}}",
|
||||
"exportButton": "匯出備份",
|
||||
"exportSuccess": "備份匯出成功。",
|
||||
"exportFailed": "備份匯出失敗:{message}",
|
||||
"importButton": "匯入備份",
|
||||
"importConfirm": "要匯入此備份並覆寫本機使用者狀態嗎?",
|
||||
"importSuccess": "備份匯入成功。",
|
||||
"importFailed": "備份匯入失敗:{message}",
|
||||
"latestSnapshot": "最近快照",
|
||||
"latestAutoSnapshot": "最近自動快照",
|
||||
"snapshotCount": "已儲存快照",
|
||||
"noneAvailable": "目前還沒有快照"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "跳過這些基礎模型的下載",
|
||||
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
|
||||
@@ -640,6 +683,7 @@
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "還原模型",
|
||||
"deleteModel": "刪除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "檢視全部 LoRA",
|
||||
@@ -930,6 +974,8 @@
|
||||
"earlyAccess": "早期存取",
|
||||
"earlyAccessTooltip": "需要早期存取",
|
||||
"inLibrary": "已在庫存",
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||
"alreadyInLibrary": "已在庫存",
|
||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||
"errors": {
|
||||
@@ -1199,17 +1245,30 @@
|
||||
"days": "{count}天後"
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前版本",
|
||||
"current": "已開啟版本",
|
||||
"currentTooltip": "這是你用來開啟此彈窗的版本",
|
||||
"inLibrary": "已在庫中",
|
||||
"inLibraryTooltip": "此版本已存在於你的本地庫中",
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "此版本之前下載過,但目前不在你的本地庫中",
|
||||
"newer": "較新版本",
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "搶先體驗",
|
||||
"ignored": "已忽略"
|
||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已關閉更新通知"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"downloadTooltip": "下載此版本",
|
||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||
"delete": "刪除",
|
||||
"deleteTooltip": "刪除此本地版本",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"ignoreTooltip": "忽略此版本的更新通知",
|
||||
"unignoreTooltip": "恢復此版本的更新通知",
|
||||
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||
"earlyAccessTooltip": "需要購買搶先體驗",
|
||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||
"ignoreModelUpdates": "忽略此模型的更新",
|
||||
@@ -1748,6 +1807,8 @@
|
||||
"deleteFailed": "刪除 {type} 失敗:{message}",
|
||||
"excludeSuccess": "{type} 已成功排除",
|
||||
"excludeFailed": "排除 {type} 失敗:{message}",
|
||||
"restoreSuccess": "{type} 已成功還原",
|
||||
"restoreFailed": "還原 {type} 失敗:{message}",
|
||||
"fileNameUpdated": "檔案名稱已成功更新",
|
||||
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
||||
"previewUpdated": "預覽圖片已成功更新",
|
||||
@@ -1779,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "系統診斷",
|
||||
"title": "醫生",
|
||||
"buttonTitle": "執行診斷與常見修復",
|
||||
"loading": "正在檢查環境...",
|
||||
"footer": "如果修復後問題仍然存在,請匯出診斷套件。",
|
||||
"summary": {
|
||||
"idle": "針對設定、快取完整性與 UI 一致性執行健康檢查。",
|
||||
"ok": "目前環境中未發現任何活動中的問題。",
|
||||
"warning": "找到 {count} 個問題。大多可以直接在此面板修復。",
|
||||
"error": "應先處理 {count} 個問題,應用程式才能完全正常。"
|
||||
},
|
||||
"status": {
|
||||
"ok": "健康",
|
||||
"warning": "需要注意",
|
||||
"error": "需要處理"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新執行",
|
||||
"exportBundle": "匯出套件"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "載入診斷失敗:{message}",
|
||||
"repairSuccess": "快取重建完成。",
|
||||
"repairFailed": "快取重建失敗:{message}",
|
||||
"exportSuccess": "診斷套件已匯出。",
|
||||
"exportFailed": "匯出診斷套件失敗:{message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "偵測到應用程式更新",
|
||||
|
||||
@@ -222,6 +222,7 @@ class LoraManager:
|
||||
|
||||
# Register DownloadManager with ServiceRegistry
|
||||
await ServiceRegistry.get_download_manager()
|
||||
await ServiceRegistry.get_backup_service()
|
||||
|
||||
from .services.metadata_service import initialize_metadata_providers
|
||||
|
||||
|
||||
@@ -595,6 +595,15 @@ class MetadataProcessor:
|
||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||
else:
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||
# Generic guider nodes often expose separate positive/negative inputs.
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", max_depth=10)
|
||||
if not positive_node_id:
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||
|
||||
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", max_depth=10)
|
||||
if not negative_node_id:
|
||||
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
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):
|
||||
return True
|
||||
class _PromptOptionalInputs:
|
||||
"""Lookup that preserves explicit optional inputs and dynamic trigger slots."""
|
||||
|
||||
def __getitem__(self, key):
|
||||
return ("STRING", {"forceInput": True})
|
||||
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
|
||||
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:
|
||||
@@ -20,12 +43,19 @@ class PromptLM:
|
||||
DESCRIPTION = (
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||
"to guide the diffusion model towards generating specific images. "
|
||||
"Supports dynamic trigger words inputs."
|
||||
"Supports dynamic trigger words inputs and runtime wildcard expansion."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
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": (
|
||||
"STRING",
|
||||
{
|
||||
@@ -35,10 +65,9 @@ class PromptLM:
|
||||
),
|
||||
}
|
||||
|
||||
# Bypass validation for dynamic inputs during graph execution
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||
dyn_inputs = _AllContainer()
|
||||
optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
@@ -46,8 +75,8 @@ class PromptLM:
|
||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||
{
|
||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||
"placeholder": "Enter prompt... /char, /artist for quick tag search",
|
||||
"tooltip": "The text to be encoded.",
|
||||
"placeholder": "Enter prompt... /character, /artist, /wildcard for quick search",
|
||||
"tooltip": "The text to be encoded. Wildcard references inserted with /wildcard are expanded at runtime.",
|
||||
},
|
||||
),
|
||||
"clip": (
|
||||
@@ -55,7 +84,7 @@ class PromptLM:
|
||||
{"tooltip": "The CLIP model used for encoding the text."},
|
||||
),
|
||||
},
|
||||
"optional": dyn_inputs,
|
||||
"optional": optional_inputs,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||||
@@ -65,20 +94,39 @@ class PromptLM:
|
||||
)
|
||||
FUNCTION = "encode"
|
||||
|
||||
def encode(self, text: str, clip: Any, **kwargs):
|
||||
# Collect all trigger words from dynamic inputs
|
||||
@classmethod
|
||||
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 = []
|
||||
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)
|
||||
|
||||
# Build final prompt
|
||||
if trigger_words:
|
||||
prompt = ", ".join(trigger_words + [text])
|
||||
prompt = ", ".join(trigger_words + [expanded_text])
|
||||
else:
|
||||
prompt = text
|
||||
prompt = expanded_text
|
||||
|
||||
from nodes import CLIPTextEncode # type: ignore
|
||||
|
||||
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||
return (conditioning, prompt)
|
||||
return (conditioning, prompt)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
|
||||
|
||||
|
||||
class TextLM:
|
||||
"""A simple text node with autocomplete support."""
|
||||
|
||||
NAME = "Text (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
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
|
||||
@@ -15,8 +20,17 @@ class TextLM:
|
||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||
{
|
||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||
"placeholder": "Enter text... /char, /artist for quick tag search",
|
||||
"tooltip": "The text output.",
|
||||
"placeholder": "Enter text... /character, /artist, /wildcard for quick search",
|
||||
"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_NAMES = ("STRING",)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The text output.",
|
||||
)
|
||||
OUTPUT_TOOLTIPS = ("The text output.",)
|
||||
FUNCTION = "process"
|
||||
|
||||
def process(self, text: str):
|
||||
return (text,)
|
||||
@classmethod
|
||||
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',
|
||||
'size',
|
||||
'clip_skip',
|
||||
'denoising_strength',
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
from .merger import GenParamsMerger
|
||||
from .base import RecipeMetadataParser
|
||||
from ..services.metadata_service import get_default_metadata_provider
|
||||
from ..utils.civitai_utils import extract_civitai_image_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,11 +39,12 @@ class RecipeEnricher:
|
||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||
|
||||
# Check if it's a Civitai image URL
|
||||
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
|
||||
if image_id_match:
|
||||
image_id = image_id_match.group(1)
|
||||
image_id = extract_civitai_image_id(str(source_url))
|
||||
if image_id:
|
||||
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:
|
||||
# Handle nested meta often found in Civitai API responses
|
||||
raw_meta = image_info.get("meta")
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import logging
|
||||
|
||||
from .constants import GEN_PARAM_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenParamsMerger:
|
||||
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||
|
||||
ALLOWED_KEYS = set(GEN_PARAM_KEYS)
|
||||
|
||||
BLACKLISTED_KEYS = {
|
||||
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
||||
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
||||
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
||||
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
||||
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
||||
"checkpoint", "checksum", "model_checksum"
|
||||
"checkpoint", "checksum", "model_checksum", "raw_metadata",
|
||||
}
|
||||
|
||||
|
||||
NORMALIZATION_MAPPING = {
|
||||
# Civitai specific
|
||||
"cfg": "cfg_scale",
|
||||
"cfgScale": "cfg_scale",
|
||||
"clipSkip": "clip_skip",
|
||||
"negativePrompt": "negative_prompt",
|
||||
# Case variations
|
||||
"Sampler": "sampler",
|
||||
"sampler_name": "sampler",
|
||||
"scheduler": "sampler",
|
||||
"Steps": "steps",
|
||||
"Seed": "seed",
|
||||
"Size": "size",
|
||||
@@ -36,63 +42,40 @@ class GenParamsMerger:
|
||||
def merge(
|
||||
request_params: 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]:
|
||||
"""
|
||||
Merge generation parameters from three sources.
|
||||
|
||||
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 = {}
|
||||
|
||||
# 1. Start with embedded metadata (lowest priority)
|
||||
Priority: request_params > civitai_meta > embedded_metadata
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
if embedded_metadata:
|
||||
# If it's a full recipe metadata, we use its gen_params
|
||||
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
||||
if "gen_params" in embedded_metadata and isinstance(
|
||||
embedded_metadata["gen_params"], dict
|
||||
):
|
||||
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
||||
else:
|
||||
# Otherwise assume the dict itself contains gen_params
|
||||
GenParamsMerger._update_normalized(result, embedded_metadata)
|
||||
|
||||
# 2. Layer Civitai meta (medium priority)
|
||||
if civitai_meta:
|
||||
GenParamsMerger._update_normalized(result, civitai_meta)
|
||||
|
||||
# 3. Layer request params (highest priority)
|
||||
if request_params:
|
||||
GenParamsMerger._update_normalized(result, request_params)
|
||||
|
||||
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
|
||||
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
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""Update target dict with normalized keys from source."""
|
||||
for k, v in source.items():
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
|
||||
target[normalized_key] = v
|
||||
# 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?
|
||||
# Actually, if we rename it, we should probably NOT keep both in 'target'
|
||||
# because we want to filter them out at the end anyway.
|
||||
if normalized_key != k:
|
||||
# If we are overwriting an existing snake_case key with a camelCase one's value,
|
||||
# that's fine because of the priority order of calls to _update_normalized.
|
||||
pass
|
||||
target[k] = v
|
||||
"""Update target dict with normalized, persistence-safe keys from source."""
|
||||
for key, value in source.items():
|
||||
if key in GenParamsMerger.BLACKLISTED_KEYS:
|
||||
continue
|
||||
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||
continue
|
||||
|
||||
target[normalized_key] = value
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ from ...services.download_coordinator import DownloadCoordinator
|
||||
from ...services.metadata_sync_service import MetadataSyncService
|
||||
from ...services.model_file_service import ModelMoveService
|
||||
from ...services.preview_asset_service import PreviewAssetService
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
from ...services.settings_manager import SettingsManager, get_settings_manager
|
||||
from ...services.tag_update_service import TagUpdateService
|
||||
from ...services.use_cases import (
|
||||
@@ -64,7 +65,6 @@ class ModelPageView:
|
||||
self._settings = settings_service
|
||||
self._server_i18n = server_i18n
|
||||
self._logger = logger
|
||||
self._app_version = self._get_app_version()
|
||||
|
||||
def _load_supporters(self) -> dict:
|
||||
"""Load supporters data from JSON file."""
|
||||
@@ -155,7 +155,7 @@ class ModelPageView:
|
||||
"request": request,
|
||||
"folders": [],
|
||||
"t": self._server_i18n.get_translation,
|
||||
"version": self._app_version,
|
||||
"version": self._get_app_version(),
|
||||
}
|
||||
|
||||
if not is_initializing:
|
||||
@@ -224,6 +224,42 @@ class ModelListingHandler:
|
||||
)
|
||||
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:
|
||||
page = int(request.query.get("page", "1"))
|
||||
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)
|
||||
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:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -1532,6 +1583,20 @@ class ModelCivitaiHandler:
|
||||
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
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:
|
||||
version_id = None
|
||||
@@ -1548,6 +1613,9 @@ class ModelCivitaiHandler:
|
||||
else 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):
|
||||
local_path = cache_entry.get("file_path")
|
||||
if local_path:
|
||||
@@ -2266,7 +2334,7 @@ class ModelUpdateHandler:
|
||||
self,
|
||||
record,
|
||||
*,
|
||||
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
|
||||
version_context: Optional[Dict[int, Dict[str, Any]]] = None,
|
||||
) -> Dict:
|
||||
context = version_context or {}
|
||||
# Check user setting for hiding early access versions
|
||||
@@ -2295,7 +2363,7 @@ class ModelUpdateHandler:
|
||||
|
||||
@staticmethod
|
||||
def _serialize_version(
|
||||
version, context: Optional[Dict[str, Optional[str]]]
|
||||
version, context: Optional[Dict[str, Any]]
|
||||
) -> Dict:
|
||||
context = context or {}
|
||||
preview_override = context.get("preview_override")
|
||||
@@ -2329,6 +2397,7 @@ class ModelUpdateHandler:
|
||||
"sizeBytes": version.size_bytes,
|
||||
"previewUrl": preview_url,
|
||||
"isInLibrary": version.is_in_library,
|
||||
"hasBeenDownloaded": bool(context.get("has_been_downloaded", False)),
|
||||
"shouldIgnore": version.should_ignore,
|
||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||
"isEarlyAccess": is_early_access,
|
||||
@@ -2338,8 +2407,31 @@ class ModelUpdateHandler:
|
||||
|
||||
async def _build_version_context(
|
||||
self, record
|
||||
) -> Dict[int, Dict[str, Optional[str]]]:
|
||||
context: Dict[int, Dict[str, Optional[str]]] = {}
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
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:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
@@ -2358,16 +2450,21 @@ class ModelUpdateHandler:
|
||||
cache_entry = version_index.get(version.version_id)
|
||||
if isinstance(cache_entry, Mapping):
|
||||
preview = cache_entry.get("preview_url")
|
||||
context_entry: Dict[str, Optional[str]] = {
|
||||
"file_path": cache_entry.get("file_path"),
|
||||
"file_name": cache_entry.get("file_name"),
|
||||
"preview_override": None,
|
||||
}
|
||||
context_entry = context.setdefault(
|
||||
version.version_id,
|
||||
{
|
||||
"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:
|
||||
context_entry["preview_override"] = config.get_preview_static_url(
|
||||
preview
|
||||
)
|
||||
context[version.version_id] = context_entry
|
||||
return context
|
||||
|
||||
|
||||
@@ -2391,8 +2488,10 @@ class ModelHandlerSet:
|
||||
return {
|
||||
"handle_models_page": self.page_view.handle,
|
||||
"get_models": self.listing.get_models,
|
||||
"get_excluded_models": self.listing.get_excluded_models,
|
||||
"delete_model": self.management.delete_model,
|
||||
"exclude_model": self.management.exclude_model,
|
||||
"unexclude_model": self.management.unexclude_model,
|
||||
"fetch_civitai": self.management.fetch_civitai,
|
||||
"fetch_all_civitai": self.civitai.fetch_all_civitai,
|
||||
"relink_civitai": self.management.relink_civitai,
|
||||
|
||||
@@ -26,7 +26,7 @@ from ...services.recipes import (
|
||||
RecipeValidationError,
|
||||
)
|
||||
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 ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
@@ -756,6 +756,14 @@ class RecipeManagementHandler:
|
||||
)
|
||||
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
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": params.get("base_model", "") or "",
|
||||
@@ -1188,13 +1196,15 @@ class RecipeManagementHandler:
|
||||
temp_path = temp_file.name
|
||||
download_url = image_url
|
||||
image_info = None
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
||||
if civitai_match:
|
||||
civitai_image_id = extract_civitai_image_id(image_url)
|
||||
if civitai_image_id:
|
||||
if civitai_client is None:
|
||||
raise RecipeDownloadError(
|
||||
"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:
|
||||
raise RecipeDownloadError(
|
||||
"Failed to fetch image information from Civitai"
|
||||
@@ -1228,7 +1238,7 @@ class RecipeManagementHandler:
|
||||
return (
|
||||
file_obj.read(),
|
||||
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:
|
||||
raise
|
||||
|
||||
@@ -22,11 +22,16 @@ class RouteDefinition:
|
||||
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||
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/update-usage-stats", "update_usage_stats"),
|
||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||
@@ -62,6 +67,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/backup/status", "get_backup_status"),
|
||||
RouteDefinition("POST", "/api/lm/backup/export", "export_backup"),
|
||||
RouteDefinition("POST", "/api/lm/backup/import", "import_backup"),
|
||||
RouteDefinition("POST", "/api/lm/backup/open-location", "open_backup_location"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/model-versions-status", "get_model_versions_status"
|
||||
),
|
||||
|
||||
@@ -19,10 +19,12 @@ from ..services.downloader import get_downloader
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from .handlers.misc_handlers import (
|
||||
CustomWordsHandler,
|
||||
DoctorHandler,
|
||||
ExampleWorkflowsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
BackupHandler,
|
||||
MetadataArchiveHandler,
|
||||
MiscHandlerSet,
|
||||
ModelExampleFilesHandler,
|
||||
@@ -33,6 +35,7 @@ from .handlers.misc_handlers import (
|
||||
SupportersHandler,
|
||||
TrainedWordsHandler,
|
||||
UsageStatsHandler,
|
||||
WildcardsHandler,
|
||||
build_service_registry_adapter,
|
||||
)
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
@@ -116,6 +119,7 @@ class MiscRoutes:
|
||||
settings_service=self._settings,
|
||||
metadata_provider_updater=self._metadata_provider_updater,
|
||||
)
|
||||
backup = BackupHandler()
|
||||
filesystem = FileSystemHandler(settings_service=self._settings)
|
||||
node_registry_handler = NodeRegistryHandler(
|
||||
node_registry=self._node_registry,
|
||||
@@ -127,7 +131,9 @@ class MiscRoutes:
|
||||
metadata_provider_factory=self._metadata_provider_factory,
|
||||
)
|
||||
custom_words = CustomWordsHandler()
|
||||
wildcards = WildcardsHandler()
|
||||
supporters = SupportersHandler()
|
||||
doctor = DoctorHandler(settings_service=self._settings)
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
base_model = BaseModelHandlerSet()
|
||||
|
||||
@@ -141,9 +147,12 @@ class MiscRoutes:
|
||||
node_registry=node_registry_handler,
|
||||
model_library=model_library,
|
||||
metadata_archive=metadata_archive,
|
||||
backup=backup,
|
||||
filesystem=filesystem,
|
||||
custom_words=custom_words,
|
||||
wildcards=wildcards,
|
||||
supporters=supporters,
|
||||
doctor=doctor,
|
||||
example_workflows=example_workflows,
|
||||
base_model=base_model,
|
||||
)
|
||||
|
||||
@@ -22,8 +22,10 @@ class RouteDefinition:
|
||||
|
||||
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
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}/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-all-civitai", "fetch_all_civitai"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
|
||||
|
||||
411
py/services/backup_service.py
Normal file
411
py/services/backup_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from ..utils.cache_paths import CacheType, get_cache_base_dir, get_cache_file_path
|
||||
from ..utils.settings_paths import get_settings_dir
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
BACKUP_MANIFEST_VERSION = 1
|
||||
DEFAULT_BACKUP_RETENTION_COUNT = 5
|
||||
DEFAULT_BACKUP_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupEntry:
|
||||
kind: str
|
||||
archive_path: str
|
||||
target_path: str
|
||||
sha256: str
|
||||
size: int
|
||||
mtime: float
|
||||
|
||||
|
||||
class BackupService:
|
||||
"""Create and restore user-state backup archives."""
|
||||
|
||||
_instance: "BackupService | None" = None
|
||||
_instance_lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, *, settings_manager=None, backup_dir: str | None = None) -> None:
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._backup_dir = Path(backup_dir or self._resolve_backup_dir())
|
||||
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = asyncio.Lock()
|
||||
self._auto_task: asyncio.Task[None] | None = None
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "BackupService":
|
||||
async with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
cls._instance._ensure_auto_snapshot_task()
|
||||
return cls._instance
|
||||
|
||||
@staticmethod
|
||||
def _resolve_backup_dir() -> str:
|
||||
return os.path.join(get_settings_dir(create=True), "backups")
|
||||
|
||||
def get_backup_dir(self) -> str:
|
||||
return str(self._backup_dir)
|
||||
|
||||
def _ensure_auto_snapshot_task(self) -> None:
|
||||
if self._auto_task is not None and not self._auto_task.done():
|
||||
return
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
self._auto_task = loop.create_task(self._auto_backup_loop())
|
||||
|
||||
def _get_setting_bool(self, key: str, default: bool) -> bool:
|
||||
try:
|
||||
return bool(self._settings.get(key, default))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _get_setting_int(self, key: str, default: int) -> int:
|
||||
try:
|
||||
value = self._settings.get(key, default)
|
||||
return max(1, int(value))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _settings_file_path(self) -> str:
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
return str(settings_file)
|
||||
return os.path.join(get_settings_dir(create=True), "settings.json")
|
||||
|
||||
def _download_history_path(self) -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
history_dir = os.path.join(base_dir, "download_history")
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
return os.path.join(history_dir, "downloaded_versions.sqlite")
|
||||
|
||||
def _model_update_dir(self) -> str:
|
||||
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent)
|
||||
|
||||
def _model_update_targets(self) -> list[tuple[str, str, str]]:
|
||||
"""Return (kind, archive_path, target_path) tuples for backup."""
|
||||
|
||||
targets: list[tuple[str, str, str]] = []
|
||||
|
||||
settings_path = self._settings_file_path()
|
||||
targets.append(("settings", "settings/settings.json", settings_path))
|
||||
|
||||
history_path = self._download_history_path()
|
||||
targets.append(
|
||||
(
|
||||
"download_history",
|
||||
"cache/download_history/downloaded_versions.sqlite",
|
||||
history_path,
|
||||
)
|
||||
)
|
||||
|
||||
symlink_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||
targets.append(
|
||||
(
|
||||
"symlink_map",
|
||||
"cache/symlink/symlink_map.json",
|
||||
symlink_path,
|
||||
)
|
||||
)
|
||||
|
||||
model_update_dir = Path(self._model_update_dir())
|
||||
if model_update_dir.exists():
|
||||
for sqlite_file in sorted(model_update_dir.glob("*.sqlite")):
|
||||
targets.append(
|
||||
(
|
||||
"model_update",
|
||||
f"cache/model_update/{sqlite_file.name}",
|
||||
str(sqlite_file),
|
||||
)
|
||||
)
|
||||
|
||||
return targets
|
||||
|
||||
@staticmethod
|
||||
def _hash_file(path: str) -> tuple[str, int, float]:
|
||||
digest = hashlib.sha256()
|
||||
total = 0
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
total += len(chunk)
|
||||
digest.update(chunk)
|
||||
mtime = os.path.getmtime(path)
|
||||
return digest.hexdigest(), total, mtime
|
||||
|
||||
def _build_manifest(self, entries: Iterable[BackupEntry], *, snapshot_type: str) -> dict[str, Any]:
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
active_library = None
|
||||
try:
|
||||
active_library = self._settings.get_active_library_name()
|
||||
except Exception:
|
||||
active_library = None
|
||||
|
||||
return {
|
||||
"manifest_version": BACKUP_MANIFEST_VERSION,
|
||||
"created_at": created_at,
|
||||
"snapshot_type": snapshot_type,
|
||||
"active_library": active_library,
|
||||
"files": [
|
||||
{
|
||||
"kind": entry.kind,
|
||||
"archive_path": entry.archive_path,
|
||||
"target_path": entry.target_path,
|
||||
"sha256": entry.sha256,
|
||||
"size": entry.size,
|
||||
"mtime": entry.mtime,
|
||||
}
|
||||
for entry in entries
|
||||
],
|
||||
}
|
||||
|
||||
def _write_archive(self, archive_path: str, entries: list[BackupEntry], manifest: dict[str, Any]) -> None:
|
||||
with zipfile.ZipFile(
|
||||
archive_path,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6,
|
||||
) as zf:
|
||||
zf.writestr(
|
||||
"manifest.json",
|
||||
json.dumps(manifest, indent=2, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
for entry in entries:
|
||||
zf.write(entry.target_path, arcname=entry.archive_path)
|
||||
|
||||
async def create_snapshot(self, *, snapshot_type: str = "manual", persist: bool = False) -> dict[str, Any]:
|
||||
"""Create a backup archive.
|
||||
|
||||
If ``persist`` is true, the archive is stored in the backup directory
|
||||
and retained according to the configured retention policy.
|
||||
"""
|
||||
|
||||
async with self._lock:
|
||||
raw_targets = self._model_update_targets()
|
||||
entries: list[BackupEntry] = []
|
||||
for kind, archive_path, target_path in raw_targets:
|
||||
if not os.path.exists(target_path):
|
||||
continue
|
||||
sha256, size, mtime = self._hash_file(target_path)
|
||||
entries.append(
|
||||
BackupEntry(
|
||||
kind=kind,
|
||||
archive_path=archive_path,
|
||||
target_path=target_path,
|
||||
sha256=sha256,
|
||||
size=size,
|
||||
mtime=mtime,
|
||||
)
|
||||
)
|
||||
|
||||
if not entries:
|
||||
raise FileNotFoundError("No backupable files were found")
|
||||
|
||||
manifest = self._build_manifest(entries, snapshot_type=snapshot_type)
|
||||
archive_name = self._build_archive_name(snapshot_type=snapshot_type)
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".zip", dir=str(self._backup_dir))
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
self._write_archive(temp_path, entries, manifest)
|
||||
if persist:
|
||||
final_path = self._backup_dir / archive_name
|
||||
os.replace(temp_path, final_path)
|
||||
self._prune_snapshots()
|
||||
return {
|
||||
"archive_path": str(final_path),
|
||||
"archive_name": final_path.name,
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
with open(temp_path, "rb") as handle:
|
||||
data = handle.read()
|
||||
return {
|
||||
"archive_name": archive_name,
|
||||
"archive_bytes": data,
|
||||
"manifest": manifest,
|
||||
}
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.remove(temp_path)
|
||||
|
||||
def _build_archive_name(self, *, snapshot_type: str) -> str:
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return f"lora-manager-backup-{timestamp}-{snapshot_type}.zip"
|
||||
|
||||
def _prune_snapshots(self) -> None:
|
||||
retention = self._get_setting_int(
|
||||
"backup_retention_count", DEFAULT_BACKUP_RETENTION_COUNT
|
||||
)
|
||||
archives = sorted(
|
||||
self._backup_dir.glob("lora-manager-backup-*-auto.zip"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in archives[retention:]:
|
||||
with contextlib.suppress(OSError):
|
||||
path.unlink()
|
||||
|
||||
async def restore_snapshot(self, archive_path: str) -> dict[str, Any]:
|
||||
"""Restore backup contents from a ZIP archive."""
|
||||
|
||||
async with self._lock:
|
||||
try:
|
||||
zf = zipfile.ZipFile(archive_path, mode="r")
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ValueError("Backup archive is not a valid ZIP file") from exc
|
||||
|
||||
with zf:
|
||||
try:
|
||||
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
except KeyError as exc:
|
||||
raise ValueError("Backup archive is missing manifest.json") from exc
|
||||
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("Backup manifest is invalid")
|
||||
if manifest.get("manifest_version") != BACKUP_MANIFEST_VERSION:
|
||||
raise ValueError("Backup manifest version is not supported")
|
||||
|
||||
files = manifest.get("files", [])
|
||||
if not isinstance(files, list):
|
||||
raise ValueError("Backup manifest file list is invalid")
|
||||
|
||||
extracted_paths: list[tuple[str, str]] = []
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="lora-manager-restore-"))
|
||||
try:
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
archive_member = item.get("archive_path")
|
||||
if not isinstance(archive_member, str) or not archive_member:
|
||||
continue
|
||||
archive_member_path = Path(archive_member)
|
||||
if archive_member_path.is_absolute() or ".." in archive_member_path.parts:
|
||||
raise ValueError(f"Invalid archive member path: {archive_member}")
|
||||
|
||||
kind = item.get("kind")
|
||||
target_path = self._resolve_restore_target(kind, archive_member)
|
||||
if target_path is None:
|
||||
continue
|
||||
|
||||
extracted_path = temp_dir / archive_member_path
|
||||
extracted_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(archive_member) as source, open(
|
||||
extracted_path, "wb"
|
||||
) as destination:
|
||||
shutil.copyfileobj(source, destination)
|
||||
|
||||
expected_hash = item.get("sha256")
|
||||
if isinstance(expected_hash, str) and expected_hash:
|
||||
actual_hash, _, _ = self._hash_file(str(extracted_path))
|
||||
if actual_hash != expected_hash:
|
||||
raise ValueError(
|
||||
f"Checksum mismatch for {archive_member}"
|
||||
)
|
||||
|
||||
extracted_paths.append((str(extracted_path), target_path))
|
||||
|
||||
for extracted_path, target_path in extracted_paths:
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
os.replace(extracted_path, target_path)
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"restored_files": len(extracted_paths),
|
||||
"snapshot_type": manifest.get("snapshot_type"),
|
||||
}
|
||||
|
||||
def _resolve_restore_target(self, kind: Any, archive_member: str) -> str | None:
|
||||
if kind == "settings":
|
||||
return self._settings_file_path()
|
||||
if kind == "download_history":
|
||||
return self._download_history_path()
|
||||
if kind == "symlink_map":
|
||||
return get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||
if kind == "model_update":
|
||||
filename = os.path.basename(archive_member)
|
||||
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename)
|
||||
return None
|
||||
|
||||
async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]:
|
||||
if not self._get_setting_bool("backup_auto_enabled", True):
|
||||
return None
|
||||
|
||||
latest = self.get_latest_auto_snapshot()
|
||||
now = time.time()
|
||||
if latest and now - latest["mtime"] < DEFAULT_BACKUP_INTERVAL_SECONDS:
|
||||
return None
|
||||
|
||||
return await self.create_snapshot(snapshot_type="auto", persist=True)
|
||||
|
||||
async def _auto_backup_loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self.create_auto_snapshot_if_due()
|
||||
await asyncio.sleep(DEFAULT_BACKUP_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning("Automatic backup snapshot failed: %s", exc, exc_info=True)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
def get_available_snapshots(self) -> list[dict[str, Any]]:
|
||||
snapshots: list[dict[str, Any]] = []
|
||||
for path in sorted(self._backup_dir.glob("lora-manager-backup-*.zip")):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
snapshots.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"path": str(path),
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"is_auto": path.name.endswith("-auto.zip"),
|
||||
}
|
||||
)
|
||||
snapshots.sort(key=lambda item: item["mtime"], reverse=True)
|
||||
return snapshots
|
||||
|
||||
def get_latest_auto_snapshot(self) -> Optional[dict[str, Any]]:
|
||||
autos = [snapshot for snapshot in self.get_available_snapshots() if snapshot["is_auto"]]
|
||||
if not autos:
|
||||
return None
|
||||
return autos[0]
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
snapshots = self.get_available_snapshots()
|
||||
return {
|
||||
"backupDir": self.get_backup_dir(),
|
||||
"enabled": self._get_setting_bool("backup_auto_enabled", True),
|
||||
"retentionCount": self._get_setting_int(
|
||||
"backup_retention_count", DEFAULT_BACKUP_RETENTION_COUNT
|
||||
),
|
||||
"snapshotCount": len(snapshots),
|
||||
"latestSnapshot": snapshots[0] if snapshots else None,
|
||||
"latestAutoSnapshot": self.get_latest_auto_snapshot(),
|
||||
}
|
||||
@@ -20,6 +20,7 @@ from .model_query import (
|
||||
resolve_sub_type,
|
||||
)
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.civitai_utils import build_civitai_model_page_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -178,6 +179,57 @@ class BaseModelService(ABC):
|
||||
)
|
||||
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):
|
||||
"""Fetch data sorted by usage count (desc/asc)."""
|
||||
cache = await self.cache_repository.get_cache()
|
||||
@@ -217,6 +269,62 @@ class BaseModelService(ABC):
|
||||
)
|
||||
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(
|
||||
self, data: List[Dict], hash_filters: Dict
|
||||
) -> List[Dict]:
|
||||
@@ -774,9 +882,12 @@ class BaseModelService(ABC):
|
||||
version_id = civitai_data.get("id")
|
||||
|
||||
if model_id:
|
||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
civitai_host = self.settings.get("civitai_host", "civitai.com")
|
||||
civitai_url = build_civitai_model_page_url(
|
||||
model_id,
|
||||
version_id,
|
||||
host=civitai_host,
|
||||
)
|
||||
|
||||
return {
|
||||
"civitai_url": civitai_url,
|
||||
|
||||
@@ -105,12 +105,18 @@ class CheckpointScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
# Load current metadata
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if metadata is None:
|
||||
logger.error(f"No metadata found for {file_path}")
|
||||
return None
|
||||
if should_skip:
|
||||
logger.error(f"Invalid metadata found for {file_path}")
|
||||
return None
|
||||
created_metadata = await self._create_default_metadata(file_path)
|
||||
if created_metadata is None:
|
||||
logger.error(f"No metadata found for {file_path}")
|
||||
return None
|
||||
metadata = created_metadata
|
||||
|
||||
# Check if hash is already calculated
|
||||
if metadata.hash_status == "completed" and metadata.sha256:
|
||||
|
||||
@@ -42,6 +42,7 @@ class CheckpointService(BaseModelService):
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
|
||||
@@ -30,7 +30,7 @@ class CivitaiBaseModelService:
|
||||
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
|
||||
|
||||
# Civitai API endpoint for enums
|
||||
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums"
|
||||
CIVITAI_ENUMS_URL = "https://civitai.red/api/v1/enums"
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> CivitaiBaseModelService:
|
||||
|
||||
@@ -39,7 +39,10 @@ class CivitaiClient:
|
||||
return
|
||||
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(
|
||||
self,
|
||||
@@ -190,7 +193,9 @@ class CivitaiClient:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
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:
|
||||
# Also return model type along with versions
|
||||
@@ -346,7 +351,9 @@ class CivitaiClient:
|
||||
|
||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||
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:
|
||||
return data
|
||||
@@ -358,7 +365,9 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
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:
|
||||
return version
|
||||
@@ -371,7 +380,9 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
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:
|
||||
return version
|
||||
@@ -453,13 +464,11 @@ class CivitaiClient:
|
||||
try:
|
||||
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)
|
||||
|
||||
if success:
|
||||
logger.debug(
|
||||
f"Successfully fetched model version info for: {version_id}"
|
||||
)
|
||||
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||
self._remove_comfy_metadata(result)
|
||||
return result, None
|
||||
|
||||
@@ -479,48 +488,58 @@ class CivitaiClient:
|
||||
logger.error(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
|
||||
|
||||
Args:
|
||||
image_id: The Civitai image ID
|
||||
source_url: Original image page URL. Accepted for caller compatibility;
|
||||
API requests always target ``civitai.red``.
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The image data or None if not found
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
requested_id = int(image_id)
|
||||
|
||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||
url = self._build_image_info_url(image_id)
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
if result and "items" in result and isinstance(result["items"], list):
|
||||
items = result["items"]
|
||||
|
||||
# First, try to find the item with matching ID
|
||||
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}")
|
||||
if not success:
|
||||
logger.error(
|
||||
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||
image_id,
|
||||
result,
|
||||
)
|
||||
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
|
||||
except RateLimitError:
|
||||
raise
|
||||
@@ -539,8 +558,12 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/models?username={username}"
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
success, result = await self._make_request(
|
||||
"GET",
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={"username": username},
|
||||
)
|
||||
|
||||
if not success:
|
||||
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
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_EMBEDDED_COMMAND_PATTERN = re.compile(r"\s/\w")
|
||||
class CustomWordsService:
|
||||
"""Service for autocomplete via TagFTSIndex.
|
||||
|
||||
@@ -77,12 +79,28 @@ class CustomWordsService:
|
||||
Returns:
|
||||
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()
|
||||
if tag_index is not None:
|
||||
results = tag_index.search(
|
||||
search_term, categories=categories, limit=limit, offset=offset
|
||||
return tag_index.search(
|
||||
normalized_search, categories=categories, limit=limit, offset=offset
|
||||
)
|
||||
return results
|
||||
|
||||
logger.debug("TagFTSIndex not available, returning empty results")
|
||||
return []
|
||||
|
||||
@@ -16,7 +16,7 @@ from ..utils.constants import (
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
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.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -31,6 +31,11 @@ import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
"https://civitai.com/api/download/",
|
||||
"https://civitai.red/api/download/",
|
||||
)
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
_instance = None
|
||||
@@ -639,7 +644,9 @@ class DownloadManager:
|
||||
if mirrors:
|
||||
for mirror in mirrors:
|
||||
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
|
||||
# This avoids failed downloads from deleted Civitai models
|
||||
@@ -647,18 +654,20 @@ class DownloadManager:
|
||||
civitai_urls = [
|
||||
u
|
||||
for u in download_urls
|
||||
if u.startswith("https://civitai.com/api/download/")
|
||||
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
]
|
||||
non_civitai_urls = [
|
||||
u
|
||||
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
|
||||
else:
|
||||
download_url = file_info.get("downloadUrl")
|
||||
if download_url:
|
||||
download_urls.append(download_url)
|
||||
download_urls.append(
|
||||
normalize_civitai_download_url(download_url)
|
||||
)
|
||||
|
||||
if not download_urls:
|
||||
return {"success": False, "error": "No mirror URL found"}
|
||||
@@ -1133,7 +1142,8 @@ class DownloadManager:
|
||||
pause_control.update_stall_timeout(downloader.stall_timeout)
|
||||
last_error = None
|
||||
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 = {
|
||||
"progress_callback": lambda progress, snapshot=None: (
|
||||
self._handle_download_progress(
|
||||
|
||||
@@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService):
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"exclude": bool(embedding_data.get("exclude", False)),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"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", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"exclude": bool(lora_data.get("exclude", False)),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(
|
||||
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 ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -207,11 +208,56 @@ class ModelLifecycleService:
|
||||
|
||||
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||
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"
|
||||
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]:
|
||||
"""Delete a collection of models via the scanner bulk operation."""
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
@@ -234,12 +235,52 @@ class ModelUpdateService:
|
||||
ON model_update_versions(model_id);
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str, *, ttl_seconds: int = 24 * 60 * 60, settings_manager=None) -> None:
|
||||
self._db_path = db_path
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str | None = None,
|
||||
*,
|
||||
ttl_seconds: int = 24 * 60 * 60,
|
||||
settings_manager=None,
|
||||
) -> None:
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._library_name = self._get_active_library_name()
|
||||
self._db_path = db_path or self._resolve_default_path(self._library_name)
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._lock = asyncio.Lock()
|
||||
self._schema_initialized = False
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._custom_db_path = db_path is not None
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
|
||||
def _get_active_library_name(self) -> str:
|
||||
try:
|
||||
value = self._settings.get_active_library_name()
|
||||
except Exception:
|
||||
value = None
|
||||
return value or "default"
|
||||
|
||||
def _resolve_default_path(self, library_name: str) -> str:
|
||||
env_override = os.environ.get("LORA_MANAGER_MODEL_UPDATE_DB")
|
||||
return resolve_cache_path_with_migration(
|
||||
CacheType.MODEL_UPDATE,
|
||||
library_name=library_name,
|
||||
env_override=env_override,
|
||||
)
|
||||
|
||||
def on_library_changed(self) -> None:
|
||||
"""Switch to the database for the active library."""
|
||||
|
||||
if self._custom_db_path:
|
||||
return
|
||||
|
||||
library_name = self._get_active_library_name()
|
||||
new_path = self._resolve_default_path(library_name)
|
||||
if new_path == self._db_path:
|
||||
return
|
||||
|
||||
self._library_name = library_name
|
||||
self._db_path = new_path
|
||||
self._schema_initialized = False
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
|
||||
@@ -262,11 +303,114 @@ class ModelUpdateService:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.executescript(self._SCHEMA)
|
||||
self._apply_migrations(conn)
|
||||
self._migrate_from_legacy_snapshot(conn)
|
||||
self._schema_initialized = True
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.error("Failed to initialize update schema: %s", exc, exc_info=True)
|
||||
raise
|
||||
|
||||
def _migrate_from_legacy_snapshot(self, conn: sqlite3.Connection) -> None:
|
||||
"""Copy update tracking data out of the legacy model snapshot database."""
|
||||
|
||||
if self._custom_db_path:
|
||||
return
|
||||
|
||||
try:
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
|
||||
legacy_path = get_persistent_cache(self._library_name).get_database_path()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not legacy_path or os.path.abspath(legacy_path) == os.path.abspath(self._db_path):
|
||||
return
|
||||
if not os.path.exists(legacy_path):
|
||||
return
|
||||
|
||||
try:
|
||||
existing_row = conn.execute(
|
||||
"SELECT 1 FROM model_update_status LIMIT 1"
|
||||
).fetchone()
|
||||
if existing_row:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
with sqlite3.connect(legacy_path, check_same_thread=False) as legacy_conn:
|
||||
legacy_conn.row_factory = sqlite3.Row
|
||||
status_rows = legacy_conn.execute(
|
||||
"""
|
||||
SELECT model_id, model_type, last_checked_at, should_ignore_model
|
||||
FROM model_update_status
|
||||
"""
|
||||
).fetchall()
|
||||
if not status_rows:
|
||||
return
|
||||
|
||||
version_rows = legacy_conn.execute(
|
||||
"""
|
||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore,
|
||||
early_access_ends_at, is_early_access
|
||||
FROM model_update_versions
|
||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT OR REPLACE INTO model_update_status (
|
||||
model_id, model_type, last_checked_at, should_ignore_model
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
int(row["model_id"]),
|
||||
row["model_type"],
|
||||
row["last_checked_at"],
|
||||
int(row["should_ignore_model"] or 0),
|
||||
)
|
||||
for row in status_rows
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT OR REPLACE INTO model_update_versions (
|
||||
model_id, version_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore,
|
||||
early_access_ends_at, is_early_access
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
int(row["model_id"]),
|
||||
int(row["version_id"]),
|
||||
int(row["sort_index"] or 0),
|
||||
row["name"],
|
||||
row["base_model"],
|
||||
row["released_at"],
|
||||
row["size_bytes"],
|
||||
row["preview_url"],
|
||||
int(row["is_in_library"] or 0),
|
||||
int(row["should_ignore"] or 0),
|
||||
row["early_access_ends_at"],
|
||||
int(row["is_early_access"] or 0),
|
||||
)
|
||||
for row in version_rows
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"Migrated model update tracking data from legacy snapshot DB for %s",
|
||||
self._library_name,
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.debug("Legacy model update migration skipped: %s", exc)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning("Failed to migrate model update data: %s", exc, exc_info=True)
|
||||
|
||||
def _apply_migrations(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure legacy databases match the current schema without dropping data."""
|
||||
|
||||
|
||||
@@ -952,6 +952,30 @@ class RecipeScanner:
|
||||
except Exception as 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:
|
||||
"""Perform remote metadata enrichment after the initial scan."""
|
||||
|
||||
@@ -1345,6 +1369,7 @@ class RecipeScanner:
|
||||
# Ensure gen_params exists
|
||||
if "gen_params" not in recipe_data:
|
||||
recipe_data["gen_params"] = {}
|
||||
recipe_data = self._normalize_recipe_gen_params(recipe_data)
|
||||
|
||||
# Update lora information with local paths and availability
|
||||
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)
|
||||
|
||||
# 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
|
||||
for item in paginated_items:
|
||||
@@ -2114,8 +2142,18 @@ class RecipeScanner:
|
||||
if not recipe:
|
||||
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
|
||||
formatted_recipe = {**recipe} # Copy all fields
|
||||
formatted_recipe = {**merged_recipe}
|
||||
|
||||
# Format file path to URL
|
||||
if "file_path" in formatted_recipe:
|
||||
@@ -2149,6 +2187,30 @@ class RecipeScanner:
|
||||
|
||||
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:
|
||||
"""Format file path as URL for serving in web UI"""
|
||||
if not file_path:
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
@@ -14,7 +13,7 @@ import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
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 (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
@@ -104,9 +103,11 @@ class RecipeAnalysisService:
|
||||
extension = ".jpg" # Default
|
||||
|
||||
try:
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
|
||||
if civitai_match:
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
civitai_image_id = extract_civitai_image_id(url)
|
||||
if civitai_image_id:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=url
|
||||
)
|
||||
if not image_info:
|
||||
raise RecipeDownloadError(
|
||||
"Failed to fetch image information from Civitai"
|
||||
|
||||
@@ -12,6 +12,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from ...config import config
|
||||
from ...recipes.constants import GEN_PARAM_KEYS
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
|
||||
@@ -90,23 +91,7 @@ class RecipePersistenceService:
|
||||
current_time = time.time()
|
||||
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))
|
||||
|
||||
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)
|
||||
gen_params = self._sanitize_gen_params_for_storage(metadata)
|
||||
|
||||
fingerprint = calculate_recipe_fingerprint(loras_data)
|
||||
recipe_data: Dict[str, Any] = {
|
||||
@@ -133,6 +118,7 @@ class RecipePersistenceService:
|
||||
json_filename = f"{recipe_id}.recipe.json"
|
||||
json_path = os.path.join(recipes_dir, json_filename)
|
||||
json_path = os.path.normpath(json_path)
|
||||
|
||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||
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:
|
||||
"""Delete an existing recipe."""
|
||||
|
||||
|
||||
@@ -159,10 +159,9 @@ class ServiceRegistry:
|
||||
return cls._services[service_name]
|
||||
|
||||
from .model_update_service import ModelUpdateService
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
cache = get_persistent_cache()
|
||||
service = ModelUpdateService(cache.get_database_path())
|
||||
service = ModelUpdateService(settings_manager=get_settings_manager())
|
||||
cls._services[service_name] = service
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
@@ -189,6 +188,26 @@ class ServiceRegistry:
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_backup_service(cls):
|
||||
"""Get or create the backup service."""
|
||||
|
||||
service_name = "backup_service"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
from .backup_service import BackupService
|
||||
|
||||
service = await BackupService.get_instance()
|
||||
cls._services[service_name] = service
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_civarchive_client(cls):
|
||||
"""Get or create CivArchive client instance"""
|
||||
|
||||
@@ -54,6 +54,7 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
|
||||
|
||||
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"civitai_api_key": "",
|
||||
"civitai_host": "civitai.com",
|
||||
"use_portable_settings": False,
|
||||
"hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB,
|
||||
"language": "en",
|
||||
@@ -95,6 +96,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"metadata_refresh_skip_paths": [],
|
||||
"skip_previously_downloaded_model_versions": False,
|
||||
"download_skip_base_models": [],
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
}
|
||||
|
||||
|
||||
@@ -1983,6 +1986,7 @@ class SettingsManager:
|
||||
"checkpoint_scanner",
|
||||
"embedding_scanner",
|
||||
"recipe_scanner",
|
||||
"model_update_service",
|
||||
):
|
||||
service = ServiceRegistry.get_service_sync(service_name)
|
||||
if service and hasattr(service, "on_library_changed"):
|
||||
|
||||
@@ -450,9 +450,9 @@ class TagFTSIndex:
|
||||
the tag_name, the result will include a "matched_alias" field.
|
||||
|
||||
Ranking is based on a combination of:
|
||||
1. FTS5 bm25 relevance score (how well the text matches)
|
||||
2. Post count (popularity)
|
||||
3. Exact prefix match boost (tag_name starts with query)
|
||||
1. Exact prefix match boost (tag_name starts with query)
|
||||
2. Post count to preserve expected autocomplete ordering
|
||||
3. FTS5 bm25 relevance score as a deterministic tie-breaker
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
@@ -484,65 +484,17 @@ class TagFTSIndex:
|
||||
with self._lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
# Build the SQL query with bm25 ranking
|
||||
# FTS5 bm25() returns negative scores, lower is better
|
||||
# We use -bm25() to get higher=better scores
|
||||
# Weights: -100.0 for exact matches, 1.0 for others
|
||||
# Add LOG10(post_count) weighting to boost popular tags
|
||||
# Use CASE to boost tag_name prefix matches above alias matches
|
||||
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]
|
||||
|
||||
sql, params = self._build_search_statement(
|
||||
query_lower=query_lower,
|
||||
fts_query=fts_query,
|
||||
categories=categories,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
cursor = conn.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
for row in rows:
|
||||
result = {
|
||||
"tag_name": row[0],
|
||||
"category": row[1],
|
||||
@@ -571,6 +523,62 @@ class TagFTSIndex:
|
||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||
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(
|
||||
self, query: str, tag_name: str, aliases_str: 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",
|
||||
]
|
||||
@@ -11,6 +11,8 @@ Target structure:
|
||||
│ └── symlink_map.json
|
||||
├── model/
|
||||
│ └── {library_name}.sqlite
|
||||
├── model_update/
|
||||
│ └── {library_name}.sqlite
|
||||
├── recipe/
|
||||
│ └── {library_name}.sqlite
|
||||
└── fts/
|
||||
@@ -36,6 +38,7 @@ class CacheType(Enum):
|
||||
"""Types of cache files managed by the cache path resolver."""
|
||||
|
||||
MODEL = "model"
|
||||
MODEL_UPDATE = "model_update"
|
||||
RECIPE = "recipe"
|
||||
RECIPE_FTS = "recipe_fts"
|
||||
TAG_FTS = "tag_fts"
|
||||
@@ -45,6 +48,7 @@ class CacheType(Enum):
|
||||
# Subdirectory structure for each cache type
|
||||
_CACHE_SUBDIRS = {
|
||||
CacheType.MODEL: "model",
|
||||
CacheType.MODEL_UPDATE: "model_update",
|
||||
CacheType.RECIPE: "recipe",
|
||||
CacheType.RECIPE_FTS: "fts",
|
||||
CacheType.TAG_FTS: "fts",
|
||||
@@ -54,6 +58,7 @@ _CACHE_SUBDIRS = {
|
||||
# Filename patterns for each cache type
|
||||
_CACHE_FILENAMES = {
|
||||
CacheType.MODEL: "{library_name}.sqlite",
|
||||
CacheType.MODEL_UPDATE: "{library_name}.sqlite",
|
||||
CacheType.RECIPE: "{library_name}.sqlite",
|
||||
CacheType.RECIPE_FTS: "recipe_fts.sqlite",
|
||||
CacheType.TAG_FTS: "tag_fts.sqlite",
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
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",)
|
||||
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
||||
"allowNoCredit": True,
|
||||
@@ -17,6 +20,133 @@ _COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
|
||||
_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]:
|
||||
"""Return a normalized list of commercial permissions preserving source values."""
|
||||
|
||||
@@ -199,6 +329,10 @@ def rewrite_preview_url(
|
||||
|
||||
__all__ = [
|
||||
"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_info",
|
||||
"rewrite_preview_url",
|
||||
|
||||
@@ -101,6 +101,7 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
[
|
||||
"ZImageTurbo",
|
||||
"ZImageBase",
|
||||
"Wan Video 1.3B t2v",
|
||||
"Wan Video 14B t2v",
|
||||
"Wan Video 14B i2v 480p",
|
||||
|
||||
136
py/utils/session_logging.py
Normal file
136
py/utils/session_logging.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
_SESSION_HANDLER_NAME = "lora_manager_standalone_session_memory"
|
||||
_FILE_HANDLER_NAME = "lora_manager_standalone_session_file"
|
||||
_session_state: "StandaloneSessionLogState | None" = None
|
||||
_session_lock = threading.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class StandaloneSessionLogState:
|
||||
started_at: str
|
||||
session_id: str
|
||||
log_file_path: str | None
|
||||
memory_handler: "StandaloneSessionMemoryHandler"
|
||||
|
||||
|
||||
class StandaloneSessionMemoryHandler(logging.Handler):
|
||||
def __init__(self, capacity: int = 4000) -> None:
|
||||
super().__init__()
|
||||
self._entries: deque[str] = deque(maxlen=capacity)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
rendered = self.format(record)
|
||||
except Exception:
|
||||
rendered = record.getMessage()
|
||||
|
||||
with self._lock:
|
||||
self._entries.append(rendered)
|
||||
|
||||
def render(self, max_lines: int | None = None) -> str:
|
||||
with self._lock:
|
||||
entries = list(self._entries)
|
||||
|
||||
if max_lines is not None and max_lines > 0:
|
||||
entries = entries[-max_lines:]
|
||||
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
return "\n".join(entries) + "\n"
|
||||
|
||||
|
||||
def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str | None:
|
||||
if not settings_file:
|
||||
return None
|
||||
|
||||
settings_dir = os.path.dirname(os.path.abspath(settings_file))
|
||||
log_dir = os.path.join(settings_dir, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
timestamp = started_at.strftime("%Y%m%dT%H%M%SZ")
|
||||
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
||||
|
||||
|
||||
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
||||
global _session_state
|
||||
|
||||
with _session_lock:
|
||||
if _session_state is not None:
|
||||
return _session_state
|
||||
|
||||
started_dt = datetime.now(timezone.utc)
|
||||
started_at = started_dt.replace(microsecond=0).isoformat()
|
||||
session_id = f"{started_dt.strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
|
||||
formatter = logging.Formatter(LOG_FORMAT)
|
||||
root_logger = logging.getLogger()
|
||||
if root_logger.level > logging.INFO:
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
memory_handler = StandaloneSessionMemoryHandler()
|
||||
memory_handler.set_name(_SESSION_HANDLER_NAME)
|
||||
memory_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(memory_handler)
|
||||
|
||||
log_file_path = _build_log_file_path(settings_file, started_dt)
|
||||
if log_file_path:
|
||||
file_handler = logging.FileHandler(log_file_path, encoding="utf-8")
|
||||
file_handler.set_name(_FILE_HANDLER_NAME)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
_session_state = StandaloneSessionLogState(
|
||||
started_at=started_at,
|
||||
session_id=session_id,
|
||||
log_file_path=log_file_path,
|
||||
memory_handler=memory_handler,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("lora-manager-standalone")
|
||||
logger.info("LoRA Manager standalone startup time: %s", started_at)
|
||||
logger.info("LoRA Manager standalone session id: %s", session_id)
|
||||
if log_file_path:
|
||||
logger.info("LoRA Manager standalone session log path: %s", log_file_path)
|
||||
|
||||
return _session_state
|
||||
|
||||
|
||||
def get_standalone_session_log_snapshot(max_lines: int = 2000) -> dict[str, Any] | None:
|
||||
state = _session_state
|
||||
if state is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"started_at": state.started_at,
|
||||
"session_id": state.session_id,
|
||||
"log_file_path": state.log_file_path,
|
||||
"in_memory_text": state.memory_handler.render(max_lines=max_lines),
|
||||
}
|
||||
|
||||
|
||||
def reset_standalone_session_logging_for_tests() -> None:
|
||||
global _session_state
|
||||
|
||||
with _session_lock:
|
||||
root_logger = logging.getLogger()
|
||||
handlers_to_remove = [
|
||||
handler
|
||||
for handler in root_logger.handlers
|
||||
if handler.get_name() in {_SESSION_HANDLER_NAME, _FILE_HANDLER_NAME}
|
||||
]
|
||||
for handler in handlers_to_remove:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
_session_state = None
|
||||
@@ -29,6 +29,18 @@ if not standalone_mode:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_CHECKPOINT_EXTENSIONS = {
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".safetensors",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
|
||||
class UsageStats:
|
||||
"""Track usage statistics for models and save to JSON"""
|
||||
|
||||
@@ -335,6 +347,55 @@ class UsageStats:
|
||||
|
||||
return None
|
||||
|
||||
async def _find_checkpoint_file_on_disk(self, checkpoint_scanner, model_name: str):
|
||||
"""Search checkpoint roots directly for a matching file.
|
||||
|
||||
This is used when usage tracking sees a checkpoint name before the cache has
|
||||
been refreshed. The lookup is intentionally exact: we only match the model
|
||||
basename and supported checkpoint extensions.
|
||||
"""
|
||||
get_model_roots = getattr(checkpoint_scanner, "get_model_roots", None)
|
||||
if not callable(get_model_roots):
|
||||
return None
|
||||
|
||||
roots = [root for root in get_model_roots() if root]
|
||||
if not roots:
|
||||
return None
|
||||
|
||||
supported_extensions = getattr(
|
||||
checkpoint_scanner, "file_extensions", _DEFAULT_CHECKPOINT_EXTENSIONS
|
||||
)
|
||||
if not isinstance(supported_extensions, (set, frozenset, list, tuple)):
|
||||
supported_extensions = _DEFAULT_CHECKPOINT_EXTENSIONS
|
||||
|
||||
normalized_name = self._normalize_model_lookup_name(model_name)
|
||||
matches: list[str] = []
|
||||
|
||||
for root_path in roots:
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for dirpath, _dirnames, filenames in os.walk(root_path):
|
||||
for filename in filenames:
|
||||
extension = os.path.splitext(filename)[1].lower()
|
||||
if extension not in supported_extensions:
|
||||
continue
|
||||
|
||||
if os.path.splitext(filename)[0] != normalized_name:
|
||||
continue
|
||||
|
||||
matches.append(os.path.join(dirpath, filename).replace(os.sep, "/"))
|
||||
|
||||
if len(matches) > 1:
|
||||
logger.warning(
|
||||
"Multiple checkpoint files matched '%s'; skipping usage tracking: %s",
|
||||
normalized_name,
|
||||
", ".join(matches),
|
||||
)
|
||||
return None
|
||||
|
||||
return matches[0] if matches else None
|
||||
|
||||
async def _resolve_checkpoint_hash(self, checkpoint_scanner, model_name: str):
|
||||
"""Resolve a checkpoint hash, calculating pending hashes on demand when needed."""
|
||||
model_filename = self._normalize_model_lookup_name(model_name)
|
||||
@@ -343,27 +404,49 @@ class UsageStats:
|
||||
return model_hash
|
||||
|
||||
cached_entry = await self._find_cached_checkpoint_entry(checkpoint_scanner, model_name)
|
||||
if not cached_entry:
|
||||
logger.warning(f"No hash found for checkpoint '{model_filename}', skipping usage tracking")
|
||||
return None
|
||||
if cached_entry:
|
||||
cached_hash = cached_entry.get("sha256")
|
||||
if cached_hash:
|
||||
return cached_hash
|
||||
|
||||
cached_hash = cached_entry.get("sha256")
|
||||
if cached_hash:
|
||||
return cached_hash
|
||||
|
||||
if cached_entry.get("hash_status") == "pending":
|
||||
calculate_hash = getattr(checkpoint_scanner, "calculate_hash_for_model", None)
|
||||
file_path = cached_entry.get("file_path")
|
||||
if callable(calculate_hash) and file_path:
|
||||
calculated_hash = await calculate_hash(file_path)
|
||||
if calculated_hash:
|
||||
return calculated_hash
|
||||
hash_status = cached_entry.get("hash_status")
|
||||
if hash_status and hash_status != "pending":
|
||||
logger.warning(
|
||||
f"Failed to calculate pending hash for checkpoint '{model_filename}', skipping usage tracking"
|
||||
"Checkpoint '%s' has hash_status=%s; skipping usage tracking",
|
||||
model_filename,
|
||||
hash_status,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(f"No hash found for checkpoint '{model_filename}', skipping usage tracking")
|
||||
file_path = cached_entry.get("file_path") if cached_entry else None
|
||||
if not file_path:
|
||||
file_path = await self._find_checkpoint_file_on_disk(
|
||||
checkpoint_scanner, model_name
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
logger.warning(
|
||||
f"No hash found for checkpoint '{model_filename}', skipping usage tracking"
|
||||
)
|
||||
return None
|
||||
|
||||
calculate_hash = getattr(checkpoint_scanner, "calculate_hash_for_model", None)
|
||||
if not callable(calculate_hash):
|
||||
logger.warning("Checkpoint scanner not available for usage tracking")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Calculating hash for checkpoint '%s' from %s",
|
||||
model_filename,
|
||||
file_path,
|
||||
)
|
||||
calculated_hash = await calculate_hash(file_path)
|
||||
if calculated_hash:
|
||||
return calculated_hash
|
||||
|
||||
logger.warning(
|
||||
f"Failed to calculate hash for checkpoint '{model_filename}', skipping usage tracking"
|
||||
)
|
||||
return None
|
||||
|
||||
async def _process_checkpoints(self, models_data, today_date):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.0.2"
|
||||
version = "1.0.5"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
@@ -14,7 +14,8 @@ dependencies = [
|
||||
"natsort",
|
||||
"GitPython",
|
||||
"aiosqlite",
|
||||
"platformdirs"
|
||||
"platformdirs",
|
||||
"pyyaml"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -11,3 +11,4 @@ GitPython
|
||||
aiosqlite
|
||||
beautifulsoup4
|
||||
platformdirs
|
||||
pyyaml
|
||||
|
||||
@@ -113,6 +113,8 @@ import asyncio
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
from py.utils.session_logging import setup_standalone_session_logging
|
||||
|
||||
# Increase allowable header size to align with in-ComfyUI configuration.
|
||||
HEADER_SIZE_LIMIT = 16384
|
||||
|
||||
@@ -125,6 +127,8 @@ logger = logging.getLogger("lora-manager-standalone")
|
||||
# Configure aiohttp access logger to be less verbose
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||
|
||||
setup_standalone_session_logging(ensure_settings_file(logger))
|
||||
|
||||
|
||||
# Add specific suppression for connection reset errors
|
||||
class ConnectionResetFilter(logging.Filter):
|
||||
|
||||
@@ -243,3 +243,58 @@
|
||||
-ms-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;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent),
|
||||
@@ -217,6 +229,7 @@
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.versions-tab-version-name {
|
||||
@@ -226,6 +239,27 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
top: var(--header-height, 48px); /* Start below the header */
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(100vh - var(--header-height, 48px)); /* Fallback for browsers without dvh */
|
||||
height: calc(100dvh - var(--header-height, 48px)); /* Adjust height to exclude header */
|
||||
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
|
||||
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
|
||||
z-index: var(--z-modal);
|
||||
overflow: auto; /* Change from hidden to auto to allow scrolling */
|
||||
@@ -24,8 +23,7 @@ body.modal-open {
|
||||
position: relative;
|
||||
max-width: 800px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--header-height, 48px) - 5.5rem); /* Fallback for browsers without dvh */
|
||||
max-height: calc(100dvh - var(--header-height, 48px) - 5.5rem); /* Subtract header height and modal margins */
|
||||
max-height: calc(100vh - var(--header-height, 48px) - 5.5rem); /* Subtract header height and modal margins */
|
||||
margin: 1rem auto; /* Keep reduced top margin */
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-base);
|
||||
@@ -313,6 +311,161 @@ button:disabled,
|
||||
color: var(--lora-error, #ef4444);
|
||||
}
|
||||
|
||||
.backup-status {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-status {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.backup-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.backup-summary-card {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-summary-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.backup-summary-label {
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.backup-summary-value {
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.backup-summary-value.status-enabled {
|
||||
color: var(--lora-success, #10b981);
|
||||
}
|
||||
|
||||
.backup-summary-value.status-disabled {
|
||||
color: var(--lora-error, #ef4444);
|
||||
}
|
||||
|
||||
.backup-status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.backup-status-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 180px) 1fr;
|
||||
gap: var(--space-2);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.backup-status-label {
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.backup-status-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-status-primary {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.backup-status-secondary {
|
||||
color: var(--text-color);
|
||||
opacity: 0.72;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.backup-location-details {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-location-details {
|
||||
border-color: var(--lora-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.backup-location-details summary {
|
||||
cursor: pointer;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.backup-location-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.backup-location-panel .text-btn {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.backup-location-path {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--text-color);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-location-path {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.backup-status-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-location-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-location-panel .text-btn {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add styles for delete preview image */
|
||||
.delete-preview {
|
||||
max-width: 150px;
|
||||
|
||||
281
static/css/components/modal/doctor-modal.css
Normal file
281
static/css/components/modal/doctor-modal.css
Normal file
@@ -0,0 +1,281 @@
|
||||
.doctor-trigger {
|
||||
min-width: 120px;
|
||||
position: relative;
|
||||
border-color: color-mix(in srgb, var(--lora-accent) 24%, var(--border-color));
|
||||
}
|
||||
|
||||
.doctor-trigger i {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.doctor-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--lora-error);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doctor-status-badge.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doctor-modal {
|
||||
width: min(960px, 92vw);
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.doctor-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.doctor-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--lora-border);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.doctor-kicker {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--lora-accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.doctor-hero h2 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.doctor-hero p {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.doctor-summary-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.doctor-status-ok {
|
||||
background: color-mix(in oklch, var(--lora-success) 14%, transparent);
|
||||
border-color: color-mix(in oklch, var(--lora-success) 28%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-success) 72%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-status-warning {
|
||||
background: color-mix(in oklch, var(--lora-warning) 16%, transparent);
|
||||
border-color: color-mix(in oklch, var(--lora-warning) 30%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-warning) 70%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-status-error {
|
||||
background: color-mix(in oklch, var(--lora-error) 16%, transparent);
|
||||
border-color: color-mix(in oklch, var(--lora-error) 30%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-error) 68%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-loading-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
min-height: 22px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, visibility 0.18s ease;
|
||||
}
|
||||
|
||||
.doctor-loading-state.visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.doctor-issues-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.doctor-issue-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="warning"] {
|
||||
border-color: color-mix(in oklch, var(--lora-warning) 32%, var(--lora-border));
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="error"] {
|
||||
border-color: color-mix(in oklch, var(--lora-error) 28%, var(--lora-border));
|
||||
}
|
||||
|
||||
.doctor-issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.doctor-issue-header h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.doctor-issue-summary {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.doctor-issue-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="ok"] .doctor-issue-tag {
|
||||
background: color-mix(in oklch, var(--lora-success) 14%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-success) 72%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="warning"] .doctor-issue-tag {
|
||||
background: color-mix(in oklch, var(--lora-warning) 16%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-warning) 70%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="error"] .doctor-issue-tag {
|
||||
background: color-mix(in oklch, var(--lora-error) 16%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-error) 68%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-issue-details {
|
||||
margin: 14px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.doctor-issue-details li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.doctor-issue-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.doctor-inline-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.doctor-inline-detail {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.doctor-inline-detail strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.doctor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.doctor-footer-note {
|
||||
color: var(--text-color);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.doctor-footer-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .doctor-hero,
|
||||
[data-theme="dark"] .doctor-issue-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: var(--lora-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.doctor-trigger {
|
||||
min-width: auto;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.doctor-trigger span:not(.doctor-status-badge) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doctor-hero,
|
||||
.doctor-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.doctor-hero {
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
.doctor-summary-badge {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.doctor-footer-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.doctor-footer-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,13 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@supports not (scrollbar-gutter: stable) {
|
||||
.settings-content {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content .settings-form {
|
||||
|
||||
@@ -214,15 +214,11 @@
|
||||
#recipeModal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 960px;
|
||||
width: min(960px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - var(--header-height, 48px) - 2rem);
|
||||
max-height: calc(100dvh - var(--header-height, 48px) - 2rem);
|
||||
}
|
||||
|
||||
#recipeModal .modal-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -236,14 +232,12 @@
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Recipe Preview */
|
||||
.recipe-preview-container {
|
||||
width: 100%;
|
||||
height: clamp(220px, 32vh, 360px);
|
||||
height: clamp(220px, 32dvh, 360px);
|
||||
height: 360px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
@@ -397,8 +391,7 @@
|
||||
|
||||
/* Generation Parameters */
|
||||
.recipe-gen-params {
|
||||
height: clamp(220px, 32vh, 360px);
|
||||
height: clamp(220px, 32dvh, 360px);
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -672,7 +665,6 @@
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recipe-checkpoint-container {
|
||||
@@ -1044,36 +1036,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 960px) {
|
||||
@media (max-height: 860px) {
|
||||
#recipeModal .modal-content {
|
||||
max-height: calc(100vh - var(--header-height, 48px) - 1rem);
|
||||
max-height: calc(100dvh - var(--header-height, 48px) - 1rem);
|
||||
padding: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-modal-header {
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recipe-modal-header h2 {
|
||||
max-height: 1.3em;
|
||||
-webkit-line-clamp: 1;
|
||||
font-size: 1.25em;
|
||||
max-height: 2.5em;
|
||||
}
|
||||
|
||||
.recipe-tags-container {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recipe-preview-container,
|
||||
.recipe-gen-params {
|
||||
height: clamp(180px, 28vh, 300px);
|
||||
height: clamp(180px, 28dvh, 300px);
|
||||
height: auto;
|
||||
max-height: 210px;
|
||||
}
|
||||
|
||||
.recipe-gen-params h3 {
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.gen-params-container {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.param-content {
|
||||
max-height: 90px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.param-textarea {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.other-params {
|
||||
margin-top: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recipe-bottom-section {
|
||||
@@ -1085,19 +1103,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 780px) {
|
||||
.recipe-preview-container,
|
||||
.recipe-gen-params {
|
||||
height: clamp(160px, 24vh, 260px);
|
||||
height: clamp(160px, 24dvh, 260px);
|
||||
}
|
||||
|
||||
.recipe-modal-header {
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -21,6 +21,27 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
@@ -108,4 +129,4 @@
|
||||
color: var(--lora-error);
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
@import 'components/modal/delete-modal.css';
|
||||
@import 'components/modal/update-modal.css';
|
||||
@import 'components/modal/settings-modal.css';
|
||||
@import 'components/modal/doctor-modal.css';
|
||||
@import 'components/modal/help-modal.css';
|
||||
@import 'components/modal/relink-civitai-modal.css';
|
||||
@import 'components/modal/example-access-modal.css';
|
||||
|
||||
@@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) {
|
||||
return {
|
||||
// Base CRUD operations
|
||||
list: `/api/lm/${modelType}/list`,
|
||||
excluded: `/api/lm/${modelType}/excluded`,
|
||||
delete: `/api/lm/${modelType}/delete`,
|
||||
exclude: `/api/lm/${modelType}/exclude`,
|
||||
unexclude: `/api/lm/${modelType}/unexclude`,
|
||||
rename: `/api/lm/${modelType}/rename`,
|
||||
save: `/api/lm/${modelType}/save-metadata`,
|
||||
cancelTask: `/api/lm/${modelType}/cancel-task`,
|
||||
|
||||
@@ -51,6 +51,7 @@ export class BaseModelApiClient {
|
||||
async fetchModelsPage(page = 1, pageSize = null) {
|
||||
const pageState = this.getPageState();
|
||||
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
|
||||
try {
|
||||
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) {
|
||||
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export class BaseModelApiClient {
|
||||
totalPages: data.total_pages,
|
||||
currentPage: page,
|
||||
hasMore: page < data.total_pages,
|
||||
folders: data.folders
|
||||
folders: data.folders || []
|
||||
};
|
||||
|
||||
} 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) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
||||
@@ -883,20 +931,21 @@ export class BaseModelApiClient {
|
||||
|
||||
_buildQueryParams(baseParams, pageState) {
|
||||
const params = new URLSearchParams(baseParams);
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
|
||||
if (pageState.activeFolder !== null) {
|
||||
if (!isExcludedView && pageState.activeFolder !== null) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
|
||||
if (pageState.showFavoritesOnly) {
|
||||
if (!isExcludedView && pageState.showFavoritesOnly) {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
if (pageState.showUpdateAvailableOnly) {
|
||||
if (!isExcludedView && pageState.showUpdateAvailableOnly) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -918,7 +967,7 @@ export class BaseModelApiClient {
|
||||
|
||||
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) {
|
||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||
if (state === 'include') {
|
||||
@@ -981,7 +1030,9 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
if (!isExcludedView) {
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RecipeCard } from '../components/RecipeCard.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
|
||||
|
||||
const RECIPE_ENDPOINTS = {
|
||||
list: '/api/lm/recipes',
|
||||
@@ -31,6 +32,20 @@ export function extractRecipeId(filePath) {
|
||||
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
|
||||
* @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 (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||
// 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) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
@@ -166,10 +183,12 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
fetchPageFunction,
|
||||
preserveScroll = false
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||
|
||||
try {
|
||||
pageState.isLoading = true;
|
||||
@@ -191,6 +210,10 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page will be 2
|
||||
|
||||
if (scrollSnapshot) {
|
||||
await restoreScrollPosition(scrollSnapshot);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading ${modelType}s:`, error);
|
||||
@@ -211,10 +234,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
modelType = 'lora',
|
||||
resetPage = false,
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
fetchPageFunction,
|
||||
preserveScroll = false
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||
|
||||
try {
|
||||
// Start loading state
|
||||
@@ -239,6 +264,10 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page to load would be 2
|
||||
|
||||
if (scrollSnapshot) {
|
||||
await restoreScrollPosition(scrollSnapshot);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (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
|
||||
* @returns {Promise<Object>} The fetch result
|
||||
*/
|
||||
export async function resetAndReload(updateFolders = false) {
|
||||
export async function resetAndReload(updateFolders = false, options = {}) {
|
||||
return resetAndReloadWithVirtualScroll({
|
||||
modelType: 'recipe',
|
||||
updateFolders,
|
||||
fetchPageFunction: fetchRecipesPage
|
||||
fetchPageFunction: fetchRecipesPage,
|
||||
preserveScroll: options.preserveScroll === true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,7 +300,7 @@ export async function syncChanges() {
|
||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
||||
|
||||
// Simply reload the recipes without rebuilding cache
|
||||
await resetAndReload();
|
||||
await resetAndReload(false, { preserveScroll: true });
|
||||
|
||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
@@ -298,7 +328,7 @@ export async function refreshRecipes() {
|
||||
}
|
||||
|
||||
// After successful cache rebuild, reload the recipes
|
||||
await resetAndReload();
|
||||
await resetAndReload(false, { preserveScroll: true });
|
||||
|
||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
@@ -349,9 +379,10 @@ export function createRecipeCard(recipe) {
|
||||
* @param {Object} updates - The metadata updates to apply
|
||||
* @returns {Promise<Object>} The updated recipe data
|
||||
*/
|
||||
export async function updateRecipeMetadata(filePath, updates) {
|
||||
export async function updateRecipeMetadata(filePath, updates, options = {}) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
const listFilePath = options.listFilePath || filePath;
|
||||
|
||||
// Extract recipeId from filePath (basename without extension)
|
||||
const recipeId = extractRecipeId(filePath);
|
||||
@@ -359,7 +390,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -374,7 +405,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
throw new Error(data.error || 'Failed to update recipe');
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, updates);
|
||||
state.virtualScroller.updateSingleItem(listFilePath, updates);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
|
||||
// Update the "Move to other root" label based on current model type
|
||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
||||
@@ -83,6 +84,9 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
async saveModelMetadata(filePath, data) {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action) {
|
||||
// First try to handle with common actions
|
||||
@@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
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 downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||
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"]');
|
||||
|
||||
if (isRecipesPage) {
|
||||
@@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
licenseRefreshItem?.classList.add('hidden');
|
||||
downloadExamplesItem?.classList.add('hidden');
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
licenseRefreshItem?.classList.remove('hidden');
|
||||
downloadExamplesItem?.classList.remove('hidden');
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to repair recipes:', error);
|
||||
});
|
||||
break;
|
||||
case 'manage-excluded-models':
|
||||
this.manageExcludedModels();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
manageExcludedModels() {
|
||||
window.pageControls?.enterExcludedView?.().catch((error) => {
|
||||
console.error('Failed to open excluded models view:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadExampleImages(menuItem) {
|
||||
const downloadPath = state?.global?.settings?.example_images_path;
|
||||
if (!downloadPath) {
|
||||
|
||||
@@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
// First try to handle with common actions
|
||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||
@@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
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 { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
||||
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
|
||||
|
||||
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
||||
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
|
||||
initNSFWSelector() {
|
||||
if (this._nsfwSelectorInitialized) {
|
||||
@@ -154,25 +188,7 @@ export const ModelContextMenuMixin = {
|
||||
},
|
||||
|
||||
extractModelVersionId(url) {
|
||||
try {
|
||||
// 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 };
|
||||
}
|
||||
return extractCivitaiModelUrlParts(url);
|
||||
},
|
||||
|
||||
parseModelId(value) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Override resetAndReload for recipe context
|
||||
async resetAndReload() {
|
||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||
return resetAndReload();
|
||||
return resetAndReload(false, { preserveScroll: true });
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
|
||||
@@ -3,16 +3,105 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.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 { 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 {
|
||||
constructor() {
|
||||
this.promptEditorState = {};
|
||||
this.recipeHydrationRequestId = 0;
|
||||
this.resetLocalEditState();
|
||||
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() {
|
||||
this.setupCopyButtons();
|
||||
this.setupPromptEditors();
|
||||
@@ -87,8 +176,10 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
showRecipeDetails(recipe) {
|
||||
const hydratedRecipe = recipe || {};
|
||||
this.resetLocalEditState();
|
||||
// Store the full recipe for editing
|
||||
this.currentRecipe = recipe;
|
||||
this.currentRecipe = hydratedRecipe;
|
||||
this.resetPromptEditors();
|
||||
|
||||
// Set modal title with edit icon
|
||||
@@ -96,11 +187,11 @@ class RecipeModal {
|
||||
if (modalTitle) {
|
||||
modalTitle.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
`;
|
||||
|
||||
@@ -122,8 +213,9 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
// Store the recipe ID for copy syntax API call
|
||||
this.recipeId = recipe.id;
|
||||
this.filePath = recipe.file_path;
|
||||
this.recipeId = hydratedRecipe.id;
|
||||
this.filePath = hydratedRecipe.file_path;
|
||||
this.listFilePath = hydratedRecipe.file_path;
|
||||
|
||||
// Set recipe tags if they exist
|
||||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
||||
@@ -143,11 +235,11 @@ class RecipeModal {
|
||||
|
||||
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
|
||||
const maxVisibleTags = 5;
|
||||
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
|
||||
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
|
||||
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
|
||||
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
|
||||
|
||||
// Add visible tags
|
||||
visibleTags.forEach(tag => {
|
||||
@@ -184,7 +276,7 @@ class RecipeModal {
|
||||
// Add all tags to tooltip
|
||||
if (tagsTooltipContent) {
|
||||
tagsTooltipContent.innerHTML = '';
|
||||
recipe.tags.forEach(tag => {
|
||||
hydratedRecipe.tags.forEach(tag => {
|
||||
const tooltipTag = document.createElement('div');
|
||||
tooltipTag.className = 'tooltip-tag';
|
||||
tooltipTag.textContent = tag;
|
||||
@@ -201,8 +293,8 @@ class RecipeModal {
|
||||
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
||||
|
||||
// Set current tags in the input
|
||||
if (recipe.tags && recipe.tags.length > 0) {
|
||||
tagsInput.value = recipe.tags.join(', ');
|
||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
||||
tagsInput.value = hydratedRecipe.tags.join(', ');
|
||||
}
|
||||
|
||||
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
||||
@@ -222,49 +314,15 @@ class RecipeModal {
|
||||
// Set recipe image
|
||||
const mediaContainer = document.getElementById('recipePreviewContainer');
|
||||
if (mediaContainer) {
|
||||
// Stop any playing video before replacing content
|
||||
const existingVideo = mediaContainer.querySelector('video');
|
||||
if (existingVideo) {
|
||||
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);
|
||||
}
|
||||
this.syncPreviewMedia(hydratedRecipe);
|
||||
mediaContainer.querySelector('.source-url-container')?.remove();
|
||||
mediaContainer.querySelector('.source-url-editor')?.remove();
|
||||
|
||||
// Add source URL container if the recipe has a source_path
|
||||
const sourceUrlContainer = document.createElement('div');
|
||||
sourceUrlContainer.className = 'source-url-container';
|
||||
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0;
|
||||
const sourceUrl = hasSourceUrl ? recipe.source_path : '';
|
||||
const hasSourceUrl = hydratedRecipe.source_path && hydratedRecipe.source_path.trim().length > 0;
|
||||
const sourceUrl = hasSourceUrl ? hydratedRecipe.source_path : '';
|
||||
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
|
||||
|
||||
sourceUrlContainer.innerHTML = `
|
||||
@@ -293,40 +351,273 @@ class RecipeModal {
|
||||
mediaContainer.appendChild(sourceUrlContainer);
|
||||
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(() => {
|
||||
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
|
||||
return;
|
||||
}
|
||||
this.setupSourceUrlHandlers();
|
||||
}, 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 negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||||
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||||
const promptInput = document.getElementById('recipePromptInput');
|
||||
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
|
||||
const promptFieldsOnly = options.promptFieldsOnly === true;
|
||||
const sanitizedGenParams = this.sanitizeGenParams(genParams);
|
||||
|
||||
if (recipe.gen_params) {
|
||||
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
|
||||
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
|
||||
if (sanitizedGenParams) {
|
||||
if (!promptFieldsOnly) {
|
||||
this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available');
|
||||
this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available');
|
||||
|
||||
if (promptInput) {
|
||||
promptInput.value = recipe.gen_params.prompt || '';
|
||||
if (promptInput) {
|
||||
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) {
|
||||
// Clear previous params
|
||||
otherParamsElement.innerHTML = '';
|
||||
|
||||
// Add all other parameters except prompt and 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) {
|
||||
const paramTag = document.createElement('div');
|
||||
paramTag.className = 'param-tag';
|
||||
@@ -338,22 +629,68 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
// If no other params, show a message
|
||||
if (otherParamsElement.children.length === 0) {
|
||||
otherParamsElement.innerHTML = '<div class="no-params">No additional parameters available</div>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No generation parameters available
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptFieldsOnly) {
|
||||
this.renderPromptContent(promptElement, '', 'No prompt information available');
|
||||
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
|
||||
if (promptInput) promptInput.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 resourceDivider = document.getElementById('recipeResourceDivider');
|
||||
const lorasListElement = document.getElementById('recipeLorasList');
|
||||
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||||
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
|
||||
|
||||
if (checkpointContainer) {
|
||||
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 missingLorasCount = 0;
|
||||
let deletedLorasCount = 0;
|
||||
|
||||
if (recipe.loras && recipe.loras.length > 0) {
|
||||
recipe.loras.forEach(lora => {
|
||||
if (lora.isDeleted) {
|
||||
deletedLorasCount++;
|
||||
} else if (!lora.inLibrary) {
|
||||
allLorasAvailable = false;
|
||||
missingLorasCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
loras.forEach(lora => {
|
||||
if (lora.isDeleted) {
|
||||
deletedLorasCount++;
|
||||
} else if (!lora.inLibrary) {
|
||||
allLorasAvailable = false;
|
||||
missingLorasCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Set LoRAs count and status
|
||||
if (lorasCountElement && recipe.loras) {
|
||||
const totalCount = recipe.loras.length;
|
||||
|
||||
// Create status indicator based on LoRA states
|
||||
if (lorasCountElement) {
|
||||
const totalCount = loras.length;
|
||||
let statusHTML = '';
|
||||
if (totalCount > 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>`;
|
||||
} else if (missingLorasCount > 0) {
|
||||
// Some LoRAs are missing (prioritize showing missing over deleted)
|
||||
statusHTML = `<div class="recipe-status missing">
|
||||
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
|
||||
<div class="missing-tooltip">Click to download missing LoRAs</div>
|
||||
</div>`;
|
||||
} 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>`;
|
||||
}
|
||||
}
|
||||
|
||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||
|
||||
// Add event listeners for buttons and status indicators
|
||||
setTimeout(() => {
|
||||
// Set up click handler for View LoRAs button
|
||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||
if (viewRecipeLorasBtn) {
|
||||
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
||||
}
|
||||
|
||||
// Add click handler for missing LoRAs status
|
||||
const missingStatus = document.querySelector('.recipe-status.missing');
|
||||
if (missingStatus && missingLorasCount > 0) {
|
||||
missingStatus.classList.add('clickable');
|
||||
@@ -425,13 +746,12 @@ class RecipeModal {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
||||
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
||||
if (lorasListElement && loras.length > 0) {
|
||||
lorasListElement.innerHTML = loras.map(lora => {
|
||||
const existsLocally = lora.inLibrary;
|
||||
const isDeleted = lora.isDeleted;
|
||||
const localPath = lora.localPath || '';
|
||||
|
||||
// Create status badge based on LoRA state
|
||||
let localStatus;
|
||||
if (existsLocally) {
|
||||
localStatus = `
|
||||
@@ -441,7 +761,7 @@ class RecipeModal {
|
||||
</div>`;
|
||||
} else if (isDeleted) {
|
||||
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>
|
||||
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
||||
</div>`;
|
||||
@@ -452,7 +772,6 @@ class RecipeModal {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Check if preview is a video
|
||||
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
||||
const previewMedia = isPreviewVideo ?
|
||||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||
@@ -460,7 +779,6 @@ class RecipeModal {
|
||||
</video>` :
|
||||
`<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';
|
||||
if (existsLocally) {
|
||||
loraItemClass += ' exists-locally';
|
||||
@@ -471,7 +789,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
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">
|
||||
${previewMedia}
|
||||
</div>
|
||||
@@ -485,7 +803,7 @@ class RecipeModal {
|
||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</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">
|
||||
<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>
|
||||
@@ -503,15 +821,12 @@ class RecipeModal {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners for reconnect functionality
|
||||
setTimeout(() => {
|
||||
this.setupReconnectButtons();
|
||||
this.setupLoraItemsClickable();
|
||||
}, 100);
|
||||
|
||||
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
|
||||
this.recipeLorasSyntax = '';
|
||||
|
||||
} else if (lorasListElement) {
|
||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||
this.recipeLorasSyntax = '';
|
||||
@@ -522,9 +837,31 @@ class RecipeModal {
|
||||
const hasLoraItems = lorasListElement && lorasListElement.querySelector('.recipe-lora-item');
|
||||
resourceDivider.style.display = hasCheckpoint && hasLoraItems ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
modalManager.showModal('recipeModal');
|
||||
updateSourceUrlDisplay(sourcePath, options = {}) {
|
||||
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
|
||||
@@ -535,6 +872,7 @@ class RecipeModal {
|
||||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||
editor.classList.add('active');
|
||||
const input = editor.querySelector('input');
|
||||
input.oninput = () => this.markFieldDirty('title');
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
@@ -553,19 +891,23 @@ class RecipeModal {
|
||||
titleContainer.querySelector('.content-text').textContent = newTitle;
|
||||
|
||||
// Update the recipe on the server
|
||||
updateRecipeMetadata(this.filePath, { title: newTitle })
|
||||
updateRecipeMetadata(this.filePath, { title: newTitle }, this.getMetadataUpdateOptions())
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('toast.recipes.nameUpdated', {}, 'success');
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.title = newTitle;
|
||||
this.commitField('title');
|
||||
})
|
||||
.catch(error => {
|
||||
// Error is handled in the API function
|
||||
// Reset the UI if needed
|
||||
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
|
||||
this.clearFieldDirty('title');
|
||||
});
|
||||
} else {
|
||||
this.clearFieldDirty('title');
|
||||
}
|
||||
|
||||
// Hide editor
|
||||
@@ -581,6 +923,7 @@ class RecipeModal {
|
||||
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||
const input = editor.querySelector('input');
|
||||
input.value = this.currentRecipe.title || '';
|
||||
this.clearFieldDirty('title');
|
||||
|
||||
// Hide editor
|
||||
editor.classList.remove('active');
|
||||
@@ -596,6 +939,7 @@ class RecipeModal {
|
||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||
editor.classList.add('active');
|
||||
const input = editor.querySelector('input');
|
||||
input.oninput = () => this.markFieldDirty('tags');
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
@@ -623,20 +967,24 @@ class RecipeModal {
|
||||
|
||||
if (tagsChanged) {
|
||||
// Update the recipe on the server
|
||||
updateRecipeMetadata(this.filePath, { tags: newTags })
|
||||
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.tags = newTags;
|
||||
this.commitField('tags');
|
||||
|
||||
// Update tags in the UI
|
||||
this.updateTagsDisplay(tagsContainer, newTags);
|
||||
})
|
||||
.catch(error => {
|
||||
// Error is handled in the API function
|
||||
this.clearFieldDirty('tags');
|
||||
});
|
||||
} else {
|
||||
this.clearFieldDirty('tags');
|
||||
}
|
||||
|
||||
// Hide editor
|
||||
@@ -708,6 +1056,7 @@ class RecipeModal {
|
||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||
const input = editor.querySelector('input');
|
||||
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
||||
this.clearFieldDirty('tags');
|
||||
|
||||
// Hide editor
|
||||
editor.classList.remove('active');
|
||||
@@ -748,6 +1097,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.addEventListener('input', () => this.markFieldDirty(config.field));
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
@@ -840,9 +1190,10 @@ class RecipeModal {
|
||||
|
||||
const currentGenParams = this.currentRecipe.gen_params || {};
|
||||
const nextValue = input.value.trim() === '' ? '' : input.value;
|
||||
const currentValue = currentGenParams[config.field] || '';
|
||||
const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || '';
|
||||
|
||||
if (nextValue === currentValue) {
|
||||
this.clearFieldDirty(config.field);
|
||||
this.hidePromptEditor(config);
|
||||
return;
|
||||
}
|
||||
@@ -857,14 +1208,17 @@ class RecipeModal {
|
||||
...promptState,
|
||||
isSaving: true,
|
||||
};
|
||||
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
|
||||
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams }, this.getMetadataUpdateOptions());
|
||||
this.currentRecipe.gen_params = nextGenParams;
|
||||
this.renderPromptContent(content, nextValue, config.placeholder);
|
||||
showToast(config.successKey, {}, 'success', config.successFallback);
|
||||
this.commitField(config.field);
|
||||
} catch (error) {
|
||||
this.renderPromptContent(content, currentValue, config.placeholder);
|
||||
input.value = currentValue;
|
||||
this.clearFieldDirty(config.field);
|
||||
} finally {
|
||||
this.clearFieldDirty(config.field);
|
||||
this.hidePromptEditor(config);
|
||||
}
|
||||
}
|
||||
@@ -872,10 +1226,10 @@ class RecipeModal {
|
||||
cancelPromptEdit(config) {
|
||||
const input = document.getElementById(config.inputId);
|
||||
if (input) {
|
||||
const initialValue = this.promptEditorState[config.field]?.initialValue;
|
||||
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
|
||||
input.value = this.currentRecipe?.gen_params?.[config.field] || '';
|
||||
}
|
||||
|
||||
this.clearFieldDirty(config.field);
|
||||
this.hidePromptEditor(config);
|
||||
}
|
||||
|
||||
@@ -918,11 +1272,16 @@ class RecipeModal {
|
||||
sourceUrlInput.focus();
|
||||
});
|
||||
|
||||
sourceUrlInput.addEventListener('input', () => {
|
||||
this.sourceUrlEditState.isDirty = true;
|
||||
});
|
||||
|
||||
// Cancel editing
|
||||
sourceUrlCancelBtn.addEventListener('click', () => {
|
||||
sourceUrlEditor.classList.remove('active');
|
||||
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
|
||||
@@ -930,23 +1289,24 @@ class RecipeModal {
|
||||
const newSourceUrl = sourceUrlInput.value.trim();
|
||||
if (newSourceUrl !== this.currentRecipe.source_path) {
|
||||
// Update the recipe on the server
|
||||
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
|
||||
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl }, this.getMetadataUpdateOptions())
|
||||
.then(data => {
|
||||
// Show success toast
|
||||
showToast('toast.recipes.sourceUrlUpdated', {}, 'success');
|
||||
|
||||
// Update source URL in the UI
|
||||
sourceUrlText.textContent = newSourceUrl || 'No source URL';
|
||||
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') ||
|
||||
newSourceUrl.startsWith('https://')) ?
|
||||
'Click to open source URL' : 'No valid URL';
|
||||
this.commitField('source_path');
|
||||
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.source_path = newSourceUrl;
|
||||
})
|
||||
.catch(error => {
|
||||
// Error is handled in the API function
|
||||
this.clearFieldDirty('source_path');
|
||||
});
|
||||
} else {
|
||||
this.clearFieldDirty('source_path');
|
||||
}
|
||||
|
||||
// Hide editor
|
||||
@@ -1286,7 +1646,7 @@ class RecipeModal {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
}, 500);
|
||||
|
||||
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, {
|
||||
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
|
||||
loras: this.currentRecipe.loras
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.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 { sidebarManager } from '../SidebarManager.js';
|
||||
|
||||
@@ -38,8 +38,12 @@ export class PageControls {
|
||||
|
||||
// Initialize favorites filter button state
|
||||
this.initFavoritesFilter();
|
||||
|
||||
this.initExcludedViewControls();
|
||||
this.syncExcludedViewState();
|
||||
|
||||
console.log(`PageControls initialized for ${pageType} page`);
|
||||
window.pageControls = this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +60,19 @@ export class PageControls {
|
||||
|
||||
// Load sort preference
|
||||
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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +133,15 @@ export class PageControls {
|
||||
// Page-specific event listeners
|
||||
this.initPageSpecificListeners();
|
||||
}
|
||||
|
||||
initExcludedViewControls() {
|
||||
const backButton = document.getElementById('excludedViewBackBtn');
|
||||
if (backButton) {
|
||||
backButton.addEventListener('click', async () => {
|
||||
await this.exitExcludedView();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dropdown functionality
|
||||
@@ -334,6 +360,13 @@ export class PageControls {
|
||||
* @param {string} sortValue - The sort value to save
|
||||
*/
|
||||
saveSortPreference(sortValue) {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
this.pageState.excludedViewState = {
|
||||
...(this.pageState.excludedViewState || {}),
|
||||
sortBy: sortValue,
|
||||
};
|
||||
return;
|
||||
}
|
||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
||||
}
|
||||
|
||||
@@ -353,18 +386,8 @@ export class PageControls {
|
||||
const metaData = JSON.parse(card.dataset.meta);
|
||||
const civitaiId = metaData.modelId;
|
||||
const versionId = metaData.id;
|
||||
|
||||
// Build URL
|
||||
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');
|
||||
}
|
||||
|
||||
openCivitaiByMetadata(civitaiId, versionId, modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,6 +506,8 @@ export class PageControls {
|
||||
// Update app state
|
||||
this.pageState.showFavoritesOnly = showFavoritesOnly;
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,12 +524,17 @@ export class PageControls {
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorites-only filter and reload models
|
||||
*/
|
||||
async toggleFavoritesOnly() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
|
||||
// Toggle the filter state in storage
|
||||
@@ -531,6 +561,9 @@ export class PageControls {
|
||||
* Toggle update-available-only filter and reload models
|
||||
*/
|
||||
async toggleUpdateAvailableOnly() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
const storageKey = `show_update_available_only_${this.pageType}`;
|
||||
const newState = !this.pageState.showUpdateAvailableOnly;
|
||||
@@ -545,6 +578,234 @@ export class PageControls {
|
||||
|
||||
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
|
||||
|
||||
@@ -433,10 +433,11 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.usage_count = String(model.usage_count);
|
||||
card.dataset.notes = model.notes || '';
|
||||
card.dataset.base_model = model.base_model || 'Unknown';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
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.
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -487,6 +488,9 @@ export function createModelCard(model, modelType) {
|
||||
if (model.skip_metadata_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)
|
||||
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>
|
||||
</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 class="card-actions">
|
||||
${actionIcons}
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.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 { state } from '../../state/index.js';
|
||||
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from './utils.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||
|
||||
function buildCivitaiVersionUrl(modelId, versionId) {
|
||||
if (modelId == null || versionId == null) {
|
||||
return null;
|
||||
}
|
||||
const normalizedModelId = String(modelId).trim();
|
||||
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}`;
|
||||
return buildCivitaiModelUrl(
|
||||
modelId,
|
||||
versionId,
|
||||
state?.global?.settings?.civitai_host
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -220,8 +215,31 @@ function buildMetaMarkup(version, options = {}) {
|
||||
.join('<span class="version-meta-separator">•</span>');
|
||||
}
|
||||
|
||||
function buildBadge(label, tone) {
|
||||
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
||||
function buildBadge(label, tone, options = {}) {
|
||||
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({
|
||||
@@ -426,23 +444,72 @@ function renderRow(version, options) {
|
||||
version.versionId > latestLibraryVersionId;
|
||||
const isEarlyAccess = isEarlyAccessActive(version);
|
||||
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) {
|
||||
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) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success'));
|
||||
} else if (isNewer && !version.shouldIgnore) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
|
||||
badges.push(buildBadge(inLibraryBadgeLabel, 'success', {
|
||||
title: translate(
|
||||
'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) {
|
||||
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) {
|
||||
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');
|
||||
@@ -459,29 +526,82 @@ function renderRow(version, options) {
|
||||
if (!version.isInLibrary) {
|
||||
// Download button with optional EA bolt icon
|
||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||
actions.push(
|
||||
`<button class="version-action version-action-primary" data-version-action="download">${downloadIcon}${escapeHtml(downloadLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
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) {
|
||||
actions.push(
|
||||
`<button class="version-action version-action-danger" data-version-action="delete">${escapeHtml(deleteLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
deleteLabel,
|
||||
'version-action-danger',
|
||||
'delete',
|
||||
{
|
||||
title: translate(
|
||||
'modals.model.versions.actions.deleteTooltip',
|
||||
{},
|
||||
'Delete this local version'
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
actions.push(
|
||||
`<button class="version-action version-action-ghost" data-version-action="toggle-ignore" data-ignore-state="${
|
||||
version.shouldIgnore ? 'ignored' : 'active'
|
||||
}">${escapeHtml(ignoreLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
ignoreLabel,
|
||||
'version-action-ghost',
|
||||
'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(
|
||||
version.modelId || parentModelId,
|
||||
version.versionId
|
||||
);
|
||||
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 = [
|
||||
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
|
||||
@@ -489,7 +609,6 @@ function renderRow(version, options) {
|
||||
];
|
||||
if (linkTarget) {
|
||||
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
|
||||
rowAttributes.push(`title="${escapeHtml(civitaiTooltip)}"`);
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -497,7 +616,8 @@ function renderRow(version, options) {
|
||||
${renderMediaMarkup(version)}
|
||||
<div class="version-details">
|
||||
<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 class="version-badges">${badges.join('')}</div>
|
||||
<div class="version-meta">
|
||||
@@ -1227,9 +1347,17 @@ export function initVersionsTab({
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
@@ -1245,7 +1373,7 @@ export function initVersionsTab({
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
||||
openCivitaiUrl(targetUrl);
|
||||
});
|
||||
|
||||
// Listen for extension-triggered refresh requests
|
||||
|
||||
@@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js';
|
||||
import { bulkManager } from './managers/BulkManager.js';
|
||||
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||
import { helpManager } from './managers/HelpManager.js';
|
||||
import { doctorManager } from './managers/DoctorManager.js';
|
||||
import { bannerService } from './managers/BannerService.js';
|
||||
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
@@ -58,6 +59,7 @@ export class AppCore {
|
||||
const exampleImagesManager = new ExampleImagesManager();
|
||||
window.exampleImagesManager = exampleImagesManager;
|
||||
window.helpManager = helpManager;
|
||||
window.doctorManager = doctorManager;
|
||||
window.moveManager = moveManager;
|
||||
window.bulkManager = bulkManager;
|
||||
|
||||
@@ -77,6 +79,7 @@ export class AppCore {
|
||||
exampleImagesManager.initialize();
|
||||
// Initialize the help manager
|
||||
helpManager.initialize();
|
||||
doctorManager.initialize();
|
||||
|
||||
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
||||
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
||||
|
||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
||||
|
||||
// Refresh recipes list to show newly imported recipes
|
||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||
window.recipeManager.loadRecipes();
|
||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
}
|
||||
|
||||
// Show results step
|
||||
|
||||
@@ -311,7 +311,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
// Refresh the recipes list to update LoRA status
|
||||
if (window.recipeManager) {
|
||||
window.recipeManager.loadRecipes();
|
||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
397
static/js/managers/DoctorManager.js
Normal file
397
static/js/managers/DoctorManager.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { escapeHtml } from '../components/shared/utils.js';
|
||||
|
||||
const MAX_CONSOLE_ENTRIES = 200;
|
||||
|
||||
function stringifyConsoleArg(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export class DoctorManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.lastDiagnostics = null;
|
||||
this.consoleEntries = [];
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerButton = document.getElementById('doctorTriggerBtn');
|
||||
this.badge = document.getElementById('doctorStatusBadge');
|
||||
this.modal = document.getElementById('doctorModal');
|
||||
this.issuesList = document.getElementById('doctorIssuesList');
|
||||
this.summaryText = document.getElementById('doctorSummaryText');
|
||||
this.summaryBadge = document.getElementById('doctorSummaryBadge');
|
||||
this.loadingState = document.getElementById('doctorLoadingState');
|
||||
this.refreshButton = document.getElementById('doctorRefreshBtn');
|
||||
this.exportButton = document.getElementById('doctorExportBtn');
|
||||
|
||||
this.installConsoleCapture();
|
||||
this.bindEvents();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.triggerButton) {
|
||||
this.triggerButton.addEventListener('click', async () => {
|
||||
modalManager.showModal('doctorModal');
|
||||
await this.refreshDiagnostics();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refreshButton) {
|
||||
this.refreshButton.addEventListener('click', async () => {
|
||||
await this.refreshDiagnostics();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.exportButton) {
|
||||
this.exportButton.addEventListener('click', async () => {
|
||||
await this.exportBundle();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
installConsoleCapture() {
|
||||
if (window.__lmDoctorConsolePatched) {
|
||||
this.consoleEntries = window.__lmDoctorConsoleEntries || [];
|
||||
return;
|
||||
}
|
||||
|
||||
const originalConsole = {};
|
||||
const levels = ['log', 'info', 'warn', 'error', 'debug'];
|
||||
window.__lmDoctorConsoleEntries = this.consoleEntries;
|
||||
|
||||
levels.forEach((level) => {
|
||||
const original = console[level]?.bind(console);
|
||||
originalConsole[level] = original;
|
||||
|
||||
console[level] = (...args) => {
|
||||
this.consoleEntries.push({
|
||||
level,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: args.map(stringifyConsoleArg).join(' '),
|
||||
});
|
||||
|
||||
if (this.consoleEntries.length > MAX_CONSOLE_ENTRIES) {
|
||||
this.consoleEntries.splice(0, this.consoleEntries.length - MAX_CONSOLE_ENTRIES);
|
||||
}
|
||||
|
||||
if (original) {
|
||||
original(...args);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
window.__lmDoctorConsolePatched = true;
|
||||
}
|
||||
|
||||
getClientVersion() {
|
||||
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) {
|
||||
if (this.loadingState) {
|
||||
this.loadingState.classList.toggle('visible', isLoading);
|
||||
}
|
||||
if (this.refreshButton) {
|
||||
this.refreshButton.disabled = isLoading;
|
||||
}
|
||||
if (this.exportButton) {
|
||||
this.exportButton.disabled = isLoading;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshDiagnostics({ silent = false } = {}) {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const clientVersion = encodeURIComponent(this.getClientVersion());
|
||||
const response = await fetch(`/api/lm/doctor/diagnostics?clientVersion=${clientVersion}`);
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || payload.success === false) {
|
||||
throw new Error(payload.error || 'Failed to load doctor diagnostics');
|
||||
}
|
||||
|
||||
this.lastDiagnostics = payload;
|
||||
this.updateTriggerState(payload.summary);
|
||||
this.renderDiagnostics(payload);
|
||||
} catch (error) {
|
||||
console.error('Doctor diagnostics failed:', error);
|
||||
if (!silent) {
|
||||
showToast('doctor.toast.loadFailed', { message: error.message }, 'error');
|
||||
}
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
updateTriggerState(summary = {}) {
|
||||
if (!this.badge || !this.triggerButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueCount = Number(summary.issue_count || 0);
|
||||
this.badge.textContent = issueCount > 9 ? '9+' : String(issueCount);
|
||||
this.badge.classList.toggle('hidden', issueCount === 0);
|
||||
|
||||
this.triggerButton.classList.remove('doctor-status-warning', 'doctor-status-error');
|
||||
if (summary.status === 'error') {
|
||||
this.triggerButton.classList.add('doctor-status-error');
|
||||
} else if (summary.status === 'warning') {
|
||||
this.triggerButton.classList.add('doctor-status-warning');
|
||||
}
|
||||
}
|
||||
|
||||
renderDiagnostics(payload) {
|
||||
if (!this.modal || !this.issuesList || !this.summaryText || !this.summaryBadge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { summary = {}, diagnostics = [] } = payload;
|
||||
this.summaryText.textContent = this.getSummaryText(summary);
|
||||
this.summaryBadge.className = `doctor-summary-badge ${this.getStatusClass(summary.status)}`;
|
||||
this.summaryBadge.innerHTML = `
|
||||
<i class="fas ${summary.status === 'error' ? 'fa-triangle-exclamation' : summary.status === 'warning' ? 'fa-stethoscope' : 'fa-heartbeat'}"></i>
|
||||
<span>${escapeHtml(this.getStatusLabel(summary.status))}</span>
|
||||
`;
|
||||
|
||||
this.issuesList.innerHTML = diagnostics.map((item) => this.renderIssueCard(item)).join('');
|
||||
this.attachIssueActions();
|
||||
}
|
||||
|
||||
getSummaryText(summary) {
|
||||
if (summary.status === 'error') {
|
||||
return translate(
|
||||
'doctor.summary.error',
|
||||
{ count: summary.issue_count || 0 },
|
||||
`${summary.issue_count || 0} issue(s) need attention before the app is fully healthy.`
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.status === 'warning') {
|
||||
return translate(
|
||||
'doctor.summary.warning',
|
||||
{ count: summary.issue_count || 0 },
|
||||
`${summary.issue_count || 0} issue(s) were found. Most can be fixed directly from this panel.`
|
||||
);
|
||||
}
|
||||
|
||||
return translate(
|
||||
'doctor.summary.ok',
|
||||
{},
|
||||
'No active issues were found in the current environment.'
|
||||
);
|
||||
}
|
||||
|
||||
getStatusClass(status) {
|
||||
if (status === 'error') {
|
||||
return 'doctor-status-error';
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return 'doctor-status-warning';
|
||||
}
|
||||
return 'doctor-status-ok';
|
||||
}
|
||||
|
||||
getStatusLabel(status) {
|
||||
return translate(`doctor.status.${status || 'ok'}`, {}, status || 'ok');
|
||||
}
|
||||
|
||||
renderIssueCard(item) {
|
||||
const status = item.status || 'ok';
|
||||
const tagLabel = this.getStatusLabel(status);
|
||||
const details = Array.isArray(item.details) ? item.details : [];
|
||||
const listItems = details
|
||||
.filter((detail) => typeof detail === 'string')
|
||||
.map((detail) => `<li>${escapeHtml(detail)}</li>`)
|
||||
.join('');
|
||||
const inlineDetails = details
|
||||
.filter((detail) => detail && typeof detail === 'object')
|
||||
.map((detail) => this.renderInlineDetail(detail))
|
||||
.join('');
|
||||
const actions = (item.actions || [])
|
||||
.map((action) => `
|
||||
<button class="${action.id === 'repair-cache' || action.id === 'reload-page' ? 'primary-btn' : 'secondary-btn'}" data-doctor-action="${escapeHtml(action.id)}">
|
||||
${escapeHtml(action.label)}
|
||||
</button>
|
||||
`)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<section class="doctor-issue-card" data-status="${escapeHtml(status)}" data-issue-id="${escapeHtml(item.id || '')}">
|
||||
<div class="doctor-issue-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.title || '')}</h3>
|
||||
<p class="doctor-issue-summary">${escapeHtml(item.summary || '')}</p>
|
||||
</div>
|
||||
<span class="doctor-issue-tag">${escapeHtml(tagLabel)}</span>
|
||||
</div>
|
||||
${inlineDetails ? `<div class="doctor-inline-detail-grid">${inlineDetails}</div>` : ''}
|
||||
${listItems ? `<ul class="doctor-issue-details">${listItems}</ul>` : ''}
|
||||
${actions ? `<div class="doctor-issue-actions">${actions}</div>` : ''}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
renderInlineDetail(detail) {
|
||||
if (detail.client_version || detail.server_version) {
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
<strong>${escapeHtml(translate('common.status.version', {}, 'Version'))}</strong>
|
||||
<div>${escapeHtml(`Client: ${detail.client_version || 'unknown'}`)}</div>
|
||||
<div>${escapeHtml(`Server: ${detail.server_version || 'unknown'}`)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const label = detail.label || detail.model_type || detail.client_version || detail.server_version || 'Detail';
|
||||
const message = detail.message
|
||||
|| detail.corruption_rate
|
||||
|| detail.server_version
|
||||
|| detail.client_version
|
||||
|| '';
|
||||
|
||||
if (detail.model_type) {
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
<strong>${escapeHtml(detail.label || detail.model_type)}</strong>
|
||||
<div>${escapeHtml(detail.message || '')}</div>
|
||||
${detail.corruption_rate ? `<div>${escapeHtml(detail.corruption_rate)} invalid</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
<strong>${escapeHtml(label)}</strong>
|
||||
<div>${escapeHtml(message)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachIssueActions() {
|
||||
this.issuesList.querySelectorAll('[data-doctor-action]').forEach((button) => {
|
||||
button.addEventListener('click', async (event) => {
|
||||
const action = event.currentTarget.dataset.doctorAction;
|
||||
await this.handleAction(action);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleAction(action) {
|
||||
switch (action) {
|
||||
case 'open-settings':
|
||||
modalManager.showModal('settingsModal');
|
||||
window.setTimeout(() => {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
case 'repair-cache':
|
||||
await this.repairCache();
|
||||
break;
|
||||
case 'reload-page':
|
||||
this.reloadUi();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async repairCache() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
const response = await fetch('/api/lm/doctor/repair-cache', { method: 'POST' });
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || payload.success === false) {
|
||||
throw new Error(payload.error || translate('doctor.toast.repairFailed', {}, 'Cache rebuild failed.'));
|
||||
}
|
||||
|
||||
showToast('doctor.toast.repairSuccess', {}, 'success');
|
||||
await this.refreshDiagnostics({ silent: true });
|
||||
} catch (error) {
|
||||
console.error('Doctor cache repair failed:', error);
|
||||
showToast('doctor.toast.repairFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async exportBundle() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
const response = await fetch('/api/lm/doctor/export-bundle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
summary: this.lastDiagnostics?.summary || null,
|
||||
diagnostics: this.lastDiagnostics?.diagnostics || [],
|
||||
frontend_logs: this.consoleEntries,
|
||||
client_context: {
|
||||
url: window.location.href,
|
||||
user_agent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
app_version: this.getClientVersion(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || 'Failed to export diagnostics bundle');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename=\"([^\"]+)\"/);
|
||||
const filename = match?.[1] || 'lora-manager-doctor.zip';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showToast('doctor.toast.exportSuccess', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Doctor export failed:', error);
|
||||
showToast('doctor.toast.exportFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const doctorManager = new DoctorManager();
|
||||
@@ -6,6 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||
|
||||
export class DownloadManager {
|
||||
constructor() {
|
||||
@@ -197,21 +198,22 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
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);
|
||||
if (civarchiveMatch) {
|
||||
const versionMatch = url.match(/modelVersionId=(\d+)/i);
|
||||
this.modelVersionId = versionMatch ? versionMatch[1] : null;
|
||||
this.source = 'civarchive';
|
||||
return civarchiveMatch[1];
|
||||
}
|
||||
|
||||
const civitaiMatch = url.match(/https?:\/\/(?:www\.)?civitai\.com\/models\/(\d+)/i);
|
||||
if (civitaiMatch) {
|
||||
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
|
||||
if (modelId) {
|
||||
this.modelVersionId = modelVersionId;
|
||||
this.source = null;
|
||||
return civitaiMatch[1];
|
||||
return modelId;
|
||||
}
|
||||
|
||||
this.modelVersionId = null;
|
||||
this.source = null;
|
||||
return null;
|
||||
}
|
||||
@@ -250,6 +252,7 @@ export class DownloadManager {
|
||||
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
||||
|
||||
const existsLocally = version.existsLocally;
|
||||
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
|
||||
const localPath = version.localPath;
|
||||
const isEarlyAccess = version.availability === 'EarlyAccess';
|
||||
|
||||
@@ -262,11 +265,22 @@ export class DownloadManager {
|
||||
`;
|
||||
}
|
||||
|
||||
const localStatus = existsLocally ?
|
||||
`<div class="local-badge">
|
||||
let localStatus = '';
|
||||
if (existsLocally) {
|
||||
localStatus = `<div class="local-badge">
|
||||
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
|
||||
<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 `
|
||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
||||
|
||||
@@ -84,6 +84,18 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
const doctorModal = document.getElementById('doctorModal');
|
||||
if (doctorModal) {
|
||||
this.registerModal('doctorModal', {
|
||||
element: doctorModal,
|
||||
onClose: () => {
|
||||
this.getModal('doctorModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
// Add moveModal registration
|
||||
const moveModal = document.getElementById('moveModal');
|
||||
if (moveModal) {
|
||||
@@ -451,4 +463,4 @@ export class ModalManager {
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const modalManager = new ModalManager();
|
||||
export const modalManager = new ModalManager();
|
||||
|
||||
@@ -361,6 +361,13 @@ export class SettingsManager {
|
||||
});
|
||||
}
|
||||
|
||||
const openBackupLocationButton = document.getElementById('backupOpenLocationBtn');
|
||||
if (openBackupLocationButton) {
|
||||
openBackupLocationButton.addEventListener('click', () => {
|
||||
this.openBackupLocation();
|
||||
});
|
||||
}
|
||||
|
||||
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||
const customInput = document.getElementById(`${modelType}CustomTemplate`);
|
||||
if (customInput) {
|
||||
@@ -742,6 +749,35 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
async openBackupLocation() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/backup/open-location', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.mode === 'clipboard' && data.path) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.path);
|
||||
showToast('settings.backup.locationCopied', { path: data.path }, 'success');
|
||||
} catch (clipboardErr) {
|
||||
console.warn('Clipboard API not available:', clipboardErr);
|
||||
showToast('settings.backup.locationClipboardFallback', { path: data.path }, 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('settings.backup.openFolderSuccess', {}, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open backup folder:', error);
|
||||
showToast('settings.backup.openFolderFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSettingsToUI() {
|
||||
// Set frontend settings from state
|
||||
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
||||
@@ -766,6 +802,11 @@ export class SettingsManager {
|
||||
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');
|
||||
if (recipesPathInput) {
|
||||
recipesPathInput.value = state.global.settings.recipes_path || '';
|
||||
@@ -878,6 +919,9 @@ export class SettingsManager {
|
||||
// Load metadata archive settings
|
||||
await this.loadMetadataArchiveSettings();
|
||||
|
||||
// Load backup settings
|
||||
await this.loadBackupSettings();
|
||||
|
||||
// Load base model path mappings
|
||||
this.loadBaseModelMappings();
|
||||
|
||||
@@ -1404,12 +1448,12 @@ export class SettingsManager {
|
||||
|
||||
// Add empty row for new path if no paths exist
|
||||
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}`);
|
||||
if (!container) return;
|
||||
|
||||
@@ -1433,7 +1477,7 @@ export class SettingsManager {
|
||||
container.appendChild(row);
|
||||
|
||||
// Focus the input if it's empty (new row)
|
||||
if (!path) {
|
||||
if (!path && shouldFocus) {
|
||||
const input = row.querySelector('.extra-folder-path-input');
|
||||
if (input) {
|
||||
setTimeout(() => input.focus(), 0);
|
||||
@@ -1857,6 +1901,10 @@ export class SettingsManager {
|
||||
await this.updateMetadataArchiveStatus();
|
||||
}
|
||||
|
||||
if (settingKey === 'backup_auto_enabled') {
|
||||
await this.updateBackupStatus();
|
||||
}
|
||||
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
|
||||
// Apply frontend settings immediately
|
||||
@@ -1945,6 +1993,163 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
async loadBackupSettings() {
|
||||
const backupAutoEnabledCheckbox = document.getElementById('backupAutoEnabled');
|
||||
if (backupAutoEnabledCheckbox) {
|
||||
backupAutoEnabledCheckbox.checked = state.global.settings.backup_auto_enabled ?? true;
|
||||
}
|
||||
|
||||
const backupRetentionCountInput = document.getElementById('backupRetentionCount');
|
||||
if (backupRetentionCountInput) {
|
||||
backupRetentionCountInput.value = state.global.settings.backup_retention_count ?? 5;
|
||||
}
|
||||
|
||||
await this.updateBackupStatus();
|
||||
}
|
||||
|
||||
async updateBackupStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/backup/status');
|
||||
const data = await response.json();
|
||||
|
||||
const statusContainer = document.getElementById('backupStatus');
|
||||
if (!statusContainer || !data.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = data.status || {};
|
||||
const latestAutoSnapshot = status.latestAutoSnapshot;
|
||||
const retentionCount = status.retentionCount ?? state.global.settings.backup_retention_count ?? 5;
|
||||
const enabled = status.enabled ?? state.global.settings.backup_auto_enabled ?? true;
|
||||
const backupDir = status.backupDir || '';
|
||||
const backupLocationPath = document.getElementById('backupLocationPath');
|
||||
if (backupLocationPath) {
|
||||
backupLocationPath.textContent = backupDir;
|
||||
backupLocationPath.title = backupDir;
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
if (!timestamp) {
|
||||
return translate('common.status.unknown', {}, 'Unknown');
|
||||
}
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
const renderSnapshotDetail = (snapshot) => {
|
||||
if (!snapshot) {
|
||||
return translate('settings.backup.noneAvailable', {}, 'No snapshots yet');
|
||||
}
|
||||
|
||||
const size = typeof snapshot.size === 'number' ? ` (${this.formatFileSize(snapshot.size)})` : '';
|
||||
return `${snapshot.name}${size}`;
|
||||
};
|
||||
|
||||
statusContainer.innerHTML = `
|
||||
<div class="backup-summary-grid">
|
||||
<div class="backup-summary-card">
|
||||
<div class="backup-summary-label">${translate('settings.backup.autoEnabled', {}, 'Automatic snapshots')}</div>
|
||||
<div class="backup-summary-value status-${enabled ? 'enabled' : 'disabled'}">
|
||||
${enabled ? translate('common.status.enabled') : translate('common.status.disabled')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-summary-card">
|
||||
<div class="backup-summary-label">${translate('settings.backup.retention', {}, 'Retention')}</div>
|
||||
<div class="backup-summary-value">${retentionCount}</div>
|
||||
</div>
|
||||
<div class="backup-summary-card">
|
||||
<div class="backup-summary-label">${translate('settings.backup.snapshotCount', {}, 'Saved snapshots')}</div>
|
||||
<div class="backup-summary-value">${status.snapshotCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-status-list">
|
||||
<div class="backup-status-row">
|
||||
<div class="backup-status-label">${translate('settings.backup.latestAutoSnapshot', {}, 'Latest auto snapshot')}</div>
|
||||
<div class="backup-status-content">
|
||||
<div class="backup-status-primary">${formatTimestamp(latestAutoSnapshot?.mtime)}</div>
|
||||
<div class="backup-status-secondary">${renderSnapshotDetail(latestAutoSnapshot)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error updating backup status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async exportBackup() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/backup/export', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition') || '';
|
||||
const match = contentDisposition.match(/filename="([^"]+)"/);
|
||||
const filename = match?.[1] || `lora-manager-backup-${Date.now()}.zip`;
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showToast('settings.backup.exportSuccess', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to export backup:', error);
|
||||
showToast('settings.backup.exportFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
triggerBackupImport() {
|
||||
const input = document.getElementById('backupImportInput');
|
||||
input?.click();
|
||||
}
|
||||
|
||||
async handleBackupImportFile(input) {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(translate('settings.backup.importConfirm', {}, 'Import this backup and overwrite local user state?'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('archive', file);
|
||||
|
||||
const response = await fetch('/api/lm/backup/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
showToast('settings.backup.importSuccess', {}, 'success');
|
||||
await this.updateBackupStatus();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to import backup:', error);
|
||||
showToast('settings.backup.importFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async updateMetadataArchiveStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/metadata-archive-status');
|
||||
@@ -2473,8 +2678,11 @@ export class SettingsManager {
|
||||
|
||||
try {
|
||||
// Check if value has changed from existing value
|
||||
const currentValue = state.global.settings[settingKey] || '';
|
||||
if (value === currentValue) {
|
||||
const currentValue = state.global.settings[settingKey];
|
||||
const normalizedCurrentValue = currentValue === undefined || currentValue === null
|
||||
? ''
|
||||
: String(currentValue).trim();
|
||||
if (value === normalizedCurrentValue) {
|
||||
return; // No change, exit early
|
||||
}
|
||||
|
||||
@@ -2515,6 +2723,9 @@ export class SettingsManager {
|
||||
|
||||
if (settingKey === 'recipes_path') {
|
||||
showToast('toast.settings.recipesPathUpdated', {}, 'success');
|
||||
} else if (settingKey === 'backup_retention_count') {
|
||||
await this.updateBackupStatus();
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
} else {
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class DownloadManager {
|
||||
modalManager.closeModal('importModal');
|
||||
|
||||
// Refresh the recipe
|
||||
window.recipeManager.loadRecipes();
|
||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
|
||||
} catch (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
|
||||
async loadRecipes(resetPage = true) {
|
||||
async loadRecipes(options = true) {
|
||||
// Skip loading if in duplicates mode
|
||||
const pageState = getCurrentPageState();
|
||||
if (pageState.duplicatesMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { resetPage, preserveScroll } = this.normalizeLoadRecipesOptions(options);
|
||||
|
||||
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({
|
||||
civitai_api_key: '',
|
||||
civitai_host: 'civitai.com',
|
||||
use_portable_settings: false,
|
||||
language: 'en',
|
||||
show_only_sfw: false,
|
||||
@@ -41,6 +42,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
metadata_refresh_skip_paths: [],
|
||||
skip_previously_downloaded_model_versions: false,
|
||||
download_skip_base_models: [],
|
||||
backup_auto_enabled: true,
|
||||
backup_retention_count: 5,
|
||||
});
|
||||
|
||||
export function createDefaultSettings() {
|
||||
@@ -87,7 +90,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedLoras: new Set(),
|
||||
@@ -95,6 +100,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
},
|
||||
|
||||
recipes: {
|
||||
@@ -144,7 +155,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||
bulkMode: false,
|
||||
@@ -153,6 +166,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
},
|
||||
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
@@ -175,7 +194,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
modelTypes: [],
|
||||
search: '',
|
||||
tagLogic: 'any',
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
@@ -183,6 +204,12 @@ export const state = {
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
viewMode: 'active',
|
||||
excludedViewState: {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
},
|
||||
activeViewSnapshot: null,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -13,6 +13,64 @@ export const OptimizationMode = {
|
||||
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.
|
||||
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
|
||||
@@ -119,3 +177,50 @@ export function isCivitaiUrl(url) {
|
||||
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 { 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
|
||||
async function getCardCreator(pageType) {
|
||||
if (pageType === 'recipes') {
|
||||
@@ -87,7 +124,7 @@ async function initializeVirtualScroll(pageType) {
|
||||
}
|
||||
|
||||
// Change this line to get the actual scrolling container
|
||||
const scrollContainer = document.querySelector('.page-content');
|
||||
const scrollContainer = getScrollContainer();
|
||||
const gridContainer = scrollContainer.querySelector('.container');
|
||||
|
||||
if (!gridContainer) {
|
||||
@@ -200,9 +237,16 @@ export function cleanupKeyboardNavigation() {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||
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 { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.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
|
||||
@@ -184,14 +244,15 @@ function filterByFolder(folderPath) {
|
||||
}
|
||||
|
||||
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
|
||||
if (versionId) {
|
||||
// Use model-versions endpoint which auto-redirects to correct model page
|
||||
window.open(`https://civitai.com/model-versions/${versionId}`, '_blank');
|
||||
} else if (civitaiId) {
|
||||
window.open(`https://civitai.com/models/${civitaiId}`, '_blank');
|
||||
} else if (modelName) {
|
||||
// Fallback: search by name
|
||||
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
|
||||
const url = buildCivitaiUrl({
|
||||
modelId: civitaiId,
|
||||
versionId,
|
||||
modelName,
|
||||
host: getPreferredCivitaiHost(),
|
||||
});
|
||||
|
||||
if (url) {
|
||||
openCivitaiUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</head>
|
||||
|
||||
{% set page_id = self.page_id() %}
|
||||
<body data-page="{{ page_id }}">
|
||||
<body data-page="{{ page_id }}" data-app-version="{{ version }}">
|
||||
<!-- Header is always visible, even during initialization -->
|
||||
{% include 'components/header.html' %}
|
||||
|
||||
|
||||
@@ -111,6 +111,9 @@
|
||||
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
||||
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
||||
</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">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||
</div>
|
||||
@@ -136,4 +139,4 @@
|
||||
|
||||
<div id="nodeSelector" class="node-selector">
|
||||
<!-- Dynamic node list will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
<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="action-buttons">
|
||||
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
|
||||
@@ -84,6 +96,13 @@
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<div class="control-group doctor-control-group">
|
||||
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<span>{{ t('doctor.title', default='Doctor') }}</span>
|
||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keyboard-nav-hint tooltip">
|
||||
<i class="fas fa-keyboard"></i>
|
||||
<span class="tooltiptext">
|
||||
@@ -117,4 +136,4 @@
|
||||
<!-- Breadcrumbs will be populated by JavaScript -->
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
{% include 'components/modals/confirm_modals.html' %}
|
||||
{% include 'components/modals/settings_modal.html' %}
|
||||
{% include 'components/modals/doctor_modal.html' %}
|
||||
{% include 'components/modals/support_modal.html' %}
|
||||
{% include 'components/modals/update_modal.html' %}
|
||||
{% include 'components/modals/help_modal.html' %}
|
||||
@@ -11,4 +12,4 @@
|
||||
{% include 'components/modals/download_modal.html' %}
|
||||
{% include 'components/modals/move_modal.html' %}
|
||||
{% include 'components/modals/bulk_add_tags_modal.html' %}
|
||||
{% include 'components/modals/bulk_base_model_modal.html' %}
|
||||
{% include 'components/modals/bulk_base_model_modal.html' %}
|
||||
|
||||
40
templates/components/modals/doctor_modal.html
Normal file
40
templates/components/modals/doctor_modal.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<div id="doctorModal" class="modal">
|
||||
<div class="modal-content doctor-modal">
|
||||
<button class="close" onclick="modalManager.closeModal('doctorModal')">×</button>
|
||||
|
||||
<div class="doctor-shell">
|
||||
<div class="doctor-hero">
|
||||
<div class="doctor-hero-copy">
|
||||
<span class="doctor-kicker">{{ t('doctor.kicker', default='System diagnostics') }}</span>
|
||||
<h2>{{ t('doctor.title', default='Doctor') }}</h2>
|
||||
<p id="doctorSummaryText">{{ t('doctor.summary.idle', default='Run a health check for settings, cache integrity, and UI consistency.') }}</p>
|
||||
</div>
|
||||
<div id="doctorSummaryBadge" class="doctor-summary-badge doctor-status-ok">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
<span>{{ t('doctor.status.ok', default='Healthy') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="doctorLoadingState" class="doctor-loading-state">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>{{ t('doctor.loading', default='Checking environment...') }}</span>
|
||||
</div>
|
||||
|
||||
<div id="doctorIssuesList" class="doctor-issues-list"></div>
|
||||
|
||||
<div class="doctor-footer">
|
||||
<div class="doctor-footer-note">{{ t('doctor.footer', default='Export a diagnostics bundle if the issues remain after repair.') }}</div>
|
||||
<div class="doctor-footer-actions">
|
||||
<button id="doctorRefreshBtn" class="secondary-btn">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span>{{ t('doctor.actions.runAgain', default='Run Again') }}</span>
|
||||
</button>
|
||||
<button id="doctorExportBtn" class="primary-btn">
|
||||
<i class="fas fa-file-export"></i>
|
||||
<span>{{ t('doctor.actions.exportBundle', default='Export Bundle') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- API Configuration -->
|
||||
<div class="setting-item api-key-item">
|
||||
<div class="setting-row">
|
||||
@@ -113,6 +113,111 @@
|
||||
</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 -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
<h4>{{ t('settings.sections.backup') }}</h4>
|
||||
</div>
|
||||
<div class="settings-help-text subtle">
|
||||
{{ t('settings.backup.scopeHelp') }}
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="backupAutoEnabled">
|
||||
{{ t('settings.backup.autoEnabled') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.backup.autoEnabledHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="backupAutoEnabled"
|
||||
onchange="settingsManager.saveToggleSetting('backupAutoEnabled', 'backup_auto_enabled')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="backupRetentionCount">
|
||||
{{ t('settings.backup.retention') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.backup.retentionHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="text-input-wrapper">
|
||||
<input
|
||||
type="number"
|
||||
id="backupRetentionCount"
|
||||
min="1"
|
||||
step="1"
|
||||
onblur="settingsManager.saveInputSetting('backupRetentionCount', 'backup_retention_count')"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label>
|
||||
{{ t('settings.backup.management') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.backup.managementHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button type="button" class="secondary-btn" onclick="settingsManager.exportBackup()">
|
||||
{{ t('settings.backup.exportButton') }}
|
||||
</button>
|
||||
<button type="button" class="secondary-btn" onclick="settingsManager.triggerBackupImport()" style="margin-left: 10px;">
|
||||
{{ t('settings.backup.importButton') }}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
id="backupImportInput"
|
||||
accept=".zip,application/zip"
|
||||
style="display: none;"
|
||||
onchange="settingsManager.handleBackupImportFile(this)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<details class="backup-location-details">
|
||||
<summary>{{ t('settings.backup.locationSummary') }}</summary>
|
||||
<div class="backup-location-panel">
|
||||
<code id="backupLocationPath" class="backup-location-path"></code>
|
||||
<button type="button" class="secondary-btn" id="backupOpenLocationBtn">
|
||||
{{ t('settings.backup.openFolderButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="backup-status" id="backupStatus">
|
||||
<!-- Status will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Settings -->
|
||||
<div class="settings-subsection">
|
||||
@@ -450,6 +555,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Library -->
|
||||
|
||||
@@ -120,6 +120,13 @@
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<div class="control-group doctor-control-group">
|
||||
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<span>{{ t('doctor.title', default='Doctor') }}</span>
|
||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keyboard-nav-hint tooltip">
|
||||
<i class="fas fa-keyboard"></i>
|
||||
<span class="tooltiptext">
|
||||
@@ -189,4 +196,4 @@
|
||||
|
||||
{% block main_script %}
|
||||
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,15 @@ const showToastMock = vi.hoisted(() => vi.fn());
|
||||
const loadingManagerMock = vi.hoisted(() => ({
|
||||
showSimpleLoading: 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', () => {
|
||||
return {
|
||||
@@ -20,17 +28,41 @@ vi.mock('../../../static/js/state/index.js', () => {
|
||||
return {
|
||||
state: {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
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(() => {
|
||||
@@ -111,4 +143,77 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
fetchApiMock.mockImplementation((url) => {
|
||||
if (url.includes('usage-tips-by-path')) {
|
||||
@@ -244,6 +269,53 @@ describe('AutoComplete widget interactions', () => {
|
||||
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 () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
@@ -300,6 +372,66 @@ describe('AutoComplete widget interactions', () => {
|
||||
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 () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
@@ -743,12 +875,12 @@ describe('AutoComplete widget interactions', () => {
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// Simulate "/char looking to the side" input
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side');
|
||||
// Simulate "/character looking to the side" input
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
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.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
@@ -766,7 +898,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = '/char looking to the side';
|
||||
autoComplete.currentSearchTerm = '/character 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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
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, () => ({
|
||||
showToast: vi.fn(),
|
||||
openCivitaiUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
const stateMock = {
|
||||
@@ -349,4 +350,59 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
);
|
||||
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 openCivitaiByMetadataMock = vi.fn();
|
||||
const updatePanelPositionsMock = vi.fn();
|
||||
const downloadManagerMock = {
|
||||
showDownloadModal: vi.fn(),
|
||||
@@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
openCivitaiByMetadata: openCivitaiByMetadataMock,
|
||||
updatePanelPositions: updatePanelPositionsMock,
|
||||
}));
|
||||
|
||||
@@ -84,6 +86,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.bulkManager;
|
||||
delete window.modelDuplicatesManager;
|
||||
delete global.fetch;
|
||||
vi.useRealTimers();
|
||||
@@ -112,6 +115,9 @@ function renderControlsDom(pageKey) {
|
||||
<button class="clear-filter"></button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||
<button id="excludedViewBackBtn">Back</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div class="control-group">
|
||||
@@ -170,6 +176,9 @@ function renderControlsDom(pageKey) {
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user