Compare commits
45 Commits
502b7eab31
...
v1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94e1a8ac7b | ||
|
|
cc20d3b992 | ||
|
|
a74cbe7aa2 | ||
|
|
94edfaa190 | ||
|
|
31c54ff068 | ||
|
|
21872a8e9e | ||
|
|
612612f1c7 | ||
|
|
ff240db5b1 | ||
|
|
bcfed4b874 | ||
|
|
1352c6ecbe | ||
|
|
30b01b8a92 | ||
|
|
a105cb322b | ||
|
|
3bf396d003 | ||
|
|
60cfb3b8e0 | ||
|
|
6763abb83c | ||
|
|
5c53968caa | ||
|
|
b4f7dd75af | ||
|
|
86118d0654 | ||
|
|
df1410535e | ||
|
|
75f74d54d8 | ||
|
|
ab6100f596 | ||
|
|
5d3ab3bbf8 | ||
|
|
d9dc0dba8d | ||
|
|
3631c5eb10 | ||
|
|
6d5b4b7312 | ||
|
|
7803bd542d | ||
|
|
f0a86dbbc0 | ||
|
|
682e964f89 | ||
|
|
908464bc0a | ||
|
|
0ffee3a854 | ||
|
|
8aa9739c44 | ||
|
|
50739bbb43 | ||
|
|
e849303763 | ||
|
|
241b2e15d2 | ||
|
|
88da754504 | ||
|
|
b4a706651f | ||
|
|
ff7cc6d9bb | ||
|
|
454210a47c | ||
|
|
2d7c404ebb | ||
|
|
e23d803ecf | ||
|
|
0cc640cfaa | ||
|
|
2ac0eb0f9d | ||
|
|
f028625ce9 | ||
|
|
06acc7f576 | ||
|
|
d324b57274 |
1
.gitignore
vendored
@@ -15,6 +15,7 @@ model_cache/
|
|||||||
# agent
|
# agent
|
||||||
.opencode/
|
.opencode/
|
||||||
.claude/
|
.claude/
|
||||||
|
.sisyphus/
|
||||||
.codex
|
.codex
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
## Overview
|
|
||||||
|
|
||||||
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
|
|
||||||
|
|
||||||
✅ Instantly see which models are already present in your local library
|
|
||||||
✅ Download new models with a single click
|
|
||||||
✅ Manage downloads efficiently with queue and parallel download support
|
|
||||||
✅ Keep your downloaded models automatically organized according to your custom settings
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why Supporter Access?
|
|
||||||
|
|
||||||
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time.
|
|
||||||
|
|
||||||
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone.
|
|
||||||
|
|
||||||
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Supported Browsers & Installation Methods
|
|
||||||
|
|
||||||
| Browser | Installation Method |
|
|
||||||
|--------------------|-------------------------------------------------------------------------------------|
|
|
||||||
| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
|
|
||||||
| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Brave Browser** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Opera** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Firefox** | <div id="firefox-install" class="install-ok"><a href="https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi">📦 Install Firefox Extension (reviewed and verified by Mozilla)</a></div> |
|
|
||||||
|
|
||||||
For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extension’s Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Privacy & Security
|
|
||||||
|
|
||||||
I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
|
|
||||||
|
|
||||||
- **Reviewed and Verified**
|
|
||||||
This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozilla’s Add-on review**.
|
|
||||||
|
|
||||||
- **Minimal Network Access**
|
|
||||||
The only external server this extension connects to is:
|
|
||||||
**`https://willmiao.shop`** — used solely for **license validation**.
|
|
||||||
|
|
||||||
It does **not collect, transmit, or store any personal or usage data**.
|
|
||||||
No browsing history, no user IDs, no analytics, no hidden trackers.
|
|
||||||
|
|
||||||
- **Local-Only Model Detection**
|
|
||||||
Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
|
|
||||||
|
|
||||||
I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
|
|
||||||
|
|
||||||
When the extension is correctly installed and your license is valid:
|
|
||||||
|
|
||||||
- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
|
|
||||||
- ✅ Models already present in your local library
|
|
||||||
- ⬇️ A download button for models not in your library
|
|
||||||
|
|
||||||
Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
|
|
||||||
|
|
||||||
### Visual Indicators Appear On:
|
|
||||||
|
|
||||||
- **Home Page** — Featured models
|
|
||||||
- **Models Page**
|
|
||||||
- **Creator Profiles** — If the creator has set their models to be visible
|
|
||||||
- **Recommended Resources** — On individual model pages
|
|
||||||
|
|
||||||
### Version Buttons on Model Pages
|
|
||||||
|
|
||||||
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
|
|
||||||
|
|
||||||
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button:
|
|
||||||
|
|
||||||
- The new **dedicated download button** directly triggers download via **LoRA Manager**
|
|
||||||
- The **original download button** remains unchanged for standard browser downloads
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Hide Models Already in Library (Beta)
|
|
||||||
|
|
||||||
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
|
|
||||||
|
|
||||||
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
|
|
||||||
|
|
||||||
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
|
|
||||||
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
|
|
||||||
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
[](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Model Download Location & LoRA Manager Settings
|
|
||||||
|
|
||||||
To use the **one-click download function**, you must first set:
|
|
||||||
|
|
||||||
- Your **Default LoRAs Root**
|
|
||||||
- Your **Default Checkpoints Root**
|
|
||||||
|
|
||||||
These are set within LoRA Manager's settings.
|
|
||||||
|
|
||||||
When everything is configured, downloaded model files will be placed in:
|
|
||||||
|
|
||||||
`<Default_Models_Root>/<Base_Model_of_the_Model>/<First_Tag_of_the_Model>`
|
|
||||||
|
|
||||||
|
|
||||||
### Update: Default Path Customization (2025-07-21)
|
|
||||||
|
|
||||||
A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Port Configuration
|
|
||||||
|
|
||||||
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
|
|
||||||
|
|
||||||
After correctly setting and saving the port, you'll see in the extension's header area:
|
|
||||||
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Connecting to a Remote LoRA Manager
|
|
||||||
|
|
||||||
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
|
|
||||||
|
|
||||||
> **Why can't you set a remote IP directly?**
|
|
||||||
>
|
|
||||||
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
|
|
||||||
|
|
||||||
**Solution: Port Forwarding with `socat`**
|
|
||||||
|
|
||||||
On your browser computer, run:
|
|
||||||
|
|
||||||
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
|
|
||||||
|
|
||||||
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
|
|
||||||
- Adjust the port if needed.
|
|
||||||
|
|
||||||
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
|
|
||||||
|
|
||||||
_Thanks to user **Temikus** for sharing this solution!_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
|
|
||||||
|
|
||||||
- [x] Support for **additional model types** (e.g., embeddings)
|
|
||||||
- [x] One-click **Recipe Import**
|
|
||||||
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
|
|
||||||
- [x] One-click **Auto-organize Models**
|
|
||||||
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
|
|
||||||
|
|
||||||
**Stay tuned — and thank you for your support!**
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
"noTags": "Keine Tags",
|
"noTags": "Keine Tags",
|
||||||
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||||
"clearAll": "Alle Filter löschen",
|
"clearAll": "Alle Filter löschen",
|
||||||
"any": "Beliebig",
|
"any": "Beliebig",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Modelliste aktualisieren",
|
"title": "Modelliste aktualisieren",
|
||||||
"quick": "Änderungen synchronisieren",
|
|
||||||
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
|
||||||
"full": "Cache neu aufbauen",
|
"full": "Cache neu aufbauen",
|
||||||
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "Automatisch organisieren",
|
"autoOrganize": "Automatisch organisieren",
|
||||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||||
|
"setFavorite": "Als Favorit setzen",
|
||||||
|
"setFavoriteCount": "Als Favorit setzen ({favorited}/{total})",
|
||||||
|
"unfavorite": "Aus Favoriten entfernen",
|
||||||
"deleteAll": "Ausgewählte löschen",
|
"deleteAll": "Ausgewählte löschen",
|
||||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||||
|
"downloadExamples": "Beispielbilder herunterladen",
|
||||||
"clear": "Auswahl löschen",
|
"clear": "Auswahl löschen",
|
||||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||||
|
"sendToWorkflow": "An Workflow senden",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadaten",
|
||||||
|
"attributes": "Attribute",
|
||||||
|
"organize": "Organisieren",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Automatische Organisation wird initialisiert...",
|
"initializing": "Automatische Organisation wird initialisiert...",
|
||||||
"starting": "Automatische Organisation für {type} wird gestartet...",
|
"starting": "Automatische Organisation für {type} wird gestartet...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Rezeptliste aktualisieren",
|
"title": "Rezeptliste aktualisieren",
|
||||||
"quick": "Änderungen synchronisieren",
|
|
||||||
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
|
|
||||||
"full": "Cache neu aufbauen",
|
"full": "Cache neu aufbauen",
|
||||||
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||||
"action": "Alle löschen"
|
"action": "Alle löschen"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Mehrere Rezepte löschen",
|
||||||
|
"message": "Sind Sie sicher, dass Sie alle ausgewählten Rezepte und ihre zugehörigen Dateien löschen möchten?",
|
||||||
|
"countMessage": "Rezepte werden dauerhaft gelöscht.",
|
||||||
|
"action": "Alle löschen"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Alle {typePlural} auf Updates prüfen?",
|
"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.",
|
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||||
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||||
|
"bulkFavoriteUpdating": "Füge {count} Modell(e) zu Favoriten hinzu...",
|
||||||
|
"bulkUnfavoriteUpdating": "Entferne {count} Modell(e) aus Favoriten...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} Modell(e) zu Favoriten hinzugefügt, {failed} fehlgeschlagen",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} Modell(e) aus Favoriten entfernt, {failed} fehlgeschlagen",
|
||||||
|
"bulkFavoriteFailed": "Fehler beim Aktualisieren des Favoritenstatus",
|
||||||
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
||||||
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||||
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
"noTags": "No tags",
|
"noTags": "No tags",
|
||||||
|
"autoTags": "Auto Tags",
|
||||||
"noBaseModelMatches": "No base models match the current search.",
|
"noBaseModelMatches": "No base models match the current search.",
|
||||||
"clearAll": "Clear All Filters",
|
"clearAll": "Clear All Filters",
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh model list",
|
"title": "Refresh model list",
|
||||||
"quick": "Sync Changes",
|
|
||||||
"quickTooltip": "Scan for new or missing model files so the list stays current.",
|
|
||||||
"full": "Rebuild Cache",
|
"full": "Rebuild Cache",
|
||||||
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "Auto-Organize Selected",
|
"autoOrganize": "Auto-Organize Selected",
|
||||||
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
||||||
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
||||||
|
"setFavorite": "Set as Favorite",
|
||||||
|
"setFavoriteCount": "Set as Favorite ({favorited}/{total})",
|
||||||
|
"unfavorite": "Remove from Favorites",
|
||||||
"deleteAll": "Delete Selected",
|
"deleteAll": "Delete Selected",
|
||||||
"downloadMissingLoras": "Download Missing LoRAs",
|
"downloadMissingLoras": "Download Missing LoRAs",
|
||||||
|
"downloadExamples": "Download Example Images",
|
||||||
"clear": "Clear Selection",
|
"clear": "Clear Selection",
|
||||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||||
|
"sendToWorkflow": "Send to Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"attributes": "Attributes",
|
||||||
|
"organize": "Organize",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Initializing auto-organize...",
|
"initializing": "Initializing auto-organize...",
|
||||||
"starting": "Starting auto-organize for {type}...",
|
"starting": "Starting auto-organize for {type}...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh recipe list",
|
"title": "Refresh recipe list",
|
||||||
"quick": "Sync Changes",
|
|
||||||
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
|
|
||||||
"full": "Rebuild Cache",
|
"full": "Rebuild Cache",
|
||||||
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
|
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "models will be permanently deleted.",
|
"countMessage": "models will be permanently deleted.",
|
||||||
"action": "Delete All"
|
"action": "Delete All"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Delete Multiple Recipes",
|
||||||
|
"message": "Are you sure you want to delete all selected recipes and their associated files?",
|
||||||
|
"countMessage": "recipes will be permanently deleted.",
|
||||||
|
"action": "Delete All"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Check updates for all {typePlural}?",
|
"title": "Check updates for all {typePlural}?",
|
||||||
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||||
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||||
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
||||||
|
"bulkFavoriteUpdating": "Adding {count} model(s) to favorites...",
|
||||||
|
"bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...",
|
||||||
|
"bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed",
|
||||||
|
"bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed",
|
||||||
|
"bulkFavoriteFailed": "Failed to update favorite status for selected models",
|
||||||
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||||
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||||
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
"noTags": "Sin etiquetas",
|
"noTags": "Sin etiquetas",
|
||||||
|
"autoTags": "Etiquetas automáticas",
|
||||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||||
"clearAll": "Limpiar todos los filtros",
|
"clearAll": "Limpiar todos los filtros",
|
||||||
"any": "Cualquiera",
|
"any": "Cualquiera",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de modelos",
|
"title": "Actualizar lista de modelos",
|
||||||
"quick": "Sincronizar cambios",
|
|
||||||
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
|
|
||||||
"full": "Reconstruir caché",
|
"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."
|
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "Auto-organizar seleccionados",
|
"autoOrganize": "Auto-organizar seleccionados",
|
||||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||||
|
"setFavorite": "Marcar como favorito",
|
||||||
|
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
|
||||||
|
"unfavorite": "Quitar de favoritos",
|
||||||
"deleteAll": "Eliminar seleccionados",
|
"deleteAll": "Eliminar seleccionados",
|
||||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||||
|
"downloadExamples": "Descargar imágenes de ejemplo",
|
||||||
"clear": "Limpiar selección",
|
"clear": "Limpiar selección",
|
||||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||||
|
"sendToWorkflow": "Enviar al workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadatos",
|
||||||
|
"attributes": "Atributos",
|
||||||
|
"organize": "Organizar",
|
||||||
|
"download": "Descargar"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Inicializando auto-organización...",
|
"initializing": "Inicializando auto-organización...",
|
||||||
"starting": "Iniciando auto-organización para {type}...",
|
"starting": "Iniciando auto-organización para {type}...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de recetas",
|
"title": "Actualizar lista de recetas",
|
||||||
"quick": "Sincronizar cambios",
|
|
||||||
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
|
|
||||||
"full": "Reconstruir caché",
|
"full": "Reconstruir caché",
|
||||||
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "modelos serán eliminados permanentemente.",
|
"countMessage": "modelos serán eliminados permanentemente.",
|
||||||
"action": "Eliminar todo"
|
"action": "Eliminar todo"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Eliminar múltiples recetas",
|
||||||
|
"message": "¿Estás seguro de que quieres eliminar todas las recetas seleccionadas y sus archivos asociados?",
|
||||||
|
"countMessage": "recetas serán eliminadas permanentemente.",
|
||||||
|
"action": "Eliminar todo"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
"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.",
|
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
"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",
|
"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",
|
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||||
|
"bulkFavoriteUpdating": "Añadiendo {count} modelo(s) a favoritos...",
|
||||||
|
"bulkUnfavoriteUpdating": "Eliminando {count} modelo(s) de favoritos...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} modelo(s) añadido(s) a favoritos, {failed} fallido(s)",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} modelo(s) eliminado(s) de favoritos, {failed} fallido(s)",
|
||||||
|
"bulkFavoriteFailed": "Error al actualizar el estado de favorito",
|
||||||
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
||||||
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||||
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
"noTags": "Aucun tag",
|
"noTags": "Aucun tag",
|
||||||
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||||
"clearAll": "Effacer tous les filtres",
|
"clearAll": "Effacer tous les filtres",
|
||||||
"any": "N'importe quel",
|
"any": "N'importe quel",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des modèles",
|
"title": "Actualiser la liste des modèles",
|
||||||
"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",
|
"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."
|
"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."
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "Auto-organiser la sélection",
|
"autoOrganize": "Auto-organiser la sélection",
|
||||||
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
||||||
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
||||||
|
"setFavorite": "Définir comme favori",
|
||||||
|
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
|
||||||
|
"unfavorite": "Retirer des favoris",
|
||||||
"deleteAll": "Supprimer la sélection",
|
"deleteAll": "Supprimer la sélection",
|
||||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||||
|
"downloadExamples": "Télécharger les images d'exemple",
|
||||||
"clear": "Effacer la sélection",
|
"clear": "Effacer la sélection",
|
||||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||||
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
||||||
|
"sendToWorkflow": "Envoyer au workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Métadonnées",
|
||||||
|
"attributes": "Attributs",
|
||||||
|
"organize": "Organiser",
|
||||||
|
"download": "Télécharger"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Initialisation de l'auto-organisation...",
|
"initializing": "Initialisation de l'auto-organisation...",
|
||||||
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des recipes",
|
"title": "Actualiser la liste des recipes",
|
||||||
"quick": "Synchroniser les changements",
|
|
||||||
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
|
|
||||||
"full": "Reconstruire le cache",
|
"full": "Reconstruire le cache",
|
||||||
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "modèles seront définitivement supprimés.",
|
"countMessage": "modèles seront définitivement supprimés.",
|
||||||
"action": "Tout supprimer"
|
"action": "Tout supprimer"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Supprimer plusieurs recipes",
|
||||||
|
"message": "Êtes-vous sûr de vouloir supprimer toutes les recipes sélectionnées et leurs fichiers associés ?",
|
||||||
|
"countMessage": "recipes seront définitivement supprimées.",
|
||||||
|
"action": "Tout supprimer"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
"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.",
|
"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.",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
"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)",
|
"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",
|
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||||
|
"bulkFavoriteUpdating": "Ajout de {count} modèle(s) aux favoris...",
|
||||||
|
"bulkUnfavoriteUpdating": "Suppression de {count} modèle(s) des favoris...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} modèle(s) ajouté(s) aux favoris, {failed} échec(s)",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} modèle(s) retiré(s) des favoris, {failed} échec(s)",
|
||||||
|
"bulkFavoriteFailed": "Échec de la mise à jour du statut de favori",
|
||||||
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} 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",
|
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||||
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
"noTags": "ללא תגיות",
|
"noTags": "ללא תגיות",
|
||||||
|
"autoTags": "תגיות אוטומטיות",
|
||||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||||
"clearAll": "נקה את כל המסננים",
|
"clearAll": "נקה את כל המסננים",
|
||||||
"any": "כלשהו",
|
"any": "כלשהו",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מודלים",
|
"title": "רענן רשימת מודלים",
|
||||||
"quick": "סנכרון שינויים",
|
|
||||||
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
|
|
||||||
"full": "בניית מטמון מחדש",
|
"full": "בניית מטמון מחדש",
|
||||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||||
|
"setFavorite": "הגדר כמועדף",
|
||||||
|
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
|
||||||
|
"unfavorite": "הסר ממועדפים",
|
||||||
"deleteAll": "מחק נבחרים",
|
"deleteAll": "מחק נבחרים",
|
||||||
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||||
|
"downloadExamples": "הורד תמונות דוגמה",
|
||||||
"clear": "נקה בחירה",
|
"clear": "נקה בחירה",
|
||||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||||
|
"sendToWorkflow": "שלח ל-Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "מטא-נתונים",
|
||||||
|
"attributes": "מאפיינים",
|
||||||
|
"organize": "ארגן",
|
||||||
|
"download": "הורדה"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "מאתחל ארגון אוטומטי...",
|
"initializing": "מאתחל ארגון אוטומטי...",
|
||||||
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מתכונים",
|
"title": "רענן רשימת מתכונים",
|
||||||
"quick": "סנכרן שינויים",
|
|
||||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
|
||||||
"full": "בנה מטמון מחדש",
|
"full": "בנה מטמון מחדש",
|
||||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "מודלים יימחקו לצמיתות.",
|
"countMessage": "מודלים יימחקו לצמיתות.",
|
||||||
"action": "מחק הכל"
|
"action": "מחק הכל"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "מחק מספר מתכונים",
|
||||||
|
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
|
||||||
|
"countMessage": "מתכונים יימחקו לצמיתות.",
|
||||||
|
"action": "מחק הכל"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
||||||
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
||||||
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
||||||
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
||||||
|
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
|
||||||
|
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
|
||||||
|
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
|
||||||
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
||||||
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
||||||
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
"noTags": "タグなし",
|
"noTags": "タグなし",
|
||||||
|
"autoTags": "自動タグ",
|
||||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||||
"clearAll": "すべてのフィルタをクリア",
|
"clearAll": "すべてのフィルタをクリア",
|
||||||
"any": "いずれか",
|
"any": "いずれか",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "モデルリストを更新",
|
"title": "モデルリストを更新",
|
||||||
"quick": "変更を同期",
|
|
||||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
|
||||||
"full": "キャッシュを再構築",
|
"full": "キャッシュを再構築",
|
||||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "自動整理を実行",
|
"autoOrganize": "自動整理を実行",
|
||||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||||
|
"setFavorite": "お気に入りに設定",
|
||||||
|
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
|
||||||
|
"unfavorite": "お気に入りから削除",
|
||||||
"deleteAll": "選択したものを削除",
|
"deleteAll": "選択したものを削除",
|
||||||
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||||
|
"downloadExamples": "例画像をダウンロード",
|
||||||
"clear": "選択をクリア",
|
"clear": "選択をクリア",
|
||||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||||
|
"sendToWorkflow": "ワークフローに送信",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "ワークフロー",
|
||||||
|
"metadata": "メタデータ",
|
||||||
|
"attributes": "属性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "ダウンロード"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "自動整理を初期化中...",
|
"initializing": "自動整理を初期化中...",
|
||||||
"starting": "{type}の自動整理を開始中...",
|
"starting": "{type}の自動整理を開始中...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "レシピリストを更新",
|
"title": "レシピリストを更新",
|
||||||
"quick": "変更を同期",
|
|
||||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
|
||||||
"full": "キャッシュを再構築",
|
"full": "キャッシュを再構築",
|
||||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "モデルが完全に削除されます。",
|
"countMessage": "モデルが完全に削除されます。",
|
||||||
"action": "すべて削除"
|
"action": "すべて削除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "複数のレシピを削除",
|
||||||
|
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
|
||||||
|
"countMessage": "レシピが完全に削除されます。",
|
||||||
|
"action": "すべて削除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "すべての{type}の更新を確認しますか?",
|
"title": "すべての{type}の更新を確認しますか?",
|
||||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||||
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||||
|
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
|
||||||
|
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
|
||||||
|
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
|
||||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
"noTags": "태그 없음",
|
"noTags": "태그 없음",
|
||||||
|
"autoTags": "자동 태그",
|
||||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||||
"clearAll": "모든 필터 지우기",
|
"clearAll": "모든 필터 지우기",
|
||||||
"any": "아무",
|
"any": "아무",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "모델 목록 새로고침",
|
"title": "모델 목록 새로고침",
|
||||||
"quick": "변경 사항 동기화",
|
|
||||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
|
||||||
"full": "캐시 재구성",
|
"full": "캐시 재구성",
|
||||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "자동 정리 선택",
|
"autoOrganize": "자동 정리 선택",
|
||||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||||
|
"setFavorite": "즐겨찾기로 설정",
|
||||||
|
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
|
||||||
|
"unfavorite": "즐겨찾기 해제",
|
||||||
"deleteAll": "선택된 항목 삭제",
|
"deleteAll": "선택된 항목 삭제",
|
||||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
|
"downloadExamples": "예시 이미지 다운로드",
|
||||||
"clear": "선택 지우기",
|
"clear": "선택 지우기",
|
||||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||||
|
"sendToWorkflow": "워크플로우로 보내기",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "워크플로우",
|
||||||
|
"metadata": "메타데이터",
|
||||||
|
"attributes": "속성",
|
||||||
|
"organize": "정리",
|
||||||
|
"download": "다운로드"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "자동 정리 초기화 중...",
|
"initializing": "자동 정리 초기화 중...",
|
||||||
"starting": "{type}에 대한 자동 정리 시작...",
|
"starting": "{type}에 대한 자동 정리 시작...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "레시피 목록 새로고침",
|
"title": "레시피 목록 새로고침",
|
||||||
"quick": "변경 사항 동기화",
|
|
||||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
|
||||||
"full": "캐시 재구성",
|
"full": "캐시 재구성",
|
||||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||||
"action": "모두 삭제"
|
"action": "모두 삭제"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "여러 레시피 삭제",
|
||||||
|
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
|
||||||
|
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
|
||||||
|
"action": "모두 삭제"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||||
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||||
|
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
|
||||||
|
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
|
||||||
|
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
|
||||||
|
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
|
||||||
|
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
|
||||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
"noTags": "Без тегов",
|
"noTags": "Без тегов",
|
||||||
|
"autoTags": "Авто-теги",
|
||||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||||
"clearAll": "Очистить все фильтры",
|
"clearAll": "Очистить все фильтры",
|
||||||
"any": "Любой",
|
"any": "Любой",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список моделей",
|
"title": "Обновить список моделей",
|
||||||
"quick": "Синхронизировать изменения",
|
|
||||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
|
||||||
"full": "Перестроить кэш",
|
"full": "Перестроить кэш",
|
||||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "Автоматически организовать выбранные",
|
"autoOrganize": "Автоматически организовать выбранные",
|
||||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||||
|
"setFavorite": "Добавить в избранное",
|
||||||
|
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
|
||||||
|
"unfavorite": "Удалить из избранного",
|
||||||
"deleteAll": "Удалить выбранные",
|
"deleteAll": "Удалить выбранные",
|
||||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||||
|
"downloadExamples": "Загрузить примеры изображений",
|
||||||
"clear": "Очистить выбор",
|
"clear": "Очистить выбор",
|
||||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||||
|
"sendToWorkflow": "Отправить в Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Метаданные",
|
||||||
|
"attributes": "Атрибуты",
|
||||||
|
"organize": "Организовать",
|
||||||
|
"download": "Скачать"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Инициализация автоматической организации...",
|
"initializing": "Инициализация автоматической организации...",
|
||||||
"starting": "Запуск автоматической организации для {type}...",
|
"starting": "Запуск автоматической организации для {type}...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список рецептов",
|
"title": "Обновить список рецептов",
|
||||||
"quick": "Синхронизировать изменения",
|
|
||||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
|
||||||
"full": "Перестроить кэш",
|
"full": "Перестроить кэш",
|
||||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "моделей будут удалены навсегда.",
|
"countMessage": "моделей будут удалены навсегда.",
|
||||||
"action": "Удалить все"
|
"action": "Удалить все"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Удалить несколько рецептов",
|
||||||
|
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
|
||||||
|
"countMessage": "рецептов будут удалены навсегда.",
|
||||||
|
"action": "Удалить все"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Проверить обновления для всех {typePlural}?",
|
"title": "Проверить обновления для всех {typePlural}?",
|
||||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||||
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||||
|
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
|
||||||
|
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
|
||||||
|
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
|
||||||
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||||
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||||
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
"noTags": "无标签",
|
"noTags": "无标签",
|
||||||
|
"autoTags": "自动标签",
|
||||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||||
"clearAll": "清除所有筛选",
|
"clearAll": "清除所有筛选",
|
||||||
"any": "任一",
|
"any": "任一",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新模型列表",
|
"title": "刷新模型列表",
|
||||||
"quick": "同步变更",
|
|
||||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
|
||||||
"full": "重建缓存",
|
"full": "重建缓存",
|
||||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "自动整理所选模型",
|
"autoOrganize": "自动整理所选模型",
|
||||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||||
|
"setFavorite": "设为收藏",
|
||||||
|
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
"deleteAll": "删除已选",
|
"deleteAll": "删除已选",
|
||||||
"downloadMissingLoras": "下载缺失的 LoRAs",
|
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||||
|
"downloadExamples": "下载示例图片",
|
||||||
"clear": "清除选择",
|
"clear": "清除选择",
|
||||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||||
|
"sendToWorkflow": "发送到工作流",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "工作流",
|
||||||
|
"metadata": "元数据",
|
||||||
|
"attributes": "属性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "下载"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "正在初始化自动整理...",
|
"initializing": "正在初始化自动整理...",
|
||||||
"starting": "正在为 {type} 启动自动整理...",
|
"starting": "正在为 {type} 启动自动整理...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新配方列表",
|
"title": "刷新配方列表",
|
||||||
"quick": "同步变更",
|
|
||||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
|
||||||
"full": "重建缓存",
|
"full": "重建缓存",
|
||||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "模型将被永久删除。",
|
"countMessage": "模型将被永久删除。",
|
||||||
"action": "全部删除"
|
"action": "全部删除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "删除多个配方",
|
||||||
|
"message": "你确定要删除所有选中的配方及其相关文件吗?",
|
||||||
|
"countMessage": "配方将被永久删除。",
|
||||||
|
"action": "全部删除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "检查所有 {type} 的更新?",
|
"title": "检查所有 {type} 的更新?",
|
||||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||||
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||||
|
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
|
||||||
|
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
|
||||||
|
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
|
||||||
|
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
|
||||||
|
"bulkFavoriteFailed": "更新收藏状态失败",
|
||||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
"noTags": "無標籤",
|
"noTags": "無標籤",
|
||||||
|
"autoTags": "自動標籤",
|
||||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||||
"clearAll": "清除所有篩選",
|
"clearAll": "清除所有篩選",
|
||||||
"any": "任一",
|
"any": "任一",
|
||||||
@@ -640,8 +641,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理模型列表",
|
"title": "重新整理模型列表",
|
||||||
"quick": "同步變更",
|
|
||||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
|
||||||
"full": "重建快取",
|
"full": "重建快取",
|
||||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||||
},
|
},
|
||||||
@@ -687,11 +686,23 @@
|
|||||||
"autoOrganize": "自動整理所選模型",
|
"autoOrganize": "自動整理所選模型",
|
||||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||||
|
"setFavorite": "設為收藏",
|
||||||
|
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
"deleteAll": "刪除所選",
|
"deleteAll": "刪除所選",
|
||||||
"downloadMissingLoras": "下載缺失的 LoRAs",
|
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||||
|
"downloadExamples": "下載範例圖片",
|
||||||
"clear": "清除選取",
|
"clear": "清除選取",
|
||||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||||
|
"sendToWorkflow": "發送到工作流",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "工作流",
|
||||||
|
"metadata": "元數據",
|
||||||
|
"attributes": "屬性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "下載"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "正在初始化自動整理...",
|
"initializing": "正在初始化自動整理...",
|
||||||
"starting": "正在開始自動整理 {type}...",
|
"starting": "正在開始自動整理 {type}...",
|
||||||
@@ -804,8 +815,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理配方列表",
|
"title": "重新整理配方列表",
|
||||||
"quick": "同步變更",
|
|
||||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
|
||||||
"full": "重建快取",
|
"full": "重建快取",
|
||||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||||
},
|
},
|
||||||
@@ -1077,6 +1086,12 @@
|
|||||||
"countMessage": "模型將被永久刪除。",
|
"countMessage": "模型將被永久刪除。",
|
||||||
"action": "全部刪除"
|
"action": "全部刪除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "刪除多個配方",
|
||||||
|
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
|
||||||
|
"countMessage": "配方將被永久刪除。",
|
||||||
|
"action": "全部刪除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "要檢查所有 {type} 的更新嗎?",
|
"title": "要檢查所有 {type} 的更新嗎?",
|
||||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||||
@@ -1699,6 +1714,11 @@
|
|||||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||||
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||||
|
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
|
||||||
|
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
|
||||||
|
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
|
||||||
|
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
|
||||||
|
"bulkFavoriteFailed": "更新收藏狀態失敗",
|
||||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||||
|
|||||||
96
py/config.py
@@ -172,6 +172,12 @@ class Config:
|
|||||||
self.extra_unet_roots: List[str] = []
|
self.extra_unet_roots: List[str] = []
|
||||||
self.extra_embeddings_roots: List[str] = []
|
self.extra_embeddings_roots: List[str] = []
|
||||||
self.recipes_path: str = ""
|
self.recipes_path: str = ""
|
||||||
|
|
||||||
|
# Load extra folder paths from active library settings before symlink scan
|
||||||
|
# so both primary and extra paths are discovered in a single pass.
|
||||||
|
if not standalone_mode:
|
||||||
|
self._load_extra_paths_from_settings()
|
||||||
|
|
||||||
# Scan symbolic links during initialization
|
# Scan symbolic links during initialization
|
||||||
self._initialize_symlink_mappings()
|
self._initialize_symlink_mappings()
|
||||||
|
|
||||||
@@ -179,6 +185,96 @@ class Config:
|
|||||||
# Save the paths to settings.json when running in ComfyUI mode
|
# Save the paths to settings.json when running in ComfyUI mode
|
||||||
self.save_folder_paths_to_settings()
|
self.save_folder_paths_to_settings()
|
||||||
|
|
||||||
|
def _load_extra_paths_from_settings(self) -> None:
|
||||||
|
"""Read extra folder paths from the active library and apply them.
|
||||||
|
|
||||||
|
Called during ``Config.__init__`` before the symlink scan so both primary and
|
||||||
|
extra paths are discovered in a single pass. Mirrors the extra-path
|
||||||
|
portion of ``_apply_library_paths`` without replacing the primary roots
|
||||||
|
that were already resolved from ComfyUI's ``folder_paths``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
library_name = settings_manager.get_active_library_name()
|
||||||
|
libraries = settings_manager.get_libraries()
|
||||||
|
|
||||||
|
if not library_name or library_name not in libraries:
|
||||||
|
return
|
||||||
|
|
||||||
|
library_config = libraries[library_name]
|
||||||
|
if not isinstance(library_config, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
extra_folder_paths = library_config.get("extra_folder_paths")
|
||||||
|
if not isinstance(extra_folder_paths, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
extra_lora = extra_folder_paths.get("loras", []) or []
|
||||||
|
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
|
||||||
|
extra_unet = extra_folder_paths.get("unet", []) or []
|
||||||
|
extra_embedding = extra_folder_paths.get("embeddings", []) or []
|
||||||
|
|
||||||
|
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
|
||||||
|
return
|
||||||
|
|
||||||
|
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
|
||||||
|
self.loras_roots, extra_lora
|
||||||
|
)
|
||||||
|
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora)
|
||||||
|
(
|
||||||
|
_,
|
||||||
|
self.extra_checkpoints_roots,
|
||||||
|
self.extra_unet_roots,
|
||||||
|
) = self._prepare_checkpoint_paths(extra_checkpoint, extra_unet)
|
||||||
|
self.extra_embeddings_roots = self._prepare_embedding_paths(
|
||||||
|
extra_embedding
|
||||||
|
)
|
||||||
|
|
||||||
|
recipes_path = library_config.get("recipes_path", "")
|
||||||
|
if isinstance(recipes_path, str) and recipes_path:
|
||||||
|
self.recipes_path = recipes_path
|
||||||
|
|
||||||
|
if self.extra_loras_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra LoRA roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_loras_roots)
|
||||||
|
)
|
||||||
|
if self.extra_checkpoints_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra checkpoint roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_checkpoints_roots)
|
||||||
|
)
|
||||||
|
if self.extra_unet_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra diffusion model roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_unet_roots)
|
||||||
|
)
|
||||||
|
if self.extra_embeddings_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra embedding roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_embeddings_roots)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Applied library settings for '%s' with extra paths: loras=%s, "
|
||||||
|
"checkpoints=%s, embeddings=%s",
|
||||||
|
library_name,
|
||||||
|
extra_lora,
|
||||||
|
extra_checkpoint,
|
||||||
|
extra_embedding,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Could not load extra paths from library settings: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
def save_folder_paths_to_settings(self):
|
def save_folder_paths_to_settings(self):
|
||||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -184,39 +184,6 @@ class LoraManager:
|
|||||||
async def _initialize_services(cls):
|
async def _initialize_services(cls):
|
||||||
"""Initialize all services using the ServiceRegistry"""
|
"""Initialize all services using the ServiceRegistry"""
|
||||||
try:
|
try:
|
||||||
# Apply library settings to load extra folder paths before scanning
|
|
||||||
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
|
|
||||||
try:
|
|
||||||
from .services.settings_manager import get_settings_manager
|
|
||||||
|
|
||||||
settings_manager = get_settings_manager()
|
|
||||||
library_name = settings_manager.get_active_library_name()
|
|
||||||
libraries = settings_manager.get_libraries()
|
|
||||||
if library_name and library_name in libraries:
|
|
||||||
library_config = libraries[library_name]
|
|
||||||
# Only apply settings if extra paths are not already configured
|
|
||||||
# This preserves values set by tests via monkeypatch
|
|
||||||
extra_paths = library_config.get("extra_folder_paths", {})
|
|
||||||
has_extra_paths = (
|
|
||||||
config.extra_loras_roots
|
|
||||||
or config.extra_checkpoints_roots
|
|
||||||
or config.extra_unet_roots
|
|
||||||
or config.extra_embeddings_roots
|
|
||||||
)
|
|
||||||
if not has_extra_paths and any(extra_paths.values()):
|
|
||||||
config.apply_library_settings(library_config)
|
|
||||||
logger.info(
|
|
||||||
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
|
|
||||||
library_name,
|
|
||||||
extra_paths.get("loras", []),
|
|
||||||
extra_paths.get("checkpoints", []),
|
|
||||||
extra_paths.get("embeddings", []),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to apply library settings during initialization: %s", exc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize CivitaiClient first to ensure it's ready for other services
|
# Initialize CivitaiClient first to ensure it's ready for other services
|
||||||
await ServiceRegistry.get_civitai_client()
|
await ServiceRegistry.get_civitai_client()
|
||||||
|
|
||||||
|
|||||||
@@ -560,8 +560,14 @@ class MetadataProcessor:
|
|||||||
|
|
||||||
params["loras"] = " ".join(lora_parts)
|
params["loras"] = " ".join(lora_parts)
|
||||||
|
|
||||||
# Set default clip_skip value
|
# Extract clip_skip from any SAMPLING node that provides it
|
||||||
params["clip_skip"] = "1" # Common default
|
for sampler_info in metadata.get(SAMPLING, {}).values():
|
||||||
|
clip_skip = sampler_info.get("parameters", {}).get("clip_skip")
|
||||||
|
if clip_skip is not None:
|
||||||
|
params["clip_skip"] = clip_skip
|
||||||
|
break
|
||||||
|
if params["clip_skip"] is None:
|
||||||
|
params["clip_skip"] = "1"
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
|||||||
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
||||||
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class EasyComfyLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "ckpt_name" in inputs:
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, inputs["ckpt_name"])
|
||||||
|
|
||||||
|
# Only extract from optional_lora_stack — skip the single lora_name to
|
||||||
|
# avoid double-counting LoRAs that come through the LORA_STACK path.
|
||||||
|
active_loras = []
|
||||||
|
optional_lora_stack = inputs.get("optional_lora_stack")
|
||||||
|
if optional_lora_stack is not None and isinstance(optional_lora_stack, (list, tuple)):
|
||||||
|
for item in optional_lora_stack:
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
lora_path = item[0]
|
||||||
|
model_strength = item[1]
|
||||||
|
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": model_strength
|
||||||
|
})
|
||||||
|
|
||||||
|
if active_loras:
|
||||||
|
metadata[LORAS][node_id] = {
|
||||||
|
"lora_list": active_loras,
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
positive_text = inputs.get("positive", "")
|
||||||
|
negative_text = inputs.get("negative", "")
|
||||||
|
|
||||||
|
if positive_text or negative_text:
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
metadata[PROMPTS][node_id]["positive_text"] = positive_text
|
||||||
|
metadata[PROMPTS][node_id]["negative_text"] = negative_text
|
||||||
|
|
||||||
|
if "clip_skip" in inputs:
|
||||||
|
clip_skip = inputs["clip_skip"]
|
||||||
|
if node_id not in metadata[SAMPLING]:
|
||||||
|
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
|
||||||
|
metadata[SAMPLING][node_id]["parameters"]["clip_skip"] = clip_skip
|
||||||
|
|
||||||
|
width = inputs.get("empty_latent_width")
|
||||||
|
height = inputs.get("empty_latent_height")
|
||||||
|
if width is not None and height is not None:
|
||||||
|
if SIZE not in metadata:
|
||||||
|
metadata[SIZE] = {}
|
||||||
|
metadata[SIZE][node_id] = {
|
||||||
|
"width": int(width),
|
||||||
|
"height": int(height),
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
# outputs: [(pipe_dict, model, vae), ...]
|
||||||
|
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||||
|
return
|
||||||
|
first_output = outputs[0]
|
||||||
|
if not isinstance(first_output, tuple) or len(first_output) < 1:
|
||||||
|
return
|
||||||
|
pipe = first_output[0]
|
||||||
|
if not isinstance(pipe, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
positive_conditioning = pipe.get("positive")
|
||||||
|
negative_conditioning = pipe.get("negative")
|
||||||
|
|
||||||
|
if positive_conditioning is not None or negative_conditioning is not None:
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
if positive_conditioning is not None:
|
||||||
|
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
||||||
|
if negative_conditioning is not None:
|
||||||
|
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class EasyPreSamplingExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
sampling_params = {}
|
||||||
|
for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
|
||||||
|
if key in inputs:
|
||||||
|
sampling_params[key] = inputs[key]
|
||||||
|
|
||||||
|
metadata[SAMPLING][node_id] = {
|
||||||
|
"parameters": sampling_params,
|
||||||
|
"node_id": node_id,
|
||||||
|
IS_SAMPLER: True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EasySeedExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "seed" not in inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata[SAMPLING][node_id] = {
|
||||||
|
"parameters": {"seed": inputs["seed"]},
|
||||||
|
"node_id": node_id,
|
||||||
|
IS_SAMPLER: False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -1013,9 +1125,12 @@ NODE_EXTRACTORS = {
|
|||||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||||
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
||||||
|
# ComfyUI-Easy-Use pre-sampling / seed
|
||||||
|
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
|
||||||
|
"easySeed": EasySeedExtractor, # easy seed
|
||||||
# Loaders
|
# Loaders
|
||||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
|
||||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import folder_paths # type: ignore
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from ..config import config
|
||||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_within_loras(abs_path):
|
||||||
|
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
|
||||||
|
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
|
||||||
|
for root in all_roots:
|
||||||
|
try:
|
||||||
|
return os.path.relpath(abs_path, root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
class WanVideoLoraSelectLM:
|
class WanVideoLoraSelectLM:
|
||||||
NAME = "WanVideo Lora Select (LoraManager)"
|
NAME = "WanVideo Lora Select (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/stackers"
|
CATEGORY = "Lora Manager/stackers"
|
||||||
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
|
|||||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||||
|
|
||||||
# Get lora path and trigger words
|
# Get lora path and trigger words
|
||||||
lora_path, trigger_words = get_lora_info(lora_name)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||||
|
|
||||||
# Create lora item for WanVideo format
|
# Create lora item for WanVideo format
|
||||||
lora_item = {
|
lora_item = {
|
||||||
"path": folder_paths.get_full_path("loras", lora_path),
|
"path": lora_path,
|
||||||
"strength": model_strength,
|
"strength": model_strength,
|
||||||
"name": lora_path.split(".")[0],
|
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import folder_paths # type: ignore
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from ..config import config
|
||||||
from .utils import any_type
|
from .utils import any_type
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# 初始化日志记录器
|
# 初始化日志记录器
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_within_loras(abs_path):
|
||||||
|
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
|
||||||
|
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
|
||||||
|
for root in all_roots:
|
||||||
|
try:
|
||||||
|
return os.path.relpath(abs_path, root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
# 定义新节点的类
|
# 定义新节点的类
|
||||||
class WanVideoLoraTextSelectLM:
|
class WanVideoLoraTextSelectLM:
|
||||||
# 节点在UI中显示的名称
|
# 节点在UI中显示的名称
|
||||||
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lora_path, trigger_words = get_lora_info(lora_name_raw)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
|
||||||
|
|
||||||
lora_item = {
|
lora_item = {
|
||||||
"path": folder_paths.get_full_path("loras", lora_path),
|
"path": lora_path,
|
||||||
"strength": model_strength,
|
"strength": model_strength,
|
||||||
"name": lora_path.split(".")[0],
|
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ class RecipeEnricher:
|
|||||||
async def enrich_recipe(
|
async def enrich_recipe(
|
||||||
recipe: Dict[str, Any],
|
recipe: Dict[str, Any],
|
||||||
civitai_client: Any,
|
civitai_client: Any,
|
||||||
request_params: Optional[Dict[str, Any]] = None
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
|
prefetched_civitai_meta_raw: Optional[Dict[str, Any]] = None,
|
||||||
|
prefetched_model_version_id: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||||
@@ -25,6 +27,9 @@ class RecipeEnricher:
|
|||||||
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||||
civitai_client: Authenticated Civitai client instance.
|
civitai_client: Authenticated Civitai client instance.
|
||||||
request_params: (Optional) Parameters from a user request (e.g. import).
|
request_params: (Optional) Parameters from a user request (e.g. import).
|
||||||
|
prefetched_civitai_meta_raw: (Optional) Pre-fetched raw meta from Civitai
|
||||||
|
get_image_info, avoiding a duplicate API call.
|
||||||
|
prefetched_model_version_id: (Optional) Pre-fetched model version ID.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the recipe was modified, False otherwise.
|
bool: True if the recipe was modified, False otherwise.
|
||||||
@@ -32,39 +37,44 @@ class RecipeEnricher:
|
|||||||
updated = False
|
updated = False
|
||||||
gen_params = recipe.get("gen_params", {})
|
gen_params = recipe.get("gen_params", {})
|
||||||
|
|
||||||
# 1. Fetch Civitai Info if available
|
# 1. Obtain Civitai metadata
|
||||||
civitai_meta = None
|
civitai_meta = None
|
||||||
model_version_id = None
|
model_version_id = prefetched_model_version_id
|
||||||
|
|
||||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
source_path = recipe.get("source_path", "")
|
||||||
|
|
||||||
# Check if it's a Civitai image URL
|
if prefetched_civitai_meta_raw is not None:
|
||||||
image_id = extract_civitai_image_id(str(source_url))
|
raw_meta = prefetched_civitai_meta_raw
|
||||||
if image_id:
|
if isinstance(raw_meta, dict):
|
||||||
try:
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
image_info = await civitai_client.get_image_info(
|
civitai_meta = raw_meta["meta"]
|
||||||
image_id, source_url=str(source_url)
|
else:
|
||||||
)
|
civitai_meta = raw_meta
|
||||||
if image_info:
|
else:
|
||||||
# Handle nested meta often found in Civitai API responses
|
image_id = extract_civitai_image_id(str(source_path))
|
||||||
raw_meta = image_info.get("meta")
|
if image_id:
|
||||||
if isinstance(raw_meta, dict):
|
try:
|
||||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
image_info = await civitai_client.get_image_info(
|
||||||
civitai_meta = raw_meta["meta"]
|
image_id, source_url=str(source_path)
|
||||||
else:
|
)
|
||||||
civitai_meta = raw_meta
|
if image_info:
|
||||||
|
raw_meta = image_info.get("meta")
|
||||||
|
if isinstance(raw_meta, dict):
|
||||||
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
|
civitai_meta = raw_meta["meta"]
|
||||||
|
else:
|
||||||
|
civitai_meta = raw_meta
|
||||||
|
|
||||||
model_version_id = image_info.get("modelVersionId")
|
model_version_id = image_info.get("modelVersionId")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||||
|
|
||||||
# If not at top level, check resources in meta
|
if not model_version_id and civitai_meta:
|
||||||
if not model_version_id and civitai_meta:
|
resources = civitai_meta.get("civitaiResources", [])
|
||||||
resources = civitai_meta.get("civitaiResources", [])
|
for res in resources:
|
||||||
for res in resources:
|
if res.get("type") == "checkpoint":
|
||||||
if res.get("type") == "checkpoint":
|
model_version_id = res.get("modelVersionId")
|
||||||
model_version_id = res.get("modelVersionId")
|
break
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
|
||||||
|
|
||||||
# 2. Merge Parameters
|
# 2. Merge Parameters
|
||||||
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from ...services.metadata_service import (
|
|||||||
update_metadata_providers,
|
update_metadata_providers,
|
||||||
)
|
)
|
||||||
from ...services.service_registry import ServiceRegistry
|
from ...services.service_registry import ServiceRegistry
|
||||||
|
from ...services.model_lifecycle_service import delete_model_artifacts
|
||||||
from ...services.settings_manager import get_settings_manager
|
from ...services.settings_manager import get_settings_manager
|
||||||
from ...services.websocket_manager import ws_manager
|
from ...services.websocket_manager import ws_manager
|
||||||
from ...services.downloader import get_downloader
|
from ...services.downloader import get_downloader
|
||||||
@@ -1791,29 +1792,33 @@ class ModelLibraryHandler:
|
|||||||
exists = True
|
exists = True
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"exists": True,
|
||||||
|
"modelType": model_type,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
has_been_downloaded = False
|
has_been_downloaded = False
|
||||||
history_type = model_type
|
history_type = None
|
||||||
if history_type:
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
has_been_downloaded = await history_service.has_been_downloaded(
|
if await history_service.has_been_downloaded(
|
||||||
history_type,
|
candidate_type,
|
||||||
model_version_id,
|
model_version_id,
|
||||||
)
|
):
|
||||||
else:
|
has_been_downloaded = True
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
history_type = candidate_type
|
||||||
if await history_service.has_been_downloaded(
|
break
|
||||||
candidate_type,
|
|
||||||
model_version_id,
|
|
||||||
):
|
|
||||||
has_been_downloaded = True
|
|
||||||
history_type = candidate_type
|
|
||||||
break
|
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"exists": exists,
|
"exists": False,
|
||||||
"modelType": model_type if exists else history_type,
|
"modelType": history_type,
|
||||||
"hasBeenDownloaded": has_been_downloaded,
|
"hasBeenDownloaded": has_been_downloaded,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1833,40 +1838,46 @@ class ModelLibraryHandler:
|
|||||||
model_type = None
|
model_type = None
|
||||||
versions = []
|
versions = []
|
||||||
downloaded_version_ids = []
|
downloaded_version_ids = []
|
||||||
history_service = await self._get_download_history_service()
|
|
||||||
if lora_versions:
|
if lora_versions:
|
||||||
model_type = "lora"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(lora_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "lora",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(lora_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
elif checkpoint_versions:
|
if checkpoint_versions:
|
||||||
model_type = "checkpoint"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(checkpoint_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "checkpoint",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
elif embedding_versions:
|
if embedding_versions:
|
||||||
model_type = "embedding"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(embedding_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "embedding",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(embedding_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
history_service = await self._get_download_history_service()
|
||||||
candidate_downloaded_version_ids = (
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
await history_service.get_downloaded_version_ids(
|
candidate_downloaded_version_ids = (
|
||||||
candidate_type,
|
await history_service.get_downloaded_version_ids(
|
||||||
model_id,
|
candidate_type,
|
||||||
)
|
model_id,
|
||||||
)
|
)
|
||||||
if candidate_downloaded_version_ids:
|
)
|
||||||
model_type = candidate_type
|
if candidate_downloaded_version_ids:
|
||||||
downloaded_version_ids = candidate_downloaded_version_ids
|
model_type = candidate_type
|
||||||
break
|
downloaded_version_ids = candidate_downloaded_version_ids
|
||||||
|
break
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1880,6 +1891,86 @@ class ModelLibraryHandler:
|
|||||||
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def check_models_exist(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_ids_raw = request.query.get("modelIds", "")
|
||||||
|
if not model_ids_raw:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_ids = model_ids_raw.split(",")
|
||||||
|
seen: set[int] = set()
|
||||||
|
model_ids: list[int] = []
|
||||||
|
for raw in raw_ids:
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mid = int(stripped)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if mid not in seen:
|
||||||
|
seen.add(mid)
|
||||||
|
model_ids.append(mid)
|
||||||
|
|
||||||
|
if not model_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
for model_id in model_ids:
|
||||||
|
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if lora_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "lora",
|
||||||
|
"versions": self._with_downloaded_flag(lora_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if checkpoint_scanner:
|
||||||
|
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if checkpoint_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "checkpoint",
|
||||||
|
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if embedding_scanner:
|
||||||
|
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if embedding_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "embedding",
|
||||||
|
"versions": self._with_downloaded_flag(embedding_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": None,
|
||||||
|
"versions": [],
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": results}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to check models existence: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_version_download_status(
|
async def get_model_version_download_status(
|
||||||
self, request: web.Request
|
self, request: web.Request
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
@@ -1974,7 +2065,7 @@ class ModelLibraryHandler:
|
|||||||
file_path=file_path if isinstance(file_path, str) else None,
|
file_path=file_path if isinstance(file_path, str) else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await history_service.mark_not_downloaded(model_type, model_version_id)
|
await history_service.mark_as_deleted(model_type, model_version_id)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1992,6 +2083,89 @@ class ModelLibraryHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def delete_model_version(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_version_id_str = request.query.get("modelVersionId")
|
||||||
|
if not model_version_id_str:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing required parameter: modelVersionId"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model_version_id = int(model_version_id_str)
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
|
found_type = None
|
||||||
|
file_path = None
|
||||||
|
found_cache = None
|
||||||
|
|
||||||
|
for model_type, scanner in (
|
||||||
|
("lora", lora_scanner),
|
||||||
|
("checkpoint", checkpoint_scanner),
|
||||||
|
("embedding", embedding_scanner),
|
||||||
|
):
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
if cache and model_version_id in cache.version_index:
|
||||||
|
found_type = model_type
|
||||||
|
found_cache = cache
|
||||||
|
entry = cache.version_index[model_version_id]
|
||||||
|
file_path = entry.get("file_path")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model version not found in any scanner cache"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
target_dir = os.path.dirname(file_path)
|
||||||
|
base_name = os.path.basename(file_path)
|
||||||
|
file_name, extension = os.path.splitext(base_name)
|
||||||
|
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||||
|
|
||||||
|
if found_cache:
|
||||||
|
found_cache.raw_data = [
|
||||||
|
item
|
||||||
|
for item in found_cache.raw_data
|
||||||
|
if item.get("file_path") != file_path
|
||||||
|
]
|
||||||
|
await found_cache.resort()
|
||||||
|
|
||||||
|
scanner_map = {
|
||||||
|
"lora": lora_scanner,
|
||||||
|
"checkpoint": checkpoint_scanner,
|
||||||
|
"embedding": embedding_scanner,
|
||||||
|
}
|
||||||
|
scanner = scanner_map.get(found_type)
|
||||||
|
if scanner:
|
||||||
|
persist = getattr(scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist):
|
||||||
|
await persist()
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
await history_service.mark_as_deleted(found_type, model_version_id)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"modelType": found_type,
|
||||||
|
"modelVersionId": model_version_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete model version: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
model_id_str = request.query.get("modelId")
|
model_id_str = request.query.get("modelId")
|
||||||
@@ -3025,8 +3199,10 @@ class MiscHandlerSet:
|
|||||||
"update_node_widget": self.node_registry.update_node_widget,
|
"update_node_widget": self.node_registry.update_node_widget,
|
||||||
"get_registry": self.node_registry.get_registry,
|
"get_registry": self.node_registry.get_registry,
|
||||||
"check_model_exists": self.model_library.check_model_exists,
|
"check_model_exists": self.model_library.check_model_exists,
|
||||||
|
"check_models_exist": self.model_library.check_models_exist,
|
||||||
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
||||||
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
||||||
|
"delete_model_version": self.model_library.delete_model_version,
|
||||||
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
||||||
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
||||||
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
||||||
|
|||||||
@@ -301,6 +301,15 @@ class ModelListingHandler:
|
|||||||
for tag in exclude_tags:
|
for tag in exclude_tags:
|
||||||
if tag:
|
if tag:
|
||||||
tag_filters[tag] = "exclude"
|
tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
auto_tag_filters: Dict[str, str] = {}
|
||||||
|
for tag in request.query.getall("auto_tag_include", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "include"
|
||||||
|
for tag in request.query.getall("auto_tag_exclude", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
favorites_only = request.query.get("favorites_only", "false").lower() == "true"
|
favorites_only = request.query.get("favorites_only", "false").lower() == "true"
|
||||||
|
|
||||||
search_options = {
|
search_options = {
|
||||||
@@ -367,6 +376,7 @@ class ModelListingHandler:
|
|||||||
"fuzzy_search": fuzzy_search,
|
"fuzzy_search": fuzzy_search,
|
||||||
"base_models": base_models,
|
"base_models": base_models,
|
||||||
"tags": tag_filters,
|
"tags": tag_filters,
|
||||||
|
"auto_tags": auto_tag_filters,
|
||||||
"tag_logic": tag_logic,
|
"tag_logic": tag_logic,
|
||||||
"search_options": search_options,
|
"search_options": search_options,
|
||||||
"hash_filters": hash_filters,
|
"hash_filters": hash_filters,
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ class RecipeHandlerSet:
|
|||||||
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
||||||
"start_directory_import": self.batch_import.start_directory_import,
|
"start_directory_import": self.batch_import.start_directory_import,
|
||||||
"browse_directory": self.batch_import.browse_directory,
|
"browse_directory": self.batch_import.browse_directory,
|
||||||
|
"check_image_exists": self.management.check_image_exists,
|
||||||
|
"import_from_url": self.management.import_from_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -541,7 +543,7 @@ class RecipeQueryHandler:
|
|||||||
)
|
)
|
||||||
response_data.append(
|
response_data.append(
|
||||||
{
|
{
|
||||||
"type": "source_url",
|
"type": "source_path",
|
||||||
"fingerprint": url,
|
"fingerprint": url,
|
||||||
"count": len(recipes),
|
"count": len(recipes),
|
||||||
"recipes": recipes,
|
"recipes": recipes,
|
||||||
@@ -607,6 +609,7 @@ class RecipeManagementHandler:
|
|||||||
self._downloader_factory = downloader_factory
|
self._downloader_factory = downloader_factory
|
||||||
self._civitai_client_getter = civitai_client_getter
|
self._civitai_client_getter = civitai_client_getter
|
||||||
self._ws_manager = ws_manager
|
self._ws_manager = ws_manager
|
||||||
|
self._import_semaphore = asyncio.Semaphore(2)
|
||||||
|
|
||||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -760,125 +763,28 @@ class RecipeManagementHandler:
|
|||||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||||
|
|
||||||
self._logger.info(
|
self._logger.info(
|
||||||
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
|
"Remote recipe import received: url=%s, lora_count=%d",
|
||||||
image_url,
|
image_url,
|
||||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
|
||||||
len(lora_entries),
|
len(lora_entries),
|
||||||
|
)
|
||||||
|
self._logger.debug(
|
||||||
|
" gen_params_keys=%s, checkpoint_keys=%s",
|
||||||
|
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Initial Metadata Construction
|
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
||||||
metadata: Dict[str, Any] = {
|
async with self._import_semaphore:
|
||||||
"base_model": params.get("base_model", "") or "",
|
return await self._do_import_remote_recipe(
|
||||||
"loras": lora_entries,
|
image_url=image_url,
|
||||||
"gen_params": gen_params_request or {},
|
name=name,
|
||||||
"source_url": image_url,
|
lora_entries=lora_entries,
|
||||||
}
|
checkpoint_entry=checkpoint_entry,
|
||||||
|
gen_params_request=gen_params_request,
|
||||||
source_path = params.get("source_path")
|
tags=self._parse_tags(params.get("tags")),
|
||||||
if source_path:
|
base_model=params.get("base_model", "") or "",
|
||||||
metadata["source_path"] = source_path
|
source_path=params.get("source_path") or image_url,
|
||||||
|
|
||||||
# Checkpoint handling
|
|
||||||
if checkpoint_entry:
|
|
||||||
metadata["checkpoint"] = checkpoint_entry
|
|
||||||
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
|
||||||
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
|
||||||
|
|
||||||
# Try to resolve base model from checkpoint if not explicitly provided
|
|
||||||
if not metadata["base_model"]:
|
|
||||||
base_model_from_metadata = (
|
|
||||||
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
|
||||||
)
|
|
||||||
if base_model_from_metadata:
|
|
||||||
metadata["base_model"] = base_model_from_metadata
|
|
||||||
|
|
||||||
tags = self._parse_tags(params.get("tags"))
|
|
||||||
|
|
||||||
# 3. Download Image
|
|
||||||
(
|
|
||||||
image_bytes,
|
|
||||||
extension,
|
|
||||||
civitai_meta_from_download,
|
|
||||||
) = await self._download_remote_media(image_url)
|
|
||||||
|
|
||||||
# 4. Extract Embedded Metadata
|
|
||||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
|
||||||
# with embedded data if we want it to merge it.
|
|
||||||
# However, logic in Enricher merges: request > civitai > embedded.
|
|
||||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
|
||||||
# OR pass them to enricher to handle?
|
|
||||||
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
|
||||||
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
|
||||||
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
|
||||||
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
|
||||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
|
||||||
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
|
||||||
|
|
||||||
# Let's extract embedded metadata first
|
|
||||||
embedded_gen_params = {}
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
suffix=extension, delete=False
|
|
||||||
) as temp_img:
|
|
||||||
temp_img.write(image_bytes)
|
|
||||||
temp_img_path = temp_img.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
|
||||||
if raw_embedded:
|
|
||||||
parser = (
|
|
||||||
self._analysis_service._recipe_parser_factory.create_parser(
|
|
||||||
raw_embedded
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if parser:
|
|
||||||
parsed_embedded = await parser.parse_metadata(
|
|
||||||
raw_embedded, recipe_scanner=recipe_scanner
|
|
||||||
)
|
|
||||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
|
||||||
embedded_gen_params = parsed_embedded["gen_params"]
|
|
||||||
else:
|
|
||||||
embedded_gen_params = {"raw_metadata": raw_embedded}
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temp_img_path):
|
|
||||||
os.unlink(temp_img_path)
|
|
||||||
except Exception as exc:
|
|
||||||
self._logger.warning(
|
|
||||||
"Failed to extract embedded metadata during import: %s", exc
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
|
|
||||||
if embedded_gen_params:
|
|
||||||
# Merge embedded into existing gen_params (which currently only has request params if any)
|
|
||||||
# But wait, we want request params to override everything.
|
|
||||||
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
|
||||||
metadata["gen_params"] = embedded_gen_params
|
|
||||||
|
|
||||||
# 5. Enrich with unified logic
|
|
||||||
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
|
||||||
civitai_client = self._civitai_client_getter()
|
|
||||||
await RecipeEnricher.enrich_recipe(
|
|
||||||
recipe=metadata,
|
|
||||||
civitai_client=civitai_client,
|
|
||||||
request_params=gen_params_request, # Pass explicit request params here to override
|
|
||||||
)
|
|
||||||
|
|
||||||
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
|
|
||||||
# we might want to manually merge it?
|
|
||||||
# But usually `import_remote_recipe` is used with Civitai URLs.
|
|
||||||
# For now, relying on Enricher's internal fetch is consistent with repair.
|
|
||||||
|
|
||||||
result = await self._persistence_service.save_recipe(
|
|
||||||
recipe_scanner=recipe_scanner,
|
|
||||||
image_bytes=image_bytes,
|
|
||||||
image_base64=None,
|
|
||||||
name=name,
|
|
||||||
tags=tags,
|
|
||||||
metadata=metadata,
|
|
||||||
extension=extension,
|
|
||||||
)
|
|
||||||
return web.json_response(result.payload, status=result.status)
|
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
return web.json_response({"error": str(exc)}, status=400)
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
except RecipeDownloadError as exc:
|
except RecipeDownloadError as exc:
|
||||||
@@ -889,6 +795,150 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _do_import_remote_recipe(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
image_url: str,
|
||||||
|
name: str,
|
||||||
|
lora_entries: list,
|
||||||
|
checkpoint_entry: dict,
|
||||||
|
gen_params_request: dict,
|
||||||
|
tags: list,
|
||||||
|
base_model: str,
|
||||||
|
source_path: str,
|
||||||
|
) -> web.Response:
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"base_model": base_model,
|
||||||
|
"loras": lora_entries,
|
||||||
|
"gen_params": gen_params_request or {},
|
||||||
|
"source_path": source_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkpoint_entry:
|
||||||
|
metadata["checkpoint"] = checkpoint_entry
|
||||||
|
if not metadata["base_model"]:
|
||||||
|
base_model_from_metadata = (
|
||||||
|
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||||
|
)
|
||||||
|
if base_model_from_metadata:
|
||||||
|
metadata["base_model"] = base_model_from_metadata
|
||||||
|
|
||||||
|
# Download image
|
||||||
|
(
|
||||||
|
image_bytes,
|
||||||
|
extension,
|
||||||
|
civitai_meta_raw,
|
||||||
|
model_version_id,
|
||||||
|
) = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
||||||
|
embedded_gen_params = {}
|
||||||
|
parsed_embedded = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=extension, delete=False
|
||||||
|
) as temp_img:
|
||||||
|
temp_img.write(image_bytes)
|
||||||
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_embedded = await asyncio.to_thread(
|
||||||
|
ExifUtils.extract_image_metadata, temp_img_path
|
||||||
|
)
|
||||||
|
if raw_embedded:
|
||||||
|
parser = (
|
||||||
|
self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
raw_embedded
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
else:
|
||||||
|
embedded_gen_params = {"raw_metadata": raw_embedded}
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_img_path):
|
||||||
|
os.unlink(temp_img_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Failed to extract embedded metadata during import: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse CivitAI API meta to discover all resources from modelVersionIds
|
||||||
|
# (modelVersionIds is injected at root level by _download_remote_media).
|
||||||
|
# Run unconditionally — EXIF parsing may succeed for gen_params but miss
|
||||||
|
# LoRAs since modelVersionIds is NOT embedded in the image EXIF.
|
||||||
|
civitai_parsed = None
|
||||||
|
if civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw
|
||||||
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||||
|
# modelVersionIds lives at outer meta level; propagate after unwrap
|
||||||
|
_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||||
|
if _mvids and isinstance(civitai_inner_meta, dict):
|
||||||
|
civitai_inner_meta["modelVersionIds"] = _mvids
|
||||||
|
if isinstance(civitai_inner_meta, dict):
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
civitai_inner_meta
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
|
# Merge: API gen_params override EXIF at field level,
|
||||||
|
# EXIF fills in fields the API doesn't have.
|
||||||
|
embedded_gen_params = {
|
||||||
|
**(embedded_gen_params or {}),
|
||||||
|
**civitai_parsed["gen_params"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if embedded_gen_params:
|
||||||
|
metadata["gen_params"] = embedded_gen_params
|
||||||
|
|
||||||
|
# Merge LoRAs: prefer frontend resources, supplement with CivitAI modelVersionIds
|
||||||
|
if civitai_parsed:
|
||||||
|
civitai_loras = civitai_parsed.get("loras", [])
|
||||||
|
if civitai_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = civitai_loras
|
||||||
|
civitai_model = civitai_parsed.get("model")
|
||||||
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = civitai_model
|
||||||
|
elif parsed_embedded:
|
||||||
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
|
if parsed_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = parsed_loras
|
||||||
|
parsed_model = parsed_embedded.get("model")
|
||||||
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = parsed_model
|
||||||
|
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=gen_params_request,
|
||||||
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||||
|
prefetched_model_version_id=model_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=tags,
|
||||||
|
metadata=metadata,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -1190,7 +1240,7 @@ class RecipeManagementHandler:
|
|||||||
"exclude": False,
|
"exclude": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
|
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
downloader = await self._downloader_factory()
|
downloader = await self._downloader_factory()
|
||||||
temp_path = None
|
temp_path = None
|
||||||
@@ -1238,10 +1288,31 @@ class RecipeManagementHandler:
|
|||||||
extension = ".webp" # Default to webp if unknown
|
extension = ".webp" # Default to webp if unknown
|
||||||
|
|
||||||
with open(temp_path, "rb") as file_obj:
|
with open(temp_path, "rb") as file_obj:
|
||||||
|
model_ver_id = None
|
||||||
|
civitai_meta_raw = (
|
||||||
|
image_info.get("meta") if civitai_image_id and image_info else None
|
||||||
|
)
|
||||||
|
if civitai_image_id and image_info:
|
||||||
|
model_ver_id = image_info.get("modelVersionId")
|
||||||
|
if not model_ver_id:
|
||||||
|
ids = image_info.get("modelVersionIds")
|
||||||
|
if isinstance(ids, list) and ids:
|
||||||
|
model_ver_id = ids[0]
|
||||||
|
|
||||||
|
# Inject root-level modelVersionIds into meta so downstream
|
||||||
|
# parsers (CivitaiApiMetadataParser) can discover ALL resources
|
||||||
|
# (checkpoint + LoRAs), not just the first model version ID.
|
||||||
|
# CivitAI API returns modelVersionIds at the root level of
|
||||||
|
# the image response, NOT inside the meta object.
|
||||||
|
mvids = image_info.get("modelVersionIds")
|
||||||
|
if mvids and isinstance(civitai_meta_raw, dict):
|
||||||
|
civitai_meta_raw["modelVersionIds"] = mvids
|
||||||
|
|
||||||
return (
|
return (
|
||||||
file_obj.read(),
|
file_obj.read(),
|
||||||
extension,
|
extension,
|
||||||
image_info.get("meta") if civitai_image_id and image_info else None,
|
civitai_meta_raw,
|
||||||
|
model_ver_id,
|
||||||
)
|
)
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
@@ -1289,6 +1360,226 @@ class RecipeManagementHandler:
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def check_image_exists(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
image_ids_raw = request.query.get("image_ids", "")
|
||||||
|
if not image_ids_raw:
|
||||||
|
return web.json_response({"success": True, "results": {}})
|
||||||
|
|
||||||
|
requested_ids = set()
|
||||||
|
for raw in image_ids_raw.split(","):
|
||||||
|
stripped = raw.strip()
|
||||||
|
if stripped and stripped.isdigit():
|
||||||
|
requested_ids.add(stripped)
|
||||||
|
|
||||||
|
if not requested_ids:
|
||||||
|
return web.json_response({"success": True, "results": {}})
|
||||||
|
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Build lookup: image_id -> recipe_id from stored source_path
|
||||||
|
image_to_recipe = {}
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
source = recipe.get("source_path")
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
image_id = extract_civitai_image_id(source)
|
||||||
|
if image_id and image_id not in image_to_recipe:
|
||||||
|
image_to_recipe[image_id] = recipe.get("id")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for img_id in requested_ids:
|
||||||
|
recipe_id = image_to_recipe.get(img_id)
|
||||||
|
results[img_id] = {
|
||||||
|
"in_library": recipe_id is not None,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "results": results})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error checking image existence: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def import_from_url(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
image_url = request.query.get("image_url")
|
||||||
|
if not image_url:
|
||||||
|
raise RecipeValidationError("Missing required field: image_url")
|
||||||
|
|
||||||
|
image_id = extract_civitai_image_id(image_url)
|
||||||
|
if not image_id:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Could not extract Civitai image ID from URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for duplicate (fast, before acquiring semaphore)
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
source = recipe.get("source_path")
|
||||||
|
if source:
|
||||||
|
existing_id = extract_civitai_image_id(source)
|
||||||
|
if existing_id == image_id:
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": recipe.get("id"),
|
||||||
|
"name": recipe.get("title", ""),
|
||||||
|
"already_exists": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
async with self._import_semaphore:
|
||||||
|
return await self._do_import_from_url(image_url, recipe_scanner)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except RecipeDownloadError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error importing recipe from URL: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _do_import_from_url(
|
||||||
|
self,
|
||||||
|
image_url: str,
|
||||||
|
recipe_scanner: Any,
|
||||||
|
) -> web.Response:
|
||||||
|
image_id = extract_civitai_image_id(image_url)
|
||||||
|
if not image_id:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Could not extract Civitai image ID from URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
image_bytes, extension, civitai_meta_raw, model_version_id = (
|
||||||
|
await self._download_remote_media(image_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract embedded EXIF metadata
|
||||||
|
embedded_gen_params = {}
|
||||||
|
parsed_embedded = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=extension, delete=False
|
||||||
|
) as temp_img:
|
||||||
|
temp_img.write(image_bytes)
|
||||||
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_embedded = await asyncio.to_thread(
|
||||||
|
ExifUtils.extract_image_metadata, temp_img_path
|
||||||
|
)
|
||||||
|
if raw_embedded:
|
||||||
|
parser = (
|
||||||
|
self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
raw_embedded
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_img_path):
|
||||||
|
os.unlink(temp_img_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Failed to extract embedded metadata: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse CivitAI API meta to discover all resources from modelVersionIds.
|
||||||
|
# Run unconditionally — EXIF parsing succeeds for gen_params but misses
|
||||||
|
# LoRAs (modelVersionIds is NOT in the image EXIF).
|
||||||
|
civitai_parsed = None
|
||||||
|
if civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw
|
||||||
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||||
|
# Propagate modelVersionIds into unwrapped meta — it lives
|
||||||
|
# at the outer meta level in the CivitAI API response.
|
||||||
|
_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||||
|
if _mvids and isinstance(civitai_inner_meta, dict):
|
||||||
|
civitai_inner_meta["modelVersionIds"] = _mvids
|
||||||
|
if isinstance(civitai_inner_meta, dict):
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
civitai_inner_meta
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
|
# Merge: API gen_params override EXIF at field level,
|
||||||
|
# EXIF fills in fields the API doesn't have.
|
||||||
|
embedded_gen_params = {
|
||||||
|
**(embedded_gen_params or {}),
|
||||||
|
**civitai_parsed["gen_params"],
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"base_model": "",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": embedded_gen_params or {},
|
||||||
|
"source_path": image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if civitai_parsed:
|
||||||
|
civitai_loras = civitai_parsed.get("loras", [])
|
||||||
|
if civitai_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = civitai_loras
|
||||||
|
civitai_model = civitai_parsed.get("model")
|
||||||
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = civitai_model
|
||||||
|
elif parsed_embedded:
|
||||||
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
|
if parsed_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = parsed_loras
|
||||||
|
parsed_model = parsed_embedded.get("model")
|
||||||
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = parsed_model
|
||||||
|
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params={},
|
||||||
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||||
|
prefetched_model_version_id=model_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
metadata.get("gen_params", {}).get("prompt")
|
||||||
|
or metadata.get("gen_params", {}).get("positivePrompt")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if prompt:
|
||||||
|
name = " ".join(str(prompt).split()[:10])
|
||||||
|
else:
|
||||||
|
name = f"Civitai Image {image_id}"
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=[],
|
||||||
|
metadata=metadata,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
|
|
||||||
class RecipeAnalysisHandler:
|
class RecipeAnalysisHandler:
|
||||||
"""Analyze images to extract recipe metadata."""
|
"""Analyze images to extract recipe metadata."""
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||||
|
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET",
|
"GET",
|
||||||
"/api/lm/model-version-download-status",
|
"/api/lm/model-version-download-status",
|
||||||
@@ -90,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
||||||
),
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
|
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
|
||||||
),
|
),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
|
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
|
||||||
|
),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
121
py/services/auto_tag_service.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Auto-tag extraction service for model cards.
|
||||||
|
|
||||||
|
Extracts implicit model attributes (HIGH/LOW, I2V/T2V/TI2V, Lightning, Turbo)
|
||||||
|
from filename, base_model, and CivitAI version name — no manual tagging required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Set
|
||||||
|
|
||||||
|
# ── Tag category definitions ──────────────────────────────────────────
|
||||||
|
# Each category maps a display label to a regex pattern.
|
||||||
|
# Patterns are case-insensitive and matched against filename, base_model,
|
||||||
|
# and civitai version name.
|
||||||
|
|
||||||
|
# Use (?<![a-zA-Z0-9]) and (?![a-zA-Z0-9]) instead of \b because
|
||||||
|
# Python's \b treats underscore as a word character, so \bHIGH\b
|
||||||
|
# won't match '_HIGH_' in filenames.
|
||||||
|
_B = r"(?<![a-zA-Z0-9])" # left boundary
|
||||||
|
_E = r"(?![a-zA-Z0-9])" # right boundary
|
||||||
|
|
||||||
|
AUTO_TAG_CATEGORIES: Dict[str, str] = {
|
||||||
|
"HIGH": _B + r"HIGH" + _E,
|
||||||
|
"LOW": _B + r"(?<!F)LOW" + _E,
|
||||||
|
"I2V": _B + r"I2V" + _E,
|
||||||
|
"T2V": _B + r"T2V" + _E,
|
||||||
|
"TI2V": _B + r"TI2V" + _E,
|
||||||
|
"Lightning": _B + r"Lightning" + _E,
|
||||||
|
"Turbo": _B + r"Turbo" + _E,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tags that belong to the "mode" group (HIGH/LOW)
|
||||||
|
MODE_TAGS = {"HIGH", "LOW"}
|
||||||
|
|
||||||
|
# Tags that belong to the "video mode" group (I2V/T2V/TI2V)
|
||||||
|
VIDEO_MODE_TAGS = {"I2V", "T2V", "TI2V"}
|
||||||
|
|
||||||
|
# Tags that belong to the "speed/optimization" group
|
||||||
|
SPEED_TAGS = {"Lightning", "Turbo"}
|
||||||
|
|
||||||
|
# ── Display category groups (for settings UI) ─────────────────────────
|
||||||
|
|
||||||
|
AUTO_TAG_GROUPS = {
|
||||||
|
"mode": {"HIGH", "LOW"},
|
||||||
|
"video": {"I2V", "T2V", "TI2V"},
|
||||||
|
"speed": {"Lightning", "Turbo"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default enabled categories
|
||||||
|
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_sources(model_data: Dict) -> List[str]:
|
||||||
|
"""Collect all text sources from model data for tag matching."""
|
||||||
|
sources: List[str] = []
|
||||||
|
|
||||||
|
file_name = model_data.get("file_name", "")
|
||||||
|
if file_name:
|
||||||
|
sources.append(file_name)
|
||||||
|
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
if base_model:
|
||||||
|
sources.append(base_model)
|
||||||
|
|
||||||
|
civitai = model_data.get("civitai", {})
|
||||||
|
if isinstance(civitai, dict):
|
||||||
|
version_name = civitai.get("name", "")
|
||||||
|
if version_name:
|
||||||
|
sources.append(version_name)
|
||||||
|
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def extract_auto_tags(model_data: Dict) -> List[str]:
|
||||||
|
"""Extract auto-detected tags from model metadata.
|
||||||
|
|
||||||
|
Matches predefined patterns against filename, base_model, and
|
||||||
|
CivitAI version name. Returns a sorted, deduplicated list of tag labels.
|
||||||
|
|
||||||
|
HIGH/LOW tags are only returned when the base_model indicates a Wan
|
||||||
|
family model — no other model architecture uses this distinction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_data: Model metadata dict with keys:
|
||||||
|
file_name, base_model, civitai (with optional 'name' field).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
|
||||||
|
"""
|
||||||
|
sources = _collect_sources(model_data)
|
||||||
|
if not sources:
|
||||||
|
return []
|
||||||
|
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
is_wan = "wan" in base_model.lower()
|
||||||
|
|
||||||
|
found: Set[str] = set()
|
||||||
|
|
||||||
|
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||||
|
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
|
||||||
|
if label in ("HIGH", "LOW"):
|
||||||
|
if not is_wan:
|
||||||
|
continue
|
||||||
|
# Use case-insensitive character class + case-sensitive boundary,
|
||||||
|
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
|
||||||
|
# Boundary: not followed by lowercase letter (= word has ended).
|
||||||
|
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
|
||||||
|
if label == "LOW":
|
||||||
|
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
|
||||||
|
else:
|
||||||
|
regex = re.compile(ci + r"(?![a-z])")
|
||||||
|
else:
|
||||||
|
regex = re.compile(pattern, re.IGNORECASE)
|
||||||
|
for source in sources:
|
||||||
|
if regex.search(source):
|
||||||
|
found.add(label)
|
||||||
|
break
|
||||||
|
|
||||||
|
return sorted(found)
|
||||||
@@ -77,6 +77,7 @@ class BaseModelService(ABC):
|
|||||||
base_models: list = None,
|
base_models: list = None,
|
||||||
model_types: list = None,
|
model_types: list = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None,
|
||||||
search_options: dict = None,
|
search_options: dict = None,
|
||||||
hash_filters: dict = None,
|
hash_filters: dict = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
@@ -95,6 +96,11 @@ class BaseModelService(ABC):
|
|||||||
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
||||||
else:
|
else:
|
||||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||||
|
# Pre-compute auto_tags for every item — needed for both filtering
|
||||||
|
# and display. Computation is cheap (string regex on 2-3 fields).
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
|
for item in sorted_data:
|
||||||
|
item["auto_tags"] = extract_auto_tags(item)
|
||||||
fetch_duration = time.perf_counter() - t0
|
fetch_duration = time.perf_counter() - t0
|
||||||
initial_count = len(sorted_data)
|
initial_count = len(sorted_data)
|
||||||
|
|
||||||
@@ -110,6 +116,7 @@ class BaseModelService(ABC):
|
|||||||
base_models=base_models,
|
base_models=base_models,
|
||||||
model_types=model_types,
|
model_types=model_types,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
auto_tags=auto_tags,
|
||||||
favorites_only=favorites_only,
|
favorites_only=favorites_only,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
tag_logic=tag_logic,
|
tag_logic=tag_logic,
|
||||||
@@ -354,6 +361,7 @@ class BaseModelService(ABC):
|
|||||||
base_models: list = None,
|
base_models: list = None,
|
||||||
model_types: list = None,
|
model_types: list = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
search_options: dict = None,
|
search_options: dict = None,
|
||||||
tag_logic: str = "any",
|
tag_logic: str = "any",
|
||||||
@@ -367,6 +375,7 @@ class BaseModelService(ABC):
|
|||||||
base_models=base_models,
|
base_models=base_models,
|
||||||
model_types=model_types,
|
model_types=model_types,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
auto_tags=auto_tags,
|
||||||
favorites_only=favorites_only,
|
favorites_only=favorites_only,
|
||||||
search_options=normalized_options,
|
search_options=normalized_options,
|
||||||
tag_logic=tag_logic,
|
tag_logic=tag_logic,
|
||||||
@@ -908,6 +917,17 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
if should_skip or metadata is None:
|
if should_skip or metadata is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Prune stale example-image metadata entries whose files no longer
|
||||||
|
# exist on disk (e.g. a user deleted the files manually).
|
||||||
|
from ..utils.example_images_metadata import MetadataUpdater
|
||||||
|
|
||||||
|
was_modified = await MetadataUpdater.prune_stale_example_images(metadata)
|
||||||
|
if was_modified:
|
||||||
|
asyncio.create_task(
|
||||||
|
MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
)
|
||||||
|
|
||||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class BatchImportService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
source_path = recipe.get("source_path") or recipe.get("source_url")
|
source_path = recipe.get("source_path")
|
||||||
if source_path and source_path == source:
|
if source_path and source_path == source:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import CheckpointMetadata
|
from ..utils.models import CheckpointMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ class CheckpointService(BaseModelService):
|
|||||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||||
|
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ class CivitaiBaseModelService:
|
|||||||
"zimageturbo": "ZIT",
|
"zimageturbo": "ZIT",
|
||||||
"zimagebase": "ZIB",
|
"zimagebase": "ZIB",
|
||||||
"anima": "ANI",
|
"anima": "ANI",
|
||||||
|
"ernie": "ERNI",
|
||||||
|
"ernie turbo": "ETRB",
|
||||||
|
"nucleus": "NUCL",
|
||||||
"svd": "SVD",
|
"svd": "SVD",
|
||||||
"ltxv": "LTXV",
|
"ltxv": "LTXV",
|
||||||
"ltxv2": "LTV2",
|
"ltxv2": "LTV2",
|
||||||
@@ -418,6 +421,9 @@ class CivitaiBaseModelService:
|
|||||||
"Kolors",
|
"Kolors",
|
||||||
"NoobAI",
|
"NoobAI",
|
||||||
"Anima",
|
"Anima",
|
||||||
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ class CivitaiClient:
|
|||||||
"GET",
|
"GET",
|
||||||
f"{self.base_url}/models",
|
f"{self.base_url}/models",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
params={"ids": query},
|
params={"ids": query, "nsfw": "true"},
|
||||||
)
|
)
|
||||||
if not success:
|
if not success:
|
||||||
return None
|
return None
|
||||||
@@ -577,6 +577,59 @@ class CivitaiClient:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
|
||||||
|
|
||||||
|
Uses POST /api/v1/model-versions/by-hash which returns full version
|
||||||
|
details including ``usageControl`` and ``earlyAccessEndsAt`` that are
|
||||||
|
not available from the model-level API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hashes: List of SHA256 hashes (max 100 per batch; auto-split).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of version dicts or None on failure.
|
||||||
|
"""
|
||||||
|
if not hashes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
BATCH_SIZE = 100
|
||||||
|
all_versions: List[Dict] = []
|
||||||
|
|
||||||
|
for start in range(0, len(hashes), BATCH_SIZE):
|
||||||
|
batch = hashes[start : start + BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
success, result = await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.base_url}/model-versions/by-hash",
|
||||||
|
use_auth=True,
|
||||||
|
json=batch,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.warning(
|
||||||
|
"Batch by-hash request failed for %d hashes: %s",
|
||||||
|
len(batch),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
all_versions.extend(result)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Unexpected by-hash response type: %s", type(result)
|
||||||
|
)
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Error fetching model versions by hashes: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_versions if all_versions else None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
"""Fetch all models for a specific Civitai user."""
|
"""Fetch all models for a specific Civitai user."""
|
||||||
if not username:
|
if not username:
|
||||||
@@ -587,7 +640,7 @@ class CivitaiClient:
|
|||||||
"GET",
|
"GET",
|
||||||
f"{self.base_url}/models",
|
f"{self.base_url}/models",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
params={"username": username},
|
params={"username": username, "nsfw": "true"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
|
|||||||
self._db_path = db_path or _resolve_database_path()
|
self._db_path = db_path or _resolve_database_path()
|
||||||
self._settings = settings_manager or get_settings_manager()
|
self._settings = settings_manager or get_settings_manager()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._conn: sqlite3.Connection | None = None
|
||||||
self._schema_initialized = False
|
self._schema_initialized = False
|
||||||
self._ensure_directory()
|
self._ensure_directory()
|
||||||
self._initialize_schema()
|
self._initialize_schema()
|
||||||
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
def _get_conn(self) -> sqlite3.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
return self._conn
|
||||||
|
|
||||||
def _initialize_schema(self) -> None:
|
def _initialize_schema(self) -> None:
|
||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
@@ -116,33 +123,33 @@ class DownloadedVersionHistoryService:
|
|||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 0
|
is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
normalized_type,
|
normalized_type,
|
||||||
normalized_version_id,
|
normalized_version_id,
|
||||||
normalized_model_id,
|
normalized_model_id,
|
||||||
timestamp,
|
timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
source,
|
source,
|
||||||
file_path,
|
file_path,
|
||||||
active_library_name,
|
active_library_name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def mark_downloaded_bulk(
|
async def mark_downloaded_bulk(
|
||||||
self,
|
self,
|
||||||
@@ -180,26 +187,26 @@ class DownloadedVersionHistoryService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 0
|
is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
normalized_version_id = _normalize_int(version_id)
|
normalized_version_id = _normalize_int(version_id)
|
||||||
if normalized_type is None or normalized_version_id is None:
|
if normalized_type is None or normalized_version_id is None:
|
||||||
@@ -208,28 +215,28 @@ class DownloadedVersionHistoryService:
|
|||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 1
|
is_deleted_override = 1
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
normalized_type,
|
normalized_type,
|
||||||
normalized_version_id,
|
normalized_version_id,
|
||||||
timestamp,
|
timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
self._get_active_library_name(),
|
self._get_active_library_name(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
@@ -238,15 +245,15 @@ class DownloadedVersionHistoryService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT is_deleted_override
|
SELECT is_deleted_override
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ? AND version_id = ?
|
WHERE model_type = ? AND version_id = ?
|
||||||
""",
|
""",
|
||||||
(normalized_type, normalized_version_id),
|
(normalized_type, normalized_version_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return bool(row) and not bool(row["is_deleted_override"])
|
return bool(row) and not bool(row["is_deleted_override"])
|
||||||
|
|
||||||
async def get_downloaded_version_ids(
|
async def get_downloaded_version_ids(
|
||||||
@@ -258,16 +265,16 @@ class DownloadedVersionHistoryService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT version_id
|
SELECT version_id
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||||
ORDER BY version_id ASC
|
ORDER BY version_id ASC
|
||||||
""",
|
""",
|
||||||
(normalized_type, normalized_model_id),
|
(normalized_type, normalized_model_id),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [int(row["version_id"]) for row in rows]
|
return [int(row["version_id"]) for row in rows]
|
||||||
|
|
||||||
async def get_downloaded_version_ids_bulk(
|
async def get_downloaded_version_ids_bulk(
|
||||||
@@ -291,17 +298,17 @@ class DownloadedVersionHistoryService:
|
|||||||
params: list[object] = [normalized_type, *normalized_model_ids]
|
params: list[object] = [normalized_type, *normalized_model_ids]
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT model_id, version_id
|
SELECT model_id, version_id
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ?
|
WHERE model_type = ?
|
||||||
AND model_id IN ({placeholders})
|
AND model_id IN ({placeholders})
|
||||||
AND is_deleted_override = 0
|
AND is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
result: dict[int, set[int]] = {}
|
result: dict[int, set[int]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import EmbeddingMetadata
|
from ..utils.models import EmbeddingMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ class EmbeddingService(BaseModelService):
|
|||||||
"exclude": bool(embedding_data.get("exclude", False)),
|
"exclude": bool(embedding_data.get("exclude", False)),
|
||||||
"update_available": bool(embedding_data.get("update_available", False)),
|
"update_available": bool(embedding_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||||
|
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .model_query import resolve_sub_type
|
from .model_query import resolve_sub_type
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
from ..utils.models import LoraMetadata
|
from ..utils.models import LoraMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ class LoraService(BaseModelService):
|
|||||||
"civitai": self.filter_civitai_data(
|
"civitai": self.filter_civitai_data(
|
||||||
lora_data.get("civitai", {}), minimal=True
|
lora_data.get("civitai", {}), minimal=True
|
||||||
),
|
),
|
||||||
|
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ class ModelLifecycleService:
|
|||||||
self._scanner._hash_index.remove_by_path(file_path)
|
self._scanner._hash_index.remove_by_path(file_path)
|
||||||
|
|
||||||
await self._sync_update_for_model(model_id)
|
await self._sync_update_for_model(model_id)
|
||||||
|
|
||||||
|
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist_current_cache):
|
||||||
|
await persist_current_cache()
|
||||||
|
|
||||||
return {"success": True, "deleted_files": deleted_files}
|
return {"success": True, "deleted_files": deleted_files}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ class ModelMetadataProvider(ABC):
|
|||||||
"""Fetch model versions for multiple model ids when supported."""
|
"""Fetch model versions for multiple model ids when supported."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch full version details for multiple SHA256 hashes.
|
||||||
|
|
||||||
|
Used specifically to retrieve ``usageControl`` which is only
|
||||||
|
available from the per-version / by-hash API, not from model-level
|
||||||
|
responses. Providers that cannot resolve hashes should let the
|
||||||
|
default ``NotImplementedError`` propagate.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
"""Get specific model version with additional metadata"""
|
"""Get specific model version with additional metadata"""
|
||||||
@@ -141,6 +153,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
|||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict]]:
|
||||||
return await self.client.get_model_versions_bulk(model_ids)
|
return await self.client.get_model_versions_bulk(model_ids)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
return await self.client.get_model_versions_by_hashes(hashes)
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
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)
|
return await self.client.get_model_version(model_id, version_id)
|
||||||
|
|
||||||
@@ -519,6 +536,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None, "No provider could retrieve the data"
|
return None, "No provider could retrieve the data"
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result = await self._call_with_rate_limit(
|
||||||
|
label,
|
||||||
|
provider.get_model_versions_by_hashes,
|
||||||
|
hashes,
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
except NotImplementedError:
|
||||||
|
continue
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"Provider %s failed for get_model_versions_by_hashes: %s",
|
||||||
|
label,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
@@ -593,6 +636,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
|||||||
model_ids,
|
model_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_model_versions_by_hashes,
|
||||||
|
hashes,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
@@ -669,6 +721,17 @@ class ModelMetadataProviderManager:
|
|||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_model_version_info(version_id)
|
return await provider.get_model_version_info(version_id)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self,
|
||||||
|
hashes: List[str],
|
||||||
|
provider_name: str = None,
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
try:
|
||||||
|
return await provider.get_model_versions_by_hashes(hashes)
|
||||||
|
except NotImplementedError:
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
||||||
"""Fetch models owned by the specified user"""
|
"""Fetch models owned by the specified user"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class FilterCriteria:
|
|||||||
folder_exclude: Optional[Sequence[str]] = None
|
folder_exclude: Optional[Sequence[str]] = None
|
||||||
base_models: Optional[Sequence[str]] = None
|
base_models: Optional[Sequence[str]] = None
|
||||||
tags: Optional[Dict[str, str]] = None
|
tags: Optional[Dict[str, str]] = None
|
||||||
|
auto_tags: Optional[Dict[str, str]] = None
|
||||||
favorites_only: bool = False
|
favorites_only: bool = False
|
||||||
search_options: Optional[Dict[str, Any]] = None
|
search_options: Optional[Dict[str, Any]] = None
|
||||||
model_types: Optional[Sequence[str]] = None
|
model_types: Optional[Sequence[str]] = None
|
||||||
@@ -359,10 +360,37 @@ class ModelFilterSet:
|
|||||||
]
|
]
|
||||||
model_types_duration = time.perf_counter() - t0
|
model_types_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
|
auto_tags_duration = 0
|
||||||
|
auto_tag_filters = criteria.auto_tags or {}
|
||||||
|
if auto_tag_filters:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
include_at = set()
|
||||||
|
exclude_at = set()
|
||||||
|
for tag, state in auto_tag_filters.items():
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
if state == "exclude":
|
||||||
|
exclude_at.add(tag)
|
||||||
|
else:
|
||||||
|
include_at.add(tag)
|
||||||
|
|
||||||
|
if include_at:
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if any(tag in include_at for tag in (item.get("auto_tags") or []))
|
||||||
|
]
|
||||||
|
|
||||||
|
if exclude_at:
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if not any(tag in exclude_at for tag in (item.get("auto_tags") or []))
|
||||||
|
]
|
||||||
|
auto_tags_duration = time.perf_counter() - t0
|
||||||
|
|
||||||
duration = time.perf_counter() - overall_start
|
duration = time.perf_counter() - overall_start
|
||||||
if duration > 0.1: # Only log if it's potentially slow
|
if duration > 0.1: # Only log if it's potentially slow
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). "
|
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs, auto_tags: %.3fs). "
|
||||||
"Count: %d -> %d",
|
"Count: %d -> %d",
|
||||||
duration,
|
duration,
|
||||||
sfw_duration,
|
sfw_duration,
|
||||||
@@ -371,6 +399,7 @@ class ModelFilterSet:
|
|||||||
base_models_duration,
|
base_models_duration,
|
||||||
tags_duration,
|
tags_duration,
|
||||||
model_types_duration,
|
model_types_duration,
|
||||||
|
auto_tags_duration,
|
||||||
initial_count,
|
initial_count,
|
||||||
len(items),
|
len(items),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -989,6 +989,11 @@ class ModelUpdateService:
|
|||||||
fallback_attempted = True
|
fallback_attempted = True
|
||||||
try:
|
try:
|
||||||
response = await metadata_provider.get_model_versions(model_id)
|
response = await metadata_provider.get_model_versions(model_id)
|
||||||
|
if response is not None:
|
||||||
|
await self._enrich_version_entries(
|
||||||
|
metadata_provider,
|
||||||
|
{model_id: response},
|
||||||
|
)
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
raise
|
raise
|
||||||
except ResourceNotFoundError as exc:
|
except ResourceNotFoundError as exc:
|
||||||
@@ -1083,6 +1088,136 @@ class ModelUpdateService:
|
|||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
async def _enrich_version_entries(
|
||||||
|
self,
|
||||||
|
metadata_provider,
|
||||||
|
responses_by_model_id: Dict[int, Mapping],
|
||||||
|
) -> None:
|
||||||
|
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
|
||||||
|
|
||||||
|
The model-level API does not include ``usageControl`` on version
|
||||||
|
entries. This method collects SHA256 hashes from every version's
|
||||||
|
primary model file, calls ``POST /api/v1/model-versions/by-hash``
|
||||||
|
(up to 100 hashes per request), and injects ``usageControl`` +
|
||||||
|
``earlyAccessEndsAt`` into each version entry dict in-place.
|
||||||
|
"""
|
||||||
|
if not metadata_provider or not responses_by_model_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
hashes_by_version: Dict[int, str] = {}
|
||||||
|
for response in responses_by_model_id.values():
|
||||||
|
hashes_by_version.update(
|
||||||
|
self._collect_hashes_from_response(response)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hashes_by_version:
|
||||||
|
return
|
||||||
|
|
||||||
|
version_ids_by_hash: Dict[str, List[int]] = {}
|
||||||
|
for version_id, sha256 in hashes_by_version.items():
|
||||||
|
version_ids_by_hash.setdefault(sha256, []).append(version_id)
|
||||||
|
|
||||||
|
all_hashes = list(version_ids_by_hash.keys())
|
||||||
|
BATCH_SIZE = 100
|
||||||
|
|
||||||
|
enrichment: Dict[int, Dict] = {}
|
||||||
|
try:
|
||||||
|
for start in range(0, len(all_hashes), BATCH_SIZE):
|
||||||
|
batch = all_hashes[start : start + BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
enriched = await metadata_provider.get_model_versions_by_hashes(
|
||||||
|
batch
|
||||||
|
)
|
||||||
|
except NotImplementedError:
|
||||||
|
return
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not enriched:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in enriched:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
version_id = entry.get("id")
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
enrichment[version_id] = {
|
||||||
|
"usageControl": _normalize_string(
|
||||||
|
entry.get("usageControl")
|
||||||
|
),
|
||||||
|
"earlyAccessEndsAt": _normalize_string(
|
||||||
|
entry.get("earlyAccessEndsAt")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not enrichment:
|
||||||
|
return
|
||||||
|
|
||||||
|
for response in responses_by_model_id.values():
|
||||||
|
versions = response.get("modelVersions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
continue
|
||||||
|
for version in versions:
|
||||||
|
if not isinstance(version, dict):
|
||||||
|
continue
|
||||||
|
version_id = version.get("id")
|
||||||
|
if version_id not in enrichment:
|
||||||
|
continue
|
||||||
|
extra = enrichment[version_id]
|
||||||
|
if extra.get("usageControl") and not version.get("usageControl"):
|
||||||
|
version["usageControl"] = extra["usageControl"]
|
||||||
|
if extra.get("earlyAccessEndsAt") and not version.get(
|
||||||
|
"earlyAccessEndsAt"
|
||||||
|
):
|
||||||
|
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_hashes_from_response(response: Mapping) -> Dict[int, str]:
|
||||||
|
"""Extract ``{version_id: sha256}`` from a model-level API response.
|
||||||
|
|
||||||
|
Returns an empty dict if the response structure is unexpected.
|
||||||
|
"""
|
||||||
|
result: Dict[int, str] = {}
|
||||||
|
versions = response.get("modelVersions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
return result
|
||||||
|
for entry in versions:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
version_id = _normalize_int(entry.get("id"))
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
sha256 = ModelUpdateService._extract_sha256_from_version_entry(entry)
|
||||||
|
if sha256:
|
||||||
|
result[version_id] = sha256
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_sha256_from_version_entry(entry: Mapping) -> Optional[str]:
|
||||||
|
"""Return the SHA256 hash from the primary model file of a version entry."""
|
||||||
|
files = entry.get("files")
|
||||||
|
if not isinstance(files, list):
|
||||||
|
return None
|
||||||
|
for file_info in files:
|
||||||
|
if not isinstance(file_info, dict):
|
||||||
|
continue
|
||||||
|
if file_info.get("type") != "Model":
|
||||||
|
continue
|
||||||
|
primary = file_info.get("primary")
|
||||||
|
if primary is not True and str(primary).strip().lower() != "true":
|
||||||
|
continue
|
||||||
|
hashes = file_info.get("hashes")
|
||||||
|
if isinstance(hashes, dict):
|
||||||
|
sha256 = hashes.get("SHA256")
|
||||||
|
if sha256:
|
||||||
|
return sha256
|
||||||
|
return None
|
||||||
|
|
||||||
async def _fetch_model_versions_bulk(
|
async def _fetch_model_versions_bulk(
|
||||||
self,
|
self,
|
||||||
metadata_provider,
|
metadata_provider,
|
||||||
@@ -1134,6 +1269,7 @@ class ModelUpdateService:
|
|||||||
len(aggregated),
|
len(aggregated),
|
||||||
provider_name,
|
provider_name,
|
||||||
)
|
)
|
||||||
|
await self._enrich_version_entries(metadata_provider, aggregated)
|
||||||
return aggregated
|
return aggregated
|
||||||
|
|
||||||
async def _collect_local_versions(
|
async def _collect_local_versions(
|
||||||
@@ -1261,6 +1397,7 @@ class ModelUpdateService:
|
|||||||
sort_index=sort_map.get(version_id, index),
|
sort_index=sort_map.get(version_id, index),
|
||||||
early_access_ends_at=remote_version.early_access_ends_at,
|
early_access_ends_at=remote_version.early_access_ends_at,
|
||||||
is_early_access=remote_version.is_early_access,
|
is_early_access=remote_version.is_early_access,
|
||||||
|
usage_control=remote_version.usage_control,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class PersistentRecipeCache:
|
|||||||
"json_path",
|
"json_path",
|
||||||
"title",
|
"title",
|
||||||
"folder",
|
"folder",
|
||||||
|
"source_path",
|
||||||
"base_model",
|
"base_model",
|
||||||
"fingerprint",
|
"fingerprint",
|
||||||
"created_date",
|
"created_date",
|
||||||
@@ -334,6 +335,7 @@ class PersistentRecipeCache:
|
|||||||
json_path TEXT,
|
json_path TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
folder TEXT,
|
folder TEXT,
|
||||||
|
source_path TEXT,
|
||||||
base_model TEXT,
|
base_model TEXT,
|
||||||
fingerprint TEXT,
|
fingerprint TEXT,
|
||||||
created_date REAL,
|
created_date REAL,
|
||||||
@@ -358,6 +360,13 @@ class PersistentRecipeCache:
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# Migration: add source_path column to existing databases
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE recipes ADD COLUMN source_path TEXT"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # column already exists
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._schema_initialized = True
|
self._schema_initialized = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -406,6 +415,7 @@ class PersistentRecipeCache:
|
|||||||
json_path,
|
json_path,
|
||||||
recipe.get("title"),
|
recipe.get("title"),
|
||||||
recipe.get("folder"),
|
recipe.get("folder"),
|
||||||
|
recipe.get("source_path"),
|
||||||
recipe.get("base_model"),
|
recipe.get("base_model"),
|
||||||
recipe.get("fingerprint"),
|
recipe.get("fingerprint"),
|
||||||
float(recipe.get("created_date") or 0.0),
|
float(recipe.get("created_date") or 0.0),
|
||||||
@@ -456,6 +466,7 @@ class PersistentRecipeCache:
|
|||||||
"file_path": row["file_path"] or "",
|
"file_path": row["file_path"] or "",
|
||||||
"title": row["title"] or "",
|
"title": row["title"] or "",
|
||||||
"folder": row["folder"] or "",
|
"folder": row["folder"] or "",
|
||||||
|
"source_path": row["source_path"] or "",
|
||||||
"base_model": row["base_model"] or "",
|
"base_model": row["base_model"] or "",
|
||||||
"fingerprint": row["fingerprint"] or "",
|
"fingerprint": row["fingerprint"] or "",
|
||||||
"created_date": row["created_date"] or 0.0,
|
"created_date": row["created_date"] or 0.0,
|
||||||
|
|||||||
@@ -504,6 +504,9 @@ class RecipeScanner:
|
|||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
self._update_folder_metadata(self._cache)
|
self._update_folder_metadata(self._cache)
|
||||||
self._sort_cache_sync()
|
self._sort_cache_sync()
|
||||||
|
# Backfill source_path from JSON files if missing (schema migration)
|
||||||
|
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||||
|
self._persistent_cache.save_cache(recipes, json_paths)
|
||||||
return self._cache
|
return self._cache
|
||||||
else:
|
else:
|
||||||
# Partial update: some files changed
|
# Partial update: some files changed
|
||||||
@@ -514,6 +517,8 @@ class RecipeScanner:
|
|||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
self._update_folder_metadata(self._cache)
|
self._update_folder_metadata(self._cache)
|
||||||
self._sort_cache_sync()
|
self._sort_cache_sync()
|
||||||
|
# Backfill source_path from JSON files if missing (schema migration)
|
||||||
|
self._backfill_source_path_if_needed(recipes, json_paths)
|
||||||
# Persist updated cache
|
# Persist updated cache
|
||||||
self._persistent_cache.save_cache(recipes, json_paths)
|
self._persistent_cache.save_cache(recipes, json_paths)
|
||||||
return self._cache
|
return self._cache
|
||||||
@@ -642,6 +647,34 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return recipes, changed, json_paths
|
return recipes, changed, json_paths
|
||||||
|
|
||||||
|
def _backfill_source_path_if_needed(
|
||||||
|
self,
|
||||||
|
recipes: List[Dict],
|
||||||
|
json_paths: Dict[str, str],
|
||||||
|
) -> bool:
|
||||||
|
"""Backfill source_path from recipe JSON files if missing from cache.
|
||||||
|
|
||||||
|
Returns True if any recipes were updated (caller should persist cache).
|
||||||
|
"""
|
||||||
|
updated = False
|
||||||
|
for recipe in recipes:
|
||||||
|
if recipe.get("source_path"):
|
||||||
|
continue
|
||||||
|
recipe_id = str(recipe.get("id", ""))
|
||||||
|
json_path = json_paths.get(recipe_id)
|
||||||
|
if not json_path or not os.path.exists(json_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
json_data = json.load(f)
|
||||||
|
file_source_path = json_data.get("source_path")
|
||||||
|
if file_source_path:
|
||||||
|
recipe["source_path"] = file_source_path
|
||||||
|
updated = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return updated
|
||||||
|
|
||||||
def _full_directory_scan_sync(
|
def _full_directory_scan_sync(
|
||||||
self, recipes_dir: str
|
self, recipes_dir: str
|
||||||
) -> Tuple[List[Dict], Dict[str, str]]:
|
) -> Tuple[List[Dict], Dict[str, str]]:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
@@ -14,6 +15,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||||
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
RecipeNotFoundError,
|
RecipeNotFoundError,
|
||||||
@@ -170,9 +172,11 @@ class RecipeAnalysisService:
|
|||||||
await self._download_image(url, temp_path)
|
await self._download_image(url, temp_path)
|
||||||
|
|
||||||
if metadata is None and not is_video:
|
if metadata is None and not is_video:
|
||||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata, temp_path
|
||||||
|
)
|
||||||
|
|
||||||
return await self._parse_metadata(
|
result = await self._parse_metadata(
|
||||||
metadata or {},
|
metadata or {},
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_path=temp_path,
|
image_path=temp_path,
|
||||||
@@ -180,6 +184,37 @@ class RecipeAnalysisService:
|
|||||||
is_video=is_video,
|
is_video=is_video,
|
||||||
extension=extension,
|
extension=extension,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||||
|
mvid = image_info.get("modelVersionId")
|
||||||
|
if not mvid:
|
||||||
|
mvids = image_info.get("modelVersionIds")
|
||||||
|
if isinstance(mvids, list) and mvids:
|
||||||
|
mvid = mvids[0]
|
||||||
|
|
||||||
|
recipe_for_enrich = {
|
||||||
|
"gen_params": result.payload.get("gen_params", {}),
|
||||||
|
"loras": result.payload.get("loras", []),
|
||||||
|
"base_model": result.payload.get("base_model", "") or "",
|
||||||
|
"checkpoint": result.payload.get("checkpoint") or result.payload.get("model"),
|
||||||
|
"source_path": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=recipe_for_enrich,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=None,
|
||||||
|
prefetched_civitai_meta_raw=image_info.get("meta"),
|
||||||
|
prefetched_model_version_id=mvid,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||||
|
if recipe_for_enrich.get("checkpoint"):
|
||||||
|
result.payload["checkpoint"] = recipe_for_enrich["checkpoint"]
|
||||||
|
if recipe_for_enrich.get("base_model"):
|
||||||
|
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||||
|
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
if temp_path:
|
if temp_path:
|
||||||
self._safe_cleanup(temp_path)
|
self._safe_cleanup(temp_path)
|
||||||
@@ -199,7 +234,9 @@ class RecipeAnalysisService:
|
|||||||
if not os.path.isfile(normalized_path):
|
if not os.path.isfile(normalized_path):
|
||||||
raise RecipeNotFoundError("File not found")
|
raise RecipeNotFoundError("File not found")
|
||||||
|
|
||||||
metadata = self._exif_utils.extract_image_metadata(normalized_path)
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata, normalized_path
|
||||||
|
)
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return self._metadata_not_found_response(normalized_path)
|
return self._metadata_not_found_response(normalized_path)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, Mapping, Sequence
|
|||||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||||
|
|
||||||
|
|
||||||
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
|
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red", "civitai.green"})
|
||||||
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
||||||
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
||||||
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
||||||
|
|||||||
@@ -178,5 +178,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
|||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
"Hunyuan Video",
|
"Hunyuan Video",
|
||||||
"Anima",
|
"Anima",
|
||||||
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -452,3 +452,111 @@ class MetadataUpdater:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def prune_stale_example_images(metadata) -> bool:
|
||||||
|
"""Remove example-image metadata entries whose files no longer exist on disk.
|
||||||
|
|
||||||
|
Checks ``civitai.customImages`` (by ``id``) and ``civitai.images`` entries
|
||||||
|
that have an empty ``url`` (no remote fallback) against actual files in
|
||||||
|
the model's example-image folder. Stale entries are removed in-place so
|
||||||
|
the caller can persist the cleaned metadata afterwards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: A ``BaseModelMetadata`` instance (modified in place).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one entry was removed.
|
||||||
|
"""
|
||||||
|
from ..utils.example_images_paths import get_model_folder
|
||||||
|
|
||||||
|
model_hash = getattr(metadata, "sha256", None)
|
||||||
|
if not model_hash:
|
||||||
|
return False
|
||||||
|
|
||||||
|
model_folder = get_model_folder(model_hash)
|
||||||
|
if not model_folder:
|
||||||
|
return False
|
||||||
|
|
||||||
|
civitai = getattr(metadata, "civitai", None)
|
||||||
|
if not isinstance(civitai, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
has_changes = False
|
||||||
|
|
||||||
|
custom_images = civitai.get("customImages")
|
||||||
|
if isinstance(custom_images, list) and custom_images:
|
||||||
|
stale: list[int] = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(custom_images):
|
||||||
|
img_id = img.get("id", "")
|
||||||
|
if not img_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.isdir(model_folder):
|
||||||
|
stale.append(idx)
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
prefix = f"custom_{img_id}"
|
||||||
|
for fname in os.listdir(model_folder):
|
||||||
|
if fname.startswith(prefix) and os.path.isfile(
|
||||||
|
os.path.join(model_folder, fname)
|
||||||
|
):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
stale.append(idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
stale.append(idx)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
for idx in reversed(stale):
|
||||||
|
custom_images.pop(idx)
|
||||||
|
has_changes = True
|
||||||
|
logger.info(
|
||||||
|
"Pruned %d stale custom image(s) for %s",
|
||||||
|
len(stale),
|
||||||
|
getattr(metadata, "model_name", model_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
images = civitai.get("images")
|
||||||
|
if isinstance(images, list) and images:
|
||||||
|
stale: list[int] = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(images):
|
||||||
|
if img.get("url", ""):
|
||||||
|
# Has a remote fallback – keep it even if the local copy
|
||||||
|
# is gone.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.isdir(model_folder):
|
||||||
|
stale.append(idx)
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
prefix = f"image_{idx}."
|
||||||
|
for fname in os.listdir(model_folder):
|
||||||
|
if fname.startswith(prefix):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
stale.append(idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
stale.append(idx)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
for idx in reversed(stale):
|
||||||
|
images.pop(idx)
|
||||||
|
has_changes = True
|
||||||
|
logger.info(
|
||||||
|
"Pruned %d stale image entry(ies) for %s",
|
||||||
|
len(stale),
|
||||||
|
getattr(metadata, "model_name", model_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
return has_changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.0.5"
|
version = "1.0.7"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
||||||
content: '\f00c';
|
content: '\f00c';
|
||||||
font-family: 'Font Awesome 6 Free';
|
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--lora-text);
|
color: var(--lora-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -329,7 +329,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-actions i {
|
.card-actions i {
|
||||||
margin-left: var(--space-1);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: white;
|
color: white;
|
||||||
transition: opacity 0.2s, transform 0.15s ease;
|
transition: opacity 0.2s, transform 0.15s ease;
|
||||||
@@ -508,21 +507,96 @@
|
|||||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Version row — flex container for badges + version names */
|
||||||
|
.version-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge + version-name binding: they wrap as a single unit */
|
||||||
|
.badge-version-unit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Medium density adjustments for version name */
|
/* Medium density adjustments for version name */
|
||||||
.medium-density .version-name {
|
.medium-density .version-name {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.medium-density .badge-version-unit .version-name {
|
||||||
|
max-width: 90px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
/* Compact density adjustments for version name */
|
/* Compact density adjustments for version name */
|
||||||
.compact-density .version-name {
|
.compact-density .version-name {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide civitai version name when setting is disabled */
|
.compact-density .badge-version-unit .version-name {
|
||||||
body.hide-card-version .civitai-version {
|
max-width: 70px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium-density .version-row {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HIGH / LOW badges — shown inline before version name in card footer */
|
||||||
|
.hl-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-badge--high {
|
||||||
|
color: oklch(75% 0.12 230);
|
||||||
|
background: oklch(55% 0.15 240 / 0.25);
|
||||||
|
border-color: oklch(60% 0.18 250 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hl-badge--low {
|
||||||
|
color: oklch(78% 0.10 185);
|
||||||
|
background: oklch(50% 0.10 190 / 0.25);
|
||||||
|
border-color: oklch(55% 0.12 195 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium-density .hl-badge {
|
||||||
|
font-size: 0.65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-density .hl-badge {
|
||||||
|
font-size: 0.62em;
|
||||||
|
padding: 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide version-related elements when setting is disabled */
|
||||||
|
body.hide-card-version .civitai-version,
|
||||||
|
body.hide-card-version .hl-badge {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact density adjustments for version name */
|
||||||
|
.compact-density .version-name {
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Prevent text selection on cards and interactive elements */
|
/* Prevent text selection on cards and interactive elements */
|
||||||
.model-card,
|
.model-card,
|
||||||
.model-card *,
|
.model-card *,
|
||||||
|
|||||||
@@ -141,8 +141,7 @@
|
|||||||
|
|
||||||
.header-search .search-container:focus-within {
|
.header-search .search-container:focus-within {
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 1px var(--lora-accent);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search input {
|
.header-search input {
|
||||||
|
|||||||
@@ -387,6 +387,10 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-action-disabled-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.versions-loading-state,
|
.versions-loading-state,
|
||||||
.versions-empty,
|
.versions-empty,
|
||||||
.versions-error {
|
.versions-error {
|
||||||
|
|||||||
124
static/css/components/media-viewer.css
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
.media-viewer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active {
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10001;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active .media-viewer-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-content-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 95vh;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-media {
|
||||||
|
display: block;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-video {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-counter {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-title {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 90vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 48px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10001;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active .media-viewer-nav {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-nav:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-prev {
|
||||||
|
left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-next {
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
@@ -41,6 +41,63 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section Headers */
|
||||||
|
.context-menu-section-header {
|
||||||
|
padding: 6px 12px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu */
|
||||||
|
.context-menu-item.has-submenu {
|
||||||
|
position: relative;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% - 4px);
|
||||||
|
top: -1px;
|
||||||
|
display: none;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1001;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu .context-menu-item {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu .context-menu-item:first-child {
|
||||||
|
padding-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu .context-menu-item:last-child {
|
||||||
|
padding-bottom: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu.flip-left {
|
||||||
|
left: auto;
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* NSFW Level Selector */
|
/* NSFW Level Selector */
|
||||||
.nsfw-level-selector {
|
.nsfw-level-selector {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -4,15 +4,20 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
border-bottom: 1px solid var(--lora-border);
|
border-bottom: 1px solid var(--lora-border);
|
||||||
padding-bottom: 10px;
|
padding-bottom: var(--space-2);
|
||||||
margin-bottom: 10px;
|
margin-bottom: var(--space-3);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-modal-header h2 {
|
.recipe-modal-header h2 {
|
||||||
font-size: 1.4em; /* Reduced from default h2 size */
|
margin: 0 0 var(--space-1);
|
||||||
line-height: 1.3;
|
padding: var(--space-1);
|
||||||
margin: 0;
|
border-radius: var(--border-radius-xs);
|
||||||
max-height: 2.6em; /* Limit to 2 lines */
|
font-size: 1.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-color);
|
||||||
|
max-height: 2.8em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -127,7 +132,7 @@
|
|||||||
/* Recipe Tags styles */
|
/* Recipe Tags styles */
|
||||||
.recipe-tags-container {
|
.recipe-tags-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 6px;
|
margin-top: 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +230,62 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recipe Header Actions */
|
||||||
|
.recipe-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-header-actions:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-source-url-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.03);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9em;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .recipe-source-url-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-source-url-btn:hover {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-source-url-btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 860px) {
|
||||||
|
.recipe-header-actions {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Top Section: Preview and Gen Params */
|
/* Top Section: Preview and Gen Params */
|
||||||
.recipe-top-section {
|
.recipe-top-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -396,14 +457,54 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-gen-params h3 {
|
.gen-params-header-row {
|
||||||
margin-top: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
font-size: 1.2em;
|
|
||||||
color: var(--text-color);
|
|
||||||
padding-bottom: var(--space-1);
|
padding-bottom: var(--space-1);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-params-header-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline toggle for lora strip setting */
|
||||||
|
.lora-strip-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .inline-toggle-label {
|
||||||
|
font-size: 0.78em;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle:hover .inline-toggle-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-switch {
|
||||||
|
width: 32px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-slider:before {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-switch input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gen-params-container {
|
.gen-params-container {
|
||||||
@@ -1043,13 +1144,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recipe-modal-header {
|
.recipe-modal-header {
|
||||||
padding-bottom: 6px;
|
padding-bottom: var(--space-1);
|
||||||
margin-bottom: 8px;
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-modal-header h2 {
|
.recipe-modal-header h2 {
|
||||||
font-size: 1.25em;
|
font-size: 1.3em;
|
||||||
max-height: 2.5em;
|
max-height: 2.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-tags-container {
|
.recipe-tags-container {
|
||||||
|
|||||||
@@ -67,7 +67,6 @@
|
|||||||
|
|
||||||
.early-access-info {
|
.early-access-info {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
@@ -97,7 +96,6 @@
|
|||||||
|
|
||||||
.local-path {
|
.local-path {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|||||||
@@ -371,6 +371,14 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
|
||||||
|
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
|
||||||
|
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
|
||||||
|
the sticky breadcrumb. */
|
||||||
|
.controls:has(.dropdown-group.active) {
|
||||||
|
z-index: var(--z-header);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 6px 15px;
|
padding: 6px 15px;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||||
@import 'components/statistics.css'; /* Add statistics component */
|
@import 'components/statistics.css'; /* Add statistics component */
|
||||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||||
|
@import 'components/media-viewer.css';
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -978,6 +978,16 @@ export class BaseModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageState.filters.autoTags && Object.keys(pageState.filters.autoTags).length > 0) {
|
||||||
|
Object.entries(pageState.filters.autoTags).forEach(([tag, state]) => {
|
||||||
|
if (state === 'include') {
|
||||||
|
params.append('auto_tag_include', tag);
|
||||||
|
} else if (state === 'exclude') {
|
||||||
|
params.append('auto_tag_exclude', tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
||||||
// Check for empty wildcard marker - if present, no models should match
|
// Check for empty wildcard marker - if present, no models should match
|
||||||
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
|
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export class BaseContextMenu {
|
|||||||
this.menu = document.getElementById(menuId);
|
this.menu = document.getElementById(menuId);
|
||||||
this.cardSelector = cardSelector;
|
this.cardSelector = cardSelector;
|
||||||
this.currentCard = null;
|
this.currentCard = null;
|
||||||
|
this.submenuTimeout = null;
|
||||||
|
this.openSubmenu = null;
|
||||||
|
|
||||||
if (!this.menu) {
|
if (!this.menu) {
|
||||||
console.error(`Context menu element with ID ${menuId} not found`);
|
console.error(`Context menu element with ID ${menuId} not found`);
|
||||||
@@ -13,20 +15,99 @@ export class BaseContextMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Hide menu on regular clicks
|
// Hide menu when clicking outside
|
||||||
document.addEventListener('click', () => this.hideMenu());
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.menu.contains(e.target)) {
|
||||||
|
this.hideMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle menu item clicks
|
// Handle menu item clicks (including submenu items)
|
||||||
this.menu.addEventListener('click', (e) => {
|
this.menu.addEventListener('click', (e) => {
|
||||||
const menuItem = e.target.closest('.context-menu-item');
|
const menuItem = e.target.closest('.context-menu-item');
|
||||||
if (!menuItem || !this.currentCard) return;
|
if (!menuItem || !this.currentCard) return;
|
||||||
|
|
||||||
|
// Ignore clicks on submenu trigger (has-submenu parent)
|
||||||
|
if (menuItem.classList.contains('has-submenu')) return;
|
||||||
|
|
||||||
const action = menuItem.dataset.action;
|
const action = menuItem.dataset.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|
||||||
this.handleMenuAction(action, menuItem);
|
this.handleMenuAction(action, menuItem);
|
||||||
this.hideMenu();
|
this.hideMenu();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Submenu hover handling
|
||||||
|
// Use mouseover/mouseout (which bubble) with relatedTarget checks
|
||||||
|
// to reliably detect crossing the .has-submenu boundary
|
||||||
|
this.menu.addEventListener('mouseover', (e) => {
|
||||||
|
const trigger = e.target.closest('.has-submenu');
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
// Only act when entering from outside this trigger's tree
|
||||||
|
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
|
||||||
|
|
||||||
|
this._openSubmenu(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.menu.addEventListener('mouseout', (e) => {
|
||||||
|
const trigger = e.target.closest('.has-submenu');
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
// Only close when leaving the trigger's tree entirely
|
||||||
|
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
|
||||||
|
|
||||||
|
this._scheduleSubmenuClose(trigger);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_openSubmenu(trigger) {
|
||||||
|
// Clear any pending close
|
||||||
|
if (this.submenuTimeout) {
|
||||||
|
clearTimeout(this.submenuTimeout);
|
||||||
|
this.submenuTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide any previously open submenu
|
||||||
|
if (this.openSubmenu && this.openSubmenu !== trigger) {
|
||||||
|
this._hideSubmenu(this.openSubmenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submenu = trigger.querySelector('.context-submenu');
|
||||||
|
if (!submenu) return;
|
||||||
|
|
||||||
|
submenu.style.display = 'block';
|
||||||
|
this.openSubmenu = trigger;
|
||||||
|
this._positionSubmenu(submenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleSubmenuClose(trigger) {
|
||||||
|
this.submenuTimeout = setTimeout(() => {
|
||||||
|
this._hideSubmenu(trigger);
|
||||||
|
this.submenuTimeout = null;
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hideSubmenu(trigger) {
|
||||||
|
const submenu = trigger.querySelector('.context-submenu');
|
||||||
|
if (submenu) {
|
||||||
|
submenu.style.display = 'none';
|
||||||
|
submenu.classList.remove('flip-left');
|
||||||
|
}
|
||||||
|
if (this.openSubmenu === trigger) {
|
||||||
|
this.openSubmenu = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_positionSubmenu(submenu) {
|
||||||
|
const submenuRect = submenu.getBoundingClientRect();
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
|
||||||
|
if (submenuRect.right > viewportWidth) {
|
||||||
|
submenu.classList.add('flip-left');
|
||||||
|
} else {
|
||||||
|
submenu.classList.remove('flip-left');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMenuAction(action, menuItem) {
|
handleMenuAction(action, menuItem) {
|
||||||
@@ -65,6 +146,13 @@ export class BaseContextMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hideMenu() {
|
hideMenu() {
|
||||||
|
if (this.submenuTimeout) {
|
||||||
|
clearTimeout(this.submenuTimeout);
|
||||||
|
this.submenuTimeout = null;
|
||||||
|
}
|
||||||
|
if (this.openSubmenu) {
|
||||||
|
this._hideSubmenu(this.openSubmenu);
|
||||||
|
}
|
||||||
if (this.menu) {
|
if (this.menu) {
|
||||||
this.menu.style.display = 'none';
|
this.menu.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
|
|||||||
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
||||||
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
|
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
export class BulkContextMenu extends BaseContextMenu {
|
export class BulkContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -50,6 +51,14 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
if (copyAllItem) {
|
if (copyAllItem) {
|
||||||
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
|
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Submenu parent visibility
|
||||||
|
const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]');
|
||||||
|
if (sendToWorkflowSubmenu) {
|
||||||
|
const hasWorkflowActions = config.sendToWorkflow || config.copyAll;
|
||||||
|
sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (refreshAllItem) {
|
if (refreshAllItem) {
|
||||||
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
|
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
@@ -74,11 +83,46 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
if (setContentRatingItem) {
|
if (setContentRatingItem) {
|
||||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setFavoriteItem = this.menu.querySelector('[data-action="set-favorite"]');
|
||||||
|
|
||||||
|
if (setFavoriteItem && config.setFavorite) {
|
||||||
|
setFavoriteItem.style.display = 'flex';
|
||||||
|
|
||||||
|
const total = state.selectedModels.size;
|
||||||
|
const favoritedCount = this.countFavoritedInSelection();
|
||||||
|
const allFavorited = total > 0 && favoritedCount === total;
|
||||||
|
|
||||||
|
const icon = setFavoriteItem.querySelector('i');
|
||||||
|
const label = setFavoriteItem.querySelector('span');
|
||||||
|
|
||||||
|
if (allFavorited) {
|
||||||
|
if (icon) { icon.className = 'far fa-star'; }
|
||||||
|
if (label) { label.textContent = translate('loras.bulkOperations.unfavorite'); }
|
||||||
|
} else {
|
||||||
|
if (icon) { icon.className = 'fas fa-star'; }
|
||||||
|
if (label) {
|
||||||
|
label.textContent = favoritedCount > 0
|
||||||
|
? translate('loras.bulkOperations.setFavoriteCount', { favorited: favoritedCount, total })
|
||||||
|
: translate('loras.bulkOperations.setFavorite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (setFavoriteItem) {
|
||||||
|
setFavoriteItem.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadMissingLorasItem) {
|
if (downloadMissingLorasItem) {
|
||||||
// Only show for recipes page
|
// Only show for recipes page
|
||||||
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||||
|
if (downloadExampleImagesItem) {
|
||||||
|
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
|
||||||
|
const modelPages = ['loras', 'checkpoints', 'embeddings'];
|
||||||
|
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||||
|
|
||||||
@@ -112,6 +156,14 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide empty sections
|
||||||
|
this.menu.querySelectorAll('.context-menu-section').forEach(section => {
|
||||||
|
const items = Array.from(section.querySelectorAll('.context-menu-item'))
|
||||||
|
.filter(item => !item.closest('.context-submenu'));
|
||||||
|
const allHidden = items.length > 0 && items.every(item => item.style.display === 'none');
|
||||||
|
section.style.display = allHidden ? 'none' : '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedCountHeader() {
|
updateSelectedCountHeader() {
|
||||||
@@ -138,6 +190,20 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countFavoritedInSelection() {
|
||||||
|
let count = 0;
|
||||||
|
for (const filePath of state.selectedModels) {
|
||||||
|
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||||
|
? window.CSS.escape(filePath)
|
||||||
|
: filePath.replace(/["\\]/g, '\\$&');
|
||||||
|
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||||
|
if (card && card.dataset.favorite === 'true') {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
this.updateMenuItemsForModelType();
|
this.updateMenuItemsForModelType();
|
||||||
this.updateSelectedCountHeader();
|
this.updateSelectedCountHeader();
|
||||||
@@ -185,9 +251,17 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
case 'delete-all':
|
case 'delete-all':
|
||||||
bulkManager.showBulkDeleteModal();
|
bulkManager.showBulkDeleteModal();
|
||||||
break;
|
break;
|
||||||
|
case 'set-favorite': {
|
||||||
|
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
|
||||||
|
bulkManager.setBulkFavorites(!allFavorited);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'download-missing-loras':
|
case 'download-missing-loras':
|
||||||
this.handleDownloadMissingLoras();
|
this.handleDownloadMissingLoras();
|
||||||
break;
|
break;
|
||||||
|
case 'download-example-images':
|
||||||
|
this.handleDownloadExampleImages();
|
||||||
|
break;
|
||||||
case 'clear':
|
case 'clear':
|
||||||
bulkManager.clearSelection();
|
bulkManager.clearSelection();
|
||||||
break;
|
break;
|
||||||
@@ -230,4 +304,31 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleDownloadExampleImages() {
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashes = new Set();
|
||||||
|
for (const filePath of state.selectedModels) {
|
||||||
|
const escapedPath = CSS.escape(filePath);
|
||||||
|
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||||
|
if (card?.dataset?.sha256) {
|
||||||
|
hashes.add(card.dataset.sha256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashes.size === 0) {
|
||||||
|
showToast('No valid model hashes found in selection', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
await apiClient.downloadExampleImages([...hashes]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bulk download example images failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { downloadManager } from '../managers/DownloadManager.js';
|
import { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
|
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||||
|
|
||||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||||
'prompt',
|
'prompt',
|
||||||
@@ -104,6 +105,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.setupCopyButtons();
|
this.setupCopyButtons();
|
||||||
|
this.setupStripLoraToggle();
|
||||||
this.setupPromptEditors();
|
this.setupPromptEditors();
|
||||||
// Set up tooltip positioning handlers after DOM is ready
|
// Set up tooltip positioning handlers after DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -112,6 +114,23 @@ class RecipeModal {
|
|||||||
|
|
||||||
// Set up document click handler to close edit fields
|
// Set up document click handler to close edit fields
|
||||||
document.addEventListener('click', (event) => {
|
document.addEventListener('click', (event) => {
|
||||||
|
const recipeModal = document.getElementById('recipeModal');
|
||||||
|
if (recipeModal && recipeModal.style.display !== 'none') {
|
||||||
|
const mediaEl = event.target.closest('.recipe-preview-media');
|
||||||
|
if (mediaEl && mediaEl.tagName) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const isVideo = mediaEl.tagName === 'VIDEO';
|
||||||
|
const url = mediaEl.src || mediaEl.currentSrc;
|
||||||
|
if (url) {
|
||||||
|
openMediaViewer(url, {
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
title: document.getElementById('recipeModalTitle')?.textContent || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle title edit
|
// Handle title edit
|
||||||
const titleEditor = document.getElementById('recipeTitleEditor');
|
const titleEditor = document.getElementById('recipeTitleEditor');
|
||||||
if (titleEditor && titleEditor.classList.contains('active') &&
|
if (titleEditor && titleEditor.classList.contains('active') &&
|
||||||
@@ -364,6 +383,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
this.syncGenerationParams(hydratedRecipe.gen_params);
|
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||||
this.syncResourcesSection(hydratedRecipe);
|
this.syncResourcesSection(hydratedRecipe);
|
||||||
|
this.syncSourceUrlAction();
|
||||||
|
|
||||||
// Show the modal
|
// Show the modal
|
||||||
modalManager.showModal('recipeModal');
|
modalManager.showModal('recipeModal');
|
||||||
@@ -496,6 +516,7 @@ class RecipeModal {
|
|||||||
} else {
|
} else {
|
||||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||||
}
|
}
|
||||||
|
this.syncSourceUrlAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
getPreviewMediaUrl(recipe = {}) {
|
getPreviewMediaUrl(recipe = {}) {
|
||||||
@@ -563,6 +584,30 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncSourceUrlAction() {
|
||||||
|
const actionsContainer = document.getElementById('recipeHeaderActions');
|
||||||
|
if (!actionsContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
const sourcePath = this.currentRecipe?.source_path || '';
|
||||||
|
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
||||||
|
if (!isValidUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'recipe-source-url-btn';
|
||||||
|
btn.title = sourcePath;
|
||||||
|
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
window.open(sourcePath, '_blank');
|
||||||
|
});
|
||||||
|
actionsContainer.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
syncTagsDisplay(tags) {
|
syncTagsDisplay(tags) {
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||||
if (!tagsContainer) {
|
if (!tagsContainer) {
|
||||||
@@ -1297,6 +1342,7 @@ class RecipeModal {
|
|||||||
// Update source URL in the UI
|
// Update source URL in the UI
|
||||||
this.commitField('source_path');
|
this.commitField('source_path');
|
||||||
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
||||||
|
this.syncSourceUrlAction();
|
||||||
|
|
||||||
// Update the current recipe object
|
// Update the current recipe object
|
||||||
this.currentRecipe.source_path = newSourceUrl;
|
this.currentRecipe.source_path = newSourceUrl;
|
||||||
@@ -1332,14 +1378,20 @@ class RecipeModal {
|
|||||||
|
|
||||||
if (copyPromptBtn) {
|
if (copyPromptBtn) {
|
||||||
copyPromptBtn.addEventListener('click', () => {
|
copyPromptBtn.addEventListener('click', () => {
|
||||||
const promptText = this.currentRecipe?.gen_params?.prompt || '';
|
let promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||||
|
if (this.shouldStripLoraOnCopy()) {
|
||||||
|
promptText = RecipeModal.stripLoraTags(promptText);
|
||||||
|
}
|
||||||
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyNegativePromptBtn) {
|
if (copyNegativePromptBtn) {
|
||||||
copyNegativePromptBtn.addEventListener('click', () => {
|
copyNegativePromptBtn.addEventListener('click', () => {
|
||||||
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||||
|
if (this.shouldStripLoraOnCopy()) {
|
||||||
|
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
|
||||||
|
}
|
||||||
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1359,6 +1411,43 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
|
||||||
|
* Handles both unescaped (<lora:...>) and HTML-escaped (<lora:...>) variants.
|
||||||
|
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||||
|
*/
|
||||||
|
static stripLoraTags(text) {
|
||||||
|
return text
|
||||||
|
.replace(/<lora:[^>]*>/gi, '')
|
||||||
|
.replace(/<lora:[^&]*>/gi, '')
|
||||||
|
.replace(/,(\s*,)+/g, ',')
|
||||||
|
.replace(/^,\s*/, '')
|
||||||
|
.replace(/,\s*$/, '')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldStripLoraOnCopy() {
|
||||||
|
const toggle = document.getElementById('stripLoraOnCopyToggle');
|
||||||
|
return toggle ? toggle.checked : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupStripLoraToggle() {
|
||||||
|
const toggle = document.getElementById('stripLoraOnCopyToggle');
|
||||||
|
if (!toggle) return;
|
||||||
|
|
||||||
|
const stored = getStorageItem('strip_lora_on_copy');
|
||||||
|
if (stored !== null) {
|
||||||
|
toggle.checked = stored === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('change', () => {
|
||||||
|
const checked = toggle.checked;
|
||||||
|
setStorageItem('strip_lora_on_copy', checked);
|
||||||
|
state.global.settings.strip_lora_on_copy = checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch recipe syntax from backend and copy to clipboard
|
// Fetch recipe syntax from backend and copy to clipboard
|
||||||
async fetchAndCopyRecipeSyntax() {
|
async fetchAndCopyRecipeSyntax() {
|
||||||
if (!this.recipeId) {
|
if (!this.recipeId) {
|
||||||
|
|||||||
@@ -166,17 +166,6 @@ export class PageControls {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle quick refresh option
|
|
||||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
|
||||||
if (quickRefreshOption) {
|
|
||||||
quickRefreshOption.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.refreshModels(false);
|
|
||||||
// Close the dropdown
|
|
||||||
document.querySelector('.dropdown-group.active')?.classList.remove('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle full rebuild option
|
// Handle full rebuild option
|
||||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||||
if (fullRebuildOption) {
|
if (fullRebuildOption) {
|
||||||
|
|||||||
204
static/js/components/shared/MediaViewer.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
let activeViewer = null;
|
||||||
|
|
||||||
|
function createMediaElement(item) {
|
||||||
|
const { url, type = 'image' } = item;
|
||||||
|
if (type === 'video') {
|
||||||
|
const el = document.createElement('video');
|
||||||
|
el.controls = true;
|
||||||
|
el.autoplay = true;
|
||||||
|
el.loop = true;
|
||||||
|
el.muted = true;
|
||||||
|
el.className = 'media-viewer-media media-viewer-video';
|
||||||
|
el.src = url;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
const el = document.createElement('img');
|
||||||
|
el.className = 'media-viewer-media media-viewer-image';
|
||||||
|
el.src = url;
|
||||||
|
el.alt = 'Full size preview';
|
||||||
|
el.draggable = false;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preloadAdjacent(items, index) {
|
||||||
|
[index - 1, index + 1].forEach(i => {
|
||||||
|
if (i >= 0 && i < items.length && items[i].type !== 'video') {
|
||||||
|
const preload = new Image();
|
||||||
|
preload.src = items[i].url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMediaViewer(arg1, arg2, arg3) {
|
||||||
|
closeMediaViewer();
|
||||||
|
|
||||||
|
let items, currentIndex, title = '';
|
||||||
|
|
||||||
|
if (Array.isArray(arg1)) {
|
||||||
|
items = arg1;
|
||||||
|
currentIndex = typeof arg2 === 'number' ? arg2 : 0;
|
||||||
|
title = (arg3 && arg3.title) || '';
|
||||||
|
} else {
|
||||||
|
items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }];
|
||||||
|
currentIndex = 0;
|
||||||
|
title = (arg2 && arg2.title) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'media-viewer-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-label', title || 'Media viewer');
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'media-viewer-close';
|
||||||
|
closeBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||||
|
closeBtn.title = 'Close (Esc)';
|
||||||
|
closeBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeMediaViewer();
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentContainer = document.createElement('div');
|
||||||
|
contentContainer.className = 'media-viewer-content-container';
|
||||||
|
|
||||||
|
let mediaElement = createMediaElement(items[currentIndex]);
|
||||||
|
contentContainer.appendChild(mediaElement);
|
||||||
|
|
||||||
|
const hasNavigation = items.length > 1;
|
||||||
|
|
||||||
|
const counter = document.createElement('div');
|
||||||
|
counter.className = 'media-viewer-counter';
|
||||||
|
counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : '';
|
||||||
|
contentContainer.appendChild(counter);
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
const titleBar = document.createElement('div');
|
||||||
|
titleBar.className = 'media-viewer-title';
|
||||||
|
titleBar.textContent = title;
|
||||||
|
contentContainer.appendChild(titleBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevBtn, nextBtn;
|
||||||
|
if (hasNavigation) {
|
||||||
|
prevBtn = document.createElement('button');
|
||||||
|
prevBtn.className = 'media-viewer-nav media-viewer-prev';
|
||||||
|
prevBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
|
||||||
|
prevBtn.title = 'Previous (←)';
|
||||||
|
nextBtn = document.createElement('button');
|
||||||
|
nextBtn.className = 'media-viewer-nav media-viewer-next';
|
||||||
|
nextBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||||||
|
nextBtn.title = 'Next (→)';
|
||||||
|
|
||||||
|
const navigate = (delta) => {
|
||||||
|
const newIndex = (currentIndex + delta + items.length) % items.length;
|
||||||
|
currentIndex = newIndex;
|
||||||
|
|
||||||
|
const oldMedia = contentContainer.querySelector('.media-viewer-media');
|
||||||
|
const newMedia = createMediaElement(items[currentIndex]);
|
||||||
|
|
||||||
|
if (oldMedia) {
|
||||||
|
if (oldMedia.tagName === 'VIDEO') {
|
||||||
|
oldMedia.pause();
|
||||||
|
oldMedia.src = '';
|
||||||
|
}
|
||||||
|
oldMedia.replaceWith(newMedia);
|
||||||
|
}
|
||||||
|
mediaElement = newMedia;
|
||||||
|
|
||||||
|
counter.textContent = `${currentIndex + 1} / ${items.length}`;
|
||||||
|
preloadAdjacent(items, currentIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||||
|
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); });
|
||||||
|
|
||||||
|
overlay.appendChild(prevBtn);
|
||||||
|
overlay.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.appendChild(closeBtn);
|
||||||
|
overlay.appendChild(contentContainer);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
closeMediaViewer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeMediaViewer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasNavigation) {
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
prevBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
nextBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', keyHandler, true);
|
||||||
|
|
||||||
|
activeViewer = { overlay, keyHandler };
|
||||||
|
preloadAdjacent(items, currentIndex);
|
||||||
|
|
||||||
|
if (items[currentIndex].type === 'video') {
|
||||||
|
const recipeVideo = document.getElementById('recipeModalVideo');
|
||||||
|
if (recipeVideo && !recipeVideo.paused) {
|
||||||
|
recipeVideo.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeMediaViewer() {
|
||||||
|
if (!activeViewer) return;
|
||||||
|
|
||||||
|
const { overlay, keyHandler } = activeViewer;
|
||||||
|
|
||||||
|
const video = overlay.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
video.pause();
|
||||||
|
video.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = overlay.querySelector('img');
|
||||||
|
if (img) {
|
||||||
|
img.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('keydown', keyHandler, true);
|
||||||
|
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
overlay.addEventListener('transitionend', () => {
|
||||||
|
if (overlay.parentNode) {
|
||||||
|
overlay.parentNode.removeChild(overlay);
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (overlay.parentNode) {
|
||||||
|
overlay.parentNode.removeChild(overlay);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
activeViewer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMediaViewerOpen() {
|
||||||
|
return activeViewer !== null;
|
||||||
|
}
|
||||||
@@ -644,8 +644,23 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
||||||
<div>
|
<div class="version-row">
|
||||||
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''}
|
${(() => {
|
||||||
|
const autoTags = model.auto_tags || [];
|
||||||
|
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
||||||
|
const hasVersionName = model.civitai?.name;
|
||||||
|
if (!hlTags.length && !hasVersionName) return '';
|
||||||
|
const density = state.global.settings.display_density || 'default';
|
||||||
|
const shortLabels = density === 'medium' || density === 'compact';
|
||||||
|
const badges = hlTags.map(t => {
|
||||||
|
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
|
||||||
|
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
||||||
|
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
||||||
|
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
||||||
|
}).join('');
|
||||||
|
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
|
||||||
|
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
|
||||||
|
})()}
|
||||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ function buildActionButton(label, variant, action, options = {}) {
|
|||||||
if (action) {
|
if (action) {
|
||||||
attributes.push(`data-version-action="${escapeHtml(action)}"`);
|
attributes.push(`data-version-action="${escapeHtml(action)}"`);
|
||||||
}
|
}
|
||||||
if (options.title) {
|
if (!options.disabled && options.title) {
|
||||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||||
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,11 @@ function buildActionButton(label, variant, action, options = {}) {
|
|||||||
if (options.extraAttributes) {
|
if (options.extraAttributes) {
|
||||||
attributes.push(options.extraAttributes);
|
attributes.push(options.extraAttributes);
|
||||||
}
|
}
|
||||||
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
const buttonHtml = `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
||||||
|
if (options.disabled && options.title) {
|
||||||
|
return `<span class="version-action-disabled-wrapper" title="${escapeHtml(options.title)}" aria-label="${escapeHtml(options.title)}">${buttonHtml}</span>`;
|
||||||
|
}
|
||||||
|
return buttonHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||||
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||||
|
import { openMediaViewer } from '../MediaViewer.js';
|
||||||
|
|
||||||
export const showcaseListenerMetrics = {
|
export const showcaseListenerMetrics = {
|
||||||
wheelListeners: 0,
|
wheelListeners: 0,
|
||||||
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
|
|||||||
initMediaControlHandlers(carousel);
|
initMediaControlHandlers(carousel);
|
||||||
positionAllMediaControls(carousel);
|
positionAllMediaControls(carousel);
|
||||||
|
|
||||||
|
// Click-to-view: open full-size media viewer when clicking showcase images/videos
|
||||||
|
const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
|
||||||
|
const allItems = [];
|
||||||
|
const elementIndexMap = new Map();
|
||||||
|
viewerElements.forEach((el) => {
|
||||||
|
const isVideo = el.tagName === 'VIDEO';
|
||||||
|
const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
|
||||||
|
if (url) {
|
||||||
|
elementIndexMap.set(el, allItems.length);
|
||||||
|
allItems.push({ url, type: isVideo ? 'video' : 'image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
viewerElements.forEach((mediaEl) => {
|
||||||
|
const idx = elementIndexMap.get(mediaEl);
|
||||||
|
if (idx === undefined) return;
|
||||||
|
mediaEl.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openMediaViewer(allItems, idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Bind scroll-indicator click events
|
// Bind scroll-indicator click events
|
||||||
bindScrollIndicatorEvents(carousel);
|
bindScrollIndicatorEvents(carousel);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
|||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
import { RecipeSidebarApiClient, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
@@ -41,7 +41,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: true,
|
setContentRating: true,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
@@ -53,7 +55,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: false,
|
setContentRating: false,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.CHECKPOINT]: {
|
[MODEL_TYPES.CHECKPOINT]: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
@@ -65,7 +69,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: true,
|
setContentRating: true,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
addTags: false,
|
addTags: false,
|
||||||
@@ -77,7 +83,9 @@ export class BulkManager {
|
|||||||
autoOrganize: false,
|
autoOrganize: false,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: false,
|
setContentRating: false,
|
||||||
skipMetadataRefresh: false
|
skipMetadataRefresh: false,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -538,9 +546,23 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const countElement = document.getElementById('bulkDeleteCount');
|
const count = state.selectedModels.size;
|
||||||
if (countElement) {
|
const isRecipes = state.currentPageType === 'recipes';
|
||||||
countElement.textContent = state.selectedModels.size;
|
const keyPrefix = isRecipes ? 'modals.bulkDeleteRecipes' : 'modals.bulkDelete';
|
||||||
|
|
||||||
|
const titleEl = document.querySelector('#bulkDeleteModal h2');
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.textContent = translate(`${keyPrefix}.title`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageEl = document.querySelector('#bulkDeleteModal .delete-message');
|
||||||
|
if (messageEl) {
|
||||||
|
messageEl.textContent = translate(`${keyPrefix}.message`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countInfoEl = document.querySelector('#bulkDeleteModal .delete-model-info p');
|
||||||
|
if (countInfoEl) {
|
||||||
|
countInfoEl.innerHTML = `<span id="bulkDeleteCount">${count}</span> ${translate(`${keyPrefix}.countMessage`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
modalManager.showModal('bulkDeleteModal');
|
modalManager.showModal('bulkDeleteModal');
|
||||||
@@ -1090,6 +1112,60 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setBulkFavorites(value) {
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
|
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = state.selectedModels.size;
|
||||||
|
const isRecipesPage = state.currentPageType === 'recipes';
|
||||||
|
|
||||||
|
state.loadingManager.showSimpleLoading(
|
||||||
|
translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
|
||||||
|
);
|
||||||
|
let cancelled = false;
|
||||||
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
cancelled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failureCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const filePath of state.selectedModels) {
|
||||||
|
if (cancelled) {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isRecipesPage) {
|
||||||
|
await updateRecipeMetadata(filePath, { favorite: value });
|
||||||
|
} else {
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
await apiClient.saveModelMetadata(filePath, { favorite: value });
|
||||||
|
}
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
failureCount++;
|
||||||
|
console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.loadingManager?.hide?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount === totalCount) {
|
||||||
|
const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
|
||||||
|
showToast(toastKey, {}, 'success');
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
|
||||||
|
showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
|
||||||
|
} else {
|
||||||
|
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show bulk base model modal
|
* Show bulk base model modal
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export class FilterManager {
|
|||||||
// Initialize tag logic toggle
|
// Initialize tag logic toggle
|
||||||
this.initializeTagLogicToggle();
|
this.initializeTagLogicToggle();
|
||||||
|
|
||||||
|
// Create auto-tag filter section (I2V, T2V, TI2V, Lightning, Turbo)
|
||||||
|
this.createAutoTagFilters();
|
||||||
|
|
||||||
// Add click handler for filter button
|
// Add click handler for filter button
|
||||||
if (this.filterButton) {
|
if (this.filterButton) {
|
||||||
this.filterButton.addEventListener('click', () => {
|
this.filterButton.addEventListener('click', () => {
|
||||||
@@ -480,6 +483,58 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AUTO_TAG_FILTER_TAGS = ['I2V', 'T2V', 'TI2V', 'Lightning', 'Turbo'];
|
||||||
|
|
||||||
|
createAutoTagFilters() {
|
||||||
|
const container = document.getElementById('autoTagFilterTags');
|
||||||
|
if (container) return;
|
||||||
|
|
||||||
|
const modelTypeSection = document.getElementById('modelTypeTags')?.closest('.filter-section');
|
||||||
|
if (!modelTypeSection) return;
|
||||||
|
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'filter-section';
|
||||||
|
section.innerHTML = `
|
||||||
|
<h4>${translate('header.filter.autoTags', {}, 'Auto Tags')}</h4>
|
||||||
|
<div class="filter-tags" id="autoTagFilterTags"></div>
|
||||||
|
`;
|
||||||
|
modelTypeSection.parentNode.insertBefore(section, modelTypeSection.nextSibling);
|
||||||
|
|
||||||
|
const tagsContainer = document.getElementById('autoTagFilterTags');
|
||||||
|
this.AUTO_TAG_FILTER_TAGS.forEach(tag => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'filter-tag auto-tag-filter';
|
||||||
|
el.dataset.autoTag = tag;
|
||||||
|
el.textContent = tag;
|
||||||
|
|
||||||
|
// Restore previous state
|
||||||
|
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||||
|
this._applyTriState(el, state);
|
||||||
|
|
||||||
|
el.addEventListener('click', async () => {
|
||||||
|
const current = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||||
|
const next = current === 'none' ? 'include' : current === 'include' ? 'exclude' : 'none';
|
||||||
|
if (!this.filters.autoTags) this.filters.autoTags = {};
|
||||||
|
if (next === 'none') {
|
||||||
|
delete this.filters.autoTags[tag];
|
||||||
|
} else {
|
||||||
|
this.filters.autoTags[tag] = next;
|
||||||
|
}
|
||||||
|
this._applyTriState(el, next);
|
||||||
|
this.updateActiveFiltersCount();
|
||||||
|
await this.applyFilters(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tagsContainer.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyTriState(el, state) {
|
||||||
|
el.classList.remove('active', 'exclude');
|
||||||
|
if (state === 'include') el.classList.add('active');
|
||||||
|
else if (state === 'exclude') el.classList.add('exclude');
|
||||||
|
}
|
||||||
|
|
||||||
toggleFilterPanel() {
|
toggleFilterPanel() {
|
||||||
if (this.filterPanel) {
|
if (this.filterPanel) {
|
||||||
const isHidden = this.filterPanel.classList.contains('hidden');
|
const isHidden = this.filterPanel.classList.contains('hidden');
|
||||||
@@ -540,6 +595,13 @@ export class FilterManager {
|
|||||||
this.updateLicenseSelections();
|
this.updateLicenseSelections();
|
||||||
}
|
}
|
||||||
this.updateModelTypeSelections();
|
this.updateModelTypeSelections();
|
||||||
|
|
||||||
|
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
|
||||||
|
autoTagEls.forEach(el => {
|
||||||
|
const tag = el.dataset.autoTag;
|
||||||
|
const state = (this.filters.autoTags && this.filters.autoTags[tag]) || 'none';
|
||||||
|
this._applyTriState(el, state);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateModelTypeSelections() {
|
updateModelTypeSelections() {
|
||||||
@@ -556,11 +618,12 @@ export class FilterManager {
|
|||||||
|
|
||||||
updateActiveFiltersCount() {
|
updateActiveFiltersCount() {
|
||||||
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
|
const autoTagFilterCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
|
||||||
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||||
const modelTypeFilterCount = this.filters.modelTypes.length;
|
const modelTypeFilterCount = this.filters.modelTypes.length;
|
||||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||||
const totalActiveFilters = baseModelCount + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||||
|
|
||||||
if (this.activeFiltersCount) {
|
if (this.activeFiltersCount) {
|
||||||
if (totalActiveFilters > 0) {
|
if (totalActiveFilters > 0) {
|
||||||
@@ -599,7 +662,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
@@ -652,6 +715,7 @@ export class FilterManager {
|
|||||||
...this.filters,
|
...this.filters,
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: {},
|
tags: {},
|
||||||
|
autoTags: {},
|
||||||
license: {},
|
license: {},
|
||||||
modelTypes: [],
|
modelTypes: [],
|
||||||
tagLogic: 'any'
|
tagLogic: 'any'
|
||||||
@@ -682,7 +746,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
@@ -721,6 +785,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
hasActiveFilters() {
|
hasActiveFilters() {
|
||||||
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
|
||||||
|
const autoTagCount = this.filters.autoTags ? Object.keys(this.filters.autoTags).length : 0;
|
||||||
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
|
||||||
const modelTypeCount = this.filters.modelTypes.length;
|
const modelTypeCount = this.filters.modelTypes.length;
|
||||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||||
@@ -728,6 +793,7 @@ export class FilterManager {
|
|||||||
return (
|
return (
|
||||||
baseModelCount > 0 ||
|
baseModelCount > 0 ||
|
||||||
tagCount > 0 ||
|
tagCount > 0 ||
|
||||||
|
autoTagCount > 0 ||
|
||||||
licenseCount > 0 ||
|
licenseCount > 0 ||
|
||||||
modelTypeCount > 0
|
modelTypeCount > 0
|
||||||
);
|
);
|
||||||
@@ -739,6 +805,7 @@ export class FilterManager {
|
|||||||
...source,
|
...source,
|
||||||
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
|
baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [],
|
||||||
tags: this.normalizeTagFilters(source.tags),
|
tags: this.normalizeTagFilters(source.tags),
|
||||||
|
autoTags: this.normalizeTagFilters(source.autoTags),
|
||||||
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
||||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
||||||
tagLogic: source.tagLogic || 'any'
|
tagLogic: source.tagLogic || 'any'
|
||||||
@@ -822,6 +889,7 @@ export class FilterManager {
|
|||||||
...this.filters,
|
...this.filters,
|
||||||
baseModel: [...(this.filters.baseModel || [])],
|
baseModel: [...(this.filters.baseModel || [])],
|
||||||
tags: { ...(this.filters.tags || {}) },
|
tags: { ...(this.filters.tags || {}) },
|
||||||
|
autoTags: { ...(this.filters.autoTags || {}) },
|
||||||
license: { ...(this.filters.license || {}) },
|
license: { ...(this.filters.license || {}) },
|
||||||
modelTypes: [...(this.filters.modelTypes || [])],
|
modelTypes: [...(this.filters.modelTypes || [])],
|
||||||
tagLogic: this.filters.tagLogic || 'any'
|
tagLogic: this.filters.tagLogic || 'any'
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class SearchManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes(true); // true to reset pagination
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
|
|||||||
@@ -2863,7 +2863,7 @@ export class SettingsManager {
|
|||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} else if (this.currentPage === 'recipes') {
|
} else if (this.currentPage === 'recipes') {
|
||||||
// Reload the recipes without updating folders
|
// Reload the recipes without updating folders
|
||||||
await window.recipeManager.loadRecipes();
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class RecipePageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
refreshVirtualScroll();
|
await refreshVirtualScroll({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
@@ -286,16 +286,6 @@ class RecipeManager {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle quick refresh option (Sync Changes)
|
|
||||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
|
||||||
if (quickRefreshOption) {
|
|
||||||
quickRefreshOption.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.pageControls.refreshModels(false);
|
|
||||||
this.closeDropdowns();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle full rebuild option (Rebuild Cache)
|
// Handle full rebuild option (Rebuild Cache)
|
||||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||||
if (fullRebuildOption) {
|
if (fullRebuildOption) {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
download_skip_base_models: [],
|
download_skip_base_models: [],
|
||||||
backup_auto_enabled: true,
|
backup_auto_enabled: true,
|
||||||
backup_retention_count: 5,
|
backup_retention_count: 5,
|
||||||
|
strip_lora_on_copy: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createDefaultSettings() {
|
export function createDefaultSettings() {
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export const BASE_MODELS = {
|
|||||||
HUNYUAN_VIDEO: "Hunyuan Video",
|
HUNYUAN_VIDEO: "Hunyuan Video",
|
||||||
// Other models
|
// Other models
|
||||||
ANIMA: "Anima",
|
ANIMA: "Anima",
|
||||||
|
ERNIE: "Ernie",
|
||||||
|
ERNIE_TURBO: "Ernie Turbo",
|
||||||
|
NUCLEUS: "Nucleus",
|
||||||
PONY_V7: "Pony V7",
|
PONY_V7: "Pony V7",
|
||||||
// Default
|
// Default
|
||||||
UNKNOWN: "Other"
|
UNKNOWN: "Other"
|
||||||
@@ -191,6 +194,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
|
|||||||
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
|
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
|
||||||
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
|
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
|
||||||
[BASE_MODELS.ANIMA]: 'ANI',
|
[BASE_MODELS.ANIMA]: 'ANI',
|
||||||
|
[BASE_MODELS.ERNIE]: 'ERNI',
|
||||||
|
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
|
||||||
|
[BASE_MODELS.NUCLEUS]: 'NUCL',
|
||||||
|
|
||||||
// Default
|
// Default
|
||||||
[BASE_MODELS.UNKNOWN]: 'OTH'
|
[BASE_MODELS.UNKNOWN]: 'OTH'
|
||||||
@@ -394,6 +400,7 @@ export const BASE_MODEL_CATEGORIES = {
|
|||||||
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
|
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
|
||||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
||||||
|
BASE_MODELS.ERNIE, BASE_MODELS.ERNIE_TURBO, BASE_MODELS.NUCLEUS,
|
||||||
BASE_MODELS.UNKNOWN
|
BASE_MODELS.UNKNOWN
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -493,6 +500,18 @@ export function clearDynamicBaseModels() {
|
|||||||
dynamicBaseModelsTimestamp = null;
|
dynamicBaseModelsTimestamp = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const AUTO_TAG_GROUPS = {
|
||||||
|
mode: new Set(['HIGH', 'LOW']),
|
||||||
|
video: new Set(['I2V', 'T2V', 'TI2V']),
|
||||||
|
speed: new Set(['Lightning', 'Turbo']),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AUTO_TAG_GROUP_LABELS = {
|
||||||
|
mode: 'High / Low',
|
||||||
|
video: 'I2V / T2V / TI2V',
|
||||||
|
speed: 'Lightning / Turbo',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if dynamic base models cache is valid
|
* Check if dynamic base models cache is valid
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
|
|||||||
@@ -53,46 +53,74 @@
|
|||||||
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
|
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item" data-action="refresh-all">
|
<div class="context-menu-section" data-section="workflow">
|
||||||
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.workflow') }}</div>
|
||||||
|
<div class="context-menu-item has-submenu" data-has-submenu="send-to-workflow">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
<span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
|
||||||
|
<i class="fas fa-chevron-right submenu-arrow"></i>
|
||||||
|
<div class="context-submenu">
|
||||||
|
<div class="context-menu-item" data-action="send-to-workflow-append">
|
||||||
|
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="send-to-workflow-replace">
|
||||||
|
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="copy-all">
|
||||||
|
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="check-updates">
|
<div class="context-menu-section" data-section="metadata">
|
||||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.metadata') }}</div>
|
||||||
|
<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="skip-metadata-refresh">
|
||||||
|
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="resume-metadata-refresh">
|
||||||
|
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="copy-all">
|
<div class="context-menu-section" data-section="attributes">
|
||||||
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.attributes') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="add-tags">
|
||||||
|
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="set-base-model">
|
||||||
|
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="set-favorite">
|
||||||
|
<i class="fas fa-star"></i> <span>{{ t('loras.bulkOperations.setFavorite') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="set-content-rating">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="send-to-workflow-append">
|
<div class="context-menu-section" data-section="organize">
|
||||||
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.organize') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="auto-organize">
|
||||||
|
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="move-all">
|
||||||
|
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="send-to-workflow-replace">
|
<div class="context-menu-section" data-section="download">
|
||||||
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
|
||||||
</div>
|
<div class="context-menu-item" data-action="download-example-images">
|
||||||
<div class="context-menu-item" data-action="auto-organize">
|
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
|
||||||
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
|
</div>
|
||||||
</div>
|
<div class="context-menu-item" data-action="download-missing-loras">
|
||||||
<div class="context-menu-item" data-action="add-tags">
|
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
||||||
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="set-base-model">
|
|
||||||
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="set-content-rating">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="skip-metadata-refresh">
|
|
||||||
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="resume-metadata-refresh">
|
|
||||||
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item" data-action="download-missing-loras">
|
|
||||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="move-all">
|
|
||||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item delete-item" data-action="delete-all">
|
<div class="context-menu-item delete-item" data-action="delete-all">
|
||||||
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
|
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,9 +41,6 @@
|
|||||||
<i class="fas fa-caret-down"></i>
|
<i class="fas fa-caret-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<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" title="{{ t('loras.controls.refresh.fullTooltip') }}">
|
<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>
|
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
||||||
|
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
||||||
<!-- Recipe Tags Container -->
|
<!-- Recipe Tags Container -->
|
||||||
<div class="recipe-tags-container">
|
<div class="recipe-tags-container">
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||||
@@ -22,7 +24,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
<h3>Generation Parameters</h3>
|
<div class="gen-params-header-row">
|
||||||
|
<h3>Generation Parameters</h3>
|
||||||
|
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, <lora:...> tags are removed from prompt text when copying">
|
||||||
|
<span class="inline-toggle-label">Strip <lora:></span>
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="stripLoraOnCopyToggle">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<!-- Prompt -->
|
<!-- Prompt -->
|
||||||
|
|||||||
@@ -75,9 +75,6 @@
|
|||||||
<i class="fas fa-caret-down"></i>
|
<i class="fas fa-caret-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('recipes.controls.refresh.quickTooltip', default='Sync changes - quick refresh without rebuilding cache') }}">
|
|
||||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick', default='Sync Changes') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ describe('LoRA widget drag interactions', () => {
|
|||||||
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
|
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
|
||||||
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
|
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
|
||||||
expect(onDragEnd).toHaveBeenCalledTimes(1);
|
expect(onDragEnd).toHaveBeenCalledTimes(1);
|
||||||
expect(renderSpy).toHaveBeenCalledWith(widget.value, widget);
|
// 454210a4 replaced renderFunction() with widget.value setter + widget.callback()
|
||||||
|
expect(widget.callback).toHaveBeenCalledWith(widget.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {
|
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ function renderControlsDom(pageKey) {
|
|||||||
<button data-action="refresh" class="dropdown-main"></button>
|
<button data-action="refresh" class="dropdown-main"></button>
|
||||||
<button class="dropdown-toggle"></button>
|
<button class="dropdown-toggle"></button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<div class="dropdown-item" data-action="quick-refresh"></div>
|
|
||||||
<div class="dropdown-item" data-action="full-rebuild"></div>
|
<div class="dropdown-item" data-action="full-rebuild"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class FakeDownloadHistoryService:
|
|||||||
async def mark_downloaded(self, *_args, **_kwargs):
|
async def mark_downloaded(self, *_args, **_kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def mark_not_downloaded(self, *_args, **_kwargs):
|
async def mark_as_deleted(self, *_args, **_kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -903,7 +903,7 @@ class FakeDownloadHistoryService:
|
|||||||
(model_type, version_id, model_id, source, file_path)
|
(model_type, version_id, model_id, source, file_path)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def mark_not_downloaded(self, model_type, version_id):
|
async def mark_as_deleted(self, model_type, version_id):
|
||||||
self.marked_not_downloaded.append((model_type, version_id))
|
self.marked_not_downloaded.append((model_type, version_id))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -785,10 +785,16 @@ async def test_import_remote_recipe_merges_metadata(
|
|||||||
async def parse_metadata(self, raw, recipe_scanner=None):
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||||
return json.loads(raw[len("Recipe metadata: ") :])
|
return json.loads(raw[len("Recipe metadata: ") :])
|
||||||
|
|
||||||
|
class MockApiParser:
|
||||||
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||||
|
return {"gen_params": raw, "loras": []}
|
||||||
|
|
||||||
class MockFactory:
|
class MockFactory:
|
||||||
def create_parser(self, raw):
|
def create_parser(self, raw):
|
||||||
if raw.startswith("Recipe metadata: "):
|
if isinstance(raw, str) and raw.startswith("Recipe metadata: "):
|
||||||
return MockParser()
|
return MockParser()
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return MockApiParser()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 4. Setup Harness and run test
|
# 4. Setup Harness and run test
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ async def test_get_model_versions_raises_on_other_errors(monkeypatch, downloader
|
|||||||
async def test_get_model_versions_bulk_success(monkeypatch, downloader):
|
async def test_get_model_versions_bulk_success(monkeypatch, downloader):
|
||||||
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
assert url.endswith("/models")
|
assert url.endswith("/models")
|
||||||
assert kwargs.get("params") == {"ids": "1,2"}
|
assert kwargs.get("params") == {"ids": "1,2", "nsfw": "true"}
|
||||||
return True, {
|
return True, {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async def test_download_history_roundtrip_and_manual_override(tmp_path: Path) ->
|
|||||||
assert await service.has_been_downloaded("lora", 101) is True
|
assert await service.has_been_downloaded("lora", 101) is True
|
||||||
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||||
|
|
||||||
await service.mark_not_downloaded("lora", 101)
|
await service.mark_as_deleted("lora", 101)
|
||||||
assert await service.has_been_downloaded("lora", 101) is False
|
assert await service.has_been_downloaded("lora", 101) is False
|
||||||
assert await service.get_downloaded_version_ids("lora", 11) == []
|
assert await service.get_downloaded_version_ids("lora", 11) == []
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
|||||||
recipe = {
|
recipe = {
|
||||||
"id": "r1",
|
"id": "r1",
|
||||||
"title": "Old Recipe",
|
"title": "Old Recipe",
|
||||||
"source_url": "https://civitai.com/images/12345",
|
"source_path": "https://civitai.com/images/12345",
|
||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
"gen_params": {"prompt": ""}
|
"gen_params": {"prompt": ""}
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
|
|||||||
recipe = {
|
recipe = {
|
||||||
"id": "r1",
|
"id": "r1",
|
||||||
"title": "Red Recipe",
|
"title": "Red Recipe",
|
||||||
"source_url": "https://civitai.red/images/12345",
|
"source_path": "https://civitai.red/images/12345",
|
||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
"gen_params": {"prompt": ""},
|
"gen_params": {"prompt": ""},
|
||||||
}
|
}
|
||||||
|
|||||||
151
tests/test_auto_tag_service.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "py"))
|
||||||
|
|
||||||
|
from services.auto_tag_service import extract_auto_tags, AUTO_TAG_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractAutoTags:
|
||||||
|
def test_file_name_high_i2v(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "Shirt_lift_Wan2.2_14B_I2V_HIGH_v1.0",
|
||||||
|
"base_model": "Wan Video 2.2 I2V-A14B",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "I2V"}
|
||||||
|
|
||||||
|
def test_file_name_t2v_low(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "my_wan_t2v_low_v2",
|
||||||
|
"base_model": "Wan 2.1",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"LOW", "T2V"}
|
||||||
|
|
||||||
|
def test_file_name_ti2v_high(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "wan_ti2v_high_quality",
|
||||||
|
"base_model": "Wan 2.2",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "TI2V"}
|
||||||
|
|
||||||
|
def test_file_name_lightning_turbo(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "sdxl_lightning_turbo_v3",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"Lightning", "Turbo"}
|
||||||
|
|
||||||
|
def test_base_model_source(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "my_lora_v1",
|
||||||
|
"base_model": "Wan Video 2.2 I2V-A14B",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert "I2V" in result
|
||||||
|
|
||||||
|
def test_civitai_name_source(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "model_v1",
|
||||||
|
"base_model": "Wan",
|
||||||
|
"civitai": {"name": "HIGH Quality"},
|
||||||
|
})
|
||||||
|
assert "HIGH" in result
|
||||||
|
|
||||||
|
def test_no_false_match_flow(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "flux_dev_model",
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert "LOW" not in result
|
||||||
|
|
||||||
|
def test_no_false_match_glow(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "glow_style_lora",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert "LOW" not in result
|
||||||
|
|
||||||
|
def test_high_low_only_for_wan(self):
|
||||||
|
"""HIGH/LOW should not appear for non-Wan models even in filename."""
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "my_model_high_quality_v2",
|
||||||
|
"base_model": "Flux.1 D",
|
||||||
|
"civitai": {"name": "HIGH"},
|
||||||
|
})
|
||||||
|
assert "HIGH" not in result
|
||||||
|
assert "LOW" not in result
|
||||||
|
|
||||||
|
def test_no_distilled(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "ltx-2.3-22b-distilled-lora-384",
|
||||||
|
"base_model": "LTXV 2.3",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "generic_lora_v1",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_missing_fields(self):
|
||||||
|
result = extract_auto_tags({})
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_dash_separated(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "wan-i2v-high-v2",
|
||||||
|
"base_model": "Wan 2.2",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "I2V"}
|
||||||
|
|
||||||
|
def test_dot_separated(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "wan.i2v.high.v2",
|
||||||
|
"base_model": "Wan 2.2",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "I2V"}
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
result = extract_auto_tags({
|
||||||
|
"file_name": "WAN_i2v_High",
|
||||||
|
"base_model": "Wan 2.2",
|
||||||
|
"civitai": {},
|
||||||
|
})
|
||||||
|
assert set(result) == {"HIGH", "I2V"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoTagCategories:
|
||||||
|
def test_all_patterns_compile(self):
|
||||||
|
import re
|
||||||
|
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||||
|
re.compile(pattern, re.IGNORECASE)
|
||||||
|
|
||||||
|
def test_mode_group_tags(self):
|
||||||
|
from services.auto_tag_service import MODE_TAGS
|
||||||
|
assert "HIGH" in MODE_TAGS
|
||||||
|
assert "LOW" in MODE_TAGS
|
||||||
|
|
||||||
|
def test_video_group_tags(self):
|
||||||
|
from services.auto_tag_service import VIDEO_MODE_TAGS
|
||||||
|
assert "I2V" in VIDEO_MODE_TAGS
|
||||||
|
assert "T2V" in VIDEO_MODE_TAGS
|
||||||
|
assert "TI2V" in VIDEO_MODE_TAGS
|
||||||
|
|
||||||
|
def test_default_enabled_groups(self):
|
||||||
|
from services.auto_tag_service import DEFAULT_ENABLED_GROUPS
|
||||||
|
assert "mode" in DEFAULT_ENABLED_GROUPS
|
||||||
|
assert "video" in DEFAULT_ENABLED_GROUPS
|
||||||
|
assert "speed" not in DEFAULT_ENABLED_GROUPS
|
||||||
@@ -232,9 +232,13 @@ export function initDrag(
|
|||||||
onDragEnd();
|
onDragEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now do the re-render after drag is complete
|
// Commit final value through options.setValue so external observers are notified.
|
||||||
if (renderFunction) {
|
// During drag, handleStrengthDrag mutates widgetValue in-place (updateWidget=false),
|
||||||
renderFunction(widget.value, widget);
|
// bypassing widget.value setter and options.setValue entirely. This assignment
|
||||||
|
// flushes the in-place mutation through the setter so any setValue wrappers fire.
|
||||||
|
widget.value = widget.value;
|
||||||
|
if (typeof widget.callback === 'function') {
|
||||||
|
widget.callback(widget.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -349,8 +353,12 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
|
|||||||
document.body.classList.remove('lm-lora-strength-dragging');
|
document.body.classList.remove('lm-lora-strength-dragging');
|
||||||
|
|
||||||
// Only re-render if we actually dragged
|
// Only re-render if we actually dragged
|
||||||
if (wasDragging && renderFunction) {
|
if (wasDragging) {
|
||||||
renderFunction(widget.value, widget);
|
// Commit final value through options.setValue so external observers are notified.
|
||||||
|
widget.value = widget.value;
|
||||||
|
if (typeof widget.callback === 'function') {
|
||||||
|
widget.callback(widget.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -658,32 +658,34 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
textEl.style.maxWidth = "140px";
|
textEl.style.maxWidth = "140px";
|
||||||
}
|
}
|
||||||
|
|
||||||
const countBadge = document.createElement("span");
|
if (tagData.items.length > 1) {
|
||||||
countBadge.className = "lm-trigger-count-badge";
|
const countBadge = document.createElement("span");
|
||||||
countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`;
|
countBadge.className = "lm-trigger-count-badge";
|
||||||
Object.assign(countBadge.style, {
|
countBadge.textContent = `${groupState.activeChildren}/${groupState.totalChildren}`;
|
||||||
fontSize: "11px",
|
|
||||||
padding: "1px 6px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
backgroundColor: "rgba(255,255,255,0.12)",
|
|
||||||
color: "inherit",
|
|
||||||
flexShrink: "0",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
minWidth: "42px",
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
lineHeight: "1",
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
});
|
|
||||||
if (groupState.hasInactiveChildren) {
|
|
||||||
countBadge.classList.add("lm-trigger-count-badge--edited");
|
|
||||||
Object.assign(countBadge.style, {
|
Object.assign(countBadge.style, {
|
||||||
backgroundColor: "rgba(255,255,255,0.08)",
|
fontSize: "11px",
|
||||||
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)",
|
padding: "1px 6px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.12)",
|
||||||
|
color: "inherit",
|
||||||
|
flexShrink: "0",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
minWidth: "42px",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
lineHeight: "1",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
});
|
});
|
||||||
|
if (groupState.hasInactiveChildren) {
|
||||||
|
countBadge.classList.add("lm-trigger-count-badge--edited");
|
||||||
|
Object.assign(countBadge.style, {
|
||||||
|
backgroundColor: "rgba(255,255,255,0.08)",
|
||||||
|
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.28)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
groupChip.appendChild(countBadge);
|
||||||
}
|
}
|
||||||
groupChip.appendChild(countBadge);
|
|
||||||
|
|
||||||
if (showStrengthInfo) {
|
if (showStrengthInfo) {
|
||||||
const strengthBadge = createStrengthBadge();
|
const strengthBadge = createStrengthBadge();
|
||||||
@@ -697,39 +699,43 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text;
|
groupChip.title = activePreview ? `${tagData.text}\nActive: ${activePreview}` : tagData.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
const editButton = document.createElement("button");
|
let editButton = null;
|
||||||
editButton.type = "button";
|
|
||||||
editButton.className = "lm-trigger-group-edit-button";
|
|
||||||
editButton.textContent = "⋯";
|
|
||||||
Object.assign(editButton.style, {
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
color: "inherit",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "14px",
|
|
||||||
lineHeight: "1",
|
|
||||||
padding: "0 2px",
|
|
||||||
marginLeft: "2px",
|
|
||||||
opacity: groupState.hasInactiveChildren ? "0.9" : "0.72",
|
|
||||||
flexShrink: "0",
|
|
||||||
});
|
|
||||||
editButton.title = "Edit group tags";
|
|
||||||
|
|
||||||
const openEditor = (event) => {
|
if (tagData.items.length > 1) {
|
||||||
event.preventDefault();
|
editButton = document.createElement("button");
|
||||||
event.stopPropagation();
|
editButton.type = "button";
|
||||||
toggleGroupEditor(widget, index, groupChip);
|
editButton.className = "lm-trigger-group-edit-button";
|
||||||
renderGroupEditor(widget, tagData, index);
|
editButton.textContent = "⋯";
|
||||||
};
|
Object.assign(editButton.style, {
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "inherit",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
lineHeight: "1",
|
||||||
|
padding: "0 2px",
|
||||||
|
marginLeft: "2px",
|
||||||
|
opacity: groupState.hasInactiveChildren ? "0.9" : "0.72",
|
||||||
|
flexShrink: "0",
|
||||||
|
});
|
||||||
|
editButton.title = "Edit group tags";
|
||||||
|
|
||||||
editButton.addEventListener("click", openEditor);
|
const openEditor = (event) => {
|
||||||
groupChip.addEventListener("contextmenu", openEditor);
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleGroupEditor(widget, index, groupChip);
|
||||||
|
renderGroupEditor(widget, tagData, index);
|
||||||
|
};
|
||||||
|
|
||||||
groupChip.appendChild(editButton);
|
editButton.addEventListener("click", openEditor);
|
||||||
|
groupChip.addEventListener("contextmenu", openEditor);
|
||||||
|
|
||||||
|
groupChip.appendChild(editButton);
|
||||||
|
}
|
||||||
|
|
||||||
groupChip.addEventListener("click", (e) => {
|
groupChip.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.target === editButton) {
|
if (editButton && e.target === editButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateWidgetValue(widget, (updatedTags) => {
|
updateWidgetValue(widget, (updatedTags) => {
|
||||||
@@ -740,7 +746,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
|
|
||||||
if (showStrengthInfo) {
|
if (showStrengthInfo) {
|
||||||
groupChip.addEventListener("wheel", (e) => {
|
groupChip.addEventListener("wheel", (e) => {
|
||||||
if (e.target === editButton) {
|
if (editButton && e.target === editButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupMode = groupModeWidget?.value ?? false;
|
||||||
|
|
||||||
const updatedTags = node.tagWidget.value.map((tag) => {
|
const updatedTags = node.tagWidget.value.map((tag) => {
|
||||||
if (!Array.isArray(tag.items)) {
|
if (!Array.isArray(tag.items)) {
|
||||||
return {
|
return {
|
||||||
@@ -311,6 +313,15 @@ app.registerExtension({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In group mode, default_active only controls the group-level switch.
|
||||||
|
// Children's individual active states are managed exclusively via the group editor.
|
||||||
|
if (groupMode) {
|
||||||
|
return {
|
||||||
|
...tag,
|
||||||
|
active: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...tag,
|
...tag,
|
||||||
active: value,
|
active: value,
|
||||||
@@ -320,7 +331,6 @@ app.registerExtension({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
node.tagWidget.value = updatedTags;
|
node.tagWidget.value = updatedTags;
|
||||||
node.applyTriggerHighlightState?.();
|
node.applyTriggerHighlightState?.();
|
||||||
};
|
};
|
||||||
@@ -413,7 +423,7 @@ app.registerExtension({
|
|||||||
const savedItem = consumeQueuedState(itemState, itemText);
|
const savedItem = consumeQueuedState(itemState, itemText);
|
||||||
return {
|
return {
|
||||||
text: itemText,
|
text: itemText,
|
||||||
active: savedItem ? savedItem.active : defaultActive,
|
active: savedItem ? savedItem.active : true,
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
strength: null,
|
strength: null,
|
||||||
};
|
};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 872 KiB |
|
Before Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 43 KiB |