mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-10 04:49:24 -03:00
Compare commits
143 Commits
5dcfde36ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cbddd9cf7 | ||
|
|
cb8c699224 | ||
|
|
451f74b874 | ||
|
|
a1d248baa6 | ||
|
|
18577fa336 | ||
|
|
5797ce9408 | ||
|
|
826f06255a | ||
|
|
84e16b5c5b | ||
|
|
eb22054580 | ||
|
|
08afb05ece | ||
|
|
f51f125cf1 | ||
|
|
24b2078f21 | ||
|
|
130fb5d2d5 | ||
|
|
23c6863a3a | ||
|
|
c0e2578640 | ||
|
|
e3c812367e | ||
|
|
4d239008a6 | ||
|
|
00177a06d0 | ||
|
|
568daa351e | ||
|
|
5a4664fa12 | ||
|
|
dd5b213adc | ||
|
|
d9ee9b3155 | ||
|
|
01dac57c35 | ||
|
|
7f92d09239 | ||
|
|
62f9e3f44a | ||
|
|
e55895786d | ||
|
|
82b77bf593 | ||
|
|
1beef5dea9 | ||
|
|
c8beaa64e1 | ||
|
|
fb443ed6ae | ||
|
|
151a467598 | ||
|
|
98e1d168b0 | ||
|
|
716f18e0ed | ||
|
|
b060dc99fc | ||
|
|
54bcdfab38 | ||
|
|
2e7532eecc | ||
|
|
7e5e3b1ec7 | ||
|
|
df67bd396a | ||
|
|
dd5d9cfcb2 | ||
|
|
d9fd60bec1 | ||
|
|
b633b22779 | ||
|
|
1ffa543160 | ||
|
|
cdc940586e | ||
|
|
ccf1c6f2ae | ||
|
|
bfe7b5e1c7 | ||
|
|
85c020cd12 | ||
|
|
1b202f8ec7 | ||
|
|
d02a0611d3 | ||
|
|
92166a161a | ||
|
|
b509f27cb7 | ||
|
|
5c2ef48917 | ||
|
|
ad2bd82c67 | ||
|
|
17ba350153 | ||
|
|
60175334b5 | ||
|
|
f65a01df00 | ||
|
|
430e24d70b | ||
|
|
14f0c48fdd | ||
|
|
34791c2ad7 | ||
|
|
3f6824eef6 | ||
|
|
3919dfa3f4 | ||
|
|
7124b5293f | ||
|
|
d2a04f8993 | ||
|
|
7027a7c270 | ||
|
|
0a1d7dfd4c | ||
|
|
3962b1a96d | ||
|
|
8b856276bf | ||
|
|
c97c802956 | ||
|
|
24e2909627 | ||
|
|
b768f1368f | ||
|
|
37ccd29fc0 | ||
|
|
7416080cfb | ||
|
|
26be187d42 | ||
|
|
d7caa1fa47 | ||
|
|
2629fcce23 | ||
|
|
438e7d07b9 | ||
|
|
e9932ea870 | ||
|
|
5dd8b96422 | ||
|
|
5e1cf68bbd | ||
|
|
1044fa3c83 | ||
|
|
397892bb7f | ||
|
|
f105500740 | ||
|
|
806555cf06 | ||
|
|
5cd7204101 | ||
|
|
3b602a3698 | ||
|
|
15dfaed462 | ||
|
|
0e51851025 | ||
|
|
0d0f4defca | ||
|
|
818fa34a48 | ||
|
|
78303b2a5e | ||
|
|
9ce56dd40c | ||
|
|
4e3ede23b7 | ||
|
|
33e5f3d85d | ||
|
|
031d5e4f40 | ||
|
|
4ff5774e34 | ||
|
|
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 | ||
|
|
502b7eab31 | ||
|
|
be75ad930e | ||
|
|
763c4f4dad | ||
|
|
d32c492bdb |
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -13,8 +13,5 @@ A clear and concise description of what the problem is. Ex. I'm always frustrate
|
|||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,7 +15,9 @@ model_cache/
|
|||||||
# agent
|
# agent
|
||||||
.opencode/
|
.opencode/
|
||||||
.claude/
|
.claude/
|
||||||
|
.sisyphus/
|
||||||
.codex
|
.codex
|
||||||
|
.omo
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
vue-widgets/node_modules/
|
vue-widgets/node_modules/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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!**
|
|
||||||
|
|
||||||
---
|
|
||||||
File diff suppressed because one or more lines are too long
113
locales/de.json
113
locales/de.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"close": "Schließen"
|
"close": "Schließen",
|
||||||
|
"menu": "Menü",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"change": "Ändern"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||||
|
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||||
"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",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Download-Backend",
|
"label": "Download-Backend",
|
||||||
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den experimentellen externen Downloader-Prozess.",
|
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den empfohlenen externen Downloader-Prozess.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (integriert)",
|
"python": "Python (integriert)",
|
||||||
"aria2": "aria2 (experimentell)"
|
"aria2": "aria2 (empfohlen)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
|
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
|
||||||
|
"loraSyntaxFormat": "LoRA-Syntaxformat",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA-Syntaxformat. Der vollständige Pfad enthält den Unterordnerpfad (<lora:style/anime/x:1.0>) für verlustfreie Modellauflösung. Legacy verwendet nur den Dateinamen (<lora:x:1.0>) — A1111-Konvention, kann bei doppelten Dateinamen in verschiedenen Ordnern zu Mehrdeutigkeiten führen.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Vollständiger Pfad (Unterordner/Name)",
|
||||||
|
"legacy": "Legacy A1111 (nur Name)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
|
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
|
||||||
@@ -639,8 +651,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."
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||||
"copyAll": "Alle Syntax kopieren",
|
"copyAll": "Alle Syntax kopieren",
|
||||||
"refreshAll": "Alle Metadaten aktualisieren",
|
"refreshAll": "Alle Metadaten aktualisieren",
|
||||||
|
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||||
"moveAll": "Alle in Ordner verschieben",
|
"moveAll": "Alle in Ordner verschieben",
|
||||||
"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...",
|
||||||
@@ -803,8 +826,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"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Keine Ordner gefunden",
|
"noFolders": "Keine Ordner gefunden",
|
||||||
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Auf Updates in diesem Ordner prüfen",
|
||||||
|
"loading": "Prüfe {type}-Updates in diesem Ordner...",
|
||||||
|
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
|
||||||
|
"none": "Alle {type}s in diesem Ordner sind aktuell",
|
||||||
|
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Modell von URL herunterladen",
|
"title": "Modell von URL herunterladen",
|
||||||
"titleWithType": "{type} von URL herunterladen",
|
"titleWithType": "{type} von URL herunterladen",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
|
||||||
"locationPreview": "Download-Speicherort Vorschau",
|
"locationPreview": "Download-Speicherort Vorschau",
|
||||||
"useDefaultPath": "Standardpfad verwenden",
|
"useDefaultPath": "Standardpfad verwenden",
|
||||||
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Dateiformat auswählen",
|
||||||
|
"files": "Dateien",
|
||||||
|
"select": "Datei auswählen"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||||
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
||||||
@@ -1076,6 +1109,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.",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "Modellname bearbeiten",
|
"editModelName": "Modellname bearbeiten",
|
||||||
"editFileName": "Dateiname bearbeiten",
|
"editFileName": "Dateiname bearbeiten",
|
||||||
"editBaseModel": "Basis-Modell bearbeiten",
|
"editBaseModel": "Basis-Modell bearbeiten",
|
||||||
|
"editVersionName": "Versionsname bearbeiten",
|
||||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notizen erfolgreich gespeichert",
|
"saved": "Notizen erfolgreich gespeichert",
|
||||||
"saveFailed": "Fehler beim Speichern der Notizen"
|
"saveFailed": "Fehler beim Speichern der Notizen",
|
||||||
|
"showMore": "Mehr anzeigen",
|
||||||
|
"showLess": "Weniger anzeigen"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "Früher Zugriff",
|
"earlyAccess": "Früher Zugriff",
|
||||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||||
"ignored": "Ignoriert",
|
"ignored": "Ignoriert",
|
||||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
|
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
||||||
|
"onSiteOnly": "Nur On-Site",
|
||||||
|
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloadTooltip": "Diese Version herunterladen",
|
"downloadTooltip": "Diese Version herunterladen",
|
||||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||||
|
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"deleteTooltip": "Diese lokale Version löschen",
|
"deleteTooltip": "Diese lokale Version löschen",
|
||||||
"ignore": "Ignorieren",
|
"ignore": "Ignorieren",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "Keine Rezept-ID verfügbar",
|
"noRecipeId": "Keine Rezept-ID verfügbar",
|
||||||
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
||||||
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
||||||
|
"createError": "Fehler beim Erstellen des Rezepts:{message}",
|
||||||
|
"createFailed": "Fehler beim Erstellen des Rezepts:{error}",
|
||||||
|
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||||
|
"created": "Rezept erfolgreich erstellt",
|
||||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
|
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||||
|
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
||||||
|
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
||||||
|
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "Handlungsbedarf",
|
"warning": "Handlungsbedarf",
|
||||||
"error": "Aktion erforderlich"
|
"error": "Aktion erforderlich"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Erneut ausführen",
|
"runAgain": "Erneut ausführen",
|
||||||
"exportBundle": "Paket exportieren"
|
"exportBundle": "Paket exportieren",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
|
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Dateinamenskonflikte auflösen",
|
||||||
|
"message": "Umbenennen durch Anhängen eines 4-stelligen Hashs an jeden doppelten Dateinamen.",
|
||||||
|
"note": "Dieser Vorgang benennt Dateien auf der Festplatte um. Modellreferenzen in vorhandenen Workflows müssen möglicherweise aktualisiert werden, wenn Sie das A1111-Syntaxformat verwenden.",
|
||||||
|
"detail": "Beispiel: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Benennt <strong>{count}</strong> Datei(en) in <strong>{groups}</strong> Duplikatgruppe(n) um",
|
||||||
|
"confirm": "Dateien umbenennen",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Anwendungs-Update erkannt",
|
"title": "Anwendungs-Update erkannt",
|
||||||
|
|||||||
117
locales/en.json
117
locales/en.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"menu": "Menu",
|
||||||
|
"remove": "Remove",
|
||||||
|
"change": "Change"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "License",
|
"license": "License",
|
||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
||||||
|
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
||||||
"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",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Download backend",
|
"label": "Download backend",
|
||||||
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the experimental external downloader process.",
|
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the recommended external downloader process.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (built-in)",
|
"python": "Python (built-in)",
|
||||||
"aria2": "aria2 (experimental)"
|
"aria2": "aria2 (recommended)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
|
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
|
||||||
|
"loraSyntaxFormat": "LoRA Syntax Format",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA syntax format. Full includes subfolder path (<lora:style/anime/x:1.0>) for lossless model resolution. Legacy uses filename only (<lora:x:1.0>) — A1111 convention, may be ambiguous with duplicate filenames across folders.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Full path (subfolder/name)",
|
||||||
|
"legacy": "Legacy A1111 (name only)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Enable Metadata Archive Database",
|
"enableArchiveDb": "Enable Metadata Archive Database",
|
||||||
@@ -639,8 +651,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."
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "Set Content Rating for Selected",
|
"setContentRating": "Set Content Rating for Selected",
|
||||||
"copyAll": "Copy Selected Syntax",
|
"copyAll": "Copy Selected Syntax",
|
||||||
"refreshAll": "Refresh Selected Metadata",
|
"refreshAll": "Refresh Selected Metadata",
|
||||||
|
"repairMetadata": "Repair Metadata for Selected",
|
||||||
"checkUpdates": "Check Updates for Selected",
|
"checkUpdates": "Check Updates for Selected",
|
||||||
"moveAll": "Move Selected to Folder",
|
"moveAll": "Move Selected to Folder",
|
||||||
"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}...",
|
||||||
@@ -803,8 +826,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"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "No folders found",
|
"noFolders": "No folders found",
|
||||||
"dragHint": "Drag items here to create folders"
|
"dragHint": "Drag items here to create folders"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Check for updates in this folder",
|
||||||
|
"loading": "Checking {type} updates for this folder...",
|
||||||
|
"success": "Found {count} update(s) for {type}s in this folder",
|
||||||
|
"none": "All {type}s in this folder are up to date",
|
||||||
|
"error": "Failed to check folder for {type} updates: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Download Model from URL",
|
"title": "Download Model from URL",
|
||||||
"titleWithType": "Download {type} from URL",
|
"titleWithType": "Download {type} from URL",
|
||||||
"url": "Civitai URL",
|
"civitaiUrl": "Civitai URL(s):",
|
||||||
"civitaiUrl": "Civitai URL:",
|
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
|
||||||
"locationPreview": "Download Location Preview",
|
"locationPreview": "Download Location Preview",
|
||||||
"useDefaultPath": "Use Default Path",
|
"useDefaultPath": "Use Default Path",
|
||||||
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||||
"alreadyInLibrary": "Already in Library",
|
"alreadyInLibrary": "Already in Library",
|
||||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Select File Format",
|
||||||
|
"files": "files",
|
||||||
|
"select": "Select File"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Invalid Civitai URL format",
|
"invalidUrl": "Invalid Civitai URL format",
|
||||||
"noVersions": "No versions available for this model"
|
"noVersions": "No versions available for this model"
|
||||||
@@ -1076,6 +1109,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.",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "Edit model name",
|
"editModelName": "Edit model name",
|
||||||
"editFileName": "Edit file name",
|
"editFileName": "Edit file name",
|
||||||
"editBaseModel": "Edit base model",
|
"editBaseModel": "Edit base model",
|
||||||
|
"editVersionName": "Edit version name",
|
||||||
"viewOnCivitai": "View on Civitai",
|
"viewOnCivitai": "View on Civitai",
|
||||||
"viewOnCivitaiText": "View on Civitai",
|
"viewOnCivitaiText": "View on Civitai",
|
||||||
"viewCreatorProfile": "View Creator Profile",
|
"viewCreatorProfile": "View Creator Profile",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notes saved successfully",
|
"saved": "Notes saved successfully",
|
||||||
"saveFailed": "Failed to save notes"
|
"saveFailed": "Failed to save notes",
|
||||||
|
"showMore": "Show more",
|
||||||
|
"showLess": "Show less"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Add preset parameter...",
|
"addPresetParameter": "Add preset parameter...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "Early Access",
|
"earlyAccess": "Early Access",
|
||||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||||
"ignored": "Ignored",
|
"ignored": "Ignored",
|
||||||
"ignoredTooltip": "Update notifications are disabled for this version"
|
"ignoredTooltip": "Update notifications are disabled for this version",
|
||||||
|
"onSiteOnly": "On-Site Only",
|
||||||
|
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTooltip": "Download this version",
|
"downloadTooltip": "Download this version",
|
||||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteTooltip": "Delete this local version",
|
"deleteTooltip": "Delete this local version",
|
||||||
"ignore": "Ignore",
|
"ignore": "Ignore",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "No recipe ID available",
|
"noRecipeId": "No recipe ID available",
|
||||||
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
||||||
"copyFailed": "Error copying recipe syntax: {message}",
|
"copyFailed": "Error copying recipe syntax: {message}",
|
||||||
|
"createError": "Error creating recipe: {message}",
|
||||||
|
"createFailed": "Failed to create recipe: {error}",
|
||||||
|
"createMissingData": "Missing required data to create recipe",
|
||||||
|
"created": "Recipe created successfully",
|
||||||
"noMissingLoras": "No missing LoRAs to download",
|
"noMissingLoras": "No missing LoRAs to download",
|
||||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
"preparingForDownloadFailed": "Error preparing LoRAs for download",
|
"preparingForDownloadFailed": "Error preparing LoRAs for download",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No recipes selected",
|
"noRecipesSelected": "No recipes selected",
|
||||||
|
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
||||||
|
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
||||||
|
"repairBulkFailed": "Failed to repair selected recipes: {message}",
|
||||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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)",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "Needs Attention",
|
"warning": "Needs Attention",
|
||||||
"error": "Action Required"
|
"error": "Action Required"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Run Again",
|
"runAgain": "Run Again",
|
||||||
"exportBundle": "Export Bundle"
|
"exportBundle": "Export Bundle",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Failed to load diagnostics: {message}",
|
"loadFailed": "Failed to load diagnostics: {message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
|
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Resolve Filename Conflicts",
|
||||||
|
"message": "Renaming by appending a 4-character hash to each duplicate filename.",
|
||||||
|
"note": "This operation renames files on disk. Model references in existing workflows may need updating if you use the A1111 syntax format.",
|
||||||
|
"detail": "Example: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Will rename <strong>{count}</strong> file(s) across <strong>{groups}</strong> duplicate group(s).",
|
||||||
|
"confirm": "Rename Files",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Application Update Detected",
|
"title": "Application Update Detected",
|
||||||
@@ -1940,4 +2029,4 @@
|
|||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
113
locales/es.json
113
locales/es.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"help": "Ayuda",
|
"help": "Ayuda",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"close": "Cerrar"
|
"close": "Cerrar",
|
||||||
|
"menu": "Menú",
|
||||||
|
"remove": "Eliminar",
|
||||||
|
"change": "Cambiar"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||||
|
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||||
"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",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Backend de descarga",
|
"label": "Backend de descarga",
|
||||||
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo experimental de descarga.",
|
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo recomendado de descarga.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (integrado)",
|
"python": "Python (integrado)",
|
||||||
"aria2": "aria2 (experimental)"
|
"aria2": "aria2 (recomendado)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
|
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
|
||||||
|
"loraSyntaxFormat": "Formato de sintaxis LoRA",
|
||||||
|
"loraSyntaxFormatHelp": "Formato de sintaxis LoRA. El formato completo incluye la ruta de la subcarpeta (<lora:style/anime/x:1.0>) para una resolución de modelo sin pérdidas. El formato heredado usa solo el nombre del archivo (<lora:x:1.0>) — convención A1111, puede ser ambiguo con nombres de archivo duplicados entre carpetas.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Ruta completa (subcarpeta/nombre)",
|
||||||
|
"legacy": "A1111 heredado (solo nombre)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
|
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
|
||||||
@@ -639,8 +651,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."
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||||
"copyAll": "Copiar toda la sintaxis",
|
"copyAll": "Copiar toda la sintaxis",
|
||||||
"refreshAll": "Actualizar todos los metadatos",
|
"refreshAll": "Actualizar todos los metadatos",
|
||||||
|
"repairMetadata": "Reparar metadatos de la selección",
|
||||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||||
"moveAll": "Mover todos a carpeta",
|
"moveAll": "Mover todos a carpeta",
|
||||||
"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}...",
|
||||||
@@ -803,8 +826,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"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "No se encontraron carpetas",
|
"noFolders": "No se encontraron carpetas",
|
||||||
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Buscar actualizaciones en esta carpeta",
|
||||||
|
"loading": "Buscando actualizaciones de {type} en esta carpeta...",
|
||||||
|
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
|
||||||
|
"none": "Todos los {type}s en esta carpeta están actualizados",
|
||||||
|
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Descargar modelo desde URL",
|
"title": "Descargar modelo desde URL",
|
||||||
"titleWithType": "Descargar {type} desde URL",
|
"titleWithType": "Descargar {type} desde URL",
|
||||||
"url": "URL de Civitai",
|
|
||||||
"civitaiUrl": "URL de Civitai:",
|
"civitaiUrl": "URL de Civitai:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.",
|
||||||
"locationPreview": "Vista previa de ubicación de descarga",
|
"locationPreview": "Vista previa de ubicación de descarga",
|
||||||
"useDefaultPath": "Usar ruta predeterminada",
|
"useDefaultPath": "Usar ruta predeterminada",
|
||||||
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||||
"alreadyInLibrary": "Ya en la biblioteca",
|
"alreadyInLibrary": "Ya en la biblioteca",
|
||||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Seleccionar formato de archivo",
|
||||||
|
"files": "archivos",
|
||||||
|
"select": "Seleccionar archivo"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||||
"noVersions": "No hay versiones disponibles para este modelo"
|
"noVersions": "No hay versiones disponibles para este modelo"
|
||||||
@@ -1076,6 +1109,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.",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "Editar nombre del modelo",
|
"editModelName": "Editar nombre del modelo",
|
||||||
"editFileName": "Editar nombre de archivo",
|
"editFileName": "Editar nombre de archivo",
|
||||||
"editBaseModel": "Editar modelo base",
|
"editBaseModel": "Editar modelo base",
|
||||||
|
"editVersionName": "Editar nombre de versión",
|
||||||
"viewOnCivitai": "Ver en Civitai",
|
"viewOnCivitai": "Ver en Civitai",
|
||||||
"viewOnCivitaiText": "Ver en Civitai",
|
"viewOnCivitaiText": "Ver en Civitai",
|
||||||
"viewCreatorProfile": "Ver perfil del creador",
|
"viewCreatorProfile": "Ver perfil del creador",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notas guardadas exitosamente",
|
"saved": "Notas guardadas exitosamente",
|
||||||
"saveFailed": "Error al guardar notas"
|
"saveFailed": "Error al guardar notas",
|
||||||
|
"showMore": "Mostrar más",
|
||||||
|
"showLess": "Mostrar menos"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Añadir parámetro preestablecido...",
|
"addPresetParameter": "Añadir parámetro preestablecido...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "Acceso temprano",
|
"earlyAccess": "Acceso temprano",
|
||||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||||
"ignored": "Ignorada",
|
"ignored": "Ignorada",
|
||||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
|
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
||||||
|
"onSiteOnly": "Solo en Sitio",
|
||||||
|
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"downloadTooltip": "Descargar esta versión",
|
"downloadTooltip": "Descargar esta versión",
|
||||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"deleteTooltip": "Eliminar esta versión local",
|
"deleteTooltip": "Eliminar esta versión local",
|
||||||
"ignore": "Ignorar",
|
"ignore": "Ignorar",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "No hay ID de receta disponible",
|
"noRecipeId": "No hay ID de receta disponible",
|
||||||
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
||||||
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
||||||
|
"createError": "Error al crear la receta:{message}",
|
||||||
|
"createFailed": "Error al crear la receta:{error}",
|
||||||
|
"createMissingData": "Faltan datos necesarios para crear la receta",
|
||||||
|
"created": "Receta creada exitosamente",
|
||||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",
|
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No se han seleccionado recetas",
|
"noRecipesSelected": "No se han seleccionado recetas",
|
||||||
|
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
||||||
|
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
||||||
|
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
|
||||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "Requiere atención",
|
"warning": "Requiere atención",
|
||||||
"error": "Se requiere acción"
|
"error": "Se requiere acción"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Ejecutar de nuevo",
|
"runAgain": "Ejecutar de nuevo",
|
||||||
"exportBundle": "Exportar paquete"
|
"exportBundle": "Exportar paquete",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
|
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Resolver conflictos de nombres de archivo",
|
||||||
|
"message": "Renombrar añadiendo un hash de 4 caracteres a cada nombre de archivo duplicado.",
|
||||||
|
"note": "Esta operación renombra archivos en el disco. Es posible que las referencias a modelos en flujos de trabajo existentes deban actualizarse si usas el formato de sintaxis A1111.",
|
||||||
|
"detail": "Ejemplo: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Renombrará <strong>{count}</strong> archivo(s) en <strong>{groups}</strong> grupo(s) de duplicados",
|
||||||
|
"confirm": "Renombrar archivos",
|
||||||
|
"cancel": "Cancelar"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Actualización de la aplicación detectada",
|
"title": "Actualización de la aplicación detectada",
|
||||||
|
|||||||
113
locales/fr.json
113
locales/fr.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"close": "Fermer"
|
"close": "Fermer",
|
||||||
|
"menu": "Menu",
|
||||||
|
"remove": "Supprimer",
|
||||||
|
"change": "Modifier"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
||||||
|
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
||||||
"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",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Moteur de téléchargement",
|
"label": "Moteur de téléchargement",
|
||||||
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe expérimental de téléchargement.",
|
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe recommandé de téléchargement.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (intégré)",
|
"python": "Python (intégré)",
|
||||||
"aria2": "aria2 (expérimental)"
|
"aria2": "aria2 (recommandé)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
||||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
|
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
|
||||||
|
"loraSyntaxFormat": "Format de syntaxe LoRA",
|
||||||
|
"loraSyntaxFormatHelp": "Format de syntaxe LoRA. Le format complet inclut le chemin du sous-dossier (<lora:style/anime/x:1.0>) pour une résolution de modèle sans perte. Le format hérité utilise uniquement le nom du fichier (<lora:x:1.0>) — convention A1111, peut être ambiguë en cas de noms de fichiers en double dans différents dossiers.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Chemin complet (sous-dossier/nom)",
|
||||||
|
"legacy": "A1111 hérité (nom uniquement)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
|
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
|
||||||
@@ -639,8 +651,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."
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "Définir la classification du contenu pour tous",
|
"setContentRating": "Définir la classification du contenu pour tous",
|
||||||
"copyAll": "Copier toute la syntaxe",
|
"copyAll": "Copier toute la syntaxe",
|
||||||
"refreshAll": "Actualiser toutes les métadonnées",
|
"refreshAll": "Actualiser toutes les métadonnées",
|
||||||
|
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||||
"moveAll": "Déplacer tout vers un dossier",
|
"moveAll": "Déplacer tout vers un dossier",
|
||||||
"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}...",
|
||||||
@@ -803,8 +826,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"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Aucun dossier trouvé",
|
"noFolders": "Aucun dossier trouvé",
|
||||||
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Vérifier les mises à jour dans ce dossier",
|
||||||
|
"loading": "Vérification des mises à jour {type} dans ce dossier...",
|
||||||
|
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
|
||||||
|
"none": "Tous les {type}s dans ce dossier sont à jour",
|
||||||
|
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Télécharger un modèle depuis une URL",
|
"title": "Télécharger un modèle depuis une URL",
|
||||||
"titleWithType": "Télécharger {type} depuis une URL",
|
"titleWithType": "Télécharger {type} depuis une URL",
|
||||||
"url": "URL Civitai",
|
|
||||||
"civitaiUrl": "URL Civitai :",
|
"civitaiUrl": "URL Civitai :",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.",
|
||||||
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
||||||
"useDefaultPath": "Utiliser le chemin par défaut",
|
"useDefaultPath": "Utiliser le chemin par défaut",
|
||||||
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Choisir le format de fichier",
|
||||||
|
"files": "fichiers",
|
||||||
|
"select": "Choisir le fichier"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Format d'URL Civitai invalide",
|
"invalidUrl": "Format d'URL Civitai invalide",
|
||||||
"noVersions": "Aucune version disponible pour ce modèle"
|
"noVersions": "Aucune version disponible pour ce modèle"
|
||||||
@@ -1076,6 +1109,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.",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "Modifier le nom du modèle",
|
"editModelName": "Modifier le nom du modèle",
|
||||||
"editFileName": "Modifier le nom de fichier",
|
"editFileName": "Modifier le nom de fichier",
|
||||||
"editBaseModel": "Modifier le modèle de base",
|
"editBaseModel": "Modifier le modèle de base",
|
||||||
|
"editVersionName": "Modifier le nom de la version",
|
||||||
"viewOnCivitai": "Voir sur Civitai",
|
"viewOnCivitai": "Voir sur Civitai",
|
||||||
"viewOnCivitaiText": "Voir sur Civitai",
|
"viewOnCivitaiText": "Voir sur Civitai",
|
||||||
"viewCreatorProfile": "Voir le profil du créateur",
|
"viewCreatorProfile": "Voir le profil du créateur",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notes sauvegardées avec succès",
|
"saved": "Notes sauvegardées avec succès",
|
||||||
"saveFailed": "Échec de la sauvegarde des notes"
|
"saveFailed": "Échec de la sauvegarde des notes",
|
||||||
|
"showMore": "Afficher plus",
|
||||||
|
"showLess": "Afficher moins"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "Accès anticipé",
|
"earlyAccess": "Accès anticipé",
|
||||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||||
"ignored": "Ignorée",
|
"ignored": "Ignorée",
|
||||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
|
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
||||||
|
"onSiteOnly": "Uniquement sur Site",
|
||||||
|
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"downloadTooltip": "Télécharger cette version",
|
"downloadTooltip": "Télécharger cette version",
|
||||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"deleteTooltip": "Supprimer cette version locale",
|
"deleteTooltip": "Supprimer cette version locale",
|
||||||
"ignore": "Ignorer",
|
"ignore": "Ignorer",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "Aucun ID de recipe disponible",
|
"noRecipeId": "Aucun ID de recipe disponible",
|
||||||
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
||||||
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
||||||
|
"createError": "Erreur lors de la création du Recipe :{message}",
|
||||||
|
"createFailed": "Échec de la création du Recipe :{error}",
|
||||||
|
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
||||||
|
"created": "Recipe créé avec succès",
|
||||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||||
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
|
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Aucune recette sélectionnée",
|
"noRecipesSelected": "Aucune recette sélectionnée",
|
||||||
|
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
|
||||||
|
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
|
||||||
|
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
|
||||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "Nécessite une attention",
|
"warning": "Nécessite une attention",
|
||||||
"error": "Action requise"
|
"error": "Action requise"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Relancer",
|
"runAgain": "Relancer",
|
||||||
"exportBundle": "Exporter le lot"
|
"exportBundle": "Exporter le lot",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
|
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Résoudre les conflits de noms de fichiers",
|
||||||
|
"message": "Renommer en ajoutant un hachage de 4 caractères à chaque nom de fichier en double.",
|
||||||
|
"note": "Cette opération renomme les fichiers sur le disque. Les références de modèle dans les workflows existants peuvent nécessiter une mise à jour si vous utilisez le format de syntaxe A1111.",
|
||||||
|
"detail": "Exemple : <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Renommera <strong>{count}</strong> fichier(s) dans <strong>{groups}</strong> groupe(s) de doublons",
|
||||||
|
"confirm": "Renommer les fichiers",
|
||||||
|
"cancel": "Annuler"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Mise à jour de l'application détectée",
|
"title": "Mise à jour de l'application détectée",
|
||||||
|
|||||||
113
locales/he.json
113
locales/he.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "הגדרות",
|
"settings": "הגדרות",
|
||||||
"help": "עזרה",
|
"help": "עזרה",
|
||||||
"add": "הוספה",
|
"add": "הוספה",
|
||||||
"close": "סגור"
|
"close": "סגור",
|
||||||
|
"menu": "תפריט",
|
||||||
|
"remove": "הסר",
|
||||||
|
"change": "שנה"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "רישיון",
|
"license": "רישיון",
|
||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
|
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||||
|
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||||
"noTags": "ללא תגיות",
|
"noTags": "ללא תגיות",
|
||||||
|
"autoTags": "תגיות אוטומטיות",
|
||||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||||
"clearAll": "נקה את כל המסננים",
|
"clearAll": "נקה את כל המסננים",
|
||||||
"any": "כלשהו",
|
"any": "כלשהו",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "מנגנון הורדה",
|
"label": "מנגנון הורדה",
|
||||||
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני ניסיוני.",
|
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני מומלץ.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (מובנה)",
|
"python": "Python (מובנה)",
|
||||||
"aria2": "aria2 (ניסיוני)"
|
"aria2": "aria2 (מומלץ)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
|
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
|
||||||
|
"loraSyntaxFormat": "פורמט תחביר LoRA",
|
||||||
|
"loraSyntaxFormatHelp": "פורמט תחביר LoRA. נתיב מלא כולל תת-תיקייה (<lora:style/anime/x:1.0>) לפתרון מודל ללא אובדן. גרסה ישנה משתמשת בשם קובץ בלבד (<lora:x:1.0>) — מוסכמת A1111, עלולה להיות לא חד משמעית עם שמות קבצים כפולים בתיקיות שונות.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "נתיב מלא (תת-תיקייה/שם)",
|
||||||
|
"legacy": "A1111 ישן (שם בלבד)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
|
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
|
||||||
@@ -639,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מודלים",
|
"title": "רענן רשימת מודלים",
|
||||||
"quick": "סנכרון שינויים",
|
|
||||||
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
|
|
||||||
"full": "בניית מטמון מחדש",
|
"full": "בניית מטמון מחדש",
|
||||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||||
"copyAll": "העתק את כל התחבירים",
|
"copyAll": "העתק את כל התחבירים",
|
||||||
"refreshAll": "רענן את כל המטא-דאטה",
|
"refreshAll": "רענן את כל המטא-דאטה",
|
||||||
|
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||||
"moveAll": "העבר הכל לתיקייה",
|
"moveAll": "העבר הכל לתיקייה",
|
||||||
"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}...",
|
||||||
@@ -803,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מתכונים",
|
"title": "רענן רשימת מתכונים",
|
||||||
"quick": "סנכרן שינויים",
|
|
||||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
|
||||||
"full": "בנה מטמון מחדש",
|
"full": "בנה מטמון מחדש",
|
||||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "לא נמצאו תיקיות",
|
"noFolders": "לא נמצאו תיקיות",
|
||||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "בדוק עדכונים בתיקייה זו",
|
||||||
|
"loading": "בודק עדכוני {type} בתיקייה זו...",
|
||||||
|
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
|
||||||
|
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
|
||||||
|
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "הורד מודל מכתובת URL",
|
"title": "הורד מודל מכתובת URL",
|
||||||
"titleWithType": "הורד {type} מכתובת URL",
|
"titleWithType": "הורד {type} מכתובת URL",
|
||||||
"url": "כתובת URL של Civitai",
|
|
||||||
"civitaiUrl": "כתובת URL של Civitai:",
|
"civitaiUrl": "כתובת URL של Civitai:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
|
||||||
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
||||||
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
||||||
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||||
"alreadyInLibrary": "כבר בספרייה",
|
"alreadyInLibrary": "כבר בספרייה",
|
||||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "בחר פורמט קובץ",
|
||||||
|
"files": "קבצים",
|
||||||
|
"select": "בחר קובץ"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||||
"noVersions": "אין גרסאות זמינות למודל זה"
|
"noVersions": "אין גרסאות זמינות למודל זה"
|
||||||
@@ -1076,6 +1109,12 @@
|
|||||||
"countMessage": "מודלים יימחקו לצמיתות.",
|
"countMessage": "מודלים יימחקו לצמיתות.",
|
||||||
"action": "מחק הכל"
|
"action": "מחק הכל"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "מחק מספר מתכונים",
|
||||||
|
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
|
||||||
|
"countMessage": "מתכונים יימחקו לצמיתות.",
|
||||||
|
"action": "מחק הכל"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
||||||
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "ערוך שם מודל",
|
"editModelName": "ערוך שם מודל",
|
||||||
"editFileName": "ערוך שם קובץ",
|
"editFileName": "ערוך שם קובץ",
|
||||||
"editBaseModel": "ערוך מודל בסיס",
|
"editBaseModel": "ערוך מודל בסיס",
|
||||||
|
"editVersionName": "ערוך שם גרסה",
|
||||||
"viewOnCivitai": "הצג ב-Civitai",
|
"viewOnCivitai": "הצג ב-Civitai",
|
||||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "הערות נשמרו בהצלחה",
|
"saved": "הערות נשמרו בהצלחה",
|
||||||
"saveFailed": "שמירת ההערות נכשלה"
|
"saveFailed": "שמירת ההערות נכשלה",
|
||||||
|
"showMore": "הצג עוד",
|
||||||
|
"showLess": "הצג פחות"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "גישה מוקדמת",
|
"earlyAccess": "גישה מוקדמת",
|
||||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||||
"ignored": "התעלם",
|
"ignored": "התעלם",
|
||||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||||
|
"onSiteOnly": "רק באתר",
|
||||||
|
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "הורדה",
|
"download": "הורדה",
|
||||||
"downloadTooltip": "הורד את הגרסה הזו",
|
"downloadTooltip": "הורד את הגרסה הזו",
|
||||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||||
"delete": "מחיקה",
|
"delete": "מחיקה",
|
||||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||||
"ignore": "התעלם",
|
"ignore": "התעלם",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "אין מזהה מתכון זמין",
|
"noRecipeId": "אין מזהה מתכון זמין",
|
||||||
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
||||||
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
||||||
|
"createError": "שגיאה ביצירת המתכון:{message}",
|
||||||
|
"createFailed": "יצירת המתכון נכשלה:{error}",
|
||||||
|
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||||
|
"created": "המתכון נוצר בהצלחה",
|
||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||||
|
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||||
|
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||||
|
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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} שנבחרו",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "דורש תשומת לב",
|
"warning": "דורש תשומת לב",
|
||||||
"error": "נדרשת פעולה"
|
"error": "נדרשת פעולה"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "הפעל שוב",
|
"runAgain": "הפעל שוב",
|
||||||
"exportBundle": "ייצוא חבילה"
|
"exportBundle": "ייצוא חבילה",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "פתור התנגשויות בשמות קבצים",
|
||||||
|
"message": "שינוי שם על ידי הוספת האש באורך 4 תווים לכל שם קובץ כפול.",
|
||||||
|
"note": "פעולה זו משנה שמות של קבצים בדיסק. ייתכן שיהיה צורך לעדכן הפניות למודלים בזרימות עבודה קיימות אם אתה משתמש בפורמט התחביר A1111.",
|
||||||
|
"detail": "דוגמה: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "ישנה שם של <strong>{count}</strong> קבצים ב-<strong>{groups}</strong> קבוצות כפולות",
|
||||||
|
"confirm": "שנה שמות קבצים",
|
||||||
|
"cancel": "ביטול"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "זוהה עדכון יישום",
|
"title": "זוהה עדכון יישום",
|
||||||
|
|||||||
113
locales/ja.json
113
locales/ja.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
"close": "閉じる"
|
"close": "閉じる",
|
||||||
|
"menu": "メニュー",
|
||||||
|
"remove": "削除",
|
||||||
|
"change": "変更"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
|
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||||
|
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||||
"noTags": "タグなし",
|
"noTags": "タグなし",
|
||||||
|
"autoTags": "自動タグ",
|
||||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||||
"clearAll": "すべてのフィルタをクリア",
|
"clearAll": "すべてのフィルタをクリア",
|
||||||
"any": "いずれか",
|
"any": "いずれか",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "ダウンロードバックエンド",
|
"label": "ダウンロードバックエンド",
|
||||||
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は実験的な外部ダウンローダープロセスを使用します。",
|
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は推奨の外部ダウンローダープロセスを使用します。",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python(内蔵)",
|
"python": "Python(内蔵)",
|
||||||
"aria2": "aria2(実験的)"
|
"aria2": "aria2(推奨)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
|
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
|
||||||
|
"loraSyntaxFormat": "LoRA構文形式",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA構文形式。フルパスはサブフォルダパスを含み(<lora:style/anime/x:1.0>)、モデルをロスレスで解決します。レガシーはファイル名のみ(<lora:x:1.0>)— A1111規約ですが、フォルダ間でファイル名が重複する場合に曖昧になる可能性があります。",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "フルパス(サブフォルダ/名前)",
|
||||||
|
"legacy": "レガシーA1111(名前のみ)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
||||||
@@ -639,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "モデルリストを更新",
|
"title": "モデルリストを更新",
|
||||||
"quick": "変更を同期",
|
|
||||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
|
||||||
"full": "キャッシュを再構築",
|
"full": "キャッシュを再構築",
|
||||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||||
"copyAll": "すべての構文をコピー",
|
"copyAll": "すべての構文をコピー",
|
||||||
"refreshAll": "すべてのメタデータを更新",
|
"refreshAll": "すべてのメタデータを更新",
|
||||||
|
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||||
"checkUpdates": "選択項目の更新を確認",
|
"checkUpdates": "選択項目の更新を確認",
|
||||||
"moveAll": "すべてをフォルダに移動",
|
"moveAll": "すべてをフォルダに移動",
|
||||||
"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}の自動整理を開始中...",
|
||||||
@@ -803,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "レシピリストを更新",
|
"title": "レシピリストを更新",
|
||||||
"quick": "変更を同期",
|
|
||||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
|
||||||
"full": "キャッシュを再構築",
|
"full": "キャッシュを再構築",
|
||||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "フォルダが見つかりません",
|
"noFolders": "フォルダが見つかりません",
|
||||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "このフォルダのアップデートを確認",
|
||||||
|
"loading": "このフォルダの{type}アップデートを確認中...",
|
||||||
|
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
|
||||||
|
"none": "このフォルダのすべての{type}sは最新です",
|
||||||
|
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "URLからモデルをダウンロード",
|
"title": "URLからモデルをダウンロード",
|
||||||
"titleWithType": "URLから{type}をダウンロード",
|
"titleWithType": "URLから{type}をダウンロード",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||||
"locationPreview": "ダウンロード場所プレビュー",
|
"locationPreview": "ダウンロード場所プレビュー",
|
||||||
"useDefaultPath": "デフォルトパスを使用",
|
"useDefaultPath": "デフォルトパスを使用",
|
||||||
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||||
"alreadyInLibrary": "既にライブラリ内",
|
"alreadyInLibrary": "既にライブラリ内",
|
||||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "ファイル形式を選択",
|
||||||
|
"files": "ファイル",
|
||||||
|
"select": "ファイルを選択"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "無効なCivitai URL形式",
|
"invalidUrl": "無効なCivitai URL形式",
|
||||||
"noVersions": "このモデルの利用可能なバージョンがありません"
|
"noVersions": "このモデルの利用可能なバージョンがありません"
|
||||||
@@ -1076,6 +1109,12 @@
|
|||||||
"countMessage": "モデルが完全に削除されます。",
|
"countMessage": "モデルが完全に削除されます。",
|
||||||
"action": "すべて削除"
|
"action": "すべて削除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "複数のレシピを削除",
|
||||||
|
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
|
||||||
|
"countMessage": "レシピが完全に削除されます。",
|
||||||
|
"action": "すべて削除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "すべての{type}の更新を確認しますか?",
|
"title": "すべての{type}の更新を確認しますか?",
|
||||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "モデル名を編集",
|
"editModelName": "モデル名を編集",
|
||||||
"editFileName": "ファイル名を編集",
|
"editFileName": "ファイル名を編集",
|
||||||
"editBaseModel": "ベースモデルを編集",
|
"editBaseModel": "ベースモデルを編集",
|
||||||
|
"editVersionName": "バージョン名を編集",
|
||||||
"viewOnCivitai": "Civitaiで表示",
|
"viewOnCivitai": "Civitaiで表示",
|
||||||
"viewOnCivitaiText": "Civitaiで表示",
|
"viewOnCivitaiText": "Civitaiで表示",
|
||||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "メモが正常に保存されました",
|
"saved": "メモが正常に保存されました",
|
||||||
"saveFailed": "メモの保存に失敗しました"
|
"saveFailed": "メモの保存に失敗しました",
|
||||||
|
"showMore": "もっと見る",
|
||||||
|
"showLess": "折りたたむ"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "プリセットパラメータを追加...",
|
"addPresetParameter": "プリセットパラメータを追加...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "早期アクセス",
|
"earlyAccess": "早期アクセス",
|
||||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||||
"ignored": "無視中",
|
"ignored": "無視中",
|
||||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||||
|
"onSiteOnly": "サイト内のみ",
|
||||||
|
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"downloadTooltip": "このバージョンをダウンロード",
|
"downloadTooltip": "このバージョンをダウンロード",
|
||||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||||
|
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"deleteTooltip": "このローカルバージョンを削除",
|
"deleteTooltip": "このローカルバージョンを削除",
|
||||||
"ignore": "無視",
|
"ignore": "無視",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "レシピIDが利用できません",
|
"noRecipeId": "レシピIDが利用できません",
|
||||||
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
||||||
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
||||||
|
"createError": "レシピ作成中にエラーが発生しました:{message}",
|
||||||
|
"createFailed": "レシピの作成に失敗しました:{error}",
|
||||||
|
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||||
|
"created": "レシピを作成しました",
|
||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "レシピが選択されていません",
|
"noRecipesSelected": "レシピが選択されていません",
|
||||||
|
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||||
|
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||||
|
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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}には更新が見つかりませんでした",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "要注意",
|
"warning": "要注意",
|
||||||
"error": "対応が必要"
|
"error": "対応が必要"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API キー"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "モデルキャッシュの健全性"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "ファイル名重複競合"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI バージョン"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "再実行",
|
"runAgain": "再実行",
|
||||||
"exportBundle": "パッケージをエクスポート"
|
"exportBundle": "パッケージをエクスポート",
|
||||||
|
"open-settings": "設定を開く",
|
||||||
|
"open-settings-syntax-format": "フルパス構文に切り替え",
|
||||||
|
"repair-cache": "キャッシュを再構築",
|
||||||
|
"resolve-filename-conflicts": "競合を解決",
|
||||||
|
"reload-page": "UI をリロード"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "競合",
|
||||||
|
"version": "バージョン"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "ファイル名の競合を解決",
|
||||||
|
"message": "重複したファイル名に4文字のハッシュを追加してリネームします。",
|
||||||
|
"note": "この操作はディスク上のファイルをリネームします。A1111 構文形式を使用している場合、既存のワークフロー内のモデル参照を更新する必要があるかもしれません。",
|
||||||
|
"detail": "例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "<strong>{groups}</strong> 組の重複にわたって <strong>{count}</strong> 個のファイルをリネームします",
|
||||||
|
"confirm": "ファイルをリネーム",
|
||||||
|
"cancel": "キャンセル"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "アプリケーション更新が検出されました",
|
"title": "アプリケーション更新が検出されました",
|
||||||
|
|||||||
113
locales/ko.json
113
locales/ko.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"help": "도움말",
|
"help": "도움말",
|
||||||
"add": "추가",
|
"add": "추가",
|
||||||
"close": "닫기"
|
"close": "닫기",
|
||||||
|
"menu": "메뉴",
|
||||||
|
"remove": "제거",
|
||||||
|
"change": "변경"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
|
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||||
|
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||||
"noTags": "태그 없음",
|
"noTags": "태그 없음",
|
||||||
|
"autoTags": "자동 태그",
|
||||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||||
"clearAll": "모든 필터 지우기",
|
"clearAll": "모든 필터 지우기",
|
||||||
"any": "아무",
|
"any": "아무",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "다운로드 백엔드",
|
"label": "다운로드 백엔드",
|
||||||
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 실험적인 외부 다운로더 프로세스를 사용합니다.",
|
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 권장되는 외부 다운로더 프로세스를 사용합니다.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python(내장)",
|
"python": "Python(내장)",
|
||||||
"aria2": "aria2(실험적)"
|
"aria2": "aria2(권장)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
|
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
|
||||||
|
"loraSyntaxFormat": "LoRA 구문 형식",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA 구문 형식. 전체 경로는 하위 폴더 경로(<lora:style/anime/x:1.0>)를 포함하여 손실 없는 모델 해상도를 제공합니다. 레거시는 파일 이름만(<lora:x:1.0>) 사용 — A1111 규칙이지만, 폴더 간 파일명 중복 시 모호할 수 있습니다.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "전체 경로(하위 폴더/이름)",
|
||||||
|
"legacy": "레거시 A1111(이름만)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
||||||
@@ -639,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "모델 목록 새로고침",
|
"title": "모델 목록 새로고침",
|
||||||
"quick": "변경 사항 동기화",
|
|
||||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
|
||||||
"full": "캐시 재구성",
|
"full": "캐시 재구성",
|
||||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||||
"copyAll": "모든 문법 복사",
|
"copyAll": "모든 문법 복사",
|
||||||
"refreshAll": "모든 메타데이터 새로고침",
|
"refreshAll": "모든 메타데이터 새로고침",
|
||||||
|
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||||
"checkUpdates": "선택 항목 업데이트 확인",
|
"checkUpdates": "선택 항목 업데이트 확인",
|
||||||
"moveAll": "모두 폴더로 이동",
|
"moveAll": "모두 폴더로 이동",
|
||||||
"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}에 대한 자동 정리 시작...",
|
||||||
@@ -803,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "레시피 목록 새로고침",
|
"title": "레시피 목록 새로고침",
|
||||||
"quick": "변경 사항 동기화",
|
|
||||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
|
||||||
"full": "캐시 재구성",
|
"full": "캐시 재구성",
|
||||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "폴더를 찾을 수 없습니다",
|
"noFolders": "폴더를 찾을 수 없습니다",
|
||||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "이 폴더의 업데이트 확인",
|
||||||
|
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
|
||||||
|
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
|
||||||
|
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
|
||||||
|
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "URL에서 모델 다운로드",
|
"title": "URL에서 모델 다운로드",
|
||||||
"titleWithType": "URL에서 {type} 다운로드",
|
"titleWithType": "URL에서 {type} 다운로드",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||||
"locationPreview": "다운로드 위치 미리보기",
|
"locationPreview": "다운로드 위치 미리보기",
|
||||||
"useDefaultPath": "기본 경로 사용",
|
"useDefaultPath": "기본 경로 사용",
|
||||||
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "파일 형식 선택",
|
||||||
|
"files": "개 파일",
|
||||||
|
"select": "파일 선택"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
||||||
@@ -1076,6 +1109,12 @@
|
|||||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||||
"action": "모두 삭제"
|
"action": "모두 삭제"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "여러 레시피 삭제",
|
||||||
|
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
|
||||||
|
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
|
||||||
|
"action": "모두 삭제"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "모델명 편집",
|
"editModelName": "모델명 편집",
|
||||||
"editFileName": "파일명 편집",
|
"editFileName": "파일명 편집",
|
||||||
"editBaseModel": "베이스 모델 편집",
|
"editBaseModel": "베이스 모델 편집",
|
||||||
|
"editVersionName": "버전명 편집",
|
||||||
"viewOnCivitai": "Civitai에서 보기",
|
"viewOnCivitai": "Civitai에서 보기",
|
||||||
"viewOnCivitaiText": "Civitai에서 보기",
|
"viewOnCivitaiText": "Civitai에서 보기",
|
||||||
"viewCreatorProfile": "제작자 프로필 보기",
|
"viewCreatorProfile": "제작자 프로필 보기",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "메모가 성공적으로 저장됨",
|
"saved": "메모가 성공적으로 저장됨",
|
||||||
"saveFailed": "메모 저장 실패"
|
"saveFailed": "메모 저장 실패",
|
||||||
|
"showMore": "더 보기",
|
||||||
|
"showLess": "접기"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "프리셋 매개변수 추가...",
|
"addPresetParameter": "프리셋 매개변수 추가...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "얼리 액세스",
|
"earlyAccess": "얼리 액세스",
|
||||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||||
"ignored": "무시됨",
|
"ignored": "무시됨",
|
||||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||||
|
"onSiteOnly": "사이트 내 전용",
|
||||||
|
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"downloadTooltip": "이 버전 다운로드",
|
"downloadTooltip": "이 버전 다운로드",
|
||||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||||
|
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"deleteTooltip": "이 로컬 버전 삭제",
|
"deleteTooltip": "이 로컬 버전 삭제",
|
||||||
"ignore": "무시",
|
"ignore": "무시",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||||
|
"createError": "레시피 생성 중 오류 발생:{message}",
|
||||||
|
"createFailed": "레시피 생성 실패:{error}",
|
||||||
|
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||||
|
"created": "레시피가 생성되었습니다",
|
||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||||
|
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||||
|
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||||
|
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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}에 대한 업데이트가 없습니다",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "주의 필요",
|
"warning": "주의 필요",
|
||||||
"error": "조치 필요"
|
"error": "조치 필요"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API 키"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "모델 캐시 상태"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "파일명 중복 충돌"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI 버전"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "다시 실행",
|
"runAgain": "다시 실행",
|
||||||
"exportBundle": "번들 내보내기"
|
"exportBundle": "번들 내보내기",
|
||||||
|
"open-settings": "설정 열기",
|
||||||
|
"open-settings-syntax-format": "전체 경로 구문으로 전환",
|
||||||
|
"repair-cache": "캐시 재구축",
|
||||||
|
"resolve-filename-conflicts": "충돌 해결",
|
||||||
|
"reload-page": "UI 새로고침"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "충돌",
|
||||||
|
"version": "버전"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "진단 로드 실패: {message}",
|
"loadFailed": "진단 로드 실패: {message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "파일명 충돌 해결",
|
||||||
|
"message": "중복 파일명에 4자리 해시를 추가하여 이름을 변경합니다.",
|
||||||
|
"note": "이 작업은 디스크에 있는 파일의 이름을 변경합니다. A1111 구문 형식을 사용하는 경우 기존 워크플로우의 모델 참조를 업데이트해야 할 수 있습니다.",
|
||||||
|
"detail": "예시: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "<strong>{groups}</strong>개 중복 그룹에서 <strong>{count}</strong>개 파일 이름을 변경합니다",
|
||||||
|
"confirm": "파일 이름 변경",
|
||||||
|
"cancel": "취소"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "애플리케이션 업데이트 감지",
|
"title": "애플리케이션 업데이트 감지",
|
||||||
|
|||||||
113
locales/ru.json
113
locales/ru.json
@@ -15,7 +15,10 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"close": "Закрыть"
|
"close": "Закрыть",
|
||||||
|
"menu": "Меню",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"change": "Изменить"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
|
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||||
|
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||||
"noTags": "Без тегов",
|
"noTags": "Без тегов",
|
||||||
|
"autoTags": "Авто-теги",
|
||||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||||
"clearAll": "Очистить все фильтры",
|
"clearAll": "Очистить все фильтры",
|
||||||
"any": "Любой",
|
"any": "Любой",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "Бэкенд загрузки",
|
"label": "Бэкенд загрузки",
|
||||||
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует экспериментальный внешний процесс загрузки.",
|
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует рекомендуемый внешний процесс загрузки.",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python (встроенный)",
|
"python": "Python (встроенный)",
|
||||||
"aria2": "aria2 (экспериментальный)"
|
"aria2": "aria2 (рекомендуемый)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
|
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
|
||||||
|
"loraSyntaxFormat": "Формат синтаксиса LoRA",
|
||||||
|
"loraSyntaxFormatHelp": "Формат синтаксиса LoRA. Полный путь включает подпапку (<lora:style/anime/x:1.0>) для безпотерьного разрешения модели. Устаревший использует только имя файла (<lora:x:1.0>) — соглашение A1111, может быть неоднозначным при дублировании имён файлов в разных папках.",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "Полный путь (подпапка/имя)",
|
||||||
|
"legacy": "Устаревший A1111 (только имя)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "Включить архив метаданных",
|
"enableArchiveDb": "Включить архив метаданных",
|
||||||
@@ -639,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список моделей",
|
"title": "Обновить список моделей",
|
||||||
"quick": "Синхронизировать изменения",
|
|
||||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
|
||||||
"full": "Перестроить кэш",
|
"full": "Перестроить кэш",
|
||||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "Установить рейтинг контента для всех",
|
"setContentRating": "Установить рейтинг контента для всех",
|
||||||
"copyAll": "Копировать весь синтаксис",
|
"copyAll": "Копировать весь синтаксис",
|
||||||
"refreshAll": "Обновить все метаданные",
|
"refreshAll": "Обновить все метаданные",
|
||||||
|
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||||
"checkUpdates": "Проверить обновления для выбранных",
|
"checkUpdates": "Проверить обновления для выбранных",
|
||||||
"moveAll": "Переместить все в папку",
|
"moveAll": "Переместить все в папку",
|
||||||
"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}...",
|
||||||
@@ -803,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список рецептов",
|
"title": "Обновить список рецептов",
|
||||||
"quick": "Синхронизировать изменения",
|
|
||||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
|
||||||
"full": "Перестроить кэш",
|
"full": "Перестроить кэш",
|
||||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Папки не найдены",
|
"noFolders": "Папки не найдены",
|
||||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Проверить обновления в этой папке",
|
||||||
|
"loading": "Проверка обновлений {type} в этой папке...",
|
||||||
|
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
|
||||||
|
"none": "Все {type}s в этой папке актуальны",
|
||||||
|
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "Скачать модель по URL",
|
"title": "Скачать модель по URL",
|
||||||
"titleWithType": "Скачать {type} по URL",
|
"titleWithType": "Скачать {type} по URL",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
|
||||||
"locationPreview": "Предпросмотр места загрузки",
|
"locationPreview": "Предпросмотр места загрузки",
|
||||||
"useDefaultPath": "Использовать путь по умолчанию",
|
"useDefaultPath": "Использовать путь по умолчанию",
|
||||||
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||||
"alreadyInLibrary": "Уже в библиотеке",
|
"alreadyInLibrary": "Уже в библиотеке",
|
||||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Выбрать формат файла",
|
||||||
|
"files": "файлов",
|
||||||
|
"select": "Выбрать файл"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Неверный формат URL Civitai",
|
"invalidUrl": "Неверный формат URL Civitai",
|
||||||
"noVersions": "Нет доступных версий для этой модели"
|
"noVersions": "Нет доступных версий для этой модели"
|
||||||
@@ -1076,6 +1109,12 @@
|
|||||||
"countMessage": "моделей будут удалены навсегда.",
|
"countMessage": "моделей будут удалены навсегда.",
|
||||||
"action": "Удалить все"
|
"action": "Удалить все"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "Удалить несколько рецептов",
|
||||||
|
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
|
||||||
|
"countMessage": "рецептов будут удалены навсегда.",
|
||||||
|
"action": "Удалить все"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "Проверить обновления для всех {typePlural}?",
|
"title": "Проверить обновления для всех {typePlural}?",
|
||||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "Редактировать название модели",
|
"editModelName": "Редактировать название модели",
|
||||||
"editFileName": "Редактировать имя файла",
|
"editFileName": "Редактировать имя файла",
|
||||||
"editBaseModel": "Редактировать базовую модель",
|
"editBaseModel": "Редактировать базовую модель",
|
||||||
|
"editVersionName": "Редактировать название версии",
|
||||||
"viewOnCivitai": "Посмотреть на Civitai",
|
"viewOnCivitai": "Посмотреть на Civitai",
|
||||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Заметки успешно сохранены",
|
"saved": "Заметки успешно сохранены",
|
||||||
"saveFailed": "Не удалось сохранить заметки"
|
"saveFailed": "Не удалось сохранить заметки",
|
||||||
|
"showMore": "Показать больше",
|
||||||
|
"showLess": "Свернуть"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "Добавить предустановленный параметр...",
|
"addPresetParameter": "Добавить предустановленный параметр...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "Ранний доступ",
|
"earlyAccess": "Ранний доступ",
|
||||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||||
"ignored": "Игнорируется",
|
"ignored": "Игнорируется",
|
||||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||||
|
"onSiteOnly": "Только на Сайте",
|
||||||
|
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"downloadTooltip": "Скачать эту версию",
|
"downloadTooltip": "Скачать эту версию",
|
||||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"deleteTooltip": "Удалить эту локальную версию",
|
"deleteTooltip": "Удалить эту локальную версию",
|
||||||
"ignore": "Игнорировать",
|
"ignore": "Игнорировать",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "ID рецепта недоступен",
|
"noRecipeId": "ID рецепта недоступен",
|
||||||
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
||||||
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
||||||
|
"createError": "Ошибка при создании рецепта:{message}",
|
||||||
|
"createFailed": "Не удалось создать рецепт:{error}",
|
||||||
|
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||||
|
"created": "Рецепт успешно создан",
|
||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Рецепты не выбраны",
|
"noRecipesSelected": "Рецепты не выбраны",
|
||||||
|
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||||
|
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||||
|
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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} не найдены",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "Требует внимания",
|
"warning": "Требует внимания",
|
||||||
"error": "Требуется действие"
|
"error": "Требуется действие"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API Key"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "Model Cache Health"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "Duplicate Filename Conflicts"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "Запустить снова",
|
"runAgain": "Запустить снова",
|
||||||
"exportBundle": "Экспортировать пакет"
|
"exportBundle": "Экспортировать пакет",
|
||||||
|
"open-settings": "Open Settings",
|
||||||
|
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||||
|
"repair-cache": "Rebuild Cache",
|
||||||
|
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||||
|
"reload-page": "Reload UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "Conflicts",
|
||||||
|
"version": "Version"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "Разрешить конфликты имён файлов",
|
||||||
|
"message": "Переименование с добавлением 4-символьного хеша к каждому дублирующемуся имени файла.",
|
||||||
|
"note": "Эта операция переименовывает файлы на диске. Если вы используете синтаксис A1111, ссылки на модели в существующих рабочих процессах могут потребовать обновления.",
|
||||||
|
"detail": "Пример: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "Будет переименовано <strong>{count}</strong> файл(ов) в <strong>{groups}</strong> группе(ах) дубликатов",
|
||||||
|
"confirm": "Переименовать файлы",
|
||||||
|
"cancel": "Отмена"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "Обнаружено обновление приложения",
|
"title": "Обнаружено обновление приложения",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"menu": "菜单",
|
||||||
|
"remove": "移除",
|
||||||
|
"change": "更换"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
|
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||||
|
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||||
"noTags": "无标签",
|
"noTags": "无标签",
|
||||||
|
"autoTags": "自动标签",
|
||||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||||
"clearAll": "清除所有筛选",
|
"clearAll": "清除所有筛选",
|
||||||
"any": "任一",
|
"any": "任一",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "下载后端",
|
"label": "下载后端",
|
||||||
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用实验性的外部下载进程。",
|
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用推荐的外部下载进程。",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python(内置)",
|
"python": "Python(内置)",
|
||||||
"aria2": "aria2(实验性)"
|
"aria2": "aria2(推荐)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
|
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
|
||||||
|
"loraSyntaxFormat": "LoRA 语法格式",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA 语法格式。完整路径(Full)包含子文件夹路径 (<lora:style/anime/x:1.0>),解析精确无歧义。旧版(Legacy)仅使用文件名 (<lora:x:1.0>)——A1111 原始约定,同名文件跨文件夹时可能产生歧义。",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "完整路径(子文件夹/名称)",
|
||||||
|
"legacy": "旧版 A1111(仅名称)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "启用元数据归档数据库",
|
"enableArchiveDb": "启用元数据归档数据库",
|
||||||
@@ -639,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新模型列表",
|
"title": "刷新模型列表",
|
||||||
"quick": "同步变更",
|
|
||||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
|
||||||
"full": "重建缓存",
|
"full": "重建缓存",
|
||||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "为所选中设置内容评级",
|
"setContentRating": "为所选中设置内容评级",
|
||||||
"copyAll": "复制所选中语法",
|
"copyAll": "复制所选中语法",
|
||||||
"refreshAll": "刷新所选中元数据",
|
"refreshAll": "刷新所选中元数据",
|
||||||
|
"repairMetadata": "修复所选中元数据",
|
||||||
"checkUpdates": "检查所选更新",
|
"checkUpdates": "检查所选更新",
|
||||||
"moveAll": "移动所选中到文件夹",
|
"moveAll": "移动所选中到文件夹",
|
||||||
"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} 启动自动整理...",
|
||||||
@@ -803,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新配方列表",
|
"title": "刷新配方列表",
|
||||||
"quick": "同步变更",
|
|
||||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
|
||||||
"full": "重建缓存",
|
"full": "重建缓存",
|
||||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "未找到文件夹",
|
"noFolders": "未找到文件夹",
|
||||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "检查此文件夹的更新",
|
||||||
|
"loading": "正在检查此文件夹中的{type}更新...",
|
||||||
|
"success": "在此文件夹中找到 {count} 个{type}更新",
|
||||||
|
"none": "此文件夹中的所有{type}都是最新版本",
|
||||||
|
"error": "检查文件夹{type}更新失败: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "从 URL 下载模型",
|
"title": "从 URL 下载模型",
|
||||||
"titleWithType": "从 URL 下载 {type}",
|
"titleWithType": "从 URL 下载 {type}",
|
||||||
"url": "Civitai URL",
|
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
|
||||||
"locationPreview": "下载位置预览",
|
"locationPreview": "下载位置预览",
|
||||||
"useDefaultPath": "使用默认路径",
|
"useDefaultPath": "使用默认路径",
|
||||||
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||||
"alreadyInLibrary": "已存在于库中",
|
"alreadyInLibrary": "已存在于库中",
|
||||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "选择文件格式",
|
||||||
|
"files": "个文件",
|
||||||
|
"select": "选择文件"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "无效的 Civitai URL 格式",
|
"invalidUrl": "无效的 Civitai URL 格式",
|
||||||
"noVersions": "此模型没有可用版本"
|
"noVersions": "此模型没有可用版本"
|
||||||
@@ -1076,6 +1109,12 @@
|
|||||||
"countMessage": "模型将被永久删除。",
|
"countMessage": "模型将被永久删除。",
|
||||||
"action": "全部删除"
|
"action": "全部删除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "删除多个配方",
|
||||||
|
"message": "你确定要删除所有选中的配方及其相关文件吗?",
|
||||||
|
"countMessage": "配方将被永久删除。",
|
||||||
|
"action": "全部删除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "检查所有 {type} 的更新?",
|
"title": "检查所有 {type} 的更新?",
|
||||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "编辑模型名称",
|
"editModelName": "编辑模型名称",
|
||||||
"editFileName": "编辑文件名",
|
"editFileName": "编辑文件名",
|
||||||
"editBaseModel": "编辑基础模型",
|
"editBaseModel": "编辑基础模型",
|
||||||
|
"editVersionName": "编辑版本名称",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
"viewCreatorProfile": "查看创作者主页",
|
"viewCreatorProfile": "查看创作者主页",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "备注保存成功",
|
"saved": "备注保存成功",
|
||||||
"saveFailed": "备注保存失败"
|
"saveFailed": "备注保存失败",
|
||||||
|
"showMore": "展开",
|
||||||
|
"showLess": "收起"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "添加预设参数...",
|
"addPresetParameter": "添加预设参数...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "抢先体验",
|
"earlyAccess": "抢先体验",
|
||||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已关闭更新通知"
|
"ignoredTooltip": "此版本已关闭更新通知",
|
||||||
|
"onSiteOnly": "仅站内生成",
|
||||||
|
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTooltip": "下载此版本",
|
"downloadTooltip": "下载此版本",
|
||||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||||
|
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleteTooltip": "删除此本地版本",
|
"deleteTooltip": "删除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "无配方 ID",
|
"noRecipeId": "无配方 ID",
|
||||||
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
||||||
"copyFailed": "复制配方语法出错:{message}",
|
"copyFailed": "复制配方语法出错:{message}",
|
||||||
|
"createError": "创建配方时出错:{message}",
|
||||||
|
"createFailed": "创建配方失败:{error}",
|
||||||
|
"createMissingData": "缺少创建配方所需的数据",
|
||||||
|
"created": "配方创建成功",
|
||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||||
"noRecipesSelected": "未选择任何配方",
|
"noRecipesSelected": "未选择任何配方",
|
||||||
|
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||||
|
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||||
|
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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} 未发现更新",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "需要关注",
|
"warning": "需要关注",
|
||||||
"error": "需要处理"
|
"error": "需要处理"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API 密钥"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "模型缓存健康状态"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "文件名重复冲突"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI 版本"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "重新检查",
|
"runAgain": "重新检查",
|
||||||
"exportBundle": "导出诊断包"
|
"exportBundle": "导出诊断包",
|
||||||
|
"open-settings": "打开设置",
|
||||||
|
"open-settings-syntax-format": "切换为完整路径语法",
|
||||||
|
"repair-cache": "重建缓存",
|
||||||
|
"resolve-filename-conflicts": "解决冲突",
|
||||||
|
"reload-page": "刷新 UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "冲突详情",
|
||||||
|
"version": "版本信息"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "加载诊断结果失败:{message}",
|
"loadFailed": "加载诊断结果失败:{message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "解决文件名冲突",
|
||||||
|
"message": "通过在每个重复文件名后附加 4 位哈希值来重命名文件。",
|
||||||
|
"note": "此操作会重命名磁盘上的文件。如果使用 A1111 语法格式,现有工作流中的模型引用可能需要更新。",
|
||||||
|
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "将重命名 <strong>{count}</strong> 个文件(共 <strong>{groups}</strong> 组重复)",
|
||||||
|
"confirm": "重命名文件",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "检测到应用更新",
|
"title": "检测到应用更新",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "說明",
|
"help": "說明",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
"close": "關閉"
|
"close": "關閉",
|
||||||
|
"menu": "選單",
|
||||||
|
"remove": "移除",
|
||||||
|
"change": "更換"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
@@ -231,7 +234,10 @@
|
|||||||
"license": "授權",
|
"license": "授權",
|
||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
|
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||||
|
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||||
"noTags": "無標籤",
|
"noTags": "無標籤",
|
||||||
|
"autoTags": "自動標籤",
|
||||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||||
"clearAll": "清除所有篩選",
|
"clearAll": "清除所有篩選",
|
||||||
"any": "任一",
|
"any": "任一",
|
||||||
@@ -265,10 +271,10 @@
|
|||||||
},
|
},
|
||||||
"downloadBackend": {
|
"downloadBackend": {
|
||||||
"label": "下載後端",
|
"label": "下載後端",
|
||||||
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用實驗性的外部下載程序。",
|
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用推薦的外部下載程序。",
|
||||||
"options": {
|
"options": {
|
||||||
"python": "Python(內建)",
|
"python": "Python(內建)",
|
||||||
"aria2": "aria2(實驗性)"
|
"aria2": "aria2(推薦)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aria2cPath": {
|
"aria2cPath": {
|
||||||
@@ -575,7 +581,13 @@
|
|||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
|
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
|
||||||
|
"loraSyntaxFormat": "LoRA 語法格式",
|
||||||
|
"loraSyntaxFormatHelp": "LoRA 語法格式。完整路徑(Full)包含子資料夾路徑 (<lora:style/anime/x:1.0>),解析精確無歧義。舊版(Legacy)僅使用檔名 (<lora:x:1.0>)——A1111 原始約定,同名檔案跨資料夾時可能產生歧義。",
|
||||||
|
"loraSyntaxFormatOptions": {
|
||||||
|
"full": "完整路徑(子資料夾/名稱)",
|
||||||
|
"legacy": "舊版 A1111(僅名稱)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"metadataArchive": {
|
"metadataArchive": {
|
||||||
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
||||||
@@ -639,8 +651,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理模型列表",
|
"title": "重新整理模型列表",
|
||||||
"quick": "同步變更",
|
|
||||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
|
||||||
"full": "重建快取",
|
"full": "重建快取",
|
||||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||||
},
|
},
|
||||||
@@ -681,16 +691,29 @@
|
|||||||
"setContentRating": "為全部設定內容分級",
|
"setContentRating": "為全部設定內容分級",
|
||||||
"copyAll": "複製全部語法",
|
"copyAll": "複製全部語法",
|
||||||
"refreshAll": "刷新全部 metadata",
|
"refreshAll": "刷新全部 metadata",
|
||||||
|
"repairMetadata": "修復所選中元數據",
|
||||||
"checkUpdates": "檢查所選更新",
|
"checkUpdates": "檢查所選更新",
|
||||||
"moveAll": "全部移動到資料夾",
|
"moveAll": "全部移動到資料夾",
|
||||||
"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}...",
|
||||||
@@ -803,8 +826,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理配方列表",
|
"title": "重新整理配方列表",
|
||||||
"quick": "同步變更",
|
|
||||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
|
||||||
"full": "重建快取",
|
"full": "重建快取",
|
||||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||||
},
|
},
|
||||||
@@ -944,6 +965,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "未找到資料夾",
|
"noFolders": "未找到資料夾",
|
||||||
"dragHint": "將項目拖到此處以建立資料夾"
|
"dragHint": "將項目拖到此處以建立資料夾"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "檢查此資料夾的更新",
|
||||||
|
"loading": "正在檢查此資料夾中的{type}更新...",
|
||||||
|
"success": "在此資料夾中找到 {count} 個{type}更新",
|
||||||
|
"none": "此資料夾中的所有{type}都是最新版本",
|
||||||
|
"error": "檢查資料夾{type}更新失敗: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -988,9 +1016,9 @@
|
|||||||
"download": {
|
"download": {
|
||||||
"title": "從網址下載模型",
|
"title": "從網址下載模型",
|
||||||
"titleWithType": "從網址下載 {type}",
|
"titleWithType": "從網址下載 {type}",
|
||||||
"url": "Civitai 網址",
|
|
||||||
"civitaiUrl": "Civitai 網址:",
|
"civitaiUrl": "Civitai 網址:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
|
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
|
||||||
"locationPreview": "下載位置預覽",
|
"locationPreview": "下載位置預覽",
|
||||||
"useDefaultPath": "使用預設路徑",
|
"useDefaultPath": "使用預設路徑",
|
||||||
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
||||||
@@ -1012,6 +1040,11 @@
|
|||||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||||
"alreadyInLibrary": "已在庫存",
|
"alreadyInLibrary": "已在庫存",
|
||||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "選擇檔案格式",
|
||||||
|
"files": "個檔案",
|
||||||
|
"select": "選擇檔案"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Civitai 網址格式無效",
|
"invalidUrl": "Civitai 網址格式無效",
|
||||||
"noVersions": "此模型無可用版本"
|
"noVersions": "此模型無可用版本"
|
||||||
@@ -1076,6 +1109,12 @@
|
|||||||
"countMessage": "模型將被永久刪除。",
|
"countMessage": "模型將被永久刪除。",
|
||||||
"action": "全部刪除"
|
"action": "全部刪除"
|
||||||
},
|
},
|
||||||
|
"bulkDeleteRecipes": {
|
||||||
|
"title": "刪除多個配方",
|
||||||
|
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
|
||||||
|
"countMessage": "配方將被永久刪除。",
|
||||||
|
"action": "全部刪除"
|
||||||
|
},
|
||||||
"checkUpdates": {
|
"checkUpdates": {
|
||||||
"title": "要檢查所有 {type} 的更新嗎?",
|
"title": "要檢查所有 {type} 的更新嗎?",
|
||||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||||
@@ -1156,6 +1195,7 @@
|
|||||||
"editModelName": "編輯模型名稱",
|
"editModelName": "編輯模型名稱",
|
||||||
"editFileName": "編輯檔案名稱",
|
"editFileName": "編輯檔案名稱",
|
||||||
"editBaseModel": "編輯基礎模型",
|
"editBaseModel": "編輯基礎模型",
|
||||||
|
"editVersionName": "編輯版本名稱",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
"viewCreatorProfile": "查看創作者個人檔案",
|
"viewCreatorProfile": "查看創作者個人檔案",
|
||||||
@@ -1187,7 +1227,9 @@
|
|||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "備註已儲存",
|
"saved": "備註已儲存",
|
||||||
"saveFailed": "儲存備註失敗"
|
"saveFailed": "儲存備註失敗",
|
||||||
|
"showMore": "展開",
|
||||||
|
"showLess": "收起"
|
||||||
},
|
},
|
||||||
"usageTips": {
|
"usageTips": {
|
||||||
"addPresetParameter": "新增預設參數...",
|
"addPresetParameter": "新增預設參數...",
|
||||||
@@ -1292,12 +1334,15 @@
|
|||||||
"earlyAccess": "搶先體驗",
|
"earlyAccess": "搶先體驗",
|
||||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已關閉更新通知"
|
"ignoredTooltip": "此版本已關閉更新通知",
|
||||||
|
"onSiteOnly": "僅站內生成",
|
||||||
|
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"downloadTooltip": "下載此版本",
|
"downloadTooltip": "下載此版本",
|
||||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||||
|
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"deleteTooltip": "刪除此本地版本",
|
"deleteTooltip": "刪除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
@@ -1627,6 +1672,10 @@
|
|||||||
"noRecipeId": "無配方 ID",
|
"noRecipeId": "無配方 ID",
|
||||||
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
||||||
"copyFailed": "複製配方語法錯誤:{message}",
|
"copyFailed": "複製配方語法錯誤:{message}",
|
||||||
|
"createError": "建立配方時發生錯誤:{message}",
|
||||||
|
"createFailed": "建立配方失敗:{error}",
|
||||||
|
"createMissingData": "缺少建立配方所需的資料",
|
||||||
|
"created": "配方建立成功",
|
||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
||||||
@@ -1665,6 +1714,9 @@
|
|||||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||||
"noRecipesSelected": "未選取任何食譜",
|
"noRecipesSelected": "未選取任何食譜",
|
||||||
|
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||||
|
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||||
|
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||||
},
|
},
|
||||||
@@ -1695,6 +1747,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} 未找到更新",
|
||||||
@@ -1897,9 +1954,32 @@
|
|||||||
"warning": "需要注意",
|
"warning": "需要注意",
|
||||||
"error": "需要處理"
|
"error": "需要處理"
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"civitai_api_key": {
|
||||||
|
"title": "Civitai API 金鑰"
|
||||||
|
},
|
||||||
|
"cache_health": {
|
||||||
|
"title": "模型快取健康狀態"
|
||||||
|
},
|
||||||
|
"filename_conflicts": {
|
||||||
|
"title": "檔案名稱重複衝突"
|
||||||
|
},
|
||||||
|
"ui_version": {
|
||||||
|
"title": "UI 版本"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"runAgain": "重新執行",
|
"runAgain": "重新執行",
|
||||||
"exportBundle": "匯出套件"
|
"exportBundle": "匯出套件",
|
||||||
|
"open-settings": "開啟設定",
|
||||||
|
"open-settings-syntax-format": "切換為完整路徑語法",
|
||||||
|
"repair-cache": "重建快取",
|
||||||
|
"resolve-filename-conflicts": "解決衝突",
|
||||||
|
"reload-page": "重新載入 UI"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"conflicts": "衝突詳情",
|
||||||
|
"version": "版本"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"loadFailed": "載入診斷失敗:{message}",
|
"loadFailed": "載入診斷失敗:{message}",
|
||||||
@@ -1911,6 +1991,15 @@
|
|||||||
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictConfirm": {
|
||||||
|
"title": "解決檔案名稱衝突",
|
||||||
|
"message": "通過在每個重複檔案名稱後附加 4 位元哈希值來重新命名檔案。",
|
||||||
|
"note": "此操作會重新命名磁碟上的檔案。如果使用 A1111 語法格式,現有工作流程中的模型參考可能需要更新。",
|
||||||
|
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||||
|
"impact": "將重新命名 <strong>{count}</strong> 個檔案(共 <strong>{groups}</strong> 組重複)",
|
||||||
|
"confirm": "重新命名檔案",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
"versionMismatch": {
|
"versionMismatch": {
|
||||||
"title": "偵測到應用程式更新",
|
"title": "偵測到應用程式更新",
|
||||||
|
|||||||
96
py/config.py
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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ..utils.utils import get_lora_info_absolute
|
|||||||
from .utils import (
|
from .utils import (
|
||||||
FlexibleOptionalInputType,
|
FlexibleOptionalInputType,
|
||||||
any_type,
|
any_type,
|
||||||
|
apply_lora_syntax_format,
|
||||||
detect_nunchaku_model_kind,
|
detect_nunchaku_model_kind,
|
||||||
extract_lora_name,
|
extract_lora_name,
|
||||||
get_loras_list,
|
get_loras_list,
|
||||||
@@ -52,7 +53,7 @@ def _collect_widget_entries(kwargs):
|
|||||||
for lora in get_loras_list(kwargs):
|
for lora in get_loras_list(kwargs):
|
||||||
if not lora.get("active", False):
|
if not lora.get("active", False):
|
||||||
continue
|
continue
|
||||||
lora_name = lora["name"]
|
lora_name = apply_lora_syntax_format(lora["name"])
|
||||||
model_strength = float(lora["strength"])
|
model_strength = float(lora["strength"])
|
||||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class LoraStackerLM:
|
|||||||
if not lora.get('active', False):
|
if not lora.get('active', False):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lora_name = lora['name']
|
lora_name = apply_lora_syntax_format(lora['name'])
|
||||||
model_strength = float(lora['strength'])
|
model_strength = float(lora['strength'])
|
||||||
# Get clip strength - use model strength as default if not specified
|
# Get clip strength - use model strength as default if not specified
|
||||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||||
|
|||||||
@@ -44,11 +44,29 @@ import folder_paths # type: ignore
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_lora_syntax_format():
|
||||||
|
try:
|
||||||
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
return get_settings_manager().get("lora_syntax_format", "legacy")
|
||||||
|
except Exception:
|
||||||
|
return "legacy"
|
||||||
|
|
||||||
|
|
||||||
|
def apply_lora_syntax_format(name):
|
||||||
|
fmt = get_lora_syntax_format()
|
||||||
|
if fmt == "legacy":
|
||||||
|
return name.replace("\\", "/").rstrip("/").split("/")[-1]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
def extract_lora_name(lora_path):
|
def extract_lora_name(lora_path):
|
||||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
normalized = lora_path.replace("\\", "/")
|
||||||
# Get the basename without extension
|
basename = os.path.basename(normalized)
|
||||||
basename = os.path.basename(lora_path)
|
name_no_ext = os.path.splitext(basename)[0]
|
||||||
return os.path.splitext(basename)[0]
|
dirname = os.path.dirname(normalized)
|
||||||
|
if dirname and dirname not in (".", "/") and not normalized.startswith("/"):
|
||||||
|
return apply_lora_syntax_format(f"{dirname}/{name_no_ext}")
|
||||||
|
return apply_lora_syntax_format(name_no_ext)
|
||||||
|
|
||||||
|
|
||||||
def get_loras_list(kwargs):
|
def get_loras_list(kwargs):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import re
|
|||||||
from typing import Dict, List, Any, Optional, Tuple
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.constants import VALID_LORA_TYPES
|
from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,9 +58,52 @@ class RecipeMetadataParser(ABC):
|
|||||||
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||||
|
|
||||||
if not civitai_info or error_msg == "Model not found":
|
if not civitai_info or error_msg == "Model not found":
|
||||||
# Model not found or deleted
|
# CivitAI may fail to resolve a hash that is still being
|
||||||
lora_entry['isDeleted'] = True
|
# computed (known CivitAI issue). Before marking as deleted,
|
||||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
# try to reconcile with a local model that has the same
|
||||||
|
# filename and matching AutoV3 hash.
|
||||||
|
reconciled = False
|
||||||
|
file_name = lora_entry.get("file_name")
|
||||||
|
if file_name and recipe_scanner and hash_value:
|
||||||
|
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
||||||
|
if lora_scanner:
|
||||||
|
try:
|
||||||
|
# Local import to avoid circular dependency:
|
||||||
|
# base.py → file_utils → settings_manager → ...
|
||||||
|
# → recipe_scanner → enrichment → base.py
|
||||||
|
from ..utils.file_utils import calculate_autov3 # fmt: skip
|
||||||
|
cache = await lora_scanner.get_cached_data()
|
||||||
|
for item in getattr(cache, "raw_data", []):
|
||||||
|
if item.get("file_name") == file_name:
|
||||||
|
local_path = item.get("file_path")
|
||||||
|
if local_path and os.path.exists(local_path):
|
||||||
|
local_autov3 = calculate_autov3(local_path)
|
||||||
|
if local_autov3 and local_autov3 == hash_value:
|
||||||
|
lora_entry["existsLocally"] = True
|
||||||
|
lora_entry["localPath"] = local_path
|
||||||
|
lora_entry["hash"] = item.get("sha256", hash_value)
|
||||||
|
if "preview_url" in item:
|
||||||
|
lora_entry["thumbnailUrl"] = config.get_preview_static_url(item["preview_url"])
|
||||||
|
civ = item.get("civitai") or {}
|
||||||
|
if isinstance(civ, dict):
|
||||||
|
if civ.get("id") is not None:
|
||||||
|
lora_entry["id"] = civ["id"]
|
||||||
|
if civ.get("modelId") is not None:
|
||||||
|
lora_entry["modelId"] = civ["modelId"]
|
||||||
|
if civ.get("name"):
|
||||||
|
lora_entry["version"] = civ["name"]
|
||||||
|
# model_name is the CivitAI model display
|
||||||
|
# name stored directly in the cache column.
|
||||||
|
cached_model_name = item.get("model_name")
|
||||||
|
if cached_model_name:
|
||||||
|
lora_entry["name"] = cached_model_name
|
||||||
|
reconciled = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not reconciled:
|
||||||
|
lora_entry['isDeleted'] = True
|
||||||
|
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||||
return lora_entry
|
return lora_entry
|
||||||
|
|
||||||
# Get model type and validate
|
# Get model type and validate
|
||||||
@@ -173,6 +216,20 @@ class RecipeMetadataParser(ABC):
|
|||||||
checkpoint['isDeleted'] = True
|
checkpoint['isDeleted'] = True
|
||||||
return checkpoint
|
return checkpoint
|
||||||
|
|
||||||
|
# Validate that the model type is actually a checkpoint.
|
||||||
|
# Unlike populate_lora_from_civitai which has this check,
|
||||||
|
# this function was missing type validation — allowing LoRA
|
||||||
|
# version data to be saved as the recipe's checkpoint when the
|
||||||
|
# wrong version ID was passed downstream (fixed in v2.7+).
|
||||||
|
model_type = civitai_data.get('model', {}).get('type', '').lower()
|
||||||
|
if model_type not in VALID_CHECKPOINT_SUB_TYPES:
|
||||||
|
logger.warning(
|
||||||
|
f"Cannot populate checkpoint: model version {civitai_data.get('id')} "
|
||||||
|
f"has type '{model_type}', expected one of {VALID_CHECKPOINT_SUB_TYPES}. "
|
||||||
|
f"Skipping checkpoint enrichment."
|
||||||
|
)
|
||||||
|
return checkpoint
|
||||||
|
|
||||||
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||||
checkpoint['name'] = civitai_data['model']['name']
|
checkpoint['name'] = civitai_data['model']['name']
|
||||||
|
|
||||||
|
|||||||
@@ -16,55 +16,65 @@ 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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
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")
|
||||||
model_version_id = image_info.get("modelVersionId")
|
if isinstance(raw_meta, dict):
|
||||||
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
# If not at top level, check resources in meta
|
civitai_meta = raw_meta["meta"]
|
||||||
if not model_version_id and civitai_meta:
|
else:
|
||||||
resources = civitai_meta.get("civitaiResources", [])
|
civitai_meta = raw_meta
|
||||||
for res in resources:
|
|
||||||
if res.get("type") == "checkpoint":
|
model_version_id = image_info.get("modelVersionId")
|
||||||
model_version_id = res.get("modelVersionId")
|
except Exception as e:
|
||||||
break
|
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
if not model_version_id and civitai_meta:
|
||||||
|
resources = civitai_meta.get("civitaiResources", [])
|
||||||
|
for res in resources:
|
||||||
|
if res.get("type") == "checkpoint":
|
||||||
|
model_version_id = res.get("modelVersionId")
|
||||||
|
break
|
||||||
|
|
||||||
# 2. Merge Parameters
|
# 2. Merge Parameters
|
||||||
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||||
@@ -180,27 +190,42 @@ class RecipeEnricher:
|
|||||||
existing_cp = recipe.get("checkpoint")
|
existing_cp = recipe.get("checkpoint")
|
||||||
if existing_cp is None:
|
if existing_cp is None:
|
||||||
existing_cp = {}
|
existing_cp = {}
|
||||||
|
|
||||||
|
# Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
|
||||||
|
# (populate may reject non-checkpoint types and lose this data)
|
||||||
|
base_model_from_civitai: str = ""
|
||||||
|
if isinstance(civitai_info, dict):
|
||||||
|
base_model_from_civitai = civitai_info.get("baseModel", "") or ""
|
||||||
|
elif isinstance(civitai_info, tuple) and len(civitai_info) > 0 and isinstance(civitai_info[0], dict):
|
||||||
|
base_model_from_civitai = civitai_info[0].get("baseModel", "") or ""
|
||||||
|
|
||||||
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
||||||
# 1. First, resolve base_model using full data before we format it away
|
|
||||||
|
# 1. Resolve base_model from checkpoint_data first, then fall back to raw civitai_info
|
||||||
current_base_model = recipe.get("base_model")
|
current_base_model = recipe.get("base_model")
|
||||||
resolved_base_model = checkpoint_data.get("baseModel")
|
resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
|
||||||
if resolved_base_model:
|
if resolved_base_model:
|
||||||
# Update if empty OR if it matches our generic prefix but is less specific
|
|
||||||
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
||||||
if is_generic and resolved_base_model != current_base_model:
|
if is_generic and resolved_base_model != current_base_model:
|
||||||
recipe["base_model"] = resolved_base_model
|
recipe["base_model"] = resolved_base_model
|
||||||
|
|
||||||
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
|
# 2. Only format and save checkpoint if it has real data (not just type after type rejection)
|
||||||
formatted_checkpoint = {
|
has_checkpoint_data = any([
|
||||||
"type": "checkpoint",
|
checkpoint_data.get("modelId"),
|
||||||
"modelId": checkpoint_data.get("modelId"),
|
checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
checkpoint_data.get("name"),
|
||||||
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
|
checkpoint_data.get("version"),
|
||||||
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
|
])
|
||||||
}
|
if has_checkpoint_data:
|
||||||
# Remove None values
|
formatted_checkpoint = {
|
||||||
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
"type": "checkpoint",
|
||||||
|
"modelId": checkpoint_data.get("modelId"),
|
||||||
|
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
|
"modelName": checkpoint_data.get("name"),
|
||||||
|
"modelVersionName": checkpoint_data.get("version"),
|
||||||
|
}
|
||||||
|
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
||||||
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# Fallback to name extraction if we don't already have one
|
# Fallback to name extraction if we don't already have one
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Dict, Any, Union
|
|||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
from ...config import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,7 +74,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def parse_metadata( # type: ignore[override]
|
async def parse_metadata( # type: ignore[override]
|
||||||
self, user_comment, recipe_scanner=None, civitai_client=None
|
self, user_comment, recipe_scanner=None, civitai_client=None,
|
||||||
|
local_cache: dict[str, Any] | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Parse metadata from Civitai image format
|
"""Parse metadata from Civitai image format
|
||||||
|
|
||||||
@@ -81,6 +83,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
user_comment: The metadata from the image (dict)
|
user_comment: The metadata from the image (dict)
|
||||||
recipe_scanner: Optional recipe scanner service
|
recipe_scanner: Optional recipe scanner service
|
||||||
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
|
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
|
||||||
|
local_cache: Optional dict mapping sha256/autov3 hash → scanner cache item.
|
||||||
|
When provided, matching models skip CivitAI API calls.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing parsed recipe data
|
Dict containing parsed recipe data
|
||||||
@@ -185,8 +189,77 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Process standard resources array
|
# Process standard resources array
|
||||||
if "resources" in metadata and isinstance(metadata["resources"], list):
|
if "resources" in metadata and isinstance(metadata["resources"], list):
|
||||||
for resource in metadata["resources"]:
|
for resource in metadata["resources"]:
|
||||||
|
resource_type = resource.get("type", "lora")
|
||||||
|
|
||||||
|
# Track resources with type "model" — these are checkpoint models.
|
||||||
|
# The resources array is the most reliable source for checkpoint
|
||||||
|
# identification because it has an explicit type field and hash,
|
||||||
|
# unlike modelVersionIds which is a flat list with no type info.
|
||||||
|
if resource_type == "model":
|
||||||
|
checkpoint_entry = {
|
||||||
|
"id": 0,
|
||||||
|
"modelId": 0,
|
||||||
|
"name": resource.get("name", "Unknown Model"),
|
||||||
|
"version": "",
|
||||||
|
"type": resource.get("type", "model"),
|
||||||
|
"existsLocally": False,
|
||||||
|
"localPath": None,
|
||||||
|
"file_name": resource.get("name", ""),
|
||||||
|
"hash": resource.get("hash", "") or "",
|
||||||
|
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||||
|
"baseModel": "",
|
||||||
|
"size": 0,
|
||||||
|
"downloadUrl": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to look up base model from the checkpoint hash
|
||||||
|
cp_hash = checkpoint_entry.get("hash")
|
||||||
|
if cp_hash and metadata_provider:
|
||||||
|
local_cached = local_cache.get(cp_hash) if local_cache else None
|
||||||
|
if local_cached:
|
||||||
|
self._populate_entry_from_cache(
|
||||||
|
checkpoint_entry, local_cached
|
||||||
|
)
|
||||||
|
bm = checkpoint_entry.get("baseModel", "")
|
||||||
|
if bm and not result["base_model"]:
|
||||||
|
result["base_model"] = bm
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
civitai_info = (
|
||||||
|
await metadata_provider.get_model_by_hash(
|
||||||
|
cp_hash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
civitai_data, error_msg = (
|
||||||
|
(civitai_info, None)
|
||||||
|
if not isinstance(civitai_info, tuple)
|
||||||
|
else civitai_info
|
||||||
|
)
|
||||||
|
if civitai_data and error_msg != "Model not found":
|
||||||
|
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||||
|
checkpoint_entry['name'] = civitai_data['model']['name']
|
||||||
|
checkpoint_entry['id'] = civitai_data.get('id', 0)
|
||||||
|
checkpoint_entry['modelId'] = civitai_data.get('modelId', 0)
|
||||||
|
if 'name' in civitai_data:
|
||||||
|
checkpoint_entry['version'] = civitai_data['name']
|
||||||
|
base_model = civitai_data.get('baseModel', '')
|
||||||
|
if base_model:
|
||||||
|
checkpoint_entry['baseModel'] = base_model
|
||||||
|
if not result['base_model']:
|
||||||
|
result['base_model'] = base_model
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching checkpoint info for hash "
|
||||||
|
f"{cp_hash}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["model"] is None:
|
||||||
|
result["model"] = checkpoint_entry
|
||||||
|
continue
|
||||||
|
|
||||||
# Modified to process resources without a type field as potential LoRAs
|
# Modified to process resources without a type field as potential LoRAs
|
||||||
if resource.get("type", "lora") == "lora":
|
if resource_type == "lora":
|
||||||
lora_hash = resource.get("hash", "")
|
lora_hash = resource.get("hash", "")
|
||||||
|
|
||||||
# Try to get hash from the hashes field if not present in resource
|
# Try to get hash from the hashes field if not present in resource
|
||||||
@@ -220,34 +293,45 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Try to get info from Civitai if hash is available
|
# Try to get info from Civitai if hash is available
|
||||||
if lora_entry["hash"] and metadata_provider:
|
if lora_hash and metadata_provider:
|
||||||
try:
|
local_cached = local_cache.get(lora_hash) if local_cache else None
|
||||||
civitai_info = (
|
if local_cached:
|
||||||
await metadata_provider.get_model_by_hash(lora_hash)
|
self._populate_entry_from_cache(
|
||||||
|
lora_entry, local_cached
|
||||||
)
|
)
|
||||||
|
# Track by version ID for deduplication
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
if lora_entry.get("id"):
|
||||||
lora_entry,
|
|
||||||
civitai_info,
|
|
||||||
recipe_scanner,
|
|
||||||
base_model_counts,
|
|
||||||
lora_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
if populated_entry is None:
|
|
||||||
continue # Skip invalid LoRA types
|
|
||||||
|
|
||||||
lora_entry = populated_entry
|
|
||||||
|
|
||||||
# If we have a version ID from Civitai, track it for deduplication
|
|
||||||
if "id" in lora_entry and lora_entry["id"]:
|
|
||||||
added_loras[str(lora_entry["id"])] = len(
|
added_loras[str(lora_entry["id"])] = len(
|
||||||
result["loras"]
|
result["loras"]
|
||||||
)
|
)
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(
|
try:
|
||||||
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
|
civitai_info = (
|
||||||
)
|
await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
|
)
|
||||||
|
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts,
|
||||||
|
lora_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
if populated_entry is None:
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
|
||||||
|
# If we have a version ID from Civitai, track it for deduplication
|
||||||
|
if "id" in lora_entry and lora_entry["id"]:
|
||||||
|
added_loras[str(lora_entry["id"])] = len(
|
||||||
|
result["loras"]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Track by hash if we have it
|
# Track by hash if we have it
|
||||||
if lora_hash:
|
if lora_hash:
|
||||||
@@ -625,3 +709,41 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
||||||
return {"error": str(e), "loras": []}
|
return {"error": str(e), "loras": []}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _populate_entry_from_cache(
|
||||||
|
entry: dict[str, Any],
|
||||||
|
cache_item: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Fill a lora/checkpoint entry from a scanner cache item.
|
||||||
|
|
||||||
|
Avoids CivitAI API calls for models that exist locally.
|
||||||
|
Mirrors the population logic in
|
||||||
|
``RecipeMetadataParser.populate_lora_from_civitai()`` but operates
|
||||||
|
entirely on cached data.
|
||||||
|
"""
|
||||||
|
civ = cache_item.get("civitai") or {}
|
||||||
|
if isinstance(civ, dict):
|
||||||
|
if civ.get("id") is not None:
|
||||||
|
entry["id"] = civ["id"]
|
||||||
|
if civ.get("modelId") is not None:
|
||||||
|
entry["modelId"] = civ["modelId"]
|
||||||
|
if civ.get("name"):
|
||||||
|
entry["version"] = civ["name"]
|
||||||
|
cached_name = cache_item.get("model_name")
|
||||||
|
if cached_name:
|
||||||
|
entry["name"] = cached_name
|
||||||
|
entry["existsLocally"] = True
|
||||||
|
local_path = cache_item.get("file_path")
|
||||||
|
if local_path:
|
||||||
|
entry["localPath"] = local_path
|
||||||
|
sha256 = cache_item.get("sha256")
|
||||||
|
if sha256:
|
||||||
|
entry["hash"] = sha256
|
||||||
|
if "preview_url" in cache_item:
|
||||||
|
entry["thumbnailUrl"] = config.get_preview_static_url(
|
||||||
|
cache_item["preview_url"]
|
||||||
|
)
|
||||||
|
base_model = cache_item.get("base_model", "")
|
||||||
|
if base_model:
|
||||||
|
entry["baseModel"] = base_model
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -685,6 +686,9 @@ class DoctorHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
|
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
|
if self._settings.get("lora_syntax_format", "legacy") == "full":
|
||||||
|
return web.json_response({"success": True, "renamed": [], "count": 0})
|
||||||
|
|
||||||
renamed: list[dict[str, Any]] = []
|
renamed: list[dict[str, Any]] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -989,11 +993,29 @@ class DoctorHandler:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def _check_filename_conflicts(self) -> dict[str, Any]:
|
async def _check_filename_conflicts(self) -> dict[str, Any]:
|
||||||
|
# When full path syntax is active, duplicate filenames across subfolders
|
||||||
|
# are not ambiguous (<lora:subfolder/name:strength>), so skip the check.
|
||||||
|
if self._settings.get("lora_syntax_format", "legacy") == "full":
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "ok",
|
||||||
|
"summary": "Full path syntax is active — duplicate filenames across folders are not ambiguous.",
|
||||||
|
"details": [],
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
|
||||||
all_conflicts: list[dict[str, Any]] = []
|
all_conflicts: list[dict[str, Any]] = []
|
||||||
total_conflict_groups = 0
|
total_conflict_groups = 0
|
||||||
total_conflict_files = 0
|
total_conflict_files = 0
|
||||||
|
|
||||||
for model_type, label, factory in self._scanner_factories:
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
# Duplicate filename detection targets LoRAs which use basename-only
|
||||||
|
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
||||||
|
# models via relative paths with extensions, so conflicts there would
|
||||||
|
# be false positives.
|
||||||
|
if model_type != "lora":
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
scanner = await factory()
|
scanner = await factory()
|
||||||
hash_index = getattr(scanner, "_hash_index", None)
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
@@ -1041,12 +1063,22 @@ class DoctorHandler:
|
|||||||
"total_conflict_files": total_conflict_files,
|
"total_conflict_files": total_conflict_files,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
for conflict in all_conflicts:
|
|
||||||
|
# Show at most 5 conflict groups inline; note any remainder.
|
||||||
|
MAX_VISIBLE_CONFLICTS = 5
|
||||||
|
visible_conflicts = all_conflicts[:MAX_VISIBLE_CONFLICTS]
|
||||||
|
for conflict in visible_conflicts:
|
||||||
details.append(
|
details.append(
|
||||||
f"[{conflict['label']}] '{conflict['filename']}' "
|
f"'{conflict['filename']}' "
|
||||||
f"found in {len(conflict['paths'])} locations"
|
f"found in {len(conflict['paths'])} locations"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hidden_count = len(all_conflicts) - MAX_VISIBLE_CONFLICTS
|
||||||
|
if hidden_count > 0:
|
||||||
|
details.append(
|
||||||
|
f"...and {hidden_count} more duplicate filename group(s)"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": "filename_conflicts",
|
"id": "filename_conflicts",
|
||||||
"title": "Duplicate Filename Conflicts",
|
"title": "Duplicate Filename Conflicts",
|
||||||
@@ -1057,7 +1089,11 @@ class DoctorHandler:
|
|||||||
{
|
{
|
||||||
"id": "resolve-filename-conflicts",
|
"id": "resolve-filename-conflicts",
|
||||||
"label": "Resolve Conflicts",
|
"label": "Resolve Conflicts",
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"id": "open-settings-syntax-format",
|
||||||
|
"label": "Switch to Full Path Syntax",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1791,29 +1827,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 +1873,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 +1926,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 +2100,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 +2118,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 +3234,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,
|
||||||
@@ -778,7 +788,7 @@ class ModelManagementHandler:
|
|||||||
|
|
||||||
metadata_updates = {k: v for k, v in data.items() if k != "file_path"}
|
metadata_updates = {k: v for k, v in data.items() if k != "file_path"}
|
||||||
|
|
||||||
await self._metadata_sync.save_metadata_updates(
|
updated_metadata = await self._metadata_sync.save_metadata_updates(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
updates=metadata_updates,
|
updates=metadata_updates,
|
||||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||||
@@ -789,7 +799,12 @@ class ModelManagementHandler:
|
|||||||
cache = await self._service.scanner.get_cached_data()
|
cache = await self._service.scanner.get_cached_data()
|
||||||
await cache.resort()
|
await cache.resort()
|
||||||
|
|
||||||
return web.json_response({"success": True})
|
from ...services.auto_tag_service import extract_auto_tags
|
||||||
|
auto_tags = extract_auto_tags(updated_metadata)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "auto_tags": auto_tags}
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error saving metadata: %s", exc, exc_info=True)
|
self._logger.error("Error saving metadata: %s", exc, exc_info=True)
|
||||||
return web.Response(text=str(exc), status=500)
|
return web.Response(text=str(exc), status=500)
|
||||||
@@ -806,14 +821,16 @@ class ModelManagementHandler:
|
|||||||
if not isinstance(new_tags, list):
|
if not isinstance(new_tags, list):
|
||||||
return web.Response(text="Tags must be a list", status=400)
|
return web.Response(text="Tags must be a list", status=400)
|
||||||
|
|
||||||
tags = await self._tag_update_service.add_tags(
|
tags, auto_tags = await self._tag_update_service.add_tags(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
new_tags=new_tags,
|
new_tags=new_tags,
|
||||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||||
update_cache=self._service.scanner.update_single_model_cache,
|
update_cache=self._service.scanner.update_single_model_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.json_response({"success": True, "tags": tags})
|
return web.json_response(
|
||||||
|
{"success": True, "tags": tags, "auto_tags": auto_tags}
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error adding tags: %s", exc, exc_info=True)
|
self._logger.error("Error adding tags: %s", exc, exc_info=True)
|
||||||
return web.Response(text=str(exc), status=500)
|
return web.Response(text=str(exc), status=500)
|
||||||
@@ -1160,6 +1177,12 @@ class ModelQueryHandler:
|
|||||||
|
|
||||||
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
|
settings = get_settings_manager()
|
||||||
|
if settings.get("lora_syntax_format", "legacy") == "full":
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "conflicts": [], "count": 0}
|
||||||
|
)
|
||||||
|
|
||||||
duplicates = self._service.find_duplicate_filenames()
|
duplicates = self._service.find_duplicate_filenames()
|
||||||
result = []
|
result = []
|
||||||
cache = await self._service.scanner.get_cached_data()
|
cache = await self._service.scanner.get_cached_data()
|
||||||
@@ -1449,6 +1472,21 @@ class ModelDownloadHandler:
|
|||||||
)
|
)
|
||||||
return web.Response(status=500, text=str(exc))
|
return web.Response(status=500, text=str(exc))
|
||||||
|
|
||||||
|
async def skip_download_get(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
download_id = request.query.get("download_id")
|
||||||
|
if not download_id:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Download ID is required"}, status=400
|
||||||
|
)
|
||||||
|
result = await self._download_coordinator.skip_download(download_id)
|
||||||
|
return web.json_response(result)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error skipping download via GET: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def cancel_download_get(self, request: web.Request) -> web.Response:
|
async def cancel_download_get(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
download_id = request.query.get("download_id")
|
download_id = request.query.get("download_id")
|
||||||
@@ -1937,6 +1975,10 @@ class ModelUpdateHandler:
|
|||||||
if target_model_ids:
|
if target_model_ids:
|
||||||
target_model_ids = sorted(set(target_model_ids))
|
target_model_ids = sorted(set(target_model_ids))
|
||||||
|
|
||||||
|
folder_path: Optional[str] = payload.get("folder_path")
|
||||||
|
if folder_path is not None and not isinstance(folder_path, str):
|
||||||
|
folder_path = None
|
||||||
|
|
||||||
provider = await self._get_civitai_provider()
|
provider = await self._get_civitai_provider()
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1951,6 +1993,7 @@ class ModelUpdateHandler:
|
|||||||
provider,
|
provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
target_model_ids=target_model_ids or None,
|
target_model_ids=target_model_ids or None,
|
||||||
|
folder_path=folder_path,
|
||||||
)
|
)
|
||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1973,10 +2016,21 @@ class ModelUpdateHandler:
|
|||||||
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
|
self._logger.error("Failed to refresh model updates: %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)
|
||||||
|
|
||||||
|
hide_early_access = False
|
||||||
|
if self._settings is not None:
|
||||||
|
try:
|
||||||
|
hide_early_access = bool(
|
||||||
|
self._settings.get("hide_early_access_updates", False)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
serialized_records = []
|
serialized_records = []
|
||||||
for record in records.values():
|
for record in records.values():
|
||||||
has_update_fn = getattr(record, "has_update", None)
|
has_update_fn = getattr(record, "has_update", None)
|
||||||
if callable(has_update_fn) and has_update_fn():
|
if callable(has_update_fn) and has_update_fn(
|
||||||
|
hide_early_access=hide_early_access
|
||||||
|
):
|
||||||
serialized_records.append(self._serialize_record(record))
|
serialized_records.append(self._serialize_record(record))
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2423,6 +2477,7 @@ class ModelUpdateHandler:
|
|||||||
"shouldIgnore": version.should_ignore,
|
"shouldIgnore": version.should_ignore,
|
||||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||||
"isEarlyAccess": is_early_access,
|
"isEarlyAccess": is_early_access,
|
||||||
|
"usageControl": version.usage_control,
|
||||||
"filePath": context.get("file_path"),
|
"filePath": context.get("file_path"),
|
||||||
"fileName": context.get("file_name"),
|
"fileName": context.get("file_name"),
|
||||||
}
|
}
|
||||||
@@ -2537,6 +2592,7 @@ class ModelHandlerSet:
|
|||||||
"download_model": self.download.download_model,
|
"download_model": self.download.download_model,
|
||||||
"download_model_get": self.download.download_model_get,
|
"download_model_get": self.download.download_model_get,
|
||||||
"cancel_download_get": self.download.cancel_download_get,
|
"cancel_download_get": self.download.cancel_download_get,
|
||||||
|
"skip_download_get": self.download.skip_download_get,
|
||||||
"pause_download_get": self.download.pause_download_get,
|
"pause_download_get": self.download.pause_download_get,
|
||||||
"resume_download_get": self.download.resume_download_get,
|
"resume_download_get": self.download.resume_download_get,
|
||||||
"get_download_progress": self.download.get_download_progress,
|
"get_download_progress": self.download.get_download_progress,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import mimetypes
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ from ...config import config as global_config
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CHUNK_SIZE = 256 * 1024 # 256 KB
|
||||||
|
|
||||||
|
# Video file extensions that bypass native sendfile on Windows
|
||||||
|
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
|
||||||
|
_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".avi", ".mkv"})
|
||||||
|
|
||||||
|
|
||||||
class PreviewHandler:
|
class PreviewHandler:
|
||||||
"""Serve preview assets for the active library at request time."""
|
"""Serve preview assets for the active library at request time."""
|
||||||
@@ -48,8 +55,51 @@ class PreviewHandler:
|
|||||||
logger.debug("Preview file not found at %s", str(resolved))
|
logger.debug("Preview file not found at %s", str(resolved))
|
||||||
raise web.HTTPNotFound(text="Preview file not found")
|
raise web.HTTPNotFound(text="Preview file not found")
|
||||||
|
|
||||||
|
# Video files: stream manually to avoid Windows native sendfile crash.
|
||||||
|
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
|
||||||
|
# which breaks when the client disconnects mid-transfer — this happens
|
||||||
|
# constantly when users scroll through a gallery of animated previews.
|
||||||
|
suffix = resolved.suffix.lower()
|
||||||
|
if suffix in _VIDEO_EXTENSIONS:
|
||||||
|
return await self._stream_file(request, resolved)
|
||||||
|
|
||||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||||
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
|
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
||||||
|
|
||||||
|
async def _stream_file(
|
||||||
|
self, request: web.Request, path: Path
|
||||||
|
) -> web.StreamResponse:
|
||||||
|
"""Stream a file chunk-by-chunk, bypassing native sendfile.
|
||||||
|
|
||||||
|
This avoids the Windows IOCP ``_sendfile_native`` crash that occurs
|
||||||
|
when the client disconnects during a large file transfer.
|
||||||
|
"""
|
||||||
|
content_type, _ = mimetypes.guess_type(str(path))
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
|
file_size = path.stat().st_size
|
||||||
|
resp = web.StreamResponse()
|
||||||
|
resp.content_type = content_type
|
||||||
|
resp.content_length = file_size
|
||||||
|
|
||||||
|
await resp.prepare(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
await resp.write(chunk)
|
||||||
|
except (ConnectionResetError, ConnectionAbortedError):
|
||||||
|
# Client disconnected during streaming — expected when scrolling
|
||||||
|
# rapidly through a library with animated previews.
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
logger.debug("I/O error streaming preview %s: %s", path, exc)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["PreviewHandler"]
|
__all__ = ["PreviewHandler"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
||||||
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||||
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||||
|
RouteDefinition("GET", "/api/lm/skip-download", "skip_download_get"),
|
||||||
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
||||||
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
@@ -70,6 +71,13 @@ 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"),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/recipes/create-from-example", "create_from_example"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Dict, List
|
|||||||
|
|
||||||
from ..utils.settings_paths import ensure_settings_file
|
from ..utils.settings_paths import ensure_settings_file
|
||||||
from ..services.downloader import get_downloader
|
from ..services.downloader import get_downloader
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -212,8 +213,19 @@ class UpdateRoutes:
|
|||||||
|
|
||||||
zip_path = tmp_zip_path
|
zip_path = tmp_zip_path
|
||||||
|
|
||||||
# Skip both settings.json, civitai and model cache folder
|
# Close the downloaded-versions SQLite connection before cleaning,
|
||||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
# so that shutil.rmtree() does not fail on Windows (the process
|
||||||
|
# cannot delete a file with an outstanding open handle).
|
||||||
|
try:
|
||||||
|
history_svc = ServiceRegistry._services.get("downloaded_version_history_service")
|
||||||
|
if history_svc is not None:
|
||||||
|
history_svc.close()
|
||||||
|
logger.info("Closed downloaded-version history database connection")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
||||||
|
|
||||||
|
# Skip settings.json, civitai, model cache and runtime cache folders
|
||||||
|
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups'])
|
||||||
|
|
||||||
# Extract ZIP to temp dir
|
# Extract ZIP to temp dir
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -222,16 +234,17 @@ class UpdateRoutes:
|
|||||||
# Find extracted folder (GitHub ZIP contains a root folder)
|
# Find extracted folder (GitHub ZIP contains a root folder)
|
||||||
extracted_root = next(os.scandir(tmp_dir)).path
|
extracted_root = next(os.scandir(tmp_dir)).path
|
||||||
|
|
||||||
# Copy files, skipping settings.json and civitai folder
|
# Copy files, skipping user data that should be preserved
|
||||||
|
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
|
||||||
for item in os.listdir(extracted_root):
|
for item in os.listdir(extracted_root):
|
||||||
if item == 'settings.json' or item == 'civitai':
|
if item in skip_items:
|
||||||
continue
|
continue
|
||||||
src = os.path.join(extracted_root, item)
|
src = os.path.join(extracted_root, item)
|
||||||
dst = os.path.join(plugin_root, item)
|
dst = os.path.join(plugin_root, item)
|
||||||
if os.path.isdir(src):
|
if os.path.isdir(src):
|
||||||
if os.path.exists(dst):
|
if os.path.exists(dst):
|
||||||
shutil.rmtree(dst)
|
shutil.rmtree(dst)
|
||||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai'))
|
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(*skip_items))
|
||||||
else:
|
else:
|
||||||
shutil.copy2(src, dst)
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
@@ -239,15 +252,17 @@ class UpdateRoutes:
|
|||||||
# for ComfyUI Manager to work properly
|
# for ComfyUI Manager to work properly
|
||||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||||
tracking_files = []
|
tracking_files = []
|
||||||
|
skip_tracked = {'civitai', 'wildcards', 'backups'}
|
||||||
for root, dirs, files in os.walk(extracted_root):
|
for root, dirs, files in os.walk(extracted_root):
|
||||||
# Skip civitai folder and its contents
|
# Skip user data directories and their contents
|
||||||
rel_root = os.path.relpath(root, extracted_root)
|
rel_root = os.path.relpath(root, extracted_root)
|
||||||
if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep):
|
top_dir = rel_root.split(os.sep)[0] if rel_root != '.' else ''
|
||||||
|
if top_dir in skip_tracked:
|
||||||
continue
|
continue
|
||||||
for file in files:
|
for file in files:
|
||||||
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
||||||
# Skip settings.json and any file under civitai
|
# Skip settings.json and any file under user data dirs
|
||||||
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep):
|
if rel_path == 'settings.json' or rel_path.split(os.sep)[0] in skip_tracked:
|
||||||
continue
|
continue
|
||||||
tracking_files.append(rel_path.replace("\\", "/"))
|
tracking_files.append(rel_path.replace("\\", "/"))
|
||||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||||
|
|||||||
@@ -14,12 +14,30 @@ from typing import Any, Dict, Optional, Tuple
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from .downloader import DownloadProgress, get_downloader
|
from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
|
||||||
from .aria2_transfer_state import Aria2TransferStateStore
|
from .aria2_transfer_state import Aria2TransferStateStore
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _try_certifi_ca_path() -> str | None:
|
||||||
|
"""Return the certifi CA bundle path if available, else None."""
|
||||||
|
try:
|
||||||
|
import certifi # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
path = certifi.where()
|
||||||
|
if os.path.isfile(path):
|
||||||
|
logger.debug(
|
||||||
|
"aria2 --ca-certificate: using certifi CA bundle at %s", path
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.debug("aria2 --ca-certificate: certifi not available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||||
"https://civitai.com/api/download/",
|
"https://civitai.com/api/download/",
|
||||||
"https://civitai.red/api/download/",
|
"https://civitai.red/api/download/",
|
||||||
@@ -39,7 +57,7 @@ class Aria2Transfer:
|
|||||||
|
|
||||||
|
|
||||||
class Aria2Downloader:
|
class Aria2Downloader:
|
||||||
"""Manage an aria2 RPC daemon for experimental model downloads."""
|
"""Manage an aria2 RPC daemon for recommended model downloads."""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_lock = asyncio.Lock()
|
_lock = asyncio.Lock()
|
||||||
@@ -391,6 +409,15 @@ class Aria2Downloader:
|
|||||||
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
||||||
)
|
)
|
||||||
except aiohttp.ClientError as exc:
|
except aiohttp.ClientError as exc:
|
||||||
|
if is_ssl_cert_verify_error(exc):
|
||||||
|
logger.error(
|
||||||
|
"SSL certificate verification failed during Civitai redirect "
|
||||||
|
"resolution for %s. This is usually caused by an outdated CA "
|
||||||
|
"certificate bundle. Recommended fixes:\n"
|
||||||
|
" 1. pip install --upgrade certifi\n"
|
||||||
|
" 2. pip install pip-system-certs",
|
||||||
|
url,
|
||||||
|
)
|
||||||
raise Aria2Error(
|
raise Aria2Error(
|
||||||
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -414,6 +441,11 @@ class Aria2Downloader:
|
|||||||
f"--rpc-listen-port={self._rpc_port}",
|
f"--rpc-listen-port={self._rpc_port}",
|
||||||
f"--rpc-secret={self._rpc_secret}",
|
f"--rpc-secret={self._rpc_secret}",
|
||||||
"--check-certificate=true",
|
"--check-certificate=true",
|
||||||
|
# Point aria2 at certifi's CA bundle when available so it uses
|
||||||
|
# the same certificate store as Python downloads.
|
||||||
|
*((
|
||||||
|
f"--ca-certificate={ca_cert}",
|
||||||
|
) if (ca_cert := _try_certifi_ca_path()) else ()),
|
||||||
"--allow-overwrite=true",
|
"--allow-overwrite=true",
|
||||||
"--auto-file-renaming=false",
|
"--auto-file-renaming=false",
|
||||||
"--file-allocation=none",
|
"--file-allocation=none",
|
||||||
|
|||||||
139
py/services/auto_tag_service.py
Normal file
139
py/services/auto_tag_service.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
|
||||||
|
Uses a two-layer approach:
|
||||||
|
Layer 1 — Regex-based detection against filename, base_model, and
|
||||||
|
CivitAI version name.
|
||||||
|
Layer 2 — Merge in any user-defined tags that overlap with known
|
||||||
|
auto-tag categories. This provides a manual fallback when
|
||||||
|
auto-detection fails (e.g. "I2V HN" or unlabeled models).
|
||||||
|
|
||||||
|
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),
|
||||||
|
tags (user-defined tag list, used as fallback).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
|
||||||
|
"""
|
||||||
|
sources = _collect_sources(model_data)
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
is_wan = "wan" in base_model.lower()
|
||||||
|
|
||||||
|
found: Set[str] = set()
|
||||||
|
|
||||||
|
# ── Layer 1: regex-based detection ────────────────────────────
|
||||||
|
if sources:
|
||||||
|
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
|
||||||
|
|
||||||
|
# ── Layer 2: user-defined tags as manual fallback ─────────────
|
||||||
|
# When auto-detection fails (abbreviated names like "Hi"/"Lo",
|
||||||
|
# "I2V HN", or unlabeled models), users can add canonical tags
|
||||||
|
# (HIGH, LOW, I2V, etc.) to the model's regular tags for correct
|
||||||
|
# badge display and filtering. Matching is case-insensitive so
|
||||||
|
# "high"/"High"/"HIGH" all resolve to the canonical label.
|
||||||
|
user_tags = model_data.get("tags")
|
||||||
|
if user_tags:
|
||||||
|
label_map = {label.lower(): label for label in AUTO_TAG_CATEGORIES}
|
||||||
|
for t in user_tags:
|
||||||
|
canonical = label_map.get(t.lower())
|
||||||
|
if canonical:
|
||||||
|
found.add(canonical)
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -861,22 +870,75 @@ class BaseModelService(ABC):
|
|||||||
"""Get the static preview URL for a model file"""
|
"""Get the static preview URL for a model file"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
name_normalized = model_name.replace("\\", "/")
|
||||||
|
name_no_ext = name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if name_no_ext.lower().endswith(ext):
|
||||||
|
name_no_ext = name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in name_no_ext
|
||||||
|
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model["file_name"] == model_name:
|
file_name = model.get("file_name", "")
|
||||||
|
folder = model.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||||
preview_url = model.get("preview_url")
|
preview_url = model.get("preview_url")
|
||||||
if preview_url:
|
if preview_url:
|
||||||
from ..config import config
|
from ..config import config
|
||||||
|
|
||||||
return config.get_preview_static_url(preview_url)
|
return config.get_preview_static_url(preview_url)
|
||||||
|
|
||||||
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = model
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = model
|
||||||
|
|
||||||
|
if best_fallback:
|
||||||
|
preview_url = best_fallback.get("preview_url")
|
||||||
|
if preview_url:
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
|
return config.get_preview_static_url(preview_url)
|
||||||
|
|
||||||
return "/loras_static/images/no-preview.png"
|
return "/loras_static/images/no-preview.png"
|
||||||
|
|
||||||
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||||
"""Get the Civitai URL for a model file"""
|
"""Get the Civitai URL for a model file"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
name_normalized = model_name.replace("\\", "/")
|
||||||
|
name_no_ext = name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if name_no_ext.lower().endswith(ext):
|
||||||
|
name_no_ext = name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in name_no_ext
|
||||||
|
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model["file_name"] == model_name:
|
file_name = model.get("file_name", "")
|
||||||
|
folder = model.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||||
civitai_data = model.get("civitai", {})
|
civitai_data = model.get("civitai", {})
|
||||||
model_id = civitai_data.get("modelId")
|
model_id = civitai_data.get("modelId")
|
||||||
version_id = civitai_data.get("id")
|
version_id = civitai_data.get("id")
|
||||||
@@ -895,6 +957,27 @@ class BaseModelService(ABC):
|
|||||||
"version_id": str(version_id) if version_id else None,
|
"version_id": str(version_id) if version_id else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = model
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = model
|
||||||
|
|
||||||
|
if best_fallback:
|
||||||
|
civitai_data = best_fallback.get("civitai", {})
|
||||||
|
model_id = civitai_data.get("modelId")
|
||||||
|
if model_id:
|
||||||
|
version_id = civitai_data.get("id")
|
||||||
|
civitai_host = self.settings.get("civitai_host", "civitai.com")
|
||||||
|
civitai_url = build_civitai_model_page_url(
|
||||||
|
model_id, version_id, host=civitai_host
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"civitai_url": civitai_url,
|
||||||
|
"model_id": str(model_id),
|
||||||
|
"version_id": str(version_id) if version_id else None,
|
||||||
|
}
|
||||||
|
|
||||||
return {"civitai_url": None, "model_id": None, "version_id": None}
|
return {"civitai_url": None, "model_id": None, "version_id": None}
|
||||||
|
|
||||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||||
@@ -908,6 +991,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:
|
||||||
|
|||||||
@@ -186,6 +186,22 @@ class CivArchiveClient:
|
|||||||
if "metadata" in file_data:
|
if "metadata" in file_data:
|
||||||
transformed["metadata"] = file_data["metadata"]
|
transformed["metadata"] = file_data["metadata"]
|
||||||
|
|
||||||
|
# Infer metadata.format from filename extension
|
||||||
|
name = transformed.get("name")
|
||||||
|
if name and isinstance(name, str):
|
||||||
|
lower_name = name.lower()
|
||||||
|
if lower_name.endswith(".safetensors"):
|
||||||
|
inferred_format = "SafeTensor"
|
||||||
|
elif lower_name.endswith(".ckpt"):
|
||||||
|
inferred_format = "PickleTensor"
|
||||||
|
else:
|
||||||
|
inferred_format = None
|
||||||
|
if inferred_format:
|
||||||
|
if "metadata" not in transformed:
|
||||||
|
transformed["metadata"] = {}
|
||||||
|
if isinstance(transformed["metadata"], dict):
|
||||||
|
transformed["metadata"].setdefault("format", inferred_format)
|
||||||
|
|
||||||
if file_data.get("modelVersionId") is not None:
|
if file_data.get("modelVersionId") is not None:
|
||||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||||
elif file_data.get("model_version_id") is not None:
|
elif file_data.get("model_version_id") is not None:
|
||||||
@@ -213,6 +229,20 @@ class CivArchiveClient:
|
|||||||
for file_data in candidates:
|
for file_data in candidates:
|
||||||
if isinstance(file_data, dict):
|
if isinstance(file_data, dict):
|
||||||
transformed_files.append(self._transform_file_entry(file_data))
|
transformed_files.append(self._transform_file_entry(file_data))
|
||||||
|
|
||||||
|
# Sort: .safetensors first, .ckpt second, others last
|
||||||
|
# so the backend fallback (no file_params) prefers safetensors
|
||||||
|
def _sort_key(f: Dict) -> int:
|
||||||
|
fname = f.get("name") or ""
|
||||||
|
if isinstance(fname, str):
|
||||||
|
lower = fname.lower()
|
||||||
|
if lower.endswith(".safetensors"):
|
||||||
|
return 0
|
||||||
|
elif lower.endswith(".ckpt"):
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
transformed_files.sort(key=_sort_key)
|
||||||
return transformed_files
|
return transformed_files
|
||||||
|
|
||||||
def _transform_version(
|
def _transform_version(
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||||
from .connectivity_guard import (
|
from .connectivity_guard import (
|
||||||
OFFLINE_FRIENDLY_MESSAGE,
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
@@ -45,6 +46,14 @@ class CivitaiClient:
|
|||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
self.base_url = "https://civitai.red/api/v1"
|
self.base_url = "https://civitai.red/api/v1"
|
||||||
|
# In-memory cache to avoid redundant get_model_version_info calls
|
||||||
|
# within the same import/scan flow. Only successful results are cached.
|
||||||
|
# Uses OrderedDict with LRU eviction at MAX_CACHE_ENTRIES to prevent
|
||||||
|
# unbounded growth in long-running server processes.
|
||||||
|
self._version_info_cache: OrderedDict[
|
||||||
|
str, Tuple[Optional[Dict], Optional[str]]
|
||||||
|
] = OrderedDict()
|
||||||
|
self._MAX_CACHE_ENTRIES = 500
|
||||||
|
|
||||||
def _build_image_info_url(self, image_id: str) -> str:
|
def _build_image_info_url(self, image_id: str) -> str:
|
||||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||||
@@ -57,22 +66,57 @@ class CivitaiClient:
|
|||||||
use_auth: bool = False,
|
use_auth: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Tuple[bool, Dict | str]:
|
) -> Tuple[bool, Dict | str]:
|
||||||
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
"""Wrapper around downloader.make_request that surfaces rate limits,
|
||||||
|
with retry for transient server errors (5xx, Cloudflare 524, network flakiness)."""
|
||||||
|
|
||||||
downloader = await get_downloader()
|
max_retries = 3
|
||||||
success, result = await downloader.make_request(
|
for attempt in range(max_retries):
|
||||||
method,
|
downloader = await get_downloader()
|
||||||
url,
|
success, result = await downloader.make_request(
|
||||||
use_auth=use_auth,
|
method,
|
||||||
**kwargs,
|
url,
|
||||||
)
|
use_auth=use_auth,
|
||||||
if not success and isinstance(result, RateLimitError):
|
**kwargs,
|
||||||
if result.provider is None:
|
)
|
||||||
result.provider = "civitai_api"
|
if success:
|
||||||
raise result
|
return True, result
|
||||||
if not success and is_offline_cooldown_error(result):
|
|
||||||
return False, OFFLINE_FRIENDLY_MESSAGE
|
if isinstance(result, RateLimitError):
|
||||||
return success, result
|
if result.provider is None:
|
||||||
|
result.provider = "civitai_api"
|
||||||
|
raise result
|
||||||
|
|
||||||
|
if is_offline_cooldown_error(result):
|
||||||
|
return False, OFFLINE_FRIENDLY_MESSAGE
|
||||||
|
|
||||||
|
# Transient server error — retry with exponential backoff
|
||||||
|
if self._is_transient_server_error(str(result)):
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
wait = 2**attempt # 1s, 2s, 4s
|
||||||
|
logger.info(
|
||||||
|
"Transient error on %s %s, retrying in %ds "
|
||||||
|
"(attempt %d/%d): %s",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
wait,
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
logger.warning(
|
||||||
|
"All %d retries exhausted for %s %s: %s",
|
||||||
|
max_retries,
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return False, result
|
||||||
|
|
||||||
|
return False, result
|
||||||
|
|
||||||
|
return False, "Unexpected error in _make_request"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
||||||
@@ -201,6 +245,29 @@ class CivitaiClient:
|
|||||||
|
|
||||||
return _from_value(payload)
|
return _from_value(payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_transient_server_error(message: str) -> bool:
|
||||||
|
"""Return True when the message indicates a transient upstream failure.
|
||||||
|
|
||||||
|
Recognises Cloudflare 524, generic 5xx, and connectivity-level flakiness
|
||||||
|
that should not be treated as a permanent failure.
|
||||||
|
"""
|
||||||
|
normalized = message.lower()
|
||||||
|
if "status 5" in normalized or "status 524" in normalized:
|
||||||
|
return True
|
||||||
|
if any(
|
||||||
|
keyword in normalized
|
||||||
|
for keyword in (
|
||||||
|
"connection refused",
|
||||||
|
"connection reset",
|
||||||
|
"temporary failure",
|
||||||
|
"name resolution",
|
||||||
|
"connection closed",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
"""Get all versions of a model with local availability info"""
|
"""Get all versions of a model with local availability info"""
|
||||||
try:
|
try:
|
||||||
@@ -223,6 +290,13 @@ class CivitaiClient:
|
|||||||
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
||||||
return None
|
return None
|
||||||
if message:
|
if message:
|
||||||
|
if self._is_transient_server_error(message):
|
||||||
|
logger.info(
|
||||||
|
"Transient server error for model %s: %s",
|
||||||
|
model_id,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
return None
|
||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
return None
|
return None
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
@@ -257,7 +331,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
|
||||||
@@ -336,6 +410,25 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
target_version = self._select_target_version(model_data, model_id, version_id)
|
target_version = self._select_target_version(model_data, model_id, version_id)
|
||||||
|
|
||||||
|
# If modelVersions is empty (e.g. CivitAI cache lag for newly published
|
||||||
|
# models) but a specific version_id is known, fall back to fetching the
|
||||||
|
# version directly via the individual model-versions endpoint, then
|
||||||
|
# enrich it with the model-level data we already have.
|
||||||
|
if target_version is None and version_id is not None:
|
||||||
|
logger.info(
|
||||||
|
"modelVersions empty for model %s; falling back to direct "
|
||||||
|
"version lookup for %s",
|
||||||
|
model_id,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
version = await self._fetch_version_by_id(version_id)
|
||||||
|
if version:
|
||||||
|
self._enrich_version_with_model_data(version, model_data)
|
||||||
|
self._remove_comfy_metadata(version)
|
||||||
|
return version
|
||||||
|
return None
|
||||||
|
|
||||||
if target_version is None:
|
if target_version is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -482,6 +575,14 @@ class CivitaiClient:
|
|||||||
- The model version data or None if not found
|
- The model version data or None if not found
|
||||||
- An error message if there was an error, or None on success
|
- An error message if there was an error, or None on success
|
||||||
"""
|
"""
|
||||||
|
# In-memory cache avoids redundant API calls within the same
|
||||||
|
# import/scan flow (e.g. _resolve_base_model_from_checkpoint
|
||||||
|
# followed by _resolve_and_populate_checkpoint with the same id).
|
||||||
|
if version_id in self._version_info_cache:
|
||||||
|
logger.debug("Cache hit for model version info: %s", version_id)
|
||||||
|
self._version_info_cache.move_to_end(version_id) # LRU bump
|
||||||
|
return self._version_info_cache[version_id]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f"{self.base_url}/model-versions/{version_id}"
|
url = f"{self.base_url}/model-versions/{version_id}"
|
||||||
|
|
||||||
@@ -491,6 +592,11 @@ class CivitaiClient:
|
|||||||
if success:
|
if success:
|
||||||
logger.debug("Successfully fetched model version info for: %s", version_id)
|
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||||
self._remove_comfy_metadata(result)
|
self._remove_comfy_metadata(result)
|
||||||
|
self._version_info_cache[version_id] = (result, None)
|
||||||
|
self._version_info_cache.move_to_end(version_id)
|
||||||
|
# Evict oldest entry when over capacity
|
||||||
|
if len(self._version_info_cache) > self._MAX_CACHE_ENTRIES:
|
||||||
|
self._version_info_cache.popitem(last=False)
|
||||||
return result, None
|
return result, None
|
||||||
|
|
||||||
# Handle specific error cases
|
# Handle specific error cases
|
||||||
@@ -532,6 +638,13 @@ class CivitaiClient:
|
|||||||
if not success:
|
if not success:
|
||||||
if is_expected_offline_error(result):
|
if is_expected_offline_error(result):
|
||||||
return None
|
return None
|
||||||
|
if self._is_transient_server_error(str(result)):
|
||||||
|
logger.info(
|
||||||
|
"Transient server error fetching image info for ID %s: %s",
|
||||||
|
image_id,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return None
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to fetch image info for ID %s from civitai.red: %s",
|
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||||
image_id,
|
image_id,
|
||||||
@@ -577,6 +690,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 +753,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:
|
||||||
|
|||||||
@@ -110,6 +110,23 @@ class DownloadCoordinator:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def skip_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
|
"""Skip a download while preserving all partial files on disk."""
|
||||||
|
download_manager = await self._download_manager_factory()
|
||||||
|
result = await download_manager.skip_download(download_id)
|
||||||
|
|
||||||
|
await self._ws_manager.broadcast_download_progress(
|
||||||
|
download_id,
|
||||||
|
{
|
||||||
|
"status": "skipped",
|
||||||
|
"progress": 0,
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": "Download skipped by user (partial files preserved)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
"""Pause an active download and notify listeners."""
|
"""Pause an active download and notify listeners."""
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from ..utils.constants import (
|
|||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
|
from ..utils.file_utils import calculate_sha256
|
||||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||||
from ..utils.utils import sanitize_folder_name
|
from ..utils.utils import sanitize_folder_name
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
@@ -2239,8 +2240,11 @@ class DownloadManager:
|
|||||||
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
# Update size to actual downloaded file size
|
# Update size to actual downloaded file size
|
||||||
entry.size = os.path.getsize(file_path)
|
entry.size = os.path.getsize(file_path)
|
||||||
# Use SHA256 from API metadata (already set in from_civitai_info)
|
# Compute SHA256 locally when the API response didn't include it
|
||||||
# Do not recalculate to avoid blocking during ComfyUI execution
|
if not entry.sha256:
|
||||||
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
if sha256:
|
||||||
|
entry.sha256 = sha256.lower()
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
@@ -2400,6 +2404,89 @@ class DownloadManager:
|
|||||||
self._download_tasks.pop(download_id, None)
|
self._download_tasks.pop(download_id, None)
|
||||||
await self._aria2_state_store.remove(download_id)
|
await self._aria2_state_store.remove(download_id)
|
||||||
|
|
||||||
|
async def skip_download(self, download_id: str) -> Dict:
|
||||||
|
"""Skip a download while preserving all partial files on disk.
|
||||||
|
|
||||||
|
Removes all in-memory tracking (asyncio task, semaphore, active/pause
|
||||||
|
state) but keeps partial files (.part / .aria2) on disk so that a
|
||||||
|
subsequent download-model-get request for the same save path can
|
||||||
|
auto-resume from the preserved partial download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_id: The unique identifier of the download task
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: Status of the skip operation
|
||||||
|
"""
|
||||||
|
await self._restore_persisted_downloads()
|
||||||
|
|
||||||
|
if download_id not in self._download_tasks and download_id not in self._active_downloads:
|
||||||
|
return {"success": False, "error": "Download task not found"}
|
||||||
|
|
||||||
|
download_info = self._active_downloads.get(download_id)
|
||||||
|
task = self._download_tasks.get(download_id)
|
||||||
|
active_statuses = {"queued", "waiting", "downloading", "paused", "cancelling"}
|
||||||
|
if task is None and (
|
||||||
|
not isinstance(download_info, dict)
|
||||||
|
or download_info.get("status") not in active_statuses
|
||||||
|
):
|
||||||
|
return {"success": False, "error": "Download task not found"}
|
||||||
|
|
||||||
|
backend = (
|
||||||
|
self._active_downloads.get(download_id, {}).get("transfer_backend")
|
||||||
|
or "python"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# For aria2: pause the transfer rather than force-removing it, so
|
||||||
|
# the .aria2 control file stays on disk for future resume
|
||||||
|
if backend == "aria2":
|
||||||
|
try:
|
||||||
|
aria2_downloader = await get_aria2_downloader()
|
||||||
|
pause_result = await aria2_downloader.pause_download(download_id)
|
||||||
|
if not pause_result.get("success"):
|
||||||
|
logger.warning(
|
||||||
|
"Failed to pause aria2 transfer for %s during skip: %s",
|
||||||
|
download_id,
|
||||||
|
pause_result.get("error"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to pause aria2 transfer for %s during skip: %s",
|
||||||
|
download_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel the asyncio task so the semaphore slot is released
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Resume pause event so the task can exit cleanly
|
||||||
|
pause_control = self._pause_events.get(download_id)
|
||||||
|
if pause_control is not None:
|
||||||
|
pause_control.resume()
|
||||||
|
|
||||||
|
# Wait briefly for task to acknowledge cancellation
|
||||||
|
if task is not None:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"Download skipped for task {download_id} (partial files preserved)")
|
||||||
|
return {"success": True, "message": "Download skipped successfully"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error skipping download: {e}", exc_info=True)
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
finally:
|
||||||
|
# Clean up local in-memory tracking only - NO file deletion
|
||||||
|
self._pause_events.pop(download_id, None)
|
||||||
|
self._download_tasks.pop(download_id, None)
|
||||||
|
if download_id in self._active_downloads:
|
||||||
|
del self._active_downloads[download_id]
|
||||||
|
# Preserve aria2 state store entry so the partial download
|
||||||
|
# info survives restarts and can be resumed later
|
||||||
|
|
||||||
async def pause_download(self, download_id: str) -> Dict:
|
async def pause_download(self, download_id: str) -> Dict:
|
||||||
"""Pause an active download without losing progress."""
|
"""Pause an active download without losing progress."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -89,6 +96,21 @@ class DownloadedVersionHistoryService:
|
|||||||
def get_database_path(self) -> str:
|
def get_database_path(self) -> str:
|
||||||
return self._db_path
|
return self._db_path
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the persistent SQLite connection, if open.
|
||||||
|
|
||||||
|
This is called before plugin update operations to release the
|
||||||
|
database file lock on Windows, allowing ``shutil.rmtree()`` to
|
||||||
|
succeed when the cache resides inside the plugin directory.
|
||||||
|
"""
|
||||||
|
if self._conn is not None:
|
||||||
|
try:
|
||||||
|
self._conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
def _get_active_library_name(self) -> str | None:
|
def _get_active_library_name(self) -> str | None:
|
||||||
try:
|
try:
|
||||||
value = self._settings.get_active_library_name()
|
value = self._settings.get_active_library_name()
|
||||||
@@ -116,33 +138,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 +202,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 +230,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 +260,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 +280,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 +313,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:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This module provides a centralized download service with:
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ssl
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -31,6 +32,20 @@ from .errors import RateLimitError
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_ssl_cert_verify_error(exc: BaseException) -> bool:
|
||||||
|
"""Check if an exception represents an SSL certificate verification failure.
|
||||||
|
|
||||||
|
Matches ``ssl.SSLCertVerificationError``, ``aiohttp.ClientConnectorCertificateError``
|
||||||
|
(which wraps the former), and falls back to the standard OpenSSL error text.
|
||||||
|
"""
|
||||||
|
if isinstance(exc, ssl.SSLCertVerificationError):
|
||||||
|
return True
|
||||||
|
cert_error = getattr(exc, "certificate_error", None)
|
||||||
|
if isinstance(cert_error, ssl.SSLCertVerificationError):
|
||||||
|
return True
|
||||||
|
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DownloadProgress:
|
class DownloadProgress:
|
||||||
"""Snapshot of a download transfer at a moment in time."""
|
"""Snapshot of a download transfer at a moment in time."""
|
||||||
@@ -265,9 +280,22 @@ class Downloader:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
|
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
|
||||||
)
|
)
|
||||||
|
# Build SSL context: prefer certifi's CA bundle for broader
|
||||||
|
# CA coverage across different Python environments (especially
|
||||||
|
# embedded/compatibility Python builds).
|
||||||
|
try:
|
||||||
|
import certifi # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
ca_path = certifi.where()
|
||||||
|
ssl_context = ssl.create_default_context(cafile=ca_path)
|
||||||
|
logger.debug("SSL: using certifi CA bundle at %s", ca_path)
|
||||||
|
except (ImportError, FileNotFoundError, ValueError, OSError):
|
||||||
|
ssl_context = ssl.create_default_context()
|
||||||
|
logger.debug("SSL: certifi unavailable; using system default CA bundle")
|
||||||
|
|
||||||
# Optimize TCP connection parameters
|
# Optimize TCP connection parameters
|
||||||
connector = aiohttp.TCPConnector(
|
connector = aiohttp.TCPConnector(
|
||||||
ssl=True,
|
ssl=ssl_context,
|
||||||
limit=8, # Concurrent connections
|
limit=8, # Concurrent connections
|
||||||
ttl_dns_cache=300, # DNS cache timeout
|
ttl_dns_cache=300, # DNS cache timeout
|
||||||
force_close=False, # Keep connections for reuse
|
force_close=False, # Keep connections for reuse
|
||||||
@@ -736,6 +764,17 @@ class Downloader:
|
|||||||
DownloadRestartRequested,
|
DownloadRestartRequested,
|
||||||
) as e:
|
) as e:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
|
|
||||||
|
if is_ssl_cert_verify_error(e):
|
||||||
|
logger.error(
|
||||||
|
"SSL certificate verification failed when connecting to %s. "
|
||||||
|
"This is usually caused by an outdated CA certificate bundle "
|
||||||
|
"in the Python environment. Recommended fixes:\n"
|
||||||
|
" 1. pip install --upgrade certifi\n"
|
||||||
|
" 2. pip install pip-system-certs",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]:
|
||||||
@@ -310,8 +312,23 @@ class LoraService(BaseModelService):
|
|||||||
"""Return cached raw metadata for a LoRA matching the given filename."""
|
"""Return cached raw metadata for a LoRA matching the given filename."""
|
||||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||||
|
|
||||||
|
fn_normalized = filename.replace("\\", "/")
|
||||||
|
fn_no_ext = fn_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if fn_no_ext.lower().endswith(ext):
|
||||||
|
fn_no_ext = fn_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
for lora in cache.raw_data if cache else []:
|
for lora in cache.raw_data if cache else []:
|
||||||
if lora.get("file_name") == filename:
|
file_name = lora.get("file_name", "")
|
||||||
|
folder = lora.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
if fn_no_ext in (file_name_no_ext, path_name):
|
||||||
return lora
|
return lora
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -399,7 +416,10 @@ class LoraService(BaseModelService):
|
|||||||
locked_loras = locked_loras[:target_count]
|
locked_loras = locked_loras[:target_count]
|
||||||
|
|
||||||
# Filter out locked LoRAs from available pool
|
# Filter out locked LoRAs from available pool
|
||||||
locked_names = {lora["name"] for lora in locked_loras}
|
locked_names = {
|
||||||
|
os.path.basename(lora["name"]) if "/" in str(lora.get("name", "")) else lora["name"]
|
||||||
|
for lora in locked_loras
|
||||||
|
}
|
||||||
available_pool = [
|
available_pool = [
|
||||||
l for l in available_loras if l["file_name"] not in locked_names
|
l for l in available_loras if l["file_name"] not in locked_names
|
||||||
]
|
]
|
||||||
@@ -454,7 +474,7 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
result_loras.append(
|
result_loras.append(
|
||||||
{
|
{
|
||||||
"name": lora["file_name"],
|
"name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||||
"strength": model_str,
|
"strength": model_str,
|
||||||
"clipStrength": clip_str,
|
"clipStrength": clip_str,
|
||||||
"active": True,
|
"active": True,
|
||||||
@@ -670,8 +690,9 @@ class LoraService(BaseModelService):
|
|||||||
# Return minimal data needed for cycling
|
# Return minimal data needed for cycling
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"file_name": lora["file_name"],
|
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||||
"model_name": lora.get("model_name", lora["file_name"]),
|
"model_name": lora.get("model_name", lora["file_name"]),
|
||||||
|
"folder": lora.get("folder", ""),
|
||||||
}
|
}
|
||||||
for lora in available_loras
|
for lora in available_loras
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ModelHashIndex:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._hash_to_path: Dict[str, str] = {}
|
self._hash_to_path: Dict[str, str] = {}
|
||||||
self._filename_to_hash: Dict[str, str] = {}
|
self._filename_to_hash: Dict[str, str] = {}
|
||||||
|
self._autov2_to_path: Dict[str, str] = {}
|
||||||
# New data structures for tracking duplicates
|
# New data structures for tracking duplicates
|
||||||
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
||||||
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
||||||
@@ -63,6 +64,9 @@ class ModelHashIndex:
|
|||||||
# Add new mappings
|
# Add new mappings
|
||||||
self._hash_to_path[sha256] = file_path
|
self._hash_to_path[sha256] = file_path
|
||||||
self._filename_to_hash[filename] = sha256
|
self._filename_to_hash[filename] = sha256
|
||||||
|
# AutoV2 = first 10 chars of SHA256
|
||||||
|
if len(sha256) >= 10:
|
||||||
|
self._autov2_to_path[sha256[:10]] = file_path
|
||||||
|
|
||||||
def _get_filename_from_path(self, file_path: str) -> str:
|
def _get_filename_from_path(self, file_path: str) -> str:
|
||||||
"""Extract filename without extension from path"""
|
"""Extract filename without extension from path"""
|
||||||
@@ -157,7 +161,12 @@ class ModelHashIndex:
|
|||||||
del self._duplicate_filenames[filename]
|
del self._duplicate_filenames[filename]
|
||||||
if filename in self._filename_to_hash:
|
if filename in self._filename_to_hash:
|
||||||
del self._filename_to_hash[filename]
|
del self._filename_to_hash[filename]
|
||||||
|
|
||||||
|
# Remove from AutoV2 index
|
||||||
|
autov2_keys_to_remove = [k for k, v in self._autov2_to_path.items() if v == file_path]
|
||||||
|
for k in autov2_keys_to_remove:
|
||||||
|
del self._autov2_to_path[k]
|
||||||
|
|
||||||
def remove_by_hash(self, sha256: str) -> None:
|
def remove_by_hash(self, sha256: str) -> None:
|
||||||
"""Remove entry by hash"""
|
"""Remove entry by hash"""
|
||||||
sha256 = sha256.lower()
|
sha256 = sha256.lower()
|
||||||
@@ -177,6 +186,10 @@ class ModelHashIndex:
|
|||||||
# Remove hash-to-path mapping
|
# Remove hash-to-path mapping
|
||||||
del self._hash_to_path[sha256]
|
del self._hash_to_path[sha256]
|
||||||
|
|
||||||
|
autov2_key = sha256[:10]
|
||||||
|
if autov2_key in self._autov2_to_path:
|
||||||
|
del self._autov2_to_path[autov2_key]
|
||||||
|
|
||||||
# Update filename-to-hash and duplicate filenames for all paths
|
# Update filename-to-hash and duplicate filenames for all paths
|
||||||
for path_to_remove in paths_to_remove:
|
for path_to_remove in paths_to_remove:
|
||||||
fname = self._get_filename_from_path(path_to_remove)
|
fname = self._get_filename_from_path(path_to_remove)
|
||||||
@@ -195,13 +208,24 @@ class ModelHashIndex:
|
|||||||
# If only one entry remains, it's no longer a duplicate
|
# If only one entry remains, it's no longer a duplicate
|
||||||
del self._duplicate_filenames[fname]
|
del self._duplicate_filenames[fname]
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, hash_value: str) -> bool:
|
||||||
"""Check if hash exists in index"""
|
"""Check if hash exists in index (SHA256 or AutoV2)"""
|
||||||
return sha256.lower() in self._hash_to_path
|
normalized = hash_value.lower()
|
||||||
|
if normalized in self._hash_to_path:
|
||||||
def get_path(self, sha256: str) -> Optional[str]:
|
return True
|
||||||
"""Get file path for a hash"""
|
if len(normalized) == 10:
|
||||||
return self._hash_to_path.get(sha256.lower())
|
return normalized in self._autov2_to_path
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_path(self, hash_value: str) -> Optional[str]:
|
||||||
|
"""Get file path for a hash (SHA256 or AutoV2)"""
|
||||||
|
normalized = hash_value.lower()
|
||||||
|
path = self._hash_to_path.get(normalized)
|
||||||
|
if path is not None:
|
||||||
|
return path
|
||||||
|
if len(normalized) == 10:
|
||||||
|
return self._autov2_to_path.get(normalized)
|
||||||
|
return None
|
||||||
|
|
||||||
def get_hash(self, file_path: str) -> Optional[str]:
|
def get_hash(self, file_path: str) -> Optional[str]:
|
||||||
"""Get hash for a file path"""
|
"""Get hash for a file path"""
|
||||||
@@ -209,13 +233,16 @@ class ModelHashIndex:
|
|||||||
return self._filename_to_hash.get(filename)
|
return self._filename_to_hash.get(filename)
|
||||||
|
|
||||||
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
||||||
"""Get hash for a filename without extension"""
|
"""Get hash for a filename (bare basename or path-prefixed name)"""
|
||||||
|
if "/" in filename or "\\" in filename:
|
||||||
|
filename = os.path.splitext(os.path.basename(filename.replace("\\", "/")))[0]
|
||||||
return self._filename_to_hash.get(filename)
|
return self._filename_to_hash.get(filename)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all entries"""
|
"""Clear all entries"""
|
||||||
self._hash_to_path.clear()
|
self._hash_to_path.clear()
|
||||||
self._filename_to_hash.clear()
|
self._filename_to_hash.clear()
|
||||||
|
self._autov2_to_path.clear()
|
||||||
self._duplicate_hashes.clear()
|
self._duplicate_hashes.clear()
|
||||||
self._duplicate_filenames.clear()
|
self._duplicate_filenames.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
import random
|
import random
|
||||||
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
||||||
from .downloader import get_downloader
|
from .downloader import get_downloader
|
||||||
from .errors import RateLimitError
|
from .errors import RateLimitError, ResourceNotFoundError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
@@ -108,6 +108,18 @@ class ModelMetadataProvider(ABC):
|
|||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict]]:
|
||||||
"""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]:
|
||||||
@@ -140,6 +152,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
|||||||
self, model_ids: Sequence[int]
|
self, model_ids: Sequence[int]
|
||||||
) -> 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)
|
||||||
@@ -465,6 +482,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
return None, "Model not found"
|
return None, "Model not found"
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
not_found_confirmed = False
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result = await self._call_with_rate_limit(
|
result = await self._call_with_rate_limit(
|
||||||
@@ -475,8 +493,24 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
|
if not_found_confirmed:
|
||||||
|
logger.debug(
|
||||||
|
"Suppressing rate limit from %s for model %s: "
|
||||||
|
"already confirmed as not found by another provider",
|
||||||
|
label,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
exc.provider = exc.provider or label
|
exc.provider = exc.provider or label
|
||||||
raise exc
|
raise exc
|
||||||
|
except ResourceNotFoundError:
|
||||||
|
not_found_confirmed = True
|
||||||
|
logger.debug(
|
||||||
|
"Provider %s reports model %s as not found",
|
||||||
|
label,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
||||||
continue
|
continue
|
||||||
@@ -519,6 +553,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 +653,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 +738,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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Set,
|
|||||||
|
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.file_utils import find_preview_file, get_preview_extension
|
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.civitai_utils import resolve_license_info
|
from ..utils.civitai_utils import resolve_license_info
|
||||||
from .model_cache import ModelCache
|
from .model_cache import ModelCache
|
||||||
@@ -1067,6 +1067,19 @@ class ModelScanner:
|
|||||||
|
|
||||||
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
||||||
|
|
||||||
|
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
|
||||||
|
if not model_data.get('sha256') and file_path:
|
||||||
|
try:
|
||||||
|
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
||||||
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
if sha256:
|
||||||
|
model_data['sha256'] = sha256.lower()
|
||||||
|
if isinstance(metadata, BaseModelMetadata):
|
||||||
|
metadata.sha256 = sha256.lower()
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to compute SHA256 for {file_path}: {e}")
|
||||||
|
|
||||||
# Skip excluded models
|
# Skip excluded models
|
||||||
if model_data.get('exclude', False):
|
if model_data.get('exclude', False):
|
||||||
excluded_models.append(model_data['file_path'])
|
excluded_models.append(model_data['file_path'])
|
||||||
@@ -1101,7 +1114,15 @@ class ModelScanner:
|
|||||||
|
|
||||||
def _log_duplicate_filename_summary(self) -> None:
|
def _log_duplicate_filename_summary(self) -> None:
|
||||||
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
||||||
if self._hash_index is None:
|
# Duplicate filename detection is only relevant for LoRAs, which use
|
||||||
|
# basename-only syntax (<lora:name:strength>). Checkpoints and embeddings
|
||||||
|
# use full relative paths for resolution, so conflicts are not ambiguous.
|
||||||
|
if self._hash_index is None or self.model_type != "lora":
|
||||||
|
return
|
||||||
|
|
||||||
|
# When full path syntax is active, duplicate filenames across subfolders
|
||||||
|
# are fully qualified, so there is no ambiguity — skip the warning.
|
||||||
|
if get_settings_manager().get("lora_syntax_format", "legacy") == "full":
|
||||||
return
|
return
|
||||||
|
|
||||||
duplicates = self._hash_index.get_duplicate_filenames()
|
duplicates = self._hash_index.get_duplicate_filenames()
|
||||||
@@ -1473,6 +1494,15 @@ class ModelScanner:
|
|||||||
file_path_override=normalized_new_path,
|
file_path_override=normalized_new_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Ensure sha256 is populated even when metadata doesn't have it
|
||||||
|
if not cache_entry.get('sha256') and normalized_new_path and os.path.exists(normalized_new_path):
|
||||||
|
try:
|
||||||
|
sha256 = await calculate_sha256(normalized_new_path)
|
||||||
|
if sha256:
|
||||||
|
cache_entry['sha256'] = sha256.lower()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to compute SHA256 for {normalized_new_path}: {e}")
|
||||||
|
|
||||||
if recalculate_type:
|
if recalculate_type:
|
||||||
cache_entry = self.adjust_cached_entry(cache_entry)
|
cache_entry = self.adjust_cached_entry(cache_entry)
|
||||||
|
|
||||||
@@ -1572,12 +1602,39 @@ class ModelScanner:
|
|||||||
"""Get model information by name"""
|
"""Get model information by name"""
|
||||||
try:
|
try:
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
|
name_normalized = name.replace("\\", "/")
|
||||||
|
name_no_ext = name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if name_no_ext.lower().endswith(ext):
|
||||||
|
name_no_ext = name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in name_no_ext
|
||||||
|
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for model in cache.raw_data:
|
for model in cache.raw_data:
|
||||||
if model.get("file_name") == name:
|
file_name = model.get("file_name", "")
|
||||||
|
folder = model.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||||
return model
|
return model
|
||||||
|
|
||||||
return None
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = model
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = model
|
||||||
|
|
||||||
|
return best_fallback
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ModelVersionRecord:
|
|||||||
early_access_ends_at: Optional[str] = None
|
early_access_ends_at: Optional[str] = None
|
||||||
sort_index: int = 0
|
sort_index: int = 0
|
||||||
is_early_access: bool = False
|
is_early_access: bool = False
|
||||||
|
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
|
|||||||
|
|
||||||
return [version.version_id for version in self.versions if version.is_in_library]
|
return [version.version_id for version in self.versions if version.is_in_library]
|
||||||
|
|
||||||
def has_update(self, hide_early_access: bool = False) -> bool:
|
def has_update(
|
||||||
|
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
|
||||||
|
) -> bool:
|
||||||
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
hide_early_access: If True, exclude early access versions from update check.
|
||||||
|
hide_non_downloadable: If True, exclude versions that don't allow downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.should_ignore_model:
|
if self.should_ignore_model:
|
||||||
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
|
|||||||
not version.is_in_library
|
not version.is_in_library
|
||||||
and not version.should_ignore
|
and not version.should_ignore
|
||||||
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
||||||
|
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
|
||||||
for version in self.versions
|
for version in self.versions
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
|
continue
|
||||||
if version.version_id > max_in_library:
|
if version.version_id > max_in_library:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
|
|||||||
# Phase 1: Basic EA flag from bulk API
|
# Phase 1: Basic EA flag from bulk API
|
||||||
return version.is_early_access
|
return version.is_early_access
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_downloadable(version: ModelVersionRecord) -> bool:
|
||||||
|
if version.usage_control is None:
|
||||||
|
return True
|
||||||
|
return version.usage_control == "Download"
|
||||||
|
|
||||||
def has_update_for_base(
|
def has_update_for_base(
|
||||||
self,
|
self,
|
||||||
local_version_id: Optional[int],
|
local_version_id: Optional[int],
|
||||||
local_base_model: Optional[str],
|
local_base_model: Optional[str],
|
||||||
hide_early_access: bool = False,
|
hide_early_access: bool = False,
|
||||||
|
hide_non_downloadable: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True when a newer remote version with the same base model exists.
|
"""Return True when a newer remote version with the same base model exists.
|
||||||
|
|
||||||
@@ -167,6 +181,7 @@ class ModelUpdateRecord:
|
|||||||
local_version_id: The current local version id.
|
local_version_id: The current local version id.
|
||||||
local_base_model: The base model to filter by.
|
local_base_model: The base model to filter by.
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
hide_early_access: If True, exclude early access versions from update check.
|
||||||
|
hide_non_downloadable: If True, exclude versions that don't allow downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.should_ignore_model:
|
if self.should_ignore_model:
|
||||||
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
|
continue
|
||||||
version_base = _normalize_base_model(version.base_model)
|
version_base = _normalize_base_model(version.base_model)
|
||||||
if version_base != normalized_base:
|
if version_base != normalized_base:
|
||||||
continue
|
continue
|
||||||
@@ -230,6 +247,7 @@ class ModelUpdateService:
|
|||||||
preview_url TEXT,
|
preview_url TEXT,
|
||||||
is_in_library INTEGER NOT NULL DEFAULT 0,
|
is_in_library INTEGER NOT NULL DEFAULT 0,
|
||||||
should_ignore INTEGER NOT NULL DEFAULT 0,
|
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||||
|
usage_control TEXT,
|
||||||
PRIMARY KEY (model_id, version_id),
|
PRIMARY KEY (model_id, version_id),
|
||||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -465,6 +483,10 @@ class ModelUpdateService:
|
|||||||
"ALTER TABLE model_update_versions "
|
"ALTER TABLE model_update_versions "
|
||||||
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
||||||
),
|
),
|
||||||
|
"usage_control": (
|
||||||
|
"ALTER TABLE model_update_versions "
|
||||||
|
"ADD COLUMN usage_control TEXT"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, statement in migrations.items():
|
for column, statement in migrations.items():
|
||||||
@@ -667,6 +689,7 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
|
folder_path: Optional[str] = None,
|
||||||
) -> Dict[int, ModelUpdateRecord]:
|
) -> Dict[int, ModelUpdateRecord]:
|
||||||
"""Refresh update information for every model present in the cache."""
|
"""Refresh update information for every model present in the cache."""
|
||||||
scanner.reset_cancellation()
|
scanner.reset_cancellation()
|
||||||
@@ -681,6 +704,7 @@ class ModelUpdateService:
|
|||||||
local_versions = await self._collect_local_versions(
|
local_versions = await self._collect_local_versions(
|
||||||
scanner,
|
scanner,
|
||||||
target_model_ids=target_filter,
|
target_model_ids=target_filter,
|
||||||
|
folder_path=folder_path,
|
||||||
)
|
)
|
||||||
total_models = len(local_versions)
|
total_models = len(local_versions)
|
||||||
if total_models == 0:
|
if total_models == 0:
|
||||||
@@ -967,18 +991,22 @@ 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:
|
||||||
fallback_error_message = str(exc) or "resource not found"
|
fallback_error_message = str(exc) or "resource not found"
|
||||||
mark_model_as_ignored = True
|
mark_model_as_ignored = True
|
||||||
except Exception as exc: # pragma: no cover - defensive log
|
except Exception as exc: # pragma: no cover - defensive log
|
||||||
logger.error(
|
logger.warning(
|
||||||
"Failed to fetch versions for model %s (%s): %s",
|
"Failed to fetch versions for model %s (%s): %s",
|
||||||
model_id,
|
model_id,
|
||||||
model_type,
|
model_type,
|
||||||
exc,
|
exc,
|
||||||
exc_info=True,
|
|
||||||
)
|
)
|
||||||
fallback_error_message = str(exc)
|
fallback_error_message = str(exc)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
@@ -1061,6 +1089,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,
|
||||||
@@ -1112,6 +1270,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(
|
||||||
@@ -1119,6 +1278,7 @@ class ModelUpdateService:
|
|||||||
scanner,
|
scanner,
|
||||||
*,
|
*,
|
||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
|
folder_path: Optional[str] = None,
|
||||||
) -> Dict[int, List[int]]:
|
) -> Dict[int, List[int]]:
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
mapping: Dict[int, set[int]] = {}
|
mapping: Dict[int, set[int]] = {}
|
||||||
@@ -1131,7 +1291,19 @@ class ModelUpdateService:
|
|||||||
if not target_set:
|
if not target_set:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
normalized_folder = None
|
||||||
|
if folder_path is not None:
|
||||||
|
normalized_folder = folder_path.replace("\\", "/").strip("/")
|
||||||
|
|
||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
|
# Apply folder filter first (cheapest check)
|
||||||
|
if normalized_folder is not None:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_folder = (item.get("folder") or "").replace("\\", "/").strip("/")
|
||||||
|
if item_folder != normalized_folder and not item_folder.startswith(normalized_folder + "/"):
|
||||||
|
continue
|
||||||
|
|
||||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
continue
|
continue
|
||||||
@@ -1239,6 +1411,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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1337,6 +1510,7 @@ class ModelUpdateService:
|
|||||||
# Check availability field from bulk API for basic EA detection
|
# Check availability field from bulk API for basic EA detection
|
||||||
availability = _normalize_string(entry.get("availability"))
|
availability = _normalize_string(entry.get("availability"))
|
||||||
is_early_access = availability == "EarlyAccess"
|
is_early_access = availability == "EarlyAccess"
|
||||||
|
usage_control = _normalize_string(entry.get("usageControl"))
|
||||||
|
|
||||||
return ModelVersionRecord(
|
return ModelVersionRecord(
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
@@ -1350,6 +1524,7 @@ class ModelUpdateService:
|
|||||||
early_access_ends_at=early_access_ends_at,
|
early_access_ends_at=early_access_ends_at,
|
||||||
sort_index=index,
|
sort_index=index,
|
||||||
is_early_access=is_early_access,
|
is_early_access=is_early_access,
|
||||||
|
usage_control=usage_control,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||||
@@ -1464,7 +1639,7 @@ class ModelUpdateService:
|
|||||||
f"""
|
f"""
|
||||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access
|
is_early_access, usage_control
|
||||||
FROM model_update_versions
|
FROM model_update_versions
|
||||||
WHERE model_id IN ({placeholders})
|
WHERE model_id IN ({placeholders})
|
||||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||||
@@ -1492,6 +1667,7 @@ class ModelUpdateService:
|
|||||||
early_access_ends_at=row["early_access_ends_at"],
|
early_access_ends_at=row["early_access_ends_at"],
|
||||||
sort_index=_normalize_int(row["sort_index"]) or 0,
|
sort_index=_normalize_int(row["sort_index"]) or 0,
|
||||||
is_early_access=bool(row["is_early_access"]),
|
is_early_access=bool(row["is_early_access"]),
|
||||||
|
usage_control=row["usage_control"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1548,8 +1724,8 @@ class ModelUpdateService:
|
|||||||
INSERT INTO model_update_versions (
|
INSERT INTO model_update_versions (
|
||||||
version_id, model_id, sort_index, name, base_model, released_at,
|
version_id, model_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access
|
is_early_access, usage_control
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
version.version_id,
|
version.version_id,
|
||||||
@@ -1564,6 +1740,7 @@ class ModelUpdateService:
|
|||||||
1 if version.should_ignore else 0,
|
1 if version.should_ignore else 0,
|
||||||
version.early_access_ends_at,
|
version.early_access_ends_at,
|
||||||
1 if version.is_early_access else 0,
|
1 if version.is_early_access else 0,
|
||||||
|
version.usage_control,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class RecipeScanner:
|
|||||||
cls._instance._civitai_client = None # Will be lazily initialized
|
cls._instance._civitai_client = None # Will be lazily initialized
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
REPAIR_VERSION = 3
|
REPAIR_VERSION = 4
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -292,6 +292,32 @@ class RecipeScanner:
|
|||||||
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
|
||||||
|
# A checkpoint whose modelVersionId also appears in a LoRA entry is
|
||||||
|
# definitely wrong — the CivitAI import code used to pick
|
||||||
|
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
|
||||||
|
# Clearing it lets the enrichment flow re-resolve the correct
|
||||||
|
# checkpoint from CivitAI image metadata.
|
||||||
|
cp = recipe.get("checkpoint")
|
||||||
|
lora_mvids = {
|
||||||
|
l.get("modelVersionId")
|
||||||
|
for l in recipe.get("loras", [])
|
||||||
|
if l.get("modelVersionId")
|
||||||
|
}
|
||||||
|
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
|
||||||
|
cp_mvid = cp["modelVersionId"]
|
||||||
|
logger.info(
|
||||||
|
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
|
||||||
|
"clearing corrupted checkpoint and removing matching LoRA entry",
|
||||||
|
recipe.get("id"),
|
||||||
|
cp_mvid,
|
||||||
|
)
|
||||||
|
recipe["checkpoint"] = None
|
||||||
|
recipe["loras"] = [
|
||||||
|
l for l in recipe.get("loras", [])
|
||||||
|
if l.get("modelVersionId") != cp_mvid
|
||||||
|
]
|
||||||
|
|
||||||
# 2. Identification: Is repair needed?
|
# 2. Identification: Is repair needed?
|
||||||
has_checkpoint = (
|
has_checkpoint = (
|
||||||
"checkpoint" in recipe
|
"checkpoint" in recipe
|
||||||
@@ -504,6 +530,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 +543,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 +673,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]]:
|
||||||
@@ -2484,6 +2543,7 @@ class RecipeScanner:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
file_name = None
|
file_name = None
|
||||||
|
folder = ""
|
||||||
hash_value = (lora.get("hash") or "").lower()
|
hash_value = (lora.get("hash") or "").lower()
|
||||||
if (
|
if (
|
||||||
hash_value
|
hash_value
|
||||||
@@ -2493,6 +2553,11 @@ class RecipeScanner:
|
|||||||
file_path = self._lora_scanner._hash_index.get_path(hash_value)
|
file_path = self._lora_scanner._hash_index.get_path(hash_value)
|
||||||
if file_path:
|
if file_path:
|
||||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
if lora_cache is not None:
|
||||||
|
for cached_lora in getattr(lora_cache, "raw_data", []):
|
||||||
|
if cached_lora.get("file_path") == file_path:
|
||||||
|
folder = cached_lora.get("folder", "")
|
||||||
|
break
|
||||||
|
|
||||||
if not file_name and lora.get("modelVersionId") and lora_cache is not None:
|
if not file_name and lora.get("modelVersionId") and lora_cache is not None:
|
||||||
for cached_lora in getattr(lora_cache, "raw_data", []):
|
for cached_lora in getattr(lora_cache, "raw_data", []):
|
||||||
@@ -2507,13 +2572,16 @@ class RecipeScanner:
|
|||||||
file_name = os.path.splitext(os.path.basename(cached_path))[
|
file_name = os.path.splitext(os.path.basename(cached_path))[
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
|
folder = cached_lora.get("folder", "")
|
||||||
break
|
break
|
||||||
|
|
||||||
if not file_name:
|
if not file_name:
|
||||||
file_name = lora.get("file_name", "unknown-lora")
|
file_name = lora.get("file_name", "unknown-lora")
|
||||||
|
folder = lora.get("folder", "")
|
||||||
|
|
||||||
|
lora_name = f"{folder}/{file_name}" if folder else file_name
|
||||||
strength = lora.get("strength", 1.0)
|
strength = lora.get("strength", 1.0)
|
||||||
syntax_parts.append(f"<lora:{file_name}:{strength}>")
|
syntax_parts.append(f"<lora:{lora_name}:{strength}>")
|
||||||
|
|
||||||
return syntax_parts
|
return syntax_parts
|
||||||
|
|
||||||
|
|||||||
@@ -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,29 @@ 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(
|
if not metadata and civitai_image_id and image_info:
|
||||||
|
original_url = image_info.get("url")
|
||||||
|
if original_url:
|
||||||
|
self._logger.debug(
|
||||||
|
"Optimized image lacks embedded metadata, "
|
||||||
|
"falling back to original image: %s",
|
||||||
|
original_url,
|
||||||
|
)
|
||||||
|
orig_temp_path = self._create_temp_path(suffix=".png")
|
||||||
|
try:
|
||||||
|
await self._download_image(original_url, orig_temp_path)
|
||||||
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata,
|
||||||
|
orig_temp_path,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._safe_cleanup(orig_temp_path)
|
||||||
|
|
||||||
|
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 +202,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 +252,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)
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ class RecipePersistenceService:
|
|||||||
if metadata.get("source_path"):
|
if metadata.get("source_path"):
|
||||||
recipe_data["source_path"] = metadata.get("source_path")
|
recipe_data["source_path"] = metadata.get("source_path")
|
||||||
|
|
||||||
|
nsfw_level = metadata.get("preview_nsfw_level")
|
||||||
|
if nsfw_level is not None and isinstance(nsfw_level, int):
|
||||||
|
recipe_data["preview_nsfw_level"] = nsfw_level
|
||||||
|
|
||||||
json_filename = f"{recipe_id}.recipe.json"
|
json_filename = f"{recipe_id}.recipe.json"
|
||||||
json_path = os.path.join(recipes_dir, json_filename)
|
json_path = os.path.join(recipes_dir, json_filename)
|
||||||
json_path = os.path.normpath(json_path)
|
json_path = os.path.normpath(json_path)
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"compact_mode": False,
|
"compact_mode": False,
|
||||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||||
"model_name_display": "model_name",
|
"model_name_display": "model_name",
|
||||||
|
"lora_syntax_format": "legacy",
|
||||||
"model_card_footer_action": "replace_preview",
|
"model_card_footer_action": "replace_preview",
|
||||||
"show_version_on_card": True,
|
"show_version_on_card": True,
|
||||||
"update_flag_strategy": "same_base",
|
"update_flag_strategy": "same_base",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from typing import Awaitable, Callable, Dict, List, Sequence
|
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
|
||||||
|
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
|
|
||||||
|
|
||||||
class TagUpdateService:
|
class TagUpdateService:
|
||||||
@@ -20,9 +22,8 @@ class TagUpdateService:
|
|||||||
new_tags: Sequence[str],
|
new_tags: Sequence[str],
|
||||||
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
||||||
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
||||||
) -> List[str]:
|
) -> Tuple[List[str], List[str]]:
|
||||||
"""Add tags to a metadata entry while keeping case-insensitive uniqueness."""
|
"""Add tags to a metadata entry and return updated tags and auto_tags."""
|
||||||
|
|
||||||
base, _ = os.path.splitext(file_path)
|
base, _ = os.path.splitext(file_path)
|
||||||
metadata_path = f"{base}.metadata.json"
|
metadata_path = f"{base}.metadata.json"
|
||||||
metadata = await metadata_loader(metadata_path)
|
metadata = await metadata_loader(metadata_path)
|
||||||
@@ -44,5 +45,6 @@ class TagUpdateService:
|
|||||||
await self._metadata_manager.save_metadata(file_path, metadata)
|
await self._metadata_manager.save_metadata(file_path, metadata)
|
||||||
await update_cache(file_path, file_path, metadata)
|
await update_cache(file_path, file_path, metadata)
|
||||||
|
|
||||||
return existing_tags
|
auto_tags = extract_auto_tags(metadata)
|
||||||
|
return existing_tags, auto_tags
|
||||||
|
|
||||||
|
|||||||
@@ -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] = {
|
||||||
@@ -66,6 +66,46 @@ def build_civitai_model_page_url(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_RE_CDN_IMAGE_ID = re.compile(r"/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_civitai_image_id_from_cdn_url(url: str | None) -> str | None:
|
||||||
|
"""Extract the numeric image ID from a Cloudflare CDN image URL.
|
||||||
|
|
||||||
|
CivitAI image CDN URLs follow the pattern::
|
||||||
|
|
||||||
|
https://image.civitai.com/{cf_uuid}/{params}/{image_id}.{ext}
|
||||||
|
|
||||||
|
The image database ID is always the last path segment (minus extension)
|
||||||
|
because ``getEdgeUrl(…, name=id.toString())`` embeds it explicitly
|
||||||
|
in the model-versions REST API response.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
match = _RE_CDN_IMAGE_ID.search(url)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_civitai_image_page_url(
|
||||||
|
image_id: str | int | None,
|
||||||
|
*,
|
||||||
|
host: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Build a Civitai image page URL.
|
||||||
|
|
||||||
|
Returns something like ``https://civitai.com/images/12345``.
|
||||||
|
The host is resolved through :func:`normalize_civitai_page_host` and
|
||||||
|
therefore respects the user's ``civitai_host`` setting.
|
||||||
|
"""
|
||||||
|
if not image_id:
|
||||||
|
return None
|
||||||
|
normalized_host = normalize_civitai_page_host(host)
|
||||||
|
normalized_id = str(image_id).strip()
|
||||||
|
if not normalized_id:
|
||||||
|
return None
|
||||||
|
return urlunparse(("https", normalized_host, f"/images/{normalized_id}", "", "", ""))
|
||||||
|
|
||||||
|
|
||||||
def _parse_supported_civitai_page_url(url: str | None):
|
def _parse_supported_civitai_page_url(url: str | None):
|
||||||
if not url:
|
if not url:
|
||||||
return None
|
return None
|
||||||
@@ -239,9 +279,9 @@ def _resolve_commercial_bits(values: Sequence[str]) -> int:
|
|||||||
normalized_values.add(normalized)
|
normalized_values.add(normalized)
|
||||||
|
|
||||||
has_sell = "sell" in normalized_values
|
has_sell = "sell" in normalized_values
|
||||||
has_rent = has_sell or "rent" in normalized_values
|
has_rent = "rent" in normalized_values
|
||||||
has_rentcivit = has_rent or "rentcivit" in normalized_values
|
has_rentcivit = "rentcivit" in normalized_values
|
||||||
has_image = has_sell or "image" in normalized_values
|
has_image = "image" in normalized_values
|
||||||
|
|
||||||
commercial_bits = (
|
commercial_bits = (
|
||||||
(1 if has_sell else 0) << 3
|
(1 if has_sell else 0) << 3
|
||||||
@@ -328,8 +368,10 @@ def rewrite_preview_url(
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"build_civitai_image_page_url",
|
||||||
"build_license_flags",
|
"build_license_flags",
|
||||||
"extract_civitai_image_id",
|
"extract_civitai_image_id",
|
||||||
|
"extract_civitai_image_id_from_cdn_url",
|
||||||
"extract_civitai_page_host",
|
"extract_civitai_page_host",
|
||||||
"extract_civitai_model_url_parts",
|
"extract_civitai_model_url_parts",
|
||||||
"is_supported_civitai_page_host",
|
"is_supported_civitai_page_host",
|
||||||
|
|||||||
@@ -101,8 +101,34 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
|||||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||||
[
|
[
|
||||||
"Anima",
|
"Anima",
|
||||||
"ZImageTurbo",
|
# Flux series — DiT architecture, loaded via UNETLoader in ComfyUI
|
||||||
"ZImageBase",
|
"Flux.1 D",
|
||||||
|
"Flux.1 S",
|
||||||
|
"Flux.1 Krea",
|
||||||
|
"Flux.1 Kontext",
|
||||||
|
"Flux.2 D",
|
||||||
|
"Flux.2 Klein 9B",
|
||||||
|
"Flux.2 Klein 9B-base",
|
||||||
|
"Flux.2 Klein 4B",
|
||||||
|
"Flux.2 Klein 4B-base",
|
||||||
|
# Non-UNet / DiT image diffusion models
|
||||||
|
"AuraFlow",
|
||||||
|
"Chroma",
|
||||||
|
"HiDream",
|
||||||
|
"Hunyuan 1",
|
||||||
|
"Kolors",
|
||||||
|
"Lumina",
|
||||||
|
"PixArt a",
|
||||||
|
"PixArt E",
|
||||||
|
# Video diffusion models
|
||||||
|
"CogVideoX",
|
||||||
|
"Hunyuan Video",
|
||||||
|
"LTXV",
|
||||||
|
"LTXV2",
|
||||||
|
"LTXV 2.3",
|
||||||
|
"Mochi",
|
||||||
|
"SVD",
|
||||||
|
"Wan Video",
|
||||||
"Wan Video 1.3B t2v",
|
"Wan Video 1.3B t2v",
|
||||||
"Wan Video 14B t2v",
|
"Wan Video 14B t2v",
|
||||||
"Wan Video 14B i2v 480p",
|
"Wan Video 14B i2v 480p",
|
||||||
@@ -112,9 +138,13 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
|||||||
"Wan Video 2.2 T2V-A14B",
|
"Wan Video 2.2 T2V-A14B",
|
||||||
"Wan Video 2.5 T2V",
|
"Wan Video 2.5 T2V",
|
||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
"CogVideoX",
|
# Other diffusion models
|
||||||
"Mochi",
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
|
"ZImageBase",
|
||||||
|
"ZImageTurbo",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,5 +208,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",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -397,13 +397,12 @@ class DownloadManager:
|
|||||||
|
|
||||||
models_with_hash = len(all_models_with_hash)
|
models_with_hash = len(all_models_with_hash)
|
||||||
|
|
||||||
# Calculate pending count: check which models actually need processing
|
# Calculate pending count: check which models actually need processing.
|
||||||
# A model is pending if it has a hash, is not in processed_models,
|
# A model is pending if it has a hash, is not already processed or known-failed,
|
||||||
# and its folder doesn't exist or is empty
|
# and its folder doesn't exist or is empty.
|
||||||
pending_hashes = set()
|
pending_hashes = set()
|
||||||
for model_hash, model_name in all_models_with_hash:
|
for model_hash, model_name in all_models_with_hash:
|
||||||
if model_hash not in processed_models:
|
if model_hash not in processed_models and model_hash not in failed_models:
|
||||||
# Check if model folder exists with files
|
|
||||||
model_dir = ExampleImagePathResolver.get_model_folder(
|
model_dir = ExampleImagePathResolver.get_model_folder(
|
||||||
model_hash, active_library
|
model_hash, active_library
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,10 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
CARD_PREVIEW_WIDTH,
|
CARD_PREVIEW_WIDTH,
|
||||||
@@ -31,7 +34,7 @@ def _get_hash_chunk_size_bytes() -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def calculate_sha256(file_path: str) -> str:
|
async def calculate_sha256(file_path: str) -> str:
|
||||||
"""Calculate SHA256 hash of a file"""
|
"""Calculate SHA256 hash of a file (full file content)."""
|
||||||
sha256_hash = hashlib.sha256()
|
sha256_hash = hashlib.sha256()
|
||||||
chunk_size = _get_hash_chunk_size_bytes()
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
@@ -39,6 +42,79 @@ async def calculate_sha256(file_path: str) -> str:
|
|||||||
sha256_hash.update(byte_block)
|
sha256_hash.update(byte_block)
|
||||||
return sha256_hash.hexdigest()
|
return sha256_hash.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_autov2(file_path: str) -> str:
|
||||||
|
"""Calculate CivitAI AutoV2 hash.
|
||||||
|
|
||||||
|
AutoV2 is the first 10 characters of the full file SHA256.
|
||||||
|
Used by CivitAI as a shortened file identifier.
|
||||||
|
|
||||||
|
Reference: https://developer.civitai.com/site/reference/model-versions
|
||||||
|
"""
|
||||||
|
full_hash = hashlib.sha256()
|
||||||
|
chunk_size = _get_hash_chunk_size_bytes()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
for byte_block in iter(lambda: f.read(chunk_size), b""):
|
||||||
|
full_hash.update(byte_block)
|
||||||
|
return full_hash.hexdigest()[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def read_safetensors_metadata(file_path: str) -> dict[str, Any]:
|
||||||
|
"""Read the ``__metadata__`` dict from a safetensors file header.
|
||||||
|
|
||||||
|
Safetensors file format:
|
||||||
|
- 8 bytes: header length (little-endian 64-bit)
|
||||||
|
- N bytes: UTF-8 JSON header
|
||||||
|
- The header JSON contains a ``__metadata__`` key holding arbitrary metadata.
|
||||||
|
|
||||||
|
Returns an empty dict if the file is not a valid safetensors file or has no
|
||||||
|
metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
header_len_bytes = f.read(8)
|
||||||
|
if len(header_len_bytes) < 8:
|
||||||
|
return {}
|
||||||
|
header_len = struct.unpack("<Q", header_len_bytes)[0]
|
||||||
|
header_bytes = f.read(header_len)
|
||||||
|
if len(header_bytes) < header_len:
|
||||||
|
return {}
|
||||||
|
header = json.loads(header_bytes.decode("utf-8"))
|
||||||
|
return header.get("__metadata__", {})
|
||||||
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError, struct.error, MemoryError, Exception):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_autov3(file_path: str) -> str | None:
|
||||||
|
"""Calculate CivitAI AutoV3 hash from a safetensors file.
|
||||||
|
|
||||||
|
AutoV3 is extracted from the safetensors file's embedded metadata, not
|
||||||
|
computed from the file bytes directly. The orchestrator reads the
|
||||||
|
``sshs_model_hash`` (kohya-ss format) or ``modelspec.hash_sha256`` field
|
||||||
|
from the safetensors header and stores the first 12 characters.
|
||||||
|
|
||||||
|
The embedded hash itself is the SHA256 of the file after skipping the
|
||||||
|
8-byte header length + JSON header (a.k.a. the addnet hash / tensor-only
|
||||||
|
hash).
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
- CivitAI DB trigger: ``SUBSTRING(NEW.hash FROM 1 FOR 12)``
|
||||||
|
- https://developer.civitai.com/site/reference/model-versions
|
||||||
|
|
||||||
|
Returns ``None`` when no AutoV3 hash can be determined (e.g. the file is
|
||||||
|
not safetensors, or the metadata doesn't contain a recognised hash field).
|
||||||
|
"""
|
||||||
|
metadata = read_safetensors_metadata(file_path)
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
embedded_hash = metadata.get("sshs_model_hash") or metadata.get("modelspec.hash_sha256")
|
||||||
|
if embedded_hash and isinstance(embedded_hash, str) and len(embedded_hash) >= 12:
|
||||||
|
return embedded_hash[:12]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_preview_file(base_name: str, dir_path: str) -> str:
|
def find_preview_file(base_name: str, dir_path: str) -> str:
|
||||||
"""Find preview file for given base name in directory.
|
"""Find preview file for given base name in directory.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str
|
|||||||
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
||||||
|
|
||||||
|
|
||||||
|
_KEEP_LOG_COUNT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_old_logs(log_dir: str) -> None:
|
||||||
|
"""Remove older session log files, keeping only the ``_KEEP_LOG_COUNT`` newest."""
|
||||||
|
try:
|
||||||
|
files = [
|
||||||
|
os.path.join(log_dir, name)
|
||||||
|
for name in os.listdir(log_dir)
|
||||||
|
if name.startswith("standalone-session-") and name.endswith(".log")
|
||||||
|
]
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
files.sort(key=os.path.getmtime, reverse=True)
|
||||||
|
for path in files[_KEEP_LOG_COUNT:]:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
||||||
global _session_state
|
global _session_state
|
||||||
|
|
||||||
@@ -90,6 +111,7 @@ def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSes
|
|||||||
file_handler.set_name(_FILE_HANDLER_NAME)
|
file_handler.set_name(_FILE_HANDLER_NAME)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(file_handler)
|
root_logger.addHandler(file_handler)
|
||||||
|
_prune_old_logs(os.path.dirname(log_file_path))
|
||||||
|
|
||||||
_session_state = StandaloneSessionLogState(
|
_session_state = StandaloneSessionLogState(
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
|
|||||||
@@ -15,30 +15,64 @@ def get_lora_info(lora_name):
|
|||||||
scanner = await ServiceRegistry.get_lora_scanner()
|
scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
lora_name_normalized = lora_name.replace("\\", "/")
|
||||||
|
lora_name_no_ext = lora_name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if lora_name_no_ext.lower().endswith(ext):
|
||||||
|
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in lora_name_no_ext
|
||||||
|
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
if item.get("file_name") == lora_name:
|
file_name = item.get("file_name", "")
|
||||||
file_path = item.get("file_path")
|
folder = item.get("folder", "")
|
||||||
if file_path:
|
file_name_no_ext = file_name
|
||||||
# Check all lora roots including extra paths
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
all_roots = list(config.loras_roots or []) + list(
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
config.extra_loras_roots or []
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if lora_name_no_ext not in (file_name_no_ext, path_name):
|
||||||
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = item
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = item
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_roots = list(config.loras_roots or []) + list(
|
||||||
|
config.extra_loras_roots or []
|
||||||
|
)
|
||||||
|
for root in all_roots:
|
||||||
|
root = root.replace(os.sep, "/")
|
||||||
|
if file_path.startswith(root):
|
||||||
|
relative_path = os.path.relpath(file_path, root).replace(
|
||||||
|
os.sep, "/"
|
||||||
)
|
)
|
||||||
for root in all_roots:
|
|
||||||
root = root.replace(os.sep, "/")
|
|
||||||
if file_path.startswith(root):
|
|
||||||
relative_path = os.path.relpath(file_path, root).replace(
|
|
||||||
os.sep, "/"
|
|
||||||
)
|
|
||||||
# Get trigger words from civitai metadata
|
|
||||||
civitai = item.get("civitai", {})
|
|
||||||
trigger_words = (
|
|
||||||
civitai.get("trainedWords", []) if civitai else []
|
|
||||||
)
|
|
||||||
return relative_path, trigger_words
|
|
||||||
# If not found in any root, return path with trigger words from cache
|
|
||||||
civitai = item.get("civitai", {})
|
civitai = item.get("civitai", {})
|
||||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
trigger_words = (
|
||||||
return file_path, trigger_words
|
civitai.get("trainedWords", []) if civitai else []
|
||||||
|
)
|
||||||
|
return relative_path, trigger_words
|
||||||
|
civitai = item.get("civitai", {})
|
||||||
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
|
return file_path, trigger_words
|
||||||
|
|
||||||
|
if best_fallback:
|
||||||
|
file_path = best_fallback.get("file_path")
|
||||||
|
if file_path:
|
||||||
|
civitai = best_fallback.get("civitai", {})
|
||||||
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
|
return file_path, trigger_words
|
||||||
|
|
||||||
return lora_name, []
|
return lora_name, []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -77,15 +111,54 @@ def get_lora_info_absolute(lora_name):
|
|||||||
scanner = await ServiceRegistry.get_lora_scanner()
|
scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
lora_name_normalized = lora_name.replace("\\", "/")
|
||||||
|
lora_name_no_ext = lora_name_normalized
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if lora_name_no_ext.lower().endswith(ext):
|
||||||
|
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
|
||||||
|
has_path = "/" in lora_name_no_ext
|
||||||
|
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
|
||||||
|
best_fallback = None
|
||||||
|
|
||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
if item.get("file_name") == lora_name:
|
file_name = item.get("file_name", "")
|
||||||
|
folder = item.get("folder", "")
|
||||||
|
file_name_no_ext = file_name
|
||||||
|
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||||
|
if file_name_no_ext.lower().endswith(ext):
|
||||||
|
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||||
|
break
|
||||||
|
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||||
|
|
||||||
|
if lora_name_no_ext == file_name_no_ext:
|
||||||
file_path = item.get("file_path")
|
file_path = item.get("file_path")
|
||||||
if file_path:
|
if file_path:
|
||||||
# Return absolute path directly
|
|
||||||
# Get trigger words from civitai metadata
|
|
||||||
civitai = item.get("civitai", {})
|
civitai = item.get("civitai", {})
|
||||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
return file_path, trigger_words
|
return file_path, trigger_words
|
||||||
|
|
||||||
|
if lora_name_no_ext == path_name:
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if file_path:
|
||||||
|
civitai = item.get("civitai", {})
|
||||||
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
|
return file_path, trigger_words
|
||||||
|
|
||||||
|
if has_path and file_name_no_ext == basename:
|
||||||
|
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||||
|
best_fallback = item
|
||||||
|
elif best_fallback is None:
|
||||||
|
best_fallback = item
|
||||||
|
|
||||||
|
if best_fallback:
|
||||||
|
file_path = best_fallback.get("file_path")
|
||||||
|
if file_path:
|
||||||
|
civitai = best_fallback.get("civitai", {})
|
||||||
|
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||||
|
return file_path, trigger_words
|
||||||
|
|
||||||
return lora_name, []
|
return lora_name, []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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.11"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
353
scripts/migrate_legacy_metadata.py
Normal file
353
scripts/migrate_legacy_metadata.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.
|
||||||
|
|
||||||
|
This script automatically discovers model folders from LoRA Manager's settings.json,
|
||||||
|
finds JSON files with the same basename as model files (e.g., `model.json` for
|
||||||
|
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.
|
||||||
|
|
||||||
|
Fields migrated:
|
||||||
|
- "activation text" → civitai.trainedWords (array of trigger words)
|
||||||
|
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
|
||||||
|
- "notes" → notes (user-defined notes)
|
||||||
|
|
||||||
|
Supported model types: LoRA, Checkpoint
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Read settings.json to find all configured model folders
|
||||||
|
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
|
||||||
|
3. Find corresponding legacy metadata JSON files
|
||||||
|
4. Migrate data to .metadata.json files
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
|
||||||
|
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_path() -> Path:
|
||||||
|
repo_root = Path(__file__).parent.parent.resolve()
|
||||||
|
portable = repo_root / "settings.json"
|
||||||
|
if portable.exists():
|
||||||
|
payload = load_json(portable)
|
||||||
|
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||||
|
return portable
|
||||||
|
|
||||||
|
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logger.error(f"Invalid JSON in {path}: {exc}")
|
||||||
|
return {}
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(f"Cannot read {path}: {exc}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_path(value: str) -> str:
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [expand_path(value)] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [expand_path(item) for item in value if isinstance(item, str) and item]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
roots: dict[str, list[str]] = {}
|
||||||
|
active_library = settings.get("active_library") or "default"
|
||||||
|
sources = [settings]
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict):
|
||||||
|
sources.insert(0, library)
|
||||||
|
for source in sources:
|
||||||
|
folder_paths = source.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, dict):
|
||||||
|
for key, value in folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(normalize_path_list(value))
|
||||||
|
for default_key, folder_key in (
|
||||||
|
("default_lora_root", "loras"),
|
||||||
|
("default_checkpoint_root", "checkpoints"),
|
||||||
|
("default_embedding_root", "embeddings"),
|
||||||
|
("default_unet_root", "unet"),
|
||||||
|
):
|
||||||
|
value = settings.get(default_key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
roots.setdefault(folder_key, []).append(expand_path(value))
|
||||||
|
return {key: dedupe(values) for key, values in roots.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def find_model_files(directory: Path) -> list[Path]:
|
||||||
|
model_files = []
|
||||||
|
for ext in MODEL_EXTENSIONS:
|
||||||
|
model_files.extend(directory.rglob(f"*{ext}"))
|
||||||
|
return model_files
|
||||||
|
|
||||||
|
|
||||||
|
def find_legacy_metadata(model_path: Path) -> Path | None:
|
||||||
|
base_name = model_path.stem
|
||||||
|
legacy_path = model_path.with_name(f"{base_name}.json")
|
||||||
|
if legacy_path.exists() and legacy_path.is_file():
|
||||||
|
return legacy_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
with open(legacy_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading legacy file {legacy_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_metadata(metadata_path: Path) -> dict[str, Any]:
|
||||||
|
if not metadata_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading metadata file {metadata_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
|
||||||
|
if dry_run:
|
||||||
|
logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
|
||||||
|
return True
|
||||||
|
temp_path = metadata_path.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(temp_path, metadata_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving metadata to {metadata_path}: {e}")
|
||||||
|
if temp_path.exists():
|
||||||
|
try:
|
||||||
|
temp_path.unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_metadata(
|
||||||
|
legacy_data: dict[str, Any],
|
||||||
|
existing_metadata: dict[str, Any],
|
||||||
|
model_type: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
metadata = existing_metadata.copy()
|
||||||
|
changes_made = False
|
||||||
|
if "civitai" not in metadata:
|
||||||
|
metadata["civitai"] = {}
|
||||||
|
activation_text = legacy_data.get("activation text")
|
||||||
|
if activation_text and isinstance(activation_text, str):
|
||||||
|
trigger_words = [
|
||||||
|
word.strip()
|
||||||
|
for word in activation_text.replace("\n", ",").split(",")
|
||||||
|
if word.strip()
|
||||||
|
]
|
||||||
|
if trigger_words:
|
||||||
|
existing_trained = metadata["civitai"].get("trainedWords", [])
|
||||||
|
if not isinstance(existing_trained, list):
|
||||||
|
existing_trained = []
|
||||||
|
merged = list(dict.fromkeys(existing_trained + trigger_words))
|
||||||
|
if merged != existing_trained:
|
||||||
|
metadata["civitai"]["trainedWords"] = merged
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(f" Migrated activation text: {trigger_words}")
|
||||||
|
if model_type == "lora":
|
||||||
|
preferred_weight = legacy_data.get("preferred weight")
|
||||||
|
if preferred_weight is not None:
|
||||||
|
try:
|
||||||
|
weight_value = float(preferred_weight)
|
||||||
|
usage_tips_str = metadata.get("usage_tips", "{}")
|
||||||
|
if isinstance(usage_tips_str, str):
|
||||||
|
try:
|
||||||
|
usage_tips = json.loads(usage_tips_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
usage_tips = {}
|
||||||
|
elif isinstance(usage_tips_str, dict):
|
||||||
|
usage_tips = usage_tips_str
|
||||||
|
else:
|
||||||
|
usage_tips = {}
|
||||||
|
if "strength" not in usage_tips:
|
||||||
|
usage_tips["strength"] = weight_value
|
||||||
|
metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(f" Migrated preferred weight: {weight_value}")
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning(f" Could not parse preferred weight '{preferred_weight}': {e}")
|
||||||
|
else:
|
||||||
|
if legacy_data.get("preferred weight") is not None:
|
||||||
|
logger.debug(" Skipping 'preferred weight' for non-LoRA model")
|
||||||
|
notes = legacy_data.get("notes")
|
||||||
|
if notes and isinstance(notes, str) and notes.strip():
|
||||||
|
existing_notes = metadata.get("notes", "")
|
||||||
|
if not existing_notes:
|
||||||
|
metadata["notes"] = notes.strip()
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(" Migrated notes")
|
||||||
|
elif notes.strip() not in existing_notes:
|
||||||
|
metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(" Appended notes")
|
||||||
|
return metadata if changes_made else None
|
||||||
|
|
||||||
|
|
||||||
|
def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
|
||||||
|
legacy_path = find_legacy_metadata(model_path)
|
||||||
|
if not legacy_path:
|
||||||
|
return True
|
||||||
|
logger.info(f"Processing: {model_path.name} ({model_type})")
|
||||||
|
logger.info(f" Found legacy metadata: {legacy_path.name}")
|
||||||
|
legacy_data = load_legacy_metadata(legacy_path)
|
||||||
|
if legacy_data is None:
|
||||||
|
return False
|
||||||
|
metadata_path = model_path.with_suffix(".metadata.json")
|
||||||
|
existing_metadata = load_metadata(metadata_path)
|
||||||
|
migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
|
||||||
|
if migrated is None:
|
||||||
|
logger.info(" No changes needed (fields already exist or no migratable data)")
|
||||||
|
return True
|
||||||
|
if save_metadata(metadata_path, migrated, dry_run):
|
||||||
|
logger.info(f" ✓ Successfully migrated metadata to: {metadata_path.name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(" ✗ Failed to save metadata")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python scripts/migrate_legacy_metadata.py
|
||||||
|
python scripts/migrate_legacy_metadata.py --dry-run
|
||||||
|
python scripts/migrate_legacy_metadata.py --verbose
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Preview changes without modifying any files"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable verbose output"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
settings_path = resolve_settings_path()
|
||||||
|
logger.info(f"Using settings: {settings_path}")
|
||||||
|
settings = load_json(settings_path)
|
||||||
|
if not settings:
|
||||||
|
logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
|
||||||
|
return 1
|
||||||
|
roots = get_model_roots(settings)
|
||||||
|
if not roots:
|
||||||
|
logger.error("No model folders configured in settings.json.")
|
||||||
|
return 1
|
||||||
|
lora_roots = roots.get("loras", [])
|
||||||
|
checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
|
||||||
|
all_roots = []
|
||||||
|
for root_list in [lora_roots, checkpoint_roots]:
|
||||||
|
for root in root_list:
|
||||||
|
path = Path(root)
|
||||||
|
if path.exists() and path.is_dir():
|
||||||
|
all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
|
||||||
|
if not all_roots:
|
||||||
|
logger.error("No valid model folders found.")
|
||||||
|
return 1
|
||||||
|
logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
|
||||||
|
processed = 0
|
||||||
|
migrated = 0
|
||||||
|
errors = 0
|
||||||
|
skipped = 0
|
||||||
|
lora_count = 0
|
||||||
|
checkpoint_count = 0
|
||||||
|
for root_path, model_type in all_roots:
|
||||||
|
logger.info(f"Scanning: {root_path} ({model_type})")
|
||||||
|
model_files = find_model_files(root_path)
|
||||||
|
logger.debug(f" Found {len(model_files)} model files")
|
||||||
|
for model_path in model_files:
|
||||||
|
legacy_path = find_legacy_metadata(model_path)
|
||||||
|
if not legacy_path:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
processed += 1
|
||||||
|
if process_model(model_path, model_type, dry_run=args.dry_run):
|
||||||
|
migrated += 1
|
||||||
|
if model_type == "lora":
|
||||||
|
lora_count += 1
|
||||||
|
else:
|
||||||
|
checkpoint_count += 1
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
logger.info("\n" + "=" * 50)
|
||||||
|
logger.info("Migration Summary:")
|
||||||
|
logger.info(f" Models with legacy metadata: {processed}")
|
||||||
|
logger.info(f" Successfully migrated: {migrated}")
|
||||||
|
logger.info(f" - LoRA models: {lora_count}")
|
||||||
|
logger.info(f" - Checkpoint models: {checkpoint_count}")
|
||||||
|
logger.info(f" Errors: {errors}")
|
||||||
|
logger.info(f" Skipped (no legacy file): {skipped}")
|
||||||
|
if args.dry_run:
|
||||||
|
logger.info("\n [DRY RUN MODE - No files were modified]")
|
||||||
|
return 0 if errors == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
403
scripts/restore_suffixed_filenames.py
Normal file
403
scripts/restore_suffixed_filenames.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Restore original filenames by removing leftover 4-char hash suffixes.
|
||||||
|
|
||||||
|
When LoRA Manager's old duplicate filename resolver ran, it appended
|
||||||
|
``-{first4ofSHA256}`` to duplicate filenames, e.g.::
|
||||||
|
|
||||||
|
my_lora.safetensors → my_lora-a3f7.safetensors
|
||||||
|
|
||||||
|
With full-path LoRA syntax now available (``<lora:subfolder/name:1.0>``),
|
||||||
|
these suffixes are unnecessary. This script detects such files and, with
|
||||||
|
your confirmation, restores their original names.
|
||||||
|
|
||||||
|
The same suffix pattern is also used by the download conflict handler
|
||||||
|
(``{name}-{hash}.{ext}``). To avoid false positives, this script skips
|
||||||
|
any file whose original name already exists in the same directory — those
|
||||||
|
were likely added by a download conflict, not the old resolver.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# Detect only (dry-run, default)
|
||||||
|
python scripts/restore_suffixed_filenames.py
|
||||||
|
|
||||||
|
# Detect + restore (with confirmation prompt)
|
||||||
|
python scripts/restore_suffixed_filenames.py --apply
|
||||||
|
|
||||||
|
After restoring filenames, run **Rebuild Cache** in the LoRA Manager
|
||||||
|
Doctor panel to refresh the model cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
|
||||||
|
PREVIEW_EXTENSIONS = {
|
||||||
|
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp",
|
||||||
|
".mp4", ".webm", ".mov",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Matches filenames like "my_lora-a3f7.safetensors"
|
||||||
|
# Groups: (base_name, 4-char-hex, extension)
|
||||||
|
_SUFFIX_RE = re.compile(r"^(.+)-([0-9a-f]{4})(\.[^.]+)$")
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers (copied from migrate_legacy_metadata.py for consistency) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_path() -> Path:
|
||||||
|
repo_root = Path(__file__).parent.parent.resolve()
|
||||||
|
portable = repo_root / "settings.json"
|
||||||
|
if portable.exists():
|
||||||
|
payload = _load_json(portable)
|
||||||
|
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||||
|
return portable
|
||||||
|
|
||||||
|
return Path(user_config_dir(APP_NAME, appauthor=False)) / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_path(value: str) -> str:
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [_expand_path(value)] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_expand_path(item) for item in value if isinstance(item, str) and item]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
"""Extract model folder roots from LoRA Manager settings.
|
||||||
|
|
||||||
|
Returns ``{model_type: [path, ...]}`` where *model_type* is one of
|
||||||
|
``loras``, ``checkpoints``, ``embeddings``, ``unet``, etc.
|
||||||
|
|
||||||
|
Both primary (``folder_paths``) and extra (``extra_folder_paths``)
|
||||||
|
paths are included. Extra paths can be configured via the UI at
|
||||||
|
Settings → Model Libraries → Extra Folder Paths.
|
||||||
|
"""
|
||||||
|
roots: dict[str, list[str]] = {}
|
||||||
|
active_library = settings.get("active_library") or "default"
|
||||||
|
sources = [settings]
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict):
|
||||||
|
sources.insert(0, library)
|
||||||
|
for source in sources:
|
||||||
|
# Primary folder paths.
|
||||||
|
folder_paths = source.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, dict):
|
||||||
|
for key, value in folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(_normalize_path_list(value))
|
||||||
|
# Extra folder paths (Settings → Model Libraries → Extra Folder Paths).
|
||||||
|
extra_folder_paths = source.get("extra_folder_paths")
|
||||||
|
if isinstance(extra_folder_paths, dict):
|
||||||
|
for key, value in extra_folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(_normalize_path_list(value))
|
||||||
|
for default_key, folder_key in (
|
||||||
|
("default_lora_root", "loras"),
|
||||||
|
("default_checkpoint_root", "checkpoints"),
|
||||||
|
("default_unet_root", "unet"),
|
||||||
|
("default_embedding_root", "embeddings"),
|
||||||
|
):
|
||||||
|
value = settings.get(default_key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
roots.setdefault(folder_key, []).append(_expand_path(value))
|
||||||
|
return {key: _dedupe(values) for key, values in roots.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def find_model_files(directory: Path) -> list[Path]:
|
||||||
|
"""Recursively find all model files in *directory*."""
|
||||||
|
files: list[Path] = []
|
||||||
|
for ext in MODEL_EXTENSIONS:
|
||||||
|
files.extend(directory.rglob(f"*{ext}"))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
# ── core detection logic ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def check_file(path: Path) -> tuple[str, str, str] | None:
|
||||||
|
"""If *path* matches the suffix pattern, return ``(base_name, hex, ext)``.
|
||||||
|
|
||||||
|
Returns ``None`` when:
|
||||||
|
* The filename does not match the pattern, or
|
||||||
|
* The original name (without the suffix) already exists in the same
|
||||||
|
directory (likely a download-conflict rename, not a doctor rename).
|
||||||
|
"""
|
||||||
|
match = _SUFFIX_RE.match(path.name)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_name = match.group(1)
|
||||||
|
hex_part = match.group(2)
|
||||||
|
extension = match.group(3)
|
||||||
|
orig_name = base_name + extension
|
||||||
|
orig_path = path.with_name(orig_name)
|
||||||
|
|
||||||
|
# Safety: skip if the original name already exists.
|
||||||
|
if orig_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return base_name, hex_part, extension
|
||||||
|
|
||||||
|
|
||||||
|
def scan_roots(
|
||||||
|
roots: dict[str, list[str]],
|
||||||
|
) -> dict[str, list[tuple[Path, str, str, str]]]:
|
||||||
|
"""Scan all model roots and return detected files grouped by model type.
|
||||||
|
|
||||||
|
Returns ``{model_type: [(full_path, base_name, hex, ext), ...]}``.
|
||||||
|
"""
|
||||||
|
results: dict[str, list[tuple[Path, str, str, str]]] = {}
|
||||||
|
|
||||||
|
for model_type, root_list in roots.items():
|
||||||
|
type_results: list[tuple[Path, str, str, str]] = []
|
||||||
|
for root in root_list:
|
||||||
|
root_path = Path(root)
|
||||||
|
if not root_path.is_dir():
|
||||||
|
continue
|
||||||
|
for model_file in find_model_files(root_path):
|
||||||
|
match = check_file(model_file)
|
||||||
|
if match:
|
||||||
|
type_results.append((model_file, *match))
|
||||||
|
if type_results:
|
||||||
|
results[model_type] = type_results
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def rename_file(
|
||||||
|
path: Path, base_name: str, extension: str, dry_run: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Rename *path* to ``{base_name}{extension}``.
|
||||||
|
|
||||||
|
Also renames sidecar files (``.metadata.json``, ``.civitai.info``) and
|
||||||
|
preview images. Returns ``True`` on success.
|
||||||
|
"""
|
||||||
|
new_path = path.with_name(base_name + extension)
|
||||||
|
old_stem = path.with_suffix("") # /dir/base_name-hex (no ext)
|
||||||
|
new_stem = new_path.with_suffix("") # /dir/base_name (no ext)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(" would rename: %s", path.name)
|
||||||
|
logger.info(" -> %s", new_path.name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(path, new_path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(" FAILED to rename %s: %s", path.name, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Rename sidecar metadata files.
|
||||||
|
for suffix in (".metadata.json", ".civitai.info"):
|
||||||
|
old_sidecar = old_stem.with_name(old_stem.name + suffix)
|
||||||
|
new_sidecar = new_stem.with_name(new_stem.name + suffix)
|
||||||
|
if old_sidecar.exists():
|
||||||
|
try:
|
||||||
|
os.rename(old_sidecar, new_sidecar)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(" could not rename sidecar %s: %s", old_sidecar.name, exc)
|
||||||
|
|
||||||
|
# Rename preview images.
|
||||||
|
for preview_ext in PREVIEW_EXTENSIONS:
|
||||||
|
old_preview = old_stem.with_name(old_stem.name + preview_ext)
|
||||||
|
new_preview = new_stem.with_name(new_stem.name + preview_ext)
|
||||||
|
if old_preview.exists():
|
||||||
|
try:
|
||||||
|
os.rename(old_preview, new_preview)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(" could not rename preview %s: %s", old_preview.name, exc)
|
||||||
|
|
||||||
|
logger.info(" renamed: %s -> %s", path.name, new_path.name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── report helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def print_report(results: dict[str, list[tuple[Path, str, str, str]]]) -> int:
|
||||||
|
"""Print a human-readable report of detected files. Returns total count."""
|
||||||
|
if not results:
|
||||||
|
logger.info("No leftover suffixed filenames detected.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for model_type in sorted(results):
|
||||||
|
entries = results[model_type]
|
||||||
|
total += len(entries)
|
||||||
|
label = model_type.capitalize()
|
||||||
|
logger.info("")
|
||||||
|
logger.info("─" * 50)
|
||||||
|
logger.info(" %s (%d file(s))", label, len(entries))
|
||||||
|
logger.info("─" * 50)
|
||||||
|
for path, base_name, hex_part, ext in sorted(entries):
|
||||||
|
logger.info(" %s → %s%s", path.name, base_name, ext)
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(" Total: %d file(s) with leftover suffixes.", total)
|
||||||
|
logger.info("=" * 50)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_user(count: int) -> bool:
|
||||||
|
"""Ask the user whether to proceed with the rename."""
|
||||||
|
try:
|
||||||
|
answer = input(
|
||||||
|
f"\nRestore {count} file(s) to their original names? [y/N] "
|
||||||
|
).strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
return False
|
||||||
|
return answer in ("y", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Detect and restore model filenames that have leftover "
|
||||||
|
"4-character hash suffixes from the old conflict resolver."
|
||||||
|
),
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=(
|
||||||
|
"Examples:\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py --apply\n"
|
||||||
|
" python scripts/restore_suffixed_filenames.py --apply --yes\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply",
|
||||||
|
action="store_true",
|
||||||
|
help="Actually rename files (with confirmation prompt unless --yes is given)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yes", "-y",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip confirmation prompt (implies --apply)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Detect only — show what would be renamed without making changes",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable debug-level logging",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Resolve settings.
|
||||||
|
settings_path = resolve_settings_path()
|
||||||
|
logger.info("Settings: %s", settings_path)
|
||||||
|
settings = _load_json(settings_path)
|
||||||
|
if not settings:
|
||||||
|
logger.error("Could not load settings.json. Is LoRA Manager configured?")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
roots = get_model_roots(settings)
|
||||||
|
if not roots:
|
||||||
|
logger.error("No model folders found in settings.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Log which roots are being scanned.
|
||||||
|
for model_type, root_list in roots.items():
|
||||||
|
for root in root_list:
|
||||||
|
logger.info("Scanning %s: %s", model_type, root)
|
||||||
|
|
||||||
|
# Detect.
|
||||||
|
results = scan_roots(roots)
|
||||||
|
total = print_report(results)
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Determine mode.
|
||||||
|
dry_run = not args.apply and not args.yes
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info("\n[Dry-run mode — no files modified]")
|
||||||
|
logger.info("Run with --apply to restore filenames.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Confirm unless --yes.
|
||||||
|
if not args.yes:
|
||||||
|
if not prompt_user(total):
|
||||||
|
logger.info("Aborted.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Rename.
|
||||||
|
logger.info("")
|
||||||
|
success = 0
|
||||||
|
fail = 0
|
||||||
|
for model_type in sorted(results):
|
||||||
|
entries = results[model_type]
|
||||||
|
logger.info("")
|
||||||
|
logger.info("─" * 50)
|
||||||
|
logger.info(" Restoring %s (%d file(s))", model_type, len(entries))
|
||||||
|
logger.info("─" * 50)
|
||||||
|
for path, base_name, hex_part, ext in sorted(entries):
|
||||||
|
ok = rename_file(path, base_name, ext, dry_run=False)
|
||||||
|
if ok:
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(" Done: %d restored, %d failed.", success, fail)
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info("")
|
||||||
|
logger.info(" ⚠ Please run Rebuild Cache in the LoRA Manager")
|
||||||
|
logger.info(" Doctor panel to refresh the model cache.")
|
||||||
|
|
||||||
|
return 0 if fail == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -10,13 +10,14 @@
|
|||||||
"C:/path/to/your/checkpoints_folder",
|
"C:/path/to/your/checkpoints_folder",
|
||||||
"C:/path/to/another/checkpoints_folder"
|
"C:/path/to/another/checkpoints_folder"
|
||||||
],
|
],
|
||||||
|
"unet": [
|
||||||
|
"C:/path/to/your/diffusion_models_folder",
|
||||||
|
"C:/path/to/another/diffusion_models_folder"
|
||||||
|
],
|
||||||
"embeddings": [
|
"embeddings": [
|
||||||
"C:/path/to/your/embeddings_folder",
|
"C:/path/to/your/embeddings_folder",
|
||||||
"C:/path/to/another/embeddings_folder"
|
"C:/path/to/another/embeddings_folder"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"example_images_open_mode": "system",
|
|
||||||
"example_images_local_root": "",
|
|
||||||
"example_images_open_uri_template": "",
|
|
||||||
"auto_organize_exclusions": []
|
"auto_organize_exclusions": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
|
@import 'tokens/index.css';
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Disable default scrolling */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对Firefox */
|
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border-color) transparent;
|
scrollbar-color: var(--border-base) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对Webkit browsers (Chrome, Safari等) */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: var(--scrollbar-width, 8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -24,116 +23,128 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--border-color);
|
background-color: var(--border-base);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #ffffff;
|
|
||||||
--text-color: #333333;
|
|
||||||
--text-muted: #6c757d;
|
|
||||||
--card-bg: #ffffff;
|
|
||||||
--border-color: #e0e0e0;
|
|
||||||
--header-height: 48px;
|
--header-height: 48px;
|
||||||
|
|
||||||
/* Color Components */
|
|
||||||
--lora-accent-l: 68%;
|
|
||||||
--lora-accent-c: 0.28;
|
|
||||||
--lora-accent-h: 256;
|
|
||||||
--lora-warning-l: 75%;
|
|
||||||
--lora-warning-c: 0.25;
|
|
||||||
--lora-warning-h: 80;
|
|
||||||
--lora-success-l: 70%;
|
|
||||||
--lora-success-c: 0.2;
|
|
||||||
--lora-success-h: 140;
|
|
||||||
|
|
||||||
/* Composed Colors */
|
|
||||||
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
|
||||||
--lora-surface: oklch(97% 0 0 / 0.95);
|
|
||||||
--lora-border: oklch(72% 0.03 256 / 0.45);
|
|
||||||
--lora-text: oklch(95% 0.02 256);
|
|
||||||
--lora-error: oklch(75% 0.32 29);
|
|
||||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 20%, transparent);
|
|
||||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 50%, transparent);
|
|
||||||
--lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
|
||||||
--lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h));
|
|
||||||
--badge-update-bg: oklch(72% 0.2 220);
|
|
||||||
--badge-update-text: oklch(28% 0.03 220);
|
|
||||||
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
|
||||||
--badge-skip-refresh-bg: oklch(82% 0.12 45);
|
|
||||||
--badge-skip-refresh-text: oklch(35% 0.02 45);
|
|
||||||
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
|
||||||
|
|
||||||
/* Spacing Scale */
|
|
||||||
--space-1: calc(8px * 1);
|
|
||||||
--space-2: calc(8px * 2);
|
|
||||||
--space-3: calc(8px * 3);
|
|
||||||
--space-4: calc(8px * 4);
|
|
||||||
|
|
||||||
/* Z-index Scale */
|
|
||||||
--z-base: 10;
|
|
||||||
--z-header: 100;
|
|
||||||
--z-modal: 1000;
|
|
||||||
--z-overlay: 2000;
|
|
||||||
|
|
||||||
/* Border Radius */
|
|
||||||
--border-radius-base: 12px;
|
|
||||||
--border-radius-md: 12px;
|
|
||||||
--border-radius-sm: 8px;
|
|
||||||
--border-radius-xs: 4px;
|
|
||||||
|
|
||||||
--scrollbar-width: 8px;
|
--scrollbar-width: 8px;
|
||||||
/* 添加滚动条宽度变量 */
|
|
||||||
|
|
||||||
/* Shortcut styles */
|
--shortcut-bg: var(--color-accent-subtle);
|
||||||
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
--shortcut-border: var(--color-accent-border);
|
||||||
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
--shortcut-text: var(--text-primary);
|
||||||
--shortcut-text: var(--text-color);
|
|
||||||
|
--lora-accent-transparent: var(--color-accent-transparent);
|
||||||
|
|
||||||
|
/* Legacy spacing aliases: 8px base grid to match existing component usage */
|
||||||
|
--space-1: 8px;
|
||||||
|
--space-2: 16px;
|
||||||
|
--space-3: 24px;
|
||||||
|
--space-4: 32px;
|
||||||
|
|
||||||
|
/* Legacy border-radius aliases to match existing component usage */
|
||||||
|
--border-radius-xs: 4px;
|
||||||
|
--border-radius-sm: 6px;
|
||||||
|
--border-radius-base: 8px;
|
||||||
|
--border-radius-md: 12px;
|
||||||
|
--border-radius-lg: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-color: var(--bg-base);
|
||||||
|
--text-color: var(--text-primary);
|
||||||
|
--text-muted: var(--text-secondary);
|
||||||
|
--card-bg: var(--surface-base);
|
||||||
|
--border-color: var(--border-base);
|
||||||
|
|
||||||
|
--lora-accent: var(--color-accent);
|
||||||
|
--lora-surface: var(--bg-elevated);
|
||||||
|
--lora-border: var(--border-subtle);
|
||||||
|
--lora-text: var(--text-primary);
|
||||||
|
--lora-error: var(--color-error);
|
||||||
|
--lora-error-bg: var(--color-error-bg);
|
||||||
|
--lora-error-border: var(--color-error-border);
|
||||||
|
--lora-warning: var(--color-warning);
|
||||||
|
--lora-success: var(--color-success);
|
||||||
|
|
||||||
|
--badge-update-bg: var(--color-info-bg);
|
||||||
|
--badge-update-text: var(--color-info-text);
|
||||||
|
--badge-update-glow: var(--color-info-glow);
|
||||||
|
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
|
||||||
|
--badge-skip-refresh-text: var(--color-skip-refresh-text);
|
||||||
|
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg-color: var(--bg-base);
|
||||||
|
--text-color: var(--text-primary);
|
||||||
|
--text-muted: var(--text-secondary);
|
||||||
|
--card-bg: var(--surface-base);
|
||||||
|
--border-color: var(--border-base);
|
||||||
|
|
||||||
|
--lora-accent: var(--color-accent);
|
||||||
|
--lora-surface: var(--bg-elevated);
|
||||||
|
--lora-border: var(--border-subtle);
|
||||||
|
--lora-text: var(--text-primary);
|
||||||
|
--lora-error: var(--color-error);
|
||||||
|
--lora-error-bg: var(--color-error-bg);
|
||||||
|
--lora-error-border: var(--color-error-border);
|
||||||
|
--lora-warning: var(--color-warning);
|
||||||
|
--lora-success: var(--color-success);
|
||||||
|
|
||||||
|
--badge-update-bg: var(--color-info-bg);
|
||||||
|
--badge-update-text: var(--color-info-text);
|
||||||
|
--badge-update-glow: var(--color-info-glow);
|
||||||
|
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
|
||||||
|
--badge-skip-refresh-text: var(--color-skip-refresh-text);
|
||||||
|
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] {
|
html[data-theme="dark"] {
|
||||||
background-color: #1a1a1a !important;
|
background-color: var(--bg-base) !important;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="light"] {
|
html[data-theme="light"] {
|
||||||
background-color: #ffffff !important;
|
background-color: var(--bg-base) !important;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--bg-color: #1a1a1a;
|
|
||||||
--text-color: #e0e0e0;
|
|
||||||
--text-muted: #a0a0a0;
|
|
||||||
--card-bg: #2d2d2d;
|
|
||||||
--border-color: #404040;
|
|
||||||
|
|
||||||
--lora-accent: oklch(68% 0.28 256);
|
|
||||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
|
||||||
--lora-text: oklch(98% 0.02 256);
|
|
||||||
--lora-warning: oklch(75% 0.25 80);
|
|
||||||
/* Modified to be used with oklch() */
|
|
||||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
|
||||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
|
||||||
--badge-update-bg: oklch(62% 0.18 220);
|
|
||||||
--badge-update-text: oklch(98% 0.02 240);
|
|
||||||
--badge-update-glow: oklch(62% 0.18 220 / 0.4);
|
|
||||||
--badge-skip-refresh-bg: oklch(82% 0.12 45);
|
|
||||||
--badge-skip-refresh-text: oklch(98% 0.02 45);
|
|
||||||
--badge-skip-refresh-glow: oklch(82% 0.12 45 / 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI', sans-serif;
|
font-family: var(--font-body);
|
||||||
background: var(--bg-color);
|
background: var(--bg-base);
|
||||||
color: var(--text-color);
|
color: var(--text-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
/* Remove the padding-top */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus:not(:focus-visible),
|
||||||
|
input:focus:not(:focus-visible),
|
||||||
|
select:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-side);
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-side);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-alphabet-bar:hover {
|
.toggle-alphabet-bar:hover {
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
min-width: 24px;
|
min-width: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.letter-chip.active {
|
.letter-chip.active {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tertiary Action Button */
|
/* Tertiary Action Button */
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn:hover {
|
.back-btn:hover {
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* 卡片网格布局 */
|
/* Card grid layout */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
||||||
gap: 12px; /* Consistent gap for both row and column spacing */
|
gap: 12px; /* Consistent gap for both row and column spacing */
|
||||||
row-gap: 20px; /* Increase vertical spacing between rows */
|
row-gap: 20px; /* Increase vertical spacing between rows */
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
padding-top: 4px;
|
||||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
padding-bottom: 4px;
|
||||||
width: 100%; /* Ensure it takes full width of container */
|
width: 100%; /* Ensure it takes full width of container */
|
||||||
max-width: 1400px; /* Base container width */
|
max-width: 1400px; /* Base container width */
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -19,9 +19,10 @@
|
|||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
transition: transform 160ms ease-out;
|
transition: transform var(--transition-fast) ease-out, box-shadow var(--transition-fast) ease-out, border-color var(--transition-fast) ease-out;
|
||||||
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||||
max-width: 260px; /* Base size */
|
max-width: 260px; /* Base size */
|
||||||
|
min-width: 200px; /* Prevent cards from becoming too narrow */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
|
|
||||||
.model-card:hover {
|
.model-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
background: oklch(100% 0 0 / 0.6);
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card:focus-visible {
|
.model-card:focus-visible {
|
||||||
@@ -328,7 +330,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;
|
||||||
@@ -353,24 +354,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-1); /* Use gap instead of margin for spacing between icons */
|
gap: var(--space-1);
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions i:hover {
|
.card-actions i:hover,
|
||||||
|
.card-actions i:focus-visible {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for active favorites */
|
|
||||||
.favorite-active {
|
.favorite-active {
|
||||||
color: #ffc107 !important; /* Gold color for favorites */
|
color: var(--favorite-color) !important;
|
||||||
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
text-shadow: 0 0 5px var(--favorite-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
@media (max-width: 1200px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card {
|
||||||
|
max-width: 240px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
||||||
@@ -378,17 +393,10 @@
|
|||||||
|
|
||||||
.model-card {
|
.model-card {
|
||||||
max-width: 100%; /* Allow cards to fill available space on mobile */
|
max-width: 100%; /* Allow cards to fill available space on mobile */
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
|
||||||
flex-shrink: 0; /* Prevent actions from shrinking */
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-1);
|
|
||||||
align-items: flex-end; /* 将图标靠下对齐 */
|
|
||||||
align-self: flex-end; /* 将整个actions容器靠下对齐 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-link {
|
.model-link {
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
}
|
}
|
||||||
@@ -401,9 +409,13 @@
|
|||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-link a:hover {
|
.model-link a:hover,
|
||||||
|
.model-link a:focus-visible {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Updated model name to fix text cutoff issues */
|
/* Updated model name to fix text cutoff issues */
|
||||||
@@ -428,7 +440,7 @@
|
|||||||
|
|
||||||
.base-model {
|
.base-model {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #f0f0f0;
|
background: var(--surface-hover, oklch(95% 0 0));
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
@@ -497,21 +509,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 *,
|
||||||
@@ -563,8 +650,13 @@ body.hide-card-version .civitai-version {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
|
margin: 0;
|
||||||
width: 100%; /* Allow width to be set by the VirtualScroller */
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow cards to grow beyond 260px in virtual scroll mode */
|
||||||
|
.virtual-scroll-item.model-card {
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroll-item:hover {
|
.virtual-scroll-item:hover {
|
||||||
@@ -576,11 +668,11 @@ body.hide-card-version .civitai-version {
|
|||||||
.card-grid.virtual-scroll {
|
.card-grid.virtual-scroll {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 0 auto;
|
margin: 0; /* Remove auto margins - positioning handled by VirtualScroller leftOffset */
|
||||||
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1400px; /* Keep the max-width from original grid */
|
max-width: none; /* Remove max-width constraint - handled by VirtualScroller */
|
||||||
box-sizing: border-box; /* Include padding in width calculation */
|
box-sizing: border-box; /* Include padding in width calculation */
|
||||||
overflow-x: hidden; /* Prevent horizontal overflow */
|
overflow-x: hidden; /* Prevent horizontal overflow */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
|
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); /* Stronger shadow */
|
box-shadow: var(--shadow-lg); /* Stronger shadow */
|
||||||
transition: all 0.3s ease;
|
transition: var(--transition-slow);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner button.btn-exit-mode:hover {
|
.duplicates-banner button.btn-exit-mode:hover {
|
||||||
@@ -86,16 +86,16 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner button:hover {
|
.duplicates-banner button:hover {
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner button.btn-exit {
|
.duplicates-banner button.btn-exit {
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); /* Add subtle shadow to groups */
|
box-shadow: var(--shadow-md); /* Add subtle shadow to groups */
|
||||||
/* Add responsive width settings to match banner */
|
/* Add responsive width settings to match banner */
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -173,9 +173,9 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
box-shadow: var(--shadow-xs);
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-group-container {
|
.card-group-container {
|
||||||
@@ -230,20 +230,20 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-toggle-btn:hover {
|
.group-toggle-btn:hover {
|
||||||
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Duplicate card styling */
|
/* Duplicate card styling */
|
||||||
.model-card.duplicate {
|
.model-card.duplicate {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card.duplicate:hover {
|
.model-card.duplicate:hover {
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
|
|
||||||
.model-card.duplicate-selected {
|
.model-card.duplicate-selected {
|
||||||
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-card .selector-checkbox {
|
.model-card .selector-checkbox {
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
box-shadow: var(--shadow-lg);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-verify-hashes:hover {
|
.btn-verify-hashes:hover {
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -8px; /* Moved closer to button */
|
top: -8px; /* Moved closer to button */
|
||||||
right: -8px; /* Moved closer to button */
|
right: -8px; /* Moved closer to button */
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); /* Softer shadow */
|
box-shadow: var(--shadow-sm); /* Softer shadow */
|
||||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-icon:hover {
|
.help-icon:hover {
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-elevated);
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
@@ -572,16 +572,16 @@
|
|||||||
|
|
||||||
/* In dark mode, add additional distinction */
|
/* In dark mode, add additional distinction */
|
||||||
html[data-theme="dark"] .duplicates-banner {
|
html[data-theme="dark"] .duplicates-banner {
|
||||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); /* Stronger shadow in dark mode */
|
box-shadow: var(--shadow-dark-lg); /* Stronger shadow in dark mode */
|
||||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .duplicate-group {
|
html[data-theme="dark"] .duplicate-group {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); /* Stronger shadow in dark mode */
|
box-shadow: var(--shadow-lg); /* Stronger shadow in dark mode */
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .help-tooltip {
|
html[data-theme="dark"] .help-tooltip {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Styles for disabled controls during duplicates mode */
|
/* Styles for disabled controls during duplicates mode */
|
||||||
|
|||||||
@@ -7,22 +7,22 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
border: 1px solid var(--lora-accent);
|
border: 1px solid var(--lora-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group .filter-active:hover {
|
.control-group .filter-active:hover {
|
||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group .filter-active:active {
|
.control-group .filter-active:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group .filter-active i.fa-filter {
|
.control-group .filter-active i.fa-filter {
|
||||||
@@ -59,9 +59,9 @@
|
|||||||
|
|
||||||
/* Animation for filter indicator */
|
/* Animation for filter indicator */
|
||||||
@keyframes filterPulse {
|
@keyframes filterPulse {
|
||||||
0% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
0% { transform: scale(1); box-shadow: var(--shadow-sm); }
|
||||||
50% { transform: scale(1.03); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); }
|
50% { transform: scale(1.03); box-shadow: var(--shadow-lg); }
|
||||||
100% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
100% { transform: scale(1); box-shadow: var(--shadow-sm); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-active.animate {
|
.filter-active.animate {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
/* Reduced height */
|
/* Reduced height */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
/* Slightly stronger shadow */
|
/* Slightly stronger shadow */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,22 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left section: Logo + Navigation */
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right section: Controls */
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive header container for larger screens */
|
/* Responsive header container for larger screens */
|
||||||
@media (min-width: 2150px) {
|
@media (min-width: 2150px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -77,6 +93,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover,
|
.nav-item:hover,
|
||||||
@@ -97,13 +114,101 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header search */
|
/* Header search - Centered with VS Code command palette style */
|
||||||
.header-search {
|
.header-search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* VS Code command palette style search container */
|
||||||
|
.header-search .search-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg, var(--card-bg));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm, 6px);
|
||||||
|
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||||
|
box-shadow: var(--shadow-header);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container:focus-within {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: var(--shadow-header), 0 0 0 1px var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
padding-left: 2.25rem !important;
|
||||||
|
padding-right: 5rem !important;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle,
|
||||||
|
.header-search .search-filter-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
transition: background-color var(--transition-base), color var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle {
|
||||||
|
right: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle:hover,
|
||||||
|
.header-search .search-filter-toggle:hover,
|
||||||
|
.header-search .search-filter-toggle:focus-visible {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .filter-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--lora-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Disabled state for header search */
|
/* Disabled state for header search */
|
||||||
.header-search.disabled {
|
.header-search.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -166,7 +271,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: background-color var(--transition-base), color var(--transition-base), transform var(--transition-base);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +343,7 @@
|
|||||||
background-color: var(--lora-error);
|
background-color: var(--lora-error);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid var(--card-bg);
|
border: 2px solid var(--card-bg);
|
||||||
transition: all 0.2s ease;
|
transition: opacity var(--transition-base);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@@ -247,44 +352,216 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile adjustments */
|
/* Hamburger menu button - hidden by default */
|
||||||
@media (max-width: 768px) {
|
.hamburger-menu-btn {
|
||||||
.app-title {
|
display: none;
|
||||||
display: none;
|
width: 32px;
|
||||||
/* Hide text title on mobile */
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-base), color var(--transition-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn:hover,
|
||||||
|
.hamburger-menu-btn:focus-visible {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item:hover,
|
||||||
|
.hamburger-dropdown .dropdown-item:focus-visible {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger dropdown menu */
|
||||||
|
.hamburger-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm, 6px);
|
||||||
|
box-shadow: var(--shadow-toast);
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: var(--z-dropdown, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-base), color var(--transition-base);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item i {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.header-container {
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls>div {
|
.header-controls > div {
|
||||||
width: 28px;
|
width: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Hide nav icons at 1100px to save space */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.nav-item {
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
.app-title {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.active {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0 0.5rem;
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
margin-right: 0.5rem;
|
gap: 0.25rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For very small screens */
|
/* Responsive: Compact mode at 768px */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-search input {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
padding-left: 2rem !important;
|
||||||
|
padding-right: 4.5rem !important;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container {
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For very small screens - switch nav to icons only */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: none;
|
display: flex;
|
||||||
/* Hide navigation on very small screens */
|
gap: 0.15rem;
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.nav-item {
|
||||||
flex: 1;
|
padding: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Position relative for hamburger menu positioning */
|
||||||
|
.header-right {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|||||||
@@ -757,7 +757,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@
|
|||||||
background: rgba(var(--lora-accent), 0.05);
|
background: rgba(var(--lora-accent), 0.05);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips-header {
|
.tips-header {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-nav-hint i {
|
.keyboard-nav-hint i {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
transform: translateY(-15%); /* Vertically center */
|
transform: translateY(-15%); /* Vertically center */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -92,5 +92,5 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
width: min(400px, 90vw); /* 固定最大宽度,但保持响应式 */
|
width: min(400px, 90vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
.loading-status {
|
.loading-status {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: var(--text-color); /* 使用主题文本颜色 */
|
color: var(--text-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -42,11 +42,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
width: 280px; /* 固定进度条宽度 */
|
width: 280px;
|
||||||
background-color: var(--lora-border); /* 使用主题边框颜色 */
|
background-color: var(--lora-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 auto; /* 居中显示 */
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.model-description-content code {
|
.model-description-content code {
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
padding: 0.1em 0.3em;
|
padding: 0.1em 0.3em;
|
||||||
|
|||||||
@@ -105,14 +105,14 @@
|
|||||||
|
|
||||||
.info-item {
|
.info-item {
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 调整深色主题下的样式 */
|
/* Dark theme info item styles */
|
||||||
[data-theme="dark"] .info-item {
|
[data-theme="dark"] .info-item {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,18 +140,70 @@
|
|||||||
|
|
||||||
/* Add specific styles for notes content */
|
/* Add specific styles for notes content */
|
||||||
.info-item.notes .editable-field [contenteditable] {
|
.info-item.notes .editable-field [contenteditable] {
|
||||||
height: 60px; /* Keep initial modal layout stable regardless of note length */
|
min-height: 60px;
|
||||||
min-height: 60px; /* Increase height for multiple lines */
|
white-space: pre-wrap;
|
||||||
max-height: 420px; /* Limit maximum height */
|
line-height: 1.5;
|
||||||
overflow: auto; /* Enable scrolling and resize handle for long content */
|
padding: 8px 12px;
|
||||||
resize: vertical; /* Allow manual vertical resizing */
|
}
|
||||||
white-space: pre-wrap; /* Preserve line breaks */
|
|
||||||
line-height: 1.5; /* Improve readability */
|
/* Notes expand/collapse — collapsed by default; only applies when JS detects long content */
|
||||||
padding: 8px 12px; /* Slightly increase padding */
|
.info-item.notes .editable-field {
|
||||||
|
position: relative;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item.notes .editable-field.collapsed {
|
||||||
|
max-height: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient fade overlay hint when collapsed */
|
||||||
|
.info-item.notes .editable-field.collapsed::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 28px;
|
||||||
|
background: linear-gradient(transparent, var(--bg-color));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes header row — label left, toggle button right */
|
||||||
|
.notes-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle button — icon only, inline with the label */
|
||||||
|
.notes-toggle-btn {
|
||||||
|
display: none; /* shown by JS when content exceeds threshold */
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-toggle-btn:hover {
|
||||||
|
background: rgba(66, 153, 225, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-toggle-btn i {
|
||||||
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-path {
|
.file-path {
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,13 +271,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
|
/* Back-to-top button pinned inside modal */
|
||||||
.modal-content .back-to-top {
|
.modal-content .back-to-top {
|
||||||
position: sticky; /* 改用 sticky 定位 */
|
position: sticky;
|
||||||
float: right; /* 使用 float 确保按钮在右侧 */
|
float: right;
|
||||||
bottom: 20px; /* 距离底部的距离 */
|
bottom: 20px;
|
||||||
margin-right: 20px; /* 右侧间距 */
|
margin-right: 20px;
|
||||||
margin-top: -56px; /* 负边距确保不占用额外空间 */
|
margin-top: -56px;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -239,7 +291,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
transition: all 0.3s ease;
|
transition: opacity var(--transition-slow), visibility var(--transition-slow), transform var(--transition-slow);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,35 +307,39 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* File name copy styles */
|
/* Editable inline field styles (file name, version name, etc.) */
|
||||||
.file-name-wrapper {
|
.file-name-wrapper,
|
||||||
|
.version-name-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px;
|
padding: 4px 0;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name-content {
|
.file-name-content,
|
||||||
padding: 2px 4px;
|
.version-name-content {
|
||||||
|
padding: 2px 4px 2px 0;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name-wrapper.editing .file-name-content {
|
.file-name-wrapper.editing .file-name-content,
|
||||||
|
.version-name-wrapper.editing .version-name-content {
|
||||||
border: 1px solid var(--lora-accent);
|
border: 1px solid var(--lora-accent);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 合并编辑按钮样式 */
|
/* Consolidated edit button styles */
|
||||||
.edit-model-name-btn,
|
.edit-model-name-btn,
|
||||||
.edit-file-name-btn,
|
.edit-file-name-btn,
|
||||||
.edit-base-model-btn,
|
.edit-base-model-btn,
|
||||||
.edit-model-description-btn {
|
.edit-model-description-btn,
|
||||||
|
.edit-version-name-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -291,7 +347,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: all 0.2s ease;
|
transition: opacity var(--transition-base), background-color var(--transition-base);
|
||||||
margin-left: var(--space-1);
|
margin-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,9 +355,11 @@
|
|||||||
.edit-file-name-btn.visible,
|
.edit-file-name-btn.visible,
|
||||||
.edit-base-model-btn.visible,
|
.edit-base-model-btn.visible,
|
||||||
.edit-model-description-btn.visible,
|
.edit-model-description-btn.visible,
|
||||||
|
.edit-version-name-btn.visible,
|
||||||
.model-name-header:hover .edit-model-name-btn,
|
.model-name-header:hover .edit-model-name-btn,
|
||||||
.file-name-wrapper:hover .edit-file-name-btn,
|
.file-name-wrapper:hover .edit-file-name-btn,
|
||||||
.base-model-display:hover .edit-base-model-btn,
|
.base-model-display:hover .edit-base-model-btn,
|
||||||
|
.version-name-wrapper:hover .edit-version-name-btn,
|
||||||
.model-name-header:hover .edit-model-description-btn {
|
.model-name-header:hover .edit-model-description-btn {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
@@ -309,14 +367,16 @@
|
|||||||
.edit-model-name-btn:hover,
|
.edit-model-name-btn:hover,
|
||||||
.edit-file-name-btn:hover,
|
.edit-file-name-btn:hover,
|
||||||
.edit-base-model-btn:hover,
|
.edit-base-model-btn:hover,
|
||||||
.edit-model-description-btn:hover {
|
.edit-model-description-btn:hover,
|
||||||
opacity: 0.8 !important;
|
.edit-version-name-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .edit-model-name-btn:hover,
|
[data-theme="dark"] .edit-model-name-btn:hover,
|
||||||
[data-theme="dark"] .edit-file-name-btn:hover,
|
[data-theme="dark"] .edit-file-name-btn:hover,
|
||||||
[data-theme="dark"] .edit-base-model-btn:hover {
|
[data-theme="dark"] .edit-base-model-btn:hover,
|
||||||
|
[data-theme="dark"] .edit-version-name-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +387,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.base-wrapper {
|
.base-wrapper {
|
||||||
flex: 2; /* 分配更多空间给base model */
|
flex: 2; /* Allocate more space to base model */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base model display and editing styles */
|
/* Base model display and editing styles */
|
||||||
@@ -338,7 +398,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.base-model-content {
|
.base-model-content {
|
||||||
padding: 2px 4px;
|
padding: 2px 4px 2px 0;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -370,7 +430,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.size-wrapper span {
|
.size-wrapper span {
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
@@ -387,7 +447,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
font-size: 1.5em !important;
|
font-size: 1.5em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -423,7 +483,7 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -828,18 +888,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
max-width: fit-content;
|
max-width: fit-content;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .creator-info,
|
[data-theme="dark"] .creator-info,
|
||||||
[data-theme="dark"] .civitai-view,
|
[data-theme="dark"] .civitai-view,
|
||||||
[data-theme="dark"] .modal-send-btn {
|
[data-theme="dark"] .modal-send-btn {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,14 +958,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.civitai-view i {
|
.civitai-view i {
|
||||||
@@ -921,18 +981,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .modal-send-btn {
|
[data-theme="dark"] .modal-send-btn {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: calc(var(--space-1) * 0.5) var(--space-1);
|
padding: calc(var(--space-1) * 0.5) var(--space-1);
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tag span {
|
.preset-tag span {
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tag:hover {
|
.preset-tag:hover {
|
||||||
|
|||||||
@@ -111,8 +111,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-md);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
|
|
||||||
.media-control-btn:hover {
|
.media-control-btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-control-btn.set-preview-btn:hover {
|
.media-control-btn.set-preview-btn:hover {
|
||||||
@@ -141,8 +141,9 @@
|
|||||||
border-color: var(--lora-error);
|
border-color: var(--lora-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disabled state for delete button */
|
/* Disabled state for delete and create-recipe buttons */
|
||||||
.media-control-btn.example-delete-btn.disabled {
|
.media-control-btn.example-delete-btn.disabled,
|
||||||
|
.media-control-btn.create-recipe-btn.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -204,7 +205,7 @@
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
max-height: 50%; /* Reduced to take less space */
|
max-height: 50%; /* Reduced to take less space */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-inset-top);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@
|
|||||||
/* Adjust to dark theme */
|
/* Adjust to dark theme */
|
||||||
[data-theme="dark"] .image-metadata-panel {
|
[data-theme="dark"] .image-metadata-panel {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-inset-top);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-content {
|
.metadata-content {
|
||||||
@@ -296,7 +297,7 @@
|
|||||||
|
|
||||||
.metadata-prompt {
|
.metadata-prompt {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@@ -311,7 +312,7 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-prompt-btn:hover {
|
.copy-prompt-btn:hover {
|
||||||
@@ -408,7 +409,7 @@
|
|||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.3s ease;
|
transition: var(--transition-slow);
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -454,9 +455,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.import-formats {
|
.import-formats {
|
||||||
font-size: 0.8em !important;
|
font-size: 0.8em;
|
||||||
opacity: 0.6 !important;
|
opacity: 0.6;
|
||||||
margin-top: var(--space-2) !important;
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-files-btn {
|
.select-files-btn {
|
||||||
@@ -470,7 +471,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-files-btn:hover {
|
.select-files-btn:hover {
|
||||||
@@ -480,7 +481,7 @@
|
|||||||
|
|
||||||
/* For dark theme */
|
/* For dark theme */
|
||||||
[data-theme="dark"] .import-container {
|
[data-theme="dark"] .import-container {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Setup Guidance State - When example images path is not configured */
|
/* Setup Guidance State - When example images path is not configured */
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
.model-tag-compact {
|
.model-tag-compact {
|
||||||
/* Updated styles to match info-item appearance */
|
/* Updated styles to match info-item appearance */
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
/* Adjust dark theme tag styles */
|
/* Adjust dark theme tag styles */
|
||||||
[data-theme="dark"] .model-tag-compact {
|
[data-theme="dark"] .model-tag-compact {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,14 +73,14 @@
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: var(--shadow-lg);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
.tooltip-tag {
|
.tooltip-tag {
|
||||||
/* Updated styles to match info-item appearance */
|
/* Updated styles to match info-item appearance */
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-hover);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
/* Adjust dark theme tooltip tag styles */
|
/* Adjust dark theme tooltip tag styles */
|
||||||
[data-theme="dark"] .tooltip-tag {
|
[data-theme="dark"] .tooltip-tag {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-hover);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
margin-left: var(--space-1);
|
margin-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/* Update Trigger Words styles */
|
/* Update Trigger Words styles */
|
||||||
.info-item.trigger-words {
|
.info-item.trigger-words {
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 调整 trigger words 样式 */
|
/* Trigger words styles */
|
||||||
[data-theme="dark"] .info-item.trigger-words {
|
[data-theme="dark"] .info-item.trigger-words {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
|
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
box-shadow: var(--shadow-xs);
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
|
|
||||||
.model-version-row:hover {
|
.model-version-row:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
box-shadow: var(--shadow-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-version-row.is-clickable {
|
.model-version-row.is-clickable {
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
height: 88px;
|
height: 88px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-hover);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -374,11 +374,23 @@
|
|||||||
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-action-disabled {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.version-action:disabled {
|
.version-action:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
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
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: var(--shadow-dark-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-dropdown);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: none;
|
display: none;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -21,9 +21,11 @@
|
|||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-item:hover {
|
.context-menu-item:hover,
|
||||||
|
.context-menu-item:focus-visible {
|
||||||
background-color: var(--lora-accent);
|
background-color: var(--lora-accent);
|
||||||
color: var(--lora-text);
|
color: var(--lora-text);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-separator {
|
.context-menu-separator {
|
||||||
@@ -41,6 +43,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: var(--shadow-dropdown);
|
||||||
|
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;
|
||||||
@@ -51,7 +110,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-modal);
|
||||||
z-index: var(--z-modal);
|
z-index: var(--z-modal);
|
||||||
width: 300px;
|
width: 300px;
|
||||||
display: none;
|
display: none;
|
||||||
@@ -105,7 +164,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nsfw-level-btn:hover {
|
.nsfw-level-btn:hover {
|
||||||
@@ -129,7 +188,7 @@
|
|||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-dropdown);
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
display: none;
|
display: none;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* modal 基础样式 */
|
/* Modal base styles */
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -6,19 +6,19 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
|
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
|
||||||
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
|
background: rgba(0, 0, 0, 0.2);
|
||||||
z-index: var(--z-modal);
|
z-index: var(--z-modal);
|
||||||
overflow: auto; /* Change from hidden to auto to allow scrolling */
|
overflow: auto; /* Change from hidden to auto to allow scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 当模态窗口打开时,禁止body滚动 */
|
/* Prevent body scroll when modal is open */
|
||||||
body.modal-open {
|
body.modal-open {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-right: var(--scrollbar-width, 0px); /* 补偿滚动条消失导致的页面偏移 */
|
padding-right: var(--scrollbar-width, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* modal-content 样式 */
|
/* Modal content styles */
|
||||||
.modal-content {
|
.modal-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
@@ -29,12 +29,9 @@ body.modal-open {
|
|||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
box-shadow:
|
box-shadow: var(--shadow-md);
|
||||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
|
||||||
0 2px 4px -1px rgba(0, 0, 0, 0.06),
|
|
||||||
0 10px 15px -3px rgba(0, 0, 0, 0.05);
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden; /* 防止水平滚动条 */
|
overflow-x: hidden;
|
||||||
scrollbar-gutter: stable both-edges; /* Reserve space to prevent layout shift when scrollbar toggles */
|
scrollbar-gutter: stable both-edges; /* Reserve space to prevent layout shift when scrollbar toggles */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +39,10 @@ body.modal-open {
|
|||||||
min-height: 480px;
|
min-height: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 当 modal 打开时锁定 body */
|
/* Lock body when modal is open */
|
||||||
body.modal-open {
|
body.modal-open {
|
||||||
overflow: hidden !important; /* 覆盖 base.css 中的 scroll */
|
overflow: hidden !important;
|
||||||
padding-right: var(--scrollbar-width, 8px); /* 使用滚动条宽度作为补偿 */
|
padding-right: var(--scrollbar-width, 8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes modalFadeIn {
|
@keyframes modalFadeIn {
|
||||||
@@ -67,12 +64,25 @@ body.modal-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
|
.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
|
||||||
padding: 8px var(--space-2);
|
display: flex;
|
||||||
border-radius: 6px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-size: 0.95em;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
transition: background-color var(--transition-base), opacity var(--transition-base), transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:active,
|
||||||
|
.delete-btn:active,
|
||||||
|
.exclude-btn:active,
|
||||||
|
.confirm-btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn {
|
.cancel-btn {
|
||||||
@@ -92,16 +102,20 @@ body.modal-open {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn:hover {
|
.cancel-btn:hover,
|
||||||
|
.cancel-btn:focus-visible {
|
||||||
background: var(--lora-border);
|
background: var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn:hover {
|
.delete-btn:hover,
|
||||||
opacity: 0.9;
|
.delete-btn:focus-visible {
|
||||||
|
background: oklch(from var(--lora-error) l c h / 85%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.exclude-btn:hover, .confirm-btn:hover {
|
.exclude-btn:hover,
|
||||||
opacity: 0.9;
|
.exclude-btn:focus-visible,
|
||||||
|
.confirm-btn:hover,
|
||||||
|
.confirm-btn:focus-visible {
|
||||||
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
|
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,47 +135,41 @@ body.modal-open {
|
|||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.2s;
|
transition: opacity var(--transition-base);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close:hover {
|
.close:hover,
|
||||||
|
.close:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 统一各个 section 的样式 */
|
/* Unified section styles */
|
||||||
.support-section,
|
.support-section,
|
||||||
.changelog-section,
|
.changelog-section,
|
||||||
.update-info,
|
.update-info,
|
||||||
.info-item,
|
.info-item,
|
||||||
.path-preview {
|
.path-preview {
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色主题统一样式 */
|
/* Dark theme unified styles */
|
||||||
[data-theme="dark"] .modal-content {
|
[data-theme="dark"] .modal-content {
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .support-section,
|
|
||||||
[data-theme="dark"] .changelog-section,
|
|
||||||
[data-theme="dark"] .update-info,
|
|
||||||
[data-theme="dark"] .info-item,
|
|
||||||
[data-theme="dark"] .path-preview,
|
|
||||||
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-btn {
|
.primary-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: var(--space-1) var(--space-2);
|
||||||
background-color: var(--lora-accent);
|
background-color: var(--lora-accent);
|
||||||
color: var(--lora-text);
|
color: var(--lora-text);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -171,9 +179,11 @@ body.modal-open {
|
|||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-btn:hover {
|
.primary-btn:hover,
|
||||||
|
.primary-btn:focus-visible {
|
||||||
background-color: oklch(from var(--lora-accent) l c h / 85%);
|
background-color: oklch(from var(--lora-accent) l c h / 85%);
|
||||||
color: var(--lora-text);
|
color: var(--lora-text);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary button styles */
|
/* Secondary button styles */
|
||||||
@@ -181,19 +191,21 @@ body.modal-open {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: var(--space-1) var(--space-2);
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
color: var (--text-color);
|
color: var(--text-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-btn:hover {
|
.secondary-btn:hover,
|
||||||
|
.secondary-btn:focus-visible {
|
||||||
background-color: var(--border-color);
|
background-color: var(--border-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disabled button styles */
|
/* Disabled button styles */
|
||||||
@@ -244,7 +256,7 @@ button:disabled,
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: var(--space-1) var(--space-2);
|
||||||
background-color: var(--lora-error);
|
background-color: var(--lora-error);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -254,25 +266,22 @@ button:disabled,
|
|||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-btn:hover {
|
.danger-btn:hover,
|
||||||
|
.danger-btn:focus-visible {
|
||||||
background-color: oklch(from var(--lora-error) l c h / 85%);
|
background-color: oklch(from var(--lora-error) l c h / 85%);
|
||||||
color: white;
|
color: white;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metadata archive status styles */
|
/* Metadata archive status styles */
|
||||||
.metadata-archive-status {
|
.metadata-archive-status {
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .metadata-archive-status {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-status-item {
|
.archive-status-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -312,17 +321,12 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-status {
|
.backup-status {
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .backup-status {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-summary-grid {
|
.backup-summary-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
@@ -331,17 +335,12 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-summary-card {
|
.backup-summary-card {
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: var(--lora-surface);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .backup-summary-card {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
border-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-summary-label {
|
.backup-summary-label {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -404,14 +403,9 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-location-details {
|
.backup-location-details {
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
background: rgba(0, 0, 0, 0.02);
|
background: var(--surface-subtle);
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .backup-location-details {
|
|
||||||
border-color: var(--lora-border);
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-location-details summary {
|
.backup-location-details summary {
|
||||||
@@ -442,16 +436,12 @@ button:disabled,
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: var(--surface-subtle);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .backup-location-path {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.backup-status-row {
|
.backup-status-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -519,8 +509,8 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
@@ -578,7 +568,7 @@ button:disabled,
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: oklch(from var(--lora-accent) l c h / 0.1);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
|||||||
@@ -33,6 +33,39 @@
|
|||||||
animation: modalFadeIn 0.2s ease-out;
|
animation: modalFadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#resolveFilenameConflictsModal .confirmation-message {
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#resolveFilenameConflictsModal .resolve-conflicts-detail {
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#resolveFilenameConflictsModal .resolve-conflicts-detail code {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#resolveFilenameConflictsModal .resolve-conflicts-impact {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.delete-model-info,
|
.delete-model-info,
|
||||||
.exclude-model-info {
|
.exclude-model-info {
|
||||||
/* Update info display styling */
|
/* Update info display styling */
|
||||||
|
|||||||
@@ -48,8 +48,7 @@
|
|||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-kicker {
|
.doctor-kicker {
|
||||||
@@ -128,7 +127,7 @@
|
|||||||
|
|
||||||
.doctor-issue-card {
|
.doctor-issue-card {
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: var(--surface-subtle);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -242,7 +241,7 @@
|
|||||||
|
|
||||||
[data-theme="dark"] .doctor-hero,
|
[data-theme="dark"] .doctor-hero,
|
||||||
[data-theme="dark"] .doctor-issue-card {
|
[data-theme="dark"] .doctor-issue-card {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--surface-subtle);
|
||||||
border-color: var(--lora-border);
|
border-color: var(--lora-border);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
margin: 1px;
|
margin: 1px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
.version-item:hover {
|
.version-item:hover {
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-expand-icon:hover {
|
.tree-expand-icon:hover {
|
||||||
@@ -364,7 +364,7 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-folder-form button.confirm {
|
.create-folder-form button.confirm {
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
.path-display {
|
.path-display {
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-family: monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -453,7 +453,7 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: var(--border-color);
|
background-color: var(--border-color);
|
||||||
transition: all 0.3s ease;
|
transition: var(--transition-slow);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,9 +465,9 @@
|
|||||||
left: 3px;
|
left: 3px;
|
||||||
bottom: 3px;
|
bottom: 3px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
transition: all 0.3s ease;
|
transition: var(--transition-slow);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-toggle-container .toggle-switch input:checked+.toggle-slider {
|
.inline-toggle-container .toggle-switch input:checked+.toggle-slider {
|
||||||
@@ -502,4 +502,323 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Count Badge on Version Items */
|
||||||
|
.file-select-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: oklch(var(--lora-accent) / 0.18);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
border: 1px solid oklch(var(--lora-accent) / 0.35);
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: 0 1px 2px oklch(var(--lora-accent) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge:hover {
|
||||||
|
background: oklch(var(--lora-accent) / 0.3);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 6px oklch(var(--lora-accent) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge i {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge .badge-arrow {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-size: 0.65em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Selection Step */
|
||||||
|
.file-selection-header {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-header h3 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-version-name {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option.selected {
|
||||||
|
border: 2px solid var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-radio {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-radio input[type="radio"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.format {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.fp {
|
||||||
|
background: oklch(0.6 0.15 250 / 0.1);
|
||||||
|
color: oklch(0.55 0.15 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.size {
|
||||||
|
background: oklch(0.55 0.1 160 / 0.1);
|
||||||
|
color: oklch(0.5 0.12 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-name {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-size {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] .file-option {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .file-tag.fp {
|
||||||
|
background: oklch(0.55 0.12 250 / 0.15);
|
||||||
|
color: oklch(0.7 0.12 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .file-tag.size {
|
||||||
|
background: oklch(0.5 0.08 160 / 0.15);
|
||||||
|
color: oklch(0.65 0.08 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea for multi-URL input */
|
||||||
|
#modelUrl {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9em;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-hint i {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Batch Preview List */
|
||||||
|
.batch-preview-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-item:first-child {
|
||||||
|
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-item:last-child {
|
||||||
|
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-item:only-child {
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--lora-error);
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-error-text {
|
||||||
|
color: var(--lora-error);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-local-badge {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-local {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-change-version {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-error {
|
||||||
|
background: oklch(0.5 0.15 25 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .batch-preview-item {
|
||||||
|
background: var(--lora-surface);
|
||||||
}
|
}
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
background-color: var(--lora-surface);
|
background-color: var(--lora-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.example-option-btn:hover {
|
.example-option-btn:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,5 +68,5 @@
|
|||||||
|
|
||||||
/* Dark theme adjustments */
|
/* Dark theme adjustments */
|
||||||
[data-theme="dark"] .example-option-btn:hover {
|
[data-theme="dark"] .example-option-btn:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
box-shadow: var(--shadow-elevated);
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s;
|
transition: var(--transition-base);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
animation: fadeIn 0.5s ease-in-out;
|
animation: fadeIn 0.5s ease-in-out;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
box-shadow: var(--shadow-sm);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
|
|
||||||
/* Dark theme adjustments for new content badge */
|
/* Dark theme adjustments for new content badge */
|
||||||
[data-theme="dark"] .new-content-badge {
|
[data-theme="dark"] .new-content-badge {
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update video list styles */
|
/* Update video list styles */
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
animation: fadeIn 0.5s ease-in-out;
|
animation: fadeIn 0.5s ease-in-out;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-date-badge i {
|
.update-date-badge i {
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
|
|
||||||
/* Dark theme adjustments */
|
/* Dark theme adjustments */
|
||||||
[data-theme="dark"] .update-date-badge {
|
[data-theme="dark"] .update-date-badge {
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Privacy-friendly video embed styles */
|
/* Privacy-friendly video embed styles */
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: var(--transition-base);
|
||||||
background-color: var(--lora-accent);
|
background-color: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -303,5 +303,5 @@
|
|||||||
|
|
||||||
/* Dark theme adjustments */
|
/* Dark theme adjustments */
|
||||||
[data-theme="dark"] .video-container {
|
[data-theme="dark"] .video-container {
|
||||||
background-color: rgba(255, 255, 255, 0.03);
|
background-color: var(--surface-hover);
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user