mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c03aa1430 | ||
|
|
5376fd8724 | ||
|
|
6dea9a76bc | ||
|
|
d73903e82e | ||
|
|
4862419b61 | ||
|
|
e6e7df7454 | ||
|
|
30f9e3e2ec | ||
|
|
707d0cb8a4 | ||
|
|
56ea7594ce | ||
|
|
389e46c251 | ||
|
|
6db17e682a | ||
|
|
94e0308a12 | ||
|
|
1f9f821576 | ||
|
|
57933dfba6 | ||
|
|
c50bee7757 | ||
|
|
4e3ee843f9 | ||
|
|
7e40f6fcb9 | ||
|
|
7976956b6b | ||
|
|
adce5293d5 | ||
|
|
c2db5eb6df | ||
|
|
f958ecdf18 | ||
|
|
ef0bcc6cf1 | ||
|
|
285428ad3a | ||
|
|
ee18cff3d9 | ||
|
|
1be3235564 | ||
|
|
a92883509a | ||
|
|
ce42d83ce9 | ||
|
|
077cf7b574 | ||
|
|
b99d78bda6 | ||
|
|
39586f4a20 | ||
|
|
4ef750b206 | ||
|
|
9d3d93823d | ||
|
|
45c1113b72 | ||
|
|
e10717dcda | ||
|
|
315ab6f70b | ||
|
|
cf4d654c4b | ||
|
|
569c829709 | ||
|
|
de05b59f29 | ||
|
|
70a282a6c0 | ||
|
|
b10bcf7e78 | ||
|
|
5fb10263f3 | ||
|
|
9e76c9783e | ||
|
|
7770976513 | ||
|
|
dc1f7ab6fe | ||
|
|
32b1d6c561 | ||
|
|
5264e49f2a | ||
|
|
ce3adaf831 | ||
|
|
e2f3e57f5c | ||
|
|
5c2349ff42 | ||
|
|
50eee8c373 | ||
|
|
f89b792535 | ||
|
|
6d0ea2841c | ||
|
|
98678a8698 | ||
|
|
5326fa2970 | ||
|
|
90547670a2 | ||
|
|
4753206c52 | ||
|
|
613aa3b1c3 | ||
|
|
a6b704d4b4 | ||
|
|
227d06c736 | ||
|
|
8508763831 | ||
|
|
136d3153fa | ||
|
|
49bdf77040 | ||
|
|
f4dcd89835 | ||
|
|
139e915711 | ||
|
|
22eda58074 | ||
|
|
fb91cf4df2 | ||
|
|
e0332571da | ||
|
|
2d4bc47746 | ||
|
|
38e766484e | ||
|
|
b5ee4a6408 | ||
|
|
7892df21ec | ||
|
|
188fe407b6 | ||
|
|
600afdcd92 | ||
|
|
994fa4bd43 | ||
|
|
51098f2829 | ||
|
|
795b9e8418 | ||
|
|
9ca2b9dd56 | ||
|
|
d77b6d78b7 | ||
|
|
427e7a36d5 | ||
|
|
c90306cc9b | ||
|
|
5fe0660c64 | ||
|
|
2abb5bf122 | ||
|
|
bb65527469 | ||
|
|
d9a6db3359 | ||
|
|
58cafdb713 | ||
|
|
0594e278b6 | ||
|
|
807425f12a | ||
|
|
aa4b1ccc25 | ||
|
|
58255ec28b | ||
|
|
d62b84693d | ||
|
|
df75c7e68d | ||
|
|
c5c7fdf54f | ||
|
|
49e0deeff3 | ||
|
|
0c20701bef | ||
|
|
faa26651dd | ||
|
|
2eae8a7729 | ||
|
|
dde2b2a960 | ||
|
|
4a9089d3dd | ||
|
|
3244a5f1a1 | ||
|
|
449c1e9d10 | ||
|
|
d0aa916683 | ||
|
|
13433f8cd2 | ||
|
|
8d336320c0 | ||
|
|
d945c58d51 | ||
|
|
acaf122346 | ||
|
|
713759b411 | ||
|
|
c5175bb870 | ||
|
|
e63ef8d031 | ||
|
|
e043537241 | ||
|
|
46126f9950 | ||
|
|
f4eb916914 | ||
|
|
49b9b7a5ea | ||
|
|
9b1a9ee071 | ||
|
|
0b8f137a1b | ||
|
|
6148a12301 | ||
|
|
fadbf21b4f | ||
|
|
c38a06937d | ||
|
|
1a34403b0e | ||
|
|
e4d58d0f60 | ||
|
|
4e4ea85cc3 | ||
|
|
f7a856349a | ||
|
|
15edd7a42c | ||
|
|
46243a236d | ||
|
|
6f382e587a | ||
|
|
bf3d706bf4 | ||
|
|
cdf21e813c | ||
|
|
10f5588e4a | ||
|
|
0ecbdf6f39 | ||
|
|
61101a7ad0 | ||
|
|
6d9be814a5 | ||
|
|
52bf93e430 | ||
|
|
00fade756c | ||
|
|
3c0feb23ba | ||
|
|
3627840fe9 | ||
|
|
bbdc1bba87 | ||
|
|
21a1bc1a01 |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,5 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: PixelPawsAI
|
||||
ko_fi: pixelpawsai
|
||||
custom: ['paypal.me/pixelpawsai']
|
||||
patreon: PixelPawsAI
|
||||
custom: ['paypal.me/pixelpawsai', 'https://afdian.com/a/pixelpawsai']
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ civitai/
|
||||
node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
|
||||
18
README.md
18
README.md
@@ -34,6 +34,14 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.9.9
|
||||
* **Check for Updates Feature** - Users can now check for updates for all models or selected models in bulk mode. Models with available updates will display an "update available" badge on their model card, and users can filter to show only models with updates.
|
||||
* **Model Versions Management** - Added a new Versions tab in the model modal that centralizes all versions of a model, providing download, delete, and ignore update functions.
|
||||
* **Send Checkpoint to ComfyUI** - Users can now click the send button on a checkpoint card to send the checkpoint directly to the current workflow's checkpoint or diffusion model loader node in ComfyUI.
|
||||
* **Customizable Model Card Display** - Added a new setting that allows users to choose whether to display the model name or filename on model cards.
|
||||
* **New Path Template Placeholders** - Added new path template placeholders: `{model_name}` and `{version_name}` for more flexible organization.
|
||||
* **ComfyUI Auto Path Correction Setting** - Added a new setting within ComfyUI to enable or disable the auto path correction feature.
|
||||
|
||||
### v0.9.8
|
||||
* **Full CivArchive API Support** - Added complete support for the CivArchive API as a fallback metadata source beyond Civitai API. Models deleted from Civitai can now still retrieve metadata through the CivArchive API.
|
||||
* **Download Models from CivArchive** - Added support for downloading models directly from CivArchive, similar to downloading from Civitai. Simply click the Download button and paste the model URL to download the corresponding model.
|
||||
@@ -148,9 +156,10 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
||||
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.2/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
|
||||
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
|
||||
4. Run run.bat
|
||||
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
|
||||
|
||||
@@ -231,8 +240,9 @@ You can now run LoRA Manager independently from ComfyUI:
|
||||
```
|
||||
|
||||
2. **For non-ComfyUI users**:
|
||||
- Copy the provided `settings.json.example` file to create a new file named `settings.json`
|
||||
- Edit `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
- Copy the provided `settings.json.example` file to create a new file named `settings.json`. Update the API key, optional language, and folder paths only—the library registry is created automatically when LoRA Manager starts.
|
||||
- Edit `settings.json` to include your correct model folder paths and CivitAI API key (you can leave the defaults until ready to configure them)
|
||||
- Enable portable mode by setting `"use_portable_settings": true` if you prefer LoRA Manager to read and write the `settings.json` located in the project directory.
|
||||
- Install required dependencies: `pip install -r requirements.txt`
|
||||
- Run standalone mode:
|
||||
```bash
|
||||
|
||||
129
locales/de.json
129
locales/de.json
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||
"toggleBlur": "Unschärfe umschalten",
|
||||
"show": "Anzeigen",
|
||||
"openExampleImages": "Beispielbilder-Ordner öffnen"
|
||||
"openExampleImages": "Beispielbilder-Ordner öffnen",
|
||||
"replacePreview": "Vorschau ersetzen",
|
||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Nicht jugendfreie Inhalte",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "Fehler beim Aktualisieren des Favoriten-Status"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert"
|
||||
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert",
|
||||
"missingPath": "Modellpfad für diese Karte konnte nicht ermittelt werden"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Fehler beim Überprüfen der Beispielbilder",
|
||||
"missingHash": "Fehlende Modell-Hash-Informationen.",
|
||||
"noRemoteImagesAvailable": "Keine Remote-Beispielbilder für dieses Modell auf Civitai verfügbar"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
"updateAvailable": "Update verfügbar"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "Bitte legen Sie einen Speicherort fest, bevor Sie Beispielbilder herunterladen.",
|
||||
"unavailable": "Beispielbild-Downloads sind noch nicht verfügbar. Versuchen Sie es erneut, nachdem die Seite vollständig geladen ist."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Auf Updates prüfen",
|
||||
"loading": "Prüfe auf {type}-Updates...",
|
||||
"success": "{count} Update(s) für {type} gefunden",
|
||||
"none": "Alle {type} sind auf dem neuesten Stand",
|
||||
"error": "Fehler beim Prüfen auf {type}-Updates: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Beispielbild-Ordner bereinigen",
|
||||
"success": "{count} Ordner wurden in den Papierkorb verschoben",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"support": "Unterstützung"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,27 @@
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
|
||||
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
|
||||
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
|
||||
"cardInfoDisplay": "Karten-Info-Anzeige",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Immer sichtbar",
|
||||
"hover": "Bei Hover anzeigen"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Kopf- und Fußzeilen sind immer sichtbar",
|
||||
"hover": "Kopf- und Fußzeilen erscheinen nur beim Darüberfahren mit der Maus"
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||
|
||||
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Beispielbilder öffnen",
|
||||
"replacePreview": "Vorschau ersetzen"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Wähle aus, was die Schaltfläche unten rechts auf der Karte ausführt",
|
||||
"modelNameDisplay": "Anzeige des Modellnamens",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Modellname",
|
||||
"fileName": "Dateiname"
|
||||
},
|
||||
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "Den beschreibenden Namen des Modells anzeigen",
|
||||
"fileName": "Den tatsächlichen Dateinamen auf der Festplatte anzeigen"
|
||||
}
|
||||
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Aktive Bibliothek",
|
||||
@@ -394,8 +413,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Modelliste aktualisieren",
|
||||
"quick": "Schnelle Aktualisierung (inkrementell)",
|
||||
"full": "Vollständiger Neuaufbau (komplett)"
|
||||
"quick": "Änderungen synchronisieren",
|
||||
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
||||
"full": "Cache neu aufbauen",
|
||||
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Metadaten von Civitai abrufen",
|
||||
@@ -416,6 +437,13 @@
|
||||
"favorites": {
|
||||
"title": "Nur Favoriten anzeigen",
|
||||
"action": "Favoriten"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Nur Modelle mit verfügbaren Updates anzeigen",
|
||||
"action": "Updates",
|
||||
"menuLabel": "Weitere Update-Optionen anzeigen",
|
||||
"check": "Updates prüfen",
|
||||
"checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +455,7 @@
|
||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
"deleteAll": "Alle Modelle löschen",
|
||||
@@ -702,6 +731,12 @@
|
||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||
"action": "Alle löschen"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Alle {typePlural} auf Updates prüfen?",
|
||||
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
||||
"tip": "Du möchtest in Etappen prüfen? Wechsle in den Sammelmodus, wähle die benötigten Modelle aus und nutze anschließend \"Auswahl auf Updates prüfen\".",
|
||||
"action": "Alles prüfen"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||
"description": "Tags hinzufügen zu",
|
||||
@@ -838,13 +873,55 @@
|
||||
"tabs": {
|
||||
"examples": "Beispiele",
|
||||
"description": "Modellbeschreibung",
|
||||
"recipes": "Rezepte"
|
||||
"recipes": "Rezepte",
|
||||
"versions": "Versionen"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Beispielbilder werden geladen...",
|
||||
"description": "Modellbeschreibung wird geladen...",
|
||||
"recipes": "Rezepte werden geladen...",
|
||||
"examples": "Beispiele werden geladen..."
|
||||
"examples": "Beispiele werden geladen...",
|
||||
"versions": "Versionen werden geladen..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Modellversionen",
|
||||
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
|
||||
"media": {
|
||||
"placeholder": "Keine Vorschau"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Unbenannte Version",
|
||||
"noDetails": "Keine zusätzlichen Details"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Aktuelle Version",
|
||||
"inLibrary": "In der Bibliothek",
|
||||
"newer": "Neuere Version",
|
||||
"ignored": "Ignoriert"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Herunterladen",
|
||||
"delete": "Löschen",
|
||||
"ignore": "Ignorieren",
|
||||
"unignore": "Ignorierung aufheben",
|
||||
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||
"viewLocalVersions": "Alle lokalen Versionen anzeigen",
|
||||
"viewLocalTooltip": "Demnächst verfügbar"
|
||||
},
|
||||
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
||||
"error": "Versionen konnten nicht geladen werden.",
|
||||
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
||||
"confirm": {
|
||||
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Aktualisierungen für dieses Modell werden ignoriert",
|
||||
"modelResumed": "Aktualisierungen für dieses Modell werden wieder geprüft",
|
||||
"versionIgnored": "Aktualisierungen für diese Version werden ignoriert",
|
||||
"versionUnignored": "Version wurde wieder aktiviert",
|
||||
"versionDeleted": "Version gelöscht"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1028,9 @@
|
||||
"loraFailedToSend": "Fehler beim Senden der LoRA an den Workflow",
|
||||
"recipeAdded": "Rezept zum Workflow hinzugefügt",
|
||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow"
|
||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Rezept",
|
||||
@@ -996,6 +1075,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Nach Updates suchen",
|
||||
"notificationsTitle": "Benachrichtigungszentrum",
|
||||
"tabs": {
|
||||
"updates": "Aktualisierungen",
|
||||
"messages": "Mitteilungen"
|
||||
},
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
|
||||
"currentVersion": "Aktuelle Version",
|
||||
@@ -1027,6 +1111,13 @@
|
||||
"nightly": {
|
||||
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
||||
"enable": "Nightly Updates aktivieren"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Neueste Mitteilungen",
|
||||
"empty": "Keine aktuellen Banner verfügbar.",
|
||||
"shown": "{time} angezeigt",
|
||||
"dismissed": "{time} geschlossen",
|
||||
"active": "Aktiv"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1237,12 @@
|
||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
||||
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||
"bulkUpdatesMissing": "Ausgewählte {type}-Modelle sind nicht mit Civitai-Updates verknüpft",
|
||||
"bulkUpdatesPartialMissing": "{missing} ausgewählte {type}-Modelle ohne Civitai-Verknüpfung übersprungen",
|
||||
"bulkUpdatesFailed": "Updates für ausgewählte {type}-Modelle konnten nicht geprüft werden: {message}",
|
||||
"invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt",
|
||||
"filenameCannotBeEmpty": "Dateiname darf nicht leer sein",
|
||||
"renameFailed": "Fehler beim Umbenennen der Datei: {message}",
|
||||
|
||||
130
locales/en.json
130
locales/en.json
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "Checkpoint name copied",
|
||||
"toggleBlur": "Toggle blur",
|
||||
"show": "Show",
|
||||
"openExampleImages": "Open Example Images Folder"
|
||||
"openExampleImages": "Open Example Images Folder",
|
||||
"replacePreview": "Replace Preview",
|
||||
"copyCheckpointName": "Copy checkpoint name",
|
||||
"copyEmbeddingName": "Copy embedding name",
|
||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Mature Content",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "Failed to update favorite status"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented"
|
||||
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented",
|
||||
"missingPath": "Unable to determine model path for this card"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Error checking for example images",
|
||||
"missingHash": "Missing model hash information.",
|
||||
"noRemoteImagesAvailable": "No remote example images available for this model on Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
"updateAvailable": "Update available"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "Set a download location before downloading example images.",
|
||||
"unavailable": "Example image downloads aren't available yet. Try again after the page finishes loading."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Check for updates",
|
||||
"loading": "Checking for {type} updates...",
|
||||
"success": "Found {count} update(s) for {type}s",
|
||||
"none": "All {type}s are up to date",
|
||||
"error": "Failed to check for {type} updates: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Clean up example image folders",
|
||||
"success": "Moved {count} folder(s) to the deleted folder",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Check Updates",
|
||||
"notifications": "Notifications",
|
||||
"support": "Support"
|
||||
}
|
||||
},
|
||||
@@ -220,7 +238,7 @@
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
"default": "Default",
|
||||
"medium": "Medium",
|
||||
"medium": "Medium",
|
||||
"compact": "Compact"
|
||||
},
|
||||
"displayDensityHelp": "Choose how many cards to display per row:",
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
|
||||
"showFolderSidebar": "Show Folder Sidebar",
|
||||
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
|
||||
"cardInfoDisplay": "Card Info Display",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Always Visible",
|
||||
"hover": "Reveal on Hover"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Headers and footers are always visible",
|
||||
"hover": "Headers and footers only appear when hovering over a card"
|
||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
||||
"modelCardFooterAction": "Model Card Button Action",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Open Example Images",
|
||||
"replacePreview": "Replace Preview"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Choose what the bottom-right card button does",
|
||||
"modelNameDisplay": "Model Name Display",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Model Name",
|
||||
"fileName": "File Name"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choose what to display in the model card footer:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "Display the model's descriptive name",
|
||||
"fileName": "Display the actual file name on disk"
|
||||
}
|
||||
"modelNameDisplayHelp": "Choose what to display in the model card footer"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Active Library",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh model list",
|
||||
"quick": "Quick Refresh (incremental)",
|
||||
"full": "Full Rebuild (complete)"
|
||||
"quick": "Sync Changes",
|
||||
"quickTooltip": "Scan for new or missing model files so the list stays current.",
|
||||
"full": "Rebuild Cache",
|
||||
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Fetch metadata from Civitai",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "Show Favorites Only",
|
||||
"action": "Favorites"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Show models with updates available",
|
||||
"action": "Updates",
|
||||
"menuLabel": "Show update options",
|
||||
"check": "Check updates",
|
||||
"checkTooltip": "Checking updates may take a while."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "Set Content Rating for Selected",
|
||||
"copyAll": "Copy Selected Syntax",
|
||||
"refreshAll": "Refresh Selected Metadata",
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
"moveAll": "Move Selected to Folder",
|
||||
"autoOrganize": "Auto-Organize Selected",
|
||||
"deleteAll": "Delete Selected Models",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "models will be permanently deleted.",
|
||||
"action": "Delete All"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Check updates for all {typePlural}?",
|
||||
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
||||
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
|
||||
"action": "Check All"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Add Tags to Multiple Models",
|
||||
"description": "Add tags to",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "Examples",
|
||||
"description": "Model Description",
|
||||
"recipes": "Recipes"
|
||||
"recipes": "Recipes",
|
||||
"versions": "Versions"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Loading example images...",
|
||||
"description": "Loading model description...",
|
||||
"recipes": "Loading recipes...",
|
||||
"examples": "Loading examples..."
|
||||
"examples": "Loading examples...",
|
||||
"versions": "Loading versions..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Model versions",
|
||||
"copy": "Track and manage every version of this model in one place.",
|
||||
"media": {
|
||||
"placeholder": "No preview"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Untitled Version",
|
||||
"noDetails": "No additional details"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Current Version",
|
||||
"inLibrary": "In Library",
|
||||
"newer": "Newer Version",
|
||||
"ignored": "Ignored"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"ignore": "Ignore",
|
||||
"unignore": "Unignore",
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
"viewLocalVersions": "View all local versions",
|
||||
"viewLocalTooltip": "Coming soon"
|
||||
},
|
||||
"empty": "No version history available for this model yet.",
|
||||
"error": "Failed to load versions.",
|
||||
"missingModelId": "This model is missing a Civitai model id.",
|
||||
"confirm": {
|
||||
"delete": "Delete this version from your library?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Updates ignored for this model",
|
||||
"modelResumed": "Update tracking resumed",
|
||||
"versionIgnored": "Updates ignored for this version",
|
||||
"versionUnignored": "Version re-enabled",
|
||||
"versionDeleted": "Version deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "Failed to send LoRA to workflow",
|
||||
"recipeAdded": "Recipe appended to workflow",
|
||||
"recipeReplaced": "Recipe replaced in workflow",
|
||||
"recipeFailedToSend": "Failed to send recipe to workflow"
|
||||
"recipeFailedToSend": "Failed to send recipe to workflow",
|
||||
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
||||
"noTargetNodeSelected": "No target node selected"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Check for Updates",
|
||||
"notificationsTitle": "Notifications",
|
||||
"tabs": {
|
||||
"updates": "Updates",
|
||||
"messages": "Messages"
|
||||
},
|
||||
"updateAvailable": "Update Available",
|
||||
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
|
||||
"currentVersion": "Current Version",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
||||
"enable": "Enable Nightly Updates"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Recent messages",
|
||||
"empty": "No recent banners yet.",
|
||||
"shown": "Shown {time}",
|
||||
"dismissed": "Dismissed {time}",
|
||||
"active": "Active"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
||||
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||
"bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates",
|
||||
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links",
|
||||
"bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}",
|
||||
"invalidCharactersRemoved": "Invalid characters removed from filename",
|
||||
"filenameCannotBeEmpty": "File name cannot be empty",
|
||||
"renameFailed": "Failed to rename file: {message}",
|
||||
|
||||
128
locales/es.json
128
locales/es.json
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||
"toggleBlur": "Alternar difuminado",
|
||||
"show": "Mostrar",
|
||||
"openExampleImages": "Abrir carpeta de imágenes de ejemplo"
|
||||
"openExampleImages": "Abrir carpeta de imágenes de ejemplo",
|
||||
"replacePreview": "Reemplazar vista previa",
|
||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Contenido para adultos",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "Error al actualizar estado de favoritos"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar"
|
||||
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar",
|
||||
"missingPath": "No se puede determinar la ruta del modelo para esta tarjeta"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Error al verificar imágenes de ejemplo",
|
||||
"missingHash": "Falta información del hash del modelo.",
|
||||
"noRemoteImagesAvailable": "No hay imágenes de ejemplo remotas disponibles para este modelo en Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Actualización",
|
||||
"updateAvailable": "Actualización disponible"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "Establece una ubicación de descarga antes de descargar imágenes de ejemplo.",
|
||||
"unavailable": "Las descargas de imágenes de ejemplo aún no están disponibles. Intenta de nuevo después de que la página termine de cargar."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Buscar actualizaciones",
|
||||
"loading": "Buscando actualizaciones de {type}...",
|
||||
"success": "Se encontraron {count} actualización(es) para {type}",
|
||||
"none": "Todos los {type} están actualizados",
|
||||
"error": "Error al buscar actualizaciones de {type}: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Limpiar carpetas de imágenes de ejemplo",
|
||||
"success": "Se movieron {count} carpeta(s) a la carpeta de eliminados",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
"notifications": "Notificaciones",
|
||||
"support": "Soporte"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
|
||||
"showFolderSidebar": "Mostrar barra lateral de carpetas",
|
||||
"showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.",
|
||||
"cardInfoDisplay": "Visualización de información de tarjeta",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Siempre visible",
|
||||
"hover": "Mostrar al pasar el ratón"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Los encabezados y pies de página siempre son visibles",
|
||||
"hover": "Los encabezados y pies de página solo aparecen al pasar el ratón sobre una tarjeta"
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Abrir imágenes de ejemplo",
|
||||
"replacePreview": "Reemplazar vista previa"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Elige qué hace el botón en la esquina inferior derecha de la tarjeta",
|
||||
"modelNameDisplay": "Visualización del nombre del modelo",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Nombre del modelo",
|
||||
"fileName": "Nombre del archivo"
|
||||
},
|
||||
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "Mostrar el nombre descriptivo del modelo",
|
||||
"fileName": "Mostrar el nombre real del archivo en el disco"
|
||||
}
|
||||
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Biblioteca activa",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de modelos",
|
||||
"quick": "Actualización rápida (incremental)",
|
||||
"full": "Reconstrucción completa"
|
||||
"quick": "Sincronizar cambios",
|
||||
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
|
||||
"full": "Reconstruir caché",
|
||||
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Obtener metadatos de Civitai",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "Mostrar solo favoritos",
|
||||
"action": "Favoritos"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Mostrar solo modelos con actualizaciones disponibles",
|
||||
"action": "Actualizaciones",
|
||||
"menuLabel": "Mostrar opciones de actualización",
|
||||
"check": "Buscar actualizaciones",
|
||||
"checkTooltip": "Comprobar actualizaciones puede tardar."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
"deleteAll": "Eliminar todos los modelos",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "modelos serán eliminados permanentemente.",
|
||||
"action": "Eliminar todo"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
||||
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
||||
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
|
||||
"action": "Comprobar todo"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Añadir etiquetas a múltiples modelos",
|
||||
"description": "Añadir etiquetas a",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "Ejemplos",
|
||||
"description": "Descripción del modelo",
|
||||
"recipes": "Recetas"
|
||||
"recipes": "Recetas",
|
||||
"versions": "Versiones"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Cargando imágenes de ejemplo...",
|
||||
"description": "Cargando descripción del modelo...",
|
||||
"recipes": "Cargando recetas...",
|
||||
"examples": "Cargando ejemplos..."
|
||||
"examples": "Cargando ejemplos...",
|
||||
"versions": "Cargando versiones..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versiones del modelo",
|
||||
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
|
||||
"media": {
|
||||
"placeholder": "Sin vista previa"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Versión sin nombre",
|
||||
"noDetails": "Sin detalles adicionales"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Versión actual",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"newer": "Versión más reciente",
|
||||
"ignored": "Ignorada"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Descargar",
|
||||
"delete": "Eliminar",
|
||||
"ignore": "Ignorar",
|
||||
"unignore": "Dejar de ignorar",
|
||||
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||
"viewLocalVersions": "Ver todas las versiones locales",
|
||||
"viewLocalTooltip": "Disponible pronto"
|
||||
},
|
||||
"empty": "Aún no hay historial de versiones para este modelo.",
|
||||
"error": "No se pudieron cargar las versiones.",
|
||||
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
||||
"confirm": {
|
||||
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Se ignoran las actualizaciones de este modelo",
|
||||
"modelResumed": "Seguimiento de actualizaciones reanudado",
|
||||
"versionIgnored": "Se ignoran las actualizaciones de esta versión",
|
||||
"versionUnignored": "Versión habilitada nuevamente",
|
||||
"versionDeleted": "Versión eliminada"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "Error al enviar LoRA al flujo de trabajo",
|
||||
"recipeAdded": "Receta añadida al flujo de trabajo",
|
||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo"
|
||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Receta",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Comprobar actualizaciones",
|
||||
"notificationsTitle": "Centro de notificaciones",
|
||||
"tabs": {
|
||||
"updates": "Actualizaciones",
|
||||
"messages": "Mensajes"
|
||||
},
|
||||
"updateAvailable": "Actualización disponible",
|
||||
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
|
||||
"currentVersion": "Versión actual",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
||||
"enable": "Habilitar actualizaciones nocturnas"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Notificaciones recientes",
|
||||
"empty": "No hay banners recientes.",
|
||||
"shown": "Mostrado {time}",
|
||||
"dismissed": "Descartado {time}",
|
||||
"active": "Activo"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
||||
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
||||
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
||||
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||
"bulkUpdatesMissing": "Los {type} seleccionados no están vinculados a actualizaciones de Civitai",
|
||||
"bulkUpdatesPartialMissing": "Se omitieron {missing} {type} seleccionados sin enlace de Civitai",
|
||||
"bulkUpdatesFailed": "Error al comprobar actualizaciones para los {type} seleccionados: {message}",
|
||||
"invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo",
|
||||
"filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío",
|
||||
"renameFailed": "Error al renombrar archivo: {message}",
|
||||
|
||||
130
locales/fr.json
130
locales/fr.json
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||
"toggleBlur": "Basculer le flou",
|
||||
"show": "Afficher",
|
||||
"openExampleImages": "Ouvrir le dossier d'images d'exemple"
|
||||
"openExampleImages": "Ouvrir le dossier d'images d'exemple",
|
||||
"replacePreview": "Remplacer l'aperçu",
|
||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Contenu pour adultes",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "Échec de la mise à jour du statut des favoris"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter"
|
||||
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter",
|
||||
"missingPath": "Impossible de déterminer le chemin du modèle pour cette carte"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Erreur lors de la vérification des images d'exemple",
|
||||
"missingHash": "Informations de hachage du modèle manquantes.",
|
||||
"noRemoteImagesAvailable": "Aucune image d'exemple distante disponible pour ce modèle sur Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Mise à jour",
|
||||
"updateAvailable": "Mise à jour disponible"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,8 +139,15 @@
|
||||
"missingPath": "Définissez un emplacement de téléchargement avant de télécharger les images d'exemple.",
|
||||
"unavailable": "Le téléchargement des images d'exemple n'est pas encore disponible. Réessayez après le chargement complet de la page."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Vérifier les mises à jour",
|
||||
"loading": "Recherche de mises à jour pour {type}...",
|
||||
"success": "{count} mise(s) à jour trouvée(s) pour {type}",
|
||||
"none": "Tous les {type} sont à jour",
|
||||
"error": "Échec de la vérification des mises à jour pour {type} : {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Nettoyer les dossiers d'images d'exemple",
|
||||
"label": "Supprimer les dossiers d'exemples orphelins",
|
||||
"success": "{count} dossier(s) déplacé(s) vers le dossier supprimé",
|
||||
"none": "Aucun dossier d'images d'exemple à nettoyer",
|
||||
"partial": "Nettoyage terminé avec {failures} dossier(s) ignoré(s)",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"notifications": "Notifications",
|
||||
"support": "Support"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
|
||||
"showFolderSidebar": "Afficher la barre latérale des dossiers",
|
||||
"showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.",
|
||||
"cardInfoDisplay": "Affichage des informations de carte",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Toujours visible",
|
||||
"hover": "Révéler au survol"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action :",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Les en-têtes et pieds de page sont toujours visibles",
|
||||
"hover": "Les en-têtes et pieds de page n'apparaissent qu'au survol d'une carte"
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Ouvrir les images d'exemple",
|
||||
"replacePreview": "Remplacer l'aperçu"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Choisissez ce que fait le bouton en bas à droite de la carte",
|
||||
"modelNameDisplay": "Affichage du nom du modèle",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Nom du modèle",
|
||||
"fileName": "Nom du fichier"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle :",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "Afficher le nom descriptif du modèle",
|
||||
"fileName": "Afficher le nom réel du fichier sur le disque"
|
||||
}
|
||||
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Bibliothèque active",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des modèles",
|
||||
"quick": "Actualisation rapide (incrémentale)",
|
||||
"full": "Reconstruction complète"
|
||||
"quick": "Synchroniser les changements",
|
||||
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
|
||||
"full": "Reconstruire le cache",
|
||||
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Récupérer les métadonnées depuis Civitai",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "Afficher uniquement les favoris",
|
||||
"action": "Favoris"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Afficher uniquement les modèles avec des mises à jour disponibles",
|
||||
"action": "Mises à jour",
|
||||
"menuLabel": "Afficher les options de mise à jour",
|
||||
"check": "Rechercher des mises à jour",
|
||||
"checkTooltip": "La vérification peut prendre du temps."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "Définir la classification du contenu pour tous",
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
"autoOrganize": "Auto-organiser la sélection",
|
||||
"deleteAll": "Supprimer tous les modèles",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "modèles seront définitivement supprimés.",
|
||||
"action": "Tout supprimer"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
||||
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
|
||||
"tip": "Besoin de procéder par étapes ? Passez en mode lot, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
|
||||
"action": "Tout vérifier"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Ajouter des tags à plusieurs modèles",
|
||||
"description": "Ajouter des tags à",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "Exemples",
|
||||
"description": "Description du modèle",
|
||||
"recipes": "Recipes"
|
||||
"recipes": "Recipes",
|
||||
"versions": "Versions"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Chargement des images d'exemple...",
|
||||
"description": "Chargement de la description du modèle...",
|
||||
"recipes": "Chargement des recipes...",
|
||||
"examples": "Chargement des exemples..."
|
||||
"examples": "Chargement des exemples...",
|
||||
"versions": "Chargement des versions..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versions du modèle",
|
||||
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
|
||||
"media": {
|
||||
"placeholder": "Aucune prévisualisation"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Version sans nom",
|
||||
"noDetails": "Aucun détail supplémentaire"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Version actuelle",
|
||||
"inLibrary": "Dans la bibliothèque",
|
||||
"newer": "Version plus récente",
|
||||
"ignored": "Ignorée"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Télécharger",
|
||||
"delete": "Supprimer",
|
||||
"ignore": "Ignorer",
|
||||
"unignore": "Ne plus ignorer",
|
||||
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||
"viewLocalVersions": "Voir toutes les versions locales",
|
||||
"viewLocalTooltip": "Bientôt disponible"
|
||||
},
|
||||
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
||||
"error": "Échec du chargement des versions.",
|
||||
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
||||
"confirm": {
|
||||
"delete": "Supprimer cette version de votre bibliothèque ?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Les mises à jour de ce modèle sont ignorées",
|
||||
"modelResumed": "Suivi des mises à jour repris",
|
||||
"versionIgnored": "Les mises à jour de cette version sont ignorées",
|
||||
"versionUnignored": "Version réactivée",
|
||||
"versionDeleted": "Version supprimée"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "Échec de l'envoi du LoRA au workflow",
|
||||
"recipeAdded": "Recipe ajoutée au workflow",
|
||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow"
|
||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Vérifier les mises à jour",
|
||||
"notificationsTitle": "Notifications",
|
||||
"tabs": {
|
||||
"updates": "Mises à jour",
|
||||
"messages": "Messages"
|
||||
},
|
||||
"updateAvailable": "Mise à jour disponible",
|
||||
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
|
||||
"currentVersion": "Version actuelle",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
||||
"enable": "Activer les mises à jour nightly"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Messages récents",
|
||||
"empty": "Aucune bannière récente.",
|
||||
"shown": "Affiché {time}",
|
||||
"dismissed": "Ignoré {time}",
|
||||
"active": "Actif"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
||||
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
||||
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
|
||||
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||
"bulkUpdatesMissing": "Les {type} sélectionnés ne sont pas liés aux mises à jour Civitai",
|
||||
"bulkUpdatesPartialMissing": "{missing} {type} sélectionnés sans lien Civitai ignorés",
|
||||
"bulkUpdatesFailed": "Échec de la vérification des mises à jour pour les {type} sélectionnés : {message}",
|
||||
"invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier",
|
||||
"filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide",
|
||||
"renameFailed": "Échec du renommage du fichier : {message}",
|
||||
|
||||
128
locales/he.json
128
locales/he.json
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "שם Checkpoint הועתק",
|
||||
"toggleBlur": "הפעל/כבה טשטוש",
|
||||
"show": "הצג",
|
||||
"openExampleImages": "פתח תיקיית תמונות דוגמה"
|
||||
"openExampleImages": "פתח תיקיית תמונות דוגמה",
|
||||
"replacePreview": "החלף תצוגה מקדימה",
|
||||
"copyCheckpointName": "העתק שם Checkpoint",
|
||||
"copyEmbeddingName": "העתק שם Embedding",
|
||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "תוכן למבוגרים",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "עדכון סטטוס מועדפים נכשל"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "שליחת checkpoint ל-workflow - תכונה שתיושם בעתיד"
|
||||
"checkpointNotImplemented": "שליחת checkpoint ל-workflow - תכונה שתיושם בעתיד",
|
||||
"missingPath": "לא ניתן לקבוע את נתיב המודל לכרטיס זה"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "שגיאה בבדיקת תמונות דוגמה",
|
||||
"missingHash": "חסר מידע hash של המודל.",
|
||||
"noRemoteImagesAvailable": "אין תמונות דוגמה מרוחקות זמינות למודל זה ב-Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "עדכון",
|
||||
"updateAvailable": "עדכון זמין"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "הגדר מיקום הורדה לפני הורדת תמונות דוגמה.",
|
||||
"unavailable": "הורדות תמונות דוגמה אינן זמינות עדיין. נסה שוב לאחר שהדף מסיים להיטען."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "בדוק עדכונים",
|
||||
"loading": "בודק עדכונים עבור {type}...",
|
||||
"success": "נמצאו {count} עדכונים עבור {type}",
|
||||
"none": "כל ה-{type} מעודכנים",
|
||||
"error": "נכשל בבדיקת העדכונים עבור {type}: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "נקה תיקיות תמונות דוגמה",
|
||||
"success": "הועברו {count} תיקיות לתיקיית המחוקים",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
"notifications": "התראות",
|
||||
"support": "תמיכה"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
|
||||
"showFolderSidebar": "הצג סרגל צד תיקיות",
|
||||
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
|
||||
"cardInfoDisplay": "תצוגת מידע בכרטיס",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "תמיד גלוי",
|
||||
"hover": "חשוף בריחוף"
|
||||
},
|
||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "כותרות עליונות ותחתונות תמיד גלויות",
|
||||
"hover": "כותרות עליונות ותחתונות מופיעות רק בעת ריחוף מעל כרטיס"
|
||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "פתח תמונות דוגמה",
|
||||
"replacePreview": "החלף תצוגה מקדימה"
|
||||
},
|
||||
"modelCardFooterActionHelp": "בחר מה עושה הכפתור בפינה הימנית התחתונה של הכרטיס",
|
||||
"modelNameDisplay": "תצוגת שם מודל",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "שם מודל",
|
||||
"fileName": "שם קובץ"
|
||||
},
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "הצג את השם התיאורי של המודל",
|
||||
"fileName": "הצג את שם הקובץ בפועל בדיסק"
|
||||
}
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "ספרייה פעילה",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מודלים",
|
||||
"quick": "רענון מהיר (מצטבר)",
|
||||
"full": "בנייה מחדש מלאה (שלם)"
|
||||
"quick": "סנכרון שינויים",
|
||||
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
|
||||
"full": "בניית מטמון מחדש",
|
||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "אחזר מטא-דאטה מ-Civitai",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "הצג מועדפים בלבד",
|
||||
"action": "מועדפים"
|
||||
},
|
||||
"updates": {
|
||||
"title": "הצג רק דגמים עם עדכונים זמינים",
|
||||
"action": "עדכונים",
|
||||
"menuLabel": "הצגת אפשרויות עדכון",
|
||||
"check": "בדוק עדכונים",
|
||||
"checkTooltip": "בדיקת עדכונים עלולה לקחת זמן."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||
"copyAll": "העתק את כל התחבירים",
|
||||
"refreshAll": "רענן את כל המטא-דאטה",
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
"moveAll": "העבר הכל לתיקייה",
|
||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||
"deleteAll": "מחק את כל המודלים",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "מודלים יימחקו לצמיתות.",
|
||||
"action": "מחק הכל"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
||||
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
||||
"tip": "רוצים לחלק למנות קטנות? עברו למצב קבוצתי, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
|
||||
"action": "בדוק הכל"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "הוסף תגיות למספר מודלים",
|
||||
"description": "הוסף תגיות ל-",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "דוגמאות",
|
||||
"description": "תיאור המודל",
|
||||
"recipes": "מתכונים"
|
||||
"recipes": "מתכונים",
|
||||
"versions": "גרסאות"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "טוען תמונות דוגמה...",
|
||||
"description": "טוען תיאור מודל...",
|
||||
"recipes": "טוען מתכונים...",
|
||||
"examples": "טוען דוגמאות..."
|
||||
"examples": "טוען דוגמאות...",
|
||||
"versions": "טוען גרסאות..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "גרסאות המודל",
|
||||
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
|
||||
"media": {
|
||||
"placeholder": "אין תצוגה מקדימה"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "גרסה ללא שם",
|
||||
"noDetails": "אין פרטים נוספים"
|
||||
},
|
||||
"badges": {
|
||||
"current": "גרסה נוכחית",
|
||||
"inLibrary": "בספרייה",
|
||||
"newer": "גרסה חדשה יותר",
|
||||
"ignored": "התעלם"
|
||||
},
|
||||
"actions": {
|
||||
"download": "הורדה",
|
||||
"delete": "מחיקה",
|
||||
"ignore": "התעלם",
|
||||
"unignore": "בטל התעלמות",
|
||||
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
|
||||
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
|
||||
"viewLocalVersions": "הצג את כל הגרסאות המקומיות",
|
||||
"viewLocalTooltip": "יגיע בקרוב"
|
||||
},
|
||||
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
|
||||
"error": "טעינת הגרסאות נכשלה.",
|
||||
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",
|
||||
"confirm": {
|
||||
"delete": "למחוק גרסה זו מהספרייה שלך?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "העדכונים עבור מודל זה נוגבו",
|
||||
"modelResumed": "מעקב העדכונים חודש",
|
||||
"versionIgnored": "העדכונים עבור גרסה זו נוגבו",
|
||||
"versionUnignored": "הגרסה הופעלה מחדש",
|
||||
"versionDeleted": "הגרסה נמחקה"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "שליחת LoRA ל-workflow נכשלה",
|
||||
"recipeAdded": "מתכון נוסף ל-workflow",
|
||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה"
|
||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "מתכון",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "בדוק עדכונים",
|
||||
"notificationsTitle": "מרכז התראות",
|
||||
"tabs": {
|
||||
"updates": "עדכונים",
|
||||
"messages": "הודעות"
|
||||
},
|
||||
"updateAvailable": "עדכון זמין",
|
||||
"noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.",
|
||||
"currentVersion": "גרסה נוכחית",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
|
||||
"enable": "הפעל עדכונים ליליים"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "הודעות אחרונות",
|
||||
"empty": "אין כרגע באנרים אחרונים.",
|
||||
"shown": "הוצג {time}",
|
||||
"dismissed": "הוסר {time}",
|
||||
"active": "פעיל"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
||||
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
||||
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
||||
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
||||
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
||||
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
||||
"bulkUpdatesMissing": "ה-{type} שנבחרו אינם מקושרים לעדכוני Civitai",
|
||||
"bulkUpdatesPartialMissing": "דילג על {missing} {type} שנבחרו ללא קישור Civitai",
|
||||
"bulkUpdatesFailed": "בדיקת העדכונים עבור {type} שנבחרו נכשלה: {message}",
|
||||
"invalidCharactersRemoved": "תווים לא חוקיים הוסרו משם הקובץ",
|
||||
"filenameCannotBeEmpty": "שם הקובץ אינו יכול להיות ריק",
|
||||
"renameFailed": "שינוי שם הקובץ נכשל: {message}",
|
||||
|
||||
128
locales/ja.json
128
locales/ja.json
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||
"toggleBlur": "ぼかしの切り替え",
|
||||
"show": "表示",
|
||||
"openExampleImages": "例画像フォルダを開く"
|
||||
"openExampleImages": "例画像フォルダを開く",
|
||||
"replacePreview": "プレビューを置換",
|
||||
"copyCheckpointName": "checkpoint名をコピー",
|
||||
"copyEmbeddingName": "embedding名をコピー",
|
||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成人向けコンテンツ",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "お気に入り状態の更新に失敗しました"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能"
|
||||
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能",
|
||||
"missingPath": "このカードのモデルパスを特定できません"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "例画像の確認中にエラーが発生しました",
|
||||
"missingHash": "モデルハッシュ情報がありません。",
|
||||
"noRemoteImagesAvailable": "このモデルのCivitaiでのリモート例画像は利用できません"
|
||||
},
|
||||
"badges": {
|
||||
"update": "アップデート",
|
||||
"updateAvailable": "アップデートがあります"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "例画像をダウンロードする前にダウンロード場所を設定してください。",
|
||||
"unavailable": "例画像のダウンロードはまだ利用できません。ページの読み込みが完了してから再度お試しください。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "アップデートを確認",
|
||||
"loading": "{type} のアップデートを確認中…",
|
||||
"success": "{type} のアップデートが {count} 件見つかりました",
|
||||
"none": "すべての {type} は最新です",
|
||||
"error": "{type} のアップデート確認に失敗しました: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "例画像フォルダをクリーンアップ",
|
||||
"success": "{count} 個のフォルダを削除フォルダに移動しました",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "更新確認",
|
||||
"notifications": "通知",
|
||||
"support": "サポート"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
||||
"showFolderSidebar": "フォルダサイドバーを表示",
|
||||
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
|
||||
"cardInfoDisplay": "カード情報表示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "常に表示",
|
||||
"hover": "ホバー時に表示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "ヘッダーとフッターが常に表示されます",
|
||||
"hover": "カードにホバーしたときのみヘッダーとフッターが表示されます"
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "例画像を開く",
|
||||
"replacePreview": "プレビューを置換"
|
||||
},
|
||||
"modelCardFooterActionHelp": "カード右下のボタンが何をするかを選択します",
|
||||
"modelNameDisplay": "モデル名表示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "モデル名",
|
||||
"fileName": "ファイル名"
|
||||
},
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "モデルの説明的な名前を表示",
|
||||
"fileName": "ディスク上の実際のファイル名を表示"
|
||||
}
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "アクティブライブラリ",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
"quick": "クイック更新(増分)",
|
||||
"full": "完全再構築(完全)"
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Civitaiからメタデータを取得",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "お気に入りのみ表示",
|
||||
"action": "お気に入り"
|
||||
},
|
||||
"updates": {
|
||||
"title": "アップデート可能なモデルのみ表示",
|
||||
"action": "アップデート",
|
||||
"menuLabel": "更新オプションを表示",
|
||||
"check": "アップデートを確認",
|
||||
"checkTooltip": "確認には時間がかかる場合があります。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
"autoOrganize": "自動整理を実行",
|
||||
"deleteAll": "すべてのモデルを削除",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "モデルが完全に削除されます。",
|
||||
"action": "すべて削除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "すべての{type}の更新を確認しますか?",
|
||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||
"tip": "少しずつ確認したい場合はバルクモードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||
"action": "すべて確認"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "複数モデルにタグを追加",
|
||||
"description": "タグを追加するモデル:",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "例",
|
||||
"description": "モデル説明",
|
||||
"recipes": "レシピ"
|
||||
"recipes": "レシピ",
|
||||
"versions": "バージョン"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "例画像を読み込み中...",
|
||||
"description": "モデル説明を読み込み中...",
|
||||
"recipes": "レシピを読み込み中...",
|
||||
"examples": "例を読み込み中..."
|
||||
"examples": "例を読み込み中...",
|
||||
"versions": "バージョンを読み込み中..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
||||
"media": {
|
||||
"placeholder": "プレビューなし"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "名前のないバージョン",
|
||||
"noDetails": "追加情報なし"
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在のバージョン",
|
||||
"inLibrary": "ライブラリにあります",
|
||||
"newer": "新しいバージョン",
|
||||
"ignored": "無視中"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"delete": "削除",
|
||||
"ignore": "無視",
|
||||
"unignore": "無視を解除",
|
||||
"resumeModelUpdates": "このモデルの更新を再開",
|
||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||
"viewLocalVersions": "ローカルの全バージョンを表示",
|
||||
"viewLocalTooltip": "近日対応予定"
|
||||
},
|
||||
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||
"error": "バージョンの読み込みに失敗しました。",
|
||||
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
||||
"confirm": {
|
||||
"delete": "このバージョンをライブラリから削除しますか?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "このモデルの更新は無視されます",
|
||||
"modelResumed": "更新の監視を再開しました",
|
||||
"versionIgnored": "このバージョンの更新は無視されます",
|
||||
"versionUnignored": "バージョンを再度有効にしました",
|
||||
"versionDeleted": "バージョンを削除しました"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "LoRAをワークフローに送信できませんでした",
|
||||
"recipeAdded": "レシピがワークフローに追加されました",
|
||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした"
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "更新確認",
|
||||
"notificationsTitle": "通知センター",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "メッセージ"
|
||||
},
|
||||
"updateAvailable": "更新が利用可能",
|
||||
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
|
||||
"currentVersion": "現在のバージョン",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
||||
"enable": "ナイトリー更新を有効にする"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最近の通知",
|
||||
"empty": "最近のバナーはありません。",
|
||||
"shown": "{time} に表示",
|
||||
"dismissed": "{time} に非表示",
|
||||
"active": "アクティブ"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||
"bulkUpdatesMissing": "選択された{type}はCivitaiの更新にリンクされていません",
|
||||
"bulkUpdatesPartialMissing": "Civitaiリンクがない{missing} 件の{type}をスキップしました",
|
||||
"bulkUpdatesFailed": "選択された{type}の更新確認に失敗しました: {message}",
|
||||
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
|
||||
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
|
||||
"renameFailed": "ファイル名の変更に失敗しました:{message}",
|
||||
|
||||
128
locales/ko.json
128
locales/ko.json
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||
"toggleBlur": "블러 토글",
|
||||
"show": "보기",
|
||||
"openExampleImages": "예시 이미지 폴더 열기"
|
||||
"openExampleImages": "예시 이미지 폴더 열기",
|
||||
"replacePreview": "미리보기 교체",
|
||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||
"copyEmbeddingName": "Embedding 이름 복사",
|
||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "성인 콘텐츠",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "즐겨찾기 상태 업데이트 실패"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능"
|
||||
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능",
|
||||
"missingPath": "이 카드의 모델 경로를 확인할 수 없습니다"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "예시 이미지 확인 중 오류",
|
||||
"missingHash": "모델 해시 정보가 없습니다.",
|
||||
"noRemoteImagesAvailable": "Civitai에서 이 모델의 원격 예시 이미지를 사용할 수 없습니다"
|
||||
},
|
||||
"badges": {
|
||||
"update": "업데이트",
|
||||
"updateAvailable": "업데이트 가능"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "예시 이미지를 다운로드하기 전에 다운로드 위치를 설정하세요.",
|
||||
"unavailable": "예시 이미지 다운로드는 아직 사용할 수 없습니다. 페이지 로딩이 완료된 후 다시 시도하세요."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "업데이트 확인",
|
||||
"loading": "{type} 업데이트를 확인 중...",
|
||||
"success": "{type} 업데이트 {count}개를 찾았습니다",
|
||||
"none": "모든 {type}가 최신 상태입니다",
|
||||
"error": "{type} 업데이트 확인 실패: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "예시 이미지 폴더 정리",
|
||||
"success": "{count}개의 폴더가 삭제 폴더로 이동되었습니다",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"notifications": "알림",
|
||||
"support": "지원"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
||||
"showFolderSidebar": "폴더 사이드바 표시",
|
||||
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
|
||||
"cardInfoDisplay": "카드 정보 표시",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "항상 표시",
|
||||
"hover": "호버 시 표시"
|
||||
},
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "헤더와 푸터가 항상 보입니다",
|
||||
"hover": "카드에 마우스를 올렸을 때만 헤더와 푸터가 나타납니다"
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "예시 이미지 열기",
|
||||
"replacePreview": "미리보기 교체"
|
||||
},
|
||||
"modelCardFooterActionHelp": "카드 우측 하단 버튼이 수행할 작업을 선택하세요",
|
||||
"modelNameDisplay": "모델명 표시",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "모델명",
|
||||
"fileName": "파일명"
|
||||
},
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "모델의 설명적 이름 표시",
|
||||
"fileName": "디스크의 실제 파일명 표시"
|
||||
}
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "활성 라이브러리",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
"quick": "빠른 새로고침 (증분)",
|
||||
"full": "전체 재구성 (완전)"
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Civitai에서 메타데이터 가져오기",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "즐겨찾기만 보기",
|
||||
"action": "즐겨찾기"
|
||||
},
|
||||
"updates": {
|
||||
"title": "업데이트 가능한 모델만 표시",
|
||||
"action": "업데이트",
|
||||
"menuLabel": "업데이트 옵션 표시",
|
||||
"check": "업데이트 확인",
|
||||
"checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
"deleteAll": "모든 모델 삭제",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||
"action": "모두 삭제"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||
"tip": "나눠서 진행하고 싶다면 벌크 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||
"action": "전체 확인"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "여러 모델에 태그 추가",
|
||||
"description": "다음에 태그를 추가합니다:",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "예시",
|
||||
"description": "모델 설명",
|
||||
"recipes": "레시피"
|
||||
"recipes": "레시피",
|
||||
"versions": "버전"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "예시 이미지 로딩 중...",
|
||||
"description": "모델 설명 로딩 중...",
|
||||
"recipes": "레시피 로딩 중...",
|
||||
"examples": "예시 로딩 중..."
|
||||
"examples": "예시 로딩 중...",
|
||||
"versions": "버전 로딩 중..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
||||
"media": {
|
||||
"placeholder": "미리보기 없음"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "이름 없는 버전",
|
||||
"noDetails": "추가 정보 없음"
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재 버전",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"newer": "최신 버전",
|
||||
"ignored": "무시됨"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"delete": "삭제",
|
||||
"ignore": "무시",
|
||||
"unignore": "무시 해제",
|
||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||
"viewLocalVersions": "로컬 버전 모두 보기",
|
||||
"viewLocalTooltip": "곧 제공 예정"
|
||||
},
|
||||
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||
"error": "버전을 불러오지 못했습니다.",
|
||||
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
||||
"confirm": {
|
||||
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "이 모델의 업데이트가 무시됩니다",
|
||||
"modelResumed": "업데이트 추적이 재개되었습니다",
|
||||
"versionIgnored": "이 버전의 업데이트가 무시됩니다",
|
||||
"versionUnignored": "버전이 다시 활성화되었습니다",
|
||||
"versionDeleted": "버전이 삭제되었습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "LoRA를 워크플로로 전송하지 못했습니다",
|
||||
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
|
||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다"
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "업데이트 확인",
|
||||
"notificationsTitle": "알림 센터",
|
||||
"tabs": {
|
||||
"updates": "업데이트",
|
||||
"messages": "메시지"
|
||||
},
|
||||
"updateAvailable": "업데이트 사용 가능",
|
||||
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
|
||||
"currentVersion": "현재 버전",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
||||
"enable": "나이틀리 업데이트 활성화"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "최근 알림",
|
||||
"empty": "최근 배너가 없습니다.",
|
||||
"shown": "{time}에 표시",
|
||||
"dismissed": "{time}에 닫힘",
|
||||
"active": "활성"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||
"bulkUpdatesMissing": "선택한 {type}이 Civitai 업데이트에 연결되어 있지 않습니다",
|
||||
"bulkUpdatesPartialMissing": "Civitai 링크가 없는 {missing}개의 {type}을 건너뛰었습니다",
|
||||
"bulkUpdatesFailed": "선택한 {type}의 업데이트 확인에 실패했습니다: {message}",
|
||||
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
|
||||
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
|
||||
"renameFailed": "파일 이름 변경 실패: {message}",
|
||||
|
||||
128
locales/ru.json
128
locales/ru.json
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||
"toggleBlur": "Переключить размытие",
|
||||
"show": "Показать",
|
||||
"openExampleImages": "Открыть папку с примерами"
|
||||
"openExampleImages": "Открыть папку с примерами",
|
||||
"replacePreview": "Заменить превью",
|
||||
"copyCheckpointName": "Копировать имя checkpoint",
|
||||
"copyEmbeddingName": "Копировать имя embedding",
|
||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Контент для взрослых",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "Не удалось обновить статус избранного"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована"
|
||||
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована",
|
||||
"missingPath": "Невозможно определить путь модели для этой карточки"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Ошибка проверки примеров изображений",
|
||||
"missingHash": "Отсутствует хеш модели.",
|
||||
"noRemoteImagesAvailable": "Нет удаленных примеров изображений для этой модели на Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Обновление",
|
||||
"updateAvailable": "Доступно обновление"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "Укажите место загрузки перед загрузкой примеров изображений.",
|
||||
"unavailable": "Загрузка примеров изображений пока недоступна. Попробуйте снова после полной загрузки страницы."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Проверить обновления",
|
||||
"loading": "Проверка обновлений для {type}...",
|
||||
"success": "Найдено {count} обновлений для {type}",
|
||||
"none": "Все {type} актуальны",
|
||||
"error": "Не удалось проверить обновления для {type}: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Очистить папки с примерами изображений",
|
||||
"success": "Перемещено {count} папок в папку удалённых",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Проверить обновления",
|
||||
"notifications": "Уведомления",
|
||||
"support": "Поддержка"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
||||
"showFolderSidebar": "Показывать боковую панель папок",
|
||||
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
|
||||
"cardInfoDisplay": "Отображение информации карточки",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Всегда видимо",
|
||||
"hover": "Показать при наведении"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Заголовки и подписи всегда видны",
|
||||
"hover": "Заголовки и подписи появляются только при наведении на карточку"
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Открыть примеры изображений",
|
||||
"replacePreview": "Заменить превью"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Выберите, что делает кнопка в правом нижнем углу карточки",
|
||||
"modelNameDisplay": "Отображение названия модели",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Название модели",
|
||||
"fileName": "Имя файла"
|
||||
},
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "Отображать описательное название модели",
|
||||
"fileName": "Отображать фактическое имя файла на диске"
|
||||
}
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Активная библиотека",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
"quick": "Быстрое обновление (инкрементальное)",
|
||||
"full": "Полная перестройка (полное)"
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Получить метаданные с Civitai",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "Показать только избранное",
|
||||
"action": "Избранное"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Показывать только модели с доступными обновлениями",
|
||||
"action": "Обновления",
|
||||
"menuLabel": "Показать параметры обновления",
|
||||
"check": "Проверить обновления",
|
||||
"checkTooltip": "Проверка может занять время."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "Установить рейтинг контента для всех",
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
"deleteAll": "Удалить все модели",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "моделей будут удалены навсегда.",
|
||||
"action": "Удалить все"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Проверить обновления для всех {typePlural}?",
|
||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
|
||||
"action": "Проверить всё"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Добавить теги к нескольким моделям",
|
||||
"description": "Добавить теги к",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "Примеры",
|
||||
"description": "Описание модели",
|
||||
"recipes": "Рецепты"
|
||||
"recipes": "Рецепты",
|
||||
"versions": "Версии"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Загрузка примеров изображений...",
|
||||
"description": "Загрузка описания модели...",
|
||||
"recipes": "Загрузка рецептов...",
|
||||
"examples": "Загрузка примеров..."
|
||||
"examples": "Загрузка примеров...",
|
||||
"versions": "Загрузка версий..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Версии модели",
|
||||
"copy": "Управляйте всеми версиями этой модели в одном месте.",
|
||||
"media": {
|
||||
"placeholder": "Нет превью"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Версия без названия",
|
||||
"noDetails": "Дополнительная информация отсутствует"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Текущая версия",
|
||||
"inLibrary": "В библиотеке",
|
||||
"newer": "Более новая версия",
|
||||
"ignored": "Игнорируется"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"delete": "Удалить",
|
||||
"ignore": "Игнорировать",
|
||||
"unignore": "Перестать игнорировать",
|
||||
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||
"viewLocalVersions": "Показать все локальные версии",
|
||||
"viewLocalTooltip": "Скоро появится"
|
||||
},
|
||||
"empty": "Для этой модели пока нет истории версий.",
|
||||
"error": "Не удалось загрузить версии.",
|
||||
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
||||
"confirm": {
|
||||
"delete": "Удалить эту версию из библиотеки?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Обновления для этой модели игнорируются",
|
||||
"modelResumed": "Отслеживание обновлений возобновлено",
|
||||
"versionIgnored": "Обновления для этой версии игнорируются",
|
||||
"versionUnignored": "Версия снова активна",
|
||||
"versionDeleted": "Версия удалена"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "Не удалось отправить LoRA в workflow",
|
||||
"recipeAdded": "Рецепт добавлен в workflow",
|
||||
"recipeReplaced": "Рецепт заменён в workflow",
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow"
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||
"noTargetNodeSelected": "Целевой узел не выбран"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Проверить обновления",
|
||||
"notificationsTitle": "Центр уведомлений",
|
||||
"tabs": {
|
||||
"updates": "Обновления",
|
||||
"messages": "Сообщения"
|
||||
},
|
||||
"updateAvailable": "Доступно обновление",
|
||||
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
|
||||
"currentVersion": "Текущая версия",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
||||
"enable": "Включить ночные обновления"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Недавние уведомления",
|
||||
"empty": "Недавних баннеров нет.",
|
||||
"shown": "Показано {time}",
|
||||
"dismissed": "Закрыто {time}",
|
||||
"active": "Активно"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||
"bulkUpdatesMissing": "Выбранные {type} не привязаны к обновлениям Civitai",
|
||||
"bulkUpdatesPartialMissing": "Пропущено {missing} выбранных {type} без привязки Civitai",
|
||||
"bulkUpdatesFailed": "Не удалось проверить обновления для выбранных {type}: {message}",
|
||||
"invalidCharactersRemoved": "Недопустимые символы удалены из имени файла",
|
||||
"filenameCannotBeEmpty": "Имя файла не может быть пустым",
|
||||
"renameFailed": "Не удалось переименовать файл: {message}",
|
||||
|
||||
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "检查点名称已复制",
|
||||
"toggleBlur": "切换模糊",
|
||||
"show": "显示",
|
||||
"openExampleImages": "打开示例图片文件夹"
|
||||
"openExampleImages": "打开示例图片文件夹",
|
||||
"replacePreview": "替换预览",
|
||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||
"copyEmbeddingName": "复制 Embedding 名称",
|
||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成熟内容",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "收藏状态更新失败"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现"
|
||||
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现",
|
||||
"missingPath": "无法确定此卡片的模型路径"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "检查示例图片时出错",
|
||||
"missingHash": "缺少模型哈希信息。",
|
||||
"noRemoteImagesAvailable": "此模型在 Civitai 上没有远程示例图片"
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
"updateAvailable": "有可用更新"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "请先设置下载位置后再下载示例图片。",
|
||||
"unavailable": "示例图片下载当前不可用。请在页面加载完成后重试。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "检查更新",
|
||||
"loading": "正在检查 {type} 更新...",
|
||||
"success": "找到 {count} 条 {type} 更新",
|
||||
"none": "所有 {type} 均已是最新版本",
|
||||
"error": "检查 {type} 更新失败:{message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "清理示例图片文件夹",
|
||||
"success": "已将 {count} 个文件夹移动到已删除文件夹",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "检查更新",
|
||||
"notifications": "通知",
|
||||
"support": "支持"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7(1080p),8(2K),10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
||||
"showFolderSidebar": "显示文件夹侧边栏",
|
||||
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
|
||||
"cardInfoDisplay": "卡片信息显示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "始终可见",
|
||||
"hover": "悬停时显示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "标题和底部始终显示",
|
||||
"hover": "仅在悬停卡片时显示标题和底部"
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||
"modelCardFooterAction": "模型卡片按钮操作",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "打开示例图片",
|
||||
"replacePreview": "替换预览"
|
||||
},
|
||||
"modelCardFooterActionHelp": "选择右下角卡片按钮的功能",
|
||||
"modelNameDisplay": "模型名称显示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "模型名称",
|
||||
"fileName": "文件名"
|
||||
},
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "显示模型的描述性名称",
|
||||
"fileName": "显示磁盘上的实际文件名"
|
||||
}
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "活动库",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
"quick": "快速刷新(增量)",
|
||||
"full": "完全重建(完整)"
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "从 Civitai 获取元数据",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "仅显示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"updates": {
|
||||
"title": "仅显示可用更新的模型",
|
||||
"action": "更新",
|
||||
"menuLabel": "显示更新选项",
|
||||
"check": "检查更新",
|
||||
"checkTooltip": "检查更新可能耗时。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "为所选中设置内容评级",
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
"deleteAll": "删除选中模型",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "模型将被永久删除。",
|
||||
"action": "全部删除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "检查所有 {type} 的更新?",
|
||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
||||
"action": "检查全部"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "批量添加标签",
|
||||
"description": "为多个模型添加标签",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "示例",
|
||||
"description": "模型描述",
|
||||
"recipes": "配方"
|
||||
"recipes": "配方",
|
||||
"versions": "版本"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "正在加载示例图片...",
|
||||
"description": "正在加载模型描述...",
|
||||
"recipes": "正在加载配方...",
|
||||
"examples": "正在加载示例..."
|
||||
"examples": "正在加载示例...",
|
||||
"versions": "正在加载版本..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在一个位置管理该模型的所有版本。",
|
||||
"media": {
|
||||
"placeholder": "无预览"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "未命名版本",
|
||||
"noDetails": "暂无更多信息"
|
||||
},
|
||||
"badges": {
|
||||
"current": "当前版本",
|
||||
"inLibrary": "已在库中",
|
||||
"newer": "较新的版本",
|
||||
"ignored": "已忽略"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"delete": "删除",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||
"ignoreModelUpdates": "忽略该模型的更新",
|
||||
"viewLocalVersions": "查看所有本地版本",
|
||||
"viewLocalTooltip": "敬请期待"
|
||||
},
|
||||
"empty": "该模型还没有版本历史。",
|
||||
"error": "加载版本失败。",
|
||||
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
||||
"confirm": {
|
||||
"delete": "从库中删除此版本?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "已忽略该模型的更新",
|
||||
"modelResumed": "已恢复更新跟踪",
|
||||
"versionIgnored": "已忽略该版本的更新",
|
||||
"versionUnignored": "已重新启用该版本",
|
||||
"versionDeleted": "版本已删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "发送 LoRA 到工作流失败",
|
||||
"recipeAdded": "配方已追加到工作流",
|
||||
"recipeReplaced": "配方已替换到工作流",
|
||||
"recipeFailedToSend": "发送配方到工作流失败"
|
||||
"recipeFailedToSend": "发送配方到工作流失败",
|
||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||
"noTargetNodeSelected": "未选择目标节点"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "检查更新",
|
||||
"notificationsTitle": "通知中心",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "消息"
|
||||
},
|
||||
"updateAvailable": "更新可用",
|
||||
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
|
||||
"currentVersion": "当前版本",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
||||
"enable": "启用 Nightly 更新"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最近的通知",
|
||||
"empty": "暂无最近的横幅通知。",
|
||||
"shown": "{time} 显示",
|
||||
"dismissed": "{time} 关闭",
|
||||
"active": "仍在显示"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||
"bulkUpdatesMissing": "所选 {type} 未关联 Civitai 更新",
|
||||
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 Civitai 的所选 {type}",
|
||||
"bulkUpdatesFailed": "检查所选 {type} 的更新失败:{message}",
|
||||
"invalidCharactersRemoved": "文件名中的无效字符已移除",
|
||||
"filenameCannotBeEmpty": "文件名不能为空",
|
||||
"renameFailed": "重命名文件失败:{message}",
|
||||
@@ -1305,10 +1401,10 @@
|
||||
"seconds": "秒后刷新"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
"title": "LM 浏览器插件限时优惠 ⚡",
|
||||
"content": "来爱发电为Lora Manager项目发电,支持项目持续开发的同时,获取浏览器插件验证码,按季支付更优惠!支付宝/微信方便支付。感谢支持!🚀",
|
||||
"supportCta": "为LM发电",
|
||||
"learnMore": "浏览器插件教程"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,12 @@
|
||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||
"toggleBlur": "切換模糊",
|
||||
"show": "顯示",
|
||||
"openExampleImages": "開啟範例圖片資料夾"
|
||||
"openExampleImages": "開啟範例圖片資料夾",
|
||||
"replacePreview": "更換預覽圖",
|
||||
"copyCheckpointName": "複製檢查點名稱",
|
||||
"copyEmbeddingName": "複製嵌入名稱",
|
||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成熟內容",
|
||||
@@ -115,12 +120,17 @@
|
||||
"updateFailed": "更新收藏狀態失敗"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現"
|
||||
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現",
|
||||
"missingPath": "無法確定此卡片的模型路徑"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "檢查範例圖片時發生錯誤",
|
||||
"missingHash": "缺少模型雜湊資訊。",
|
||||
"noRemoteImagesAvailable": "此模型在 Civitai 上無遠端範例圖片"
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
"updateAvailable": "有可用更新"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -129,6 +139,13 @@
|
||||
"missingPath": "請先設定下載位置再下載範例圖片。",
|
||||
"unavailable": "範例圖片下載目前尚不可用。請在頁面載入完成後再試一次。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "檢查更新",
|
||||
"loading": "正在檢查 {type} 更新...",
|
||||
"success": "找到 {count} 個 {type} 更新",
|
||||
"none": "所有 {type} 都是最新版本",
|
||||
"error": "檢查 {type} 更新失敗:{message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "清理範例圖片資料夾",
|
||||
"success": "已將 {count} 個資料夾移至已刪除資料夾",
|
||||
@@ -181,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
"notifications": "通知",
|
||||
"support": "支援"
|
||||
}
|
||||
},
|
||||
@@ -230,26 +248,26 @@
|
||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
||||
"showFolderSidebar": "顯示資料夾側邊欄",
|
||||
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
|
||||
"cardInfoDisplay": "卡片資訊顯示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "永遠顯示",
|
||||
"hover": "滑鼠懸停顯示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "標題與頁腳始終可見",
|
||||
"hover": "標題與頁腳僅在滑鼠懸停時顯示"
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "開啟範例圖片",
|
||||
"replacePreview": "更換預覽圖"
|
||||
},
|
||||
"modelCardFooterActionHelp": "選擇右下角卡片按鈕的功能",
|
||||
"modelNameDisplay": "模型名稱顯示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "模型名稱",
|
||||
"fileName": "檔案名稱"
|
||||
},
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容:",
|
||||
"modelNameDisplayDetails": {
|
||||
"modelName": "顯示模型的描述性名稱",
|
||||
"fileName": "顯示磁碟上的實際檔案名稱"
|
||||
}
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "使用中的資料庫",
|
||||
@@ -394,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
"quick": "快速刷新(增量)",
|
||||
"full": "完整重建(全部)"
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "從 Civitai 取得 metadata",
|
||||
@@ -416,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "僅顯示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"updates": {
|
||||
"title": "僅顯示可用更新的模型",
|
||||
"action": "更新",
|
||||
"menuLabel": "顯示更新選項",
|
||||
"check": "檢查更新",
|
||||
"checkTooltip": "檢查更新可能耗時。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -427,6 +454,7 @@
|
||||
"setContentRating": "為全部設定內容分級",
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
"deleteAll": "刪除全部模型",
|
||||
@@ -702,6 +730,12 @@
|
||||
"countMessage": "模型將被永久刪除。",
|
||||
"action": "全部刪除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "要檢查所有 {type} 的更新嗎?",
|
||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
||||
"action": "全部檢查"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "新增標籤到多個模型",
|
||||
"description": "新增標籤到",
|
||||
@@ -838,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "範例圖片",
|
||||
"description": "模型描述",
|
||||
"recipes": "配方"
|
||||
"recipes": "配方",
|
||||
"versions": "版本"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "載入範例圖片中...",
|
||||
"description": "載入模型描述中...",
|
||||
"recipes": "載入配方中...",
|
||||
"examples": "載入範例中..."
|
||||
"examples": "載入範例中...",
|
||||
"versions": "載入版本中..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
||||
"media": {
|
||||
"placeholder": "無預覽"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "未命名版本",
|
||||
"noDetails": "沒有其他資訊"
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前版本",
|
||||
"inLibrary": "已在庫中",
|
||||
"newer": "較新版本",
|
||||
"ignored": "已忽略"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"delete": "刪除",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||
"ignoreModelUpdates": "忽略此模型的更新",
|
||||
"viewLocalVersions": "檢視所有本地版本",
|
||||
"viewLocalTooltip": "敬請期待"
|
||||
},
|
||||
"empty": "此模型尚無版本歷史。",
|
||||
"error": "載入版本失敗。",
|
||||
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
||||
"confirm": {
|
||||
"delete": "要從庫中刪除此版本嗎?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "已忽略此模型的更新",
|
||||
"modelResumed": "已恢復更新追蹤",
|
||||
"versionIgnored": "已忽略此版本的更新",
|
||||
"versionUnignored": "已重新啟用此版本",
|
||||
"versionDeleted": "已刪除此版本"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -951,7 +1027,9 @@
|
||||
"loraFailedToSend": "傳送 LoRA 到工作流失敗",
|
||||
"recipeAdded": "配方已附加到工作流",
|
||||
"recipeReplaced": "配方已取代於工作流",
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗"
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||
"noTargetNodeSelected": "未選擇目標節點"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
@@ -996,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "檢查更新",
|
||||
"notificationsTitle": "通知中心",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "訊息"
|
||||
},
|
||||
"updateAvailable": "有新版本可用",
|
||||
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
|
||||
"currentVersion": "目前版本",
|
||||
@@ -1027,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
||||
"enable": "啟用 Nightly 更新"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最新通知",
|
||||
"empty": "目前沒有最近的橫幅通知。",
|
||||
"shown": "{time} 顯示",
|
||||
"dismissed": "{time} 關閉",
|
||||
"active": "仍在顯示"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1146,6 +1236,12 @@
|
||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||
"bulkUpdatesMissing": "所選 {type} 未連結 Civitai 更新",
|
||||
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 Civitai 的所選 {type}",
|
||||
"bulkUpdatesFailed": "檢查所選 {type} 更新失敗:{message}",
|
||||
"invalidCharactersRemoved": "已移除檔名中的無效字元",
|
||||
"filenameCannotBeEmpty": "檔案名稱不可為空",
|
||||
"renameFailed": "重新命名檔案失敗:{message}",
|
||||
|
||||
78
py/config.py
78
py/config.py
@@ -2,12 +2,12 @@ import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
import folder_paths # type: ignore
|
||||
from typing import Dict, Iterable, List, Mapping, Set
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set
|
||||
import logging
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
from .utils.settings_paths import ensure_settings_file
|
||||
from .utils.settings_paths import ensure_settings_file, load_settings_template
|
||||
|
||||
# Use an environment variable to control standalone mode
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
@@ -45,6 +45,30 @@ def _normalize_folder_paths_for_comparison(
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_library_folder_paths(
|
||||
library_payload: Mapping[str, Any]
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""Return normalized folder paths extracted from a library payload."""
|
||||
|
||||
folder_paths = library_payload.get("folder_paths")
|
||||
if isinstance(folder_paths, Mapping):
|
||||
return _normalize_folder_paths_for_comparison(folder_paths)
|
||||
return {}
|
||||
|
||||
|
||||
def _get_template_folder_paths() -> Dict[str, Set[str]]:
|
||||
"""Return normalized folder paths defined in the bundled template."""
|
||||
|
||||
template_payload = load_settings_template()
|
||||
if not template_payload:
|
||||
return {}
|
||||
|
||||
folder_paths = template_payload.get("folder_paths")
|
||||
if isinstance(folder_paths, Mapping):
|
||||
return _normalize_folder_paths_for_comparison(folder_paths)
|
||||
return {}
|
||||
|
||||
|
||||
class Config:
|
||||
"""Global configuration for LoRA Manager"""
|
||||
|
||||
@@ -81,6 +105,43 @@ class Config:
|
||||
comfy_library = libraries.get("comfyui", {})
|
||||
default_library = libraries.get("default", {})
|
||||
|
||||
template_folder_paths = _get_template_folder_paths()
|
||||
default_library_paths: Dict[str, Set[str]] = {}
|
||||
if isinstance(default_library, Mapping):
|
||||
default_library_paths = _normalize_library_folder_paths(default_library)
|
||||
|
||||
libraries_changed = False
|
||||
if (
|
||||
isinstance(default_library, Mapping)
|
||||
and template_folder_paths
|
||||
and default_library_paths == template_folder_paths
|
||||
):
|
||||
if "comfyui" in libraries:
|
||||
try:
|
||||
settings_service.delete_library("default")
|
||||
libraries_changed = True
|
||||
logger.info("Removed template 'default' library entry")
|
||||
except Exception as delete_error:
|
||||
logger.debug(
|
||||
"Failed to delete template 'default' library: %s",
|
||||
delete_error,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
settings_service.rename_library("default", "comfyui")
|
||||
libraries_changed = True
|
||||
logger.info("Renamed template 'default' library to 'comfyui'")
|
||||
except Exception as rename_error:
|
||||
logger.debug(
|
||||
"Failed to rename template 'default' library: %s",
|
||||
rename_error,
|
||||
)
|
||||
|
||||
if libraries_changed:
|
||||
libraries = settings_service.get_libraries()
|
||||
comfy_library = libraries.get("comfyui", {})
|
||||
default_library = libraries.get("default", {})
|
||||
|
||||
target_folder_paths = {
|
||||
'loras': list(self.loras_roots),
|
||||
'checkpoints': list(self.checkpoints_roots or []),
|
||||
@@ -90,9 +151,16 @@ class Config:
|
||||
|
||||
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
|
||||
|
||||
if (not comfy_library and default_library and normalized_target_paths and
|
||||
_normalize_folder_paths_for_comparison(default_library.get("folder_paths", {})) ==
|
||||
normalized_target_paths):
|
||||
normalized_default_paths: Optional[Dict[str, Set[str]]] = None
|
||||
if isinstance(default_library, Mapping):
|
||||
normalized_default_paths = _normalize_library_folder_paths(default_library)
|
||||
|
||||
if (
|
||||
not comfy_library
|
||||
and default_library
|
||||
and normalized_target_paths
|
||||
and normalized_default_paths == normalized_target_paths
|
||||
):
|
||||
try:
|
||||
settings_service.rename_library("default", "comfyui")
|
||||
logger.info("Renamed legacy 'default' library to 'comfyui'")
|
||||
|
||||
@@ -23,6 +23,18 @@ logger = logging.getLogger(__name__)
|
||||
# Check if we're in standalone mode
|
||||
STANDALONE_MODE = 'nodes' not in sys.modules
|
||||
|
||||
HEADER_SIZE_LIMIT = 16384
|
||||
|
||||
|
||||
def _sanitize_size_limit(value):
|
||||
"""Return a non-negative integer size for ``handler_args`` comparisons."""
|
||||
|
||||
try:
|
||||
coerced = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return coerced if coerced >= 0 else 0
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
def __init__(self):
|
||||
@@ -50,6 +62,24 @@ class LoraManager:
|
||||
"""Initialize and register all routes using the new refactored architecture"""
|
||||
app = PromptServer.instance.app
|
||||
|
||||
# Increase allowed header sizes so browsers with large localhost cookie
|
||||
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
|
||||
# limits. Cookies for unrelated apps are still sent to the plugin and
|
||||
# may otherwise raise LineTooLong errors when the request parser reads
|
||||
# them. Preserve any previously configured handler arguments while
|
||||
# ensuring our minimum sizes are applied.
|
||||
handler_args = getattr(app, "_handler_args", {}) or {}
|
||||
updated_handler_args = dict(handler_args)
|
||||
updated_handler_args["max_field_size"] = max(
|
||||
_sanitize_size_limit(handler_args.get("max_field_size", 0)),
|
||||
HEADER_SIZE_LIMIT,
|
||||
)
|
||||
updated_handler_args["max_line_size"] = max(
|
||||
_sanitize_size_limit(handler_args.get("max_line_size", 0)),
|
||||
HEADER_SIZE_LIMIT,
|
||||
)
|
||||
app._handler_args = updated_handler_args
|
||||
|
||||
# Configure aiohttp access logger to be less verbose
|
||||
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
||||
|
||||
|
||||
@@ -3,6 +3,18 @@ import os
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||
|
||||
|
||||
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
||||
"""Store checkpoint model information when available."""
|
||||
if not model_name:
|
||||
return
|
||||
metadata.setdefault(MODELS, {})
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
|
||||
class NodeMetadataExtractor:
|
||||
"""Base class for node-specific metadata extraction"""
|
||||
|
||||
@@ -29,12 +41,36 @@ class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
return
|
||||
|
||||
model_name = inputs.get("ckpt_name")
|
||||
if model_name:
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
|
||||
class NunchakuFluxDiTLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "model_path" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("model_path")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
|
||||
class NunchakuQwenImageDiTLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "model_name" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("model_name")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
class GGUFLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "gguf_name" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("gguf_name")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
@@ -43,12 +79,7 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
return
|
||||
|
||||
model_name = inputs.get("ckpt_name")
|
||||
if model_name:
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
# For loader node has lora_stack input, like Efficient Loader from Efficient Nodes
|
||||
active_loras = []
|
||||
@@ -660,6 +691,10 @@ NODE_EXTRACTORS = {
|
||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||
"NunchakuQwenImageDiTLoader": NunchakuQwenImageDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||
"LoaderGGUF": GGUFLoaderExtractor, # calcuis gguf
|
||||
"LoaderGGUFAdvanced": GGUFLoaderExtractor, # calcuis gguf
|
||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
|
||||
@@ -141,7 +141,6 @@ class LoraManagerTextLoader:
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"lora_syntax": ("STRING", {
|
||||
"defaultInput": True,
|
||||
"forceInput": True,
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
|
||||
}),
|
||||
|
||||
@@ -110,10 +110,14 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
|
||||
model_wrapper.model = transformer
|
||||
ret_model_wrapper.model = transformer
|
||||
|
||||
# Get full path to the LoRA file
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
|
||||
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
|
||||
if not lora_path or not os.path.isfile(lora_path):
|
||||
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
|
||||
return model
|
||||
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
|
||||
|
||||
# Convert the LoRA to diffusers format
|
||||
sd = to_diffusers(lora_path)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ class WanVideoLoraSelectFromText:
|
||||
"merge_lora": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||
"lora_syntax": ("STRING", {
|
||||
"multiline": True,
|
||||
"defaultInput": True,
|
||||
"forceInput": True,
|
||||
"tooltip": "Connect a TEXT output for LoRA syntax: <lora:name:strength>"
|
||||
}),
|
||||
|
||||
@@ -27,6 +27,7 @@ from ...services.service_registry import ServiceRegistry
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
from ...services.websocket_manager import ws_manager
|
||||
from ...services.downloader import get_downloader
|
||||
from ...services.errors import ResourceNotFoundError
|
||||
from ...utils.constants import (
|
||||
CIVITAI_USER_MODEL_TYPES,
|
||||
DEFAULT_NODE_COLOR,
|
||||
@@ -100,6 +101,36 @@ class NodeRegistry:
|
||||
node_type = node.get("type", "")
|
||||
type_id = NODE_TYPES.get(node_type, 0)
|
||||
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
||||
raw_capabilities = node.get("capabilities")
|
||||
capabilities: dict = {}
|
||||
if isinstance(raw_capabilities, dict):
|
||||
capabilities = dict(raw_capabilities)
|
||||
|
||||
raw_widget_names: list | None = node.get("widget_names")
|
||||
if not isinstance(raw_widget_names, list):
|
||||
capability_widget_names = capabilities.get("widget_names")
|
||||
raw_widget_names = capability_widget_names if isinstance(capability_widget_names, list) else None
|
||||
|
||||
widget_names: list[str] = []
|
||||
if isinstance(raw_widget_names, list):
|
||||
widget_names = [
|
||||
str(widget_name)
|
||||
for widget_name in raw_widget_names
|
||||
if isinstance(widget_name, str) and widget_name
|
||||
]
|
||||
|
||||
if widget_names:
|
||||
capabilities["widget_names"] = widget_names
|
||||
else:
|
||||
capabilities.pop("widget_names", None)
|
||||
|
||||
if "supports_lora" in capabilities:
|
||||
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
||||
|
||||
comfy_class = node.get("comfy_class")
|
||||
if not isinstance(comfy_class, str) or not comfy_class:
|
||||
comfy_class = node_type if isinstance(node_type, str) else None
|
||||
|
||||
self._nodes[unique_id] = {
|
||||
"id": node_id,
|
||||
"graph_id": graph_id,
|
||||
@@ -109,6 +140,9 @@ class NodeRegistry:
|
||||
"title": node.get("title"),
|
||||
"type": type_id,
|
||||
"type_name": node_type,
|
||||
"comfy_class": comfy_class,
|
||||
"capabilities": capabilities,
|
||||
"widget_names": widget_names,
|
||||
}
|
||||
logger.debug("Registered %s nodes in registry", len(nodes))
|
||||
self._registry_updated.set()
|
||||
@@ -159,10 +193,12 @@ class SettingsHandler:
|
||||
"autoplay_on_hover",
|
||||
"display_density",
|
||||
"card_info_display",
|
||||
"show_folder_sidebar",
|
||||
"include_trigger_words",
|
||||
"show_only_sfw",
|
||||
"compact_mode",
|
||||
"priority_tags",
|
||||
"model_card_footer_action",
|
||||
"model_name_display",
|
||||
)
|
||||
|
||||
@@ -204,7 +240,16 @@ class SettingsHandler:
|
||||
value = self._settings.get(key)
|
||||
if value is not None:
|
||||
response_data[key] = value
|
||||
return web.json_response({"success": True, "settings": response_data})
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
messages_getter = getattr(self._settings, "get_startup_messages", None)
|
||||
messages = list(messages_getter()) if callable(messages_getter) else []
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"settings": response_data,
|
||||
"messages": messages,
|
||||
})
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Error getting settings: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -575,7 +620,10 @@ class ModelLibraryHandler:
|
||||
if not metadata_provider:
|
||||
return web.json_response({"success": False, "error": "Metadata provider not available"}, status=503)
|
||||
|
||||
response = await metadata_provider.get_model_versions(model_id)
|
||||
try:
|
||||
response = await metadata_provider.get_model_versions(model_id)
|
||||
except ResourceNotFoundError:
|
||||
return web.json_response({"success": False, "error": "Model not found"}, status=404)
|
||||
if not response or not response.get("modelVersions"):
|
||||
return web.json_response({"success": False, "error": "Model not found"}, status=404)
|
||||
|
||||
@@ -918,6 +966,88 @@ class NodeRegistryHandler:
|
||||
logger.error("Failed to get registry: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": "Internal Error", "message": str(exc)}, status=500)
|
||||
|
||||
async def update_node_widget(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
widget_name = data.get("widget_name")
|
||||
value = data.get("value")
|
||||
node_ids = data.get("node_ids")
|
||||
|
||||
if not isinstance(widget_name, str) or not widget_name:
|
||||
return web.json_response({"success": False, "error": "Missing widget_name parameter"}, status=400)
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
return web.json_response({"success": False, "error": "Missing value parameter"}, status=400)
|
||||
|
||||
if not isinstance(node_ids, list) or not node_ids:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "node_ids must be a non-empty list"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
results = []
|
||||
for entry in node_ids:
|
||||
node_identifier = entry
|
||||
graph_identifier = None
|
||||
if isinstance(entry, dict):
|
||||
node_identifier = entry.get("node_id")
|
||||
graph_identifier = entry.get("graph_id")
|
||||
|
||||
if node_identifier is None:
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_identifier,
|
||||
"graph_id": graph_identifier,
|
||||
"success": False,
|
||||
"error": "Missing node_id parameter",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed_node_id = int(node_identifier)
|
||||
except (TypeError, ValueError):
|
||||
parsed_node_id = node_identifier
|
||||
|
||||
payload = {
|
||||
"id": parsed_node_id,
|
||||
"widget_name": widget_name,
|
||||
"value": value,
|
||||
}
|
||||
|
||||
if graph_identifier is not None:
|
||||
payload["graph_id"] = str(graph_identifier)
|
||||
|
||||
try:
|
||||
self._prompt_server.instance.send_sync("lm_widget_update", payload)
|
||||
results.append(
|
||||
{
|
||||
"node_id": parsed_node_id,
|
||||
"graph_id": payload.get("graph_id"),
|
||||
"success": True,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Error sending widget update to node %s (graph %s): %s",
|
||||
parsed_node_id,
|
||||
graph_identifier,
|
||||
exc,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": parsed_node_id,
|
||||
"graph_id": payload.get("graph_id"),
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
|
||||
return web.json_response({"success": True, "results": results})
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to update node widget: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class MiscHandlerSet:
|
||||
"""Aggregate handlers into a lookup compatible with the registrar."""
|
||||
@@ -961,6 +1091,7 @@ class MiscHandlerSet:
|
||||
"get_trained_words": self.trained_words.get_trained_words,
|
||||
"get_model_example_files": self.model_examples.get_model_example_files,
|
||||
"register_nodes": self.node_registry.register_nodes,
|
||||
"update_node_widget": self.node_registry.update_node_widget,
|
||||
"get_registry": self.node_registry.get_registry,
|
||||
"check_model_exists": self.model_library.check_model_exists,
|
||||
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
||||
|
||||
@@ -29,7 +29,7 @@ from ...services.use_cases import (
|
||||
)
|
||||
from ...services.websocket_manager import WebSocketManager
|
||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
||||
from ...services.errors import RateLimitError
|
||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||
from ...utils.file_utils import calculate_sha256
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
|
||||
@@ -166,10 +166,7 @@ class ModelListingHandler:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
has_update = request.query.get("has_update", "false")
|
||||
has_update_filter = (
|
||||
has_update.lower() in {"1", "true", "yes"} if isinstance(has_update, str) else False
|
||||
)
|
||||
update_available_only = request.query.get("update_available_only", "false").lower() == "true"
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
@@ -183,7 +180,7 @@ class ModelListingHandler:
|
||||
"search_options": search_options,
|
||||
"hash_filters": hash_filters,
|
||||
"favorites_only": favorites_only,
|
||||
"has_update": has_update_filter,
|
||||
"update_available_only": update_available_only,
|
||||
**self._parse_specific_params(request),
|
||||
}
|
||||
|
||||
@@ -863,7 +860,10 @@ class ModelCivitaiHandler:
|
||||
try:
|
||||
model_id = request.match_info["model_id"]
|
||||
metadata_provider = await self._metadata_provider_factory()
|
||||
response = await metadata_provider.get_model_versions(model_id)
|
||||
try:
|
||||
response = await metadata_provider.get_model_versions(model_id)
|
||||
except ResourceNotFoundError:
|
||||
return web.Response(status=404, text="Model not found")
|
||||
if not response or not response.get("modelVersions"):
|
||||
return web.Response(status=404, text="Model not found")
|
||||
|
||||
@@ -1045,6 +1045,21 @@ class ModelUpdateHandler:
|
||||
force_refresh = self._parse_bool(request.query.get("force")) or self._parse_bool(
|
||||
payload.get("force")
|
||||
)
|
||||
|
||||
raw_model_ids = payload.get("modelIds")
|
||||
if raw_model_ids is None:
|
||||
raw_model_ids = payload.get("model_ids")
|
||||
|
||||
target_model_ids: list[int] = []
|
||||
if isinstance(raw_model_ids, (list, tuple, set)):
|
||||
for value in raw_model_ids:
|
||||
normalized = self._normalize_model_id(value)
|
||||
if normalized is not None:
|
||||
target_model_ids.append(normalized)
|
||||
|
||||
if target_model_ids:
|
||||
target_model_ids = sorted(set(target_model_ids))
|
||||
|
||||
provider = await self._get_civitai_provider()
|
||||
if provider is None:
|
||||
return web.json_response(
|
||||
@@ -1057,6 +1072,7 @@ class ModelUpdateHandler:
|
||||
self._service.scanner,
|
||||
provider,
|
||||
force_refresh=force_refresh,
|
||||
target_model_ids=target_model_ids or None,
|
||||
)
|
||||
except RateLimitError as exc:
|
||||
return web.json_response(
|
||||
@@ -1066,10 +1082,16 @@ class ModelUpdateHandler:
|
||||
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
serialized_records = []
|
||||
for record in records.values():
|
||||
has_update_fn = getattr(record, "has_update", None)
|
||||
if callable(has_update_fn) and has_update_fn():
|
||||
serialized_records.append(self._serialize_record(record))
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"records": [self._serialize_record(record) for record in records.values()],
|
||||
"records": serialized_records,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1085,6 +1107,28 @@ class ModelUpdateHandler:
|
||||
)
|
||||
return web.json_response({"success": True, "record": self._serialize_record(record)})
|
||||
|
||||
async def set_version_update_ignore(self, request: web.Request) -> web.Response:
|
||||
payload = await self._read_json(request)
|
||||
model_id = self._normalize_model_id(payload.get("modelId"))
|
||||
version_id = self._normalize_model_id(payload.get("versionId"))
|
||||
if model_id is None or version_id is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "modelId and versionId are required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
should_ignore = self._parse_bool(payload.get("shouldIgnore"))
|
||||
record = await self._update_service.set_version_should_ignore(
|
||||
self._service.model_type,
|
||||
model_id,
|
||||
version_id,
|
||||
should_ignore,
|
||||
)
|
||||
overrides = await self._build_version_context(record)
|
||||
return web.json_response(
|
||||
{"success": True, "record": self._serialize_record(record, version_context=overrides)}
|
||||
)
|
||||
|
||||
async def get_model_update_status(self, request: web.Request) -> web.Response:
|
||||
model_id = self._normalize_model_id(request.match_info.get("model_id"))
|
||||
if model_id is None:
|
||||
@@ -1107,6 +1151,33 @@ class ModelUpdateHandler:
|
||||
|
||||
return web.json_response({"success": True, "record": self._serialize_record(record)})
|
||||
|
||||
async def get_model_versions(self, request: web.Request) -> web.Response:
|
||||
model_id = self._normalize_model_id(request.match_info.get("model_id"))
|
||||
if model_id is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "model_id must be an integer"}, status=400
|
||||
)
|
||||
|
||||
refresh = self._parse_bool(request.query.get("refresh"))
|
||||
force = self._parse_bool(request.query.get("force"))
|
||||
|
||||
try:
|
||||
record = await self._get_or_refresh_record(model_id, refresh=refresh, force=force)
|
||||
except RateLimitError as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc) or "Rate limited"}, status=429
|
||||
)
|
||||
|
||||
if record is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model not tracked"}, status=404
|
||||
)
|
||||
|
||||
overrides = await self._build_version_context(record)
|
||||
return web.json_response(
|
||||
{"success": True, "record": self._serialize_record(record, version_context=overrides)}
|
||||
)
|
||||
|
||||
async def _get_or_refresh_record(
|
||||
self, model_id: int, *, refresh: bool, force: bool
|
||||
) -> Optional[object]:
|
||||
@@ -1160,8 +1231,13 @@ class ModelUpdateHandler:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_record(record) -> Dict:
|
||||
def _serialize_record(
|
||||
self,
|
||||
record,
|
||||
*,
|
||||
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
|
||||
) -> Dict:
|
||||
context = version_context or {}
|
||||
return {
|
||||
"modelType": record.model_type,
|
||||
"modelId": record.model_id,
|
||||
@@ -1169,10 +1245,60 @@ class ModelUpdateHandler:
|
||||
"versionIds": record.version_ids,
|
||||
"inLibraryVersionIds": record.in_library_version_ids,
|
||||
"lastCheckedAt": record.last_checked_at,
|
||||
"shouldIgnore": record.should_ignore,
|
||||
"shouldIgnore": record.should_ignore_model,
|
||||
"hasUpdate": record.has_update(),
|
||||
"versions": [
|
||||
self._serialize_version(version, context.get(version.version_id))
|
||||
for version in record.versions
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_version(version, context: Optional[Dict[str, Optional[str]]]) -> Dict:
|
||||
context = context or {}
|
||||
preview_override = context.get("preview_override")
|
||||
preview_url = preview_override if preview_override is not None else version.preview_url
|
||||
return {
|
||||
"versionId": version.version_id,
|
||||
"name": version.name,
|
||||
"baseModel": version.base_model,
|
||||
"releasedAt": version.released_at,
|
||||
"sizeBytes": version.size_bytes,
|
||||
"previewUrl": preview_url,
|
||||
"isInLibrary": version.is_in_library,
|
||||
"shouldIgnore": version.should_ignore,
|
||||
"filePath": context.get("file_path"),
|
||||
"fileName": context.get("file_name"),
|
||||
}
|
||||
|
||||
async def _build_version_context(self, record) -> Dict[int, Dict[str, Optional[str]]]:
|
||||
context: Dict[int, Dict[str, Optional[str]]] = {}
|
||||
try:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.debug("Failed to load cache while building preview overrides: %s", exc)
|
||||
return context
|
||||
|
||||
version_index = getattr(cache, "version_index", None)
|
||||
if not version_index:
|
||||
return context
|
||||
|
||||
for version in record.versions:
|
||||
if not version.is_in_library:
|
||||
continue
|
||||
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,
|
||||
}
|
||||
if isinstance(preview, str) and preview:
|
||||
context_entry["preview_override"] = config.get_preview_static_url(preview)
|
||||
context[version.version_id] = context_entry
|
||||
return context
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelHandlerSet:
|
||||
@@ -1233,6 +1359,7 @@ class ModelHandlerSet:
|
||||
"get_relative_paths": self.query.get_relative_paths,
|
||||
"refresh_model_updates": self.updates.refresh_model_updates,
|
||||
"set_model_update_ignore": self.updates.set_model_update_ignore,
|
||||
"set_version_update_ignore": self.updates.set_version_update_ignore,
|
||||
"get_model_update_status": self.updates.get_model_update_status,
|
||||
"get_model_versions": self.updates.get_model_versions,
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
||||
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
||||
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||
|
||||
@@ -57,7 +57,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"),
|
||||
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
||||
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||
|
||||
@@ -205,8 +205,8 @@ class UpdateRoutes:
|
||||
|
||||
zip_path = tmp_zip_path
|
||||
|
||||
# Skip both settings.json and civitai folder
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai'])
|
||||
# Skip both settings.json, civitai and model cache folder
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
||||
|
||||
# Extract ZIP to temp dir
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
|
||||
@@ -63,40 +63,54 @@ class BaseModelService(ABC):
|
||||
search_options: dict = None,
|
||||
hash_filters: dict = None,
|
||||
favorites_only: bool = False,
|
||||
has_update: bool = False,
|
||||
update_available_only: bool = False,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
"""Get paginated and filtered model data"""
|
||||
|
||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||
|
||||
if hash_filters:
|
||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||
return self._paginate(filtered_data, page, page_size)
|
||||
|
||||
filtered_data = await self._apply_common_filters(
|
||||
sorted_data,
|
||||
folder=folder,
|
||||
base_models=base_models,
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
)
|
||||
|
||||
if search:
|
||||
filtered_data = await self._apply_search_filters(
|
||||
filtered_data,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
else:
|
||||
filtered_data = await self._apply_common_filters(
|
||||
sorted_data,
|
||||
folder=folder,
|
||||
base_models=base_models,
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
)
|
||||
|
||||
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
|
||||
if search:
|
||||
filtered_data = await self._apply_search_filters(
|
||||
filtered_data,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
)
|
||||
|
||||
if has_update:
|
||||
filtered_data = await self._apply_update_filter(filtered_data)
|
||||
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
|
||||
|
||||
return self._paginate(filtered_data, page, page_size)
|
||||
annotated_for_filter: Optional[List[Dict]] = None
|
||||
if update_available_only:
|
||||
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
||||
filtered_data = [
|
||||
item for item in annotated_for_filter
|
||||
if item.get('update_available')
|
||||
]
|
||||
|
||||
paginated = self._paginate(filtered_data, page, page_size)
|
||||
|
||||
if update_available_only:
|
||||
# Items already include update flags thanks to the pre-filter annotation.
|
||||
paginated['items'] = list(paginated['items'])
|
||||
else:
|
||||
paginated['items'] = await self._annotate_update_flags(
|
||||
paginated['items'],
|
||||
)
|
||||
return paginated
|
||||
|
||||
|
||||
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]:
|
||||
@@ -156,45 +170,78 @@ class BaseModelService(ABC):
|
||||
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
||||
return data
|
||||
|
||||
async def _apply_update_filter(self, data: List[Dict]) -> List[Dict]:
|
||||
"""Filter models to those with remote updates available when requested."""
|
||||
if not data:
|
||||
async def _annotate_update_flags(
|
||||
self,
|
||||
items: List[Dict],
|
||||
) -> List[Dict]:
|
||||
"""Attach an update_available flag to each response item.
|
||||
|
||||
Items without a civitai model id default to False.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
annotated = [dict(item) for item in items]
|
||||
|
||||
if self.update_service is None:
|
||||
logger.warning(
|
||||
"Requested has_update filter for %s models but update service is unavailable",
|
||||
self.model_type,
|
||||
)
|
||||
return []
|
||||
for item in annotated:
|
||||
item['update_available'] = False
|
||||
return annotated
|
||||
|
||||
candidates: List[tuple[Dict, int]] = []
|
||||
for item in data:
|
||||
id_to_items: Dict[int, List[Dict]] = {}
|
||||
ordered_ids: List[int] = []
|
||||
for item in annotated:
|
||||
model_id = self._extract_model_id(item)
|
||||
if model_id is not None:
|
||||
candidates.append((item, model_id))
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
tasks = [
|
||||
self.update_service.has_update(self.model_type, model_id)
|
||||
for _, model_id in candidates
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
filtered: List[Dict] = []
|
||||
for (item, model_id), result in zip(candidates, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Failed to resolve update status for model %s (%s): %s",
|
||||
model_id,
|
||||
self.model_type,
|
||||
result,
|
||||
)
|
||||
if model_id is None:
|
||||
item['update_available'] = False
|
||||
continue
|
||||
if result:
|
||||
filtered.append(item)
|
||||
return filtered
|
||||
if model_id not in id_to_items:
|
||||
id_to_items[model_id] = []
|
||||
ordered_ids.append(model_id)
|
||||
id_to_items[model_id].append(item)
|
||||
|
||||
if not ordered_ids:
|
||||
return annotated
|
||||
|
||||
resolved: Optional[Dict[int, bool]] = None
|
||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||
if callable(bulk_method):
|
||||
try:
|
||||
resolved = await bulk_method(self.model_type, ordered_ids)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||
self.model_type,
|
||||
ordered_ids,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
resolved = None
|
||||
|
||||
if resolved is None:
|
||||
tasks = [
|
||||
self.update_service.has_update(self.model_type, model_id)
|
||||
for model_id in ordered_ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
resolved = {}
|
||||
for model_id, result in zip(ordered_ids, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Failed to resolve update status for model %s (%s): %s",
|
||||
model_id,
|
||||
self.model_type,
|
||||
result,
|
||||
)
|
||||
continue
|
||||
resolved[model_id] = bool(result)
|
||||
|
||||
for model_id, items_for_id in id_to_items.items():
|
||||
flag = bool(resolved.get(model_id, False))
|
||||
for item in items_for_id:
|
||||
item['update_available'] = flag
|
||||
|
||||
return annotated
|
||||
|
||||
@staticmethod
|
||||
def _extract_model_id(item: Dict) -> Optional[int]:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.models import CheckpointMetadata
|
||||
from ..config import config
|
||||
@@ -21,14 +21,33 @@ class CheckpointScanner(ModelScanner):
|
||||
hash_index=ModelHashIndex()
|
||||
)
|
||||
|
||||
def _resolve_model_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
if config.checkpoints_roots and root_path in config.checkpoints_roots:
|
||||
return "checkpoint"
|
||||
|
||||
if config.unet_roots and root_path in config.unet_roots:
|
||||
return "diffusion_model"
|
||||
|
||||
return None
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
if hasattr(metadata, "model_type"):
|
||||
if root_path in config.checkpoints_roots:
|
||||
metadata.model_type = "checkpoint"
|
||||
elif root_path in config.unet_roots:
|
||||
metadata.model_type = "diffusion_model"
|
||||
model_type = self._resolve_model_type(root_path)
|
||||
if model_type:
|
||||
metadata.model_type = model_type
|
||||
return metadata
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
model_type = self._resolve_model_type(
|
||||
self._find_root_for_file(entry.get("file_path"))
|
||||
)
|
||||
if model_type:
|
||||
entry["model_type"] = model_type
|
||||
return entry
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get checkpoint root directories"""
|
||||
return config.base_models_roots
|
||||
return config.base_models_roots
|
||||
|
||||
@@ -38,6 +38,7 @@ class CheckpointService(BaseModelService):
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||
from .model_metadata_provider import CivitaiModelMetadataProvider, ModelMetadataProviderManager
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -160,7 +160,29 @@ class CivitaiClient:
|
||||
logger.error(f"Download Error: {str(e)}")
|
||||
return False
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> List[Dict]:
|
||||
@staticmethod
|
||||
def _extract_error_message(payload: Any) -> str:
|
||||
"""Return a human-readable error message from an API payload."""
|
||||
|
||||
def _from_value(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
for key in ("message", "error", "detail", "details"):
|
||||
if key in value:
|
||||
candidate = _from_value(value[key])
|
||||
if candidate:
|
||||
return candidate
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
candidate = _from_value(item)
|
||||
if candidate:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
return _from_value(payload)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
success, result = await self._make_request(
|
||||
@@ -175,11 +197,72 @@ class CivitaiClient:
|
||||
'type': result.get('type', ''),
|
||||
'name': result.get('name', '')
|
||||
}
|
||||
message = self._extract_error_message(result)
|
||||
if message and 'not found' in message.lower():
|
||||
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
|
||||
if message:
|
||||
raise RuntimeError(message)
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except ResourceNotFoundError as exc:
|
||||
logger.info("Model %s is no longer available on Civitai: %s", model_id, exc)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model versions: {e}")
|
||||
logger.error("Error fetching model versions: %s", e, exc_info=True)
|
||||
raise
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self, model_ids: Sequence[int]
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
"""Fetch model metadata for multiple ids using the batch API."""
|
||||
|
||||
deduped: Dict[int, None] = {}
|
||||
for raw_id in model_ids:
|
||||
try:
|
||||
normalized = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
deduped.setdefault(normalized, None)
|
||||
|
||||
normalized_ids = [str(model_id) for model_id in deduped.keys()]
|
||||
if not normalized_ids:
|
||||
return {}
|
||||
|
||||
try:
|
||||
query = ",".join(normalized_ids)
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={'ids': query},
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
items = result.get('items') if isinstance(result, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return {}
|
||||
|
||||
payload: Dict[int, Dict] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
model_id = item.get('id')
|
||||
try:
|
||||
normalized_id = int(model_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
payload[normalized_id] = {
|
||||
'modelVersions': item.get('modelVersions', []),
|
||||
'type': item.get('type', ''),
|
||||
'name': item.get('name', ''),
|
||||
}
|
||||
return payload
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error fetching model versions in bulk: {exc}")
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
|
||||
@@ -9,12 +9,14 @@ from urllib.parse import urlparse
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_service import get_default_metadata_provider
|
||||
from .downloader import get_downloader, DownloadProgress
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
|
||||
# Download to temporary file first
|
||||
import tempfile
|
||||
@@ -43,7 +45,7 @@ class DownloadManager:
|
||||
self._active_downloads = OrderedDict() # download_id -> download_info
|
||||
self._download_semaphore = asyncio.Semaphore(5) # Limit concurrent downloads
|
||||
self._download_tasks = {} # download_id -> asyncio.Task
|
||||
self._pause_events: Dict[str, asyncio.Event] = {}
|
||||
self._pause_events: Dict[str, DownloadStreamControl] = {}
|
||||
|
||||
async def _get_lora_scanner(self):
|
||||
"""Get the lora scanner from registry"""
|
||||
@@ -88,11 +90,11 @@ class DownloadManager:
|
||||
'bytes_downloaded': 0,
|
||||
'total_bytes': None,
|
||||
'bytes_per_second': 0.0,
|
||||
'last_progress_timestamp': None,
|
||||
}
|
||||
|
||||
pause_event = asyncio.Event()
|
||||
pause_event.set()
|
||||
self._pause_events[task_id] = pause_event
|
||||
pause_control = DownloadStreamControl()
|
||||
self._pause_events[task_id] = pause_control
|
||||
|
||||
# Create tracking task
|
||||
download_task = asyncio.create_task(
|
||||
@@ -139,19 +141,23 @@ class DownloadManager:
|
||||
info['bytes_downloaded'] = snapshot.bytes_downloaded
|
||||
info['total_bytes'] = snapshot.total_bytes
|
||||
info['bytes_per_second'] = snapshot.bytes_per_second
|
||||
pause_control = self._pause_events.get(task_id)
|
||||
if isinstance(pause_control, DownloadStreamControl):
|
||||
pause_control.mark_progress(snapshot.timestamp)
|
||||
info['last_progress_timestamp'] = pause_control.last_progress_timestamp
|
||||
|
||||
if original_callback:
|
||||
await self._dispatch_progress(original_callback, snapshot, progress_value)
|
||||
|
||||
|
||||
# Acquire semaphore to limit concurrent downloads
|
||||
try:
|
||||
async with self._download_semaphore:
|
||||
pause_event = self._pause_events.get(task_id)
|
||||
if pause_event is not None and not pause_event.is_set():
|
||||
pause_control = self._pause_events.get(task_id)
|
||||
if pause_control is not None and pause_control.is_paused():
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]['status'] = 'paused'
|
||||
self._active_downloads[task_id]['bytes_per_second'] = 0.0
|
||||
await pause_event.wait()
|
||||
await pause_control.wait()
|
||||
|
||||
# Update status to downloading
|
||||
if task_id in self._active_downloads:
|
||||
@@ -369,6 +375,19 @@ class DownloadManager:
|
||||
download_id=download_id,
|
||||
)
|
||||
|
||||
if result.get('success', False):
|
||||
resolved_model_id = (
|
||||
model_id
|
||||
or version_info.get('modelId')
|
||||
or (version_info.get('model') or {}).get('id')
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
version_info,
|
||||
model_version_id,
|
||||
)
|
||||
|
||||
# If early_access_msg exists and download failed, replace error message
|
||||
if 'early_access_msg' in locals() and not result.get('success', False):
|
||||
result['error'] = early_access_msg
|
||||
@@ -383,6 +402,96 @@ class DownloadManager:
|
||||
return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."}
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
async def _sync_downloaded_version(
|
||||
self,
|
||||
model_type: str,
|
||||
model_id_value,
|
||||
version_info: Dict,
|
||||
fallback_version_id=None,
|
||||
) -> None:
|
||||
"""Ensure update tracking reflects a newly downloaded version."""
|
||||
|
||||
try:
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
except Exception as exc:
|
||||
logger.debug("Skipping update sync; failed to acquire update service: %s", exc)
|
||||
return
|
||||
|
||||
if update_service is None:
|
||||
return
|
||||
|
||||
resolved_model_id = model_id_value
|
||||
if resolved_model_id is None:
|
||||
resolved_model_id = version_info.get('modelId')
|
||||
if resolved_model_id is None:
|
||||
model_info = version_info.get('model')
|
||||
if isinstance(model_info, dict):
|
||||
resolved_model_id = model_info.get('id')
|
||||
try:
|
||||
resolved_model_id = int(resolved_model_id)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug("Skipping update sync; invalid model id: %s", resolved_model_id)
|
||||
return
|
||||
|
||||
version_id = version_info.get('id')
|
||||
if version_id is None:
|
||||
version_id = fallback_version_id
|
||||
try:
|
||||
version_id = int(version_id)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(
|
||||
"Skipping update sync; invalid version id for model %s: %s",
|
||||
resolved_model_id,
|
||||
version_id,
|
||||
)
|
||||
return
|
||||
|
||||
version_ids = set()
|
||||
scanner = None
|
||||
try:
|
||||
if model_type == 'lora':
|
||||
scanner = await self._get_lora_scanner()
|
||||
elif model_type == 'checkpoint':
|
||||
scanner = await self._get_checkpoint_scanner()
|
||||
elif model_type == 'embedding':
|
||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
||||
|
||||
if scanner is not None:
|
||||
try:
|
||||
local_versions = await scanner.get_model_versions_by_id(resolved_model_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to collect local versions for %s model %s: %s",
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
for entry in local_versions or []:
|
||||
vid = entry.get('versionId')
|
||||
try:
|
||||
version_ids.add(int(vid))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
version_ids.add(version_id)
|
||||
|
||||
try:
|
||||
await update_service.update_in_library_versions(
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
sorted(version_ids),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to update in-library versions for %s model %s: %s",
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
def _calculate_relative_path(self, version_info: Dict, model_type: str = 'lora') -> str:
|
||||
"""Calculate relative path using template from settings
|
||||
|
||||
@@ -415,8 +524,10 @@ class DownloadManager:
|
||||
base_model_mappings = settings_manager.get('base_model_path_mappings', {})
|
||||
mapped_base_model = base_model_mappings.get(base_model, base_model)
|
||||
|
||||
model_info = version_info.get('model') or {}
|
||||
|
||||
# Get model tags
|
||||
model_tags = version_info.get('model', {}).get('tags', [])
|
||||
model_tags = model_info.get('tags', [])
|
||||
|
||||
first_tag = settings_manager.resolve_priority_tag_for_model(model_tags, model_type)
|
||||
|
||||
@@ -425,6 +536,8 @@ class DownloadManager:
|
||||
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
|
||||
formatted_path = formatted_path.replace('{first_tag}', first_tag)
|
||||
formatted_path = formatted_path.replace('{author}', author)
|
||||
formatted_path = formatted_path.replace('{model_name}', sanitize_folder_name(model_info.get('name', '')))
|
||||
formatted_path = formatted_path.replace('{version_name}', sanitize_folder_name(version_info.get('name', '')))
|
||||
|
||||
if model_type == 'embedding':
|
||||
formatted_path = formatted_path.replace(' ', '_')
|
||||
@@ -473,8 +586,8 @@ class DownloadManager:
|
||||
part_path = save_path + '.part'
|
||||
metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
|
||||
|
||||
pause_event = self._pause_events.get(download_id) if download_id else None
|
||||
|
||||
pause_control = self._pause_events.get(download_id) if download_id else None
|
||||
|
||||
# Store file paths in active_downloads for potential cleanup
|
||||
if download_id and download_id in self._active_downloads:
|
||||
self._active_downloads[download_id]['file_path'] = save_path
|
||||
@@ -486,10 +599,21 @@ class DownloadManager:
|
||||
if progress_callback:
|
||||
await progress_callback(1) # 1% progress for starting preview download
|
||||
|
||||
first_image = images[0] if isinstance(images[0], dict) else None
|
||||
preview_url = first_image.get('url') if first_image else None
|
||||
media_type = (first_image.get('type') or '').lower() if first_image else ''
|
||||
nsfw_level = first_image.get('nsfwLevel', 0) if first_image else 0
|
||||
settings_manager = get_settings_manager()
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get('blur_mature_content', True)
|
||||
)
|
||||
selected_image, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
)
|
||||
|
||||
preview_url = selected_image.get('url') if selected_image else None
|
||||
media_type = (
|
||||
(selected_image.get('type') or '').lower()
|
||||
if selected_image
|
||||
else ''
|
||||
)
|
||||
|
||||
def _extension_from_url(url: str, fallback: str) -> str:
|
||||
try:
|
||||
@@ -585,6 +709,8 @@ class DownloadManager:
|
||||
|
||||
# Download model file with progress tracking using downloader
|
||||
downloader = await get_downloader()
|
||||
if pause_control is not None:
|
||||
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/")
|
||||
@@ -597,8 +723,8 @@ class DownloadManager:
|
||||
"use_auth": use_auth, # Only use authentication for Civitai downloads
|
||||
}
|
||||
|
||||
if pause_event is not None:
|
||||
download_kwargs["pause_event"] = pause_event
|
||||
if pause_control is not None:
|
||||
download_kwargs["pause_event"] = pause_control
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
download_url,
|
||||
@@ -638,10 +764,10 @@ class DownloadManager:
|
||||
# 4. Update file information (size and modified time)
|
||||
metadata.update_file_info(save_path)
|
||||
|
||||
# 5. Final metadata update
|
||||
await MetadataManager.save_metadata(save_path, metadata)
|
||||
scanner = None
|
||||
adjust_root: Optional[str] = None
|
||||
|
||||
# 6. Update cache based on model type
|
||||
# 5. Determine scanner and adjust metadata for cache consistency
|
||||
if model_type == "checkpoint":
|
||||
scanner = await self._get_checkpoint_scanner()
|
||||
logger.info(f"Updating checkpoint cache for {save_path}")
|
||||
@@ -651,9 +777,33 @@ class DownloadManager:
|
||||
elif model_type == "embedding":
|
||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
logger.info(f"Updating embedding cache for {save_path}")
|
||||
|
||||
|
||||
if scanner is not None:
|
||||
file_path_for_adjust = getattr(metadata, "file_path", save_path)
|
||||
if isinstance(file_path_for_adjust, str):
|
||||
normalized_file_path = file_path_for_adjust.replace(os.sep, "/")
|
||||
else:
|
||||
normalized_file_path = str(file_path_for_adjust)
|
||||
|
||||
find_root = getattr(scanner, "_find_root_for_file", None)
|
||||
if callable(find_root):
|
||||
try:
|
||||
adjust_root = find_root(normalized_file_path)
|
||||
except TypeError:
|
||||
adjust_root = None
|
||||
|
||||
adjust_metadata = getattr(scanner, "adjust_metadata", None)
|
||||
if callable(adjust_metadata):
|
||||
metadata = adjust_metadata(metadata, normalized_file_path, adjust_root)
|
||||
|
||||
# 6. Persist metadata with any adjustments
|
||||
await MetadataManager.save_metadata(save_path, metadata)
|
||||
|
||||
# Convert metadata to dictionary
|
||||
metadata_dict = metadata.to_dict()
|
||||
adjust_cached_entry = getattr(scanner, "adjust_cached_entry", None) if scanner is not None else None
|
||||
if callable(adjust_cached_entry):
|
||||
metadata_dict = adjust_cached_entry(metadata_dict)
|
||||
|
||||
# Add model to cache and save to disk in a single operation
|
||||
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
||||
@@ -727,9 +877,9 @@ class DownloadManager:
|
||||
task = self._download_tasks[download_id]
|
||||
task.cancel()
|
||||
|
||||
pause_event = self._pause_events.get(download_id)
|
||||
if pause_event is not None:
|
||||
pause_event.set()
|
||||
pause_control = self._pause_events.get(download_id)
|
||||
if pause_control is not None:
|
||||
pause_control.resume()
|
||||
|
||||
# Update status in active downloads
|
||||
if download_id in self._active_downloads:
|
||||
@@ -806,16 +956,14 @@ class DownloadManager:
|
||||
if download_id not in self._download_tasks:
|
||||
return {'success': False, 'error': 'Download task not found'}
|
||||
|
||||
pause_event = self._pause_events.get(download_id)
|
||||
if pause_event is None:
|
||||
pause_event = asyncio.Event()
|
||||
pause_event.set()
|
||||
self._pause_events[download_id] = pause_event
|
||||
pause_control = self._pause_events.get(download_id)
|
||||
if pause_control is None:
|
||||
return {'success': False, 'error': 'Download task not found'}
|
||||
|
||||
if not pause_event.is_set():
|
||||
if pause_control.is_paused():
|
||||
return {'success': False, 'error': 'Download is already paused'}
|
||||
|
||||
pause_event.clear()
|
||||
pause_control.pause()
|
||||
|
||||
download_info = self._active_downloads.get(download_id)
|
||||
if download_info is not None:
|
||||
@@ -827,16 +975,28 @@ class DownloadManager:
|
||||
async def resume_download(self, download_id: str) -> Dict:
|
||||
"""Resume a previously paused download."""
|
||||
|
||||
pause_event = self._pause_events.get(download_id)
|
||||
if pause_event is None:
|
||||
pause_control = self._pause_events.get(download_id)
|
||||
if pause_control is None:
|
||||
return {'success': False, 'error': 'Download task not found'}
|
||||
|
||||
if pause_event.is_set():
|
||||
if pause_control.is_set():
|
||||
return {'success': False, 'error': 'Download is not paused'}
|
||||
|
||||
pause_event.set()
|
||||
|
||||
download_info = self._active_downloads.get(download_id)
|
||||
force_reconnect = False
|
||||
if pause_control is not None:
|
||||
elapsed = pause_control.time_since_last_progress()
|
||||
threshold = max(30.0, pause_control.stall_timeout / 2.0)
|
||||
if elapsed is not None and elapsed >= threshold:
|
||||
force_reconnect = True
|
||||
logger.info(
|
||||
"Forcing reconnect for download %s after %.1f seconds without progress",
|
||||
download_id,
|
||||
elapsed,
|
||||
)
|
||||
|
||||
pause_control.resume(force_reconnect=force_reconnect)
|
||||
|
||||
if download_info is not None:
|
||||
if download_info.get('status') == 'paused':
|
||||
download_info['status'] = 'downloading'
|
||||
|
||||
@@ -36,6 +36,73 @@ class DownloadProgress:
|
||||
timestamp: float
|
||||
|
||||
|
||||
class DownloadStreamControl:
|
||||
"""Synchronize pause/resume requests and reconnect hints for a download."""
|
||||
|
||||
def __init__(self, *, stall_timeout: Optional[float] = None) -> None:
|
||||
self._event = asyncio.Event()
|
||||
self._event.set()
|
||||
self._reconnect_requested = False
|
||||
self.last_progress_timestamp: Optional[float] = None
|
||||
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
return not self._event.is_set()
|
||||
|
||||
def set(self) -> None:
|
||||
self._event.set()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._event.clear()
|
||||
|
||||
async def wait(self) -> None:
|
||||
await self._event.wait()
|
||||
|
||||
def pause(self) -> None:
|
||||
self.clear()
|
||||
|
||||
def resume(self, *, force_reconnect: bool = False) -> None:
|
||||
if force_reconnect:
|
||||
self._reconnect_requested = True
|
||||
self.set()
|
||||
|
||||
def request_reconnect(self) -> None:
|
||||
self._reconnect_requested = True
|
||||
self.set()
|
||||
|
||||
def has_reconnect_request(self) -> bool:
|
||||
return self._reconnect_requested
|
||||
|
||||
def consume_reconnect_request(self) -> bool:
|
||||
reconnect = self._reconnect_requested
|
||||
self._reconnect_requested = False
|
||||
return reconnect
|
||||
|
||||
def mark_progress(self, timestamp: Optional[float] = None) -> None:
|
||||
self.last_progress_timestamp = timestamp or datetime.now().timestamp()
|
||||
self._reconnect_requested = False
|
||||
|
||||
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]:
|
||||
if self.last_progress_timestamp is None:
|
||||
return None
|
||||
reference = now if now is not None else datetime.now().timestamp()
|
||||
return max(0.0, reference - self.last_progress_timestamp)
|
||||
|
||||
def update_stall_timeout(self, stall_timeout: float) -> None:
|
||||
self.stall_timeout = float(stall_timeout)
|
||||
|
||||
|
||||
class DownloadRestartRequested(Exception):
|
||||
"""Raised when a caller explicitly requests a fresh HTTP stream."""
|
||||
|
||||
|
||||
class DownloadStalledError(Exception):
|
||||
"""Raised when download progress stalls beyond the configured timeout."""
|
||||
|
||||
|
||||
class Downloader:
|
||||
"""Unified downloader for all HTTP/HTTPS downloads in the application."""
|
||||
|
||||
@@ -67,10 +134,14 @@ class Downloader:
|
||||
self.max_retries = 5
|
||||
self.base_delay = 2.0 # Base delay for exponential backoff
|
||||
self.session_timeout = 300 # 5 minutes
|
||||
self.stall_timeout = self._resolve_stall_timeout()
|
||||
|
||||
# Default headers
|
||||
self.default_headers = {
|
||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0'
|
||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0',
|
||||
# Explicitly request uncompressed payloads so aiohttp doesn't need optional
|
||||
# decoders (e.g. zstandard) that may be missing in runtime environments.
|
||||
'Accept-Encoding': 'identity',
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -79,14 +150,38 @@ class Downloader:
|
||||
if self._session is None or self._should_refresh_session():
|
||||
await self._create_session()
|
||||
return self._session
|
||||
|
||||
|
||||
@property
|
||||
def proxy_url(self) -> Optional[str]:
|
||||
"""Get the current proxy URL (initialize if needed)"""
|
||||
if not hasattr(self, '_proxy_url'):
|
||||
self._proxy_url = None
|
||||
return self._proxy_url
|
||||
|
||||
|
||||
def _resolve_stall_timeout(self) -> float:
|
||||
"""Determine the stall timeout from settings or environment."""
|
||||
default_timeout = 120.0
|
||||
settings_timeout = None
|
||||
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
settings_timeout = settings_manager.get('download_stall_timeout_seconds')
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("Failed to read stall timeout from settings: %s", exc)
|
||||
|
||||
raw_value = (
|
||||
settings_timeout
|
||||
if settings_timeout not in (None, "")
|
||||
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT')
|
||||
)
|
||||
|
||||
try:
|
||||
timeout_value = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
timeout_value = default_timeout
|
||||
|
||||
return max(30.0, timeout_value)
|
||||
|
||||
def _should_refresh_session(self) -> bool:
|
||||
"""Check if session should be refreshed"""
|
||||
if self._session is None:
|
||||
@@ -178,7 +273,7 @@ class Downloader:
|
||||
use_auth: bool = False,
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
allow_resume: bool = True,
|
||||
pause_event: Optional[asyncio.Event] = None,
|
||||
pause_event: Optional[DownloadStreamControl] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Download a file with resumable downloads and retry mechanism
|
||||
@@ -190,7 +285,7 @@ class Downloader:
|
||||
use_auth: Whether to include authentication headers (e.g., CivitAI API key)
|
||||
custom_headers: Additional headers to include in request
|
||||
allow_resume: Whether to support resumable downloads
|
||||
pause_event: Optional event that, when cleared, will pause streaming until set again
|
||||
pause_event: Optional stream control used to pause/resume and request reconnects
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, save_path or error message)
|
||||
@@ -304,59 +399,144 @@ class Downloader:
|
||||
last_progress_report_time = datetime.now()
|
||||
progress_samples: deque[tuple[datetime, int]] = deque()
|
||||
progress_samples.append((last_progress_report_time, current_size))
|
||||
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
|
||||
|
||||
# Stream download to file with progress updates
|
||||
loop = asyncio.get_running_loop()
|
||||
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb'
|
||||
control = pause_event
|
||||
|
||||
if control is not None:
|
||||
control.update_stall_timeout(self.stall_timeout)
|
||||
|
||||
with open(part_path, mode) as f:
|
||||
async for chunk in response.content.iter_chunked(self.chunk_size):
|
||||
if pause_event is not None and not pause_event.is_set():
|
||||
await pause_event.wait()
|
||||
if chunk:
|
||||
# Run blocking file write in executor
|
||||
await loop.run_in_executor(None, f.write, chunk)
|
||||
current_size += len(chunk)
|
||||
while True:
|
||||
active_stall_timeout = control.stall_timeout if control else self.stall_timeout
|
||||
|
||||
# Limit progress update frequency to reduce overhead
|
||||
now = datetime.now()
|
||||
time_diff = (now - last_progress_report_time).total_seconds()
|
||||
if control is not None:
|
||||
if control.is_paused():
|
||||
await control.wait()
|
||||
resume_time = datetime.now()
|
||||
last_progress_report_time = resume_time
|
||||
if control.consume_reconnect_request():
|
||||
raise DownloadRestartRequested(
|
||||
"Reconnect requested after resume"
|
||||
)
|
||||
elif control.consume_reconnect_request():
|
||||
raise DownloadRestartRequested("Reconnect requested")
|
||||
|
||||
if progress_callback and time_diff >= 1.0:
|
||||
progress_samples.append((now, current_size))
|
||||
cutoff = now - timedelta(seconds=5)
|
||||
while progress_samples and progress_samples[0][0] < cutoff:
|
||||
progress_samples.popleft()
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
response.content.read(self.chunk_size),
|
||||
timeout=active_stall_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
logger.warning(
|
||||
"Download stalled for %.1f seconds without progress from %s",
|
||||
active_stall_timeout,
|
||||
url,
|
||||
)
|
||||
raise DownloadStalledError(
|
||||
f"No data received for {active_stall_timeout:.1f} seconds"
|
||||
) from exc
|
||||
|
||||
percent = (current_size / total_size) * 100 if total_size else 0.0
|
||||
bytes_per_second = 0.0
|
||||
if len(progress_samples) >= 2:
|
||||
first_time, first_bytes = progress_samples[0]
|
||||
last_time, last_bytes = progress_samples[-1]
|
||||
elapsed = (last_time - first_time).total_seconds()
|
||||
if elapsed > 0:
|
||||
bytes_per_second = (last_bytes - first_bytes) / elapsed
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
progress_snapshot = DownloadProgress(
|
||||
percent_complete=percent,
|
||||
bytes_downloaded=current_size,
|
||||
total_bytes=total_size or None,
|
||||
bytes_per_second=bytes_per_second,
|
||||
timestamp=now.timestamp(),
|
||||
)
|
||||
# Run blocking file write in executor
|
||||
await loop.run_in_executor(None, f.write, chunk)
|
||||
current_size += len(chunk)
|
||||
|
||||
await self._dispatch_progress_callback(progress_callback, progress_snapshot)
|
||||
last_progress_report_time = now
|
||||
now = datetime.now()
|
||||
if control is not None:
|
||||
control.mark_progress(timestamp=now.timestamp())
|
||||
|
||||
# Limit progress update frequency to reduce overhead
|
||||
time_diff = (now - last_progress_report_time).total_seconds()
|
||||
|
||||
if progress_callback and time_diff >= 1.0:
|
||||
progress_samples.append((now, current_size))
|
||||
cutoff = now - timedelta(seconds=5)
|
||||
while progress_samples and progress_samples[0][0] < cutoff:
|
||||
progress_samples.popleft()
|
||||
|
||||
percent = (current_size / total_size) * 100 if total_size else 0.0
|
||||
bytes_per_second = 0.0
|
||||
if len(progress_samples) >= 2:
|
||||
first_time, first_bytes = progress_samples[0]
|
||||
last_time, last_bytes = progress_samples[-1]
|
||||
elapsed = (last_time - first_time).total_seconds()
|
||||
if elapsed > 0:
|
||||
bytes_per_second = (last_bytes - first_bytes) / elapsed
|
||||
|
||||
progress_snapshot = DownloadProgress(
|
||||
percent_complete=percent,
|
||||
bytes_downloaded=current_size,
|
||||
total_bytes=total_size or None,
|
||||
bytes_per_second=bytes_per_second,
|
||||
timestamp=now.timestamp(),
|
||||
)
|
||||
|
||||
await self._dispatch_progress_callback(progress_callback, progress_snapshot)
|
||||
last_progress_report_time = now
|
||||
|
||||
# Download completed successfully
|
||||
# Verify file size if total_size was provided
|
||||
final_size = os.path.getsize(part_path)
|
||||
if total_size > 0 and final_size != total_size:
|
||||
logger.warning(f"File size mismatch. Expected: {total_size}, Got: {final_size}")
|
||||
# Don't treat this as fatal error, continue anyway
|
||||
|
||||
# Verify file size integrity before finalizing
|
||||
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0
|
||||
expected_size = total_size if total_size > 0 else None
|
||||
|
||||
integrity_error: Optional[str] = None
|
||||
if final_size <= 0:
|
||||
integrity_error = "Downloaded file is empty"
|
||||
elif expected_size is not None and final_size != expected_size:
|
||||
integrity_error = (
|
||||
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
|
||||
)
|
||||
|
||||
if integrity_error is not None:
|
||||
logger.error(
|
||||
"Download integrity check failed for %s: %s",
|
||||
save_path,
|
||||
integrity_error,
|
||||
)
|
||||
|
||||
# Remove the corrupted payload so future attempts start fresh
|
||||
if os.path.exists(part_path):
|
||||
try:
|
||||
os.remove(part_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete corrupted download %s: %s",
|
||||
part_path,
|
||||
remove_error,
|
||||
)
|
||||
if part_path != save_path and os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete target file %s after integrity error: %s",
|
||||
save_path,
|
||||
remove_error,
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
if retry_count <= self.max_retries:
|
||||
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||
logger.info(
|
||||
"Retrying download in %s seconds due to integrity check failure",
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
resume_offset = 0
|
||||
total_size = 0
|
||||
await self._create_session()
|
||||
continue
|
||||
|
||||
return False, integrity_error
|
||||
|
||||
# Atomically rename .part to final file (only if using resume)
|
||||
if allow_resume and part_path != save_path:
|
||||
max_rename_attempts = 5
|
||||
@@ -379,7 +559,9 @@ class Downloader:
|
||||
else:
|
||||
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
|
||||
return False, f"Failed to finalize download: {str(e)}"
|
||||
|
||||
|
||||
final_size = os.path.getsize(save_path)
|
||||
|
||||
# Ensure 100% progress is reported
|
||||
if progress_callback:
|
||||
final_snapshot = DownloadProgress(
|
||||
@@ -394,11 +576,17 @@ class Downloader:
|
||||
|
||||
return True, save_path
|
||||
|
||||
except (aiohttp.ClientError, aiohttp.ClientPayloadError,
|
||||
aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
|
||||
except (
|
||||
aiohttp.ClientError,
|
||||
aiohttp.ClientPayloadError,
|
||||
aiohttp.ServerDisconnectedError,
|
||||
asyncio.TimeoutError,
|
||||
DownloadStalledError,
|
||||
DownloadRestartRequested,
|
||||
) as e:
|
||||
retry_count += 1
|
||||
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}")
|
||||
|
||||
|
||||
if retry_count <= self.max_retries:
|
||||
# Calculate delay with exponential backoff
|
||||
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||
|
||||
@@ -38,6 +38,7 @@ class EmbeddingService(BaseModelService):
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"model_type": embedding_data.get("model_type", "embedding"),
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,3 +19,9 @@ class RateLimitError(RuntimeError):
|
||||
self.retry_after = retry_after
|
||||
self.provider = provider
|
||||
|
||||
|
||||
class ResourceNotFoundError(RuntimeError):
|
||||
"""Raised when a remote resource is permanently missing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class LoraService(BaseModelService):
|
||||
"usage_tips": lora_data.get("usage_tips", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
|
||||
@@ -344,15 +344,6 @@ class MetadataSyncService:
|
||||
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
|
||||
primary_model_file: Optional[Dict[str, Any]] = None
|
||||
for file_info in civitai_metadata.get("files", []):
|
||||
if file_info.get("primary", False) and file_info.get("type") == "Model":
|
||||
primary_model_file = file_info
|
||||
break
|
||||
|
||||
if primary_model_file and primary_model_file.get("hashes", {}).get("SHA256"):
|
||||
metadata["sha256"] = primary_model_file["hashes"]["SHA256"].lower()
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
await self.update_model_metadata(
|
||||
metadata_path,
|
||||
|
||||
@@ -15,6 +15,9 @@ SUPPORTED_SORT_MODES = [
|
||||
('size', 'desc'),
|
||||
]
|
||||
|
||||
DISPLAY_NAME_MODES = {"model_name", "file_name"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelCache:
|
||||
"""Cache structure for model data with extensible sorting."""
|
||||
@@ -22,16 +25,65 @@ class ModelCache:
|
||||
raw_data: List[Dict]
|
||||
folders: List[str]
|
||||
version_index: Dict[int, Dict] = field(default_factory=dict)
|
||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
name_display_mode: str = "model_name"
|
||||
|
||||
def __post_init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
# Cache for last sort: (sort_key, order) -> sorted list
|
||||
self._last_sort: Tuple[str, str] = (None, None)
|
||||
self._last_sorted_data: List[Dict] = []
|
||||
self._normalize_raw_data()
|
||||
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
|
||||
# Default sort on init
|
||||
asyncio.create_task(self.resort())
|
||||
self.rebuild_version_index()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_display_mode(value: Optional[str]) -> str:
|
||||
if isinstance(value, str) and value in DISPLAY_NAME_MODES:
|
||||
return value
|
||||
return "model_name"
|
||||
|
||||
@staticmethod
|
||||
def _ensure_string(value: Any) -> str:
|
||||
"""Return a safe string representation for metadata fields."""
|
||||
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
def _normalize_item(self, item: Dict) -> None:
|
||||
"""Ensure core metadata fields are present and string typed."""
|
||||
|
||||
if not isinstance(item, dict):
|
||||
return
|
||||
|
||||
for field in ("model_name", "file_name", "folder"):
|
||||
if field in item:
|
||||
item[field] = self._ensure_string(item.get(field))
|
||||
|
||||
def _normalize_raw_data(self) -> None:
|
||||
"""Normalize every cached entry before it is consumed."""
|
||||
|
||||
for item in self.raw_data:
|
||||
self._normalize_item(item)
|
||||
|
||||
def _get_display_name(self, item: Dict) -> str:
|
||||
"""Return the value used for name-based sorting based on display settings."""
|
||||
|
||||
if self.name_display_mode == "file_name":
|
||||
primary = self._ensure_string(item.get("file_name"))
|
||||
fallback = self._ensure_string(item.get("model_name"))
|
||||
else:
|
||||
primary = self._ensure_string(item.get("model_name"))
|
||||
fallback = self._ensure_string(item.get("file_name"))
|
||||
|
||||
candidate = primary or fallback
|
||||
return candidate or ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_version_id(value: Any) -> Optional[int]:
|
||||
"""Normalize a potential version identifier into an integer."""
|
||||
@@ -46,14 +98,15 @@ class ModelCache:
|
||||
return None
|
||||
|
||||
def rebuild_version_index(self) -> None:
|
||||
"""Rebuild the version index from the current raw data."""
|
||||
"""Rebuild the version and model indexes from the current raw data."""
|
||||
|
||||
self.version_index = {}
|
||||
self.model_id_index = {}
|
||||
for item in self.raw_data:
|
||||
self.add_to_version_index(item)
|
||||
|
||||
def add_to_version_index(self, item: Dict) -> None:
|
||||
"""Register a cache item in the version index if possible."""
|
||||
"""Register a cache item in the version/model indexes if possible."""
|
||||
|
||||
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
||||
if not isinstance(civitai_data, dict):
|
||||
@@ -65,8 +118,24 @@ class ModelCache:
|
||||
|
||||
self.version_index[version_id] = item
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
return
|
||||
|
||||
descriptor = self._build_version_descriptor(item, civitai_data, version_id)
|
||||
if descriptor is None:
|
||||
return
|
||||
|
||||
versions = self.model_id_index.setdefault(model_id, [])
|
||||
for index, existing in enumerate(versions):
|
||||
if existing.get('versionId') == descriptor['versionId']:
|
||||
versions[index] = descriptor
|
||||
break
|
||||
else:
|
||||
versions.append(descriptor)
|
||||
|
||||
def remove_from_version_index(self, item: Dict) -> None:
|
||||
"""Remove a cache item from the version index if present."""
|
||||
"""Remove a cache item from the version/model indexes if present."""
|
||||
|
||||
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
||||
if not isinstance(civitai_data, dict):
|
||||
@@ -83,6 +152,46 @@ class ModelCache:
|
||||
):
|
||||
self.version_index.pop(version_id, None)
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
return
|
||||
|
||||
versions = self.model_id_index.get(model_id)
|
||||
if not versions:
|
||||
return
|
||||
|
||||
filtered = [v for v in versions if v.get('versionId') != version_id]
|
||||
if filtered:
|
||||
self.model_id_index[model_id] = filtered
|
||||
else:
|
||||
self.model_id_index.pop(model_id, None)
|
||||
|
||||
def _build_version_descriptor(
|
||||
self,
|
||||
item: Dict,
|
||||
civitai_data: Dict[str, Any],
|
||||
version_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Create a lightweight descriptor for a version entry."""
|
||||
|
||||
model_name = self._ensure_string(civitai_data.get('name'))
|
||||
file_name = self._ensure_string(item.get('file_name'))
|
||||
return {
|
||||
'versionId': version_id,
|
||||
'name': model_name,
|
||||
'fileName': file_name,
|
||||
}
|
||||
|
||||
def get_versions_by_model_id(self, model_id: Any) -> List[Dict[str, Any]]:
|
||||
"""Return cached version descriptors for a given model ID."""
|
||||
|
||||
normalized_id = self._normalize_version_id(model_id)
|
||||
if normalized_id is None:
|
||||
return []
|
||||
|
||||
versions = self.model_id_index.get(normalized_id, [])
|
||||
return [dict(version) for version in versions]
|
||||
|
||||
async def resort(self):
|
||||
"""Resort cached data according to last sort mode if set"""
|
||||
async with self._lock:
|
||||
@@ -93,7 +202,11 @@ class ModelCache:
|
||||
# Update folder list
|
||||
# else: do nothing
|
||||
|
||||
all_folders = set(l['folder'] for l in self.raw_data)
|
||||
all_folders = {
|
||||
self._ensure_string(item.get('folder'))
|
||||
for item in self.raw_data
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
self.rebuild_version_index()
|
||||
|
||||
@@ -101,10 +214,10 @@ class ModelCache:
|
||||
"""Sort data by sort_key and order"""
|
||||
reverse = (order == 'desc')
|
||||
if sort_key == 'name':
|
||||
# Natural sort by model_name, case-insensitive
|
||||
# Natural sort by configured display name, case-insensitive
|
||||
return natsorted(
|
||||
data,
|
||||
key=lambda x: x['model_name'].lower(),
|
||||
key=lambda x: self._get_display_name(x).lower(),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'date':
|
||||
@@ -135,6 +248,20 @@ class ModelCache:
|
||||
self._last_sorted_data = sorted_data
|
||||
return sorted_data
|
||||
|
||||
async def update_name_display_mode(self, display_mode: str) -> None:
|
||||
"""Update the display mode used for name sorting and refresh cached results."""
|
||||
|
||||
normalized = self._normalize_display_mode(display_mode)
|
||||
async with self._lock:
|
||||
if self.name_display_mode == normalized:
|
||||
return
|
||||
|
||||
self.name_display_mode = normalized
|
||||
|
||||
if self._last_sort[0] == 'name':
|
||||
sort_key, order = self._last_sort
|
||||
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
||||
|
||||
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
||||
"""Update preview_url for a specific model in all cached data
|
||||
|
||||
|
||||
@@ -236,10 +236,20 @@ class ModelLifecycleService:
|
||||
def _get_multipart_ext(filename: str) -> str:
|
||||
"""Return the extension for files with compound suffixes."""
|
||||
|
||||
parts = filename.split(".")
|
||||
if len(parts) == 3:
|
||||
return "." + ".".join(parts[-2:])
|
||||
if len(parts) >= 4:
|
||||
return "." + ".".join(parts[-3:])
|
||||
return os.path.splitext(filename)[1]
|
||||
known_suffixes = [
|
||||
".metadata.json.bak",
|
||||
".metadata.json",
|
||||
".safetensors",
|
||||
*PREVIEW_EXTENSIONS,
|
||||
]
|
||||
|
||||
for suffix in sorted(known_suffixes, key=len, reverse=True):
|
||||
if filename.endswith(suffix):
|
||||
return suffix
|
||||
|
||||
basename = os.path.basename(filename)
|
||||
dot_index = basename.find(".")
|
||||
if dot_index != -1:
|
||||
return basename[dot_index:]
|
||||
|
||||
return os.path.splitext(basename)[1]
|
||||
|
||||
@@ -53,6 +53,12 @@ class ModelMetadataProvider(ABC):
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model with their details"""
|
||||
pass
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self, model_ids: Sequence[int]
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
"""Fetch model versions for multiple model ids when supported."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
@@ -80,6 +86,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
return await self.client.get_model_versions(model_id)
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self, model_ids: Sequence[int]
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
return await self.client.get_model_versions_bulk(model_ids)
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
return await self.client.get_model_version(model_id, version_id)
|
||||
@@ -544,7 +555,19 @@ class ModelMetadataProviderManager:
|
||||
"""Get model versions using specified or default provider"""
|
||||
provider = self._get_provider(provider_name)
|
||||
return await provider.get_model_versions(model_id)
|
||||
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self,
|
||||
model_ids: Sequence[int],
|
||||
provider_name: str = None,
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
"""Fetch model versions for multiple model ids when supported by provider."""
|
||||
provider = self._get_provider(provider_name)
|
||||
try:
|
||||
return await provider.get_model_versions_bulk(model_ids)
|
||||
except NotImplementedError:
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None, provider_name: str = None) -> Optional[Dict]:
|
||||
"""Get specific model version using specified or default provider"""
|
||||
provider = self._get_provider(provider_name)
|
||||
|
||||
@@ -187,6 +187,9 @@ class SearchStrategy:
|
||||
return results
|
||||
|
||||
def _matches(self, candidate: str, search_term: str, search_lower: str, fuzzy: bool) -> bool:
|
||||
if not isinstance(candidate, str):
|
||||
candidate = "" if candidate is None else str(candidate)
|
||||
|
||||
if not candidate:
|
||||
return False
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from .model_lifecycle_service import delete_model_artifacts
|
||||
from .service_registry import ServiceRegistry
|
||||
from .websocket_manager import ws_manager
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,6 +82,13 @@ class ModelScanner:
|
||||
self._is_initializing = False # Flag to track initialization state
|
||||
self._excluded_models = [] # List to track excluded models
|
||||
self._persistent_cache = get_persistent_cache()
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
self._loop = loop
|
||||
self.loop = loop
|
||||
self._initialized = True
|
||||
|
||||
# Register this service
|
||||
@@ -94,6 +102,7 @@ class ModelScanner:
|
||||
self._tags_count = {}
|
||||
self._excluded_models = []
|
||||
self._is_initializing = False
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -101,8 +110,30 @@ class ModelScanner:
|
||||
loop = None
|
||||
|
||||
if loop and not loop.is_closed():
|
||||
self._loop = loop
|
||||
self.loop = loop
|
||||
loop.create_task(self.initialize_in_background())
|
||||
|
||||
|
||||
def _resolve_name_display_mode(self) -> str:
|
||||
"""Return the configured display mode for name sorting."""
|
||||
|
||||
try:
|
||||
manager = get_settings_manager()
|
||||
except Exception: # pragma: no cover - fallback to defaults
|
||||
return "model_name"
|
||||
|
||||
value = manager.get("model_name_display", "model_name")
|
||||
return ModelCache._normalize_display_mode(value)
|
||||
|
||||
async def on_model_name_display_changed(self, display_mode: str) -> None:
|
||||
"""Handle updates to the model name display preference."""
|
||||
|
||||
normalized = ModelCache._normalize_display_mode(display_mode)
|
||||
self._name_display_mode = normalized
|
||||
|
||||
if self._cache is not None:
|
||||
await self._cache.update_name_display_mode(normalized)
|
||||
|
||||
async def _register_service(self):
|
||||
"""Register this instance with the ServiceRegistry"""
|
||||
service_name = f"{self.model_type}_scanner"
|
||||
@@ -211,7 +242,8 @@ class ModelScanner:
|
||||
if self._cache is None:
|
||||
self._cache = ModelCache(
|
||||
raw_data=[],
|
||||
folders=[]
|
||||
folders=[],
|
||||
name_display_mode=self._name_display_mode,
|
||||
)
|
||||
|
||||
# Set initializing flag to true
|
||||
@@ -344,12 +376,16 @@ class ModelScanner:
|
||||
hash_index.add_entry(sha_value.lower(), path)
|
||||
|
||||
tags_count: Dict[str, int] = {}
|
||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
||||
for item in persisted.raw_data:
|
||||
for tag in item.get('tags') or []:
|
||||
adjusted_item = self.adjust_cached_entry(dict(item))
|
||||
adjusted_raw_data.append(adjusted_item)
|
||||
|
||||
for tag in adjusted_item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
|
||||
scan_result = CacheBuildResult(
|
||||
raw_data=list(persisted.raw_data),
|
||||
raw_data=adjusted_raw_data,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=list(persisted.excluded_models)
|
||||
@@ -516,7 +552,8 @@ class ModelScanner:
|
||||
if self._cache is None and not force_refresh:
|
||||
return ModelCache(
|
||||
raw_data=[],
|
||||
folders=[]
|
||||
folders=[],
|
||||
name_display_mode=self._name_display_mode,
|
||||
)
|
||||
|
||||
# If force refresh is requested, initialize the cache directly
|
||||
@@ -549,7 +586,8 @@ class ModelScanner:
|
||||
if self._cache is None:
|
||||
self._cache = ModelCache(
|
||||
raw_data=[],
|
||||
folders=[]
|
||||
folders=[],
|
||||
name_display_mode=self._name_display_mode,
|
||||
)
|
||||
finally:
|
||||
self._is_initializing = False # Unset flag
|
||||
@@ -640,6 +678,9 @@ class ModelScanner:
|
||||
if root_path:
|
||||
model_data = await self._process_model_file(path, root_path)
|
||||
if model_data:
|
||||
model_data = self.adjust_cached_entry(dict(model_data))
|
||||
if not model_data:
|
||||
continue
|
||||
# Add to cache
|
||||
self._cache.raw_data.append(model_data)
|
||||
self._cache.add_to_version_index(model_data)
|
||||
@@ -732,6 +773,41 @@ class ModelScanner:
|
||||
"""Hook for subclasses: adjust metadata during scanning"""
|
||||
return metadata
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path_value(path: Optional[str]) -> str:
|
||||
if not path:
|
||||
return ''
|
||||
|
||||
normalized = os.path.normpath(path)
|
||||
if normalized == '.':
|
||||
return ''
|
||||
|
||||
return normalized.replace('\\', '/')
|
||||
|
||||
def _find_root_for_file(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Return the configured root directory that contains ``file_path``."""
|
||||
|
||||
normalized_path = self._normalize_path_value(file_path)
|
||||
if not normalized_path:
|
||||
return None
|
||||
|
||||
for root in self.get_model_roots() or []:
|
||||
normalized_root = self._normalize_path_value(root)
|
||||
if not normalized_root:
|
||||
continue
|
||||
|
||||
if (
|
||||
normalized_path == normalized_root
|
||||
or normalized_path.startswith(f"{normalized_root}/")
|
||||
):
|
||||
return root
|
||||
|
||||
return None
|
||||
|
||||
async def _process_model_file(
|
||||
self,
|
||||
file_path: str,
|
||||
@@ -837,7 +913,8 @@ class ModelScanner:
|
||||
if self._cache is None:
|
||||
self._cache = ModelCache(
|
||||
raw_data=list(scan_result.raw_data),
|
||||
folders=[]
|
||||
folders=[],
|
||||
name_display_mode=self._name_display_mode,
|
||||
)
|
||||
else:
|
||||
self._cache.raw_data = list(scan_result.raw_data)
|
||||
@@ -1443,21 +1520,10 @@ class ModelScanner:
|
||||
"""
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
if not cache:
|
||||
return []
|
||||
|
||||
versions = []
|
||||
for item in cache.raw_data:
|
||||
if (item.get('civitai') and
|
||||
item['civitai'].get('modelId') == model_id and
|
||||
item['civitai'].get('id')):
|
||||
versions.append({
|
||||
'versionId': item['civitai'].get('id'),
|
||||
'name': item['civitai'].get('name'),
|
||||
'fileName': item.get('file_name', '')
|
||||
})
|
||||
|
||||
return versions
|
||||
|
||||
return cache.get_versions_by_model_id(model_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model versions: {e}")
|
||||
return []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from ..utils.settings_paths import get_settings_dir
|
||||
from ..utils.settings_paths import get_project_root, get_settings_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -397,7 +397,7 @@ class PersistentModelCache:
|
||||
settings_dir = get_settings_dir(create=True)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning("Falling back to project directory for cache: %s", exc)
|
||||
settings_dir = os.path.dirname(os.path.dirname(self._db_path)) if hasattr(self, "_db_path") else os.getcwd()
|
||||
settings_dir = get_project_root()
|
||||
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", library_name or "default")
|
||||
if safe_name.lower() in ("default", ""):
|
||||
legacy_path = os.path.join(settings_dir, self._DEFAULT_FILENAME)
|
||||
|
||||
@@ -9,6 +9,8 @@ from urllib.parse import urlparse
|
||||
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,7 +45,18 @@ class PreviewAssetService:
|
||||
if not images:
|
||||
return
|
||||
|
||||
first_preview = images[0]
|
||||
settings_manager = get_settings_manager()
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get("blur_mature_content", True)
|
||||
)
|
||||
first_preview, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
)
|
||||
|
||||
if not first_preview:
|
||||
return
|
||||
|
||||
base_name = os.path.splitext(os.path.splitext(os.path.basename(metadata_path))[0])[0]
|
||||
preview_dir = os.path.dirname(metadata_path)
|
||||
is_video = first_preview.get("type") == "video"
|
||||
@@ -81,7 +94,7 @@ class PreviewAssetService:
|
||||
success, _ = await downloader.download_file(candidate, preview_path, use_auth=False)
|
||||
if success:
|
||||
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
|
||||
local_metadata["preview_nsfw_level"] = first_preview.get("nsfwLevel", 0)
|
||||
local_metadata["preview_nsfw_level"] = nsfw_level
|
||||
return
|
||||
else:
|
||||
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type="image")
|
||||
@@ -93,7 +106,7 @@ class PreviewAssetService:
|
||||
)
|
||||
if success:
|
||||
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
|
||||
local_metadata["preview_nsfw_level"] = first_preview.get("nsfwLevel", 0)
|
||||
local_metadata["preview_nsfw_level"] = nsfw_level
|
||||
return
|
||||
|
||||
extension = ".webp"
|
||||
@@ -124,7 +137,7 @@ class PreviewAssetService:
|
||||
return
|
||||
|
||||
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
|
||||
local_metadata["preview_nsfw_level"] = first_preview.get("nsfwLevel", 0)
|
||||
local_metadata["preview_nsfw_level"] = nsfw_level
|
||||
|
||||
async def replace_preview(
|
||||
self,
|
||||
|
||||
@@ -384,16 +384,32 @@ class RecipeScanner:
|
||||
|
||||
# Ensure the image file exists
|
||||
image_path = recipe_data.get('file_path')
|
||||
if not os.path.exists(image_path):
|
||||
normalized_image_path = os.path.normpath(image_path) if image_path else image_path
|
||||
path_updated = False
|
||||
if image_path and normalized_image_path != image_path:
|
||||
recipe_data['file_path'] = normalized_image_path
|
||||
image_path = normalized_image_path
|
||||
path_updated = True
|
||||
|
||||
if image_path and not os.path.exists(image_path):
|
||||
logger.warning(f"Recipe image not found: {image_path}")
|
||||
# Try to find the image in the same directory as the recipe
|
||||
recipe_dir = os.path.dirname(recipe_path)
|
||||
image_filename = os.path.basename(image_path)
|
||||
alternative_path = os.path.join(recipe_dir, image_filename)
|
||||
if os.path.exists(alternative_path):
|
||||
recipe_data['file_path'] = alternative_path
|
||||
normalized_alternative = os.path.normpath(alternative_path)
|
||||
recipe_data['file_path'] = normalized_alternative
|
||||
image_path = normalized_alternative
|
||||
path_updated = True
|
||||
logger.info(
|
||||
"Updated recipe image path to %s after relocating asset", normalized_alternative
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Could not find alternative image path for {image_path}")
|
||||
|
||||
if path_updated:
|
||||
self._write_recipe_file(recipe_path, recipe_data)
|
||||
|
||||
# Ensure loras array exists
|
||||
if 'loras' not in recipe_data:
|
||||
@@ -413,18 +429,24 @@ class RecipeScanner:
|
||||
|
||||
# Write updated recipe data back to file
|
||||
try:
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
self._write_recipe_file(recipe_path, recipe_data)
|
||||
logger.info(f"Added fingerprint to recipe: {recipe_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing updated recipe with fingerprint: {e}")
|
||||
|
||||
|
||||
return recipe_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading recipe file {recipe_path}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _write_recipe_file(recipe_path: str, recipe_data: Dict[str, Any]) -> None:
|
||||
"""Persist ``recipe_data`` back to ``recipe_path`` with standard formatting."""
|
||||
|
||||
with open(recipe_path, 'w', encoding='utf-8') as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
async def _update_lora_information(self, recipe_data: Dict) -> bool:
|
||||
"""Update LoRA information with hash and file_name
|
||||
@@ -625,6 +647,17 @@ class RecipeScanner:
|
||||
# Get base dataset
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# Apply SFW filtering if enabled
|
||||
from .settings_manager import get_settings_manager
|
||||
settings = get_settings_manager()
|
||||
if settings.get("show_only_sfw", False):
|
||||
from ..utils.constants import NSFW_LEVELS
|
||||
threshold = NSFW_LEVELS.get("R", 4) # Default to R level (4) if not found
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if not item.get("preview_nsfw_level") or item.get("preview_nsfw_level") < threshold
|
||||
]
|
||||
|
||||
# Special case: Filter by LoRA hash (takes precedence if bypass_filters is True)
|
||||
if lora_hash:
|
||||
# Filter recipes that contain this LoRA hash
|
||||
|
||||
@@ -73,7 +73,8 @@ class RecipePersistenceService:
|
||||
)
|
||||
image_filename = f"{recipe_id}{extension}"
|
||||
image_path = os.path.join(recipes_dir, image_filename)
|
||||
with open(image_path, "wb") as file_obj:
|
||||
normalized_image_path = os.path.normpath(image_path)
|
||||
with open(normalized_image_path, "wb") as file_obj:
|
||||
file_obj.write(optimized_image)
|
||||
|
||||
current_time = time.time()
|
||||
@@ -97,7 +98,7 @@ class RecipePersistenceService:
|
||||
fingerprint = calculate_recipe_fingerprint(loras_data)
|
||||
recipe_data: Dict[str, Any] = {
|
||||
"id": recipe_id,
|
||||
"file_path": image_path,
|
||||
"file_path": normalized_image_path,
|
||||
"title": name,
|
||||
"modified": current_time,
|
||||
"created_date": current_time,
|
||||
@@ -116,10 +117,11 @@ 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)
|
||||
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
|
||||
|
||||
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
||||
await recipe_scanner.add_recipe(recipe_data)
|
||||
@@ -128,7 +130,7 @@ class RecipePersistenceService:
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"image_path": image_path,
|
||||
"image_path": normalized_image_path,
|
||||
"json_path": json_path,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -59,8 +61,9 @@ class RecipeSharingService:
|
||||
}
|
||||
self._cleanup_shared_recipes()
|
||||
|
||||
safe_title = recipe.get("title", "").replace(" ", "_").lower()
|
||||
filename = f"recipe_{safe_title}{ext}" if safe_title else f"recipe_{recipe_id}{ext}"
|
||||
filename = self._build_download_filename(
|
||||
title=recipe.get("title", ""), recipe_id=recipe_id, ext=ext
|
||||
)
|
||||
url_path = f"/api/lm/recipe/{recipe_id}/share/download?t={timestamp}"
|
||||
return SharingResult({"success": True, "download_url": url_path, "filename": filename})
|
||||
|
||||
@@ -78,13 +81,38 @@ class RecipeSharingService:
|
||||
raise RecipeNotFoundError("Shared recipe file not found")
|
||||
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
filename_base = (
|
||||
f"recipe_{recipe.get('title', '').replace(' ', '_').lower()}" if recipe else recipe_id
|
||||
)
|
||||
ext = os.path.splitext(file_path)[1]
|
||||
download_filename = f"{filename_base}{ext}"
|
||||
download_filename = self._build_download_filename(
|
||||
title=recipe.get("title", "") if recipe else "",
|
||||
recipe_id=recipe_id,
|
||||
ext=ext,
|
||||
)
|
||||
return DownloadInfo(file_path=file_path, download_filename=download_filename)
|
||||
|
||||
@staticmethod
|
||||
def _build_download_filename(*, title: str, recipe_id: str, ext: str) -> str:
|
||||
"""Generate a sanitized filename safe for HTTP headers and filesystems."""
|
||||
|
||||
ext = ext or ""
|
||||
safe_title = RecipeSharingService._slugify(title)
|
||||
fallback = RecipeSharingService._slugify(recipe_id)
|
||||
identifier = safe_title or fallback or "recipe"
|
||||
return f"recipe_{identifier}{ext}"
|
||||
|
||||
@staticmethod
|
||||
def _slugify(value: str) -> str:
|
||||
"""Convert arbitrary input into a lowercase, header-safe slug."""
|
||||
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
ascii_value = ascii_value.replace("\n", " ").replace("\r", " ")
|
||||
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "_", ascii_value)
|
||||
sanitized = re.sub(r"_+", "_", sanitized).strip("._-")
|
||||
return sanitized.lower()
|
||||
|
||||
def _cleanup_shared_recipes(self) -> None:
|
||||
for recipe_id in list(self._shared_recipes.keys()):
|
||||
shared = self._shared_recipes.get(recipe_id)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from ..utils.constants import DEFAULT_PRIORITY_TAG_CONFIG
|
||||
from ..utils.settings_paths import ensure_settings_file
|
||||
@@ -18,8 +20,15 @@ from ..utils.tag_priorities import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
|
||||
"civitai_api_key",
|
||||
"folder_paths",
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"civitai_api_key": "",
|
||||
"use_portable_settings": False,
|
||||
"language": "en",
|
||||
"show_only_sfw": False,
|
||||
"enable_metadata_archive_db": False,
|
||||
@@ -34,6 +43,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"default_embedding_root": "",
|
||||
"base_model_path_mappings": {},
|
||||
"download_path_templates": {},
|
||||
"folder_paths": {},
|
||||
"example_images_path": "",
|
||||
"optimize_example_images": True,
|
||||
"auto_download_example_images": False,
|
||||
@@ -41,16 +51,28 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"autoplay_on_hover": False,
|
||||
"display_density": "default",
|
||||
"card_info_display": "always",
|
||||
"show_folder_sidebar": True,
|
||||
"include_trigger_words": False,
|
||||
"compact_mode": False,
|
||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||
"model_name_display": "model_name",
|
||||
"model_card_footer_action": "example_images",
|
||||
}
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
def __init__(self):
|
||||
self.settings_file = ensure_settings_file(logger)
|
||||
self._standalone_mode = self._detect_standalone_mode()
|
||||
self._startup_messages: List[Dict[str, Any]] = []
|
||||
self._needs_initial_save = False
|
||||
self._bootstrap_reason: Optional[str] = None
|
||||
self._seed_template: Optional[Dict[str, Any]] = None
|
||||
self._template_payload_cache: Optional[Dict[str, Any]] = None
|
||||
self._template_payload_cache_loaded = False
|
||||
self._original_disk_payload: Optional[Dict[str, Any]] = None
|
||||
self._preserve_disk_template = False
|
||||
self._template_path = Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||
self.settings = self._load_settings()
|
||||
self._migrate_setting_keys()
|
||||
self._ensure_default_settings()
|
||||
@@ -58,45 +80,206 @@ class SettingsManager:
|
||||
self._migrate_download_path_template()
|
||||
self._auto_set_default_roots()
|
||||
self._check_environment_variables()
|
||||
self._collect_configuration_warnings()
|
||||
|
||||
if self._needs_initial_save:
|
||||
self._save_settings()
|
||||
self._needs_initial_save = False
|
||||
|
||||
def _detect_standalone_mode(self) -> bool:
|
||||
"""Return ``True`` when running in standalone mode."""
|
||||
|
||||
return os.environ.get("LORA_MANAGER_STANDALONE") == "1"
|
||||
|
||||
def _load_settings(self) -> Dict[str, Any]:
|
||||
"""Load settings from file"""
|
||||
if os.path.exists(self.settings_file):
|
||||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading settings: {e}")
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
self._original_disk_payload = copy.deepcopy(data)
|
||||
if self._matches_template_payload(data):
|
||||
self._preserve_disk_template = True
|
||||
return data
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse settings.json: %s", exc)
|
||||
self._add_startup_message(
|
||||
code="settings-json-invalid",
|
||||
title="Settings file could not be parsed",
|
||||
message=(
|
||||
"LoRA Manager could not parse settings.json. Default settings "
|
||||
"will be used for this session."
|
||||
),
|
||||
severity="error",
|
||||
actions=self._default_settings_actions(),
|
||||
details=str(exc),
|
||||
dismissible=False,
|
||||
)
|
||||
self._needs_initial_save = True
|
||||
self._bootstrap_reason = "invalid"
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.error("Unexpected error loading settings: %s", exc)
|
||||
self._add_startup_message(
|
||||
code="settings-json-unreadable",
|
||||
title="Settings file could not be read",
|
||||
message="LoRA Manager could not read settings.json. Default settings will be used for this session.",
|
||||
severity="error",
|
||||
actions=self._default_settings_actions(),
|
||||
details=str(exc),
|
||||
dismissible=False,
|
||||
)
|
||||
self._needs_initial_save = True
|
||||
self._bootstrap_reason = "unreadable"
|
||||
|
||||
if not os.path.exists(self.settings_file):
|
||||
self._needs_initial_save = True
|
||||
self._bootstrap_reason = "missing"
|
||||
seeded = self._load_settings_template()
|
||||
if seeded is not None:
|
||||
defaults = self._get_default_settings()
|
||||
merged = self._merge_template_with_defaults(defaults, seeded)
|
||||
return merged
|
||||
return self._get_default_settings()
|
||||
|
||||
def _load_settings_template(self) -> Optional[Dict[str, Any]]:
|
||||
"""Load the bundled template when no user settings are found."""
|
||||
|
||||
payload = self._read_template_payload()
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
self._seed_template = copy.deepcopy(payload)
|
||||
return copy.deepcopy(payload)
|
||||
|
||||
def _read_template_payload(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return the cached contents of ``settings.json.example`` when available."""
|
||||
|
||||
if self._template_payload_cache_loaded:
|
||||
if self._template_payload_cache is None:
|
||||
return None
|
||||
return copy.deepcopy(self._template_payload_cache)
|
||||
|
||||
self._template_payload_cache_loaded = True
|
||||
|
||||
try:
|
||||
with self._template_path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
logger.debug("settings.json.example not found at %s", self._template_path)
|
||||
return None
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Failed to parse settings.json.example: %s", exc)
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.debug("settings.json.example is not a JSON object; ignoring template")
|
||||
return None
|
||||
|
||||
self._template_payload_cache = copy.deepcopy(data)
|
||||
return copy.deepcopy(self._template_payload_cache)
|
||||
|
||||
def _matches_template_payload(self, payload: Mapping[str, Any]) -> bool:
|
||||
"""Return ``True`` when ``payload`` matches the bundled template."""
|
||||
|
||||
template = self._read_template_payload()
|
||||
if template is None:
|
||||
return False
|
||||
|
||||
return payload == template
|
||||
|
||||
def _merge_template_with_defaults(
|
||||
self, defaults: Dict[str, Any], template: Mapping[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge template values into the in-memory defaults."""
|
||||
|
||||
merged = copy.deepcopy(defaults)
|
||||
for key, value in template.items():
|
||||
if key == "folder_paths" and isinstance(value, Mapping):
|
||||
merged[key] = self._normalize_folder_paths(value)
|
||||
else:
|
||||
merged[key] = copy.deepcopy(value)
|
||||
|
||||
merged.setdefault("language", "en")
|
||||
merged.setdefault("folder_paths", {})
|
||||
library_name = merged.get("active_library") or "default"
|
||||
merged["libraries"] = {
|
||||
library_name: self._build_library_payload(
|
||||
folder_paths=merged.get("folder_paths", {}),
|
||||
default_lora_root=merged.get("default_lora_root"),
|
||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||
default_embedding_root=merged.get("default_embedding_root"),
|
||||
)
|
||||
}
|
||||
merged["active_library"] = library_name
|
||||
return merged
|
||||
|
||||
def _ensure_default_settings(self) -> None:
|
||||
"""Ensure all default settings keys exist"""
|
||||
updated = False
|
||||
normalized_priority = self._normalize_priority_tag_config(
|
||||
self.settings.get("priority_tags")
|
||||
)
|
||||
if normalized_priority != self.settings.get("priority_tags"):
|
||||
self.settings["priority_tags"] = normalized_priority
|
||||
updated = True
|
||||
for key, value in self._get_default_settings().items():
|
||||
defaults = self._get_default_settings()
|
||||
updated_existing = False
|
||||
inserted_defaults = False
|
||||
|
||||
if "priority_tags" in self.settings:
|
||||
normalized_priority = self._normalize_priority_tag_config(
|
||||
self.settings.get("priority_tags")
|
||||
)
|
||||
if normalized_priority != self.settings.get("priority_tags"):
|
||||
self.settings["priority_tags"] = normalized_priority
|
||||
updated_existing = True
|
||||
else:
|
||||
self.settings["priority_tags"] = copy.deepcopy(
|
||||
defaults.get("priority_tags", DEFAULT_PRIORITY_TAG_CONFIG)
|
||||
)
|
||||
inserted_defaults = True
|
||||
|
||||
for key, value in defaults.items():
|
||||
if key == "priority_tags":
|
||||
continue
|
||||
if key not in self.settings:
|
||||
if isinstance(value, dict):
|
||||
self.settings[key] = value.copy()
|
||||
self.settings[key] = copy.deepcopy(value)
|
||||
else:
|
||||
self.settings[key] = value
|
||||
updated = True
|
||||
if updated:
|
||||
inserted_defaults = True
|
||||
|
||||
if updated_existing or (
|
||||
inserted_defaults and self._bootstrap_reason in {"invalid", "unreadable"}
|
||||
):
|
||||
self._save_settings()
|
||||
|
||||
def _migrate_to_library_registry(self) -> None:
|
||||
"""Ensure settings include the multi-library registry structure."""
|
||||
libraries = self.settings.get("libraries")
|
||||
active_name = self.settings.get("active_library")
|
||||
initial_bootstrap = self._bootstrap_reason == "missing"
|
||||
|
||||
if not isinstance(libraries, dict) or not libraries:
|
||||
raw_top_level_paths = self.settings.get("folder_paths", {})
|
||||
normalized_top_level_paths: Dict[str, List[str]] = {}
|
||||
if isinstance(raw_top_level_paths, Mapping):
|
||||
normalized_top_level_paths = self._normalize_folder_paths(raw_top_level_paths)
|
||||
if normalized_top_level_paths != raw_top_level_paths:
|
||||
self.settings["folder_paths"] = copy.deepcopy(normalized_top_level_paths)
|
||||
|
||||
top_level_has_paths = self._has_configured_paths(normalized_top_level_paths)
|
||||
|
||||
needs_library_bootstrap = not isinstance(libraries, dict) or not libraries
|
||||
|
||||
if (
|
||||
not needs_library_bootstrap
|
||||
and top_level_has_paths
|
||||
and len(libraries) == 1
|
||||
):
|
||||
only_library_payload = next(iter(libraries.values()))
|
||||
if isinstance(only_library_payload, Mapping):
|
||||
folder_payload = only_library_payload.get("folder_paths")
|
||||
if not self._has_configured_paths(folder_payload):
|
||||
needs_library_bootstrap = True
|
||||
|
||||
if needs_library_bootstrap:
|
||||
library_name = active_name or "default"
|
||||
library_payload = self._build_library_payload(
|
||||
folder_paths=self.settings.get("folder_paths", {}),
|
||||
folder_paths=normalized_top_level_paths,
|
||||
default_lora_root=self.settings.get("default_lora_root", ""),
|
||||
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""),
|
||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||
@@ -105,17 +288,40 @@ class SettingsManager:
|
||||
self.settings["libraries"] = libraries
|
||||
self.settings["active_library"] = library_name
|
||||
self._sync_active_library_to_root(save=False)
|
||||
self._save_settings()
|
||||
if not initial_bootstrap and not self._preserve_disk_template:
|
||||
self._save_settings()
|
||||
return
|
||||
|
||||
seed_library_name: Optional[str] = None
|
||||
if top_level_has_paths and isinstance(libraries, dict):
|
||||
target_name: Optional[str] = None
|
||||
if active_name and active_name in libraries:
|
||||
target_name = active_name
|
||||
elif len(libraries) == 1:
|
||||
target_name = next(iter(libraries.keys()))
|
||||
|
||||
if target_name:
|
||||
candidate_payload = libraries.get(target_name)
|
||||
if isinstance(candidate_payload, Mapping) and not self._has_configured_paths(candidate_payload.get("folder_paths")):
|
||||
seed_library_name = target_name
|
||||
|
||||
sanitized_libraries: Dict[str, Dict[str, Any]] = {}
|
||||
changed = False
|
||||
for name, data in libraries.items():
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
changed = True
|
||||
|
||||
candidate_folder_paths = data.get("folder_paths")
|
||||
if (
|
||||
seed_library_name == name
|
||||
and not self._has_configured_paths(candidate_folder_paths)
|
||||
and top_level_has_paths
|
||||
):
|
||||
candidate_folder_paths = normalized_top_level_paths
|
||||
|
||||
payload = self._build_library_payload(
|
||||
folder_paths=data.get("folder_paths"),
|
||||
folder_paths=candidate_folder_paths,
|
||||
default_lora_root=data.get("default_lora_root"),
|
||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||
default_embedding_root=data.get("default_embedding_root"),
|
||||
@@ -130,12 +336,15 @@ class SettingsManager:
|
||||
self.settings["libraries"] = sanitized_libraries
|
||||
|
||||
if not active_name or active_name not in sanitized_libraries:
|
||||
changed = True
|
||||
if sanitized_libraries:
|
||||
self.settings["active_library"] = next(iter(sanitized_libraries.keys()))
|
||||
else:
|
||||
self.settings["active_library"] = "default"
|
||||
|
||||
self._sync_active_library_to_root(save=changed)
|
||||
self._sync_active_library_to_root(save=changed and not initial_bootstrap)
|
||||
if changed and initial_bootstrap:
|
||||
self._needs_initial_save = True
|
||||
|
||||
def _sync_active_library_to_root(self, *, save: bool = False) -> None:
|
||||
"""Update top-level folder path settings to mirror the active library."""
|
||||
@@ -224,6 +433,25 @@ class SettingsManager:
|
||||
normalized[key] = cleaned
|
||||
return normalized
|
||||
|
||||
def _has_configured_paths(self, folder_paths: Any) -> bool:
|
||||
if not isinstance(folder_paths, Mapping):
|
||||
return False
|
||||
|
||||
for values in folder_paths.values():
|
||||
if isinstance(values, str):
|
||||
candidate_values = [values]
|
||||
else:
|
||||
try:
|
||||
candidate_values = list(values) # type: ignore[arg-type]
|
||||
except TypeError:
|
||||
continue
|
||||
|
||||
for path in candidate_values:
|
||||
if isinstance(path, str) and path.strip():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _validate_folder_paths(
|
||||
self,
|
||||
library_name: str,
|
||||
@@ -316,6 +544,7 @@ class SettingsManager:
|
||||
'cardInfoDisplay': 'card_info_display',
|
||||
'includeTriggerWords': 'include_trigger_words',
|
||||
'compactMode': 'compact_mode',
|
||||
'modelCardFooterAction': 'model_card_footer_action',
|
||||
}
|
||||
|
||||
updated = False
|
||||
@@ -379,7 +608,10 @@ class SettingsManager:
|
||||
default_checkpoint_root=self.settings.get('default_checkpoint_root'),
|
||||
default_embedding_root=self.settings.get('default_embedding_root'),
|
||||
)
|
||||
self._save_settings()
|
||||
if self._bootstrap_reason == "missing":
|
||||
self._needs_initial_save = True
|
||||
else:
|
||||
self._save_settings()
|
||||
|
||||
def _check_environment_variables(self) -> None:
|
||||
"""Check for environment variables and update settings if needed"""
|
||||
@@ -390,17 +622,107 @@ class SettingsManager:
|
||||
self.settings['civitai_api_key'] = env_api_key
|
||||
self._save_settings()
|
||||
|
||||
def _default_settings_actions(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"action": "open-settings-location",
|
||||
"label": "Open settings folder",
|
||||
"type": "primary",
|
||||
"icon": "fas fa-folder-open",
|
||||
}
|
||||
]
|
||||
|
||||
def _add_startup_message(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
title: str,
|
||||
message: str,
|
||||
severity: str = "info",
|
||||
actions: Optional[List[Dict[str, Any]]] = None,
|
||||
details: Optional[str] = None,
|
||||
dismissible: bool = False,
|
||||
) -> None:
|
||||
if any(existing.get("code") == code for existing in self._startup_messages):
|
||||
return
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"code": code,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"severity": severity.lower(),
|
||||
"dismissible": bool(dismissible),
|
||||
}
|
||||
|
||||
if actions:
|
||||
payload["actions"] = [dict(action) for action in actions]
|
||||
if details:
|
||||
payload["details"] = details
|
||||
payload["settings_file"] = self.settings_file
|
||||
|
||||
self._startup_messages.append(payload)
|
||||
|
||||
def _collect_configuration_warnings(self) -> None:
|
||||
if not self._standalone_mode:
|
||||
return
|
||||
|
||||
folder_paths = self.settings.get('folder_paths', {}) or {}
|
||||
monitored_keys = ('loras', 'checkpoints', 'embeddings')
|
||||
|
||||
has_valid_paths = False
|
||||
for key in monitored_keys:
|
||||
raw_paths = folder_paths.get(key) or []
|
||||
if isinstance(raw_paths, str):
|
||||
raw_paths = [raw_paths]
|
||||
try:
|
||||
iterator = list(raw_paths)
|
||||
except TypeError:
|
||||
continue
|
||||
if any(isinstance(path, str) and path and os.path.exists(path) for path in iterator):
|
||||
has_valid_paths = True
|
||||
break
|
||||
|
||||
if not has_valid_paths:
|
||||
if self._bootstrap_reason == "missing":
|
||||
message = (
|
||||
"LoRA Manager created a default settings.json because no configuration was found. "
|
||||
"Edit settings.json to add your model directories so library scanning can run."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"LoRA Manager could not locate any configured model directories. "
|
||||
"Edit settings.json to add your model folders so library scanning can run."
|
||||
)
|
||||
self._add_startup_message(
|
||||
code="missing-model-paths",
|
||||
title="Model folders need setup",
|
||||
message=message,
|
||||
severity="warning",
|
||||
actions=self._default_settings_actions(),
|
||||
dismissible=False,
|
||||
)
|
||||
|
||||
def refresh_environment_variables(self) -> None:
|
||||
"""Refresh settings from environment variables"""
|
||||
self._check_environment_variables()
|
||||
|
||||
def _get_default_settings(self) -> Dict[str, Any]:
|
||||
"""Return default settings"""
|
||||
defaults = DEFAULT_SETTINGS.copy()
|
||||
# Ensure nested dicts are independent copies
|
||||
defaults = copy.deepcopy(DEFAULT_SETTINGS)
|
||||
defaults['base_model_path_mappings'] = {}
|
||||
defaults['download_path_templates'] = {}
|
||||
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
||||
defaults.setdefault('folder_paths', {})
|
||||
|
||||
library_name = defaults.get("active_library") or "default"
|
||||
default_library = self._build_library_payload(
|
||||
folder_paths=defaults.get("folder_paths", {}),
|
||||
default_lora_root=defaults.get("default_lora_root"),
|
||||
default_checkpoint_root=defaults.get("default_checkpoint_root"),
|
||||
default_embedding_root=defaults.get("default_embedding_root"),
|
||||
)
|
||||
defaults['libraries'] = {library_name: default_library}
|
||||
defaults['active_library'] = library_name
|
||||
return defaults
|
||||
|
||||
def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]:
|
||||
@@ -424,6 +746,9 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
return normalized.copy()
|
||||
|
||||
def get_startup_messages(self) -> List[Dict[str, Any]]:
|
||||
return [message.copy() for message in self._startup_messages]
|
||||
|
||||
def get_priority_tag_entries(self, model_type: str) -> List[PriorityTagEntry]:
|
||||
config = self.get_priority_tag_config()
|
||||
raw_config = config.get(model_type, "")
|
||||
@@ -465,6 +790,8 @@ class SettingsManager:
|
||||
self._update_active_library_entry(default_checkpoint_root=str(value))
|
||||
elif key == 'default_embedding_root':
|
||||
self._update_active_library_entry(default_embedding_root=str(value))
|
||||
elif key == 'model_name_display':
|
||||
self._notify_model_name_display_change(value)
|
||||
self._save_settings()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
@@ -474,13 +801,110 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
logger.info(f"Deleted setting: {key}")
|
||||
|
||||
def _notify_model_name_display_change(self, value: Any) -> None:
|
||||
"""Trigger cache resorting when the model name display preference updates."""
|
||||
|
||||
try:
|
||||
from .service_registry import ServiceRegistry # type: ignore
|
||||
except Exception: # pragma: no cover - registry optional in some contexts
|
||||
return
|
||||
|
||||
display_mode = value if isinstance(value, str) else "model_name"
|
||||
pending: List[Tuple[Optional[asyncio.AbstractEventLoop], Awaitable[Any]]] = []
|
||||
|
||||
def _resolve_service_loop(service: Any) -> Optional[asyncio.AbstractEventLoop]:
|
||||
loop = getattr(service, "loop", None)
|
||||
if loop is None:
|
||||
loop = getattr(service, "_loop", None)
|
||||
return loop if isinstance(loop, asyncio.AbstractEventLoop) else None
|
||||
|
||||
for service_name in (
|
||||
"lora_scanner",
|
||||
"checkpoint_scanner",
|
||||
"embedding_scanner",
|
||||
"recipe_scanner",
|
||||
):
|
||||
service = ServiceRegistry.get_service_sync(service_name)
|
||||
if not service or not hasattr(service, "on_model_name_display_changed"):
|
||||
continue
|
||||
|
||||
try:
|
||||
result = service.on_model_name_display_changed(display_mode)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug(
|
||||
"Service %s failed to schedule name display update: %s",
|
||||
service_name,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
service_loop = _resolve_service_loop(service)
|
||||
pending.append((service_loop, result))
|
||||
|
||||
if not pending:
|
||||
return
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
for service_loop, coroutine in pending:
|
||||
target_loop = service_loop or loop
|
||||
|
||||
if target_loop is None:
|
||||
try:
|
||||
asyncio.run(coroutine)
|
||||
except RuntimeError:
|
||||
logger.debug("Skipping name display update due to missing event loop")
|
||||
continue
|
||||
|
||||
if loop is not None and target_loop is loop:
|
||||
target_loop.create_task(coroutine)
|
||||
continue
|
||||
|
||||
if target_loop.is_running():
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(coroutine, target_loop)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("Failed to dispatch name display update: %s", exc)
|
||||
continue
|
||||
|
||||
try:
|
||||
asyncio.run(coroutine)
|
||||
except RuntimeError:
|
||||
logger.debug("Skipping name display update due to closed loop")
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
"""Save settings to file"""
|
||||
try:
|
||||
payload = self._serialize_settings_for_disk()
|
||||
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.settings, f, indent=2)
|
||||
json.dump(payload, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
else:
|
||||
if self._bootstrap_reason == "missing":
|
||||
self._bootstrap_reason = None
|
||||
self._seed_template = None
|
||||
|
||||
def _serialize_settings_for_disk(self) -> Dict[str, Any]:
|
||||
"""Return the settings payload that should be persisted to disk."""
|
||||
|
||||
if self._bootstrap_reason == "missing":
|
||||
minimal: Dict[str, Any] = {}
|
||||
for key in CORE_USER_SETTING_KEYS:
|
||||
if key in self.settings:
|
||||
minimal[key] = copy.deepcopy(self.settings[key])
|
||||
|
||||
if self._seed_template:
|
||||
for key, value in self._seed_template.items():
|
||||
minimal.setdefault(key, copy.deepcopy(value))
|
||||
|
||||
return minimal
|
||||
|
||||
return copy.deepcopy(self.settings)
|
||||
|
||||
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a copy of the registered libraries."""
|
||||
|
||||
@@ -5,8 +5,10 @@ import json
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any, Dict
|
||||
import uuid
|
||||
from typing import Any, Dict, Iterable, List, Set, Tuple
|
||||
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.example_images_paths import (
|
||||
@@ -516,10 +518,12 @@ class DownloadManager:
|
||||
if civitai_payload.get('images'):
|
||||
images = civitai_payload.get('images', [])
|
||||
|
||||
success, is_stale = await ExampleImagesProcessor.download_model_images(
|
||||
success, is_stale, failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash, model_name, images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
failed_urls: Set[str] = set(failed_images)
|
||||
|
||||
# If metadata is stale, try to refresh it
|
||||
if is_stale and model_hash not in self._progress['refreshed_models']:
|
||||
await MetadataUpdater.refresh_model_metadata(
|
||||
@@ -536,20 +540,36 @@ class DownloadManager:
|
||||
if updated_civitai.get('images'):
|
||||
# Retry download with updated metadata
|
||||
updated_images = updated_civitai.get('images', [])
|
||||
success, _ = await ExampleImagesProcessor.download_model_images(
|
||||
success, _, additional_failed = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash, model_name, updated_images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
failed_urls.update(additional_failed)
|
||||
|
||||
self._progress['refreshed_models'].add(model_hash)
|
||||
|
||||
# Mark as processed if successful, or as failed if unsuccessful after refresh
|
||||
if success:
|
||||
if failed_urls:
|
||||
await self._remove_failed_images_from_metadata(
|
||||
model_hash,
|
||||
model_name,
|
||||
model_dir,
|
||||
failed_urls,
|
||||
scanner,
|
||||
)
|
||||
|
||||
if failed_urls:
|
||||
self._progress['failed_models'].add(model_hash)
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
logger.info(
|
||||
"Removed %s failed example images for %s", len(failed_urls), model_name
|
||||
)
|
||||
elif success:
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
else:
|
||||
# If we refreshed metadata and still failed, mark as permanently failed
|
||||
if model_hash in self._progress['refreshed_models']:
|
||||
self._progress['failed_models'].add(model_hash)
|
||||
logger.info(f"Marking model {model_name} as failed after metadata refresh")
|
||||
self._progress['failed_models'].add(model_hash)
|
||||
logger.info(
|
||||
"Example images download failed for %s despite metadata refresh", model_name
|
||||
)
|
||||
|
||||
return True # Return True to indicate a remote download happened
|
||||
else:
|
||||
@@ -888,6 +908,8 @@ class DownloadManager:
|
||||
model_hash, model_name, images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
failed_urls: Set[str] = set(failed_images)
|
||||
|
||||
# If metadata is stale, try to refresh it
|
||||
if is_stale and model_hash not in self._progress['refreshed_models']:
|
||||
await MetadataUpdater.refresh_model_metadata(
|
||||
@@ -909,19 +931,18 @@ class DownloadManager:
|
||||
)
|
||||
|
||||
# Combine failed images from both attempts
|
||||
failed_images.extend(additional_failed_images)
|
||||
failed_urls.update(additional_failed_images)
|
||||
|
||||
self._progress['refreshed_models'].add(model_hash)
|
||||
|
||||
# For forced downloads, remove failed images from metadata
|
||||
if failed_images:
|
||||
# Create a copy of images excluding failed ones
|
||||
if failed_urls:
|
||||
await self._remove_failed_images_from_metadata(
|
||||
model_hash, model_name, failed_images, scanner
|
||||
model_hash, model_name, model_dir, failed_urls, scanner
|
||||
)
|
||||
|
||||
# Mark as processed
|
||||
if success or failed_images: # Mark as processed if we successfully downloaded some images or removed failed ones
|
||||
if success or failed_urls: # Mark as processed if we successfully downloaded some images or removed failed ones
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
|
||||
return True # Return True to indicate a remote download happened
|
||||
@@ -938,49 +959,112 @@ class DownloadManager:
|
||||
self._progress['last_error'] = error_msg
|
||||
return False # Return False on exception
|
||||
|
||||
async def _remove_failed_images_from_metadata(self, model_hash, model_name, failed_images, scanner):
|
||||
"""Remove failed images from model metadata"""
|
||||
async def _remove_failed_images_from_metadata(
|
||||
self,
|
||||
model_hash: str,
|
||||
model_name: str,
|
||||
model_dir: str,
|
||||
failed_images: Iterable[str],
|
||||
scanner,
|
||||
) -> None:
|
||||
"""Mark failed images in model metadata so they won't be retried."""
|
||||
|
||||
failed_set: Set[str] = {url for url in failed_images if url}
|
||||
if not failed_set:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get current model data
|
||||
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
|
||||
if not model_data:
|
||||
logger.warning(f"Could not find model data for {model_name} to remove failed images")
|
||||
return
|
||||
|
||||
if not model_data.get('civitai', {}).get('images'):
|
||||
|
||||
civitai_payload = model_data.get('civitai') or {}
|
||||
current_images = civitai_payload.get('images') or []
|
||||
if not current_images:
|
||||
logger.warning(f"No images in metadata for {model_name}")
|
||||
return
|
||||
|
||||
# Get current images
|
||||
current_images = model_data['civitai']['images']
|
||||
|
||||
# Filter out failed images
|
||||
updated_images = [img for img in current_images if img.get('url') not in failed_images]
|
||||
|
||||
# If images were removed, update metadata
|
||||
if len(updated_images) < len(current_images):
|
||||
removed_count = len(current_images) - len(updated_images)
|
||||
logger.info(f"Removing {removed_count} failed images from metadata for {model_name}")
|
||||
|
||||
# Update the images list
|
||||
model_data['civitai']['images'] = updated_images
|
||||
|
||||
# Save metadata to file
|
||||
file_path = model_data.get('file_path')
|
||||
if file_path:
|
||||
# Create a copy of model data without 'folder' field
|
||||
model_copy = model_data.copy()
|
||||
model_copy.pop('folder', None)
|
||||
|
||||
# Write metadata to file
|
||||
await MetadataManager.save_metadata(file_path, model_copy)
|
||||
logger.info(f"Saved updated metadata for {model_name} after removing failed images")
|
||||
|
||||
# Update the scanner cache
|
||||
|
||||
updated = False
|
||||
|
||||
for image in current_images:
|
||||
image_url = image.get('url')
|
||||
optimized_url = (
|
||||
ExampleImagesProcessor.get_civitai_optimized_url(image_url)
|
||||
if image_url and 'civitai.com' in image_url
|
||||
else None
|
||||
)
|
||||
|
||||
if image_url not in failed_set and optimized_url not in failed_set:
|
||||
continue
|
||||
|
||||
if image.get('downloadFailed'):
|
||||
continue
|
||||
|
||||
image['downloadFailed'] = True
|
||||
image.setdefault('downloadError', 'not_found')
|
||||
logger.debug(
|
||||
"Marked example image %s for %s as failed due to missing remote asset",
|
||||
image_url,
|
||||
model_name,
|
||||
)
|
||||
updated = True
|
||||
|
||||
if not updated:
|
||||
return
|
||||
|
||||
file_path = model_data.get('file_path')
|
||||
if file_path:
|
||||
model_copy = model_data.copy()
|
||||
model_copy.pop('folder', None)
|
||||
await MetadataManager.save_metadata(file_path, model_copy)
|
||||
|
||||
try:
|
||||
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing failed images from metadata for {model_name}: {e}", exc_info=True)
|
||||
except AttributeError:
|
||||
logger.debug("Scanner does not expose cache update for %s", model_name)
|
||||
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Error removing failed images from metadata for %s: %s", model_name, exc, exc_info=True
|
||||
)
|
||||
|
||||
def _renumber_example_image_files(self, model_dir: str) -> None:
|
||||
if not model_dir or not os.path.isdir(model_dir):
|
||||
return
|
||||
|
||||
pattern = re.compile(r'^image_(\d+)(\.[^.]+)$', re.IGNORECASE)
|
||||
matches: List[Tuple[int, str, str]] = []
|
||||
|
||||
for entry in os.listdir(model_dir):
|
||||
match = pattern.match(entry)
|
||||
if match:
|
||||
matches.append((int(match.group(1)), entry, match.group(2)))
|
||||
|
||||
if not matches:
|
||||
return
|
||||
|
||||
matches.sort(key=lambda item: item[0])
|
||||
staged_paths: List[Tuple[str, str]] = []
|
||||
|
||||
for _, original_name, extension in matches:
|
||||
source_path = os.path.join(model_dir, original_name)
|
||||
temp_name = f"tmp_{uuid.uuid4().hex}_{original_name}"
|
||||
temp_path = os.path.join(model_dir, temp_name)
|
||||
try:
|
||||
os.rename(source_path, temp_path)
|
||||
staged_paths.append((temp_path, extension))
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to stage rename for %s: %s", source_path, exc)
|
||||
|
||||
for new_index, (temp_path, extension) in enumerate(staged_paths):
|
||||
final_name = f"image_{new_index}{extension}"
|
||||
final_path = os.path.join(model_dir, final_name)
|
||||
try:
|
||||
os.rename(temp_path, final_path)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to finalise rename for %s: %s", final_path, exc)
|
||||
|
||||
async def _broadcast_progress(
|
||||
self,
|
||||
|
||||
@@ -199,10 +199,13 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
||||
if item == "_deleted":
|
||||
# Allow cleanup staging folders
|
||||
continue
|
||||
# When multi-library mode is active we expect nested hash folders
|
||||
if uses_library_scoped_folders():
|
||||
if _library_folder_has_only_hash_dirs(item_path):
|
||||
continue
|
||||
# Accept legacy library folders even when current settings do not
|
||||
# explicitly enable multi-library mode. This allows users to reuse a
|
||||
# previously configured example images directory after settings are
|
||||
# reset, as long as the nested structure still looks like dedicated
|
||||
# hash folders.
|
||||
if _library_folder_has_only_hash_dirs(item_path):
|
||||
continue
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -85,6 +85,16 @@ class ExampleImagesProcessor:
|
||||
# Default fallback
|
||||
return '.jpg'
|
||||
|
||||
@staticmethod
|
||||
def _is_not_found_error(error) -> bool:
|
||||
"""Return True when the downloader response represents a 404/Not Found."""
|
||||
|
||||
if not error:
|
||||
return False
|
||||
|
||||
message = str(error).lower()
|
||||
return '404' in message or 'file not found' in message
|
||||
|
||||
@staticmethod
|
||||
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader):
|
||||
"""Download images for a single model
|
||||
@@ -98,7 +108,15 @@ class ExampleImagesProcessor:
|
||||
image_url = image.get('url')
|
||||
if not image_url:
|
||||
continue
|
||||
|
||||
|
||||
if image.get('downloadFailed'):
|
||||
logger.debug(
|
||||
"Skipping example image %s for %s because it previously failed to download",
|
||||
image_url,
|
||||
model_name,
|
||||
)
|
||||
continue
|
||||
|
||||
# Apply optimization for Civitai URLs if enabled
|
||||
original_url = image_url
|
||||
if optimize and 'civitai.com' in image_url:
|
||||
@@ -142,7 +160,7 @@ class ExampleImagesProcessor:
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
elif "404" in str(content):
|
||||
elif ExampleImagesProcessor._is_not_found_error(content):
|
||||
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
||||
logger.warning(error_msg)
|
||||
model_success = False # Mark the model as failed due to 404 error
|
||||
@@ -173,7 +191,15 @@ class ExampleImagesProcessor:
|
||||
image_url = image.get('url')
|
||||
if not image_url:
|
||||
continue
|
||||
|
||||
|
||||
if image.get('downloadFailed'):
|
||||
logger.debug(
|
||||
"Skipping example image %s for %s because it previously failed to download",
|
||||
image_url,
|
||||
model_name,
|
||||
)
|
||||
continue
|
||||
|
||||
# Apply optimization for Civitai URLs if enabled
|
||||
original_url = image_url
|
||||
if optimize and 'civitai.com' in image_url:
|
||||
@@ -217,7 +243,7 @@ class ExampleImagesProcessor:
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
elif "404" in str(content):
|
||||
elif ExampleImagesProcessor._is_not_found_error(content):
|
||||
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
||||
logger.warning(error_msg)
|
||||
model_success = False # Mark the model as failed due to 404 error
|
||||
|
||||
63
py/utils/preview_selection.py
Normal file
63
py/utils/preview_selection.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Utilities for selecting preview media from Civitai image metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from .constants import NSFW_LEVELS
|
||||
|
||||
PreviewMedia = Mapping[str, object]
|
||||
|
||||
|
||||
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||
"""Return a normalized NSFW level value for the supplied media entry."""
|
||||
|
||||
value = entry.get("nsfwLevel", 0)
|
||||
try:
|
||||
return int(value) # type: ignore[return-value]
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def select_preview_media(
|
||||
images: Sequence[Mapping[str, object]] | None,
|
||||
*,
|
||||
blur_mature_content: bool,
|
||||
) -> Tuple[Optional[PreviewMedia], int]:
|
||||
"""Select the most appropriate preview media entry.
|
||||
|
||||
When ``blur_mature_content`` is enabled we first try to return the first media
|
||||
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are
|
||||
available we return the media entry with the lowest NSFW level. When the
|
||||
setting is disabled we simply return the first entry.
|
||||
"""
|
||||
|
||||
if not images:
|
||||
return None, 0
|
||||
|
||||
candidates = [item for item in images if isinstance(item, Mapping)]
|
||||
if not candidates:
|
||||
return None, 0
|
||||
|
||||
selected = candidates[0]
|
||||
selected_level = _extract_nsfw_level(selected)
|
||||
|
||||
if not blur_mature_content:
|
||||
return selected, selected_level
|
||||
|
||||
safe_threshold = NSFW_LEVELS.get("R", 4)
|
||||
for candidate in candidates:
|
||||
level = _extract_nsfw_level(candidate)
|
||||
if level < safe_threshold:
|
||||
return candidate, level
|
||||
|
||||
for candidate in candidates[1:]:
|
||||
level = _extract_nsfw_level(candidate)
|
||||
if level < selected_level:
|
||||
selected = candidate
|
||||
selected_level = level
|
||||
|
||||
return selected, selected_level
|
||||
|
||||
|
||||
__all__ = ["select_preview_media"]
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
@@ -36,8 +37,13 @@ def get_settings_dir(create: bool = True) -> str:
|
||||
The absolute path to the user configuration directory.
|
||||
"""
|
||||
|
||||
config_dir = user_config_dir(APP_NAME, appauthor=False)
|
||||
if create:
|
||||
legacy_path = get_legacy_settings_path()
|
||||
if _should_use_portable_settings(legacy_path, _LOGGER):
|
||||
config_dir = os.path.dirname(legacy_path)
|
||||
else:
|
||||
config_dir = user_config_dir(APP_NAME, appauthor=False)
|
||||
|
||||
if create and config_dir:
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
return config_dir
|
||||
|
||||
@@ -64,6 +70,11 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
"""
|
||||
|
||||
logger = logger or _LOGGER
|
||||
legacy_path = get_legacy_settings_path()
|
||||
|
||||
if _should_use_portable_settings(legacy_path, logger):
|
||||
return legacy_path
|
||||
|
||||
target_path = get_settings_file_path(create_dir=True)
|
||||
preferred_dir = user_config_dir(APP_NAME, appauthor=False)
|
||||
preferred_path = os.path.join(preferred_dir, "settings.json")
|
||||
@@ -71,7 +82,6 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
if os.path.abspath(target_path) != os.path.abspath(preferred_path):
|
||||
os.makedirs(preferred_dir, exist_ok=True)
|
||||
target_path = preferred_path
|
||||
legacy_path = get_legacy_settings_path()
|
||||
|
||||
if os.path.exists(legacy_path) and not os.path.exists(target_path):
|
||||
try:
|
||||
@@ -88,3 +98,63 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
|
||||
return target_path
|
||||
|
||||
|
||||
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
|
||||
"""Return ``True`` when the repository settings file enables portable mode."""
|
||||
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Failed to parse %s for portable mode flag: %s", path, exc)
|
||||
return False
|
||||
except OSError as exc:
|
||||
logger.warning("Could not read %s to determine portable mode: %s", path, exc)
|
||||
return False
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.debug("Portable settings file %s does not contain a JSON object", path)
|
||||
return False
|
||||
|
||||
flag = payload.get("use_portable_settings")
|
||||
if isinstance(flag, bool):
|
||||
return flag
|
||||
|
||||
if flag is not None:
|
||||
logger.warning(
|
||||
"Ignoring non-boolean use_portable_settings value in %s", path
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def load_settings_template() -> Optional[Dict[str, Any]]:
|
||||
"""Return the parsed contents of ``settings.json.example`` when available."""
|
||||
|
||||
template_path = os.path.join(get_project_root(), "settings.json.example")
|
||||
|
||||
try:
|
||||
with open(template_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
_LOGGER.debug("settings.json.example not found at %s", template_path)
|
||||
return None
|
||||
except json.JSONDecodeError as exc:
|
||||
_LOGGER.warning("Failed to parse settings.json.example: %s", exc)
|
||||
return None
|
||||
except OSError as exc:
|
||||
_LOGGER.warning(
|
||||
"Could not read settings.json.example at %s: %s", template_path, exc
|
||||
)
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
_LOGGER.debug(
|
||||
"settings.json.example at %s does not contain a JSON object", template_path
|
||||
)
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from difflib import SequenceMatcher
|
||||
import os
|
||||
import re
|
||||
from typing import Dict
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
@@ -85,6 +86,41 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||
# All words found either as substrings or fuzzy matches
|
||||
return True
|
||||
|
||||
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
"""Sanitize a folder name by removing or replacing invalid characters.
|
||||
|
||||
Args:
|
||||
name: The original folder name.
|
||||
replacement: The character to use when replacing invalid characters.
|
||||
|
||||
Returns:
|
||||
A sanitized folder name safe to use across common filesystems.
|
||||
"""
|
||||
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
# Replace invalid characters commonly restricted on Windows and POSIX
|
||||
invalid_chars_pattern = r'[<>:"/\\|?*\x00-\x1f]'
|
||||
sanitized = re.sub(invalid_chars_pattern, replacement, name)
|
||||
|
||||
# Trim whitespace introduced during sanitization
|
||||
sanitized = sanitized.strip()
|
||||
|
||||
# Collapse repeated replacement characters to a single instance
|
||||
if replacement:
|
||||
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
|
||||
sanitized = sanitized.strip(replacement)
|
||||
|
||||
# Remove trailing spaces or periods which are invalid on Windows
|
||||
sanitized = sanitized.rstrip(" .")
|
||||
|
||||
if not sanitized:
|
||||
return "unnamed"
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def calculate_recipe_fingerprint(loras):
|
||||
"""
|
||||
Calculate a unique fingerprint for a recipe based on its LoRAs.
|
||||
@@ -175,10 +211,18 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
|
||||
first_tag = 'no tags' # Default if no tags available
|
||||
|
||||
# Format the template with available data
|
||||
model_name = sanitize_folder_name(model_data.get('model_name', ''))
|
||||
version_name = ''
|
||||
|
||||
if isinstance(civitai_data, dict):
|
||||
version_name = sanitize_folder_name(civitai_data.get('name') or '')
|
||||
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
|
||||
formatted_path = formatted_path.replace('{first_tag}', first_tag)
|
||||
formatted_path = formatted_path.replace('{author}', author)
|
||||
formatted_path = formatted_path.replace('{model_name}', model_name)
|
||||
formatted_path = formatted_path.replace('{version_name}', version_name)
|
||||
|
||||
if model_type == 'embedding':
|
||||
formatted_path = formatted_path.replace(' ', '_')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "0.9.8"
|
||||
version = "0.9.9"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -7,5 +7,6 @@ python_functions = test_*
|
||||
# Register async marker for coroutine-style tests
|
||||
markers =
|
||||
asyncio: execute test within asyncio event loop
|
||||
no_settings_dir_isolation: allow tests to use real settings paths
|
||||
# Skip problematic directories to avoid import conflicts
|
||||
norecursedirs = .git .tox dist build *.egg __pycache__ py
|
||||
@@ -1,9 +1,17 @@
|
||||
const settingsStore = new Map();
|
||||
|
||||
export const app = {
|
||||
canvas: { ds: { scale: 1 } },
|
||||
extensionManager: {
|
||||
toast: {
|
||||
add: () => {},
|
||||
},
|
||||
setting: {
|
||||
get: (id) => (settingsStore.has(id) ? settingsStore.get(id) : undefined),
|
||||
set: async (id, value) => {
|
||||
settingsStore.set(id, value);
|
||||
},
|
||||
},
|
||||
},
|
||||
registerExtension: () => {},
|
||||
graphToPrompt: async () => ({ workflow: { nodes: new Map() } }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"use_portable_settings": false,
|
||||
"civitai_api_key": "your_civitai_api_key_here",
|
||||
"folder_paths": {
|
||||
"loras": [
|
||||
@@ -14,4 +15,4 @@
|
||||
"C:/path/to/another/embeddings_folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import sys
|
||||
import json
|
||||
from py.middleware.cache_middleware import cache_control
|
||||
from py.utils.settings_paths import ensure_settings_file, get_settings_dir
|
||||
from py.utils.settings_paths import ensure_settings_file
|
||||
|
||||
# Set environment variable to indicate standalone mode
|
||||
os.environ["LORA_MANAGER_STANDALONE"] = "1"
|
||||
@@ -102,8 +102,11 @@ import asyncio
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
# Increase allowable header size to align with in-ComfyUI configuration.
|
||||
HEADER_SIZE_LIMIT = 16384
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger("lora-manager-standalone")
|
||||
|
||||
@@ -133,7 +136,14 @@ class StandaloneServer:
|
||||
"""Server implementation for standalone mode"""
|
||||
|
||||
def __init__(self):
|
||||
self.app = web.Application(logger=logger, middlewares=[cache_control])
|
||||
self.app = web.Application(
|
||||
logger=logger,
|
||||
middlewares=[cache_control],
|
||||
handler_args={
|
||||
"max_field_size": HEADER_SIZE_LIMIT,
|
||||
"max_line_size": HEADER_SIZE_LIMIT,
|
||||
},
|
||||
)
|
||||
self.instance = self # Make it compatible with PromptServer.instance pattern
|
||||
|
||||
# Ensure the app's access logger is configured to reduce verbosity
|
||||
@@ -218,54 +228,43 @@ class StandaloneServer:
|
||||
from py.lora_manager import LoraManager
|
||||
|
||||
def validate_settings():
|
||||
"""Validate that settings.json exists and has required configuration"""
|
||||
settings_path = ensure_settings_file(logger)
|
||||
if not os.path.exists(settings_path):
|
||||
logger.error("=" * 80)
|
||||
logger.error("CONFIGURATION ERROR: settings.json file not found!")
|
||||
logger.error("")
|
||||
logger.error("Expected location: %s", settings_path)
|
||||
logger.error("")
|
||||
logger.error("To run in standalone mode, you need to create a settings.json file.")
|
||||
logger.error("Please follow these steps:")
|
||||
logger.error("")
|
||||
logger.error("1. Copy the provided settings.json.example file to create a new file")
|
||||
logger.error(" named settings.json inside the LoRA Manager settings folder:")
|
||||
logger.error(" %s", get_settings_dir())
|
||||
logger.error("")
|
||||
logger.error("2. Edit settings.json to include your correct model folder paths")
|
||||
logger.error(" and CivitAI API key")
|
||||
logger.error("=" * 80)
|
||||
return False
|
||||
|
||||
# Check if settings.json has valid folder paths
|
||||
"""Initialize settings and log any startup warnings."""
|
||||
try:
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
|
||||
folder_paths = settings.get('folder_paths', {})
|
||||
has_valid_paths = False
|
||||
|
||||
for path_type in ['loras', 'checkpoints', 'embeddings']:
|
||||
paths = folder_paths.get(path_type, [])
|
||||
if paths and any(os.path.exists(p) for p in paths):
|
||||
has_valid_paths = True
|
||||
break
|
||||
|
||||
if not has_valid_paths:
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("CONFIGURATION WARNING: No valid model folder paths found!")
|
||||
logger.warning("")
|
||||
logger.warning("Your settings.json exists but doesn't contain valid folder paths.")
|
||||
logger.warning("Please check and update the folder_paths section in settings.json")
|
||||
logger.warning("to include existing directories for your models.")
|
||||
logger.warning("=" * 80)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading settings.json: {e}")
|
||||
from py.services.settings_manager import get_settings_manager
|
||||
|
||||
manager = get_settings_manager()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to initialise settings manager: %s", exc, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
messages = manager.get_startup_messages()
|
||||
if messages:
|
||||
logger.warning("=" * 80)
|
||||
logger.warning("Standalone mode is using fallback configuration values.")
|
||||
for message in messages:
|
||||
severity = (message.get("severity") or "info").lower()
|
||||
title = message.get("title")
|
||||
body = message.get("message") or ""
|
||||
details = message.get("details")
|
||||
location = message.get("settings_file") or manager.settings_file
|
||||
|
||||
text = f"{title}: {body}" if title else body
|
||||
log_method = logger.info
|
||||
if severity == "error":
|
||||
log_method = logger.error
|
||||
elif severity == "warning":
|
||||
log_method = logger.warning
|
||||
|
||||
log_method(text)
|
||||
if details:
|
||||
log_method("Details: %s", details)
|
||||
if location:
|
||||
log_method("Settings file: %s", location)
|
||||
|
||||
logger.warning("=" * 80)
|
||||
else:
|
||||
logger.info("Loaded settings from %s", manager.settings_file)
|
||||
|
||||
return True
|
||||
|
||||
class StandaloneLoraManager(LoraManager):
|
||||
|
||||
@@ -53,6 +53,9 @@ html, body {
|
||||
--lora-error: oklch(75% 0.32 29);
|
||||
--lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||
--lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h));
|
||||
--badge-update-bg: oklch(72% 0.2 220);
|
||||
--badge-update-text: oklch(28% 0.03 220);
|
||||
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-1: calc(8px * 1);
|
||||
@@ -100,6 +103,9 @@ html[data-theme="light"] {
|
||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||
--lora-text: oklch(98% 0.02 256);
|
||||
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
|
||||
--badge-update-bg: oklch(62% 0.18 220);
|
||||
--badge-update-text: oklch(98% 0.02 240);
|
||||
--badge-update-glow: oklch(62% 0.18 220 / 0.4);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -296,6 +296,18 @@
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.card-header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-header-info .base-model-label {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.card-actions i {
|
||||
margin-left: var(--space-1);
|
||||
cursor: pointer;
|
||||
@@ -422,6 +434,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
backdrop-filter: blur(2px);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Style for version name */
|
||||
@@ -575,4 +588,26 @@
|
||||
15% { opacity: 1; transform: translateY(0); }
|
||||
85% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(0); }
|
||||
}
|
||||
}
|
||||
|
||||
.model-card.has-update {
|
||||
border-color: color-mix(in oklab, var(--badge-update-bg) 60%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--badge-update-bg) 45%, transparent);
|
||||
}
|
||||
|
||||
.model-update-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--badge-update-bg);
|
||||
color: var(--badge-update-text);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 4px 12px var(--badge-update-glow);
|
||||
border: 1px solid color-mix(in oklab, var(--badge-update-bg) 55%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -323,6 +323,10 @@
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -346,6 +350,51 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-btn .tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tab-btn .tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--badge-update-bg);
|
||||
color: var(--badge-update-text);
|
||||
font-size: 0.68em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 3px 10px var(--badge-update-glow);
|
||||
border: 1px solid color-mix(in oklab, var(--badge-update-bg) 55%, transparent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-badge--update {
|
||||
animation: tab-badge-pulse 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tab-btn--has-update:not(.active) {
|
||||
color: color-mix(in oklch, var(--text-color) 70%, var(--badge-update-bg) 30%);
|
||||
}
|
||||
|
||||
.tab-btn--has-update.active {
|
||||
border-bottom-color: var(--badge-update-bg);
|
||||
}
|
||||
|
||||
@keyframes tab-badge-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 3px 10px color-mix(in oklch, var(--badge-update-glow) 100%, transparent);
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 5px 14px color-mix(in oklch, var(--badge-update-glow) 90%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
position: relative;
|
||||
min-height: 100px;
|
||||
@@ -359,24 +408,306 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.view-all-btn {
|
||||
.recipes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 13px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0 var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.view-all-btn:hover {
|
||||
opacity: 0.9;
|
||||
.recipes-header__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.recipes-header__eyebrow {
|
||||
font-size: 0.75em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.recipes-header__text h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recipes-header__description {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
color: var(--text-color);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.recipes-header__view-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 8px 14px;
|
||||
border: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.35);
|
||||
background: transparent;
|
||||
color: var(--lora-accent);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.recipes-header__view-all i {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.recipes-header__view-all:hover,
|
||||
.recipes-header__view-all:focus-visible {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.recipes-header__view-all:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.recipes-card-grid {
|
||||
max-width: none;
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: 0;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-3);
|
||||
row-gap: var(--space-3);
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 320px;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.6);
|
||||
box-shadow: 0 16px 32px rgba(17, 17, 26, 0.18);
|
||||
}
|
||||
|
||||
.recipe-card:focus-visible {
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.recipe-card__media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.recipe-card__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__media img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.recipe-card__media::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 36%;
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(12, 13, 24, 0.55) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recipe-card__media-top {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-card__copy {
|
||||
background: rgba(15, 21, 40, 0.6);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-card__copy i {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.recipe-card__copy:hover,
|
||||
.recipe-card__copy:focus-visible {
|
||||
background: rgba(15, 21, 40, 0.8);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recipe-card__copy:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__copy {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: rgba(17, 23, 41, 0.8);
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__copy:hover,
|
||||
[data-theme="light"] .recipe-card__copy:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.recipe-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recipe-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.05em;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.recipe-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78em;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.recipe-card__badge i {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.recipe-card__badge--base {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge {
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--base {
|
||||
background: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.recipe-card__badge--ready {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.recipe-card__badge--missing {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.recipe-card__badge--empty {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--ready {
|
||||
color: #157347;
|
||||
background: rgba(76, 167, 120, 0.16);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--missing {
|
||||
color: #9f580a;
|
||||
background: rgba(245, 199, 43, 0.22);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--empty {
|
||||
color: rgba(71, 85, 105, 0.9);
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.recipe-card__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--lora-accent);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recipe-card__cta i {
|
||||
font-size: 0.85em;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-card:hover .recipe-card__cta i {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.recipes-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.recipes-header__view-all {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.recipes-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loading, error and empty states */
|
||||
.recipes-loading,
|
||||
.recipes-error,
|
||||
@@ -491,4 +822,4 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
337
static/css/components/lora-modal/versions.css
Normal file
337
static/css/components/lora-modal/versions.css
Normal file
@@ -0,0 +1,337 @@
|
||||
.model-versions-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.versions-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: color-mix(in oklch, var(--lora-surface) 70%, transparent);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.versions-toolbar-info h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.versions-toolbar-info p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.versions-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.versions-toolbar-btn {
|
||||
appearance: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.versions-toolbar-btn-primary {
|
||||
background: var(--lora-accent);
|
||||
color: #fff;
|
||||
border-color: color-mix(in oklch, var(--lora-accent) 70%, transparent);
|
||||
}
|
||||
|
||||
.versions-toolbar-btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in oklch, var(--lora-accent) 85%, transparent);
|
||||
}
|
||||
|
||||
.versions-toolbar-btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.versions-toolbar-btn-secondary:hover:not(:disabled) {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.versions-toolbar-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.versions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.version-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.model-version-row {
|
||||
display: grid;
|
||||
grid-template-columns: 124px 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .model-version-row {
|
||||
background: color-mix(in oklch, var(--card-bg) 88%, black 12%);
|
||||
}
|
||||
|
||||
.model-version-row:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.model-version-row.is-clickable {
|
||||
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),
|
||||
0 10px 22px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.version-media {
|
||||
width: 124px;
|
||||
height: 88px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid color-mix(in oklch, var(--border-color) 70%, transparent);
|
||||
}
|
||||
|
||||
.version-media img,
|
||||
.version-media video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version-media img {
|
||||
/* Bias cropping toward the upper region to keep faces visible */
|
||||
object-position: center 20%;
|
||||
}
|
||||
|
||||
.version-media video {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.version-media-placeholder {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
border-style: dashed;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.version-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.versions-tab-version-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.version-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.version-badge-info {
|
||||
background: color-mix(in oklch, var(--badge-update-bg) 25%, transparent);
|
||||
color: var(--badge-update-bg);
|
||||
border-color: color-mix(in oklch, var(--badge-update-bg) 55%, transparent);
|
||||
}
|
||||
|
||||
.version-badge-success {
|
||||
background: color-mix(in oklch, var(--lora-success) 25%, transparent);
|
||||
color: var(--lora-success);
|
||||
border-color: color-mix(in oklch, var(--lora-success) 50%, transparent);
|
||||
}
|
||||
|
||||
.version-badge-muted {
|
||||
background: color-mix(in oklch, var(--text-muted) 18%, transparent);
|
||||
color: var(--text-muted);
|
||||
border-color: color-mix(in oklch, var(--text-muted) 40%, transparent);
|
||||
}
|
||||
|
||||
.version-badge-current {
|
||||
background: color-mix(in oklch, var(--lora-accent) 22%, transparent);
|
||||
color: var(--lora-accent);
|
||||
border-color: color-mix(in oklch, var(--lora-accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.version-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.version-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.version-meta-primary {
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklch, var(--text-color) 88%, var(--lora-accent) 12%);
|
||||
}
|
||||
|
||||
.version-meta-separator {
|
||||
color: color-mix(in oklch, var(--text-muted) 90%, var(--text-color) 10%);
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.version-action {
|
||||
min-width: 128px;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.version-action-primary {
|
||||
background: var(--lora-accent);
|
||||
color: #fff;
|
||||
border-color: color-mix(in oklch, var(--lora-accent) 65%, transparent);
|
||||
}
|
||||
|
||||
.version-action-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in oklch, var(--lora-accent) 85%, transparent);
|
||||
}
|
||||
|
||||
.version-action-danger {
|
||||
background: transparent;
|
||||
border-color: color-mix(in oklch, var(--lora-error) 60%, transparent);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.version-action-danger:hover {
|
||||
background: color-mix(in oklch, var(--lora-error) 12%, transparent);
|
||||
}
|
||||
|
||||
.version-action-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.version-action-ghost:hover {
|
||||
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
||||
}
|
||||
|
||||
.version-action:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.versions-loading-state,
|
||||
.versions-empty,
|
||||
.versions-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: var(--space-3);
|
||||
border: 1px dashed var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.versions-error {
|
||||
border-style: solid;
|
||||
border-color: color-mix(in oklch, var(--lora-error) 45%, transparent);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.versions-empty i,
|
||||
.versions-error i {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.model-version-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.version-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.version-action {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -245,12 +245,20 @@
|
||||
|
||||
.priority-tags-header {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.priority-tags-actions {
|
||||
.priority-tags-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-1);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.priority-tags-info label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.priority-tags-example {
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.folder-sidebar.hidden-by-setting {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Visible state */
|
||||
.folder-sidebar.visible {
|
||||
transform: translateX(0);
|
||||
@@ -59,6 +63,10 @@
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.sidebar-hover-area.hidden-by-setting {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sidebar-hover-area.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,73 @@
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.notification-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.notification-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-tab:hover,
|
||||
.notification-tab.active {
|
||||
background: var(--lora-accent-light, rgba(0, 148, 255, 0.12));
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent-text, var(--text-color));
|
||||
}
|
||||
|
||||
.notification-tab-badge {
|
||||
display: none;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--lora-accent);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notification-tab-badge.is-dot {
|
||||
min-width: 0.5rem;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.notification-tab-badge.visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.notification-panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.notification-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.update-icon {
|
||||
font-size: 1.8em;
|
||||
color: var(--lora-accent);
|
||||
@@ -165,6 +232,137 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.banner-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.banner-history h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.banner-history-empty {
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border: 1px dashed var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
text-align: center;
|
||||
color: var(--text-muted, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.banner-history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.banner-history-item {
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
background: var(--card-bg, #fff);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .banner-history-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.banner-history-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.banner-history-description {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.banner-history-meta {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, rgba(0, 0, 0, 0.6));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-history-time {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.banner-history-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.banner-history-status.active {
|
||||
color: var(--lora-success);
|
||||
}
|
||||
|
||||
.banner-history-status.dismissed {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.banner-history-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.banner-history-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--lora-border);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.banner-history-action i {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.banner-history-action.banner-history-action-primary {
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.banner-history-action.banner-history-action-secondary {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.banner-history-action.banner-history-action-tertiary {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.banner-history-action:hover {
|
||||
background: var(--lora-accent-light, rgba(0, 148, 255, 0.12));
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent-text, var(--text-color));
|
||||
}
|
||||
|
||||
/* Override toggle switch styles for update preferences */
|
||||
.update-preferences .toggle-switch {
|
||||
position: relative;
|
||||
|
||||
@@ -104,8 +104,22 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.control-group button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.control-group button.loading,
|
||||
.dropdown-toggle.loading {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.control-group button.favorite-filter {
|
||||
.control-group button.favorite-filter,
|
||||
.control-group button.update-filter {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -120,6 +134,30 @@
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.control-group button.update-filter i {
|
||||
margin-right: 4px;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.control-group button.update-filter.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.control-group button.update-filter.active i {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-filter-group .dropdown-main.update-filter.active + .dropdown-toggle {
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-filter-group .dropdown-main.update-filter.active + .dropdown-toggle i {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Active state for buttons that can be toggled */
|
||||
.control-group button.active {
|
||||
background: var(--lora-accent);
|
||||
@@ -307,6 +345,9 @@
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
@@ -315,7 +356,8 @@
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
min-width: 230px;
|
||||
min-width: max(100%, max-content);
|
||||
width: max-content;
|
||||
padding: 5px 0;
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.85em;
|
||||
@@ -339,6 +381,12 @@
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: oklch(var(--lora-accent) / 0.1);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
@import 'components/lora-modal/preset-tags.css';
|
||||
@import 'components/lora-modal/showcase.css';
|
||||
@import 'components/lora-modal/triggerwords.css';
|
||||
@import 'components/lora-modal/versions.css';
|
||||
@import 'components/shared/edit-metadata.css';
|
||||
@import 'components/search-filter.css';
|
||||
@import 'components/bulk.css';
|
||||
@@ -55,4 +56,4 @@
|
||||
/* 使用已有的loading-spinner样式 */
|
||||
.initialization-notice .loading-spinner {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,11 @@ export function getApiEndpoints(modelType) {
|
||||
fetchAllCivitai: `/api/lm/${modelType}/fetch-all-civitai`,
|
||||
relinkCivitai: `/api/lm/${modelType}/relink-civitai`,
|
||||
civitaiVersions: `/api/lm/${modelType}/civitai/versions`,
|
||||
refreshUpdates: `/api/lm/${modelType}/updates/refresh`,
|
||||
modelUpdateStatus: `/api/lm/${modelType}/updates/status`,
|
||||
modelUpdateVersions: `/api/lm/${modelType}/updates/versions`,
|
||||
ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`,
|
||||
ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`,
|
||||
|
||||
// Preview management
|
||||
replacePreview: `/api/lm/${modelType}/replace-preview`,
|
||||
|
||||
@@ -569,6 +569,35 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshUpdatesForModels(modelIds, { force = false } = {}) {
|
||||
if (!Array.isArray(modelIds) || modelIds.length === 0) {
|
||||
throw new Error('No model IDs provided');
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_ids: modelIds,
|
||||
force
|
||||
})
|
||||
});
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Unable to parse refresh updates response as JSON', error);
|
||||
}
|
||||
|
||||
if (!response.ok || payload?.success !== true) {
|
||||
const message = payload?.error || response.statusText || 'Failed to refresh updates';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async fetchCivitaiVersions(modelId, source = null) {
|
||||
try {
|
||||
let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`;
|
||||
@@ -592,6 +621,73 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchModelUpdateVersions(modelId, { refresh = false, force = false } = {}) {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (refresh) params.append('refresh', 'true');
|
||||
if (force) params.append('force', 'true');
|
||||
const query = params.toString();
|
||||
const requestUrl = `${this.apiConfig.endpoints.modelUpdateVersions}/${modelId}${query ? `?${query}` : ''}`;
|
||||
|
||||
const response = await fetch(requestUrl);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to fetch model versions');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching model update versions:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setModelUpdateIgnore(modelId, shouldIgnore) {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.ignoreModelUpdate, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
modelId,
|
||||
shouldIgnore,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to update model ignore status');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error updating model ignore status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setVersionUpdateIgnore(modelId, versionId, shouldIgnore) {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.ignoreVersionUpdate, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
modelId,
|
||||
versionId,
|
||||
shouldIgnore,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to update version ignore status');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error updating version ignore status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchModelRoots() {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.roots);
|
||||
@@ -682,7 +778,11 @@ export class BaseModelApiClient {
|
||||
if (pageState.showFavoritesOnly) {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
|
||||
if (pageState.showUpdateAvailableOnly) {
|
||||
params.append('update_available_only', 'true');
|
||||
}
|
||||
|
||||
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||
params.append('first_letter', pageState.activeLetterFilter);
|
||||
}
|
||||
@@ -1162,4 +1262,4 @@ export class BaseModelApiClient {
|
||||
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
const sendToWorkflowReplaceItem = this.menu.querySelector('[data-action="send-to-workflow-replace"]');
|
||||
const copyAllItem = this.menu.querySelector('[data-action="copy-all"]');
|
||||
const refreshAllItem = this.menu.querySelector('[data-action="refresh-all"]');
|
||||
const checkUpdatesItem = this.menu.querySelector('[data-action="check-updates"]');
|
||||
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
|
||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||
@@ -49,6 +50,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
if (refreshAllItem) {
|
||||
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
|
||||
}
|
||||
if (checkUpdatesItem) {
|
||||
checkUpdatesItem.style.display = config.checkUpdates ? 'flex' : 'none';
|
||||
}
|
||||
if (moveAllItem) {
|
||||
moveAllItem.style.display = config.moveAll ? 'flex' : 'none';
|
||||
}
|
||||
@@ -105,6 +109,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'refresh-all':
|
||||
bulkManager.refreshAllMetadata();
|
||||
break;
|
||||
case 'check-updates':
|
||||
bulkManager.checkUpdatesForSelectedModels();
|
||||
break;
|
||||
case 'move-all':
|
||||
window.moveManager.showMoveModal('bulk');
|
||||
break;
|
||||
|
||||
@@ -11,9 +11,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
this.modelType = 'checkpoint';
|
||||
this.resetAndReload = resetAndReload;
|
||||
|
||||
// Initialize NSFW Level Selector events
|
||||
if (this.nsfwSelector) {
|
||||
// Initialize NSFW Level Selector events only if not already initialized
|
||||
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
||||
this.initNSFWSelector();
|
||||
this.nsfwSelector.dataset.initialized = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
this.modelType = 'embedding';
|
||||
this.resetAndReload = resetAndReload;
|
||||
|
||||
// Initialize NSFW Level Selector events
|
||||
if (this.nsfwSelector) {
|
||||
// Initialize NSFW Level Selector events only if not already initialized
|
||||
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
||||
this.initNSFWSelector();
|
||||
this.nsfwSelector.dataset.initialized = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
|
||||
export class GlobalContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
super('globalContextMenu');
|
||||
this._cleanupInProgress = false;
|
||||
this._updateCheckInProgress = false;
|
||||
}
|
||||
|
||||
showMenu(x, y, origin = null) {
|
||||
@@ -25,6 +27,11 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to trigger example images download:', error);
|
||||
});
|
||||
break;
|
||||
case 'check-model-updates':
|
||||
this.checkModelUpdates(menuItem).catch((error) => {
|
||||
console.error('Failed to check model updates:', error);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
@@ -101,4 +108,29 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
menuItem?.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async checkModelUpdates(menuItem) {
|
||||
if (this._updateCheckInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateCheckInProgress = true;
|
||||
menuItem?.classList.add('disabled');
|
||||
|
||||
try {
|
||||
await performModelUpdateCheck({
|
||||
onComplete: () => {
|
||||
menuItem?.classList.remove('disabled');
|
||||
this._updateCheckInProgress = false;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to check model updates:', error);
|
||||
} finally {
|
||||
if (this._updateCheckInProgress) {
|
||||
this._updateCheckInProgress = false;
|
||||
menuItem?.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
this.modelType = 'lora';
|
||||
this.resetAndReload = resetAndReload;
|
||||
|
||||
// Initialize NSFW Level Selector events
|
||||
if (this.nsfwSelector) {
|
||||
// Initialize NSFW Level Selector events only if not already initialized
|
||||
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
||||
this.initNSFWSelector();
|
||||
this.nsfwSelector.dataset.initialized = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ import { bulkManager } from '../../managers/BulkManager.js';
|
||||
export const ModelContextMenuMixin = {
|
||||
// NSFW Selector methods
|
||||
initNSFWSelector() {
|
||||
// Close button
|
||||
// Remove any existing event listeners by cloning and replacing elements
|
||||
// This is a simple way to ensure we don't have duplicate event listeners
|
||||
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
const newCloseBtn = closeBtn.cloneNode(true);
|
||||
closeBtn.parentNode.replaceChild(newCloseBtn, closeBtn);
|
||||
newCloseBtn.addEventListener('click', () => {
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
this.resetNSFWSelectorState();
|
||||
});
|
||||
@@ -18,8 +21,12 @@ export const ModelContextMenuMixin = {
|
||||
// Level buttons
|
||||
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
|
||||
levelButtons.forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const level = parseInt(btn.dataset.level);
|
||||
// Remove any existing event listeners by cloning and replacing the button
|
||||
const newBtn = btn.cloneNode(true);
|
||||
btn.parentNode.replaceChild(newBtn, btn);
|
||||
|
||||
newBtn.addEventListener('click', async () => {
|
||||
const level = parseInt(newBtn.dataset.level);
|
||||
const mode = this.nsfwSelector.dataset.mode || 'single';
|
||||
|
||||
if (mode === 'bulk') {
|
||||
@@ -56,15 +63,24 @@ export const ModelContextMenuMixin = {
|
||||
});
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
// Close when clicking outside - use a named function so we can remove it later
|
||||
const outsideClickListener = (e) => {
|
||||
if (this.nsfwSelector.style.display === 'block' &&
|
||||
!this.nsfwSelector.contains(e.target) &&
|
||||
!e.target.closest('.context-menu-item[data-action="set-nsfw"], .context-menu-item[data-action="set-content-rating"]')) {
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
this.resetNSFWSelectorState();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Remove previous listener if it exists
|
||||
if (this._outsideClickListener) {
|
||||
document.removeEventListener('click', this._outsideClickListener);
|
||||
}
|
||||
|
||||
// Store and add new listener
|
||||
this._outsideClickListener = outsideClickListener;
|
||||
document.addEventListener('click', this._outsideClickListener);
|
||||
},
|
||||
|
||||
resetNSFWSelectorState() {
|
||||
|
||||
@@ -11,9 +11,10 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||
this.modelType = 'recipe';
|
||||
|
||||
// Initialize NSFW Level Selector events
|
||||
if (this.nsfwSelector) {
|
||||
// Initialize NSFW Level Selector events only if not already initialized
|
||||
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
|
||||
this.initNSFWSelector();
|
||||
this.nsfwSelector.dataset.initialized = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ export class SidebarManager {
|
||||
this.dragHandlersInitialized = false;
|
||||
this.folderTreeElement = null;
|
||||
this.currentDropTarget = null;
|
||||
this.lastPageControls = null;
|
||||
this.isDisabledBySetting = false;
|
||||
this.initializationPromise = null;
|
||||
|
||||
// Bind methods
|
||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||
@@ -55,7 +58,17 @@ export class SidebarManager {
|
||||
this.handleFolderDrop = this.handleFolderDrop.bind(this);
|
||||
}
|
||||
|
||||
async initialize(pageControls) {
|
||||
setHostPageControls(pageControls) {
|
||||
this.lastPageControls = pageControls;
|
||||
}
|
||||
|
||||
async initialize(pageControls, options = {}) {
|
||||
const { forceInitialize = false } = options;
|
||||
|
||||
if (this.isDisabledBySetting && !forceInitialize) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up previous initialization if exists
|
||||
if (this.isInitialized) {
|
||||
this.cleanup();
|
||||
@@ -63,6 +76,7 @@ export class SidebarManager {
|
||||
|
||||
this.pageControls = pageControls;
|
||||
this.pageType = pageControls.pageType;
|
||||
this.lastPageControls = pageControls;
|
||||
this.apiClient = getModelApiClient();
|
||||
|
||||
// Set initial sidebar state immediately (hidden by default)
|
||||
@@ -73,6 +87,10 @@ export class SidebarManager {
|
||||
this.updateSidebarTitle();
|
||||
this.restoreSidebarState();
|
||||
await this.loadFolderTree();
|
||||
if (this.isDisabledBySetting && !forceInitialize) {
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
this.restoreSelectedFolder();
|
||||
|
||||
// Apply final state with animation after everything is loaded
|
||||
@@ -132,8 +150,9 @@ export class SidebarManager {
|
||||
|
||||
// Remove resize event listener
|
||||
window.removeEventListener('resize', this.updateContainerMargin);
|
||||
|
||||
|
||||
console.log('SidebarManager cleaned up');
|
||||
this.initializationPromise = null;
|
||||
}
|
||||
|
||||
removeEventHandlers() {
|
||||
@@ -471,9 +490,11 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
setInitialSidebarState() {
|
||||
if (this.isDisabledBySetting) return;
|
||||
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
|
||||
|
||||
if (!sidebar || !hoverArea) return;
|
||||
|
||||
// Get stored pin state
|
||||
@@ -492,6 +513,8 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
applyFinalSidebarState() {
|
||||
if (this.isDisabledBySetting) return;
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
this.updateAutoHideState();
|
||||
@@ -668,6 +691,8 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
updateAutoHideState() {
|
||||
if (this.isDisabledBySetting) return;
|
||||
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
|
||||
@@ -708,8 +733,8 @@ export class SidebarManager {
|
||||
updateContainerMargin() {
|
||||
const container = document.querySelector('.container');
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
|
||||
if (!container || !sidebar) return;
|
||||
|
||||
if (!container || !sidebar || this.isDisabledBySetting) return;
|
||||
|
||||
// Reset margin to default
|
||||
container.style.marginLeft = '';
|
||||
@@ -729,6 +754,70 @@ export class SidebarManager {
|
||||
}
|
||||
}
|
||||
|
||||
updateDomVisibility(enabled) {
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('hidden-by-setting', !enabled);
|
||||
sidebar.setAttribute('aria-hidden', (!enabled).toString());
|
||||
}
|
||||
|
||||
if (hoverArea) {
|
||||
hoverArea.classList.toggle('hidden-by-setting', !enabled);
|
||||
if (!enabled) {
|
||||
hoverArea.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setSidebarEnabled(enabled) {
|
||||
this.isDisabledBySetting = !enabled;
|
||||
this.updateDomVisibility(enabled);
|
||||
|
||||
const shouldForceInitialization = !enabled && !this.isInitialized;
|
||||
const needsInitialization = !this.isInitialized || shouldForceInitialization;
|
||||
|
||||
if (this.lastPageControls && needsInitialization) {
|
||||
if (!this.initializationPromise) {
|
||||
this.initializationPromise = this.initialize(this.lastPageControls, {
|
||||
forceInitialize: shouldForceInitialization,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Sidebar initialization failed:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.initializationPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
await this.initializationPromise;
|
||||
} else if (this.initializationPromise) {
|
||||
await this.initializationPromise;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
this.isHovering = false;
|
||||
this.isVisible = false;
|
||||
|
||||
const container = document.querySelector('.container');
|
||||
if (container) {
|
||||
container.style.marginLeft = '';
|
||||
}
|
||||
|
||||
if (this.isInitialized) {
|
||||
this.updateBreadcrumbs();
|
||||
this.updateSidebarHeader();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isInitialized) {
|
||||
this.updateAutoHideState();
|
||||
}
|
||||
}
|
||||
|
||||
updatePinButton() {
|
||||
const pinBtn = document.getElementById('sidebarPinToggle');
|
||||
if (pinBtn) {
|
||||
@@ -1327,6 +1416,10 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.isDisabledBySetting || !this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadFolderTree();
|
||||
this.restoreSelectedFolder();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||
import { getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { sidebarManager } from '../SidebarManager.js';
|
||||
|
||||
/**
|
||||
@@ -26,10 +27,15 @@ export class PageControls {
|
||||
|
||||
// Use global sidebar manager
|
||||
this.sidebarManager = sidebarManager;
|
||||
|
||||
|
||||
this._updateCheckInProgress = false;
|
||||
|
||||
// Initialize event listeners
|
||||
this.initEventListeners();
|
||||
|
||||
// Initialize update availability filter button state
|
||||
this.initUpdateAvailableFilter();
|
||||
|
||||
// Initialize favorites filter button state
|
||||
this.initFavoritesFilter();
|
||||
|
||||
@@ -69,7 +75,9 @@ export class PageControls {
|
||||
*/
|
||||
async initSidebarManager() {
|
||||
try {
|
||||
await this.sidebarManager.initialize(this);
|
||||
this.sidebarManager.setHostPageControls(this);
|
||||
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
|
||||
await this.sidebarManager.setSidebarEnabled(shouldShowSidebar);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize SidebarManager:', error);
|
||||
}
|
||||
@@ -153,7 +161,15 @@ export class PageControls {
|
||||
document.querySelector('.dropdown-group.active')?.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const checkUpdatesOption = document.getElementById('checkUpdatesMenuItem');
|
||||
if (checkUpdatesOption) {
|
||||
checkUpdatesOption.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this.handleCheckModelUpdates(e.currentTarget);
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.dropdown-group')) {
|
||||
@@ -163,7 +179,82 @@ export class PageControls {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async handleCheckModelUpdates(menuItem) {
|
||||
if (this._updateCheckInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
const dropdownToggle = document.getElementById('updateFilterMenuToggle');
|
||||
const dropdownGroup = menuItem?.closest('.dropdown-group');
|
||||
const iconElement = updateFilterBtn?.querySelector('i');
|
||||
|
||||
const setLoadingState = (isLoading) => {
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.disabled = isLoading;
|
||||
updateFilterBtn.classList.toggle('loading', isLoading);
|
||||
updateFilterBtn.setAttribute('aria-busy', isLoading ? 'true' : 'false');
|
||||
|
||||
if (iconElement) {
|
||||
if (isLoading) {
|
||||
if (!iconElement.dataset.originalClass) {
|
||||
iconElement.dataset.originalClass = iconElement.className;
|
||||
}
|
||||
iconElement.className = 'fas fa-spinner fa-spin';
|
||||
} else {
|
||||
const originalClass = iconElement.dataset.originalClass;
|
||||
if (originalClass) {
|
||||
iconElement.className = originalClass;
|
||||
delete iconElement.dataset.originalClass;
|
||||
} else {
|
||||
iconElement.classList.remove('fa-spinner', 'fa-spin');
|
||||
if (!iconElement.classList.contains('fa-exclamation-circle')) {
|
||||
iconElement.classList.add('fa-exclamation-circle');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dropdownToggle) {
|
||||
dropdownToggle.disabled = isLoading;
|
||||
dropdownToggle.classList.toggle('loading', isLoading);
|
||||
}
|
||||
|
||||
if (menuItem) {
|
||||
menuItem.classList.toggle('disabled', isLoading);
|
||||
if (isLoading) {
|
||||
menuItem.setAttribute('aria-disabled', 'true');
|
||||
} else {
|
||||
menuItem.removeAttribute('aria-disabled');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this._updateCheckInProgress = true;
|
||||
setLoadingState(true);
|
||||
|
||||
const handleComplete = () => {
|
||||
this._updateCheckInProgress = false;
|
||||
setLoadingState(false);
|
||||
};
|
||||
|
||||
try {
|
||||
await performModelUpdateCheck({
|
||||
onComplete: handleComplete,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to check model updates:', error);
|
||||
} finally {
|
||||
if (this._updateCheckInProgress) {
|
||||
this._updateCheckInProgress = false;
|
||||
setLoadingState(false);
|
||||
}
|
||||
dropdownGroup?.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize page-specific event listeners
|
||||
*/
|
||||
@@ -189,12 +280,17 @@ export class PageControls {
|
||||
if (bulkButton) {
|
||||
bulkButton.addEventListener('click', () => this.toggleBulkMode());
|
||||
}
|
||||
|
||||
|
||||
// Favorites filter button handler
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
if (favoriteFilterBtn) {
|
||||
favoriteFilterBtn.addEventListener('click', () => this.toggleFavoritesOnly());
|
||||
}
|
||||
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.addEventListener('click', () => this.toggleUpdateAvailableOnly());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,17 +474,33 @@ export class PageControls {
|
||||
// Get current state from session storage with page-specific key
|
||||
const storageKey = `show_favorites_only_${this.pageType}`;
|
||||
const showFavoritesOnly = getSessionItem(storageKey, false);
|
||||
|
||||
|
||||
// Update button state
|
||||
if (showFavoritesOnly) {
|
||||
favoriteFilterBtn.classList.add('active');
|
||||
}
|
||||
|
||||
|
||||
// Update app state
|
||||
this.pageState.showFavoritesOnly = showFavoritesOnly;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize update availability filter button state
|
||||
*/
|
||||
initUpdateAvailableFilter() {
|
||||
const storageKey = `show_update_available_only_${this.pageType}`;
|
||||
const storedValue = getSessionItem(storageKey, false);
|
||||
const showUpdatesOnly = storedValue === true || storedValue === 'true';
|
||||
|
||||
this.pageState.showUpdateAvailableOnly = showUpdatesOnly;
|
||||
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorites-only filter and reload models
|
||||
*/
|
||||
@@ -410,10 +522,29 @@ export class PageControls {
|
||||
if (favoriteFilterBtn) {
|
||||
favoriteFilterBtn.classList.toggle('active', newState);
|
||||
}
|
||||
|
||||
|
||||
// Reload models with new filter
|
||||
await this.resetAndReload(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle update-available-only filter and reload models
|
||||
*/
|
||||
async toggleUpdateAvailableOnly() {
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
const storageKey = `show_update_available_only_${this.pageType}`;
|
||||
const newState = !this.pageState.showUpdateAvailableOnly;
|
||||
|
||||
setSessionItem(storageKey, newState);
|
||||
|
||||
this.pageState.showUpdateAvailableOnly = newState;
|
||||
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.classList.toggle('active', newState);
|
||||
}
|
||||
|
||||
await this.resetAndReload(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicate models
|
||||
@@ -437,4 +568,4 @@ export class PageControls {
|
||||
this.sidebarManager.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, openExampleImagesFolder, buildLoraSyntax } from '../../utils/uiHelpers.js';
|
||||
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../../state/index.js';
|
||||
import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../../utils/constants.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
@@ -168,8 +168,53 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const loraSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
|
||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||
const modelPath = card.dataset.filepath;
|
||||
if (!modelPath) {
|
||||
const message = translate('modelCard.sendToWorkflow.missingPath', {}, 'Unable to determine model path for this card');
|
||||
showToast(message, {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const subtype = (card.dataset.model_type || 'checkpoint').toLowerCase();
|
||||
const isDiffusionModel = subtype === 'diffusion_model';
|
||||
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
|
||||
const actionTypeText = translate(
|
||||
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
|
||||
{},
|
||||
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||||
);
|
||||
const successMessage = translate(
|
||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelUpdated' : 'uiHelpers.workflow.checkpointUpdated',
|
||||
{},
|
||||
isDiffusionModel ? 'Diffusion model updated in workflow' : 'Checkpoint updated in workflow'
|
||||
);
|
||||
const failureMessage = translate(
|
||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelFailed' : 'uiHelpers.workflow.checkpointFailed',
|
||||
{},
|
||||
isDiffusionModel ? 'Failed to update diffusion model node' : 'Failed to update checkpoint node'
|
||||
);
|
||||
const missingNodesMessage = translate(
|
||||
'uiHelpers.workflow.noMatchingNodes',
|
||||
{},
|
||||
'No compatible nodes available in the current workflow'
|
||||
);
|
||||
const missingTargetMessage = translate(
|
||||
'uiHelpers.workflow.noTargetNodeSelected',
|
||||
{},
|
||||
'No target node selected'
|
||||
);
|
||||
|
||||
sendModelPathToWorkflow(modelPath, {
|
||||
widgetName,
|
||||
collectionType: MODEL_TYPES.CHECKPOINT,
|
||||
actionTypeText,
|
||||
successMessage,
|
||||
failureMessage,
|
||||
missingNodesMessage,
|
||||
missingTargetMessage,
|
||||
});
|
||||
} else {
|
||||
// Checkpoint send functionality - to be implemented
|
||||
showToast('modelCard.sendToWorkflow.checkpointNotImplemented', {}, 'info');
|
||||
}
|
||||
}
|
||||
@@ -242,6 +287,7 @@ async function showModelModalFromCard(card, modelType) {
|
||||
// Parse civitai metadata from the card's dataset
|
||||
civitai: JSON.parse(card.dataset.meta || '{}'),
|
||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||
update_available: card.dataset.update_available === 'true',
|
||||
modelDescription: card.dataset.modelDescription || '',
|
||||
// LoRA specific fields
|
||||
...(modelType === MODEL_TYPES.LORA && {
|
||||
@@ -387,6 +433,14 @@ export function createModelCard(model, modelType) {
|
||||
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';
|
||||
|
||||
const civitaiData = model.civitai || {};
|
||||
const modelId = civitaiData?.modelId ?? civitaiData?.model_id;
|
||||
if (modelId !== undefined && modelId !== null && modelId !== '') {
|
||||
card.dataset.modelId = modelId;
|
||||
}
|
||||
|
||||
// LoRA specific data
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
@@ -462,6 +516,9 @@ export function createModelCard(model, modelType) {
|
||||
|
||||
// Get favorite status from model data
|
||||
const isFavorite = model.favorite === true;
|
||||
if (hasUpdateAvailable) {
|
||||
card.classList.add('has-update');
|
||||
}
|
||||
|
||||
// Generate action icons based on model type with i18n support
|
||||
const favoriteTitle = isFavorite ?
|
||||
@@ -470,9 +527,24 @@ export function createModelCard(model, modelType) {
|
||||
const globeTitle = model.from_civitai ?
|
||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||
const sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
|
||||
const copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
|
||||
let sendTitle;
|
||||
let copyTitle;
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
|
||||
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
|
||||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||
sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI');
|
||||
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
|
||||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
|
||||
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
|
||||
} else {
|
||||
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI');
|
||||
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy value');
|
||||
}
|
||||
|
||||
const updateBadgeLabel = translate('modelCard.badges.update', {}, 'Update');
|
||||
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
|
||||
const actionIcons = `
|
||||
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
|
||||
title="${favoriteTitle}">
|
||||
@@ -491,7 +563,16 @@ export function createModelCard(model, modelType) {
|
||||
// Generate UI text with i18n support
|
||||
const toggleBlurTitle = translate('modelCard.actions.toggleBlur', {}, 'Toggle blur');
|
||||
const showButtonText = translate('modelCard.actions.show', {}, 'Show');
|
||||
const openExampleImagesTitle = translate('modelCard.actions.openExampleImages', {}, 'Open Example Images Folder');
|
||||
const footerActionSetting = state.global.settings.model_card_footer_action || 'example_images';
|
||||
const footerActionTitle = footerActionSetting === 'replace_preview'
|
||||
? translate('modelCard.actions.replacePreview', {}, 'Replace Preview')
|
||||
: translate('modelCard.actions.openExampleImages', {}, 'Open Example Images Folder');
|
||||
const footerActionIcon = footerActionSetting === 'replace_preview'
|
||||
? 'fas fa-image'
|
||||
: 'fas fa-folder-open';
|
||||
|
||||
const baseModelLabel = model.base_model || 'Unknown';
|
||||
const baseModelAbbreviation = getBaseModelAbbreviation(baseModelLabel);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
@@ -504,9 +585,16 @@ export function createModelCard(model, modelType) {
|
||||
`<button class="toggle-blur-btn" title="${toggleBlurTitle}">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>` : ''}
|
||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${model.base_model}">
|
||||
${model.base_model}
|
||||
</span>
|
||||
<div class="card-header-info">
|
||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">
|
||||
${baseModelAbbreviation}
|
||||
</span>
|
||||
${hasUpdateAvailable ? `
|
||||
<span class="model-update-badge" title="${updateBadgeTooltip}">
|
||||
${updateBadgeLabel}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${actionIcons}
|
||||
</div>
|
||||
@@ -525,8 +613,8 @@ export function createModelCard(model, modelType) {
|
||||
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-folder-open"
|
||||
title="${openExampleImagesTitle}">
|
||||
<i class="${footerActionIcon}"
|
||||
title="${footerActionTitle}">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -825,5 +913,3 @@ export function updateCardsForBulkMode(isBulkMode) {
|
||||
bulkManager.applySelectionState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import { translate } from '../../utils/i18nHelpers.js';
|
||||
/**
|
||||
* Set up tab switching functionality
|
||||
*/
|
||||
export function setupTabSwitching() {
|
||||
export function setupTabSwitching(options = {}) {
|
||||
const { onTabChange } = options;
|
||||
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
@@ -31,6 +32,14 @@ export function setupTabSwitching() {
|
||||
if (button.dataset.tab === 'description') {
|
||||
await loadModelDescription();
|
||||
}
|
||||
|
||||
if (typeof onTabChange === 'function') {
|
||||
try {
|
||||
await onTabChange(button.dataset.tab);
|
||||
} catch (error) {
|
||||
console.error('Error handling tab change:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -176,4 +185,4 @@ export async function setupModelDescriptionEditing(filePath) {
|
||||
descContainer.classList.remove('editing');
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,88 @@ import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
|
||||
/**
|
||||
* Resolve the active file path for the currently open model modal.
|
||||
* Falls back to the provided value when DOM state has not been initialised yet.
|
||||
* @param {string} fallback - Optional fallback path
|
||||
* @returns {string}
|
||||
*/
|
||||
function getActiveModalFilePath(fallback = '') {
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
|
||||
return modalElement.dataset.filePath;
|
||||
}
|
||||
|
||||
const fileNameContent = document.querySelector('.file-name-content');
|
||||
if (fileNameContent && fileNameContent.dataset && fileNameContent.dataset.filePath) {
|
||||
return fileNameContent.dataset.filePath;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all modal controls that cache the current model file path.
|
||||
* Keeps metadata interactions in sync after renames or moves.
|
||||
* @param {string} newFilePath - Updated model file path
|
||||
*/
|
||||
function updateModalFilePathReferences(newFilePath) {
|
||||
if (!newFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
if (modalElement) {
|
||||
modalElement.dataset.filePath = newFilePath;
|
||||
modalElement.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
const modelNameContent = document.querySelector('.model-name-content');
|
||||
if (modelNameContent && modelNameContent.dataset) {
|
||||
modelNameContent.dataset.filePath = newFilePath;
|
||||
modelNameContent.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
const baseModelContent = document.querySelector('.base-model-content');
|
||||
if (baseModelContent && baseModelContent.dataset) {
|
||||
baseModelContent.dataset.filePath = newFilePath;
|
||||
baseModelContent.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
const fileNameContent = document.querySelector('.file-name-content');
|
||||
if (fileNameContent && fileNameContent.dataset) {
|
||||
fileNameContent.dataset.filePath = newFilePath;
|
||||
fileNameContent.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
const editTagsBtn = document.querySelector('.edit-tags-btn');
|
||||
if (editTagsBtn) {
|
||||
editTagsBtn.dataset.filePath = newFilePath;
|
||||
editTagsBtn.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
const editTriggerWordsBtn = document.querySelector('.edit-trigger-words-btn');
|
||||
if (editTriggerWordsBtn) {
|
||||
editTriggerWordsBtn.dataset.filePath = newFilePath;
|
||||
editTriggerWordsBtn.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-action="open-file-location"]').forEach((el) => {
|
||||
el.dataset.filepath = newFilePath;
|
||||
el.setAttribute('data-filepath', newFilePath);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-file-path]').forEach((el) => {
|
||||
el.dataset.filePath = newFilePath;
|
||||
el.setAttribute('data-file-path', newFilePath);
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-filepath]').forEach((el) => {
|
||||
el.dataset.filepath = newFilePath;
|
||||
el.setAttribute('data-filepath', newFilePath);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up model name editing functionality
|
||||
* @param {string} filePath - File path
|
||||
@@ -110,9 +192,9 @@ export function setupModelNameEditing(filePath) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the file path from the dataset
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
// Resolve current file path from modal state
|
||||
const filePath = getActiveModalFilePath(this.dataset.filePath);
|
||||
|
||||
await getModelApiClient().saveModelMetadata(filePath, { model_name: newModelName });
|
||||
|
||||
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
|
||||
@@ -230,11 +312,8 @@ export function setupBaseModelEditing(filePath) {
|
||||
|
||||
// Only save if the value has actually changed
|
||||
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
|
||||
// Get file path from the dataset
|
||||
const filePath = baseModelContent.dataset.filePath;
|
||||
|
||||
// Save the changes, passing the original value for comparison
|
||||
saveBaseModel(filePath, originalValue);
|
||||
const resolvedPath = getActiveModalFilePath(baseModelContent.dataset.filePath);
|
||||
saveBaseModel(resolvedPath, originalValue);
|
||||
}
|
||||
|
||||
// Remove this event listener
|
||||
@@ -277,9 +356,14 @@ async function saveBaseModel(filePath, originalValue) {
|
||||
if (newBaseModel === originalValue) {
|
||||
return; // No change, no need to save
|
||||
}
|
||||
|
||||
const resolvedPath = getActiveModalFilePath(filePath);
|
||||
if (!resolvedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await getModelApiClient().saveModelMetadata(filePath, { base_model: newBaseModel });
|
||||
await getModelApiClient().saveModelMetadata(resolvedPath, { base_model: newBaseModel });
|
||||
|
||||
showToast('toast.models.baseModelUpdated', {}, 'success');
|
||||
} catch (error) {
|
||||
@@ -396,10 +480,22 @@ export function setupFileNameEditing(filePath) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the file path from the dataset
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
await getModelApiClient().renameModelFile(filePath, newFileName);
|
||||
const currentFilePath = getActiveModalFilePath(this.dataset.filePath);
|
||||
const result = await getModelApiClient().renameModelFile(currentFilePath, newFileName);
|
||||
|
||||
if (result && result.success && result.new_file_path) {
|
||||
const newFilePath = result.new_file_path;
|
||||
this.dataset.filePath = newFilePath;
|
||||
this.setAttribute('data-file-path', newFilePath);
|
||||
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
if (modalElement) {
|
||||
modalElement.dataset.filePath = newFilePath;
|
||||
modalElement.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
updateModalFilePathReferences(newFilePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renaming file:', error);
|
||||
this.textContent = originalValue; // Restore original file name
|
||||
|
||||
@@ -17,9 +17,18 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||
import { loadRecipesForLora } from './RecipeTab.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
function getModalFilePath(fallback = '') {
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
if (modalElement && modalElement.dataset && modalElement.dataset.filePath) {
|
||||
return modalElement.dataset.filePath;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the model modal with the given model data
|
||||
* @param {Object} model - Model data object
|
||||
@@ -46,6 +55,7 @@ export async function showModelModal(model, modelType) {
|
||||
...model,
|
||||
civitai: completeCivitaiData
|
||||
};
|
||||
const hasUpdateAvailable = Boolean(modelWithFullData.update_available);
|
||||
|
||||
// Prepare LoRA specific data with complete civitai data
|
||||
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
|
||||
@@ -65,19 +75,38 @@ export async function showModelModal(model, modelType) {
|
||||
const examplesText = translate('modals.model.tabs.examples', {}, 'Examples');
|
||||
const descriptionText = translate('modals.model.tabs.description', {}, 'Model Description');
|
||||
const recipesText = translate('modals.model.tabs.recipes', {}, 'Recipes');
|
||||
|
||||
const versionsText = translate('modals.model.tabs.versions', {}, 'Versions');
|
||||
const versionsBadgeLabel = translate('modelCard.badges.update', {}, 'Update');
|
||||
const versionsTabBadge = hasUpdateAvailable
|
||||
? `<span class="tab-badge tab-badge--update">${versionsBadgeLabel}</span>`
|
||||
: '';
|
||||
const versionsTabClasses = ['tab-btn'];
|
||||
if (hasUpdateAvailable) {
|
||||
versionsTabClasses.push('tab-btn--has-update');
|
||||
}
|
||||
const versionsTabButton = `<button class="${versionsTabClasses.join(' ')}" data-tab="versions">
|
||||
<span class="tab-label">${versionsText}</span>
|
||||
${versionsTabBadge}
|
||||
</button>`.trim();
|
||||
|
||||
const tabsContent = modelType === 'loras' ?
|
||||
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
||||
${versionsTabButton}
|
||||
<button class="tab-btn" data-tab="recipes">${recipesText}</button>` :
|
||||
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>`;
|
||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
||||
${versionsTabButton}`;
|
||||
|
||||
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
|
||||
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
|
||||
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
|
||||
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
|
||||
|
||||
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
||||
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
|
||||
const civitaiVersionId = modelWithFullData.civitai?.id || '';
|
||||
|
||||
const tabPanesContent = modelType === 'loras' ?
|
||||
`<div id="showcase-tab" class="tab-pane active">
|
||||
<div class="example-images-loading">
|
||||
@@ -94,7 +123,15 @@ export async function showModelModal(model, modelType) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="versions-tab" class="tab-pane">
|
||||
<div class="model-versions-tab" data-model-id="${civitaiModelId}" data-model-type="${modelType}" data-current-version-id="${civitaiVersionId}">
|
||||
<div class="versions-loading-state">
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingVersionsText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recipes-tab" class="tab-pane">
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingRecipesText}
|
||||
@@ -114,6 +151,14 @@ export async function showModelModal(model, modelType) {
|
||||
<div class="model-description-content hidden">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="versions-tab" class="tab-pane">
|
||||
<div class="model-versions-tab" data-model-id="${civitaiModelId}" data-model-type="${modelType}" data-current-version-id="${civitaiVersionId}">
|
||||
<div class="versions-loading-state">
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingVersionsText}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const content = `
|
||||
@@ -232,9 +277,26 @@ export async function showModelModal(model, modelType) {
|
||||
};
|
||||
|
||||
modalManager.showModal(modalId, content, null, onCloseCallback);
|
||||
const activeModalElement = document.getElementById(modalId);
|
||||
if (activeModalElement) {
|
||||
activeModalElement.dataset.filePath = modelWithFullData.file_path || '';
|
||||
}
|
||||
const versionsTabController = initVersionsTab({
|
||||
modalId,
|
||||
modelType,
|
||||
modelId: civitaiModelId,
|
||||
currentVersionId: civitaiVersionId,
|
||||
});
|
||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||
setupShowcaseScroll(modalId);
|
||||
setupTabSwitching();
|
||||
setupTabSwitching({
|
||||
onTabChange: async (tab) => {
|
||||
if (tab === 'versions') {
|
||||
await versionsTabController.load();
|
||||
}
|
||||
},
|
||||
});
|
||||
versionsTabController.load({ eager: true });
|
||||
setupTagTooltip();
|
||||
setupTagEditMode(modelType);
|
||||
setupModelNameEditing(modelWithFullData.file_path);
|
||||
@@ -324,7 +386,7 @@ function setupEventHandlers(filePath) {
|
||||
}
|
||||
break;
|
||||
case 'open-file-location':
|
||||
const filePath = target.dataset.filepath;
|
||||
const filePath = target.dataset.filepath || getModalFilePath();
|
||||
if (filePath) {
|
||||
openFileLocation(filePath);
|
||||
}
|
||||
@@ -373,7 +435,7 @@ function setupEditableFields(filePath, modelType) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
await saveNotes(filePath);
|
||||
await saveNotes();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -389,6 +451,7 @@ function setupLoraSpecificFields(filePath) {
|
||||
const presetValue = document.getElementById('preset-value');
|
||||
const addPresetBtn = document.querySelector('.add-preset-btn');
|
||||
const presetTags = document.querySelector('.preset-tags');
|
||||
const resolveFilePath = () => getModalFilePath(filePath);
|
||||
|
||||
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
|
||||
|
||||
@@ -416,13 +479,16 @@ function setupLoraSpecificFields(filePath) {
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const currentPath = resolveFilePath();
|
||||
if (!currentPath) return;
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
|
||||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
||||
|
||||
currentPresets[key] = parseFloat(value);
|
||||
const newPresetsJson = JSON.stringify(currentPresets);
|
||||
|
||||
await getModelApiClient().saveModelMetadata(filePath, { usage_tips: newPresetsJson });
|
||||
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
|
||||
|
||||
presetTags.innerHTML = renderPresetTags(currentPresets);
|
||||
|
||||
@@ -441,10 +507,13 @@ function setupLoraSpecificFields(filePath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save model notes
|
||||
* @param {string} filePath - Path to the model file
|
||||
* Save model notes using the current modal file path.
|
||||
*/
|
||||
async function saveNotes(filePath) {
|
||||
async function saveNotes() {
|
||||
const filePath = getModalFilePath();
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
const content = document.querySelector('.notes-content').textContent;
|
||||
try {
|
||||
await getModelApiClient().saveModelMetadata(filePath, { notes: content });
|
||||
|
||||
863
static/js/components/shared/ModelVersionsTab.js
Normal file
863
static/js/components/shared/ModelVersionsTab.js
Normal file
@@ -0,0 +1,863 @@
|
||||
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 { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { formatFileSize } from './utils.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function extractExtension(value) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
const targets = [];
|
||||
const stringValue = String(value);
|
||||
if (stringValue) {
|
||||
targets.push(stringValue);
|
||||
stringValue.split(/[?&=]/).forEach(fragment => {
|
||||
if (fragment) {
|
||||
targets.push(fragment);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
let candidate = target;
|
||||
try {
|
||||
candidate = decodeURIComponent(candidate);
|
||||
} catch (error) {
|
||||
// ignoring malformed sequences, fallback to raw value
|
||||
}
|
||||
const lastDot = candidate.lastIndexOf('.');
|
||||
if (lastDot === -1) {
|
||||
continue;
|
||||
}
|
||||
const extension = candidate.slice(lastDot).toLowerCase();
|
||||
if (extension.includes('/') || extension.includes('\\')) {
|
||||
continue;
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function isVideoUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidates = new Set();
|
||||
const addCandidate = value => {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
const stringValue = String(value);
|
||||
if (!stringValue) {
|
||||
return;
|
||||
}
|
||||
candidates.add(stringValue);
|
||||
};
|
||||
|
||||
addCandidate(url);
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
addCandidate(parsed.pathname);
|
||||
parsed.searchParams.forEach(value => addCandidate(value));
|
||||
} catch (error) {
|
||||
// ignore parse errors and rely on fallbacks below
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const extension = extractExtension(candidate);
|
||||
if (extension && VIDEO_EXTENSIONS.includes(extension)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatDateLabel(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return parsed.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function buildMetaMarkup(version) {
|
||||
const segments = [];
|
||||
if (version.baseModel) {
|
||||
segments.push(
|
||||
`<span class="version-meta-primary">${escapeHtml(version.baseModel)}</span>`
|
||||
);
|
||||
}
|
||||
const releaseLabel = formatDateLabel(version.releasedAt);
|
||||
if (releaseLabel) {
|
||||
segments.push(escapeHtml(releaseLabel));
|
||||
}
|
||||
if (typeof version.sizeBytes === 'number' && version.sizeBytes > 0) {
|
||||
segments.push(escapeHtml(formatFileSize(version.sizeBytes)));
|
||||
}
|
||||
|
||||
if (!segments.length) {
|
||||
return escapeHtml(
|
||||
translate('modals.model.versions.labels.noDetails', {}, 'No additional details')
|
||||
);
|
||||
}
|
||||
|
||||
return segments
|
||||
.map(segment => `<span class="version-meta-item">${segment}</span>`)
|
||||
.join('<span class="version-meta-separator">•</span>');
|
||||
}
|
||||
|
||||
function buildBadge(label, tone) {
|
||||
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function getAutoplaySetting() {
|
||||
try {
|
||||
return Boolean(state?.global?.settings?.autoplay_on_hover);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMediaMarkup(version) {
|
||||
if (!version.previewUrl) {
|
||||
const placeholderText = translate('modals.model.versions.media.placeholder', {}, 'No preview');
|
||||
return `<div class="version-media version-media-placeholder">${escapeHtml(placeholderText)}</div>`;
|
||||
}
|
||||
|
||||
if (isVideoUrl(version.previewUrl)) {
|
||||
const autoplayOnHover = getAutoplaySetting();
|
||||
return `
|
||||
<div class="version-media">
|
||||
<video
|
||||
src="${escapeHtml(version.previewUrl)}"
|
||||
${autoplayOnHover ? '' : 'controls'}
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="metadata"
|
||||
data-autoplay-on-hover="${autoplayOnHover ? 'true' : 'false'}"
|
||||
></video>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="version-media">
|
||||
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRow(version, options) {
|
||||
const { latestLibraryVersionId, currentVersionId, modelId: parentModelId } = options;
|
||||
const isCurrent = currentVersionId && version.versionId === currentVersionId;
|
||||
const isNewer =
|
||||
typeof latestLibraryVersionId === 'number' &&
|
||||
version.versionId > latestLibraryVersionId;
|
||||
const badges = [];
|
||||
|
||||
if (isCurrent) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current'));
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
if (version.shouldIgnore) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
|
||||
}
|
||||
|
||||
const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download');
|
||||
const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
||||
const ignoreLabel = translate(
|
||||
version.shouldIgnore
|
||||
? 'modals.model.versions.actions.unignore'
|
||||
: 'modals.model.versions.actions.ignore',
|
||||
{},
|
||||
version.shouldIgnore ? 'Unignore' : 'Ignore'
|
||||
);
|
||||
|
||||
const actions = [];
|
||||
if (!version.isInLibrary) {
|
||||
actions.push(
|
||||
`<button class="version-action version-action-primary" data-version-action="download">${escapeHtml(downloadLabel)}</button>`
|
||||
);
|
||||
} else if (version.filePath) {
|
||||
actions.push(
|
||||
`<button class="version-action version-action-danger" data-version-action="delete">${escapeHtml(deleteLabel)}</button>`
|
||||
);
|
||||
}
|
||||
actions.push(
|
||||
`<button class="version-action version-action-ghost" data-version-action="toggle-ignore" data-ignore-state="${
|
||||
version.shouldIgnore ? 'ignored' : 'active'
|
||||
}">${escapeHtml(ignoreLabel)}</button>`
|
||||
);
|
||||
|
||||
const linkTarget = buildCivitaiVersionUrl(
|
||||
version.modelId || parentModelId,
|
||||
version.versionId
|
||||
);
|
||||
const civitaiTooltip = translate(
|
||||
'modals.model.actions.viewOnCivitai',
|
||||
{},
|
||||
'View on Civitai'
|
||||
);
|
||||
|
||||
const rowAttributes = [
|
||||
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}"`,
|
||||
`data-version-id="${escapeHtml(version.versionId)}"`,
|
||||
];
|
||||
if (linkTarget) {
|
||||
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
|
||||
rowAttributes.push(`title="${escapeHtml(civitaiTooltip)}"`);
|
||||
}
|
||||
|
||||
return `
|
||||
<div ${rowAttributes.join(' ')}>
|
||||
${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>
|
||||
</div>
|
||||
<div class="version-badges">${badges.join('')}</div>
|
||||
<div class="version-meta">
|
||||
${buildMetaMarkup(version)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
${actions.join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function setupMediaHoverInteractions(container) {
|
||||
const autoplayOnHover = getAutoplaySetting();
|
||||
if (!autoplayOnHover) {
|
||||
return;
|
||||
}
|
||||
container.querySelectorAll('.version-media video').forEach(video => {
|
||||
if (video.dataset.autoplayOnHover !== 'true') {
|
||||
return;
|
||||
}
|
||||
const play = () => {
|
||||
try {
|
||||
video.currentTime = 0;
|
||||
const promise = video.play();
|
||||
if (promise && typeof promise.catch === 'function') {
|
||||
promise.catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to autoplay preview video:', error);
|
||||
}
|
||||
};
|
||||
const stop = () => {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
};
|
||||
video.addEventListener('mouseenter', play);
|
||||
video.addEventListener('focus', play);
|
||||
video.addEventListener('mouseleave', stop);
|
||||
video.addEventListener('blur', stop);
|
||||
});
|
||||
}
|
||||
|
||||
function getLatestLibraryVersionId(record) {
|
||||
if (!record || !Array.isArray(record.inLibraryVersionIds) || !record.inLibraryVersionIds.length) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(...record.inLibraryVersionIds);
|
||||
}
|
||||
|
||||
function renderToolbar(record) {
|
||||
const ignoreText = record.shouldIgnore
|
||||
? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model')
|
||||
: translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model');
|
||||
const viewLocalText = translate('modals.model.versions.actions.viewLocalVersions', {}, 'View all local versions');
|
||||
const infoText = translate(
|
||||
'modals.model.versions.copy',
|
||||
{ count: record.versions.length },
|
||||
'Track and manage every version of this model in one place.'
|
||||
);
|
||||
|
||||
return `
|
||||
<header class="versions-toolbar">
|
||||
<div class="versions-toolbar-info">
|
||||
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
|
||||
<p>${escapeHtml(infoText)}</p>
|
||||
</div>
|
||||
<div class="versions-toolbar-actions">
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
|
||||
${escapeHtml(ignoreText)}
|
||||
</button>
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled>
|
||||
${escapeHtml(viewLocalText)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEmptyState(container) {
|
||||
const message = translate('modals.model.versions.empty', {}, 'No version history available for this model yet.');
|
||||
container.innerHTML = `
|
||||
<div class="versions-empty">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderErrorState(container, message) {
|
||||
const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.');
|
||||
container.innerHTML = `
|
||||
<div class="versions-error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>${escapeHtml(message || fallback)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function initVersionsTab({
|
||||
modalId,
|
||||
modelType,
|
||||
modelId,
|
||||
currentVersionId,
|
||||
}) {
|
||||
const pane = document.querySelector(`#${modalId} #versions-tab`);
|
||||
const container = pane ? pane.querySelector('.model-versions-tab') : null;
|
||||
const normalizedCurrentVersionId =
|
||||
typeof currentVersionId === 'number'
|
||||
? currentVersionId
|
||||
: currentVersionId
|
||||
? Number(currentVersionId)
|
||||
: null;
|
||||
|
||||
if (!container) {
|
||||
return {
|
||||
async load() {},
|
||||
async refresh() {},
|
||||
};
|
||||
}
|
||||
|
||||
let controller = {
|
||||
isLoading: false,
|
||||
hasLoaded: false,
|
||||
record: null,
|
||||
};
|
||||
|
||||
let apiClient;
|
||||
|
||||
function ensureClient() {
|
||||
if (apiClient) {
|
||||
return apiClient;
|
||||
}
|
||||
try {
|
||||
apiClient = getModelApiClient(modelType);
|
||||
} catch (error) {
|
||||
apiClient = getModelApiClient();
|
||||
}
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
const loadingText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
||||
container.innerHTML = `
|
||||
<div class="versions-loading-state">
|
||||
<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(loadingText)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function render(record) {
|
||||
controller.record = record;
|
||||
controller.hasLoaded = true;
|
||||
|
||||
if (!record || !Array.isArray(record.versions) || record.versions.length === 0) {
|
||||
renderEmptyState(container);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestLibraryVersionId = getLatestLibraryVersionId(record);
|
||||
let dividerInserted = false;
|
||||
|
||||
const sortedVersions = [...record.versions].sort(
|
||||
(a, b) => Number(b.versionId) - Number(a.versionId)
|
||||
);
|
||||
|
||||
const rowsMarkup = sortedVersions
|
||||
.map(version => {
|
||||
const isNewer =
|
||||
typeof latestLibraryVersionId === 'number' &&
|
||||
version.versionId > latestLibraryVersionId;
|
||||
let markup = '';
|
||||
if (
|
||||
!dividerInserted &&
|
||||
typeof latestLibraryVersionId === 'number' &&
|
||||
!isNewer
|
||||
) {
|
||||
dividerInserted = true;
|
||||
markup += '<div class="version-divider" role="presentation"></div>';
|
||||
}
|
||||
markup += renderRow(version, {
|
||||
latestLibraryVersionId,
|
||||
currentVersionId: normalizedCurrentVersionId,
|
||||
modelId: record?.modelId ?? modelId,
|
||||
});
|
||||
return markup;
|
||||
})
|
||||
.join('');
|
||||
|
||||
container.innerHTML = `
|
||||
${renderToolbar(record)}
|
||||
<div class="versions-list">
|
||||
${rowsMarkup}
|
||||
</div>
|
||||
`;
|
||||
|
||||
setupMediaHoverInteractions(container);
|
||||
}
|
||||
|
||||
async function loadVersions({ forceRefresh = false, eager = false } = {}) {
|
||||
if (controller.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!modelId) {
|
||||
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
||||
return;
|
||||
}
|
||||
if (controller.hasLoaded && !forceRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.isLoading = true;
|
||||
if (!eager) {
|
||||
showLoading();
|
||||
}
|
||||
|
||||
try {
|
||||
const client = ensureClient();
|
||||
const response = await client.fetchModelUpdateVersions(modelId, {
|
||||
refresh: false,
|
||||
});
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Request failed');
|
||||
}
|
||||
render(response.record);
|
||||
} catch (error) {
|
||||
console.error('Failed to load model versions:', error);
|
||||
renderErrorState(container, error?.message);
|
||||
} finally {
|
||||
controller.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await loadVersions({ forceRefresh: true });
|
||||
}
|
||||
|
||||
async function handleToggleModelIgnore(button) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
}
|
||||
const client = ensureClient();
|
||||
const nextValue = !controller.record.shouldIgnore;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await client.setModelUpdateIgnore(modelId, nextValue);
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Request failed');
|
||||
}
|
||||
render(response.record);
|
||||
const toastKey = nextValue
|
||||
? 'modals.model.versions.toast.modelIgnored'
|
||||
: 'modals.model.versions.toast.modelResumed';
|
||||
const toastMessage = translate(
|
||||
toastKey,
|
||||
{},
|
||||
nextValue ? 'Updates ignored for this model' : 'Update tracking resumed'
|
||||
);
|
||||
showToast(toastMessage, {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to update model ignore state:', error);
|
||||
showToast(error?.message || 'Failed to update ignore preference', {}, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleVersionIgnore(button, versionId) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
}
|
||||
const client = ensureClient();
|
||||
const targetVersion = controller.record.versions.find(v => v.versionId === versionId);
|
||||
const nextValue = targetVersion ? !targetVersion.shouldIgnore : true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await client.setVersionUpdateIgnore(
|
||||
modelId,
|
||||
versionId,
|
||||
nextValue
|
||||
);
|
||||
if (!response?.success) {
|
||||
throw new Error(response?.error || 'Request failed');
|
||||
}
|
||||
render(response.record);
|
||||
const updatedVersion = response.record.versions.find(v => v.versionId === versionId);
|
||||
const toastKey = updatedVersion?.shouldIgnore
|
||||
? 'modals.model.versions.toast.versionIgnored'
|
||||
: 'modals.model.versions.toast.versionUnignored';
|
||||
const toastMessage = translate(
|
||||
toastKey,
|
||||
{},
|
||||
updatedVersion?.shouldIgnore ? 'Updates ignored for this version' : 'Version re-enabled'
|
||||
);
|
||||
showToast(toastMessage, {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle version ignore state:', error);
|
||||
showToast(error?.message || 'Failed to update version preference', {}, 'error');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function performDeleteVersion({
|
||||
triggerButton,
|
||||
confirmButton,
|
||||
closeModal,
|
||||
version,
|
||||
}) {
|
||||
if (!version?.filePath) {
|
||||
console.warn('Missing file path for deletion.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (triggerButton) {
|
||||
triggerButton.disabled = true;
|
||||
}
|
||||
|
||||
let confirmOriginalText = '';
|
||||
if (confirmButton) {
|
||||
confirmOriginalText = confirmButton.textContent;
|
||||
confirmButton.disabled = true;
|
||||
}
|
||||
|
||||
let deletionSucceeded = false;
|
||||
|
||||
try {
|
||||
const client = ensureClient();
|
||||
await client.deleteModel(version.filePath);
|
||||
deletionSucceeded = true;
|
||||
showToast(
|
||||
translate('modals.model.versions.toast.versionDeleted', {}, 'Version deleted'),
|
||||
{},
|
||||
'success'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete version:', error);
|
||||
showToast(error?.message || 'Failed to delete version', {}, 'error');
|
||||
} finally {
|
||||
if (triggerButton && document.body.contains(triggerButton)) {
|
||||
triggerButton.disabled = false;
|
||||
}
|
||||
|
||||
if (
|
||||
confirmButton &&
|
||||
document.body.contains(confirmButton) &&
|
||||
!deletionSucceeded
|
||||
) {
|
||||
confirmButton.disabled = false;
|
||||
if (confirmOriginalText) {
|
||||
confirmButton.textContent = confirmOriginalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deletionSucceeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof closeModal === 'function') {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function showDeleteVersionModal(version, triggerButton) {
|
||||
const modalRecord = modalManager?.getModal?.('deleteModal');
|
||||
if (!modalRecord?.element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
||||
const cancelLabel = translate('common.actions.cancel', {}, 'Cancel');
|
||||
const title = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
||||
const confirmMessage = translate(
|
||||
'modals.model.versions.confirm.delete',
|
||||
{},
|
||||
'Delete this version from your library?'
|
||||
);
|
||||
const versionName =
|
||||
version.name ||
|
||||
translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
||||
const previewUrl =
|
||||
version.previewUrl || '/loras_static/images/no-preview.png';
|
||||
const metaMarkup = buildMetaMarkup(version);
|
||||
|
||||
const modalElement = modalRecord.element;
|
||||
const originalMarkup = modalElement.innerHTML;
|
||||
|
||||
const content = `
|
||||
<div class="modal-content delete-modal-content version-delete-modal">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p class="delete-message">${escapeHtml(confirmMessage)}</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
<img src="${escapeHtml(previewUrl)}" alt="${escapeHtml(versionName)}" onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${escapeHtml(versionName)}</h3>
|
||||
${
|
||||
version.baseModel
|
||||
? `<p class="version-base-model">${escapeHtml(version.baseModel)}</p>`
|
||||
: ''
|
||||
}
|
||||
${metaMarkup ? `<div class="version-meta">${metaMarkup}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn">${escapeHtml(cancelLabel)}</button>
|
||||
<button class="delete-btn">${escapeHtml(deleteLabel)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const cleanupHandlers = [];
|
||||
|
||||
modalManager.showModal(
|
||||
'deleteModal',
|
||||
content,
|
||||
null,
|
||||
() => {
|
||||
cleanupHandlers.forEach(handler => {
|
||||
try {
|
||||
handler();
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup delete modal handler:', error);
|
||||
}
|
||||
});
|
||||
cleanupHandlers.length = 0;
|
||||
modalElement.innerHTML = originalMarkup;
|
||||
delete modalElement.dataset.versionId;
|
||||
}
|
||||
);
|
||||
|
||||
modalElement.dataset.versionId = String(version.versionId ?? '');
|
||||
|
||||
const cancelButton = modalElement.querySelector('.cancel-btn');
|
||||
const confirmButton = modalElement.querySelector('.delete-btn');
|
||||
|
||||
const closeModal = () => modalManager.closeModal('deleteModal');
|
||||
|
||||
if (cancelButton) {
|
||||
const handleCancel = event => {
|
||||
event.preventDefault();
|
||||
closeModal();
|
||||
};
|
||||
cancelButton.addEventListener('click', handleCancel);
|
||||
cleanupHandlers.push(() => {
|
||||
cancelButton.removeEventListener('click', handleCancel);
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmButton) {
|
||||
const handleConfirm = async event => {
|
||||
event.preventDefault();
|
||||
await performDeleteVersion({
|
||||
triggerButton,
|
||||
confirmButton,
|
||||
closeModal,
|
||||
version,
|
||||
});
|
||||
};
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
cleanupHandlers.push(() => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleDeleteVersion(button, versionId) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
}
|
||||
const version = controller.record.versions.find(item => item.versionId === versionId);
|
||||
if (!version) {
|
||||
console.warn('Target version missing from record for delete:', versionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showDeleteVersionModal(version, button)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmText = translate(
|
||||
'modals.model.versions.confirm.delete',
|
||||
{},
|
||||
'Delete this version from your library?'
|
||||
);
|
||||
if (!window.confirm(confirmText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await performDeleteVersion({
|
||||
triggerButton: button,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDownloadVersion(button, versionId) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
}
|
||||
|
||||
const version = controller.record.versions.find(item => item.versionId === versionId);
|
||||
if (!version) {
|
||||
console.warn('Target version missing from record for download:', versionId);
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
||||
versionName: version.name || `#${version.versionId}`,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
await refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start direct download for version:', error);
|
||||
} finally {
|
||||
if (document.body.contains(button)) {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('click', async event => {
|
||||
const toolbarAction = event.target.closest('[data-versions-action]');
|
||||
if (toolbarAction) {
|
||||
const action = toolbarAction.dataset.versionsAction;
|
||||
if (action === 'toggle-model-ignore') {
|
||||
event.preventDefault();
|
||||
await handleToggleModelIgnore(toolbarAction);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const actionButton = event.target.closest('[data-version-action]');
|
||||
if (actionButton) {
|
||||
const row = actionButton.closest('.model-version-row');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const versionId = Number(row.dataset.versionId);
|
||||
const action = actionButton.dataset.versionAction;
|
||||
|
||||
switch (action) {
|
||||
case 'download':
|
||||
event.preventDefault();
|
||||
await handleDownloadVersion(actionButton, versionId);
|
||||
break;
|
||||
case 'delete':
|
||||
event.preventDefault();
|
||||
await handleDeleteVersion(actionButton, versionId);
|
||||
break;
|
||||
case 'toggle-ignore':
|
||||
event.preventDefault();
|
||||
await handleToggleVersionIgnore(actionButton, versionId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const row = event.target.closest('.model-version-row.is-clickable');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('.version-actions')) {
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = row.dataset.civitaiUrl;
|
||||
if (!targetUrl) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
return {
|
||||
load: options => loadVersions(options),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -61,80 +61,178 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create header with count and view all button
|
||||
const headerElement = document.createElement('div');
|
||||
headerElement.className = 'recipes-header';
|
||||
headerElement.innerHTML = `
|
||||
<h3>Found ${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora</h3>
|
||||
<button class="view-all-btn" title="View all in Recipes page">
|
||||
<i class="fas fa-external-link-alt"></i> View All in Recipes
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add click handler for "View All" button
|
||||
headerElement.querySelector('.view-all-btn').addEventListener('click', () => {
|
||||
|
||||
const headerText = document.createElement('div');
|
||||
headerText.className = 'recipes-header__text';
|
||||
|
||||
const eyebrow = document.createElement('span');
|
||||
eyebrow.className = 'recipes-header__eyebrow';
|
||||
eyebrow.textContent = 'Linked recipes';
|
||||
headerText.appendChild(eyebrow);
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = `${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora`;
|
||||
headerText.appendChild(title);
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.className = 'recipes-header__description';
|
||||
description.textContent = loraName ?
|
||||
`Discover workflows crafted for ${loraName}.` :
|
||||
'Discover workflows crafted for this model.';
|
||||
headerText.appendChild(description);
|
||||
|
||||
headerElement.appendChild(headerText);
|
||||
|
||||
const viewAllButton = document.createElement('button');
|
||||
viewAllButton.className = 'recipes-header__view-all';
|
||||
viewAllButton.type = 'button';
|
||||
viewAllButton.title = 'View all recipes in Recipes page';
|
||||
|
||||
const viewAllIcon = document.createElement('i');
|
||||
viewAllIcon.className = 'fas fa-external-link-alt';
|
||||
viewAllIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const viewAllLabel = document.createElement('span');
|
||||
viewAllLabel.textContent = 'View all recipes';
|
||||
|
||||
viewAllButton.append(viewAllIcon, viewAllLabel);
|
||||
headerElement.appendChild(viewAllButton);
|
||||
|
||||
viewAllButton.addEventListener('click', () => {
|
||||
navigateToRecipesPage(loraName, loraHash);
|
||||
});
|
||||
|
||||
// Create grid container for recipe cards
|
||||
|
||||
const cardGrid = document.createElement('div');
|
||||
cardGrid.className = 'card-grid';
|
||||
cardGrid.className = 'card-grid recipes-card-grid';
|
||||
|
||||
// Create recipe cards matching the structure in recipes.html
|
||||
recipes.forEach(recipe => {
|
||||
// Get basic info
|
||||
const baseModel = recipe.base_model || '';
|
||||
const loras = recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
const statusClass = lorasCount === 0 ? 'empty' : (allLorasAvailable ? 'ready' : 'missing');
|
||||
let statusLabel;
|
||||
|
||||
if (lorasCount === 0) {
|
||||
statusLabel = 'No linked LoRAs';
|
||||
} else if (allLorasAvailable) {
|
||||
statusLabel = `${lorasCount} LoRA${lorasCount > 1 ? 's' : ''} ready`;
|
||||
} else {
|
||||
statusLabel = `Missing ${missingLorasCount} of ${lorasCount}`;
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
// Create card element matching the structure in recipes.html
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = 'recipe-card';
|
||||
card.dataset.filePath = recipe.file_path || '';
|
||||
card.dataset.title = recipe.title || '';
|
||||
card.dataset.created = recipe.created_date || '';
|
||||
card.dataset.id = recipe.id || '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-preview">
|
||||
<img src="${imageUrl}" alt="${recipe.title}" loading="lazy">
|
||||
<div class="card-header">
|
||||
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name">${recipe.title}</span>
|
||||
</div>
|
||||
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||
title="${getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners for action buttons
|
||||
card.querySelector('.fa-copy').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
card.setAttribute('role', 'button');
|
||||
card.setAttribute('tabindex', '0');
|
||||
card.setAttribute('aria-label', recipe.title ? `View recipe ${recipe.title}` : 'View recipe details');
|
||||
|
||||
const media = document.createElement('div');
|
||||
media.className = 'recipe-card__media';
|
||||
|
||||
const image = document.createElement('img');
|
||||
image.loading = 'lazy';
|
||||
image.src = imageUrl;
|
||||
image.alt = recipe.title ? `${recipe.title} preview` : 'Recipe preview';
|
||||
media.appendChild(image);
|
||||
|
||||
const mediaTop = document.createElement('div');
|
||||
mediaTop.className = 'recipe-card__media-top';
|
||||
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'recipe-card__copy';
|
||||
copyButton.type = 'button';
|
||||
copyButton.title = 'Copy recipe syntax';
|
||||
copyButton.setAttribute('aria-label', 'Copy recipe syntax');
|
||||
|
||||
const copyIcon = document.createElement('i');
|
||||
copyIcon.className = 'fas fa-copy';
|
||||
copyIcon.setAttribute('aria-hidden', 'true');
|
||||
copyButton.appendChild(copyIcon);
|
||||
|
||||
mediaTop.appendChild(copyButton);
|
||||
media.appendChild(mediaTop);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'recipe-card__body';
|
||||
|
||||
const titleElement = document.createElement('h4');
|
||||
titleElement.className = 'recipe-card__title';
|
||||
titleElement.textContent = recipe.title || 'Untitled recipe';
|
||||
titleElement.title = recipe.title || 'Untitled recipe';
|
||||
body.appendChild(titleElement);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'recipe-card__meta';
|
||||
|
||||
if (baseModel) {
|
||||
const baseBadge = document.createElement('span');
|
||||
baseBadge.className = 'recipe-card__badge recipe-card__badge--base';
|
||||
baseBadge.textContent = baseModel;
|
||||
baseBadge.title = baseModel;
|
||||
meta.appendChild(baseBadge);
|
||||
}
|
||||
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = `recipe-card__badge recipe-card__badge--${statusClass}`;
|
||||
|
||||
const statusIcon = document.createElement('i');
|
||||
statusIcon.className = 'fas fa-layer-group';
|
||||
statusIcon.setAttribute('aria-hidden', 'true');
|
||||
statusBadge.appendChild(statusIcon);
|
||||
|
||||
const statusText = document.createElement('span');
|
||||
statusText.textContent = statusLabel;
|
||||
statusBadge.appendChild(statusText);
|
||||
|
||||
statusBadge.title = getLoraStatusTitle(lorasCount, missingLorasCount);
|
||||
meta.appendChild(statusBadge);
|
||||
|
||||
body.appendChild(meta);
|
||||
|
||||
const cta = document.createElement('div');
|
||||
cta.className = 'recipe-card__cta';
|
||||
|
||||
const ctaText = document.createElement('span');
|
||||
ctaText.textContent = 'View details';
|
||||
|
||||
const ctaIcon = document.createElement('i');
|
||||
ctaIcon.className = 'fas fa-arrow-right';
|
||||
ctaIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
cta.append(ctaText, ctaIcon);
|
||||
body.appendChild(cta);
|
||||
|
||||
copyButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
copyRecipeSyntax(recipe.id);
|
||||
});
|
||||
|
||||
// Add click handler for the entire card
|
||||
|
||||
card.addEventListener('click', () => {
|
||||
navigateToRecipeDetails(recipe.id);
|
||||
});
|
||||
|
||||
// Add card to grid
|
||||
|
||||
card.addEventListener('keydown', (event) => {
|
||||
if (event.target !== card) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
navigateToRecipeDetails(recipe.id);
|
||||
}
|
||||
});
|
||||
|
||||
card.append(media, body);
|
||||
cardGrid.appendChild(card);
|
||||
});
|
||||
|
||||
@@ -226,4 +324,4 @@ function navigateToRecipeDetails(recipeId) {
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import {
|
||||
getStorageItem,
|
||||
setStorageItem
|
||||
setStorageItem,
|
||||
removeStorageItem
|
||||
} from '../utils/storageHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js'
|
||||
|
||||
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
|
||||
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
|
||||
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
|
||||
const COMMUNITY_SUPPORT_SHOWN_KEY = 'community_support_banner_shown';
|
||||
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
|
||||
// Increment this version to reset the banner schedule after significant updates
|
||||
const COMMUNITY_SUPPORT_STATE_VERSION = 'v2';
|
||||
const KO_FI_URL = 'https://ko-fi.com/pixelpawsai';
|
||||
const AFDIAN_URL = 'https://afdian.com/a/pixelpawsai';
|
||||
const BANNER_HISTORY_KEY = 'banner_history';
|
||||
const BANNER_HISTORY_VIEWED_AT_KEY = 'banner_history_viewed_at';
|
||||
const BANNER_HISTORY_LIMIT = 20;
|
||||
const HISTORY_EXCLUDED_IDS = new Set(['version-mismatch']);
|
||||
|
||||
/**
|
||||
* Banner Service for managing notification banners
|
||||
@@ -18,8 +27,10 @@ class BannerService {
|
||||
this.banners = new Map();
|
||||
this.container = null;
|
||||
this.initialized = false;
|
||||
this.communitySupportBannerTimer = null;
|
||||
this.communitySupportBannerRegistered = false;
|
||||
this.recentHistory = this.loadBannerHistory();
|
||||
this.bannerHistoryViewedAt = this.loadBannerHistoryViewedAt();
|
||||
|
||||
this.initializeCommunitySupportState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +111,9 @@ class BannerService {
|
||||
*/
|
||||
dismissBanner(bannerId) {
|
||||
const dismissedBanners = getStorageItem('dismissed_banners', []);
|
||||
if (!dismissedBanners.includes(bannerId)) {
|
||||
let bannerAlreadyDismissed = dismissedBanners.includes(bannerId);
|
||||
|
||||
if (!bannerAlreadyDismissed) {
|
||||
dismissedBanners.push(bannerId);
|
||||
setStorageItem('dismissed_banners', dismissedBanners);
|
||||
}
|
||||
@@ -120,6 +133,10 @@ class BannerService {
|
||||
this.updateContainerVisibility();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
if (!bannerAlreadyDismissed) {
|
||||
this.markBannerDismissed(bannerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +195,9 @@ class BannerService {
|
||||
`;
|
||||
|
||||
this.container.appendChild(bannerElement);
|
||||
|
||||
|
||||
this.recordBannerAppearance(banner);
|
||||
|
||||
// Call onRegister callback if provided
|
||||
if (typeof banner.onRegister === 'function') {
|
||||
banner.onRegister(bannerElement);
|
||||
@@ -214,12 +233,7 @@ class BannerService {
|
||||
}
|
||||
|
||||
prepareCommunitySupportBanner() {
|
||||
if (this.communitySupportBannerTimer) {
|
||||
clearTimeout(this.communitySupportBannerTimer);
|
||||
this.communitySupportBannerTimer = null;
|
||||
}
|
||||
|
||||
if (getStorageItem(COMMUNITY_SUPPORT_SHOWN_KEY, false)) {
|
||||
if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -232,29 +246,23 @@ class BannerService {
|
||||
}
|
||||
|
||||
const availableAt = firstSeenAt + COMMUNITY_SUPPORT_BANNER_DELAY_MS;
|
||||
const delay = Math.max(availableAt - now, 0);
|
||||
|
||||
if (delay === 0) {
|
||||
|
||||
if (now >= availableAt) {
|
||||
this.registerCommunitySupportBanner();
|
||||
} else {
|
||||
this.communitySupportBannerTimer = setTimeout(() => {
|
||||
this.registerCommunitySupportBanner();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
registerCommunitySupportBanner() {
|
||||
if (this.communitySupportBannerRegistered || getStorageItem(COMMUNITY_SUPPORT_SHOWN_KEY, false)) {
|
||||
if (this.isBannerDismissed(COMMUNITY_SUPPORT_BANNER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.communitySupportBannerTimer) {
|
||||
clearTimeout(this.communitySupportBannerTimer);
|
||||
this.communitySupportBannerTimer = null;
|
||||
}
|
||||
|
||||
this.communitySupportBannerRegistered = true;
|
||||
setStorageItem(COMMUNITY_SUPPORT_SHOWN_KEY, true);
|
||||
// Determine support URL based on user language
|
||||
const currentLanguage = state.global.settings.language;
|
||||
const supportUrl = currentLanguage === 'zh-CN' ? AFDIAN_URL : KO_FI_URL;
|
||||
const tutorialUrl = currentLanguage === 'zh-CN'
|
||||
? 'https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Lora-Manager-%E6%B5%8F%E8%A7%88%E5%99%A8%E6%8F%92%E4%BB%B6'
|
||||
: 'https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/LoRA-Manager-Civitai-Extension-(Chrome-Extension)';
|
||||
|
||||
this.registerBanner(COMMUNITY_SUPPORT_BANNER_ID, {
|
||||
id: COMMUNITY_SUPPORT_BANNER_ID,
|
||||
@@ -276,7 +284,7 @@ class BannerService {
|
||||
'Support on Ko-fi'
|
||||
),
|
||||
icon: 'fas fa-heart',
|
||||
url: KO_FI_URL,
|
||||
url: supportUrl,
|
||||
type: 'primary'
|
||||
},
|
||||
{
|
||||
@@ -286,7 +294,7 @@ class BannerService {
|
||||
'LM Civitai Extension Tutorial'
|
||||
),
|
||||
icon: 'fas fa-book',
|
||||
url: 'https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/LoRA-Manager-Civitai-Extension-(Chrome-Extension)',
|
||||
url: tutorialUrl,
|
||||
type: 'tertiary'
|
||||
}
|
||||
],
|
||||
@@ -296,6 +304,113 @@ class BannerService {
|
||||
|
||||
this.updateContainerVisibility();
|
||||
}
|
||||
|
||||
initializeCommunitySupportState() {
|
||||
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
|
||||
|
||||
if (storedVersion === COMMUNITY_SUPPORT_STATE_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, COMMUNITY_SUPPORT_STATE_VERSION);
|
||||
setStorageItem(COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY, Date.now());
|
||||
}
|
||||
|
||||
loadBannerHistory() {
|
||||
const stored = getStorageItem(BANNER_HISTORY_KEY, []);
|
||||
if (!Array.isArray(stored)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return stored.slice(0, BANNER_HISTORY_LIMIT).map(entry => ({
|
||||
...entry,
|
||||
timestamp: typeof entry.timestamp === 'number' ? entry.timestamp : Date.now(),
|
||||
dismissedAt: typeof entry.dismissedAt === 'number' ? entry.dismissedAt : null,
|
||||
actions: Array.isArray(entry.actions) ? entry.actions : []
|
||||
}));
|
||||
}
|
||||
|
||||
loadBannerHistoryViewedAt() {
|
||||
const stored = getStorageItem(BANNER_HISTORY_VIEWED_AT_KEY, 0);
|
||||
return typeof stored === 'number' ? stored : 0;
|
||||
}
|
||||
|
||||
saveBannerHistory() {
|
||||
setStorageItem(BANNER_HISTORY_KEY, this.recentHistory.slice(0, BANNER_HISTORY_LIMIT));
|
||||
}
|
||||
|
||||
notifyBannerHistoryUpdated() {
|
||||
window.dispatchEvent(new CustomEvent('lm:banner-history-updated'));
|
||||
}
|
||||
|
||||
recordBannerAppearance(banner) {
|
||||
if (!banner?.id || HISTORY_EXCLUDED_IDS.has(banner.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if banner with this ID already exists in recent history
|
||||
// Only record if it's not already in history (prevents duplicates on page refresh)
|
||||
const existingEntry = this.recentHistory.find(entry => entry.id === banner.id);
|
||||
if (existingEntry) {
|
||||
return; // Banner already exists in history, don't add again
|
||||
}
|
||||
|
||||
const sanitizedActions = Array.isArray(banner.actions)
|
||||
? banner.actions.map(action => ({
|
||||
text: action.text,
|
||||
icon: action.icon,
|
||||
url: action.url || null,
|
||||
type: action.type || 'secondary'
|
||||
}))
|
||||
: [];
|
||||
|
||||
const entry = {
|
||||
id: banner.id,
|
||||
title: banner.title,
|
||||
content: banner.content,
|
||||
actions: sanitizedActions,
|
||||
timestamp: Date.now(),
|
||||
dismissedAt: null
|
||||
};
|
||||
|
||||
this.recentHistory.unshift(entry);
|
||||
if (this.recentHistory.length > BANNER_HISTORY_LIMIT) {
|
||||
this.recentHistory.length = BANNER_HISTORY_LIMIT;
|
||||
}
|
||||
|
||||
this.saveBannerHistory();
|
||||
this.notifyBannerHistoryUpdated();
|
||||
}
|
||||
|
||||
markBannerDismissed(bannerId) {
|
||||
if (!bannerId || HISTORY_EXCLUDED_IDS.has(bannerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of this.recentHistory) {
|
||||
if (entry.id === bannerId && !entry.dismissedAt) {
|
||||
entry.dismissedAt = Date.now();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.saveBannerHistory();
|
||||
this.notifyBannerHistoryUpdated();
|
||||
}
|
||||
|
||||
getRecentBanners() {
|
||||
return this.recentHistory.slice();
|
||||
}
|
||||
|
||||
getUnreadBannerCount() {
|
||||
return this.recentHistory.filter(entry => entry.timestamp > this.bannerHistoryViewedAt).length;
|
||||
}
|
||||
|
||||
markBannerHistoryViewed() {
|
||||
this.bannerHistoryViewedAt = Date.now();
|
||||
setStorageItem(BANNER_HISTORY_VIEWED_AT_KEY, this.bannerHistoryViewedAt);
|
||||
this.notifyBannerHistoryUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
|
||||
@@ -34,6 +34,7 @@ export class BulkManager {
|
||||
sendToWorkflow: true,
|
||||
copyAll: true,
|
||||
refreshAll: true,
|
||||
checkUpdates: true,
|
||||
moveAll: true,
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
@@ -44,6 +45,7 @@ export class BulkManager {
|
||||
sendToWorkflow: false,
|
||||
copyAll: false,
|
||||
refreshAll: true,
|
||||
checkUpdates: true,
|
||||
moveAll: true,
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
@@ -54,6 +56,7 @@ export class BulkManager {
|
||||
sendToWorkflow: false,
|
||||
copyAll: false,
|
||||
refreshAll: true,
|
||||
checkUpdates: true,
|
||||
moveAll: false,
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
@@ -271,14 +274,9 @@ export class BulkManager {
|
||||
} else {
|
||||
card.classList.add('selected');
|
||||
state.selectedModels.add(filepath);
|
||||
|
||||
|
||||
// Cache the metadata for this model
|
||||
const metadataCache = this.getMetadataCache();
|
||||
metadataCache.set(filepath, {
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
modelName: card.dataset.name
|
||||
});
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
|
||||
// Update context menu header if visible
|
||||
@@ -290,7 +288,7 @@ export class BulkManager {
|
||||
getMetadataCache() {
|
||||
const currentType = state.currentPageType;
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
|
||||
// Initialize metadata cache if it doesn't exist
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
if (!state.loraMetadataCache) {
|
||||
@@ -305,6 +303,89 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
parseModelId(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
updateMetadataCacheFromCard(filepath, card) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataCache = this.getMetadataCache();
|
||||
const existing = metadataCache.get(filepath) || {};
|
||||
const modelId = this.parseModelId(card.dataset.modelId);
|
||||
|
||||
const updated = {
|
||||
...existing,
|
||||
fileName: card.dataset.file_name ?? existing.fileName,
|
||||
usageTips: card.dataset.usage_tips ?? existing.usageTips,
|
||||
modelName: card.dataset.name ?? existing.modelName,
|
||||
};
|
||||
|
||||
if (modelId !== null) {
|
||||
updated.modelId = modelId;
|
||||
}
|
||||
|
||||
metadataCache.set(filepath, updated);
|
||||
}
|
||||
|
||||
escapeAttributeValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
getModelIdForFilePath(filePath) {
|
||||
const metadataCache = this.getMetadataCache();
|
||||
const cached = metadataCache.get(filePath);
|
||||
if (cached && typeof cached.modelId === 'number') {
|
||||
return cached.modelId;
|
||||
}
|
||||
|
||||
const escapedPath = this.escapeAttributeValue(filePath);
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (!card) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.updateMetadataCacheFromCard(filePath, card);
|
||||
const updated = metadataCache.get(filePath);
|
||||
return updated && typeof updated.modelId === 'number' ? updated.modelId : null;
|
||||
}
|
||||
|
||||
collectSelectedModelIds() {
|
||||
const metadataCache = this.getMetadataCache();
|
||||
const ids = [];
|
||||
let missingCount = 0;
|
||||
|
||||
for (const filepath of state.selectedModels) {
|
||||
const cached = metadataCache.get(filepath);
|
||||
let modelId = cached && typeof cached.modelId === 'number' ? cached.modelId : null;
|
||||
if (modelId === null) {
|
||||
modelId = this.getModelIdForFilePath(filepath);
|
||||
}
|
||||
|
||||
if (typeof modelId === 'number') {
|
||||
ids.push(modelId);
|
||||
} else {
|
||||
missingCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
return { ids: uniqueIds, missingCount };
|
||||
}
|
||||
|
||||
applySelectionState() {
|
||||
if (!state.bulkMode) return;
|
||||
|
||||
@@ -312,13 +393,8 @@ export class BulkManager {
|
||||
const filepath = card.dataset.filepath;
|
||||
if (state.selectedModels.has(filepath)) {
|
||||
card.classList.add('selected');
|
||||
|
||||
const metadataCache = this.getMetadataCache();
|
||||
metadataCache.set(filepath, {
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
modelName: card.dataset.name
|
||||
});
|
||||
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
@@ -477,12 +553,14 @@ export class BulkManager {
|
||||
state.virtualScroller.items.forEach(item => {
|
||||
if (item && item.file_path) {
|
||||
state.selectedModels.add(item.file_path);
|
||||
|
||||
|
||||
if (!metadataCache.has(item.file_path)) {
|
||||
const modelId = this.parseModelId(item?.civitai?.modelId);
|
||||
metadataCache.set(item.file_path, {
|
||||
fileName: item.file_name,
|
||||
usageTips: item.usage_tips || '{}',
|
||||
modelName: item.name || item.file_name
|
||||
modelName: item.name || item.file_name,
|
||||
...(modelId !== null ? { modelId } : {})
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -521,12 +599,7 @@ export class BulkManager {
|
||||
if (metadata) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
if (card) {
|
||||
metadataCache.set(filepath, {
|
||||
...metadata,
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
modelName: card.dataset.name
|
||||
});
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,7 +614,71 @@ export class BulkManager {
|
||||
showToast('toast.models.refreshMetadataFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async checkUpdatesForSelectedModels() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentType = state.currentPageType;
|
||||
const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA];
|
||||
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
||||
|
||||
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
||||
|
||||
if (modelIds.length === 0) {
|
||||
showToast('toast.models.bulkUpdatesMissing', { type: typeLabel }, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (missingCount > 0) {
|
||||
showToast('toast.models.bulkUpdatesPartialMissing', { missing: missingCount, type: typeLabel }, 'info');
|
||||
}
|
||||
|
||||
const apiClient = getModelApiClient();
|
||||
if (!apiClient || typeof apiClient.refreshUpdatesForModels !== 'function') {
|
||||
console.warn('Model API client does not support refreshUpdatesForModels');
|
||||
showToast('toast.models.bulkUpdatesFailed', { type: typeLabel, message: 'Operation not supported' }, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingMessage = translate(
|
||||
'toast.models.bulkUpdatesChecking',
|
||||
{ count: state.selectedModels.size, type: typeLabel },
|
||||
`Checking selected ${typeLabel}(s) for updates...`
|
||||
);
|
||||
state.loadingManager?.showSimpleLoading?.(loadingMessage);
|
||||
|
||||
try {
|
||||
const response = await apiClient.refreshUpdatesForModels(modelIds);
|
||||
const records = Array.isArray(response?.records) ? response.records : [];
|
||||
const updatesCount = records.length;
|
||||
|
||||
if (updatesCount > 0) {
|
||||
showToast('toast.models.bulkUpdatesSuccess', { count: updatesCount, type: typeLabel }, 'success');
|
||||
} else {
|
||||
showToast('toast.models.bulkUpdatesNone', { type: typeLabel }, 'info');
|
||||
}
|
||||
|
||||
await resetAndReload(false);
|
||||
} catch (error) {
|
||||
console.error('Error checking updates for selected models:', error);
|
||||
showToast(
|
||||
'toast.models.bulkUpdatesFailed',
|
||||
{ type: typeLabel, message: error?.message ?? 'Unknown error' },
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
if (state.loadingManager?.hide) {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showBulkAddTagsModal() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
@@ -1263,15 +1400,11 @@ export class BulkManager {
|
||||
// Add to selection if intersecting
|
||||
newSelection.add(filepath);
|
||||
card.classList.add('selected');
|
||||
|
||||
|
||||
// Cache metadata if not already cached
|
||||
const metadataCache = this.getMetadataCache();
|
||||
if (!metadataCache.has(filepath)) {
|
||||
metadataCache.set(filepath, {
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
modelName: card.dataset.name
|
||||
});
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
} else if (!this.initialSelectedModels.has(filepath)) {
|
||||
// Remove from selection if not intersecting and wasn't initially selected
|
||||
|
||||
@@ -140,6 +140,14 @@ export class DownloadManager {
|
||||
this.loadDefaultPathSetting();
|
||||
}
|
||||
|
||||
async retrieveVersionsForModel(modelId, source = null) {
|
||||
this.versions = await this.apiClient.fetchCivitaiVersions(modelId, source);
|
||||
if (!this.versions || !this.versions.length) {
|
||||
throw new Error(translate('modals.download.errors.noVersions'));
|
||||
}
|
||||
return this.versions;
|
||||
}
|
||||
|
||||
async validateAndFetchVersions() {
|
||||
const url = document.getElementById('modelUrl').value.trim();
|
||||
const errorElement = document.getElementById('urlError');
|
||||
@@ -152,12 +160,8 @@ export class DownloadManager {
|
||||
throw new Error(translate('modals.download.errors.invalidUrl'));
|
||||
}
|
||||
|
||||
this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId, this.source);
|
||||
|
||||
if (!this.versions.length) {
|
||||
throw new Error(translate('modals.download.errors.noVersions'));
|
||||
}
|
||||
|
||||
await this.retrieveVersionsForModel(this.modelId, this.source);
|
||||
|
||||
// If we have a version ID from URL, pre-select it
|
||||
if (this.modelVersionId) {
|
||||
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
|
||||
@@ -171,6 +175,27 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchVersionsForCurrentModel() {
|
||||
const errorElement = document.getElementById('urlError');
|
||||
if (errorElement) {
|
||||
errorElement.textContent = '';
|
||||
}
|
||||
try {
|
||||
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
|
||||
await this.retrieveVersionsForModel(this.modelId, this.source);
|
||||
if (this.modelVersionId) {
|
||||
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
|
||||
}
|
||||
this.showVersionStep();
|
||||
} catch (error) {
|
||||
if (errorElement) {
|
||||
errorElement.textContent = error.message;
|
||||
}
|
||||
} finally {
|
||||
this.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
extractModelId(url) {
|
||||
const versionMatch = url.match(/modelVersionId=(\d+)/i);
|
||||
this.modelVersionId = versionMatch ? versionMatch[1] : null;
|
||||
@@ -191,6 +216,26 @@ export class DownloadManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
async openForModelVersion(modelType, modelId, versionId = null) {
|
||||
try {
|
||||
this.apiClient = getModelApiClient(modelType);
|
||||
} catch (error) {
|
||||
this.apiClient = getModelApiClient();
|
||||
}
|
||||
|
||||
this.showDownloadModal();
|
||||
|
||||
this.modelId = modelId ? modelId.toString() : null;
|
||||
this.modelVersionId = versionId ? versionId.toString() : null;
|
||||
this.source = null;
|
||||
|
||||
if (!this.modelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fetchVersionsForCurrentModel();
|
||||
}
|
||||
|
||||
showVersionStep() {
|
||||
document.getElementById('urlStep').style.display = 'none';
|
||||
document.getElementById('versionStep').style.display = 'block';
|
||||
@@ -383,6 +428,125 @@ export class DownloadManager {
|
||||
this.updateTargetPath();
|
||||
}
|
||||
|
||||
async executeDownloadWithProgress({
|
||||
modelId,
|
||||
versionId,
|
||||
versionName = '',
|
||||
modelRoot = '',
|
||||
targetFolder = '',
|
||||
useDefaultPaths = false,
|
||||
source = null,
|
||||
closeModal = false,
|
||||
}) {
|
||||
const config = this.apiClient?.apiConfig?.config;
|
||||
|
||||
if (!this.apiClient || !config) {
|
||||
throw new Error('Download manager is not initialized with an API client');
|
||||
}
|
||||
|
||||
const displayName = versionName || `#${versionId}`;
|
||||
let ws = null;
|
||||
let updateProgress = () => {};
|
||||
|
||||
try {
|
||||
this.loadingManager.restoreProgressBar();
|
||||
updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||
updateProgress(0, 0, displayName);
|
||||
|
||||
const downloadId = Date.now().toString();
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
|
||||
ws.onmessage = event => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'download_id') {
|
||||
console.log(`Connected to download progress with ID: ${data.download_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'progress' && data.download_id === downloadId) {
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
totalBytes: data.total_bytes,
|
||||
bytesPerSecond: data.bytes_per_second,
|
||||
};
|
||||
|
||||
updateProgress(data.progress, 0, displayName, metrics);
|
||||
|
||||
if (data.progress < 3) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.preparing'));
|
||||
} else if (data.progress === 3) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.downloadedPreview'));
|
||||
} else if (data.progress > 3 && data.progress < 100) {
|
||||
this.loadingManager.setStatus(
|
||||
translate('modals.download.status.downloadingFile', { type: config.singularName })
|
||||
);
|
||||
} else {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = error => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
await this.apiClient.downloadModel(
|
||||
modelId,
|
||||
versionId,
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
downloadId,
|
||||
source
|
||||
);
|
||||
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
|
||||
if (closeModal) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
}
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
|
||||
const pageState = this.apiClient.getPageState();
|
||||
|
||||
if (!useDefaultPaths && targetFolder) {
|
||||
pageState.activeFolder = targetFolder;
|
||||
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
||||
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
const isActive = tag.dataset.folder === targetFolder;
|
||||
tag.classList.toggle('active', isActive);
|
||||
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
||||
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to download model version:', error);
|
||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
ws.close();
|
||||
}
|
||||
} catch (closeError) {
|
||||
console.debug('Failed to close download progress socket:', closeError);
|
||||
}
|
||||
this.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
updatePathSelectionUI() {
|
||||
const manualSelection = document.getElementById('manualPathSelection');
|
||||
|
||||
@@ -444,92 +608,38 @@ export class DownloadManager {
|
||||
} else {
|
||||
targetFolder = this.folderTreeManager.getSelectedPath();
|
||||
}
|
||||
return this.executeDownloadWithProgress({
|
||||
modelId: this.modelId,
|
||||
versionId: this.currentVersion.id,
|
||||
versionName: this.currentVersion.name,
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
source: this.source,
|
||||
closeModal: true,
|
||||
});
|
||||
}
|
||||
|
||||
async downloadVersionWithDefaults(modelType, modelId, versionId, { versionName = '', source = null } = {}) {
|
||||
try {
|
||||
const updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||
updateProgress(0, 0, this.currentVersion.name);
|
||||
|
||||
const downloadId = Date.now().toString();
|
||||
|
||||
// Setup WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'download_id') {
|
||||
console.log(`Connected to download progress with ID: ${data.download_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'progress' && data.download_id === downloadId) {
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
totalBytes: data.total_bytes,
|
||||
bytesPerSecond: data.bytes_per_second
|
||||
};
|
||||
|
||||
updateProgress(data.progress, 0, this.currentVersion.name, metrics);
|
||||
|
||||
if (data.progress < 3) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.preparing'));
|
||||
} else if (data.progress === 3) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.downloadedPreview'));
|
||||
} else if (data.progress > 3 && data.progress < 100) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.downloadingFile', { type: config.singularName }));
|
||||
} else {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
// Start download with use_default_paths parameter
|
||||
await this.apiClient.downloadModel(
|
||||
this.modelId,
|
||||
this.currentVersion.id,
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
downloadId,
|
||||
this.source
|
||||
);
|
||||
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
modalManager.closeModal('downloadModal');
|
||||
|
||||
ws.close();
|
||||
|
||||
// Update state and trigger reload
|
||||
const pageState = this.apiClient.getPageState();
|
||||
|
||||
if (!useDefaultPaths) {
|
||||
pageState.activeFolder = targetFolder;
|
||||
|
||||
// Save the active folder preference
|
||||
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
||||
|
||||
// Update UI folder selection
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
const isActive = tag.dataset.folder === targetFolder;
|
||||
tag.classList.toggle('active', isActive);
|
||||
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
||||
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
|
||||
this.apiClient = getModelApiClient(modelType);
|
||||
} catch (error) {
|
||||
showToast('toast.downloads.downloadError', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this.loadingManager.hide();
|
||||
this.apiClient = getModelApiClient();
|
||||
}
|
||||
|
||||
this.modelId = modelId ? modelId.toString() : null;
|
||||
this.source = source;
|
||||
|
||||
return this.executeDownloadWithProgress({
|
||||
modelId,
|
||||
versionId,
|
||||
versionName,
|
||||
modelRoot: '',
|
||||
targetFolder: '',
|
||||
useDefaultPaths: true,
|
||||
source,
|
||||
closeModal: false,
|
||||
});
|
||||
}
|
||||
|
||||
async initializeFolderTree() {
|
||||
|
||||
@@ -312,7 +312,11 @@ export class FilterManager {
|
||||
removeStorageItem(storageKey);
|
||||
|
||||
// Update UI
|
||||
this.filterButton.classList.remove('active');
|
||||
if (this.hasActiveFilters()) {
|
||||
this.filterButton.classList.add('active');
|
||||
} else {
|
||||
this.filterButton.classList.remove('active');
|
||||
}
|
||||
|
||||
// Reload data using the appropriate method for the current page
|
||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||
|
||||
@@ -5,8 +5,40 @@ import { formatFileSize } from '../utils/formatters.js';
|
||||
export class LoadingManager {
|
||||
constructor() {
|
||||
this.overlay = document.getElementById('loading-overlay');
|
||||
this.progressBar = this.overlay.querySelector('.progress-bar');
|
||||
this.statusText = this.overlay.querySelector('.loading-status');
|
||||
|
||||
if (!this.overlay) {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.id = 'loading-overlay';
|
||||
this.overlay.style.display = 'none';
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
this.loadingContent = this.overlay.querySelector('.loading-content');
|
||||
if (!this.loadingContent) {
|
||||
this.loadingContent = document.createElement('div');
|
||||
this.loadingContent.className = 'loading-content';
|
||||
this.overlay.appendChild(this.loadingContent);
|
||||
}
|
||||
|
||||
this.progressBar = this.loadingContent.querySelector('.progress-bar');
|
||||
if (!this.progressBar) {
|
||||
this.progressBar = document.createElement('div');
|
||||
this.progressBar.className = 'progress-bar';
|
||||
this.progressBar.setAttribute('role', 'progressbar');
|
||||
this.progressBar.setAttribute('aria-valuemin', '0');
|
||||
this.progressBar.setAttribute('aria-valuemax', '100');
|
||||
this.progressBar.setAttribute('aria-valuenow', '0');
|
||||
this.progressBar.style.width = '0%';
|
||||
this.loadingContent.appendChild(this.progressBar);
|
||||
}
|
||||
|
||||
this.statusText = this.loadingContent.querySelector('.loading-status');
|
||||
if (!this.statusText) {
|
||||
this.statusText = document.createElement('div');
|
||||
this.statusText.className = 'loading-status';
|
||||
this.loadingContent.appendChild(this.statusText);
|
||||
}
|
||||
|
||||
this.detailsContainer = null; // Will be created when needed
|
||||
}
|
||||
|
||||
@@ -38,6 +70,7 @@ export class LoadingManager {
|
||||
this.setProgress(0);
|
||||
this.setStatus('');
|
||||
this.removeDetailsContainer();
|
||||
this.progressBar.style.display = 'block';
|
||||
}
|
||||
|
||||
// Create a details container for enhanced progress display
|
||||
@@ -50,9 +83,8 @@ export class LoadingManager {
|
||||
this.detailsContainer.className = 'progress-details-container';
|
||||
|
||||
// Insert after the main progress bar
|
||||
const loadingContent = this.overlay.querySelector('.loading-content');
|
||||
if (loadingContent) {
|
||||
loadingContent.appendChild(this.detailsContainer);
|
||||
if (this.loadingContent) {
|
||||
this.loadingContent.appendChild(this.detailsContainer);
|
||||
}
|
||||
|
||||
return this.detailsContainer;
|
||||
@@ -69,6 +101,7 @@ export class LoadingManager {
|
||||
// Show enhanced progress for downloads
|
||||
showDownloadProgress(totalItems = 1) {
|
||||
this.show(translate('modals.download.status.preparing', {}, 'Preparing download...'), 0);
|
||||
this.progressBar.style.display = 'none';
|
||||
|
||||
// Create details container
|
||||
const detailsContainer = this.createDetailsContainer();
|
||||
|
||||
@@ -195,6 +195,18 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add checkUpdatesConfirmModal registration
|
||||
const checkUpdatesConfirmModal = document.getElementById('checkUpdatesConfirmModal');
|
||||
if (checkUpdatesConfirmModal) {
|
||||
this.registerModal('checkUpdatesConfirmModal', {
|
||||
element: checkUpdatesConfirmModal,
|
||||
onClose: () => {
|
||||
this.getModal('checkUpdatesConfirmModal').element.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add helpModal registration
|
||||
const helpModal = document.getElementById('helpModal');
|
||||
if (helpModal) {
|
||||
@@ -339,7 +351,8 @@ export class ModalManager {
|
||||
id === "duplicateDeleteModal" ||
|
||||
id === "modelDuplicateDeleteModal" ||
|
||||
id === "clearCacheModal" ||
|
||||
id === "bulkDeleteModal"
|
||||
id === "bulkDeleteModal" ||
|
||||
id === "checkUpdatesConfirmModal"
|
||||
) {
|
||||
modal.element.classList.add("show");
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { translate } from '../utils/i18nHelpers.js';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
||||
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { sidebarManager } from '../components/SidebarManager.js';
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
@@ -15,6 +17,8 @@ export class SettingsManager {
|
||||
this.initializationPromise = null;
|
||||
this.availableLibraries = {};
|
||||
this.activeLibrary = '';
|
||||
this.settingsFilePath = null;
|
||||
this.registeredStartupBannerIds = new Set();
|
||||
|
||||
// Add initialization to sync with modal state
|
||||
this.currentPage = document.body.dataset.page || 'loras';
|
||||
@@ -52,14 +56,18 @@ export class SettingsManager {
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
state.global.settings = this.mergeSettingsWithDefaults(data.settings);
|
||||
this.settingsFilePath = data.settings.settings_file || this.settingsFilePath;
|
||||
this.registerStartupMessages(data.messages);
|
||||
console.log('Settings synced from backend');
|
||||
} else {
|
||||
console.error('Failed to sync settings from backend:', data.error);
|
||||
state.global.settings = this.mergeSettingsWithDefaults();
|
||||
this.registerStartupMessages(data?.messages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync settings from backend:', error);
|
||||
state.global.settings = this.mergeSettingsWithDefaults();
|
||||
this.registerStartupMessages();
|
||||
}
|
||||
|
||||
await this.applyLanguageSetting();
|
||||
@@ -128,6 +136,90 @@ export class SettingsManager {
|
||||
return merged;
|
||||
}
|
||||
|
||||
registerStartupMessages(messages = []) {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const severityPriority = {
|
||||
error: 90,
|
||||
warning: 60,
|
||||
info: 30,
|
||||
};
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.settingsFilePath && typeof message.settings_file === 'string') {
|
||||
this.settingsFilePath = message.settings_file;
|
||||
}
|
||||
|
||||
const bannerId = `startup-${message.code || index}`;
|
||||
if (this.registeredStartupBannerIds.has(bannerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const severity = (message.severity || 'info').toLowerCase();
|
||||
const bannerTitle = message.title || 'Configuration notice';
|
||||
const bannerContent = message.message || message.content || '';
|
||||
const priority = typeof message.priority === 'number'
|
||||
? message.priority
|
||||
: severityPriority[severity] || severityPriority.info;
|
||||
const dismissible = message.dismissible !== false;
|
||||
|
||||
const normalizedActions = Array.isArray(message.actions)
|
||||
? message.actions.map(action => ({
|
||||
text: action.label || action.text || 'Review settings',
|
||||
icon: action.icon || 'fas fa-cog',
|
||||
action: action.action,
|
||||
type: action.type || 'primary',
|
||||
url: action.url,
|
||||
}))
|
||||
: [];
|
||||
|
||||
bannerService.registerBanner(bannerId, {
|
||||
id: bannerId,
|
||||
title: bannerTitle,
|
||||
content: bannerContent,
|
||||
actions: normalizedActions,
|
||||
dismissible,
|
||||
priority,
|
||||
onRegister: (bannerElement) => {
|
||||
normalizedActions.forEach(action => {
|
||||
if (!action.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = bannerElement.querySelector(`.banner-action[data-action="${action.action}"]`);
|
||||
if (button) {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
this.handleStartupBannerAction(action.action);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this.registeredStartupBannerIds.add(bannerId);
|
||||
});
|
||||
}
|
||||
|
||||
handleStartupBannerAction(action) {
|
||||
switch (action) {
|
||||
case 'open-settings-modal':
|
||||
modalManager.showModal('settingsModal');
|
||||
break;
|
||||
case 'open-settings-location':
|
||||
this.openSettingsFileLocation();
|
||||
break;
|
||||
default:
|
||||
console.warn('Unhandled startup banner action:', action);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to determine if a setting should be saved to backend
|
||||
isBackendSetting(settingKey) {
|
||||
return this.backendSettingKeys.has(settingKey);
|
||||
@@ -199,6 +291,9 @@ export class SettingsManager {
|
||||
|
||||
const openSettingsLocationButton = document.querySelector('.settings-open-location-trigger');
|
||||
if (openSettingsLocationButton) {
|
||||
if (openSettingsLocationButton.dataset.settingsPath) {
|
||||
this.settingsFilePath = openSettingsLocationButton.dataset.settingsPath;
|
||||
}
|
||||
openSettingsLocationButton.addEventListener('click', () => {
|
||||
const filePath = openSettingsLocationButton.dataset.settingsPath;
|
||||
this.openSettingsFileLocation(filePath);
|
||||
@@ -235,7 +330,9 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
async openSettingsFileLocation(filePath) {
|
||||
if (!filePath) {
|
||||
const targetPath = filePath || this.settingsFilePath || document.querySelector('.settings-open-location-trigger')?.dataset.settingsPath;
|
||||
|
||||
if (!targetPath) {
|
||||
showToast('settings.openSettingsFileLocation.failed', {}, 'error');
|
||||
return;
|
||||
}
|
||||
@@ -246,13 +343,15 @@ export class SettingsManager {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
body: JSON.stringify({ file_path: targetPath }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
this.settingsFilePath = targetPath;
|
||||
|
||||
showToast('settings.openSettingsFileLocation.success', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to open settings file location:', error);
|
||||
@@ -290,6 +389,18 @@ export class SettingsManager {
|
||||
cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always';
|
||||
}
|
||||
|
||||
const showFolderSidebarCheckbox = document.getElementById('showFolderSidebar');
|
||||
if (showFolderSidebarCheckbox) {
|
||||
const showSidebarSetting = state.global.settings.show_folder_sidebar;
|
||||
showFolderSidebarCheckbox.checked = showSidebarSetting !== false;
|
||||
}
|
||||
|
||||
// Set model card footer action
|
||||
const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction');
|
||||
if (modelCardFooterActionSelect) {
|
||||
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
|
||||
}
|
||||
|
||||
// Set model name display setting
|
||||
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
||||
if (modelNameDisplaySelect) {
|
||||
@@ -1221,6 +1332,10 @@ export class SettingsManager {
|
||||
if (settingKey === 'model_name_display') {
|
||||
this.reloadContent();
|
||||
}
|
||||
|
||||
if (settingKey === 'model_card_footer_action') {
|
||||
this.reloadContent();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
@@ -1591,6 +1706,13 @@ export class SettingsManager {
|
||||
// Apply card info display setting
|
||||
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
||||
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
||||
|
||||
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
|
||||
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
|
||||
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
||||
console.error('Failed to apply sidebar visibility setting:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ export class UpdateService {
|
||||
this.nightlyMode = getStorageItem('nightly_updates', false);
|
||||
this.currentVersionInfo = null;
|
||||
this.versionMismatch = false;
|
||||
this.activeNotificationTab = 'updates';
|
||||
this.handleBannerHistoryUpdated = this.handleBannerHistoryUpdated.bind(this);
|
||||
this.handleNotificationTabKeydown = this.handleNotificationTabKeydown.bind(this);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
@@ -61,6 +64,10 @@ export class UpdateService {
|
||||
});
|
||||
this.updateNightlyWarning();
|
||||
}
|
||||
|
||||
this.setupNotificationCenter();
|
||||
window.addEventListener('lm:banner-history-updated', this.handleBannerHistoryUpdated);
|
||||
this.updateTabBadges();
|
||||
|
||||
// Perform update check if needed
|
||||
this.checkForUpdates().then(() => {
|
||||
@@ -81,6 +88,272 @@ export class UpdateService {
|
||||
warning.style.display = this.nightlyMode ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
setupNotificationCenter() {
|
||||
const modal = document.getElementById('updateModal');
|
||||
if (!modal) {
|
||||
this.notificationTabs = [];
|
||||
this.notificationPanels = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.notificationTabs = Array.from(modal.querySelectorAll('[data-notification-tab]'));
|
||||
this.notificationPanels = Array.from(modal.querySelectorAll('[data-notification-panel]'));
|
||||
|
||||
this.notificationTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const tabName = tab.getAttribute('data-notification-tab');
|
||||
this.switchNotificationTab(tabName, { markRead: true });
|
||||
});
|
||||
tab.addEventListener('keydown', this.handleNotificationTabKeydown);
|
||||
});
|
||||
|
||||
this.renderRecentBanners();
|
||||
this.switchNotificationTab(this.activeNotificationTab);
|
||||
}
|
||||
|
||||
switchNotificationTab(tabName, { markRead = false } = {}) {
|
||||
if (!tabName) return;
|
||||
|
||||
this.activeNotificationTab = tabName;
|
||||
|
||||
if (Array.isArray(this.notificationTabs)) {
|
||||
this.notificationTabs.forEach(tab => {
|
||||
const isActive = tab.getAttribute('data-notification-tab') === tabName;
|
||||
tab.classList.toggle('active', isActive);
|
||||
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||
tab.setAttribute('tabindex', isActive ? '0' : '-1');
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(this.notificationPanels)) {
|
||||
this.notificationPanels.forEach(panel => {
|
||||
const isActive = panel.getAttribute('data-notification-panel') === tabName;
|
||||
panel.classList.toggle('active', isActive);
|
||||
panel.setAttribute('aria-hidden', isActive ? 'false' : 'true');
|
||||
panel.setAttribute('tabindex', isActive ? '0' : '-1');
|
||||
});
|
||||
}
|
||||
|
||||
if (tabName === 'banners') {
|
||||
this.renderRecentBanners();
|
||||
if (markRead && typeof bannerService.markBannerHistoryViewed === 'function') {
|
||||
bannerService.markBannerHistoryViewed();
|
||||
}
|
||||
}
|
||||
|
||||
this.updateTabBadges();
|
||||
}
|
||||
|
||||
handleNotificationTabKeydown(event) {
|
||||
if (!Array.isArray(this.notificationTabs) || this.notificationTabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { key } = event;
|
||||
const supportedKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
||||
|
||||
if (!supportedKeys.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const currentIndex = this.notificationTabs.indexOf(event.currentTarget);
|
||||
if (currentIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetIndex = currentIndex;
|
||||
|
||||
if (key === 'ArrowLeft' || key === 'ArrowUp') {
|
||||
targetIndex = (currentIndex - 1 + this.notificationTabs.length) % this.notificationTabs.length;
|
||||
} else if (key === 'ArrowRight' || key === 'ArrowDown') {
|
||||
targetIndex = (currentIndex + 1) % this.notificationTabs.length;
|
||||
} else if (key === 'Home') {
|
||||
targetIndex = 0;
|
||||
} else if (key === 'End') {
|
||||
targetIndex = this.notificationTabs.length - 1;
|
||||
}
|
||||
|
||||
const nextTab = this.notificationTabs[targetIndex];
|
||||
if (!nextTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabName = nextTab.getAttribute('data-notification-tab');
|
||||
nextTab.focus();
|
||||
this.switchNotificationTab(tabName, { markRead: true });
|
||||
}
|
||||
|
||||
isNotificationModalOpen() {
|
||||
const updateModal = modalManager.getModal('updateModal');
|
||||
return !!(updateModal && updateModal.isOpen);
|
||||
}
|
||||
|
||||
handleBannerHistoryUpdated() {
|
||||
this.updateBadgeVisibility();
|
||||
|
||||
if (this.isNotificationModalOpen() && this.activeNotificationTab === 'banners') {
|
||||
this.renderRecentBanners();
|
||||
}
|
||||
}
|
||||
|
||||
updateTabBadges() {
|
||||
const updatesBadge = document.getElementById('updatesTabBadge');
|
||||
const bannerBadge = document.getElementById('bannerTabBadge');
|
||||
const hasUpdate = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function'
|
||||
? bannerService.getUnreadBannerCount()
|
||||
: 0;
|
||||
|
||||
if (updatesBadge) {
|
||||
updatesBadge.classList.toggle('visible', hasUpdate);
|
||||
updatesBadge.classList.toggle('is-dot', hasUpdate);
|
||||
updatesBadge.textContent = '';
|
||||
}
|
||||
|
||||
if (bannerBadge) {
|
||||
if (unreadBanners > 0) {
|
||||
bannerBadge.textContent = unreadBanners > 9 ? '9+' : unreadBanners.toString();
|
||||
} else {
|
||||
bannerBadge.textContent = '';
|
||||
}
|
||||
bannerBadge.classList.toggle('visible', unreadBanners > 0);
|
||||
bannerBadge.classList.remove('is-dot');
|
||||
}
|
||||
}
|
||||
|
||||
renderRecentBanners() {
|
||||
const list = document.getElementById('bannerHistoryList');
|
||||
const emptyState = document.getElementById('bannerHistoryEmpty');
|
||||
|
||||
if (!list || !emptyState) return;
|
||||
|
||||
const banners = typeof bannerService.getRecentBanners === 'function'
|
||||
? bannerService.getRecentBanners()
|
||||
: [];
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!banners.length) {
|
||||
emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
banners.forEach(banner => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'banner-history-item';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.className = 'banner-history-title';
|
||||
title.textContent = banner.title || translate('update.banners.recent', {}, 'Recent banners');
|
||||
item.appendChild(title);
|
||||
|
||||
if (banner.content) {
|
||||
const description = document.createElement('p');
|
||||
description.className = 'banner-history-description';
|
||||
description.textContent = banner.content;
|
||||
item.appendChild(description);
|
||||
}
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'banner-history-meta';
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = 'banner-history-status';
|
||||
if (banner.dismissedAt) {
|
||||
status.classList.add('dismissed');
|
||||
const dismissedRelative = this.formatRelativeTime(banner.dismissedAt);
|
||||
status.textContent = translate('update.banners.dismissed', {
|
||||
time: dismissedRelative
|
||||
}, `Dismissed ${dismissedRelative}`);
|
||||
} else {
|
||||
status.classList.add('active');
|
||||
status.textContent = translate('update.banners.active', {}, 'Active');
|
||||
}
|
||||
meta.appendChild(status);
|
||||
|
||||
const shownRelative = this.formatRelativeTime(banner.timestamp);
|
||||
const timestamp = document.createElement('span');
|
||||
timestamp.className = 'banner-history-time';
|
||||
timestamp.textContent = translate('update.banners.shown', {
|
||||
time: shownRelative
|
||||
}, `Shown ${shownRelative}`);
|
||||
meta.appendChild(timestamp);
|
||||
|
||||
item.appendChild(meta);
|
||||
|
||||
if (Array.isArray(banner.actions) && banner.actions.length > 0) {
|
||||
const actionsContainer = document.createElement('div');
|
||||
actionsContainer.className = 'banner-history-actions';
|
||||
|
||||
banner.actions.forEach(action => {
|
||||
if (!action?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.className = `banner-history-action banner-history-action-${action.type || 'secondary'}`;
|
||||
link.href = action.url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.textContent = action.text || action.url;
|
||||
|
||||
if (action.icon) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = action.icon;
|
||||
link.prepend(icon);
|
||||
}
|
||||
|
||||
actionsContainer.appendChild(link);
|
||||
});
|
||||
|
||||
if (actionsContainer.children.length > 0) {
|
||||
item.appendChild(actionsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
formatRelativeTime(timestamp) {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const locale = window?.i18n?.getCurrentLocale?.() || navigator.language || 'en';
|
||||
|
||||
try {
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
const divisions = [
|
||||
{ amount: 60, unit: 'second' },
|
||||
{ amount: 60, unit: 'minute' },
|
||||
{ amount: 24, unit: 'hour' },
|
||||
{ amount: 7, unit: 'day' },
|
||||
{ amount: 4.34524, unit: 'week' },
|
||||
{ amount: 12, unit: 'month' },
|
||||
{ amount: Infinity, unit: 'year' }
|
||||
];
|
||||
|
||||
let duration = (timestamp - Date.now()) / 1000;
|
||||
|
||||
for (const division of divisions) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return formatter.format(Math.round(duration), division.unit);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
|
||||
return formatter.format(Math.round(duration), 'year');
|
||||
} catch (error) {
|
||||
console.warn('RelativeTimeFormat not available, falling back to locale string.', error);
|
||||
return new Date(timestamp).toLocaleString(locale);
|
||||
}
|
||||
}
|
||||
|
||||
async checkForUpdates({ force = false } = {}) {
|
||||
if (!force && !this.updateNotificationsEnabled) {
|
||||
@@ -167,20 +440,29 @@ export class UpdateService {
|
||||
updateBadgeVisibility() {
|
||||
const updateToggle = document.querySelector('.update-toggle');
|
||||
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
||||
|
||||
const unreadBanners = typeof bannerService.getUnreadBannerCount === 'function'
|
||||
? bannerService.getUnreadBannerCount()
|
||||
: 0;
|
||||
|
||||
if (updateToggle) {
|
||||
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
|
||||
? translate('update.updateAvailable')
|
||||
: translate('update.title');
|
||||
let tooltipKey = 'header.actions.notifications';
|
||||
if (this.updateNotificationsEnabled && this.updateAvailable) {
|
||||
tooltipKey = 'update.updateAvailable';
|
||||
} else if (unreadBanners > 0) {
|
||||
tooltipKey = 'update.tabs.messages';
|
||||
}
|
||||
updateToggle.title = translate(tooltipKey);
|
||||
}
|
||||
|
||||
|
||||
// Force updating badges visibility based on current state
|
||||
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
|
||||
const shouldShowUpdate = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
const shouldShow = shouldShowUpdate || unreadBanners > 0;
|
||||
|
||||
if (updateBadge) {
|
||||
updateBadge.classList.toggle('visible', shouldShow);
|
||||
console.log("Update badge visibility:", shouldShow ? "visible" : "hidden");
|
||||
}
|
||||
|
||||
this.updateTabBadges();
|
||||
}
|
||||
|
||||
updateModalContent() {
|
||||
@@ -190,9 +472,9 @@ export class UpdateService {
|
||||
// Update title based on update availability
|
||||
const headerTitle = modal.querySelector('.update-header h2');
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = this.updateAvailable ?
|
||||
translate('update.updateAvailable') :
|
||||
translate('update.title');
|
||||
headerTitle.textContent = this.updateAvailable ?
|
||||
translate('update.updateAvailable') :
|
||||
translate('update.notificationsTitle');
|
||||
}
|
||||
|
||||
// Always update version information, even if updateInfo is null
|
||||
@@ -418,23 +700,32 @@ export class UpdateService {
|
||||
|
||||
toggleUpdateModal() {
|
||||
const updateModal = modalManager.getModal('updateModal');
|
||||
|
||||
|
||||
// If modal is already open, just close it
|
||||
if (updateModal && updateModal.isOpen) {
|
||||
modalManager.closeModal('updateModal');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!Array.isArray(this.notificationTabs) || !this.notificationTabs.length) {
|
||||
this.setupNotificationCenter();
|
||||
}
|
||||
|
||||
// Update the modal content immediately with current data
|
||||
this.updateModalContent();
|
||||
|
||||
this.renderRecentBanners();
|
||||
|
||||
// Show the modal with current data
|
||||
modalManager.showModal('updateModal');
|
||||
|
||||
this.switchNotificationTab(this.activeNotificationTab, { markRead: true });
|
||||
|
||||
// Then check for updates in the background
|
||||
this.manualCheckForUpdates().then(() => {
|
||||
// Update the modal content again after the check completes
|
||||
this.updateModalContent();
|
||||
if (this.activeNotificationTab === 'banners' && this.isNotificationModalOpen()) {
|
||||
this.renderRecentBanners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
autoplay_on_hover: false,
|
||||
display_density: 'default',
|
||||
card_info_display: 'always',
|
||||
show_folder_sidebar: true,
|
||||
model_name_display: 'model_name',
|
||||
model_card_footer_action: 'example_images',
|
||||
include_trigger_words: false,
|
||||
compact_mode: false,
|
||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||
@@ -80,6 +82,7 @@ export const state = {
|
||||
selectedLoras: new Set(),
|
||||
loraMetadataCache: new Map(),
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
},
|
||||
|
||||
@@ -130,6 +133,7 @@ export const state = {
|
||||
selectedModels: new Set(),
|
||||
metadataCache: new Map(),
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
},
|
||||
|
||||
@@ -157,6 +161,7 @@ export const state = {
|
||||
selectedModels: new Set(),
|
||||
metadataCache: new Map(),
|
||||
showFavoritesOnly: false,
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -55,6 +55,116 @@ export const BASE_MODELS = {
|
||||
UNKNOWN: "Other"
|
||||
};
|
||||
|
||||
export const BASE_MODEL_ABBREVIATIONS = {
|
||||
// Stable Diffusion 1.x models
|
||||
[BASE_MODELS.SD_1_4]: 'SD1',
|
||||
[BASE_MODELS.SD_1_5]: 'SD1',
|
||||
[BASE_MODELS.SD_1_5_LCM]: 'SD1',
|
||||
[BASE_MODELS.SD_1_5_HYPER]: 'SD1',
|
||||
|
||||
// Stable Diffusion 2.x models
|
||||
[BASE_MODELS.SD_2_0]: 'SD2',
|
||||
[BASE_MODELS.SD_2_1]: 'SD2',
|
||||
|
||||
// Stable Diffusion 3.x models
|
||||
[BASE_MODELS.SD_3]: 'SD3',
|
||||
[BASE_MODELS.SD_3_5]: 'SD3',
|
||||
[BASE_MODELS.SD_3_5_MEDIUM]: 'SD3',
|
||||
[BASE_MODELS.SD_3_5_LARGE]: 'SD3',
|
||||
[BASE_MODELS.SD_3_5_LARGE_TURBO]: 'SD3',
|
||||
|
||||
// SDXL models
|
||||
[BASE_MODELS.SDXL]: 'XL',
|
||||
[BASE_MODELS.SDXL_LIGHTNING]: 'XL',
|
||||
[BASE_MODELS.SDXL_HYPER]: 'XL',
|
||||
|
||||
// Flux models
|
||||
[BASE_MODELS.FLUX_1_D]: 'F1D',
|
||||
[BASE_MODELS.FLUX_1_S]: 'F1S',
|
||||
[BASE_MODELS.FLUX_1_KREA]: 'F1KR',
|
||||
[BASE_MODELS.FLUX_1_KONTEXT]: 'F1KX',
|
||||
|
||||
// Other diffusion models
|
||||
[BASE_MODELS.AURAFLOW]: 'AF',
|
||||
[BASE_MODELS.CHROMA]: 'CHR',
|
||||
[BASE_MODELS.PIXART_A]: 'PXA',
|
||||
[BASE_MODELS.PIXART_E]: 'PXE',
|
||||
[BASE_MODELS.HUNYUAN_1]: 'HY',
|
||||
[BASE_MODELS.LUMINA]: 'L',
|
||||
[BASE_MODELS.KOLORS]: 'KLR',
|
||||
[BASE_MODELS.NOOBAI]: 'NAI',
|
||||
[BASE_MODELS.ILLUSTRIOUS]: 'IL',
|
||||
[BASE_MODELS.PONY]: 'PONY',
|
||||
[BASE_MODELS.HIDREAM]: 'HID',
|
||||
[BASE_MODELS.QWEN]: 'QWEN',
|
||||
|
||||
// Video models
|
||||
[BASE_MODELS.SVD]: 'SVD',
|
||||
[BASE_MODELS.LTXV]: 'LTXV',
|
||||
[BASE_MODELS.WAN_VIDEO]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_1_3B_T2V]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_14B_T2V]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_14B_I2V_480P]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_14B_I2V_720P]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B]: 'WAN',
|
||||
[BASE_MODELS.HUNYUAN_VIDEO]: 'HYV',
|
||||
|
||||
// Default
|
||||
[BASE_MODELS.UNKNOWN]: 'OTH'
|
||||
};
|
||||
|
||||
const ABBREVIATION_MAX_LENGTH = 4;
|
||||
|
||||
const NORMALIZED_BASE_MODEL_ABBREVIATIONS = Object.entries(BASE_MODEL_ABBREVIATIONS).reduce((accumulator, [name, abbreviation]) => {
|
||||
if (typeof name === 'string') {
|
||||
accumulator[name.toLowerCase()] = abbreviation;
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
function buildFallbackAbbreviation(baseModel) {
|
||||
if (!baseModel || typeof baseModel !== 'string') {
|
||||
return BASE_MODEL_ABBREVIATIONS[BASE_MODELS.UNKNOWN];
|
||||
}
|
||||
|
||||
const tokens = baseModel.split(/[\s_-]+/).filter(Boolean);
|
||||
const initialism = tokens.map((token) => token[0]).join('').slice(0, ABBREVIATION_MAX_LENGTH);
|
||||
if (initialism.length >= 2) {
|
||||
return initialism.toUpperCase();
|
||||
}
|
||||
|
||||
const alphanumeric = baseModel.replace(/[^A-Za-z0-9]/g, '');
|
||||
if (!alphanumeric) {
|
||||
return BASE_MODEL_ABBREVIATIONS[BASE_MODELS.UNKNOWN];
|
||||
}
|
||||
|
||||
return alphanumeric.slice(0, ABBREVIATION_MAX_LENGTH).toUpperCase();
|
||||
}
|
||||
|
||||
export function getBaseModelAbbreviation(baseModel) {
|
||||
if (!baseModel || typeof baseModel !== 'string') {
|
||||
return BASE_MODEL_ABBREVIATIONS[BASE_MODELS.UNKNOWN];
|
||||
}
|
||||
|
||||
const normalizedName = baseModel.trim().toLowerCase();
|
||||
if (!normalizedName) {
|
||||
return BASE_MODEL_ABBREVIATIONS[BASE_MODELS.UNKNOWN];
|
||||
}
|
||||
|
||||
if (normalizedName.includes('wan video')) {
|
||||
return 'WAN';
|
||||
}
|
||||
|
||||
const directMatch = NORMALIZED_BASE_MODEL_ABBREVIATIONS[normalizedName];
|
||||
if (directMatch) {
|
||||
return directMatch;
|
||||
}
|
||||
|
||||
return buildFallbackAbbreviation(baseModel);
|
||||
}
|
||||
|
||||
// Path template constants for download organization
|
||||
export const DOWNLOAD_PATH_TEMPLATES = {
|
||||
FLAT: {
|
||||
@@ -117,7 +227,9 @@ export const DOWNLOAD_PATH_TEMPLATES = {
|
||||
export const PATH_TEMPLATE_PLACEHOLDERS = [
|
||||
'{base_model}',
|
||||
'{author}',
|
||||
'{first_tag}'
|
||||
'{first_tag}',
|
||||
'{model_name}',
|
||||
'{version_name}'
|
||||
];
|
||||
|
||||
// Default templates for each model type
|
||||
|
||||
@@ -398,6 +398,97 @@ export function copyLoraSyntax(card) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkflowRegistry() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/get-registry');
|
||||
const registryData = await response.json();
|
||||
|
||||
if (!registryData.success) {
|
||||
if (registryData.error === 'Standalone Mode Active') {
|
||||
showToast('toast.general.cannotInteractStandalone', {}, 'warning');
|
||||
} else {
|
||||
showToast('toast.general.failedWorkflowInfo', {}, 'error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return registryData.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to get registry:', error);
|
||||
showToast('uiHelpers.workflow.communicationFailed', {}, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function filterRegistryNodes(nodes = {}, predicate) {
|
||||
if (typeof nodes !== 'object' || nodes === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(nodes).filter(([, node]) => {
|
||||
try {
|
||||
return predicate(node);
|
||||
} catch (error) {
|
||||
console.warn('Failed to evaluate registry node predicate', error);
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getWidgetNames(node) {
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(node.widget_names)) {
|
||||
return node.widget_names;
|
||||
}
|
||||
|
||||
if (node.capabilities && Array.isArray(node.capabilities.widget_names)) {
|
||||
return node.capabilities.widget_names;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function isAbsolutePath(path) {
|
||||
if (typeof path !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return path.startsWith('/') || path.startsWith('\\') || /^[a-zA-Z]:[\\/]/.test(path);
|
||||
}
|
||||
|
||||
async function ensureRelativeModelPath(modelPath, collectionType) {
|
||||
if (!modelPath || !isAbsolutePath(modelPath)) {
|
||||
return modelPath;
|
||||
}
|
||||
|
||||
const fileName = modelPath.split(/[/\\]/).pop();
|
||||
if (!fileName) {
|
||||
return modelPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/${collectionType}/relative-paths?search=${encodeURIComponent(fileName)}&limit=10`);
|
||||
if (!response.ok) {
|
||||
return modelPath;
|
||||
}
|
||||
const data = await response.json();
|
||||
const relativePaths = Array.isArray(data?.relative_paths) ? data.relative_paths : [];
|
||||
if (relativePaths.length === 0) {
|
||||
return modelPath;
|
||||
}
|
||||
const exactMatch = relativePaths.find((path) => path.endsWith(fileName));
|
||||
return exactMatch || relativePaths[0] || modelPath;
|
||||
} catch (error) {
|
||||
console.warn('LoRA Manager: failed to resolve relative path for model', error);
|
||||
return modelPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends LoRA syntax to the active ComfyUI workflow
|
||||
* @param {string} loraSyntax - The LoRA syntax to send
|
||||
@@ -406,44 +497,106 @@ export function copyLoraSyntax(card) {
|
||||
* @returns {Promise<boolean>} - Whether the operation was successful
|
||||
*/
|
||||
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
|
||||
try {
|
||||
// Get registry information from the new endpoint
|
||||
const registryResponse = await fetch('/api/lm/get-registry');
|
||||
const registryData = await registryResponse.json();
|
||||
|
||||
if (!registryData.success) {
|
||||
// Handle specific error cases
|
||||
if (registryData.error === 'Standalone Mode Active') {
|
||||
// Standalone mode - show warning with specific message
|
||||
showToast('toast.general.cannotInteractStandalone', {}, 'warning');
|
||||
return false;
|
||||
} else {
|
||||
// Other errors - show error toast
|
||||
showToast('toast.general.failedWorkflowInfo', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Success case - check node count
|
||||
if (registryData.data.node_count === 0) {
|
||||
// No nodes found - show warning
|
||||
showToast('uiHelpers.workflow.noSupportedNodes', {}, 'warning');
|
||||
return false;
|
||||
} else if (registryData.data.node_count > 1) {
|
||||
// Multiple nodes - show selector
|
||||
showNodeSelector(registryData.data.nodes, loraSyntax, replaceMode, syntaxType);
|
||||
return true;
|
||||
} else {
|
||||
// Single node - send directly
|
||||
const nodes = registryData.data.nodes;
|
||||
const nodeId = Object.keys(nodes)[0];
|
||||
return await sendToSpecificNode([nodeId], nodes, loraSyntax, replaceMode, syntaxType);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get registry:', error);
|
||||
showToast('uiHelpers.workflow.communicationFailed', {}, 'error');
|
||||
const registry = await fetchWorkflowRegistry();
|
||||
if (!registry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const loraNodes = filterRegistryNodes(registry.nodes, (node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (node.capabilities && typeof node.capabilities === 'object') {
|
||||
if (node.capabilities.supports_lora === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return typeof node.type === 'number' && node.type > 0;
|
||||
});
|
||||
|
||||
const nodeKeys = Object.keys(loraNodes);
|
||||
if (nodeKeys.length === 0) {
|
||||
showToast('uiHelpers.workflow.noSupportedNodes', {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nodeKeys.length === 1) {
|
||||
return await sendLoraToNodes([nodeKeys[0]], loraNodes, loraSyntax, replaceMode, syntaxType);
|
||||
}
|
||||
|
||||
const actionType =
|
||||
syntaxType === 'recipe'
|
||||
? translate('uiHelpers.nodeSelector.recipe', {}, 'Recipe')
|
||||
: translate('uiHelpers.nodeSelector.lora', {}, 'LoRA');
|
||||
const actionMode = replaceMode
|
||||
? translate('uiHelpers.nodeSelector.replace', {}, 'Replace')
|
||||
: translate('uiHelpers.nodeSelector.append', {}, 'Append');
|
||||
|
||||
showNodeSelector(loraNodes, {
|
||||
actionType,
|
||||
actionMode,
|
||||
onSend: (selectedNodeIds) =>
|
||||
sendLoraToNodes(selectedNodeIds, loraNodes, loraSyntax, replaceMode, syntaxType),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function sendModelPathToWorkflow(modelPath, options) {
|
||||
const {
|
||||
widgetName,
|
||||
collectionType = 'checkpoints',
|
||||
actionTypeText = 'Checkpoint',
|
||||
successMessage = 'Updated workflow node',
|
||||
failureMessage = 'Failed to update workflow node',
|
||||
missingNodesMessage = 'No compatible nodes available in the current workflow',
|
||||
missingTargetMessage = 'No target node selected',
|
||||
} = options;
|
||||
|
||||
if (!widgetName) {
|
||||
console.warn('LoRA Manager: widget name is required to send model to workflow');
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = await ensureRelativeModelPath(modelPath, collectionType);
|
||||
|
||||
const registry = await fetchWorkflowRegistry();
|
||||
if (!registry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetNodes = filterRegistryNodes(registry.nodes, (node) => {
|
||||
const widgetNames = getWidgetNames(node);
|
||||
return widgetNames.includes(widgetName);
|
||||
});
|
||||
|
||||
const nodeKeys = Object.keys(targetNodes);
|
||||
if (nodeKeys.length === 0) {
|
||||
showToast(missingNodesMessage, {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
const actionType = actionTypeText;
|
||||
const actionMode = translate('uiHelpers.nodeSelector.replace', {}, 'Replace');
|
||||
|
||||
const messages = {
|
||||
successMessage,
|
||||
failureMessage,
|
||||
missingTargetMessage,
|
||||
};
|
||||
|
||||
const handleSend = (selectedNodeIds) =>
|
||||
sendWidgetValueToNodes(selectedNodeIds, targetNodes, widgetName, relativePath, messages);
|
||||
|
||||
if (nodeKeys.length === 1) {
|
||||
return await handleSend([nodeKeys[0]]);
|
||||
}
|
||||
|
||||
showNodeSelector(targetNodes, {
|
||||
actionType,
|
||||
actionMode,
|
||||
onSend: handleSend,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,7 +636,7 @@ function resolveNodeReference(nodeKey, nodesMap) {
|
||||
};
|
||||
}
|
||||
|
||||
async function sendToSpecificNode(nodeIds, nodesMap, loraSyntax, replaceMode, syntaxType) {
|
||||
async function sendLoraToNodes(nodeIds, nodesMap, loraSyntax, replaceMode, syntaxType) {
|
||||
try {
|
||||
// Call the backend API to update the lora code
|
||||
const requestBody = {
|
||||
@@ -547,29 +700,96 @@ async function sendToSpecificNode(nodeIds, nodesMap, loraSyntax, replaceMode, sy
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, messages = {}) {
|
||||
const {
|
||||
successMessage = 'Updated workflow node',
|
||||
failureMessage = 'Failed to update workflow node',
|
||||
missingTargetMessage = 'No target node selected',
|
||||
} = messages;
|
||||
|
||||
const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
|
||||
if (targetIds.length === 0) {
|
||||
showToast(missingTargetMessage, {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
const references = targetIds
|
||||
.map((nodeKey) => resolveNodeReference(nodeKey, nodesMap))
|
||||
.filter((reference) => reference && reference.node_id !== undefined);
|
||||
|
||||
if (references.length === 0) {
|
||||
showToast(missingTargetMessage, {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/update-node-widget', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
widget_name: widgetName,
|
||||
value,
|
||||
node_ids: references,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast(successMessage, {}, 'success');
|
||||
return true;
|
||||
}
|
||||
|
||||
const errorMessage = result?.error || failureMessage;
|
||||
showToast(errorMessage, {}, 'error');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to send widget value to workflow:', error);
|
||||
showToast(failureMessage, {}, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Global variable to track active node selector state
|
||||
let nodeSelectorState = {
|
||||
isActive: false,
|
||||
clickHandler: null,
|
||||
selectorClickHandler: null
|
||||
selectorClickHandler: null,
|
||||
currentNodes: {},
|
||||
onSend: null,
|
||||
enableSendAll: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Show node selector popup near mouse position
|
||||
* @param {Object} nodes - Registry nodes data
|
||||
* @param {string} loraSyntax - The LoRA syntax to send
|
||||
* @param {boolean} replaceMode - Whether to replace existing LoRAs
|
||||
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
|
||||
* @param {Object} options - Configuration for display and actions
|
||||
* @param {string} options.actionType - Display label for the action type (e.g. LoRA)
|
||||
* @param {string} options.actionMode - Display label for the action mode (e.g. Replace)
|
||||
* @param {Function} options.onSend - Callback invoked with selected node ids
|
||||
* @param {boolean} [options.enableSendAll=true] - Whether to show the "send to all" option
|
||||
*/
|
||||
function showNodeSelector(nodes, loraSyntax, replaceMode, syntaxType) {
|
||||
function showNodeSelector(nodes, options = {}) {
|
||||
const selector = document.getElementById('nodeSelector');
|
||||
if (!selector) return;
|
||||
|
||||
// Clean up any existing state
|
||||
hideNodeSelector();
|
||||
|
||||
const safeNodes = nodes || {};
|
||||
const onSend = typeof options.onSend === 'function' ? options.onSend : null;
|
||||
if (!onSend) {
|
||||
console.warn('LoRA Manager: node selector invoked without send handler');
|
||||
return;
|
||||
}
|
||||
|
||||
nodeSelectorState.currentNodes = safeNodes;
|
||||
nodeSelectorState.onSend = onSend;
|
||||
nodeSelectorState.enableSendAll = options.enableSendAll !== false;
|
||||
|
||||
// Generate node list HTML with icons and proper colors
|
||||
const nodeItems = Object.entries(nodes).map(([nodeKey, node]) => {
|
||||
const nodeItems = Object.entries(safeNodes).map(([nodeKey, node]) => {
|
||||
const iconClass = NODE_TYPE_ICONS[node.type] || 'fas fa-question-circle';
|
||||
const bgColor = node.bgcolor || DEFAULT_NODE_COLOR;
|
||||
const graphLabel = node.graph_name ? ` (${node.graph_name})` : '';
|
||||
@@ -585,14 +805,20 @@ function showNodeSelector(nodes, loraSyntax, replaceMode, syntaxType) {
|
||||
}).join('');
|
||||
|
||||
// Add header with action mode indicator
|
||||
const actionType = syntaxType === 'recipe' ?
|
||||
translate('uiHelpers.nodeSelector.recipe', {}, 'Recipe') :
|
||||
translate('uiHelpers.nodeSelector.lora', {}, 'LoRA');
|
||||
const actionMode = replaceMode ?
|
||||
translate('uiHelpers.nodeSelector.replace', {}, 'Replace') :
|
||||
translate('uiHelpers.nodeSelector.append', {}, 'Append');
|
||||
const actionType = options.actionType ?? translate('uiHelpers.nodeSelector.lora', {}, 'LoRA');
|
||||
const actionMode = options.actionMode ?? translate('uiHelpers.nodeSelector.replace', {}, 'Replace');
|
||||
const selectTargetNodeText = translate('uiHelpers.nodeSelector.selectTargetNode', {}, 'Select target node');
|
||||
const sendToAllText = translate('uiHelpers.nodeSelector.sendToAll', {}, 'Send to All');
|
||||
|
||||
const sendAllMarkup = nodeSelectorState.enableSendAll
|
||||
? `
|
||||
<div class="node-item send-all-item" data-action="send-all">
|
||||
<div class="node-icon-indicator all-nodes">
|
||||
<i class="fas fa-broadcast-tower"></i>
|
||||
</div>
|
||||
<span>${sendToAllText}</span>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
selector.innerHTML = `
|
||||
<div class="node-selector-header">
|
||||
@@ -600,12 +826,7 @@ function showNodeSelector(nodes, loraSyntax, replaceMode, syntaxType) {
|
||||
<span class="selector-instruction">${selectTargetNodeText}</span>
|
||||
</div>
|
||||
${nodeItems}
|
||||
<div class="node-item send-all-item" data-action="send-all">
|
||||
<div class="node-icon-indicator all-nodes">
|
||||
<i class="fas fa-broadcast-tower"></i>
|
||||
</div>
|
||||
<span>${sendToAllText}</span>
|
||||
</div>
|
||||
${sendAllMarkup}
|
||||
`;
|
||||
|
||||
// Position near mouse
|
||||
@@ -619,18 +840,14 @@ function showNodeSelector(nodes, loraSyntax, replaceMode, syntaxType) {
|
||||
eventManager.setState('nodeSelectorActive', true);
|
||||
|
||||
// Setup event listeners with proper cleanup through event manager
|
||||
setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, syntaxType);
|
||||
setupNodeSelectorEvents(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for node selector using event manager
|
||||
* @param {HTMLElement} selector - The selector element
|
||||
* @param {Object} nodes - Registry nodes data
|
||||
* @param {string} loraSyntax - The LoRA syntax to send
|
||||
* @param {boolean} replaceMode - Whether to replace existing LoRAs
|
||||
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
|
||||
*/
|
||||
function setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, syntaxType) {
|
||||
function setupNodeSelectorEvents(selector) {
|
||||
// Clean up any existing event listeners
|
||||
cleanupNodeSelectorEvents();
|
||||
|
||||
@@ -650,21 +867,32 @@ function setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, synta
|
||||
const nodeItem = e.target.closest('.node-item');
|
||||
if (!nodeItem) return false; // Continue with other handlers
|
||||
|
||||
const onSend = nodeSelectorState.onSend;
|
||||
if (typeof onSend !== 'function') {
|
||||
hideNodeSelector();
|
||||
return true;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
const action = nodeItem.dataset.action;
|
||||
const nodeId = nodeItem.dataset.nodeId;
|
||||
const nodes = nodeSelectorState.currentNodes || {};
|
||||
|
||||
if (action === 'send-all') {
|
||||
// Send to all nodes
|
||||
const allNodeIds = Object.keys(nodes);
|
||||
await sendToSpecificNode(allNodeIds, nodes, loraSyntax, replaceMode, syntaxType);
|
||||
} else if (nodeId) {
|
||||
// Send to specific node
|
||||
await sendToSpecificNode([nodeId], nodes, loraSyntax, replaceMode, syntaxType);
|
||||
try {
|
||||
if (action === 'send-all') {
|
||||
if (!nodeSelectorState.enableSendAll) {
|
||||
return true;
|
||||
}
|
||||
const allNodeIds = Object.keys(nodes);
|
||||
await onSend(allNodeIds);
|
||||
} else if (nodeId) {
|
||||
await onSend([nodeId]);
|
||||
}
|
||||
} finally {
|
||||
hideNodeSelector();
|
||||
}
|
||||
|
||||
hideNodeSelector();
|
||||
|
||||
return true; // Stop propagation
|
||||
}, {
|
||||
priority: 150, // High priority but lower than outside click
|
||||
@@ -699,6 +927,9 @@ function hideNodeSelector() {
|
||||
// Clean up event listeners
|
||||
cleanupNodeSelectorEvents();
|
||||
nodeSelectorState.isActive = false;
|
||||
nodeSelectorState.currentNodes = {};
|
||||
nodeSelectorState.onSend = null;
|
||||
nodeSelectorState.enableSendAll = true;
|
||||
|
||||
// Update event manager state
|
||||
eventManager.setState('nodeSelectorActive', false);
|
||||
@@ -787,4 +1018,4 @@ export async function openExampleImagesFolder(modelHash) {
|
||||
showToast('uiHelpers.exampleImages.failedToOpen', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
193
static/js/utils/updateCheckHelpers.js
Normal file
193
static/js/utils/updateCheckHelpers.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import { state } from '../state/index.js';
|
||||
import { translate } from './i18nHelpers.js';
|
||||
import { showToast } from './uiHelpers.js';
|
||||
import { getCompleteApiConfig, getCurrentModelType } from '../api/apiConfig.js';
|
||||
import { resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
|
||||
const CHECK_UPDATES_CONFIRMATION_KEY = 'ack_check_updates_for_all_models';
|
||||
|
||||
/**
|
||||
* Perform a model update check using the shared backend endpoint.
|
||||
* @param {Object} [options]
|
||||
* @param {Function} [options.onStart] - Callback invoked before the request is sent.
|
||||
* @param {Function} [options.onComplete] - Callback invoked after the request settles.
|
||||
* @returns {Promise<{status: 'success' | 'error' | 'unsupported', displayName: string, records: Array, error: Error | null}>}
|
||||
*/
|
||||
export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
|
||||
const modelType = getCurrentModelType();
|
||||
const apiConfig = getCompleteApiConfig(modelType);
|
||||
const displayName = apiConfig?.config?.displayName ?? 'Model';
|
||||
|
||||
if (!apiConfig?.endpoints?.refreshUpdates) {
|
||||
console.warn('Refresh updates endpoint not configured for model type:', modelType);
|
||||
onComplete?.({ status: 'unsupported', displayName, records: [], error: null });
|
||||
return { status: 'unsupported', displayName, records: [], error: null };
|
||||
}
|
||||
|
||||
const proceed = await ensureCheckUpdatesConfirmation(displayName);
|
||||
if (!proceed) {
|
||||
onComplete?.({ status: 'cancelled', displayName, records: [], error: null });
|
||||
return { status: 'cancelled', displayName, records: [], error: null };
|
||||
}
|
||||
|
||||
const loadingMessage = translate(
|
||||
'globalContextMenu.checkModelUpdates.loading',
|
||||
{ type: displayName },
|
||||
`Checking for ${displayName} updates...`
|
||||
);
|
||||
|
||||
onStart?.({ displayName, loadingMessage });
|
||||
|
||||
state.loadingManager?.showSimpleLoading?.(loadingMessage);
|
||||
|
||||
let status = 'success';
|
||||
let records = [];
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force: false })
|
||||
});
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
if (!response.ok || payload.success !== true) {
|
||||
const errorMessage = payload?.error || response.statusText || 'Unknown error';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
records = Array.isArray(payload.records) ? payload.records : [];
|
||||
|
||||
if (records.length > 0) {
|
||||
showToast('globalContextMenu.checkModelUpdates.success', { count: records.length, type: displayName }, 'success');
|
||||
} else {
|
||||
showToast('globalContextMenu.checkModelUpdates.none', { type: displayName }, 'info');
|
||||
}
|
||||
|
||||
await resetAndReload(false);
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
error = err instanceof Error ? err : new Error(String(err));
|
||||
console.error('Error checking model updates:', error);
|
||||
showToast(
|
||||
'globalContextMenu.checkModelUpdates.error',
|
||||
{ message: error?.message ?? 'Unknown error', type: displayName },
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
state.loadingManager?.hide?.();
|
||||
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
onComplete?.({ status, displayName, records, error });
|
||||
}
|
||||
|
||||
return { status, displayName, records, error };
|
||||
}
|
||||
|
||||
function getTypePlural(displayName) {
|
||||
if (!displayName) {
|
||||
return 'models';
|
||||
}
|
||||
|
||||
const lower = displayName.toLowerCase();
|
||||
if (lower.endsWith('s')) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return `${displayName}s`;
|
||||
}
|
||||
|
||||
async function ensureCheckUpdatesConfirmation(displayName) {
|
||||
const hasConfirmed = getStorageItem(CHECK_UPDATES_CONFIRMATION_KEY, false);
|
||||
if (hasConfirmed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const modalElement = document.getElementById('checkUpdatesConfirmModal');
|
||||
if (!modalElement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const typePlural = getTypePlural(displayName);
|
||||
|
||||
const titleElement = modalElement.querySelector('[data-role="title"]');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = translate(
|
||||
'modals.checkUpdates.title',
|
||||
{ type: displayName, typePlural },
|
||||
`Check updates for all ${typePlural}?`
|
||||
);
|
||||
}
|
||||
|
||||
const messageElement = modalElement.querySelector('[data-role="message"]');
|
||||
if (messageElement) {
|
||||
messageElement.textContent = translate(
|
||||
'modals.checkUpdates.message',
|
||||
{ type: displayName, typePlural },
|
||||
`This checks every ${typePlural} in your library for updates. Large collections may take a little longer.`
|
||||
);
|
||||
}
|
||||
|
||||
const tipElement = modalElement.querySelector('[data-role="tip"]');
|
||||
if (tipElement) {
|
||||
tipElement.textContent = translate(
|
||||
'modals.checkUpdates.tip',
|
||||
{ type: displayName, typePlural },
|
||||
'To work in smaller batches, switch to bulk mode, pick the ones you need, then use "Check Updates for Selected".'
|
||||
);
|
||||
}
|
||||
|
||||
const confirmButton = modalElement.querySelector('[data-action="confirm-check-updates"]');
|
||||
const cancelButton = modalElement.querySelector('[data-action="cancel-check-updates"]');
|
||||
|
||||
if (!confirmButton || !cancelButton) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
cancelButton.removeEventListener('click', handleCancel);
|
||||
};
|
||||
|
||||
const finalize = (proceed) => {
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(proceed);
|
||||
};
|
||||
|
||||
const handleConfirm = (event) => {
|
||||
event.preventDefault();
|
||||
setStorageItem(CHECK_UPDATES_CONFIRMATION_KEY, true);
|
||||
finalize(true);
|
||||
modalManager.closeModal('checkUpdatesConfirmModal');
|
||||
};
|
||||
|
||||
const handleCancel = (event) => {
|
||||
event.preventDefault();
|
||||
finalize(false);
|
||||
modalManager.closeModal('checkUpdatesConfirmModal');
|
||||
};
|
||||
|
||||
confirmButton.addEventListener('click', handleConfirm);
|
||||
cancelButton.addEventListener('click', handleCancel);
|
||||
|
||||
modalManager.showModal('checkUpdatesConfirmModal', null, () => finalize(false));
|
||||
});
|
||||
}
|
||||
@@ -53,6 +53,9 @@
|
||||
<div class="context-menu-item" data-action="refresh-all">
|
||||
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="check-updates">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="copy-all">
|
||||
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
|
||||
</div>
|
||||
@@ -87,6 +90,9 @@
|
||||
<div class="context-menu-item" data-action="download-example-images">
|
||||
<i class="fas fa-download"></i> <span>{{ t('globalContextMenu.downloadExampleImages.label') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="check-model-updates">
|
||||
<i class="fas fa-sync-alt"></i> <span>{{ t('globalContextMenu.checkModelUpdates.label') }}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="quick-refresh">
|
||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('loras.controls.refresh.quickTooltip') }}">
|
||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick') }}</span>
|
||||
</div>
|
||||
<div class="dropdown-item" data-action="full-rebuild">
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,6 +56,19 @@
|
||||
<i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="control-group dropdown-group update-filter-group">
|
||||
<button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}">
|
||||
<i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span>
|
||||
</button>
|
||||
<button id="updateFilterMenuToggle" class="dropdown-toggle" aria-label="{{ t('loras.controls.updates.menuLabel') }}">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div id="checkUpdatesMenuItem" class="dropdown-item" data-action="check-updates" title="{{ t('loras.controls.updates.checkTooltip') }}">
|
||||
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.controls.updates.check') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span class="update-badge"></span>
|
||||
</div>
|
||||
<div class="update-toggle" id="updateToggleBtn" title="{{ t('header.actions.checkUpdates') }}">
|
||||
<div class="update-toggle" id="updateToggleBtn" title="{{ t('header.actions.notifications') }}">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span class="update-badge"></span>
|
||||
</div>
|
||||
|
||||
@@ -67,4 +67,17 @@
|
||||
<button class="delete-btn" onclick="bulkManager.confirmBulkDelete()">{{ t('modals.bulkDelete.action') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check Updates Confirmation Modal -->
|
||||
<div id="checkUpdatesConfirmModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="confirmation-message" data-role="message"></p>
|
||||
<p class="confirmation-tip" data-role="tip"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-check-updates">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" data-action="confirm-check-updates">{{ t('modals.checkUpdates.action') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +104,25 @@
|
||||
<!-- Add Layout Settings Section -->
|
||||
<div class="settings-section">
|
||||
<h3>{{ t('settings.sections.layoutSettings') }}</h3>
|
||||
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="showFolderSidebar">{{ t('settings.layoutSettings.showFolderSidebar') }}</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="showFolderSidebar"
|
||||
onchange="settingsManager.saveToggleSetting('showFolderSidebar', 'show_folder_sidebar')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
{{ t('settings.layoutSettings.showFolderSidebarHelp') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
@@ -144,10 +162,6 @@
|
||||
</div>
|
||||
<div class="input-help">
|
||||
{{ t('settings.layoutSettings.modelNameDisplayHelp') }}
|
||||
<ul class="list-description">
|
||||
<li><strong>{{ t('settings.layoutSettings.modelNameDisplayOptions.modelName') }}:</strong> {{ t('settings.layoutSettings.modelNameDisplayDetails.modelName') }}</li>
|
||||
<li><strong>{{ t('settings.layoutSettings.modelNameDisplayOptions.fileName') }}:</strong> {{ t('settings.layoutSettings.modelNameDisplayDetails.fileName') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,10 +180,23 @@
|
||||
</div>
|
||||
<div class="input-help">
|
||||
{{ t('settings.layoutSettings.cardInfoDisplayHelp') }}
|
||||
<ul class="list-description">
|
||||
<li><strong>{{ t('settings.layoutSettings.cardInfoDisplayOptions.always') }}:</strong> {{ t('settings.layoutSettings.cardInfoDisplayDetails.always') }}</li>
|
||||
<li><strong>{{ t('settings.layoutSettings.cardInfoDisplayOptions.hover') }}:</strong> {{ t('settings.layoutSettings.cardInfoDisplayDetails.hover') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="modelCardFooterAction">{{ t('settings.layoutSettings.modelCardFooterAction') }}</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="modelCardFooterAction" onchange="settingsManager.saveSelectSetting('modelCardFooterAction', 'model_card_footer_action')">
|
||||
<option value="example_images">{{ t('settings.layoutSettings.modelCardFooterActionOptions.exampleImages') }}</option>
|
||||
<option value="replace_preview">{{ t('settings.layoutSettings.modelCardFooterActionOptions.replacePreview') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
{{ t('settings.layoutSettings.modelCardFooterActionHelp') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -284,6 +311,8 @@
|
||||
<span class="placeholder-tag">{base_model}</span>
|
||||
<span class="placeholder-tag">{author}</span>
|
||||
<span class="placeholder-tag">{first_tag}</span>
|
||||
<span class="placeholder-tag">{model_name}</span>
|
||||
<span class="placeholder-tag">{version_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,10 +423,8 @@
|
||||
|
||||
<div class="setting-item priority-tags-item">
|
||||
<div class="setting-row priority-tags-header">
|
||||
<div class="setting-info">
|
||||
<div class="setting-info priority-tags-info">
|
||||
<label>{{ t('settings.priorityTags.title') }}</label>
|
||||
</div>
|
||||
<div class="setting-control priority-tags-actions">
|
||||
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
|
||||
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user