mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-07 14:30:15 -03:00
Compare commits
491 Commits
c4fa1631ee
...
feat/gemin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f5f7df5d | ||
|
|
daf01fb1d6 | ||
|
|
0f11b6def9 | ||
|
|
7df83f44b8 | ||
|
|
169fa7bed6 | ||
|
|
027b504fe8 | ||
|
|
186ef4da78 | ||
|
|
dc674098e7 | ||
|
|
9087b4b07c | ||
|
|
8e45c22d7a | ||
|
|
191c4e03cd | ||
|
|
ab4154c57d | ||
|
|
28e93d12ff | ||
|
|
75e63c758b | ||
|
|
823f71f269 | ||
|
|
042dd4088d | ||
|
|
eaa791a9eb | ||
|
|
2228627ff4 | ||
|
|
4c647ad9c8 | ||
|
|
8ca3e6c33f | ||
|
|
dd6bdbf297 | ||
|
|
b47dde87e4 | ||
|
|
99e65cccd8 | ||
|
|
3bdacb8f46 | ||
|
|
b4f9c224d3 | ||
|
|
5ec0399c81 | ||
|
|
b464fdc333 | ||
|
|
53825500db | ||
|
|
f2ac790752 | ||
|
|
0d8805cdee | ||
|
|
656e24ac9b | ||
|
|
6718b37403 | ||
|
|
c9e5e784fc | ||
|
|
f92f958682 | ||
|
|
f63fab0676 | ||
|
|
cfc4903c0c | ||
|
|
a527a847fe | ||
|
|
91b0bf8933 | ||
|
|
66d1c96783 | ||
|
|
986128076e | ||
|
|
1de0a53241 | ||
|
|
0ec7eaf606 | ||
|
|
d9fcb0e92b | ||
|
|
f49b4ba4db | ||
|
|
84e708328b | ||
|
|
125bed3f09 | ||
|
|
077e70169d | ||
|
|
e6dc169a05 | ||
|
|
f34c02756d | ||
|
|
1e4c315481 | ||
|
|
a8283a0d00 | ||
|
|
55896669fc | ||
|
|
e341e0b9d2 | ||
|
|
e6538c83bb | ||
|
|
92e1285ea5 | ||
|
|
2aabd1d90e | ||
|
|
7b8b778f83 | ||
|
|
7c8dc57d55 | ||
|
|
fe95fae5f2 | ||
|
|
ce8a95abf7 | ||
|
|
c8e7e543d6 | ||
|
|
a9dbb15ffa | ||
|
|
cf64043f7d | ||
|
|
ccaff92c18 | ||
|
|
585b5c922a | ||
|
|
ea80c2224c | ||
|
|
8b0f56c1a6 | ||
|
|
8022d12f03 | ||
|
|
3939f7f91b | ||
|
|
aebf2e37dd | ||
|
|
f53f859a71 | ||
|
|
d916375abe | ||
|
|
57983df4bd | ||
|
|
c68d7559a0 | ||
|
|
9a8f5bf2d6 | ||
|
|
a8d742b031 | ||
|
|
c27e4d1bfc | ||
|
|
d15a8aa9a2 | ||
|
|
74a7d12ca4 | ||
|
|
2f94a9773e | ||
|
|
37bdfa21ea | ||
|
|
f0bf2728c9 | ||
|
|
dc715aa273 | ||
|
|
7ee2361e87 | ||
|
|
e04c22f83f | ||
|
|
681cc13e90 | ||
|
|
090e0297d4 | ||
|
|
6f71335be4 | ||
|
|
7f51812c1e | ||
|
|
a9dc4d7b9d | ||
|
|
5d50ddb5d4 | ||
|
|
f86198d234 | ||
|
|
ffe65d983c | ||
|
|
b0b5be913c | ||
|
|
01efcbc584 | ||
|
|
02c249917a | ||
|
|
419bbc90b2 | ||
|
|
b0c4510fdb | ||
|
|
bf6a614e0d | ||
|
|
feab01cd9c | ||
|
|
966024e534 | ||
|
|
2018722cc8 | ||
|
|
9d85c2a44a | ||
|
|
03dd047e62 | ||
|
|
86b547c1e0 | ||
|
|
bab9752c8b | ||
|
|
774cc1be86 | ||
|
|
234b73c8a2 | ||
|
|
abd06c48f4 | ||
|
|
6ca411e4e4 | ||
|
|
6470021e77 | ||
|
|
71658ab37b | ||
|
|
4f016a8024 | ||
|
|
f362ed585b | ||
|
|
196172624f | ||
|
|
316702b7ab | ||
|
|
a7625b009f | ||
|
|
5d4a33c90d | ||
|
|
041a6b8525 | ||
|
|
2638109ad6 | ||
|
|
b019326747 | ||
|
|
54b44131b6 | ||
|
|
a1d948025c | ||
|
|
a90b2514ba | ||
|
|
cb4ad27813 | ||
|
|
637831248b | ||
|
|
00228deaaa | ||
|
|
2373edf73c | ||
|
|
e0e1b804a7 | ||
|
|
fecbe8241f | ||
|
|
5983eaa1ce | ||
|
|
07fa454f72 | ||
|
|
4b5aa45379 | ||
|
|
9a0d866be4 | ||
|
|
308d8f71b8 | ||
|
|
d0e8938039 | ||
|
|
13ed898b6b | ||
|
|
e1dfd1c2a6 | ||
|
|
e3e944911b | ||
|
|
51c0135250 | ||
|
|
7b19bbb14e | ||
|
|
5494a70f40 | ||
|
|
26c9ade1c9 | ||
|
|
87db23825f | ||
|
|
8fb00998a7 | ||
|
|
dd3aa97d0a | ||
|
|
8bee8f4069 | ||
|
|
817fe21b3e | ||
|
|
905c37290f | ||
|
|
f7632a47f9 | ||
|
|
646f1ddfb1 | ||
|
|
170c8068c5 | ||
|
|
3494037d20 | ||
|
|
a1fd4e150b | ||
|
|
b22f09bd1d | ||
|
|
4ed9169646 | ||
|
|
f06c60bd47 | ||
|
|
ee8250c26c | ||
|
|
88349bf944 | ||
|
|
a8adcaf023 | ||
|
|
63785f82b5 | ||
|
|
cf898da193 | ||
|
|
3c83e78d9f | ||
|
|
d7291f73c9 | ||
|
|
fe90f7f9b1 | ||
|
|
8b344ea39f | ||
|
|
8348a0cef8 | ||
|
|
7cf785b72f | ||
|
|
e8913f4481 | ||
|
|
f9c3d8dc97 | ||
|
|
09ca91fc0e | ||
|
|
16f5222efd | ||
|
|
28e7c04b37 | ||
|
|
28f99c46d3 | ||
|
|
205194f4e6 | ||
|
|
402d8b07cf | ||
|
|
3e303ab316 | ||
|
|
e9e8c31ad1 | ||
|
|
703a6a4ea0 | ||
|
|
283730cf38 | ||
|
|
20417797e8 | ||
|
|
004c69b9ef | ||
|
|
47fe2d3783 | ||
|
|
36ef840a22 | ||
|
|
09c2445ac9 | ||
|
|
8a6d23f9c7 | ||
|
|
3d207b6744 | ||
|
|
b3edda62ad | ||
|
|
a429e6b1c3 | ||
|
|
c1bf9c6221 | ||
|
|
75fffc1e25 | ||
|
|
f264bab65c | ||
|
|
154fcd803b | ||
|
|
4ef32d3a96 | ||
|
|
d2d109a69c | ||
|
|
3a2941d751 | ||
|
|
0ac10dfd42 | ||
|
|
9c95856b2f | ||
|
|
5ce4667d32 | ||
|
|
be53fda6df | ||
|
|
f48de05102 | ||
|
|
93ad81ed87 | ||
|
|
ea14d211be | ||
|
|
8052cefd46 | ||
|
|
845815b9b7 | ||
|
|
609dc5d783 | ||
|
|
7a71b34b54 | ||
|
|
71a459422f | ||
|
|
cd2628a0ee | ||
|
|
85da7175bc | ||
|
|
d3bf0a164b | ||
|
|
afb6ca1b8d | ||
|
|
94f43426d7 | ||
|
|
2b361f4f5d | ||
|
|
7438072f8c | ||
|
|
26c54fd358 | ||
|
|
7cb6b04c63 | ||
|
|
fc29cde82a | ||
|
|
559ca946dc | ||
|
|
2b8e7c7504 | ||
|
|
6816d75933 | ||
|
|
b58abbad7c | ||
|
|
999814ca87 | ||
|
|
3c2760a803 | ||
|
|
0edbd7bcca | ||
|
|
21e89fa7de | ||
|
|
968d6d1d1f | ||
|
|
cf0fd0e0ad | ||
|
|
16e5dcf7b2 | ||
|
|
ab6bb25d46 | ||
|
|
07f49559be | ||
|
|
b24b1a7e57 | ||
|
|
faf64f8986 | ||
|
|
a617487a43 | ||
|
|
3012a7aef3 | ||
|
|
499e19de34 | ||
|
|
9161762ca9 | ||
|
|
9bbd26efe6 | ||
|
|
258b2622d5 | ||
|
|
80ec9085dd | ||
|
|
c5c7373e10 | ||
|
|
b7721866e5 | ||
|
|
8314b9bedb | ||
|
|
75298a402f | ||
|
|
92b5efd414 | ||
|
|
33ee392b7b | ||
|
|
5237f8b7dc | ||
|
|
5107313fd1 | ||
|
|
95bbc66919 | ||
|
|
e268e59419 | ||
|
|
547e1f9498 | ||
|
|
bf32d8b6fd | ||
|
|
8299881024 | ||
|
|
da02268196 | ||
|
|
8c4b9a1e70 | ||
|
|
0906c484e9 | ||
|
|
4199c30fec | ||
|
|
4a8084cdbc | ||
|
|
6263e6848c | ||
|
|
58c266ad07 | ||
|
|
2939813e1a | ||
|
|
a9e5ee7e79 | ||
|
|
a17b0e9901 | ||
|
|
8f23d966bf | ||
|
|
7a76fc72d0 | ||
|
|
518a4dd5ee | ||
|
|
2b6d4e5d8b | ||
|
|
1f4edbeb9d | ||
|
|
a256558a0e | ||
|
|
818b9113f0 | ||
|
|
6a4fd020dc | ||
|
|
7a23040452 | ||
|
|
138024aefe | ||
|
|
a19ddc14f6 | ||
|
|
7001ced694 | ||
|
|
a5c861646c | ||
|
|
3e0bb73793 | ||
|
|
ac51f6a2f6 | ||
|
|
bef222c77d | ||
|
|
7cd6a53447 | ||
|
|
6850b35770 | ||
|
|
237a015cde | ||
|
|
1ae2778baa | ||
|
|
84fcdb5f20 | ||
|
|
8a0b368b44 | ||
|
|
3990535505 | ||
|
|
3e961a9860 | ||
|
|
d6669f1d04 | ||
|
|
519bafebc8 | ||
|
|
d87863b423 | ||
|
|
84e9fe2dfb | ||
|
|
46cbcf94c8 | ||
|
|
05f3018495 | ||
|
|
f565cc35ca | ||
|
|
dd1cdce16d | ||
|
|
a9e0e7dc8d | ||
|
|
b302d1db7d | ||
|
|
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 | ||
|
|
5dcfde36ea | ||
|
|
1d035361a4 | ||
|
|
25605c5e78 | ||
|
|
f3268a6179 | ||
|
|
055e94d77b | ||
|
|
47fcd530a0 | ||
|
|
3c32b9e088 | ||
|
|
ffe0670a27 | ||
|
|
cc147a1795 | ||
|
|
e81409bea4 | ||
|
|
b31fae4e51 | ||
|
|
c6e5467907 | ||
|
|
df0e5797d0 | ||
|
|
ebdbb36271 | ||
|
|
2eef629821 | ||
|
|
658a04736d | ||
|
|
ef7f677933 | ||
|
|
63f0942452 | ||
|
|
a1dff6dd47 | ||
|
|
7fa40023b0 | ||
|
|
3c8acdb65e | ||
|
|
1e9a7812d6 | ||
|
|
37f0e8f213 | ||
|
|
ecf7ea21e4 | ||
|
|
79dd9a1b29 | ||
|
|
ef4923fd94 | ||
|
|
1eeba666f5 | ||
|
|
89e26d9292 | ||
|
|
fc19a145ff | ||
|
|
34f03d6495 | ||
|
|
9443175abc | ||
|
|
dc5072628f | ||
|
|
ff4b8ec849 | ||
|
|
7ab271c752 | ||
|
|
5a7f4dc88b | ||
|
|
761108bfd1 | ||
|
|
24dd3a777c | ||
|
|
1c530ea013 | ||
|
|
0ced53c059 | ||
|
|
67ad68a23f | ||
|
|
d9ec9c512e | ||
|
|
0bcd8e09a9 | ||
|
|
fa049a28c8 | ||
|
|
89fd2b43d6 | ||
|
|
c53f44e7ef | ||
|
|
ae7bfdb517 | ||
|
|
68bf8442eb | ||
|
|
605fbf4117 | ||
|
|
406d5fea6a | ||
|
|
af2146f96c | ||
|
|
bdc8dec860 |
69
.agents/skills/lora-manager-runtime-context/SKILL.md
Normal file
69
.agents/skills/lora-manager-runtime-context/SKILL.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: lora-manager-runtime-context
|
||||
description: Inspect ComfyUI LoRA Manager runtime configuration and local diagnostic state. Use when debugging LoRA Manager issues that require locating or reading settings.json, active library paths, model metadata JSON sidecars, recipe metadata JSON files, example image folders, SQLite caches, symlink maps, download history, aria2 state, or other cache files under the LoRA Manager user config directory.
|
||||
---
|
||||
|
||||
# LoRA Manager Runtime Context
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Treat runtime state as local user data. Prefer read-only inspection unless the user explicitly asks for mutation.
|
||||
- Never print secret-like settings values. Redact keys containing `key`, `token`, `secret`, `password`, `auth`, or `credential`, including `civitai_api_key`.
|
||||
- Resolve paths from the runtime configuration before guessing. In this environment the settings file is normally `/home/miao/.config/ComfyUI-LoRA-Manager/settings.json`, but portable settings can override this through the repository `settings.json`.
|
||||
- Use the active library when selecting per-library caches and paths. Read `active_library` from settings; fall back to `default` if missing.
|
||||
- Normalize and expand `~` before comparing paths. Symlinks are common in this repo.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Use the bundled helper for a safe first pass:
|
||||
|
||||
```bash
|
||||
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py summary
|
||||
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py caches
|
||||
```
|
||||
|
||||
The script redacts sensitive settings, opens SQLite databases read-only, and reports inaccessible or locked databases as warnings.
|
||||
|
||||
For focused checks:
|
||||
|
||||
```bash
|
||||
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py recipes
|
||||
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py model --path /path/to/model.safetensors
|
||||
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py sqlite --db /path/to/cache.sqlite --limit 3
|
||||
```
|
||||
|
||||
## Runtime Path Rules
|
||||
|
||||
- Settings directory: use `py/utils/settings_paths.py`. Default platform path is `platformdirs.user_config_dir("ComfyUI-LoRA-Manager", appauthor=False)`.
|
||||
- Settings file: `<settings_dir>/settings.json`.
|
||||
- Cache root: `<settings_dir>/cache`.
|
||||
- Canonical cache files:
|
||||
- Model cache: `cache/model/<active_library>.sqlite`.
|
||||
- Recipe cache: `cache/recipe/<active_library>.sqlite`.
|
||||
- Model update cache: `cache/model_update/<active_library>.sqlite`.
|
||||
- Recipe FTS: `cache/fts/recipe_fts.sqlite`.
|
||||
- Tag FTS: `cache/fts/tag_fts.sqlite`.
|
||||
- Symlink map: `cache/symlink/symlink_map.json`.
|
||||
- Download history: `cache/download_history/downloaded_versions.sqlite`.
|
||||
- aria2 state: `cache/aria2/downloads.json`.
|
||||
- Legacy cache locations may exist; prefer canonical paths unless diagnosing migrations.
|
||||
|
||||
## Data Location Rules
|
||||
|
||||
- Model roots come from `settings.folder_paths` and the active library payload under `settings.libraries[active_library]`.
|
||||
- Model metadata JSON sidecars live next to the model file as `<model basename>.metadata.json`.
|
||||
- Recipes root is `settings.recipes_path` when it is a non-empty string. If empty, use the first configured LoRA root plus `/recipes`.
|
||||
- Recipe JSON files are named `*.recipe.json` under the recipes root and may be nested in folders.
|
||||
- Example image root is `settings.example_images_path`.
|
||||
- If multiple libraries are configured, example images are stored under `<example_images_path>/<sanitized_library>/<sha256>/`; otherwise they are under `<example_images_path>/<sha256>/`.
|
||||
|
||||
## Useful Cache Tables
|
||||
|
||||
- Model cache: `models`, `model_tags`, `hash_index`, `excluded_models`.
|
||||
- Recipe cache: `recipes`, `cache_metadata`.
|
||||
- Model update cache: `model_update_status`, `model_update_versions`.
|
||||
- Tag FTS cache: `tags`, `fts_metadata`, plus FTS internal tables.
|
||||
- Recipe FTS cache: `recipe_rowid`, `fts_metadata`, plus FTS internal tables.
|
||||
- Download history: `downloaded_model_versions`.
|
||||
|
||||
Prefer querying only counts, schema, and a few sample rows unless the user asks for full output.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "LoRA Manager Runtime Context"
|
||||
short_description: "Inspect LoRA Manager runtime state"
|
||||
default_prompt: "Use $lora-manager-runtime-context to inspect LoRA Manager settings, metadata paths, and caches for debugging."
|
||||
381
.agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py
Executable file
381
.agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py
Executable file
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
|
||||
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||
CACHE_SQLITE = {
|
||||
"model": ("model", "{library}.sqlite"),
|
||||
"recipe": ("recipe", "{library}.sqlite"),
|
||||
"model_update": ("model_update", "{library}.sqlite"),
|
||||
"recipe_fts": ("fts", "recipe_fts.sqlite"),
|
||||
"tag_fts": ("fts", "tag_fts.sqlite"),
|
||||
"download_history": ("download_history", "downloaded_versions.sqlite"),
|
||||
}
|
||||
CACHE_JSON = {
|
||||
"symlink": ("symlink", "symlink_map.json"),
|
||||
"aria2": ("aria2", "downloads.json"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Inspect LoRA Manager runtime state read-only.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser("summary", help="Print redacted settings and resolved paths.")
|
||||
subparsers.add_parser("caches", help="Print cache paths and SQLite table summaries.")
|
||||
subparsers.add_parser("recipes", help="Print resolved recipes root and recipe JSON count.")
|
||||
|
||||
model_parser = subparsers.add_parser("model", help="Inspect a model metadata sidecar path.")
|
||||
model_parser.add_argument("--path", required=True, help="Path to a model file or metadata JSON file.")
|
||||
|
||||
sqlite_parser = subparsers.add_parser("sqlite", help="Inspect a SQLite database read-only.")
|
||||
sqlite_parser.add_argument("--db", required=True, help="Path to the SQLite database.")
|
||||
sqlite_parser.add_argument("--limit", type=int, default=3, help="Rows to sample from each user table.")
|
||||
|
||||
args = parser.parse_args()
|
||||
context = build_context()
|
||||
|
||||
if args.command == "summary":
|
||||
print_json(summary_payload(context))
|
||||
elif args.command == "caches":
|
||||
print_json(caches_payload(context))
|
||||
elif args.command == "recipes":
|
||||
print_json(recipes_payload(context))
|
||||
elif args.command == "model":
|
||||
print_json(model_payload(args.path))
|
||||
elif args.command == "sqlite":
|
||||
print_json(sqlite_payload(Path(args.db).expanduser(), args.limit))
|
||||
return 0
|
||||
|
||||
|
||||
def build_context() -> dict[str, Any]:
|
||||
settings_path = resolve_settings_path()
|
||||
settings = load_json(settings_path)
|
||||
settings_dir = settings_path.parent
|
||||
active_library = settings.get("active_library") or "default"
|
||||
safe_library = sanitize_library_name(str(active_library))
|
||||
cache_root = settings_dir / "cache"
|
||||
return {
|
||||
"settings_path": str(settings_path),
|
||||
"settings_dir": str(settings_dir),
|
||||
"settings": settings,
|
||||
"active_library": active_library,
|
||||
"safe_library": safe_library,
|
||||
"cache_root": str(cache_root),
|
||||
"cache_paths": resolve_cache_paths(cache_root, safe_library),
|
||||
}
|
||||
|
||||
|
||||
def resolve_settings_path() -> Path:
|
||||
repo_root = find_repo_root()
|
||||
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
|
||||
|
||||
config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||
if config_home:
|
||||
return Path(config_home).expanduser() / APP_NAME / "settings.json"
|
||||
return Path.home() / ".config" / APP_NAME / "settings.json"
|
||||
|
||||
|
||||
def find_repo_root() -> Path:
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (parent / "py").is_dir() and (parent / "standalone.py").exists():
|
||||
return parent
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"_error": f"invalid JSON: {exc}"}
|
||||
except OSError as exc:
|
||||
return {"_error": f"unreadable: {exc}"}
|
||||
return payload if isinstance(payload, dict) else {"_error": "JSON root is not an object"}
|
||||
|
||||
|
||||
def resolve_cache_paths(cache_root: Path, library: str) -> dict[str, str]:
|
||||
paths: dict[str, str] = {}
|
||||
for name, (subdir, filename) in CACHE_SQLITE.items():
|
||||
paths[name] = str(cache_root / subdir / filename.format(library=library))
|
||||
for name, (subdir, filename) in CACHE_JSON.items():
|
||||
paths[name] = str(cache_root / subdir / filename)
|
||||
return paths
|
||||
|
||||
|
||||
def summary_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||
settings = context["settings"]
|
||||
return {
|
||||
"settings_path": context["settings_path"],
|
||||
"settings_dir": context["settings_dir"],
|
||||
"active_library": context["active_library"],
|
||||
"settings": redact(settings),
|
||||
"model_roots": model_roots(settings, context["active_library"]),
|
||||
"recipes_root": str(resolve_recipes_root(settings, context["active_library"]) or ""),
|
||||
"example_images": example_images_payload(settings, context["active_library"]),
|
||||
"cache_root": context["cache_root"],
|
||||
"cache_paths": context["cache_paths"],
|
||||
}
|
||||
|
||||
|
||||
def caches_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||
caches: dict[str, Any] = {}
|
||||
for name, path_string in context["cache_paths"].items():
|
||||
path = Path(path_string)
|
||||
item: dict[str, Any] = {
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"size": path.stat().st_size if path.exists() else None,
|
||||
}
|
||||
if path.suffix == ".sqlite":
|
||||
item["sqlite"] = sqlite_payload(path, limit=0)
|
||||
elif path.suffix == ".json":
|
||||
item["json"] = json_file_summary(path)
|
||||
caches[name] = item
|
||||
return {"active_library": context["active_library"], "caches": caches}
|
||||
|
||||
|
||||
def recipes_payload(context: dict[str, Any]) -> dict[str, Any]:
|
||||
root = resolve_recipes_root(context["settings"], context["active_library"])
|
||||
files: list[str] = []
|
||||
if root and root.exists():
|
||||
files = [str(path) for path in sorted(root.rglob("*.recipe.json"))[:20]]
|
||||
return {
|
||||
"recipes_root": str(root or ""),
|
||||
"exists": bool(root and root.exists()),
|
||||
"recipe_json_count": count_recipe_files(root),
|
||||
"sample_recipe_json": files,
|
||||
"recipe_cache": context["cache_paths"].get("recipe"),
|
||||
}
|
||||
|
||||
|
||||
def model_payload(raw_path: str) -> dict[str, Any]:
|
||||
path = Path(raw_path).expanduser()
|
||||
metadata_path = path if path.name.endswith(".metadata.json") else path.with_suffix(".metadata.json")
|
||||
payload = {
|
||||
"input_path": str(path),
|
||||
"metadata_path": str(metadata_path),
|
||||
"model_exists": path.exists(),
|
||||
"metadata_exists": metadata_path.exists(),
|
||||
}
|
||||
if metadata_path.exists():
|
||||
data = load_json(metadata_path)
|
||||
payload["metadata_summary"] = redact(summarize_value(data))
|
||||
return payload
|
||||
|
||||
|
||||
def sqlite_payload(path: Path, limit: int = 3, allow_copy: bool = True) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"path": str(path), "exists": path.exists(), "tables": {}}
|
||||
if not path.exists():
|
||||
return result
|
||||
try:
|
||||
conn = connect_sqlite_readonly(path)
|
||||
except sqlite3.Error as exc:
|
||||
result["error"] = str(exc)
|
||||
return result
|
||||
try:
|
||||
table_rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
).fetchall()
|
||||
for table_row in table_rows:
|
||||
table = table_row["name"]
|
||||
columns = [
|
||||
row["name"]
|
||||
for row in conn.execute(f"PRAGMA table_info({quote_identifier(table)})").fetchall()
|
||||
]
|
||||
table_info: dict[str, Any] = {"columns": columns}
|
||||
try:
|
||||
table_info["count"] = conn.execute(
|
||||
f"SELECT COUNT(*) FROM {quote_identifier(table)}"
|
||||
).fetchone()[0]
|
||||
except sqlite3.Error as exc:
|
||||
table_info["count_error"] = str(exc)
|
||||
if limit > 0 and columns and not is_internal_sqlite_table(table):
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM {quote_identifier(table)} LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
table_info["sample"] = [redact(dict(row)) for row in rows]
|
||||
except sqlite3.Error as exc:
|
||||
table_info["sample_error"] = str(exc)
|
||||
result["tables"][table] = table_info
|
||||
except sqlite3.Error as exc:
|
||||
fallback = sqlite_copy_payload(path, limit, str(exc)) if allow_copy else None
|
||||
if fallback is not None:
|
||||
result.update(fallback)
|
||||
else:
|
||||
result["error"] = str(exc)
|
||||
finally:
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
def connect_sqlite_readonly(path: Path) -> sqlite3.Connection:
|
||||
errors: list[str] = []
|
||||
for query in ("mode=ro", "mode=ro&immutable=1"):
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{path}?{query}", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
except sqlite3.Error as exc:
|
||||
errors.append(f"{query}: {exc}")
|
||||
raise sqlite3.OperationalError("; ".join(errors))
|
||||
|
||||
|
||||
def sqlite_copy_payload(path: Path, limit: int, original_error: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="lm-cache-inspect-") as temp_dir:
|
||||
copy_path = Path(temp_dir) / path.name
|
||||
shutil.copy2(path, copy_path)
|
||||
payload = sqlite_payload(copy_path, limit, allow_copy=False)
|
||||
payload["path"] = str(path)
|
||||
payload["inspected_copy"] = True
|
||||
payload["original_error"] = original_error
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def json_file_summary(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {"exists": False}
|
||||
data = load_json(path)
|
||||
return {"exists": True, "summary": redact(summarize_value(data))}
|
||||
|
||||
|
||||
def model_roots(settings: dict[str, Any], active_library: str) -> dict[str, list[str]]:
|
||||
roots: dict[str, list[str]] = {}
|
||||
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 resolve_recipes_root(settings: dict[str, Any], active_library: str) -> Path | None:
|
||||
recipes_path = settings.get("recipes_path")
|
||||
library = settings.get("libraries", {}).get(active_library)
|
||||
if isinstance(library, dict) and isinstance(library.get("recipes_path"), str):
|
||||
recipes_path = library["recipes_path"] or recipes_path
|
||||
if isinstance(recipes_path, str) and recipes_path.strip():
|
||||
return Path(expand_path(recipes_path.strip()))
|
||||
lora_roots = model_roots(settings, active_library).get("loras") or []
|
||||
return Path(lora_roots[0]) / "recipes" if lora_roots else None
|
||||
|
||||
|
||||
def example_images_payload(settings: dict[str, Any], active_library: str) -> dict[str, Any]:
|
||||
root = settings.get("example_images_path") or ""
|
||||
libraries = settings.get("libraries")
|
||||
library_count = len(libraries) if isinstance(libraries, dict) else 0
|
||||
scoped = library_count > 1
|
||||
root_path = Path(expand_path(root)) if isinstance(root, str) and root else None
|
||||
library_root = root_path / sanitize_library_name(active_library) if root_path and scoped else root_path
|
||||
return {
|
||||
"root": str(root_path or ""),
|
||||
"uses_library_scoped_folders": scoped,
|
||||
"library_root": str(library_root or ""),
|
||||
}
|
||||
|
||||
|
||||
def count_recipe_files(root: Path | None) -> int:
|
||||
if not root or not root.exists():
|
||||
return 0
|
||||
return sum(1 for _ in root.rglob("*.recipe.json"))
|
||||
|
||||
|
||||
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 expand_path(value: str) -> str:
|
||||
return str(Path(value).expanduser().resolve(strict=False))
|
||||
|
||||
|
||||
def sanitize_library_name(name: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name or "default")
|
||||
return safe or "default"
|
||||
|
||||
|
||||
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 redact(value: Any, key: str = "") -> Any:
|
||||
if key and SECRET_PATTERN.search(key):
|
||||
return "<redacted>"
|
||||
if isinstance(value, dict):
|
||||
return {str(k): redact(v, str(k)) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [redact(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def summarize_value(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: summarize_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return {
|
||||
"type": "array",
|
||||
"length": len(value),
|
||||
"first": summarize_value(value[0]) if value else None,
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def quote_identifier(identifier: str) -> str:
|
||||
return '"' + identifier.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def is_internal_sqlite_table(table: str) -> bool:
|
||||
return table.startswith("sqlite_") or table.endswith(("_data", "_idx", "_docsize", "_config", "_content"))
|
||||
|
||||
|
||||
def print_json(payload: Any) -> None:
|
||||
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,153 +0,0 @@
|
||||
# Recipe Batch Import Feature Design
|
||||
|
||||
## Overview
|
||||
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ BatchImportManager.js │
|
||||
│ ├── InputCollector (收集URL列表/目录路径) │
|
||||
│ ├── ConcurrencyController (自适应并发控制) │
|
||||
│ ├── ProgressTracker (进度追踪) │
|
||||
│ └── ResultAggregator (结果汇总) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ batch_import_modal.html │
|
||||
│ └── 批量导入UI组件 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ batch_import_progress.css │
|
||||
│ └── 进度显示样式 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ py/routes/handlers/recipe_handlers.py │
|
||||
│ ├── start_batch_import() - 启动批量导入 │
|
||||
│ ├── get_batch_import_progress() - 查询进度 │
|
||||
│ └── cancel_batch_import() - 取消导入 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ py/services/batch_import_service.py │
|
||||
│ ├── 自适应并发执行 │
|
||||
│ ├── 结果汇总 │
|
||||
│ └── WebSocket进度广播 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
|
||||
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
|
||||
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
|
||||
|
||||
## Backend Implementation Details
|
||||
|
||||
### BatchImportService
|
||||
|
||||
Location: `py/services/batch_import_service.py`
|
||||
|
||||
Key classes:
|
||||
- `BatchImportItem`: Dataclass for individual import item
|
||||
- `BatchImportProgress`: Dataclass for tracking progress
|
||||
- `BatchImportService`: Main service class
|
||||
|
||||
Features:
|
||||
- Adaptive concurrency control (adjusts based on success/failure rate)
|
||||
- WebSocket progress broadcasting
|
||||
- Graceful error handling (individual failures don't stop the batch)
|
||||
- Result aggregation
|
||||
|
||||
### WebSocket Message Format
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "batch_import_progress",
|
||||
"operation_id": "xxx",
|
||||
"total": 50,
|
||||
"completed": 23,
|
||||
"success": 21,
|
||||
"failed": 2,
|
||||
"skipped": 0,
|
||||
"current_item": "image_024.png",
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
### Input Types
|
||||
|
||||
1. **URL List**: Array of URLs (http/https)
|
||||
2. **Local Paths**: Array of local file paths
|
||||
3. **Directory**: Path to directory with optional recursive flag
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Invalid URLs/paths: Skip and record error
|
||||
- Download failures: Record error, continue
|
||||
- Metadata extraction failures: Mark as "no metadata"
|
||||
- Duplicate detection: Option to skip duplicates
|
||||
|
||||
## Frontend Implementation Details (TODO)
|
||||
|
||||
### UI Components
|
||||
|
||||
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
|
||||
2. **ProgressDisplay**: Real-time progress bar and status
|
||||
3. **ResultsSummary**: Final results with success/failure breakdown
|
||||
|
||||
### Adaptive Concurrency Controller
|
||||
|
||||
```javascript
|
||||
class AdaptiveConcurrencyController {
|
||||
constructor(options = {}) {
|
||||
this.minConcurrency = options.minConcurrency || 1;
|
||||
this.maxConcurrency = options.maxConcurrency || 5;
|
||||
this.currentConcurrency = options.initialConcurrency || 3;
|
||||
}
|
||||
|
||||
adjustConcurrency(taskDuration, success) {
|
||||
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
|
||||
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
|
||||
}
|
||||
if (!success || taskDuration > 10000) {
|
||||
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
|
||||
}
|
||||
return this.currentConcurrency;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
Backend (implemented):
|
||||
├── py/services/batch_import_service.py # 后端服务
|
||||
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
|
||||
├── tests/services/test_batch_import_service.py # 单元测试
|
||||
└── tests/routes/test_batch_import_routes.py # API集成测试
|
||||
|
||||
Frontend (TODO):
|
||||
├── static/js/managers/BatchImportManager.js # 主管理器
|
||||
├── static/js/managers/batch/ # 子模块
|
||||
│ ├── ConcurrencyController.js # 并发控制
|
||||
│ ├── ProgressTracker.js # 进度追踪
|
||||
│ └── ResultAggregator.js # 结果汇总
|
||||
├── static/css/components/batch-import-modal.css # 样式
|
||||
└── templates/components/batch_import_modal.html # Modal模板
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- [x] Backend BatchImportService
|
||||
- [x] Backend API handlers
|
||||
- [x] WebSocket progress broadcasting
|
||||
- [x] Unit tests
|
||||
- [x] Integration tests
|
||||
- [ ] Frontend BatchImportManager
|
||||
- [ ] Frontend UI components
|
||||
- [ ] E2E tests
|
||||
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**
|
||||
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**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -7,15 +7,24 @@ py/run_test.py
|
||||
.vscode/
|
||||
cache/
|
||||
civitai/
|
||||
stats/
|
||||
wildcards/
|
||||
backups/
|
||||
logs/
|
||||
node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
|
||||
# agent
|
||||
# agent / dev tooling
|
||||
.opencode/
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.codex
|
||||
.omo
|
||||
reasonix.toml
|
||||
.reasonix/
|
||||
.codegraph/
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
vue-widgets/node_modules/
|
||||
@@ -24,3 +33,10 @@ vue-widgets/dist/
|
||||
|
||||
# Hypothesis test cache
|
||||
.hypothesis/
|
||||
|
||||
# Working/research notes (not committed)
|
||||
.docs/
|
||||
|
||||
# HF enrichment validation baseline snapshots (contain potentially
|
||||
# NSFW README content fetched from community model repos)
|
||||
tests/enrich_hf_validation/baselines/
|
||||
|
||||
181
.omo/plans/embeddings-hybrid-approach.md
Normal file
181
.omo/plans/embeddings-hybrid-approach.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Embeddings Usage Tracking — Hybrid Approach (Plan C)
|
||||
|
||||
> **Status**: Reference document for future implementation
|
||||
> **Current implementation**: Plan A (prompt text parsing only, see `usage_stats.py:_process_embeddings`)
|
||||
> **Next step**: Add Plan B as a supplement when edge-case coverage is needed
|
||||
|
||||
## Problem
|
||||
|
||||
Embeddings in ComfyUI are not loaded through dedicated ComfyUI nodes like LoRAs or
|
||||
Checkpoints. They are resolved during CLIP tokenization when the prompt text contains
|
||||
`embedding:<name>` syntax (see `comfy/sd1_clip.py:SDTokenizer.tokenize_with_weights`).
|
||||
|
||||
This means the existing metadata_collector hook (which intercepts node execution via
|
||||
`_map_node_over_list`) cannot capture embeddings the same way it captures LoRAs and
|
||||
checkpoints — there is no "EmbeddingLoader" node to intercept.
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
The hybrid approach combines **two complementary mechanisms** to capture embedding
|
||||
usage from all possible paths.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Plan A (已实现) │
|
||||
│ │
|
||||
│ MetadataRegistry.prompt_metadata["prompts"] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ _process_embeddings() │
|
||||
│ │ │
|
||||
│ ├─ Iterate all prompt node texts │
|
||||
│ ├─ regex extract "embedding:<name>" │
|
||||
│ ├─ resolve name → sha256 via EmbeddingScanner │
|
||||
│ └─ UsageStats.stats["embeddings"][sha256]++ │
|
||||
│ │
|
||||
│ Coverage: ~95% — all CLIPTextEncode/Flux/etc nodes │
|
||||
│ │
|
||||
│ Gap: Custom nodes that load embeddings programmatically │
|
||||
│ without putting embedding:name in prompt text │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
+
|
||||
↓ (future: enable Plan B when needed)
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Plan B (未来 — monkey-patch) │
|
||||
│ │
|
||||
│ comfy/sd1_clip.py:load_embed() │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Monkey-patch intercepts EVERY embedding file load │
|
||||
│ │ │
|
||||
│ ├─ Records embedding_name + success/failure │
|
||||
│ ├─ Associates with current prompt_id (via registry)│
|
||||
│ └─ Feeds into UsageStats same as Plan A │
|
||||
│ │
|
||||
│ Coverage: 100% — catches ALL embedding loads │
|
||||
│ │
|
||||
│ Cost: Requires patching into ComfyUI internals │
|
||||
│ (sd1_clip.py, sdxl_clip.py, some text_encoders) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Plan B Detail — Monkey-patch `load_embed`
|
||||
|
||||
### Target Function
|
||||
|
||||
**`comfy.sd1_clip.load_embed(embedding_name, embedding_directory, embedding_size, embed_key=None)`**
|
||||
at line 415 of `sd1_clip.py`.
|
||||
|
||||
This is the **single choke point** for all embedding file loads in ComfyUI. Every
|
||||
CLIP variant (SD1, SDXL, SD3, Flux) calls this same function.
|
||||
|
||||
### Implementation Sketch
|
||||
|
||||
```python
|
||||
# In metadata_collector/metadata_hook.py (or a new module)
|
||||
import comfy.sd1_clip as sd1_clip
|
||||
|
||||
_original_load_embed = sd1_clip.load_embed
|
||||
|
||||
def _patched_load_embed(embedding_name, embedding_directory, embedding_size, embed_key=None):
|
||||
result = _original_load_embed(
|
||||
embedding_name, embedding_directory, embedding_size, embed_key
|
||||
)
|
||||
if result is not None:
|
||||
_record_embedding_usage(embedding_name)
|
||||
return result
|
||||
|
||||
sd1_clip.load_embed = _patched_load_embed
|
||||
```
|
||||
|
||||
### Prompt ID Association
|
||||
|
||||
The challenge is associating the `load_embed` call with the current `prompt_id`.
|
||||
Options:
|
||||
|
||||
1. **Thread-local / contextvar**: Store current `prompt_id` in a `contextvars.ContextVar`
|
||||
that the metadata_collector sets at the start of each prompt execution.
|
||||
|
||||
2. **MetadataRegistry singleton**: The MetadataRegistry already has `current_prompt_id`.
|
||||
The patch can read it directly since both run in the same thread.
|
||||
|
||||
3. **Lazy aggregation**: Instead of associating with prompt_id at load time, collect
|
||||
all loaded embedding names in a global set during execution, then flush to
|
||||
UsageStats after the prompt completes.
|
||||
|
||||
### Files to Patch
|
||||
|
||||
| File | Function | Coverage |
|
||||
|------|----------|----------|
|
||||
| `comfy/sd1_clip.py:415` | `load_embed()` | Primary — SD1.x, SDXL, SD3, Flux |
|
||||
| `comfy/sdxl_clip.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
|
||||
| `comfy/text_encoders/sd3_clip.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
|
||||
| `comfy/text_encoders/flux.py` | Not needed (calls `sd1_clip.SDTokenizer`) | — |
|
||||
|
||||
The SD1 tokenizer is the base class for all CLIP variants' tokenizers, so patching
|
||||
`load_embed` covers them all.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
| Edge Case | Plan A | Plan B |
|
||||
|-----------|--------|--------|
|
||||
| `embedding:name` in CLIPTextEncode | ✅ | ✅ |
|
||||
| `embedding:name` in CLIPTextEncodeFlux | ✅ | ✅ |
|
||||
| `embedding:name` in PromptLM (LoRA Manager) | ✅ | ✅ |
|
||||
| `embedding:name` in WAS_Text_to_Conditioning | ✅ | ✅ |
|
||||
| Custom node that loads embedding programmatically | ❌ | ✅ |
|
||||
| Embedding loaded multiple times in same prompt | ✅ (dedup via set) | ✅ (dedup via set) |
|
||||
| Embedding file not found | N/A | ✅ (can log) |
|
||||
| Embedding dimension mismatch | N/A | ✅ (can log) |
|
||||
| Text encoder with non-standard tokenizer (LLaMA, T5...) | Partial | ✅ (if it calls load_embed) |
|
||||
|
||||
## Migration Path: Standalone → Hybrid
|
||||
|
||||
### Phase 1 — Plan A (当前状态)
|
||||
- Prompt text parsing only
|
||||
- No monkey-patching required
|
||||
- Covers all standard workflows
|
||||
|
||||
### Phase 2 — Enable Plan B (未来工作)
|
||||
1. Add monkey-patch of `load_embed` in `metadata_collector/metadata_hook.py` (alongside
|
||||
the existing `_map_node_over_list` hook)
|
||||
2. Collect loaded embedding names in a `set()` on the registry
|
||||
3. In `UsageStats._process_embeddings()`, merge the Plan A results (from prompt text)
|
||||
with the Plan B results (from the patch)
|
||||
4. Add `prompt_data` field on MetadataRegistry to store loaded embeddings per prompt
|
||||
|
||||
### Deduplication
|
||||
|
||||
```python
|
||||
# Merge Plan A + Plan B results in _process_embeddings
|
||||
plan_a_names = extract_from_prompt_texts(prompts_data)
|
||||
plan_b_names = registry.get_loaded_embeddings(prompt_id)
|
||||
|
||||
all_names = plan_a_names | plan_b_names
|
||||
```
|
||||
|
||||
## Testing the Hybrid
|
||||
|
||||
| Scenario | What to verify |
|
||||
|----------|---------------|
|
||||
| Standard `embedding:name` in prompt | Plan A captures it |
|
||||
| Embedding loaded by custom node script | Plan B captures it |
|
||||
| Both paths fire for same embedding | No double-counting (dedup) |
|
||||
| Embedding name resolves to hash | EmbeddingScanner.get_hash_by_filename works |
|
||||
| No embedding scanner available | Graceful skip, no crash |
|
||||
| Missing embedding file | Plan B logs warning, Plan A skips gracefully |
|
||||
| Empty prompt | No crash, no entries |
|
||||
| Standalone mode | Both plans disabled gracefully |
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `py/utils/usage_stats.py` | Core — `_process_embeddings()` for Plan A |
|
||||
| `py/metadata_collector/constants.py` | `EMBEDDINGS` category constant |
|
||||
| `py/metadata_collector/metadata_hook.py` | Future — monkey-patch for Plan B |
|
||||
| `py/services/embedding_scanner.py` | Hash resolution service |
|
||||
| `py/routes/stats_routes.py` | Already handles `usage_data.get('embeddings', {})` |
|
||||
| `comfy/sd1_clip.py` (ComfyUI) | `load_embed()` — Plan B target |
|
||||
@@ -102,6 +102,7 @@ npm run test:coverage # Generate coverage report
|
||||
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
||||
- Event handlers via `addEventListener` or widget callbacks
|
||||
- Shared utilities: `web/comfyui/utils.js`
|
||||
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
|
||||
|
||||
### Vue Composables Pattern
|
||||
|
||||
@@ -136,7 +137,13 @@ npm run test:coverage # Generate coverage report
|
||||
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||
- Symlinks require normalized paths
|
||||
- Symlinks require normalized paths.
|
||||
**Business paths vs real paths**: All stored paths and operation routing use the
|
||||
original paths as they appear under configured model roots — symlinks are NOT
|
||||
resolved. `os.path.realpath` is only for scanner dedup and the symlink cache.
|
||||
Any path passed to `os.remove`/`os.rename`/`shutil.move` or validated by a
|
||||
containment check MUST use the business path (i.e. `os.path.abspath`, not
|
||||
`realpath`).
|
||||
|
||||
## Git / Commit Messages
|
||||
|
||||
|
||||
18
__init__.py
18
__init__.py
@@ -15,6 +15,10 @@ try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.nodes.lora_pool import LoraPoolLM
|
||||
from .py.nodes.lora_randomizer import LoraRandomizerLM
|
||||
from .py.nodes.lora_cycler import LoraCyclerLM
|
||||
from .py.nodes.lora_info import LoraInfoLM
|
||||
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
||||
from .py.nodes.create_hook_lora import CreateHookLoraLM
|
||||
from .py.nodes.metadata_overwrite import MetadataOverwriteLM
|
||||
from .py.metadata_collector import init as init_metadata_collector
|
||||
except (
|
||||
ImportError
|
||||
@@ -56,6 +60,16 @@ except (
|
||||
"py.nodes.lora_randomizer"
|
||||
).LoraRandomizerLM
|
||||
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
|
||||
LoraInfoLM = importlib.import_module("py.nodes.lora_info").LoraInfoLM
|
||||
LoraSyntaxToPath = importlib.import_module(
|
||||
"py.nodes.lora_syntax_to_path"
|
||||
).LoraSyntaxToPath
|
||||
CreateHookLoraLM = importlib.import_module(
|
||||
"py.nodes.create_hook_lora"
|
||||
).CreateHookLoraLM
|
||||
MetadataOverwriteLM = importlib.import_module(
|
||||
"py.nodes.metadata_overwrite"
|
||||
).MetadataOverwriteLM
|
||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -75,6 +89,10 @@ NODE_CLASS_MAPPINGS = {
|
||||
LoraPoolLM.NAME: LoraPoolLM,
|
||||
LoraRandomizerLM.NAME: LoraRandomizerLM,
|
||||
LoraCyclerLM.NAME: LoraCyclerLM,
|
||||
LoraInfoLM.NAME: LoraInfoLM,
|
||||
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
||||
MetadataOverwriteLM.NAME: MetadataOverwriteLM,
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
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!**
|
||||
|
||||
---
|
||||
208
docs/agent_skills.md
Normal file
208
docs/agent_skills.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Agent Skills System
|
||||
|
||||
The LoRA Manager agent skills system enables LLM-powered metadata enrichment and other AI-driven tasks. Users configure their own LLM provider (BYOK), and skills are executed through right-click context menu actions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ LoRA Manager Backend │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────┐ │
|
||||
│ │ LLMService │───▶│ LLM Provider │ │
|
||||
│ │ (BYOK config, │◀───│ (OpenAI/Ollama │ │
|
||||
│ │ API calls) │ │ /custom) │ │
|
||||
│ └───────┬───────┘ └────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼───────────────────────┐ │
|
||||
│ │ AgentService │ │
|
||||
│ │ (orchestration: validate │ │
|
||||
│ │ → LLM call → post-process │ │
|
||||
│ │ → WebSocket broadcast) │ │
|
||||
│ └───────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────▼───────────────────────┐ │
|
||||
│ │ SkillRegistry │ │
|
||||
│ │ ┌─────────────────────────┐ │ │
|
||||
│ │ │ enrich_hf_metadata: │ │ │
|
||||
│ │ │ - skill.yaml │ │ │
|
||||
│ │ │ - prompt.md │ │ │
|
||||
│ │ │ - handler.py │ │ │
|
||||
│ │ └─────────────────────────┘ │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Design Principle
|
||||
|
||||
**Skills define *what* to do (prompt + post-processing). The AgentService handles *how* (LLM calls, validation, progress).**
|
||||
|
||||
Skills never call the LLM directly. This keeps BYOK configuration centralized and provider-agnostic.
|
||||
|
||||
## BYOK Configuration
|
||||
|
||||
Users configure their LLM provider in **Settings → AI Provider**:
|
||||
|
||||
| Setting | Description | Example |
|
||||
|---|---|---|
|
||||
| `llm_provider` | Provider type | `openai`, `ollama`, or `custom` |
|
||||
| `llm_api_key` | API key (not needed for local Ollama) | `sk-...` |
|
||||
| `llm_api_base` | Custom API base URL (empty = provider default) | `https://api.openai.com/v1` |
|
||||
| `llm_model` | Model name | `gpt-4o-mini` |
|
||||
|
||||
Environment variable overrides: `LLM_API_KEY`, `LLM_MODEL`, `LLM_API_BASE`, `LLM_PROVIDER`.
|
||||
|
||||
### Supported Providers
|
||||
|
||||
- **OpenAI**: Uses `https://api.openai.com/v1` by default
|
||||
- **Ollama** (local): Uses `http://localhost:11434/v1`, no API key required
|
||||
- **Custom**: Any OpenAI-compatible endpoint (vLLM, LM Studio, etc.) — set `llm_api_base` explicitly
|
||||
|
||||
## Available Skills
|
||||
|
||||
### enrich_hf_metadata
|
||||
|
||||
Enriches HuggingFace-downloaded models with metadata extracted by an LLM from the HF model card.
|
||||
|
||||
**Entry point**: Right-click context menu → "Enrich Metadata (Agent)"
|
||||
|
||||
**What it does**:
|
||||
1. Reads the model's `.metadata.json` to get the `hf_url`
|
||||
2. Fetches the README.md from the HuggingFace repository
|
||||
3. Sends the README + local metadata to the LLM for structured extraction
|
||||
4. Writes extracted fields to `.metadata.json`:
|
||||
- `base_model` — only if current value is empty
|
||||
- `trainedWords` — trigger words (LoRA only, if none exist)
|
||||
- `modelDescription` — concise summary (if none exists)
|
||||
- `tags` — merged with existing tags, deduplicated
|
||||
- `metadata_source` — audit trail: `agent:enrich_hf_metadata`
|
||||
- `llm_enriched_at` — ISO timestamp
|
||||
5. Downloads and optimizes preview image (if LLM found one in the README)
|
||||
6. Updates the scanner cache
|
||||
7. Broadcasts WebSocket progress events
|
||||
|
||||
**Model types**: LoRA, Checkpoint, Embedding
|
||||
|
||||
## Adding a New Skill
|
||||
|
||||
### 1. Create the skill directory
|
||||
|
||||
```
|
||||
py/services/agent/skills/<skill_name>/
|
||||
├── skill.yaml # Skill metadata and schemas
|
||||
├── prompt.md # LLM prompt template
|
||||
└── handler.py # Pre-processing and post-processing
|
||||
```
|
||||
|
||||
### 2. Write skill.yaml
|
||||
|
||||
```yaml
|
||||
name: my_skill
|
||||
title: "My Skill"
|
||||
description: "What this skill does"
|
||||
llm_required: true
|
||||
model_type_filter: ["lora"] # or null for all types
|
||||
input_schema:
|
||||
type: object
|
||||
properties:
|
||||
model_paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- model_paths
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
# ... JSON schema for LLM output
|
||||
permissions:
|
||||
write_metadata: true
|
||||
write_previews: false
|
||||
network_domains:
|
||||
- "example.com"
|
||||
```
|
||||
|
||||
### 3. Write prompt.md
|
||||
|
||||
Use `{{variable}}` placeholders that will be replaced with data from the `prepare` function:
|
||||
|
||||
```markdown
|
||||
You are an expert assistant...
|
||||
|
||||
Model URL: {{hf_url}}
|
||||
README content:
|
||||
{{readme_content}}
|
||||
|
||||
Current metadata:
|
||||
{{current_metadata}}
|
||||
```
|
||||
|
||||
### 4. Write handler.py
|
||||
|
||||
```python
|
||||
async def prepare(model_path: str, input_data: dict) -> dict:
|
||||
"""Gather context for the LLM prompt. Returns variables for template rendering."""
|
||||
return {
|
||||
"model_path": model_path,
|
||||
# ... other variables used in prompt.md
|
||||
}
|
||||
|
||||
async def post_process(context) -> dict:
|
||||
"""Apply the LLM-extracted data to the model."""
|
||||
llm_response = context.llm_response
|
||||
# ... write metadata, download previews, update cache
|
||||
return {
|
||||
"success": True,
|
||||
"updated_fields": ["base_model", "tags"],
|
||||
"errors": [],
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Use absolute imports (`from py.utils.metadata_manager import MetadataManager`) because skills are loaded via `importlib.util.spec_from_file_location`, which doesn't support relative imports.
|
||||
|
||||
### 5. Test
|
||||
|
||||
The skill is automatically discovered by `SkillRegistry` on startup. Test with:
|
||||
|
||||
```python
|
||||
pytest tests/services/test_agent_service.py
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/lm/agent/skills` | List available skills |
|
||||
| POST | `/api/lm/agent/execute/{skill_name}` | Execute a skill (body: `{"model_paths": [...]}`) |
|
||||
| POST | `/api/lm/agent/cancel` | Cancel running skill (stub) |
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
| Type | When | Key fields |
|
||||
|---|---|---|
|
||||
| `agent_progress` | Skill started/processing | `skill`, `status`, `total`, `processed`, `success`, `current_path` |
|
||||
| `agent_progress` | Skill completed | `skill`, `status`, `updated_models`, `errors`, `summary` |
|
||||
| `agent_progress` | Skill error | `skill`, `status`, `error` |
|
||||
|
||||
## Security Model
|
||||
|
||||
Skills declare permissions in `skill.yaml`:
|
||||
- `write_metadata` — can write `.metadata.json` files
|
||||
- `write_previews` — can download/replace preview images
|
||||
- `network_domains` — allowed domains for HTTP requests
|
||||
|
||||
These are declarative constraints checked by `AgentService`. They are defense-in-depth, not a sandbox — the Python process can technically do anything, but the contract is clear and auditable.
|
||||
|
||||
## File Locations
|
||||
|
||||
| Component | Path |
|
||||
|---|---|
|
||||
| LLMService | `py/services/llm_service.py` |
|
||||
| AgentService | `py/services/agent/agent_service.py` |
|
||||
| SkillRegistry | `py/services/agent/skill_registry.py` |
|
||||
| SkillDefinition | `py/services/agent/skill_definition.py` |
|
||||
| Skills directory | `py/services/agent/skills/` |
|
||||
| Route handlers | `py/routes/handlers/agent_handlers.py` |
|
||||
| Frontend manager | `static/js/managers/AgentManager.js` |
|
||||
| Settings UI | `templates/components/modals/settings_modal.html` |
|
||||
| Context menu | `templates/components/context_menu.html` |
|
||||
65
docs/comfyui-dual-mode-widgets.md
Normal file
65
docs/comfyui-dual-mode-widgets.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# ComfyUI Dual-Mode Widget Rendering
|
||||
|
||||
ComfyUI custom node widgets render in one of two modes. Patterns that work in one often fail silently in the other. Test both.
|
||||
|
||||
## Mode Detection
|
||||
|
||||
```js
|
||||
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
|
||||
```
|
||||
|
||||
In Vue SFCs, `window.LiteGraph` is unavailable — pass as a prop from `main.ts`.
|
||||
|
||||
## Canvas Mode Layout
|
||||
|
||||
Uses `computeLayoutSize()` + `distributeSpace()` to allocate widget height within the node. Widgets with `computeLayoutSize` participate in space distribution; those with `computeSize` have fixed height.
|
||||
|
||||
- `getMinHeight()` in `addDOMWidget` options → minimum widget height
|
||||
- `widget.computeLayoutSize()` → `{ minHeight, minWidth, maxHeight? }`
|
||||
- Avoid `getMaxHeight()` unless the widget genuinely needs a fixed cap (prevents user resize)
|
||||
|
||||
## Vue Mode Layout
|
||||
|
||||
Uses CSS Grid (`grid-template-rows`) + `ResizeObserver`. The ResizeObserver watches the widget's DOM and feeds back into grid row sizing. This creates a feedback loop: content grows → row resizes → more space for content → content reflows/grows → row resizes again.
|
||||
|
||||
### Height Containment
|
||||
|
||||
The fix: `contain: layout size` on the widget root. This tells the browser the element's intrinsic size is CSS-determined, not driven by descendant content. The ResizeObserver sees a stable size and the loop is broken.
|
||||
|
||||
```css
|
||||
.widget-root.lm-vue-node {
|
||||
height: 100%;
|
||||
min-height: var(--comfy-widget-min-height, 200px);
|
||||
contain: layout size;
|
||||
}
|
||||
```
|
||||
|
||||
Existing examples: `.lm-loras-container.lm-vue-node` and `.comfy-tags-container.lm-vue-node` in `web/comfyui/lm_styles.css`.
|
||||
|
||||
**Do NOT** fix height issues with `maxHeight`, `getMaxHeight()`, or inline `max-height` — these prevent the user from resizing the node.
|
||||
|
||||
## Scroll Wheel Isolation
|
||||
|
||||
Both modes need to distinguish "user wants to scroll widget content" from "user wants to zoom canvas".
|
||||
|
||||
**Canvas mode:** Add `@wheel` on widget root. Check `event.target.closest(selector)` for scrollable sub-areas. If scrollable → `event.stopPropagation()`. Otherwise → `app.canvas.processMouseWheel(event)`.
|
||||
|
||||
**Vue mode:** Add CSS class `lm-wheel-scrollable` to scrollable elements. The global capture-phase hook in `web/comfyui/utils.js` (`enableListWheelScroll`) detects wheel events on marked elements and manually scrolls them via `element.scrollTop`, consuming the event before canvas zoom sees it.
|
||||
|
||||
## DOM Structure
|
||||
|
||||
`main.ts` creates an outer `<div>` container, then `vueApp.mount(container)`. The Vue app renders its own root element inside.
|
||||
|
||||
- `container.id` / `container.style.*` → outer element
|
||||
- Vue scoped `<style>` → `[data-v-hash]` applies only to Vue root
|
||||
|
||||
Classes needed by scoped Vue CSS must go on the Vue root element. Pass data as props and bind with `:class` rather than manipulating the DOM from `main.ts`.
|
||||
|
||||
## Serialization
|
||||
|
||||
For stateful widgets that need workflow persistence:
|
||||
|
||||
- `serialize: true` in `addDOMWidget` options
|
||||
- `serializeValue()` → state snapshot (called on workflow save)
|
||||
- `onSetValue(v)` → restore state (called on workflow load)
|
||||
- Always handle missing keys in restored value for backward compatibility with old workflows
|
||||
File diff suppressed because one or more lines are too long
495
locales/de.json
495
locales/de.json
@@ -15,10 +15,14 @@
|
||||
"settings": "Einstellungen",
|
||||
"help": "Hilfe",
|
||||
"add": "Hinzufügen",
|
||||
"close": "Schließen"
|
||||
"close": "Schließen",
|
||||
"menu": "Menü",
|
||||
"remove": "Entfernen",
|
||||
"change": "Ändern"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Wird geladen...",
|
||||
"cancelling": "Abbrechen...",
|
||||
"unknown": "Unbekannt",
|
||||
"date": "Datum",
|
||||
"version": "Version",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "Aus Favoriten entfernen",
|
||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||
"notAvailableFromCivitai": "Nicht auf Civitai verfügbar",
|
||||
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
|
||||
"copyLoRASyntax": "LoRA-Syntax kopieren",
|
||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "Vorschau ersetzen",
|
||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Verwendungsanzahl"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} Versionen",
|
||||
"viewAllVersions": "Alle lokalen Versionen anzeigen"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "{count} Rezepte erfolgreich repariert.",
|
||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Nach Modell gruppieren"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "Statistiken"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Suchen...",
|
||||
"placeholders": {
|
||||
"loras": "LoRAs suchen...",
|
||||
"recipes": "Rezepte suchen...",
|
||||
"checkpoints": "Checkpoints suchen...",
|
||||
"embeddings": "Embeddings suchen..."
|
||||
},
|
||||
"placeholder": "Suchen",
|
||||
"options": "Suchoptionen",
|
||||
"searchIn": "Suchen in:",
|
||||
"notAvailable": "Suche auf Statistikseite nicht verfügbar",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?",
|
||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||
"baseModel": "Basis-Modell",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
|
||||
"modelTags": "Tags",
|
||||
"modelTypes": "Modelltypen",
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||
"noTags": "Keine Tags",
|
||||
"tagSearchPlaceholder": "Tags durchsuchen...",
|
||||
"noTagMatches": "Keine Tags entsprechen der aktuellen Suche.",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||
"clearAll": "Alle Filter löschen",
|
||||
"any": "Beliebig",
|
||||
"all": "Alle",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "Theme wechseln",
|
||||
"switchToLight": "Zu hellem Theme wechseln",
|
||||
"switchToDark": "Zu dunklem Theme wechseln",
|
||||
"switchToAuto": "Zu automatischem Theme wechseln"
|
||||
"switchToAuto": "Zu automatischem Theme wechseln",
|
||||
"presets": "Theme-Voreinstellungen",
|
||||
"default": "Standard",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Modus",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Updates prüfen",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||
"civitaiApiKeyConfigured": "Konfiguriert",
|
||||
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
||||
"civitaiApiKeySet": "Einrichten",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai-Host",
|
||||
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||
"options": {
|
||||
"com": "civitai.com (nur SFW)",
|
||||
"red": "civitai.red (uneingeschränkt)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "Download-Backend",
|
||||
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den empfohlenen externen Downloader-Prozess.",
|
||||
"options": {
|
||||
"python": "Python (integriert)",
|
||||
"aria2": "aria2 (empfohlen)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "aria2c-Pfad",
|
||||
"help": "Optionaler Pfad zur ausführbaren aria2c-Datei. Leer lassen, um aria2c aus dem System-PATH zu verwenden.",
|
||||
"placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
|
||||
},
|
||||
"aria2HelpLink": "Erfahren Sie, wie Sie das aria2-Download-Backend einrichten",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai-Host-Einstellung verfügbar",
|
||||
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Einstellungsordner öffnen",
|
||||
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Inhaltsfilterung",
|
||||
"downloads": "Downloads",
|
||||
"videoSettings": "Video-Einstellungen",
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"licenseIcons": "Lizenzsymbole",
|
||||
"misc": "Verschiedenes",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Standard-Roots",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||
"priorityTags": "Prioritäts-Tags",
|
||||
"updateFlags": "Update-Markierungen",
|
||||
"versionScope": "Update-Markierungen",
|
||||
"exampleImages": "Beispielbilder",
|
||||
"autoOrganize": "Auto-Organisierung",
|
||||
"metadata": "Metadaten",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Nach Modell gruppieren",
|
||||
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
||||
"displayDensity": "Anzeige-Dichte",
|
||||
"displayDensityOptions": {
|
||||
"default": "Standard",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "Bei Hover anzeigen"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||
"showVersionOnCard": "Version auf Karte anzeigen",
|
||||
"showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden",
|
||||
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Beispielbilder öffnen",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "Modellname",
|
||||
"fileName": "Dateiname"
|
||||
},
|
||||
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
|
||||
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll",
|
||||
"cardBlurAmount": "Karten-Overlay-Unschärfe",
|
||||
"cardBlurAmountHelp": "Passen Sie die Unschärfeintensität der Kopf- und Fußzeilen-Overlays auf Modell- und Rezeptkarten an (0 = keine Unschärfe, 20 = maximale Unschärfe)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Aktive Bibliothek",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
||||
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert",
|
||||
"checkpointUnetOverlap": "Derselbe Pfad kann nicht für Checkpoints und Diffusionsmodelle verwendet werden: {paths}",
|
||||
"checkpointUnetOverlapInline": "Dieser Pfad wird bereits für einen anderen Modelltyp verwendet. Bitte verwenden Sie separate Ordner für Checkpoints und Diffusionsmodelle."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,12 +574,27 @@
|
||||
"downloadLocationHelp": "Geben Sie den Ordnerpfad ein, wo Beispielbilder von Civitai gespeichert werden",
|
||||
"autoDownload": "Beispielbilder automatisch herunterladen",
|
||||
"autoDownloadHelp": "Beispielbilder automatisch für Modelle herunterladen, die keine haben (erfordert gesetzten Download-Speicherort)",
|
||||
"openMode": "Aktion für Beispielbilder öffnen",
|
||||
"openModeHelp": "Wählen Sie, ob die Aktion auf dem Server geöffnet, ein zugeordneter lokaler Pfad kopiert oder eine benutzerdefinierte URI gestartet werden soll.",
|
||||
"openModeOptions": {
|
||||
"system": "Auf Server öffnen",
|
||||
"clipboard": "Lokalen Pfad kopieren",
|
||||
"uriTemplate": "Benutzerdefinierte URI öffnen"
|
||||
},
|
||||
"localRoot": "Lokales Stammverzeichnis für Beispielbilder",
|
||||
"localRootHelp": "Optionales lokales oder eingebundenes Stammverzeichnis, das das Beispielbild-Verzeichnis des Servers widerspiegelt. Wenn leer, wird der Serverpfad wiederverwendet.",
|
||||
"localRootPlaceholder": "Beispiel: /Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "URI-Vorlage öffnen",
|
||||
"uriTemplateHelp": "Verwenden Sie einen benutzerdefinierten Deeplink wie eine Datei-URI oder einen Shortcuts-Link.",
|
||||
"uriTemplatePlaceholder": "Beispiel: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "Verfügbare Platzhalter: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||
"openModeWikiLink": "Mehr über Remote-Open-Modi erfahren",
|
||||
"optimizeImages": "Heruntergeladene Bilder optimieren",
|
||||
"optimizeImagesHelp": "Beispielbilder optimieren, um Dateigröße zu reduzieren und Ladegeschwindigkeit zu verbessern (Metadaten bleiben erhalten)",
|
||||
"download": "Herunterladen",
|
||||
"restartRequired": "Neustart erforderlich"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Strategie für Update-Markierungen",
|
||||
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
|
||||
"options": {
|
||||
@@ -523,9 +606,19 @@
|
||||
"label": "Früher Zugriff Updates ausblenden",
|
||||
"help": "Nur Early-Access-Updates"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
|
||||
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "Download wird vorbereitet...",
|
||||
"connecting": "Verbindung zum Download-Server wird hergestellt...",
|
||||
"completed": "Abgeschlossen",
|
||||
"downloadComplete": "Download erfolgreich abgeschlossen"
|
||||
"downloadComplete": "Download erfolgreich abgeschlossen",
|
||||
"enableCivarchiveApi": "CivArchive API als Metadaten-Anbieter aktivieren",
|
||||
"enableCivarchiveApiHelp": "Wenn aktiviert, wird die CivArchive API als alternative Quelle für Modell-Metadaten verwendet (z. B. für von CivitAI gelöschte Modelle). Deaktivieren, um die Ratenbegrenzungen von CivArchive vollständig zu vermeiden.",
|
||||
"providerOrder": "Reihenfolge der Metadaten-Anbieter",
|
||||
"providerOrderHelp": "Die CivitAI API wird immer zuerst versucht. Wählen Sie die Reihenfolge der übrigen Anbieter bei der Metadatensuche.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "App-Proxy aktivieren",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "Passwort (optional)",
|
||||
"proxyPasswordPlaceholder": "passwort",
|
||||
"proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "KI-Anbieter",
|
||||
"provider": "Anbieter",
|
||||
"providerHelp": "Wählen Sie Ihren LLM-Anbieter. OpenAI und Ollama verwenden voreingestellte API-Endpunkte. Mit \"Benutzerdefiniert\" können Sie jeden OpenAI-kompatiblen Endpunkt angeben.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (lokal)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "Benutzerdefiniert (OpenAI-kompatibel)"
|
||||
},
|
||||
"apiBase": "API-Basis-URL",
|
||||
"apiBaseHelp": "Die Basis-URL für die LLM-API (z.B. https://api.openai.com/v1). Leer lassen, um die Anbietervoreinstellung zu verwenden.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"apiKeyHelp": "Ihr LLM-API-Schlüssel. Wird lokal gespeichert und niemals an einen anderen Server außer Ihrem gewählten LLM-Anbieter gesendet.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "Nicht festgelegt",
|
||||
"apiKeyConfigured": "Konfiguriert",
|
||||
"apiKeySet": "Einrichten",
|
||||
"model": "Modell",
|
||||
"modelHelp": "Der zu verwendende Modellname (z.B. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Prüfen Sie Ihren Anbieter auf verfügbare Modelle.",
|
||||
"modelPlaceholder": "Modell auswählen..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "Kleinste",
|
||||
"usage": "Anzahl Nutzung",
|
||||
"usageDesc": "Meiste",
|
||||
"usageAsc": "Wenigste"
|
||||
"usageAsc": "Wenigste",
|
||||
"versionsCount": "Lokale Versionen",
|
||||
"versionsCountDesc": "Meiste Versionen zuerst",
|
||||
"versionsCountAsc": "Wenigste Versionen zuerst",
|
||||
"versionIdDesc": "Neueste Version zuerst",
|
||||
"random": "Zufällig",
|
||||
"randomAction": "Zufällig mischen"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Modelliste aktualisieren",
|
||||
"quick": "Änderungen synchronisieren",
|
||||
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
||||
"full": "Cache neu aufbauen",
|
||||
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||
"deleteAll": "Alle Modelle löschen",
|
||||
"setFavorite": "Als Favorit setzen",
|
||||
"setFavoriteCount": "Als Favorit setzen ({favorited}/{total})",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"deleteAll": "Ausgewählte löschen",
|
||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||
"downloadExamples": "Beispielbilder herunterladen",
|
||||
"downloadMissingExamples": "Fehlende herunterladen",
|
||||
"reprocessExamples": "Alle erneut verarbeiten",
|
||||
"clear": "Auswahl löschen",
|
||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||
"sendToWorkflow": "An Workflow senden",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "Metadaten",
|
||||
"attributes": "Attribute",
|
||||
"organize": "Organisieren",
|
||||
"download": "Download"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Automatische Organisation wird initialisiert...",
|
||||
"starting": "Automatische Organisation für {type} wird gestartet...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "Abgeschlossen: {success} verschoben, {skipped} übersprungen, {failures} fehlgeschlagen",
|
||||
"complete": "Automatische Organisation abgeschlossen",
|
||||
"error": "Fehler: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai-Daten aktualisieren",
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"relinkCivitai": "Mit Civitai neu verknüpfen",
|
||||
"linkModel": "Modell verknüpfen",
|
||||
"linkCivitai": "Mit Civitai neu verknüpfen",
|
||||
"linkHuggingFace": "Mit HuggingFace verknüpfen",
|
||||
"copySyntax": "LoRA-Syntax kopieren",
|
||||
"copyFilename": "Modell-Dateiname kopieren",
|
||||
"copyRecipeSyntax": "Rezept-Syntax kopieren",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "An Workflow senden (Ersetzen)",
|
||||
"openExamples": "Beispiele-Ordner öffnen",
|
||||
"downloadExamples": "Beispielbilder herunterladen",
|
||||
"downloadMissingExamples": "Fehlende herunterladen",
|
||||
"reprocessExamples": "Alle erneut verarbeiten",
|
||||
"replacePreview": "Vorschau ersetzen",
|
||||
"setContentRating": "Inhaltsbewertung festlegen",
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
"restoreModel": "Modell wiederherstellen",
|
||||
"deleteModel": "Modell löschen",
|
||||
"shareRecipe": "Rezept teilen",
|
||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||
"deleteRecipe": "Rezept löschen"
|
||||
"deleteRecipe": "Rezept löschen",
|
||||
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Rezeptliste aktualisieren",
|
||||
"quick": "Änderungen synchronisieren",
|
||||
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
|
||||
"full": "Cache neu aufbauen",
|
||||
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
|
||||
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
|
||||
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Rezept wird aus Quelle neu importiert...",
|
||||
"success": "Rezept erfolgreich neu importiert",
|
||||
"noSourceUrl": "Rezept hat keine Quell-URL, Neuimport nicht möglich",
|
||||
"failed": "Neuimport des Rezepts fehlgeschlagen: {message}",
|
||||
"missingId": "Neuimport nicht möglich: Rezept-ID fehlt"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
"pinSidebar": "Sidebar anheften",
|
||||
"unpinSidebar": "Sidebar lösen",
|
||||
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
|
||||
"showSidebar": "Seitenleiste anzeigen",
|
||||
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
|
||||
"switchToListView": "Zur Listenansicht wechseln",
|
||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||
"recursiveOn": "Unterordner einbeziehen",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"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": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "Speicher",
|
||||
"insights": "Erkenntnisse"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Modelle gesamt",
|
||||
"totalStorage": "Speicher gesamt",
|
||||
"totalGenerations": "Generationen gesamt",
|
||||
"usageRate": "Nutzungsrate",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Einzigartige Tags",
|
||||
"unusedModels": "Ungenutzte Modelle",
|
||||
"avgUsesPerModel": "Ø Nutzungen/Modell"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Meistgenutzte LoRAs",
|
||||
"mostUsedCheckpoints": "Meistgenutzte Checkpoints",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Intelligente Erkenntnisse",
|
||||
"recommendations": "Empfehlungen"
|
||||
"recommendations": "Empfehlungen",
|
||||
"noInsights": "Keine Erkenntnisse verfügbar",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Hohe Anzahl ungenutzter LoRAs",
|
||||
"description": "{percent}% Ihrer LoRAs ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Erwägen Sie, ungenutzte Modelle zu organisieren oder zu archivieren, um Speicherplatz freizugeben."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Ungenutzte Checkpoints erkannt",
|
||||
"description": "{percent}% Ihrer Checkpoints ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Überprüfen Sie nicht mehr benötigte Checkpoints und erwägen Sie deren Entfernung."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Hohe Anzahl ungenutzter Embeddings",
|
||||
"description": "{percent}% Ihrer Embeddings ({count}/{total}) wurden noch nie verwendet.",
|
||||
"suggestion": "Organisieren oder archivieren Sie ungenutzte Embeddings, um Ihre Sammlung zu optimieren."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Große Sammlung erkannt",
|
||||
"description": "Ihre Modellsammlung verwendet {size} Speicher.",
|
||||
"suggestion": "Erwägen Sie externe Speicher- oder Cloud-Lösungen für eine bessere Organisation."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Aktiver Benutzer",
|
||||
"description": "Sie haben {count} Generationen abgeschlossen!",
|
||||
"suggestion": "Entdecken und erstellen Sie weiterhin großartige Inhalte mit Ihren Modellen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Sammlungsübersicht",
|
||||
"baseModelDistribution": "Basis-Modell-Verteilung",
|
||||
"usageTrends": "Nutzungstrends (Letzte 30 Tage)",
|
||||
"usageDistribution": "Nutzungsverteilung"
|
||||
"usageDistribution": "Nutzungsverteilung",
|
||||
"date": "Datum",
|
||||
"usageCount": "Nutzungsanzahl",
|
||||
"fileSizeBytes": "Dateigröße (Bytes)",
|
||||
"models": "Modelle",
|
||||
"loraUsage": "LoRA-Nutzung",
|
||||
"checkpointUsage": "Checkpoint-Nutzung",
|
||||
"embeddingUsage": "Embedding-Nutzung"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusionsmodell",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Lädt...",
|
||||
"noModels": "Keine Modelle gefunden",
|
||||
"errorLoading": "Fehler beim Laden der Daten",
|
||||
"noStorageData": "Keine Speicherdaten verfügbar",
|
||||
"rootFolder": "Root",
|
||||
"chartLibraryMissing": "Diagramm benötigt Chart.js-Bibliothek"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} Modelle",
|
||||
"chartUsage": "{name}: {size}, {count} Nutzungen",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "Modell von URL herunterladen",
|
||||
"titleWithType": "{type} von URL herunterladen",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Geben Sie eine CivitAI-, CivArchive- oder Hugging Face-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
|
||||
"selectHfFiles": "Datei(en) zum Herunterladen aus diesem Repository auswählen:",
|
||||
"selectAll": "Alle auswählen",
|
||||
"fetchingRepoFiles": "Repository-Dateien werden abgerufen...",
|
||||
"locationPreview": "Download-Speicherort Vorschau",
|
||||
"useDefaultPath": "Standardpfad verwenden",
|
||||
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||
"fileSelection": {
|
||||
"title": "Dateiformat auswählen",
|
||||
"files": "Dateien",
|
||||
"select": "Datei auswählen"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
||||
"noVersions": "Keine Versionen für dieses Modell verfügbar",
|
||||
"mixedSources": "CivitAI- und Hugging Face-URLs können nicht in derselben Charge gemischt werden.",
|
||||
"noModelFiles": "In diesem Repository wurden keine Modelldateien gefunden."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Download wird vorbereitet...",
|
||||
"downloadedPreview": "Vorschaubild heruntergeladen",
|
||||
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
||||
"finalizing": "Download wird abgeschlossen..."
|
||||
"finalizing": "Download wird abgeschlossen...",
|
||||
"cancelling": "Download wird abgebrochen...",
|
||||
"cancelled": "Download abgebrochen"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Aktuelle Datei:",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "Ordnerpfad eingeben oder aus Baum unten auswählen...",
|
||||
"root": "Stammverzeichnis"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Mit HuggingFace verknüpfen",
|
||||
"infoText": "Fügen Sie die HuggingFace-Repository-URL ein, um dieses Modell zuzuordnen. Dies ermöglicht die KI-gestützte Metadatenanreicherung.",
|
||||
"urlLabel": "HuggingFace-Repository-URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Geben Sie die vollständige URL des HuggingFace-Repositorys ein.",
|
||||
"confirmAction": "Speichern & Verknüpfen"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Mit Civitai neu verknüpfen",
|
||||
"warning": "Warnung:",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "Modellname bearbeiten",
|
||||
"editFileName": "Dateiname bearbeiten",
|
||||
"editBaseModel": "Basis-Modell bearbeiten",
|
||||
"editVersionName": "Versionsname bearbeiten",
|
||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
|
||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
"sendToWorkflow": "An ComfyUI senden",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "Zusätzliche Notizen",
|
||||
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
|
||||
"addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...",
|
||||
"aboutThisVersion": "Über diese Version"
|
||||
"aboutThisVersion": "Über diese Version",
|
||||
"baseModelSearchPlaceholder": "Basismodell suchen…",
|
||||
"baseModelSuggested": "Vorschlag",
|
||||
"baseModelNoMatch": "Keine passenden Basismodelle"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notizen erfolgreich gespeichert",
|
||||
"saveFailed": "Fehler beim Speichern der Notizen"
|
||||
"saveFailed": "Fehler beim Speichern der Notizen",
|
||||
"showMore": "Mehr anzeigen",
|
||||
"showLess": "Weniger anzeigen"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "Bearbeitung abbrechen",
|
||||
"save": "Änderungen speichern",
|
||||
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
||||
"editWord": "Trigger Word bearbeiten",
|
||||
"editPlaceholder": "Trigger Word bearbeiten",
|
||||
"copyWord": "Trigger Word kopieren",
|
||||
"deleteWord": "Trigger Word löschen",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "Früher Zugriff",
|
||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||
"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": {
|
||||
"download": "Herunterladen",
|
||||
"downloadTooltip": "Diese Version 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",
|
||||
"deleteTooltip": "Diese lokale Version löschen",
|
||||
"ignore": "Ignorieren",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
||||
"error": "Versionen konnten nicht geladen werden.",
|
||||
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
||||
"hfGroupInfo": "Dies ist eine HuggingFace-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
|
||||
"confirm": {
|
||||
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "Version gelöscht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Metadaten abrufen — Zusammenfassung",
|
||||
"statSuccess": "Erfolgreich",
|
||||
"statFailed": "Fehlgeschlagen",
|
||||
"statSkipped": "Übersprungen",
|
||||
"statTotal": "Gesamt geprüft",
|
||||
"statDuration": "Dauer",
|
||||
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
|
||||
"failedItems": "Fehlgeschlagene Elemente ({count})",
|
||||
"close": "Schließen",
|
||||
"copyReport": "Bericht kopieren",
|
||||
"downloadCsv": "CSV herunterladen",
|
||||
"columnModelName": "Modellname",
|
||||
"columnError": "Fehler"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "Dieser Tag existiert bereits"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Tastatur-Navigation:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Eine Seite nach oben scrollen",
|
||||
"pageDown": "Eine Seite nach unten scrollen",
|
||||
"home": "Zum Anfang springen",
|
||||
"end": "Zum Ende springen"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Initialisierung",
|
||||
"message": "Ihr Arbeitsbereich wird vorbereitet...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens"
|
||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
||||
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
|
||||
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings",
|
||||
"promptSent": "Prompt an Workflow gesendet",
|
||||
"promptFailed": "Fehler beim Senden des Prompts"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Rezept",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Ersetzen",
|
||||
"append": "Anhängen",
|
||||
"selectTargetNode": "Zielknoten auswählen",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "Beispielbilder-Ordner geöffnet",
|
||||
"openingFolder": "Beispielbilder-Ordner wird geöffnet",
|
||||
"failedToOpen": "Fehler beim Öffnen des Beispielbilder-Ordners",
|
||||
"copiedPath": "Pfad in Zwischenablage kopiert: {{path}}",
|
||||
"clipboardFallback": "Pfad: {{path}}",
|
||||
"copiedUri": "Link in Zwischenablage kopiert: {{uri}}",
|
||||
"uriClipboardFallback": "Link: {{uri}}",
|
||||
"setupRequired": "Beispielbilder-Speicher",
|
||||
"setupDescription": "Um benutzerdefinierte Beispielbilder hinzuzufügen, müssen Sie zuerst einen Download-Speicherort festlegen.",
|
||||
"setupUsage": "Dieser Pfad wird sowohl für heruntergeladene als auch für benutzerdefinierte Beispielbilder verwendet.",
|
||||
@@ -1460,6 +1774,12 @@
|
||||
"checkingMessage": "Bitte warten Sie, während wir nach der neuesten Version suchen.",
|
||||
"showNotifications": "Update-Benachrichtigungen anzeigen",
|
||||
"latestBadge": "Neueste",
|
||||
"latestMain": "Main-Branch",
|
||||
"channel": "Update-Kanal",
|
||||
"channels": {
|
||||
"release": "Release",
|
||||
"nightly": "Nightly"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "Update wird vorbereitet...",
|
||||
"installing": "Update wird installiert...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
||||
"enable": "Nightly Updates aktivieren"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "Zu Nightly-Kanal wechseln",
|
||||
"nightlyMessage": "Der Wechsel zu Nightly initialisiert ein Git-Repository und verfolgt die neuesten Commits des main-Branches. Updates sind häufiger, können aber instabil sein. Sie können jederzeit zu Release zurückwechseln.",
|
||||
"releaseTitle": "Zu Release-Kanal wechseln",
|
||||
"releaseMessage": "Der Wechsel zu Release checkt den neuesten stabilen Versions-Tag aus. Sie können jederzeit zu Nightly zurückwechseln.",
|
||||
"switching": "Wechsle zu {channel}-Kanal...",
|
||||
"completed": "Erfolgreich zu {channel}-Kanal gewechselt",
|
||||
"failed": "Kanalwechsel fehlgeschlagen"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Neueste Mitteilungen",
|
||||
"empty": "Keine aktuellen Banner verfügbar.",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "Keine Rezept-ID verfügbar",
|
||||
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {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",
|
||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
|
||||
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
|
||||
"reconnectedSuccessfully": "LoRA erfolgreich neu verbunden",
|
||||
"reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}",
|
||||
"noPromptToSend": "Kein zu sendender Prompt",
|
||||
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
|
||||
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
|
||||
"sendError": "Fehler beim Senden des Rezepts an Workflow",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"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}",
|
||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
||||
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
||||
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
|
||||
"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."
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||
"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...",
|
||||
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
|
||||
"imagesFailed": "Beispielbilder {action} fehlgeschlagen",
|
||||
"loadError": "Fehler beim Laden der Downloads: {message}",
|
||||
"downloadError": "Download-Fehler: {message}"
|
||||
"downloadError": "Download-Fehler: {message}",
|
||||
"downloadStopped": "Download abgebrochen"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "Konnte trainierte Wörter nicht laden",
|
||||
"tooLong": "Trigger Word sollte 100 Wörter nicht überschreiten",
|
||||
"tooMany": "Maximal 30 Trigger Words erlaubt",
|
||||
"tooLong": "Trigger Word sollte 500 Wörter nicht überschreiten",
|
||||
"tooMany": "Maximal 100 Trigger Words erlaubt",
|
||||
"alreadyExists": "Dieses Trigger Word existiert bereits",
|
||||
"updateSuccess": "Trigger Words erfolgreich aktualisiert",
|
||||
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "Fehler beim Setzen der Inhaltsbewertung: {message}",
|
||||
"relinkSuccess": "Modell erfolgreich mit Civitai neu verknüpft",
|
||||
"relinkFailed": "Fehler: {message}",
|
||||
"linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft",
|
||||
"linkHfFailed": "Fehler: {message}",
|
||||
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
|
||||
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
|
||||
"missingHash": "Modell-Hash nicht verfügbar"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "Fehler beim Löschen von {type}: {message}",
|
||||
"excludeSuccess": "{type} erfolgreich ausgeschlossen",
|
||||
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
|
||||
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
|
||||
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
|
||||
"fileNameUpdated": "Dateiname erfolgreich aktualisiert",
|
||||
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
||||
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"downloadStarted": "Download gestartet"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "KI-Anbieter nicht konfiguriert. Aktivieren Sie ihn unter Einstellungen → KI-Anbieter.",
|
||||
"enrichStarted": "Metadaten werden mit KI angereichert...",
|
||||
"enrichComplete": "Metadatenanreicherung abgeschlossen: {{summary}}",
|
||||
"enrichFailed": "Metadatenanreicherung fehlgeschlagen: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "Handlungsbedarf",
|
||||
"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": {
|
||||
"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": {
|
||||
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
||||
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
||||
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
||||
"exportSuccess": "Diagnosepaket exportiert.",
|
||||
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
|
||||
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}",
|
||||
"conflictsResolved": "{count} Dateinamenskonflikt(e) gelöst.",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "Anwendungs-Update erkannt",
|
||||
|
||||
4089
locales/en.json
4089
locales/en.json
File diff suppressed because it is too large
Load Diff
497
locales/es.json
497
locales/es.json
@@ -15,10 +15,14 @@
|
||||
"settings": "Configuración",
|
||||
"help": "Ayuda",
|
||||
"add": "Añadir",
|
||||
"close": "Cerrar"
|
||||
"close": "Cerrar",
|
||||
"menu": "Menú",
|
||||
"remove": "Eliminar",
|
||||
"change": "Cambiar"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando...",
|
||||
"cancelling": "Cancelando...",
|
||||
"unknown": "Desconocido",
|
||||
"date": "Fecha",
|
||||
"version": "Versión",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "Eliminar de favoritos",
|
||||
"viewOnCivitai": "Ver en Civitai",
|
||||
"notAvailableFromCivitai": "No disponible en Civitai",
|
||||
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
|
||||
"copyLoRASyntax": "Copiar sintaxis de LoRA",
|
||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "Reemplazar vista previa",
|
||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Veces usado"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versiones",
|
||||
"viewAllVersions": "Ver todas las versiones locales"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "Se repararon con éxito {count} recetas.",
|
||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||
"error": "Error al reparar recetas: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Agrupar por modelo"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "Estadísticas"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar...",
|
||||
"placeholders": {
|
||||
"loras": "Buscar LoRAs...",
|
||||
"recipes": "Buscar recetas...",
|
||||
"checkpoints": "Buscar checkpoints...",
|
||||
"embeddings": "Buscar embeddings..."
|
||||
},
|
||||
"placeholder": "Buscar",
|
||||
"options": "Opciones de búsqueda",
|
||||
"searchIn": "Buscar en:",
|
||||
"notAvailable": "Búsqueda no disponible en la página de estadísticas",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?",
|
||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||
"baseModel": "Modelo base",
|
||||
"modelTags": "Etiquetas (Top 20)",
|
||||
"baseModelSearchPlaceholder": "Buscar modelos base...",
|
||||
"modelTags": "Etiquetas",
|
||||
"modelTypes": "Tipos de modelos",
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||
"noTags": "Sin etiquetas",
|
||||
"tagSearchPlaceholder": "Buscar etiquetas...",
|
||||
"noTagMatches": "Ninguna etiqueta coincide con la búsqueda actual.",
|
||||
"autoTags": "Etiquetas automáticas",
|
||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||
"clearAll": "Limpiar todos los filtros",
|
||||
"any": "Cualquiera",
|
||||
"all": "Todos",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "Cambiar tema",
|
||||
"switchToLight": "Cambiar a tema claro",
|
||||
"switchToDark": "Cambiar a tema oscuro",
|
||||
"switchToAuto": "Cambiar a tema automático"
|
||||
"switchToAuto": "Cambiar a tema automático",
|
||||
"presets": "Preajustes de tema",
|
||||
"default": "Predeterminado",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Modo",
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||
"civitaiApiKeyConfigured": "Configurado",
|
||||
"civitaiApiKeyNotConfigured": "No configurado",
|
||||
"civitaiApiKeySet": "Configurar",
|
||||
"civitaiHost": {
|
||||
"label": "Host de Civitai",
|
||||
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||
"options": {
|
||||
"com": "civitai.com (solo SFW)",
|
||||
"red": "civitai.red (sin restricciones)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "Backend 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": {
|
||||
"python": "Python (integrado)",
|
||||
"aria2": "aria2 (recomendado)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "Ruta de aria2c",
|
||||
"help": "Ruta opcional al ejecutable aria2c. Déjalo vacío para usar aria2c desde el PATH del sistema.",
|
||||
"placeholder": "Déjalo vacío para usar aria2c desde el PATH"
|
||||
},
|
||||
"aria2HelpLink": "Aprende a configurar el backend de descarga aria2",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Preferencia de host de Civitai disponible",
|
||||
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
|
||||
"openSettings": "Abrir ajustes"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Abrir carpeta de ajustes",
|
||||
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Filtrado de contenido",
|
||||
"downloads": "Descargas",
|
||||
"videoSettings": "Configuración de video",
|
||||
"layoutSettings": "Configuración de diseño",
|
||||
"licenseIcons": "Iconos de licencia",
|
||||
"misc": "Varios",
|
||||
"backup": "Copias de seguridad",
|
||||
"folderSettings": "Raíces predeterminadas",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||
"priorityTags": "Etiquetas prioritarias",
|
||||
"updateFlags": "Indicadores de actualización",
|
||||
"versionScope": "Indicadores de actualización",
|
||||
"exampleImages": "Imágenes de ejemplo",
|
||||
"autoOrganize": "Organización automática",
|
||||
"metadata": "Metadatos",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Agrupar por modelo",
|
||||
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
|
||||
"displayDensity": "Densidad de visualización",
|
||||
"displayDensityOptions": {
|
||||
"default": "Predeterminado",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "Mostrar al pasar el ratón"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||
"showVersionOnCard": "Mostrar versión en la tarjeta",
|
||||
"showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo",
|
||||
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Abrir imágenes de ejemplo",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "Nombre del modelo",
|
||||
"fileName": "Nombre del archivo"
|
||||
},
|
||||
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
|
||||
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo",
|
||||
"cardBlurAmount": "Desenfoque de superposición de tarjetas",
|
||||
"cardBlurAmountHelp": "Ajuste la intensidad de desenfoque de las superposiciones del encabezado y pie de página en las tarjetas de modelos y recetas (0 = sin desenfoque, 20 = desenfoque máximo)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Biblioteca activa",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
|
||||
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Esta ruta ya está configurada"
|
||||
"duplicatePath": "Esta ruta ya está configurada",
|
||||
"checkpointUnetOverlap": "No se puede usar la misma ruta para checkpoints y modelos de difusión: {paths}",
|
||||
"checkpointUnetOverlapInline": "Esta ruta ya se usa para otro tipo de modelo. Use carpetas separadas para checkpoints y modelos de difusión."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,12 +574,27 @@
|
||||
"downloadLocationHelp": "Introduce la ruta de la carpeta donde se guardarán las imágenes de ejemplo de Civitai",
|
||||
"autoDownload": "Descargar automáticamente imágenes de ejemplo",
|
||||
"autoDownloadHelp": "Descargar automáticamente imágenes de ejemplo para modelos que no las tengan (requiere que se establezca la ubicación de descarga)",
|
||||
"openMode": "Acción al abrir imágenes de ejemplo",
|
||||
"openModeHelp": "Elige si la acción se abre en el servidor, copia una ruta local asignada o lanza una URI personalizada.",
|
||||
"openModeOptions": {
|
||||
"system": "Abrir en el servidor",
|
||||
"clipboard": "Copiar ruta local",
|
||||
"uriTemplate": "Abrir URI personalizada"
|
||||
},
|
||||
"localRoot": "Raíz local de imágenes de ejemplo",
|
||||
"localRootHelp": "Raíz local u montada opcional que refleja el directorio de imágenes de ejemplo del servidor. Si se deja en blanco, se reutiliza la ruta del servidor.",
|
||||
"localRootPlaceholder": "Ejemplo: /Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "Abrir plantilla de URI",
|
||||
"uriTemplateHelp": "Usa un enlace profundo personalizado, como un URI de archivo o un enlace de Shortcuts.",
|
||||
"uriTemplatePlaceholder": "Ejemplo: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "Marcadores disponibles: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||
"openModeWikiLink": "Más información sobre los modos de apertura remota",
|
||||
"optimizeImages": "Optimizar imágenes descargadas",
|
||||
"optimizeImagesHelp": "Optimizar imágenes de ejemplo para reducir el tamaño del archivo y mejorar la velocidad de carga (se preservarán los metadatos)",
|
||||
"download": "Descargar",
|
||||
"restartRequired": "Requiere reinicio"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Estrategia de indicadores de actualización",
|
||||
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
|
||||
"options": {
|
||||
@@ -523,9 +606,19 @@
|
||||
"label": "Ocultar actualizaciones de acceso temprano",
|
||||
"help": "Solo actualizaciones de acceso temprano"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Usar iconos de licencia actualizados",
|
||||
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "Preparando descarga...",
|
||||
"connecting": "Conectando al servidor de descarga...",
|
||||
"completed": "Completado",
|
||||
"downloadComplete": "Descarga completada exitosamente"
|
||||
"downloadComplete": "Descarga completada exitosamente",
|
||||
"enableCivarchiveApi": "Habilitar CivArchive API como proveedor de metadatos",
|
||||
"enableCivarchiveApiHelp": "Al activarlo, la API de CivArchive se usa como fuente alternativa de metadatos de modelos (p. ej. para modelos eliminados de CivitAI). Desactívelo para evitar por completo los límites de velocidad de CivArchive.",
|
||||
"providerOrder": "Orden de proveedores de metadatos de respaldo",
|
||||
"providerOrderHelp": "La API de CivitAI siempre se intenta primero. Elija el orden de los demás proveedores al buscar metadatos.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Habilitar proxy a nivel de aplicación",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "Contraseña (opcional)",
|
||||
"proxyPasswordPlaceholder": "contraseña",
|
||||
"proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "Proveedor de IA",
|
||||
"provider": "Proveedor",
|
||||
"providerHelp": "Elija su proveedor de LLM. OpenAI y Ollama usan endpoints predefinidos. Personalizado le permite especificar cualquier endpoint compatible con OpenAI.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (local)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "Personalizado (compatible con OpenAI)"
|
||||
},
|
||||
"apiBase": "URL base de la API",
|
||||
"apiBaseHelp": "La URL base para la API LLM (p.ej. https://api.openai.com/v1). Déjelo vacío para usar el valor predeterminado del proveedor.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "Clave de API",
|
||||
"apiKeyHelp": "Su clave de API del proveedor LLM. Se almacena localmente y nunca se envía a ningún servidor excepto a su proveedor LLM elegido.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "No configurada",
|
||||
"apiKeyConfigured": "Configurada",
|
||||
"apiKeySet": "Configurar",
|
||||
"model": "Modelo",
|
||||
"modelHelp": "El nombre del modelo a usar (p.ej. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consulte a su proveedor para ver los modelos disponibles.",
|
||||
"modelPlaceholder": "Seleccionar un modelo..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "Menor",
|
||||
"usage": "Número de usos",
|
||||
"usageDesc": "Más",
|
||||
"usageAsc": "Menos"
|
||||
"usageAsc": "Menos",
|
||||
"versionsCount": "Versiones locales",
|
||||
"versionsCountDesc": "Más versiones primero",
|
||||
"versionsCountAsc": "Menos versiones primero",
|
||||
"versionIdDesc": "Versión más nueva primero",
|
||||
"random": "Aleatorio",
|
||||
"randomAction": "Aleatorizar (barajar)"
|
||||
},
|
||||
"refresh": {
|
||||
"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é",
|
||||
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"repairMetadata": "Reparar metadatos de la selección",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||
"deleteAll": "Eliminar todos los modelos",
|
||||
"setFavorite": "Marcar como favorito",
|
||||
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
|
||||
"unfavorite": "Quitar de favoritos",
|
||||
"deleteAll": "Eliminar seleccionados",
|
||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||
"downloadExamples": "Descargar imágenes de ejemplo",
|
||||
"downloadMissingExamples": "Descargar faltantes",
|
||||
"reprocessExamples": "Reprocesar todo",
|
||||
"clear": "Limpiar selección",
|
||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||
"sendToWorkflow": "Enviar al workflow",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "Metadatos",
|
||||
"attributes": "Atributos",
|
||||
"organize": "Organizar",
|
||||
"download": "Descargar"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Inicializando auto-organización...",
|
||||
"starting": "Iniciando auto-organización para {type}...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "Completado: {success} movidos, {skipped} omitidos, {failures} fallidos",
|
||||
"complete": "Auto-organización completada",
|
||||
"error": "Error: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualizar datos de Civitai",
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
"relinkCivitai": "Re-vincular a Civitai",
|
||||
"linkModel": "Vincular modelo",
|
||||
"linkCivitai": "Re-vincular a Civitai",
|
||||
"linkHuggingFace": "Vincular a HuggingFace",
|
||||
"copySyntax": "Copiar sintaxis de LoRA",
|
||||
"copyFilename": "Copiar nombre de archivo del modelo",
|
||||
"copyRecipeSyntax": "Copiar sintaxis de receta",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "Enviar al flujo de trabajo (Reemplazar)",
|
||||
"openExamples": "Abrir carpeta de ejemplos",
|
||||
"downloadExamples": "Descargar imágenes de ejemplo",
|
||||
"downloadMissingExamples": "Descargar faltantes",
|
||||
"reprocessExamples": "Reprocesar todo",
|
||||
"replacePreview": "Reemplazar vista previa",
|
||||
"setContentRating": "Establecer clasificación de contenido",
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"excludeModel": "Excluir modelo",
|
||||
"restoreModel": "Restaurar modelo",
|
||||
"deleteModel": "Eliminar modelo",
|
||||
"shareRecipe": "Compartir receta",
|
||||
"viewAllLoras": "Ver todos los LoRAs",
|
||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||
"deleteRecipe": "Eliminar receta"
|
||||
"deleteRecipe": "Eliminar receta",
|
||||
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de recetas",
|
||||
"quick": "Sincronizar cambios",
|
||||
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
|
||||
"full": "Reconstruir caché",
|
||||
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "La receta ya está en la última versión, no se necesita reparación",
|
||||
"failed": "Error al reparar la receta: {message}",
|
||||
"missingId": "No se puede reparar la receta: falta el ID de la receta"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Reimportando receta desde origen...",
|
||||
"success": "Receta reimportada exitosamente",
|
||||
"noSourceUrl": "La receta no tiene URL de origen, no se puede reimportar",
|
||||
"failed": "Error al reimportar la receta: {message}",
|
||||
"missingId": "No se puede reimportar la receta: falta el ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
"pinSidebar": "Fijar barra lateral",
|
||||
"unpinSidebar": "Desfijar barra lateral",
|
||||
"hideOnThisPage": "Ocultar barra lateral en esta página",
|
||||
"showSidebar": "Mostrar barra lateral",
|
||||
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
|
||||
"switchToListView": "Cambiar a vista de lista",
|
||||
"switchToTreeView": "Cambiar a vista de árbol",
|
||||
"recursiveOn": "Incluir subcarpetas",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "No se encontraron 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": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "Almacenamiento",
|
||||
"insights": "Perspectivas"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Total de modelos",
|
||||
"totalStorage": "Almacenamiento total",
|
||||
"totalGenerations": "Generaciones totales",
|
||||
"usageRate": "Tasa de uso",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Puntos de control",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Etiquetas únicas",
|
||||
"unusedModels": "Modelos no usados",
|
||||
"avgUsesPerModel": "Prom. usos/modelo"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs más utilizados",
|
||||
"mostUsedCheckpoints": "Checkpoints más utilizados",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Perspectivas inteligentes",
|
||||
"recommendations": "Recomendaciones"
|
||||
"recommendations": "Recomendaciones",
|
||||
"noInsights": "No hay información disponible",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Alta cantidad de LoRAs no utilizadas",
|
||||
"description": "El {percent}% de tus LoRAs ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Considera organizar o archivar modelos no utilizados para liberar espacio."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Puntos de control no utilizados detectados",
|
||||
"description": "El {percent}% de tus puntos de control ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Revisa y considera eliminar los puntos de control que ya no necesites."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Alta cantidad de Embeddings no utilizados",
|
||||
"description": "El {percent}% de tus embeddings ({count}/{total}) nunca se han utilizado.",
|
||||
"suggestion": "Considera organizar o archivar embeddings no utilizados para optimizar tu colección."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Colección grande detectada",
|
||||
"description": "Tu colección de modelos está usando {size} de almacenamiento.",
|
||||
"suggestion": "Considera usar almacenamiento externo o soluciones en la nube para una mejor organización."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Usuario activo",
|
||||
"description": "¡Has completado {count} generaciones hasta ahora!",
|
||||
"suggestion": "Sigue explorando y creando contenido increíble con tus modelos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Resumen de colección",
|
||||
"baseModelDistribution": "Distribución de modelo base",
|
||||
"usageTrends": "Tendencias de uso (Últimos 30 días)",
|
||||
"usageDistribution": "Distribución de uso"
|
||||
"usageDistribution": "Distribución de uso",
|
||||
"date": "Fecha",
|
||||
"usageCount": "Conteo de uso",
|
||||
"fileSizeBytes": "Tamaño del archivo (bytes)",
|
||||
"models": "Modelos",
|
||||
"loraUsage": "Uso de LoRA",
|
||||
"checkpointUsage": "Uso de Checkpoint",
|
||||
"embeddingUsage": "Uso de Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Punto de control",
|
||||
"diffusion_model": "Modelo de difusión",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Cargando...",
|
||||
"noModels": "No se encontraron modelos",
|
||||
"errorLoading": "Error al cargar datos",
|
||||
"noStorageData": "No hay datos de almacenamiento disponibles",
|
||||
"rootFolder": "Raíz",
|
||||
"chartLibraryMissing": "El gráfico requiere la librería Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} modelos",
|
||||
"chartUsage": "{name}: {size}, {count} usos",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "Descargar modelo desde URL",
|
||||
"titleWithType": "Descargar {type} desde URL",
|
||||
"url": "URL de Civitai",
|
||||
"civitaiUrl": "URL de Civitai:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Ingrese una URL de CivitAI, CivArchive o Hugging Face por línea. Admite múltiples URLs para descarga por lotes.",
|
||||
"selectHfFiles": "Seleccione el/los archivo(s) para descargar de este repositorio:",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"fetchingRepoFiles": "Obteniendo archivos del repositorio...",
|
||||
"locationPreview": "Vista previa de ubicación de descarga",
|
||||
"useDefaultPath": "Usar ruta predeterminada",
|
||||
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||
"alreadyInLibrary": "Ya en la biblioteca",
|
||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||
"fileSelection": {
|
||||
"title": "Seleccionar formato de archivo",
|
||||
"files": "archivos",
|
||||
"select": "Seleccionar archivo"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||
"noVersions": "No hay versiones disponibles para este modelo"
|
||||
"noVersions": "No hay versiones disponibles para este modelo",
|
||||
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face en el mismo lote.",
|
||||
"noModelFiles": "No se encontraron archivos de modelo en este repositorio."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Preparando descarga...",
|
||||
"downloadedPreview": "Imagen de vista previa descargada",
|
||||
"downloadingFile": "Descargando archivo de {type}",
|
||||
"finalizing": "Finalizando descarga..."
|
||||
"finalizing": "Finalizando descarga...",
|
||||
"cancelling": "Cancelando descarga...",
|
||||
"cancelled": "Descarga cancelada"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Archivo actual:",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "modelos serán eliminados permanentemente.",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "Escribe la ruta de la carpeta o selecciona del árbol de abajo...",
|
||||
"root": "Raíz"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Vincular a HuggingFace",
|
||||
"infoText": "Pegue la URL del repositorio de HuggingFace para asociar este modelo. Esto permite el enriquecimiento de metadatos con IA.",
|
||||
"urlLabel": "URL del repositorio de HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Ingrese la URL completa del repositorio de HuggingFace.",
|
||||
"confirmAction": "Guardar y vincular"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Re-vincular a Civitai",
|
||||
"warning": "Advertencia:",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "Editar nombre del modelo",
|
||||
"editFileName": "Editar nombre de archivo",
|
||||
"editBaseModel": "Editar modelo base",
|
||||
"editVersionName": "Editar nombre de versión",
|
||||
"viewOnCivitai": "Ver en Civitai",
|
||||
"viewOnCivitaiText": "Ver en Civitai",
|
||||
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||
"viewOnHuggingFaceText": "Ver en Hugging Face",
|
||||
"viewCreatorProfile": "Ver perfil del creador",
|
||||
"openFileLocation": "Abrir ubicación del archivo",
|
||||
"sendToWorkflow": "Enviar a ComfyUI",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "Notas adicionales",
|
||||
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
|
||||
"addNotesPlaceholder": "Añade tus notas aquí...",
|
||||
"aboutThisVersion": "Acerca de esta versión"
|
||||
"aboutThisVersion": "Acerca de esta versión",
|
||||
"baseModelSearchPlaceholder": "Buscar modelo base…",
|
||||
"baseModelSuggested": "Sugerido",
|
||||
"baseModelNoMatch": "No hay modelos base que coincidan"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Notas guardadas exitosamente",
|
||||
"saveFailed": "Error al guardar notas"
|
||||
"saveFailed": "Error al guardar notas",
|
||||
"showMore": "Mostrar más",
|
||||
"showLess": "Mostrar menos"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "Añadir parámetro preestablecido...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "Cancelar edición",
|
||||
"save": "Guardar cambios",
|
||||
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
|
||||
"editWord": "Editar palabra de activación",
|
||||
"editPlaceholder": "Editar palabra de activación",
|
||||
"copyWord": "Copiar palabra clave",
|
||||
"deleteWord": "Eliminar palabra clave",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||
"ignored": "Ignorada",
|
||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
|
||||
"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": {
|
||||
"download": "Descargar",
|
||||
"downloadTooltip": "Descargar esta versión",
|
||||
"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",
|
||||
"deleteTooltip": "Eliminar esta versión local",
|
||||
"ignore": "Ignorar",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "Aún no hay historial de versiones para este modelo.",
|
||||
"error": "No se pudieron cargar las versiones.",
|
||||
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
||||
"hfGroupInfo": "Este es un grupo de modelos de HuggingFace. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
|
||||
"confirm": {
|
||||
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "Versión eliminada"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Resumen de obtención de metadatos",
|
||||
"statSuccess": "Éxito",
|
||||
"statFailed": "Fallido",
|
||||
"statSkipped": "Omitido",
|
||||
"statTotal": "Total escaneado",
|
||||
"statDuration": "Duración",
|
||||
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
|
||||
"failedItems": "Elementos fallidos ({count})",
|
||||
"close": "Cerrar",
|
||||
"copyReport": "Copiar informe",
|
||||
"downloadCsv": "Descargar CSV",
|
||||
"columnModelName": "Nombre del modelo",
|
||||
"columnError": "Error"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "Esta etiqueta ya existe"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Navegación por teclado:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Desplazar hacia arriba una página",
|
||||
"pageDown": "Desplazar hacia abajo una página",
|
||||
"home": "Saltar al inicio",
|
||||
"end": "Saltar al final"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Inicializando",
|
||||
"message": "Preparando tu espacio de trabajo...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||
"modelFailed": "Error al actualizar nodo de modelo"
|
||||
"modelFailed": "Error al actualizar nodo de modelo",
|
||||
"embeddingAdded": "Embedding añadido al flujo de trabajo",
|
||||
"embeddingFailed": "Error al añadir el embedding",
|
||||
"promptSent": "Prompt enviado al flujo de trabajo",
|
||||
"promptFailed": "Error al enviar el prompt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Receta",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Reemplazar",
|
||||
"append": "Añadir",
|
||||
"selectTargetNode": "Seleccionar nodo de destino",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "Carpeta de imágenes de ejemplo abierta",
|
||||
"openingFolder": "Abriendo carpeta de imágenes de ejemplo",
|
||||
"failedToOpen": "Error al abrir carpeta de imágenes de ejemplo",
|
||||
"copiedPath": "Ruta copiada al portapapeles: {{path}}",
|
||||
"clipboardFallback": "Ruta: {{path}}",
|
||||
"copiedUri": "Enlace copiado al portapapeles: {{uri}}",
|
||||
"uriClipboardFallback": "Enlace: {{uri}}",
|
||||
"setupRequired": "Almacenamiento de imágenes de ejemplo",
|
||||
"setupDescription": "Para agregar imágenes de ejemplo personalizadas, primero necesita establecer una ubicación de descarga.",
|
||||
"setupUsage": "Esta ruta se utiliza tanto para imágenes de ejemplo descargadas como personalizadas.",
|
||||
@@ -1459,7 +1773,13 @@
|
||||
"checkingUpdates": "Comprobando actualizaciones...",
|
||||
"checkingMessage": "Por favor espera mientras comprobamos la última versión.",
|
||||
"showNotifications": "Mostrar notificaciones de actualización",
|
||||
"latestBadge": "Último",
|
||||
"latestBadge": "Última",
|
||||
"latestMain": "Rama main",
|
||||
"channel": "Canal de actualizacion",
|
||||
"channels": {
|
||||
"release": "Release",
|
||||
"nightly": "Nightly"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "Preparando actualización...",
|
||||
"installing": "Instalando actualización...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
||||
"enable": "Habilitar actualizaciones nocturnas"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "Cambiar a canal Nightly",
|
||||
"nightlyMessage": "Cambiar a Nightly inicializara un repositorio Git y seguira los ultimos commits de la rama main. Las actualizaciones son mas frecuentes pero pueden ser inestables. Puede volver a Release en cualquier momento.",
|
||||
"releaseTitle": "Cambiar a canal Release",
|
||||
"releaseMessage": "Cambiar a Release hara checkout de la ultima etiqueta de version estable. Puede volver a Nightly en cualquier momento.",
|
||||
"switching": "Cambiando a canal {channel}...",
|
||||
"completed": "Cambio a canal {channel} exitoso",
|
||||
"failed": "Error al cambiar de canal"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Notificaciones recientes",
|
||||
"empty": "No hay banners recientes.",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "No hay ID de receta disponible",
|
||||
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {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",
|
||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",
|
||||
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
|
||||
"reconnectedSuccessfully": "LoRA reconectado exitosamente",
|
||||
"reconnectFailed": "Error reconectando LoRA: {message}",
|
||||
"noPromptToSend": "No hay prompt para enviar",
|
||||
"cannotSend": "No se puede enviar receta: Falta ID de receta",
|
||||
"sendFailed": "Error al enviar receta al flujo de trabajo",
|
||||
"sendError": "Error enviando receta al flujo de trabajo",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"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}",
|
||||
"reimporting": "Reimportando receta desde origen...",
|
||||
"reimportSuccess": "Receta reimportada exitosamente",
|
||||
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
||||
"reimportBulkFailed": "Error al reimportar algunas recetas",
|
||||
"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."
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
||||
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
||||
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||
"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...",
|
||||
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
|
||||
"imagesFailed": "Imágenes de ejemplo {action} fallidas",
|
||||
"loadError": "Error al cargar descargas: {message}",
|
||||
"downloadError": "Error de descarga: {message}"
|
||||
"downloadError": "Error de descarga: {message}",
|
||||
"downloadStopped": "Descarga cancelada"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "No se pudieron cargar palabras entrenadas",
|
||||
"tooLong": "La palabra clave no debe exceder 100 palabras",
|
||||
"tooMany": "Máximo 30 palabras clave permitidas",
|
||||
"tooLong": "La palabra clave no debe exceder 500 palabras",
|
||||
"tooMany": "Máximo 100 palabras clave permitidas",
|
||||
"alreadyExists": "Esta palabra clave ya existe",
|
||||
"updateSuccess": "Palabras clave actualizadas exitosamente",
|
||||
"updateFailed": "Error al actualizar palabras clave",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "Error al establecer clasificación de contenido: {message}",
|
||||
"relinkSuccess": "Modelo re-vinculado exitosamente a Civitai",
|
||||
"relinkFailed": "Error: {message}",
|
||||
"linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente",
|
||||
"linkHfFailed": "Error: {message}",
|
||||
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
|
||||
"noCivitaiInfo": "No hay información de CivitAI disponible",
|
||||
"missingHash": "Hash del modelo no disponible"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "Error al eliminar {type}: {message}",
|
||||
"excludeSuccess": "{type} excluido exitosamente",
|
||||
"excludeFailed": "Error al excluir {type}: {message}",
|
||||
"restoreSuccess": "{type} restaurado correctamente",
|
||||
"restoreFailed": "No se pudo restaurar {type}: {message}",
|
||||
"fileNameUpdated": "Nombre de archivo actualizado exitosamente",
|
||||
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
||||
"previewUpdated": "Vista previa actualizada exitosamente",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "Copiado al portapapeles",
|
||||
"downloadStarted": "Descarga iniciada"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "Proveedor de IA no configurado. Actívelo en Configuración → Proveedor de IA.",
|
||||
"enrichStarted": "Enriqueciendo metadatos con IA...",
|
||||
"enrichComplete": "Enriquecimiento de metadatos completado: {{summary}}",
|
||||
"enrichFailed": "Enriquecimiento de metadatos fallido: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "Requiere atenció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": {
|
||||
"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": {
|
||||
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
||||
"repairSuccess": "Reconstrucción de caché completada.",
|
||||
"repairFailed": "Error al reconstruir la caché: {message}",
|
||||
"exportSuccess": "Paquete de diagnósticos exportado.",
|
||||
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
|
||||
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}",
|
||||
"conflictsResolved": "{count} conflicto(s) de nombre de archivo resuelto(s).",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "Actualización de la aplicación detectada",
|
||||
|
||||
497
locales/fr.json
497
locales/fr.json
@@ -15,10 +15,14 @@
|
||||
"settings": "Paramètres",
|
||||
"help": "Aide",
|
||||
"add": "Ajouter",
|
||||
"close": "Fermer"
|
||||
"close": "Fermer",
|
||||
"menu": "Menu",
|
||||
"remove": "Supprimer",
|
||||
"change": "Modifier"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
"cancelling": "Annulation...",
|
||||
"unknown": "Inconnu",
|
||||
"date": "Date",
|
||||
"version": "Version",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "Retirer des favoris",
|
||||
"viewOnCivitai": "Voir sur Civitai",
|
||||
"notAvailableFromCivitai": "Non disponible sur Civitai",
|
||||
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
|
||||
"copyLoRASyntax": "Copier la syntaxe LoRA",
|
||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "Remplacer l'aperçu",
|
||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Nombre d'utilisations"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versions",
|
||||
"viewAllVersions": "Voir toutes les versions locales"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "{count} recettes réparées avec succès.",
|
||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||
"error": "Échec de la réparation des recettes : {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Grouper par modèle"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "Statistiques"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher...",
|
||||
"placeholders": {
|
||||
"loras": "Rechercher des LoRAs...",
|
||||
"recipes": "Rechercher des recipes...",
|
||||
"checkpoints": "Rechercher des checkpoints...",
|
||||
"embeddings": "Rechercher des embeddings..."
|
||||
},
|
||||
"placeholder": "Rechercher",
|
||||
"options": "Options de recherche",
|
||||
"searchIn": "Rechercher dans :",
|
||||
"notAvailable": "Recherche non disponible sur la page de statistiques",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?",
|
||||
"presetNamePlaceholder": "Nom du préréglage...",
|
||||
"baseModel": "Modèle de base",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
|
||||
"modelTags": "Tags",
|
||||
"modelTypes": "Types de modèles",
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"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",
|
||||
"tagSearchPlaceholder": "Rechercher des tags...",
|
||||
"noTagMatches": "Aucun tag ne correspond à la recherche actuelle.",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||
"clearAll": "Effacer tous les filtres",
|
||||
"any": "N'importe quel",
|
||||
"all": "Tous",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "Basculer le thème",
|
||||
"switchToLight": "Passer au thème clair",
|
||||
"switchToDark": "Passer au thème sombre",
|
||||
"switchToAuto": "Passer au thème automatique"
|
||||
"switchToAuto": "Passer au thème automatique",
|
||||
"presets": "Préréglages de thème",
|
||||
"default": "Par défaut",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Mode",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "Clé API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||
"civitaiApiKeyConfigured": "Configuré",
|
||||
"civitaiApiKeyNotConfigured": "Non configuré",
|
||||
"civitaiApiKeySet": "Configurer",
|
||||
"civitaiHost": {
|
||||
"label": "Hôte Civitai",
|
||||
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW uniquement)",
|
||||
"red": "civitai.red (sans restriction)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"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 recommandé de téléchargement.",
|
||||
"options": {
|
||||
"python": "Python (intégré)",
|
||||
"aria2": "aria2 (recommandé)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "Chemin vers aria2c",
|
||||
"help": "Chemin facultatif vers l’exécutable aria2c. Laissez vide pour utiliser aria2c depuis le PATH système.",
|
||||
"placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
|
||||
},
|
||||
"aria2HelpLink": "Apprenez à configurer le backend de téléchargement aria2",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Préférence d’hôte Civitai disponible",
|
||||
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Ouvrir le dossier des paramètres",
|
||||
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Filtrage du contenu",
|
||||
"downloads": "Téléchargements",
|
||||
"videoSettings": "Paramètres vidéo",
|
||||
"layoutSettings": "Paramètres d'affichage",
|
||||
"licenseIcons": "Icônes de licence",
|
||||
"misc": "Divers",
|
||||
"backup": "Sauvegardes",
|
||||
"folderSettings": "Racines par défaut",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||
"priorityTags": "Étiquettes prioritaires",
|
||||
"updateFlags": "Indicateurs de mise à jour",
|
||||
"versionScope": "Indicateurs de mise à jour",
|
||||
"exampleImages": "Images d'exemple",
|
||||
"autoOrganize": "Organisation automatique",
|
||||
"metadata": "Métadonnées",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Grouper par modèle",
|
||||
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
|
||||
"displayDensity": "Densité d'affichage",
|
||||
"displayDensityOptions": {
|
||||
"default": "Par défaut",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "Révéler au survol"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||
"showVersionOnCard": "Afficher la version sur la carte",
|
||||
"showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle",
|
||||
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Ouvrir les images d'exemple",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "Nom du modèle",
|
||||
"fileName": "Nom du fichier"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
|
||||
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle",
|
||||
"cardBlurAmount": "Flou de superposition des cartes",
|
||||
"cardBlurAmountHelp": "Ajustez l'intensité du flou des superpositions d'en-tête et de pied de page sur les cartes de modèles et de recettes (0 = aucun flou, 20 = flou maximal)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Bibliothèque active",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
|
||||
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Ce chemin est déjà configuré"
|
||||
"duplicatePath": "Ce chemin est déjà configuré",
|
||||
"checkpointUnetOverlap": "Impossible d'utiliser le même chemin pour les checkpoints et les modèles de diffusion : {paths}",
|
||||
"checkpointUnetOverlapInline": "Ce chemin est déjà utilisé pour un autre type de modèle. Utilisez des dossiers séparés pour les checkpoints et les modèles de diffusion."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,12 +574,27 @@
|
||||
"downloadLocationHelp": "Entrez le chemin du dossier où les images d'exemple de Civitai seront sauvegardées",
|
||||
"autoDownload": "Téléchargement automatique des images d'exemple",
|
||||
"autoDownloadHelp": "Télécharger automatiquement les images d'exemple pour les modèles qui n'en ont pas (nécessite que l'emplacement de téléchargement soit défini)",
|
||||
"openMode": "Action d’ouverture des images d’exemple",
|
||||
"openModeHelp": "Choisissez si l’action s’ouvre sur le serveur, copie un chemin local mappé ou lance une URI personnalisée.",
|
||||
"openModeOptions": {
|
||||
"system": "Ouvrir sur le serveur",
|
||||
"clipboard": "Copier le chemin local",
|
||||
"uriTemplate": "Ouvrir une URI personnalisée"
|
||||
},
|
||||
"localRoot": "Racine locale des images d’exemple",
|
||||
"localRootHelp": "Racine locale ou montée facultative qui reflète le répertoire des images d’exemple du serveur. Si vide, le chemin du serveur est réutilisé.",
|
||||
"localRootPlaceholder": "Exemple : /Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "Ouvrir le modèle d’URI",
|
||||
"uriTemplateHelp": "Utilisez un lien profond personnalisé, tel qu’une URI de fichier ou un lien Shortcuts.",
|
||||
"uriTemplatePlaceholder": "Exemple : shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "Paramètres disponibles : {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||
"openModeWikiLink": "En savoir plus sur les modes d'ouverture à distance",
|
||||
"optimizeImages": "Optimiser les images téléchargées",
|
||||
"optimizeImagesHelp": "Optimiser les images d'exemple pour réduire la taille du fichier et améliorer la vitesse de chargement (les métadonnées seront préservées)",
|
||||
"download": "Télécharger",
|
||||
"restartRequired": "Redémarrage requis"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Stratégie des indicateurs de mise à jour",
|
||||
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.",
|
||||
"options": {
|
||||
@@ -523,9 +606,19 @@
|
||||
"label": "Masquer les mises à jour en accès anticipé",
|
||||
"help": "Seulement les mises à jour en accès anticipé"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Utiliser les icônes de licence mises à jour",
|
||||
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "Préparation du téléchargement...",
|
||||
"connecting": "Connexion au serveur de téléchargement...",
|
||||
"completed": "Terminé",
|
||||
"downloadComplete": "Téléchargement terminé avec succès"
|
||||
"downloadComplete": "Téléchargement terminé avec succès",
|
||||
"enableCivarchiveApi": "Activer l'API CivArchive comme fournisseur de métadonnées",
|
||||
"enableCivarchiveApiHelp": "Lorsqu'elle est activée, l'API CivArchive est utilisée comme source de secours pour les métadonnées des modèles (par ex. pour les modèles supprimés de CivitAI). Désactivez pour éviter entièrement les limites de débit de CivArchive.",
|
||||
"providerOrder": "Ordre de secours des fournisseurs de métadonnées",
|
||||
"providerOrderHelp": "L'API CivitAI est toujours essayée en premier. Choisissez l'ordre des autres fournisseurs lors de la recherche de métadonnées.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Activer le proxy au niveau de l'application",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "Mot de passe (optionnel)",
|
||||
"proxyPasswordPlaceholder": "mot_de_passe",
|
||||
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "Fournisseur d'IA",
|
||||
"provider": "Fournisseur",
|
||||
"providerHelp": "Choisissez votre fournisseur LLM. OpenAI et Ollama utilisent des endpoints prédéfinis. Personnalisé vous permet de spécifier n'importe quel endpoint compatible OpenAI.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (local)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "Personnalisé (compatible OpenAI)"
|
||||
},
|
||||
"apiBase": "URL de base de l'API",
|
||||
"apiBaseHelp": "L'URL de base pour l'API LLM (ex. https://api.openai.com/v1). Laissez vide pour utiliser le fournisseur par défaut.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "Clé API",
|
||||
"apiKeyHelp": "Votre clé API du fournisseur LLM. Stockée localement, jamais envoyée à un serveur autre que votre fournisseur LLM choisi.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "Non définie",
|
||||
"apiKeyConfigured": "Configurée",
|
||||
"apiKeySet": "Configurer",
|
||||
"model": "Modèle",
|
||||
"modelHelp": "Le nom du modèle à utiliser (ex. deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Consultez votre fournisseur pour les modèles disponibles.",
|
||||
"modelPlaceholder": "Sélectionner un modèle..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "Plus petit",
|
||||
"usage": "Nombre d'utilisations",
|
||||
"usageDesc": "Plus",
|
||||
"usageAsc": "Moins"
|
||||
"usageAsc": "Moins",
|
||||
"versionsCount": "Versions locales",
|
||||
"versionsCountDesc": "Plus de versions d'abord",
|
||||
"versionsCountAsc": "Moins de versions d'abord",
|
||||
"versionIdDesc": "Version la plus récente d'abord",
|
||||
"random": "Aléatoire",
|
||||
"randomAction": "Aléatoire (mélanger)"
|
||||
},
|
||||
"refresh": {
|
||||
"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",
|
||||
"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."
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "Définir la classification du contenu pour tous",
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||
"reimportMetadata": "Ré-importer depuis la source",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
"autoOrganize": "Auto-organiser 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",
|
||||
"deleteAll": "Supprimer tous les modèles",
|
||||
"setFavorite": "Définir comme favori",
|
||||
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"deleteAll": "Supprimer la sélection",
|
||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||
"downloadExamples": "Télécharger les images d'exemple",
|
||||
"downloadMissingExamples": "Télécharger les manquantes",
|
||||
"reprocessExamples": "Tout retraiter",
|
||||
"clear": "Effacer la sélection",
|
||||
"skipMetadataRefreshCount": "Ignorer({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": {
|
||||
"initializing": "Initialisation de l'auto-organisation...",
|
||||
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "Terminé : {success} déplacés, {skipped} ignorés, {failures} échecs",
|
||||
"complete": "Auto-organisation terminée",
|
||||
"error": "Erreur : {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualiser les données Civitai",
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"relinkCivitai": "Relier à nouveau à Civitai",
|
||||
"linkModel": "Lier le modèle",
|
||||
"linkCivitai": "Relier à nouveau à Civitai",
|
||||
"linkHuggingFace": "Lier à HuggingFace",
|
||||
"copySyntax": "Copier la syntaxe LoRA",
|
||||
"copyFilename": "Copier le nom de fichier du modèle",
|
||||
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "Envoyer vers le workflow (Remplacer)",
|
||||
"openExamples": "Ouvrir le dossier d'exemples",
|
||||
"downloadExamples": "Télécharger les images d'exemple",
|
||||
"downloadMissingExamples": "Télécharger les manquantes",
|
||||
"reprocessExamples": "Tout retraiter",
|
||||
"replacePreview": "Remplacer l'aperçu",
|
||||
"setContentRating": "Définir la classification du contenu",
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"reimportMetadata": "Ré-importer depuis la source",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
"restoreModel": "Restaurer le modèle",
|
||||
"deleteModel": "Supprimer le modèle",
|
||||
"shareRecipe": "Partager la recipe",
|
||||
"viewAllLoras": "Voir tous les LoRAs",
|
||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||
"deleteRecipe": "Supprimer la recipe"
|
||||
"deleteRecipe": "Supprimer la recipe",
|
||||
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des recipes",
|
||||
"quick": "Synchroniser les changements",
|
||||
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
|
||||
"full": "Reconstruire le cache",
|
||||
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
|
||||
"failed": "Échec de la réparation de la recette : {message}",
|
||||
"missingId": "Impossible de réparer la recette : ID de recette manquant"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Ré-import de la recette depuis la source...",
|
||||
"success": "Recette ré-importée avec succès",
|
||||
"noSourceUrl": "La recette n'a pas d'URL source, ré-import impossible",
|
||||
"failed": "Échec du ré-import de la recette : {message}",
|
||||
"missingId": "Impossible de ré-importer la recette : ID de recette manquant"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
"pinSidebar": "Épingler la barre latérale",
|
||||
"unpinSidebar": "Désépingler la barre latérale",
|
||||
"hideOnThisPage": "Masquer la barre latérale sur cette page",
|
||||
"showSidebar": "Afficher la barre latérale",
|
||||
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
|
||||
"switchToListView": "Passer en vue liste",
|
||||
"switchToTreeView": "Passer en vue arborescence",
|
||||
"recursiveOn": "Inclure les sous-dossiers",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"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": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "Stockage",
|
||||
"insights": "Aperçus"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Total des modèles",
|
||||
"totalStorage": "Stockage total",
|
||||
"totalGenerations": "Générations totales",
|
||||
"usageRate": "Taux d'utilisation",
|
||||
"loras": "LoRAs",
|
||||
"checkpoints": "Points de contrôle",
|
||||
"embeddings": "Embeddings",
|
||||
"uniqueTags": "Tags uniques",
|
||||
"unusedModels": "Modèles inutilisés",
|
||||
"avgUsesPerModel": "Moy. utilisations/modèle"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs les plus utilisés",
|
||||
"mostUsedCheckpoints": "Checkpoints les plus utilisés",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Aperçus intelligents",
|
||||
"recommendations": "Recommandations"
|
||||
"recommendations": "Recommandations",
|
||||
"noInsights": "Aucun aperçu disponible",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Nombre élevé de LoRAs inutilisées",
|
||||
"description": "{percent}% de vos LoRAs ({count}/{total}) n'ont jamais été utilisées.",
|
||||
"suggestion": "Envisagez d'organiser ou d'archiver les modèles inutilisés pour libérer de l'espace."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Points de contrôle inutilisés détectés",
|
||||
"description": "{percent}% de vos points de contrôle ({count}/{total}) n'ont jamais été utilisés.",
|
||||
"suggestion": "Examinez et envisagez de supprimer les points de contrôle dont vous n'avez plus besoin."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Nombre élevé d'Embeddings inutilisées",
|
||||
"description": "{percent}% de vos embeddings ({count}/{total}) n'ont jamais été utilisées.",
|
||||
"suggestion": "Envisagez d'organiser ou d'archiver les embeddings inutilisées pour optimiser votre collection."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Grande collection détectée",
|
||||
"description": "Votre collection de modèles utilise {size} de stockage.",
|
||||
"suggestion": "Envisagez d'utiliser un stockage externe ou des solutions cloud pour une meilleure organisation."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Utilisateur actif",
|
||||
"description": "Vous avez effectué {count} générations jusqu'à présent !",
|
||||
"suggestion": "Continuez à explorer et à créer du contenu formidable avec vos modèles."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Aperçu de la collection",
|
||||
"baseModelDistribution": "Distribution des modèles de base",
|
||||
"usageTrends": "Tendances d'utilisation (30 derniers jours)",
|
||||
"usageDistribution": "Distribution de l'utilisation"
|
||||
"usageDistribution": "Distribution de l'utilisation",
|
||||
"date": "Date",
|
||||
"usageCount": "Nombre d'utilisations",
|
||||
"fileSizeBytes": "Taille du fichier (octets)",
|
||||
"models": "Modèles",
|
||||
"loraUsage": "Utilisation LoRA",
|
||||
"checkpointUsage": "Utilisation Checkpoint",
|
||||
"embeddingUsage": "Utilisation Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Point de contrôle",
|
||||
"diffusion_model": "Modèle de diffusion",
|
||||
"embedding": "Embeddings"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Chargement...",
|
||||
"noModels": "Aucun modèle trouvé",
|
||||
"errorLoading": "Erreur de chargement des données",
|
||||
"noStorageData": "Aucune donnée de stockage disponible",
|
||||
"rootFolder": "Racine",
|
||||
"chartLibraryMissing": "Le graphique nécessite la bibliothèque Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} modèles",
|
||||
"chartUsage": "{name}: {size}, {count} utilisations",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "Télécharger un modèle depuis une URL",
|
||||
"titleWithType": "Télécharger {type} depuis une URL",
|
||||
"url": "URL Civitai",
|
||||
"civitaiUrl": "URL Civitai :",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Entrez une URL CivitAI, CivArchive ou Hugging Face par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
|
||||
"selectHfFiles": "Sélectionnez le(s) fichier(s) à télécharger depuis ce dépôt :",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"fetchingRepoFiles": "Récupération des fichiers du dépôt...",
|
||||
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
||||
"useDefaultPath": "Utiliser le chemin par défaut",
|
||||
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||
"fileSelection": {
|
||||
"title": "Choisir le format de fichier",
|
||||
"files": "fichiers",
|
||||
"select": "Choisir le fichier"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Format d'URL Civitai invalide",
|
||||
"noVersions": "Aucune version disponible pour ce modèle"
|
||||
"noVersions": "Aucune version disponible pour ce modèle",
|
||||
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face dans le même lot.",
|
||||
"noModelFiles": "Aucun fichier de modèle trouvé dans ce dépôt."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Préparation du téléchargement...",
|
||||
"downloadedPreview": "Image d'aperçu téléchargée",
|
||||
"downloadingFile": "Téléchargement du fichier {type}",
|
||||
"finalizing": "Finalisation du téléchargement..."
|
||||
"finalizing": "Finalisation du téléchargement...",
|
||||
"cancelling": "Annulation du téléchargement...",
|
||||
"cancelled": "Téléchargement annulé"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Fichier actuel :",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "modèles seront définitivement supprimés.",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "Tapez le chemin du dossier ou sélectionnez dans l'arbre ci-dessous...",
|
||||
"root": "Racine"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Lier à HuggingFace",
|
||||
"infoText": "Collez l'URL du dépôt HuggingFace pour associer ce modèle à sa source. Cela permet l'enrichissement des métadonnées par IA.",
|
||||
"urlLabel": "URL du dépôt HuggingFace :",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Entrez l'URL complète du dépôt HuggingFace.",
|
||||
"confirmAction": "Enregistrer & lier"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Relier à nouveau à Civitai",
|
||||
"warning": "Attention :",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "Modifier le nom du modèle",
|
||||
"editFileName": "Modifier le nom de fichier",
|
||||
"editBaseModel": "Modifier le modèle de base",
|
||||
"editVersionName": "Modifier le nom de la version",
|
||||
"viewOnCivitai": "Voir sur Civitai",
|
||||
"viewOnCivitaiText": "Voir sur Civitai",
|
||||
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||
"viewOnHuggingFaceText": "Voir sur Hugging Face",
|
||||
"viewCreatorProfile": "Voir le profil du créateur",
|
||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "Notes supplémentaires",
|
||||
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
|
||||
"addNotesPlaceholder": "Ajoutez vos notes ici...",
|
||||
"aboutThisVersion": "À propos de cette version"
|
||||
"aboutThisVersion": "À propos de cette version",
|
||||
"baseModelSearchPlaceholder": "Rechercher un modèle de base…",
|
||||
"baseModelSuggested": "Suggéré",
|
||||
"baseModelNoMatch": "Aucun modèle de base correspondant"
|
||||
},
|
||||
"notes": {
|
||||
"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": {
|
||||
"addPresetParameter": "Ajouter un paramètre prédéfini...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "Annuler la modification",
|
||||
"save": "Sauvegarder les modifications",
|
||||
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
|
||||
"editWord": "Modifier le mot déclencheur",
|
||||
"editPlaceholder": "Modifier le mot déclencheur",
|
||||
"copyWord": "Copier le mot-clé",
|
||||
"deleteWord": "Supprimer le mot-clé",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||
"ignored": "Ignorée",
|
||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
|
||||
"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": {
|
||||
"download": "Télécharger",
|
||||
"downloadTooltip": "Télécharger cette version",
|
||||
"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",
|
||||
"deleteTooltip": "Supprimer cette version locale",
|
||||
"ignore": "Ignorer",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
||||
"error": "Échec du chargement des versions.",
|
||||
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
||||
"hfGroupInfo": "Ceci est un groupe de modèles HuggingFace. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
|
||||
"confirm": {
|
||||
"delete": "Supprimer cette version de votre bibliothèque ?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "Version supprimée"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Récapitulatif de la récupération des métadonnées",
|
||||
"statSuccess": "Réussi",
|
||||
"statFailed": "Échoué",
|
||||
"statSkipped": "Ignoré",
|
||||
"statTotal": "Total scanné",
|
||||
"statDuration": "Durée",
|
||||
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
|
||||
"failedItems": "Éléments échoués ({count})",
|
||||
"close": "Fermer",
|
||||
"copyReport": "Copier le rapport",
|
||||
"downloadCsv": "Télécharger CSV",
|
||||
"columnModelName": "Nom du modèle",
|
||||
"columnError": "Erreur"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "Ce tag existe déjà"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Navigation au clavier :",
|
||||
"shortcuts": {
|
||||
"pageUp": "Défiler d'une page vers le haut",
|
||||
"pageDown": "Défiler d'une page vers le bas",
|
||||
"home": "Aller en haut",
|
||||
"end": "Aller en bas"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Initialisation",
|
||||
"message": "Préparation de votre espace de travail...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
|
||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||
"modelFailed": "Échec de la mise à jour du nœud modèle"
|
||||
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
||||
"embeddingAdded": "Embedding ajouté au workflow",
|
||||
"embeddingFailed": "Échec de l'ajout de l'embedding",
|
||||
"promptSent": "Prompt envoyé au workflow",
|
||||
"promptFailed": "Échec de l'envoi du prompt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Remplacer",
|
||||
"append": "Ajouter",
|
||||
"selectTargetNode": "Sélectionner le nœud cible",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "Dossier d'images d'exemple ouvert",
|
||||
"openingFolder": "Ouverture du dossier d'images d'exemple",
|
||||
"failedToOpen": "Échec de l'ouverture du dossier d'images d'exemple",
|
||||
"copiedPath": "Chemin copié dans le presse-papiers : {{path}}",
|
||||
"clipboardFallback": "Chemin : {{path}}",
|
||||
"copiedUri": "Lien copié dans le presse-papiers : {{uri}}",
|
||||
"uriClipboardFallback": "Lien : {{uri}}",
|
||||
"setupRequired": "Stockage d'images d'exemple",
|
||||
"setupDescription": "Pour ajouter des images d'exemple personnalisées, vous devez d'abord définir un emplacement de téléchargement.",
|
||||
"setupUsage": "Ce chemin est utilisé pour les images d'exemple téléchargées et personnalisées.",
|
||||
@@ -1459,7 +1773,13 @@
|
||||
"checkingUpdates": "Vérification des mises à jour...",
|
||||
"checkingMessage": "Veuillez patienter pendant la vérification de la dernière version.",
|
||||
"showNotifications": "Afficher les notifications de mise à jour",
|
||||
"latestBadge": "Dernier",
|
||||
"latestBadge": "Dernière",
|
||||
"latestMain": "Branche main",
|
||||
"channel": "Canal de mise a jour",
|
||||
"channels": {
|
||||
"release": "Release",
|
||||
"nightly": "Nightly"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "Préparation de la mise à jour...",
|
||||
"installing": "Installation de la mise à jour...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
||||
"enable": "Activer les mises à jour nightly"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "Passer au canal Nightly",
|
||||
"nightlyMessage": "Passer a Nightly initialisera un depot Git et suivra les derniers commits de la branche main. Les mises a jour sont plus frequentes mais peuvent etre instables. Vous pouvez revenir a Release a tout moment.",
|
||||
"releaseTitle": "Passer au canal Release",
|
||||
"releaseMessage": "Passer a Release passera au dernier tag de version stable. Vous pouvez revenir a Nightly a tout moment.",
|
||||
"switching": "Passage au canal {channel}...",
|
||||
"completed": "Basculement vers le canal {channel} reussi",
|
||||
"failed": "Echec du changement de canal"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Messages récents",
|
||||
"empty": "Aucune bannière récente.",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "Aucun ID de recipe disponible",
|
||||
"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}",
|
||||
"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",
|
||||
"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",
|
||||
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
|
||||
"reconnectedSuccessfully": "LoRA reconnecté avec succès",
|
||||
"reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}",
|
||||
"noPromptToSend": "Aucun prompt à envoyer",
|
||||
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
|
||||
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
|
||||
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"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}",
|
||||
"reimporting": "Ré-import de la recette depuis la source...",
|
||||
"reimportSuccess": "Recette ré-importée avec succès",
|
||||
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
||||
"reimportBulkFailed": "Échec du ré-import de certaines recettes",
|
||||
"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."
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
||||
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
||||
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||
"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...",
|
||||
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "Images d'exemple {action} terminées",
|
||||
"imagesFailed": "Images d'exemple {action} échouées",
|
||||
"loadError": "Erreur lors du chargement des téléchargements : {message}",
|
||||
"downloadError": "Erreur de téléchargement : {message}"
|
||||
"downloadError": "Erreur de téléchargement : {message}",
|
||||
"downloadStopped": "Téléchargement annulé"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "Impossible de charger les mots entraînés",
|
||||
"tooLong": "Le mot-clé ne doit pas dépasser 100 mots",
|
||||
"tooMany": "Maximum 30 mots-clés autorisés",
|
||||
"tooLong": "Le mot-clé ne doit pas dépasser 500 mots",
|
||||
"tooMany": "Maximum 100 mots-clés autorisés",
|
||||
"alreadyExists": "Ce mot-clé existe déjà",
|
||||
"updateSuccess": "Mots-clés mis à jour avec succès",
|
||||
"updateFailed": "Échec de la mise à jour des mots-clés",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "Échec de la définition de la classification du contenu : {message}",
|
||||
"relinkSuccess": "Modèle relié à Civitai avec succès",
|
||||
"relinkFailed": "Erreur : {message}",
|
||||
"linkHfSuccess": "Modèle lié à HuggingFace avec succès",
|
||||
"linkHfFailed": "Erreur : {message}",
|
||||
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
|
||||
"noCivitaiInfo": "Aucune information CivitAI disponible",
|
||||
"missingHash": "Hash du modèle non disponible"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "Échec de la suppression de {type} : {message}",
|
||||
"excludeSuccess": "{type} exclu avec succès",
|
||||
"excludeFailed": "Échec de l'exclusion de {type} : {message}",
|
||||
"restoreSuccess": "{type} restauré avec succès",
|
||||
"restoreFailed": "Échec de la restauration de {type} : {message}",
|
||||
"fileNameUpdated": "Nom de fichier mis à jour avec succès",
|
||||
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
||||
"previewUpdated": "Aperçu mis à jour avec succès",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "Copié dans le presse-papiers",
|
||||
"downloadStarted": "Téléchargement démarré"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "Fournisseur d'IA non configuré. Activez-le dans Paramètres → Fournisseur d'IA.",
|
||||
"enrichStarted": "Enrichissement des métadonnées par IA...",
|
||||
"enrichComplete": "Enrichissement des métadonnées terminé : {{summary}}",
|
||||
"enrichFailed": "Échec de l'enrichissement des métadonnées : {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "Nécessite une attention",
|
||||
"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": {
|
||||
"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": {
|
||||
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
||||
"repairSuccess": "Reconstruction du cache terminée.",
|
||||
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
||||
"exportSuccess": "Lot de diagnostics exporté.",
|
||||
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
|
||||
"exportFailed": "Échec de l'export du lot de diagnostics : {message}",
|
||||
"conflictsResolved": "{count} conflit(s) de nom de fichier résolu(s).",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "Mise à jour de l'application détectée",
|
||||
|
||||
497
locales/he.json
497
locales/he.json
@@ -15,10 +15,14 @@
|
||||
"settings": "הגדרות",
|
||||
"help": "עזרה",
|
||||
"add": "הוספה",
|
||||
"close": "סגור"
|
||||
"close": "סגור",
|
||||
"menu": "תפריט",
|
||||
"remove": "הסר",
|
||||
"change": "שנה"
|
||||
},
|
||||
"status": {
|
||||
"loading": "טוען...",
|
||||
"cancelling": "מבטל...",
|
||||
"unknown": "לא ידוע",
|
||||
"date": "תאריך",
|
||||
"version": "גרסה",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "הסר מהמועדפים",
|
||||
"viewOnCivitai": "הצג ב-Civitai",
|
||||
"notAvailableFromCivitai": "לא זמין מ-Civitai",
|
||||
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
|
||||
"copyLoRASyntax": "העתק תחביר LoRA",
|
||||
"checkpointNameCopied": "שם Checkpoint הועתק",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "החלף תצוגה מקדימה",
|
||||
"copyCheckpointName": "העתק שם Checkpoint",
|
||||
"copyEmbeddingName": "העתק שם Embedding",
|
||||
"embeddingNameCopied": "תחביר Embedding הועתק",
|
||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "מספר שימושים"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} גרסאות",
|
||||
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||
"error": "תיקון המתכונים נכשל: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "קיבוץ לפי דגם"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "סטטיסטיקה"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "חפש...",
|
||||
"placeholders": {
|
||||
"loras": "חפש LoRAs...",
|
||||
"recipes": "חפש מתכונים...",
|
||||
"checkpoints": "חפש checkpoints...",
|
||||
"embeddings": "חפש embeddings..."
|
||||
},
|
||||
"placeholder": "חיפוש",
|
||||
"options": "אפשרויות חיפוש",
|
||||
"searchIn": "חפש ב:",
|
||||
"notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?",
|
||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||
"baseModel": "מודל בסיס",
|
||||
"modelTags": "תגיות (20 המובילות)",
|
||||
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
|
||||
"modelTags": "תגיות",
|
||||
"modelTypes": "סוגי מודלים",
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||
"noTags": "ללא תגיות",
|
||||
"tagSearchPlaceholder": "חיפוש תגיות...",
|
||||
"noTagMatches": "אין תגיות שתואמות את החיפוש הנוכחי.",
|
||||
"autoTags": "תגיות אוטומטיות",
|
||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||
"clearAll": "נקה את כל המסננים",
|
||||
"any": "כלשהו",
|
||||
"all": "כל התגים",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "החלף ערכת נושא",
|
||||
"switchToLight": "עבור לערכת נושא בהירה",
|
||||
"switchToDark": "עבור לערכת נושא כהה",
|
||||
"switchToAuto": "עבור לערכת נושא אוטומטית"
|
||||
"switchToAuto": "עבור לערכת נושא אוטומטית",
|
||||
"presets": "ערכות נושא מוגדרות",
|
||||
"default": "ברירת מחדל",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "מצב",
|
||||
"light": "בהיר",
|
||||
"dark": "כהה",
|
||||
"auto": "אוטומטי"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||
"civitaiApiKeyConfigured": "מוגדר",
|
||||
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||
"civitaiApiKeySet": "הגדר",
|
||||
"civitaiHost": {
|
||||
"label": "מארח Civitai",
|
||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW בלבד)",
|
||||
"red": "civitai.red (ללא הגבלות)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "מנגנון הורדה",
|
||||
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני מומלץ.",
|
||||
"options": {
|
||||
"python": "Python (מובנה)",
|
||||
"aria2": "aria2 (מומלץ)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "נתיב aria2c",
|
||||
"help": "נתיב אופציונלי לקובץ ההפעלה aria2c. השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH של המערכת.",
|
||||
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
|
||||
},
|
||||
"aria2HelpLink": "למד כיצד להגדיר את מנוע ההורדה aria2",
|
||||
"civitaiHostBanner": {
|
||||
"title": "העדפת מארח Civitai זמינה",
|
||||
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
|
||||
"openSettings": "פתח הגדרות"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "פתח תיקיית הגדרות",
|
||||
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "סינון תוכן",
|
||||
"downloads": "הורדות",
|
||||
"videoSettings": "הגדרות וידאו",
|
||||
"layoutSettings": "הגדרות פריסה",
|
||||
"licenseIcons": "סמלי רישיון",
|
||||
"misc": "שונות",
|
||||
"backup": "גיבויים",
|
||||
"folderSettings": "תיקיות ברירת מחדל",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||
"priorityTags": "תגיות עדיפות",
|
||||
"updateFlags": "תגי עדכון",
|
||||
"versionScope": "תגי עדכון",
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"autoOrganize": "ארגון אוטומטי",
|
||||
"metadata": "מטא-נתונים",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "קיבוץ לפי דגם",
|
||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
"default": "ברירת מחדל",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "חשוף בריחוף"
|
||||
},
|
||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||
"showVersionOnCard": "הצג גרסה בכרטיס",
|
||||
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
|
||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "פתח תמונות דוגמה",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "שם מודל",
|
||||
"fileName": "שם קובץ"
|
||||
},
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל"
|
||||
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל",
|
||||
"cardBlurAmount": "עוצמת טשטוש שכבת-על בכרטיס",
|
||||
"cardBlurAmountHelp": "כוונן את עוצמת הטשטוש של שכבת-העל בכותרת ובכותרות תחתונה בכרטיסי מודל ומתכונים (0 = ללא טשטוש, 20 = טשטוש מקסימלי)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "ספרייה פעילה",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
||||
"duplicatePath": "נתיב זה כבר מוגדר",
|
||||
"checkpointUnetOverlap": "לא ניתן להשתמש באותו נתיב עבור checkpoints ומודלי דיפוזיה: {paths}",
|
||||
"checkpointUnetOverlapInline": "הנתיב הזה כבר נמצא בשימוש עבור סוג מודל אחר. יש להשתמש בתיקיות נפרדות עבור checkpoints ומודלי דיפוזיה."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,12 +574,27 @@
|
||||
"downloadLocationHelp": "הזן את נתיב התיקייה שבו יישמרו תמונות דוגמה מ-Civitai",
|
||||
"autoDownload": "הורדה אוטומטית של תמונות דוגמה",
|
||||
"autoDownloadHelp": "הורד אוטומטית תמונות דוגמה למודלים שאין להם (דורש הגדרת מיקום הורדה)",
|
||||
"openMode": "פעולת פתיחת תמונות דוגמה",
|
||||
"openModeHelp": "בחר אם הפעולה תיפתח בשרת, תעתיק נתיב מקומי ממופה או תפעיל URI מותאם אישית.",
|
||||
"openModeOptions": {
|
||||
"system": "פתח בשרת",
|
||||
"clipboard": "העתק נתיב מקומי",
|
||||
"uriTemplate": "פתח URI מותאם אישית"
|
||||
},
|
||||
"localRoot": "שורש מקומי לתמונות דוגמה",
|
||||
"localRootHelp": "שורש מקומי או ממופה אופציונלי שמשקף את תיקיית תמונות הדוגמה בשרת. אם השדה ריק, ייעשה שימוש חוזר בנתיב השרת.",
|
||||
"localRootPlaceholder": "דוגמה: /Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "תבנית URI לפתיחה",
|
||||
"uriTemplateHelp": "השתמש בקישור עומק מותאם אישית כמו URI של קובץ או קישור Shortcuts.",
|
||||
"uriTemplatePlaceholder": "דוגמה: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "מצייני מקום זמינים: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||
"openModeWikiLink": "למידע נוסף על מצבי פתיחה מרחוק",
|
||||
"optimizeImages": "מטב תמונות שהורדו",
|
||||
"optimizeImagesHelp": "מטב תמונות דוגמה כדי להקטין את גודל הקובץ ולשפר את מהירות הטעינה (מטא-דאטה תישמר)",
|
||||
"download": "הורד",
|
||||
"restartRequired": "דורש הפעלה מחדש"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "אסטרטגיית תגי עדכון",
|
||||
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
|
||||
"options": {
|
||||
@@ -523,9 +606,19 @@
|
||||
"label": "הסתר עדכוני גישה מוקדמת",
|
||||
"help": "רק עדכוני גישה מוקדמת"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
||||
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "מכין הורדה...",
|
||||
"connecting": "מתחבר לשרת ההורדות...",
|
||||
"completed": "הושלם",
|
||||
"downloadComplete": "ההורדה הושלמה בהצלחה"
|
||||
"downloadComplete": "ההורדה הושלמה בהצלחה",
|
||||
"enableCivarchiveApi": "הפעל את CivArchive API כספק מטא-נתונים",
|
||||
"enableCivarchiveApiHelp": "כאשר מופעל, CivArchive API משמש כמקור גיבוי למטא-נתונים של מודלים (למשל עבור מודלים שנמחקו מ-CivitAI). כבה כדי להימנע לחלוטין ממגבלות הקצב של CivArchive.",
|
||||
"providerOrder": "סדר ספקי מטא-נתונים לגיבוי",
|
||||
"providerOrderHelp": "CivitAI API תמיד מנוסה ראשון. בחר את סדר הספקים הנותרים בעת חיפוש מטא-נתונים.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "הפעל פרוקסי ברמת האפליקציה",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "סיסמה (אופציונלי)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "ספק AI",
|
||||
"provider": "ספק",
|
||||
"providerHelp": "בחר את ספק ה-LLM שלך. OpenAI ו-Ollama משתמשים בנקודות קצה מוגדרות מראש. מותאם אישית מאפשר לך לציין כל נקודת קצה תואמת OpenAI.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (מקומי)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "מותאם אישית (תואם OpenAI)"
|
||||
},
|
||||
"apiBase": "כתובת בסיס API",
|
||||
"apiBaseHelp": "כתובת ה-URL הבסיסית ל-API של LLM (לדוגמה https://api.openai.com/v1). השאר ריק לשימוש בברירת המחדל של הספק.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "מפתח API",
|
||||
"apiKeyHelp": "מפתח ה-API של ספק ה-LLM שלך. נשמר מקומית, לעולם לא נשלח לשרת כלשהו מלבד ספק ה-LLM שבחרת.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "לא הוגדר",
|
||||
"apiKeyConfigured": "הוגדר",
|
||||
"apiKeySet": "הגדר",
|
||||
"model": "מודל",
|
||||
"modelHelp": "שם המודל לשימוש (לדוגמה deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). בדוק אצל הספק שלך אילו מודלים זמינים.",
|
||||
"modelPlaceholder": "בחר מודל..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "הקטן ביותר",
|
||||
"usage": "מספר שימושים",
|
||||
"usageDesc": "הכי הרבה",
|
||||
"usageAsc": "הכי פחות"
|
||||
"usageAsc": "הכי פחות",
|
||||
"versionsCount": "גרסאות מקומיות",
|
||||
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
|
||||
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
|
||||
"versionIdDesc": "גרסה חדשה ביותר ראשונה",
|
||||
"random": "אקראי",
|
||||
"randomAction": "ערבוב אקראי"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מודלים",
|
||||
"quick": "סנכרון שינויים",
|
||||
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
|
||||
"full": "בניית מטמון מחדש",
|
||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||
"copyAll": "העתק את כל התחבירים",
|
||||
"refreshAll": "רענן את כל המטא-דאטה",
|
||||
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
"moveAll": "העבר הכל לתיקייה",
|
||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||
"deleteAll": "מחק את כל המודלים",
|
||||
"setFavorite": "הגדר כמועדף",
|
||||
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
|
||||
"unfavorite": "הסר ממועדפים",
|
||||
"deleteAll": "מחק נבחרים",
|
||||
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||
"downloadExamples": "הורד תמונות דוגמה",
|
||||
"downloadMissingExamples": "הורדת חסרים",
|
||||
"reprocessExamples": "עיבוד מחדש של הכול",
|
||||
"clear": "נקה בחירה",
|
||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||
"sendToWorkflow": "שלח ל-Workflow",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "מטא-נתונים",
|
||||
"attributes": "מאפיינים",
|
||||
"organize": "ארגן",
|
||||
"download": "הורדה"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "מאתחל ארגון אוטומטי...",
|
||||
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "הושלם: {success} הועברו, {skipped} דולגו, {failures} נכשלו",
|
||||
"complete": "ארגון אוטומטי הושלם",
|
||||
"error": "שגיאה: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "העשרת HF מטא-דאטה (AI)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "רענן נתוני Civitai",
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
"relinkCivitai": "קשר מחדש ל-Civitai",
|
||||
"linkModel": "קישור מודל",
|
||||
"linkCivitai": "קשר מחדש ל-Civitai",
|
||||
"linkHuggingFace": "קישור ל-HuggingFace",
|
||||
"copySyntax": "העתק תחביר LoRA",
|
||||
"copyFilename": "העתק שם קובץ מודל",
|
||||
"copyRecipeSyntax": "העתק תחביר מתכון",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "שלח ל-Workflow (החלף)",
|
||||
"openExamples": "פתח תיקיית דוגמאות",
|
||||
"downloadExamples": "הורד תמונות דוגמה",
|
||||
"downloadMissingExamples": "הורדת חסרים",
|
||||
"reprocessExamples": "עיבוד מחדש של הכול",
|
||||
"replacePreview": "החלף תצוגה מקדימה",
|
||||
"setContentRating": "הגדר דירוג תוכן",
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-דאטה",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"excludeModel": "החרג מודל",
|
||||
"restoreModel": "שחזור מודל",
|
||||
"deleteModel": "מחק מודל",
|
||||
"shareRecipe": "שתף מתכון",
|
||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||
"downloadMissingLoras": "הורד LoRAs חסרים",
|
||||
"deleteRecipe": "מחק מתכון"
|
||||
"deleteRecipe": "מחק מתכון",
|
||||
"enrichHfAgent": "העשרת HF מטא-דאטה (AI)"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מתכונים",
|
||||
"quick": "סנכרן שינויים",
|
||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
||||
"full": "בנה מטמון מחדש",
|
||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
|
||||
"failed": "תיקון המתכון נכשל: {message}",
|
||||
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "מייבא מתכון מחדש מהמקור...",
|
||||
"success": "המתכון יובא מחדש בהצלחה",
|
||||
"noSourceUrl": "למתכון אין כתובת מקור, לא ניתן לייבא מחדש",
|
||||
"failed": "ייבוא המתכון מחדש נכשל: {message}",
|
||||
"missingId": "לא ניתן לייבא מחדש: חסר מזהה מתכון"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
"pinSidebar": "נעל סרגל צד",
|
||||
"unpinSidebar": "שחרר סרגל צד",
|
||||
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
||||
"showSidebar": "הצג סרגל צד",
|
||||
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
||||
"switchToListView": "עבור לתצוגת רשימה",
|
||||
"switchToTreeView": "תצוגת עץ",
|
||||
"recursiveOn": "כלול תיקיות משנה",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "לא נמצאו תיקיות",
|
||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "בדוק עדכונים בתיקייה זו",
|
||||
"loading": "בודק עדכוני {type} בתיקייה זו...",
|
||||
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
|
||||
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
|
||||
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "אחסון",
|
||||
"insights": "תובנות"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "סה\"כ דגמים",
|
||||
"totalStorage": "סה\"כ אחסון",
|
||||
"totalGenerations": "סה\"כ יצירות",
|
||||
"usageRate": "שיעור שימוש",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "נקודות ביקורת",
|
||||
"embeddings": "הטמעות",
|
||||
"uniqueTags": "תגיות ייחודיות",
|
||||
"unusedModels": "דגמים שאינם בשימוש",
|
||||
"avgUsesPerModel": "ממוצע שימושים/דגם"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "LoRAs הנפוצים ביותר",
|
||||
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "תובנות חכמות",
|
||||
"recommendations": "המלצות"
|
||||
"recommendations": "המלצות",
|
||||
"noInsights": "אין תובנות זמינות",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "כמות גבוהה של LoRAs שאינן בשימוש",
|
||||
"description": "{percent}% מה-LoRAs שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
|
||||
"suggestion": "שקול לארגן או לאחסן בארכיון מודלים שאינם בשימוש כדי לפנות שטח אחסון."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "התגלו נקודות ביקורת שאינן בשימוש",
|
||||
"description": "{percent}% מנקודות הביקורת שלך ({count}/{total}) מעולם לא נעשה בהן שימוש.",
|
||||
"suggestion": "בדוק ושקול להסיר נקודות ביקורת שאינך צריך עוד."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "כמות גבוהה של Embeddings שאינם בשימוש",
|
||||
"description": "{percent}% מה-Embeddings שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
|
||||
"suggestion": "שקול לארגן או לאחסן בארכיון Embeddings שאינם בשימוש כדי לייעל את האוסף."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "התגלה אוסף גדול",
|
||||
"description": "אוסף המודלים שלך משתמש ב-{size} של אחסון.",
|
||||
"suggestion": "שקול להשתמש באחסון חיצוני או בפתרונות ענן לארגון טוב יותר."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "משתמש פעיל",
|
||||
"description": "השלמת {count} יצירות עד כה!",
|
||||
"suggestion": "המשך לחקור וליצור תוכן מדהים עם המודלים שלך."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "סקירת אוסף",
|
||||
"baseModelDistribution": "התפלגות מודלי בסיס",
|
||||
"usageTrends": "מגמות שימוש (30 יום אחרונים)",
|
||||
"usageDistribution": "התפלגות שימוש"
|
||||
"usageDistribution": "התפלגות שימוש",
|
||||
"date": "תאריך",
|
||||
"usageCount": "מספר שימושים",
|
||||
"fileSizeBytes": "גודל קובץ (בתים)",
|
||||
"models": "דגמים",
|
||||
"loraUsage": "שימוש ב-LoRA",
|
||||
"checkpointUsage": "שימוש ב-Checkpoint",
|
||||
"embeddingUsage": "שימוש ב-Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "נקודת ביקורת",
|
||||
"diffusion_model": "מודל דיפוזיה",
|
||||
"embedding": "הטמעות"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "טוען...",
|
||||
"noModels": "לא נמצאו דגמים",
|
||||
"errorLoading": "שגיאה בטעינת נתונים",
|
||||
"noStorageData": "אין נתוני אחסון זמינים",
|
||||
"rootFolder": "שורש",
|
||||
"chartLibraryMissing": "הגרף דורש את ספריית Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} דגמים",
|
||||
"chartUsage": "{name}: {size}, {count} שימושים",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "הורד מודל מכתובת URL",
|
||||
"titleWithType": "הורד {type} מכתובת URL",
|
||||
"url": "כתובת URL של Civitai",
|
||||
"civitaiUrl": "כתובת URL של Civitai:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
|
||||
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
|
||||
"selectAll": "בחר הכל",
|
||||
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
|
||||
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
||||
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
||||
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||
"alreadyInLibrary": "כבר בספרייה",
|
||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||
"fileSelection": {
|
||||
"title": "בחר פורמט קובץ",
|
||||
"files": "קבצים",
|
||||
"select": "בחר קובץ"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||
"noVersions": "אין גרסאות זמינות למודל זה"
|
||||
"noVersions": "אין גרסאות זמינות למודל זה",
|
||||
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
|
||||
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "מכין הורדה...",
|
||||
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
|
||||
"downloadingFile": "מוריד קובץ {type}",
|
||||
"finalizing": "מסיים הורדה..."
|
||||
"finalizing": "מסיים הורדה...",
|
||||
"cancelling": "מבטל הורדה...",
|
||||
"cancelled": "ההורדה בוטלה"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "הקובץ הנוכחי:",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "מודלים יימחקו לצמיתות.",
|
||||
"action": "מחק הכל"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "מחק מספר מתכונים",
|
||||
"message": "האם אתה בטוח שברצונך למחוק את כל המתכונים שנבחרו ואת הקבצים הנלווים אליהם?",
|
||||
"countMessage": "מתכונים יימחקו לצמיתות.",
|
||||
"action": "מחק הכל"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
|
||||
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
|
||||
"root": "שורש"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "קישור ל-HuggingFace",
|
||||
"infoText": "הדבק את כתובת ה-URL של מאגר HuggingFace כדי לשייך מודל זה למקורו. פעולה זו מאפשרת העשרת מטא-דאטה באמצעות AI.",
|
||||
"urlLabel": "כתובת URL של מאגר HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "הזן את כתובת ה-URL המלאה של מאגר HuggingFace.",
|
||||
"confirmAction": "שמור וקשר"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "קשר מחדש ל-Civitai",
|
||||
"warning": "אזהרה:",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "ערוך שם מודל",
|
||||
"editFileName": "ערוך שם קובץ",
|
||||
"editBaseModel": "ערוך מודל בסיס",
|
||||
"editVersionName": "ערוך שם גרסה",
|
||||
"viewOnCivitai": "הצג ב-Civitai",
|
||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
|
||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "הערות נוספות",
|
||||
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
||||
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
|
||||
"aboutThisVersion": "אודות גרסה זו"
|
||||
"aboutThisVersion": "אודות גרסה זו",
|
||||
"baseModelSearchPlaceholder": "חפש מודל בסיס…",
|
||||
"baseModelSuggested": "מוצע",
|
||||
"baseModelNoMatch": "אין מודלי בסיס תואמים"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "הערות נשמרו בהצלחה",
|
||||
"saveFailed": "שמירת ההערות נכשלה"
|
||||
"saveFailed": "שמירת ההערות נכשלה",
|
||||
"showMore": "הצג עוד",
|
||||
"showLess": "הצג פחות"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "הוסף פרמטר קבוע מראש...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "בטל עריכה",
|
||||
"save": "שמור שינויים",
|
||||
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
||||
"editWord": "עריכת מילת טריגר",
|
||||
"editPlaceholder": "עריכת מילת טריגר",
|
||||
"copyWord": "העתק מילת טריגר",
|
||||
"deleteWord": "מחק מילת טריגר",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||
"ignored": "התעלם",
|
||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||
"onSiteOnly": "רק באתר",
|
||||
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
|
||||
},
|
||||
"actions": {
|
||||
"download": "הורדה",
|
||||
"downloadTooltip": "הורד את הגרסה הזו",
|
||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||
"delete": "מחיקה",
|
||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||
"ignore": "התעלם",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
|
||||
"error": "טעינת הגרסאות נכשלה.",
|
||||
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",
|
||||
"hfGroupInfo": "זוהי קבוצת דגמים של HuggingFace. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
|
||||
"confirm": {
|
||||
"delete": "למחוק גרסה זו מהספרייה שלך?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "הגרסה נמחקה"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "סיכום שליפת מטא-דאטה",
|
||||
"statSuccess": "הצלחה",
|
||||
"statFailed": "נכשל",
|
||||
"statSkipped": "דולג",
|
||||
"statTotal": "סה\"כ נסרק",
|
||||
"statDuration": "משך",
|
||||
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
|
||||
"failedItems": "פריטים נכשלים ({count})",
|
||||
"close": "סגור",
|
||||
"copyReport": "העתק דוח",
|
||||
"downloadCsv": "הורד CSV",
|
||||
"columnModelName": "שם המודל",
|
||||
"columnError": "שגיאה"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "תגית זו כבר קיימת"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "ניווט במקלדת:",
|
||||
"shortcuts": {
|
||||
"pageUp": "גלול עמוד אחד למעלה",
|
||||
"pageDown": "גלול עמוד אחד למטה",
|
||||
"home": "קפוץ להתחלה",
|
||||
"end": "קפוץ לסוף"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "מאתחל",
|
||||
"message": "מכין את סביבת העבודה שלך...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
||||
"modelUpdated": "מודל עודכן ב-workflow",
|
||||
"modelFailed": "עדכון צומת המודל נכשל"
|
||||
"modelFailed": "עדכון צומת המודל נכשל",
|
||||
"embeddingAdded": "Embedding נוסף ל-workflow",
|
||||
"embeddingFailed": "הוספת Embedding נכשלה",
|
||||
"promptSent": "הנחיה נשלחה ל-workflow",
|
||||
"promptFailed": "שליחת ההנחיה נכשלה"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "מתכון",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "הנחיה",
|
||||
"replace": "החלף",
|
||||
"append": "הוסף",
|
||||
"selectTargetNode": "בחר צומת יעד",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "תיקיית תמונות הדוגמה נפתחה",
|
||||
"openingFolder": "פותח תיקיית תמונות דוגמה",
|
||||
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה",
|
||||
"copiedPath": "הנתיב הועתק ללוח: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}",
|
||||
"copiedUri": "הקישור הועתק ללוח: {{uri}}",
|
||||
"uriClipboardFallback": "קישור: {{uri}}",
|
||||
"setupRequired": "אחסון תמונות דוגמה",
|
||||
"setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.",
|
||||
"setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.",
|
||||
@@ -1459,7 +1773,13 @@
|
||||
"checkingUpdates": "בודק עדכונים...",
|
||||
"checkingMessage": "אנא המתן בזמן שאנו בודקים את הגרסה האחרונה.",
|
||||
"showNotifications": "הצג התראות עדכון",
|
||||
"latestBadge": "עדכן",
|
||||
"latestBadge": "אחרון",
|
||||
"latestMain": "ענף main",
|
||||
"channel": "ערוץ עדכון",
|
||||
"channels": {
|
||||
"release": "Release",
|
||||
"nightly": "Nightly"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "מכין עדכון...",
|
||||
"installing": "מתקין עדכון...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
|
||||
"enable": "הפעל עדכונים ליליים"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "מעבר לערוץ Nightly",
|
||||
"nightlyMessage": "מעבר ל-Nightly יאתחל מאגר Git ויעקוב אחר הקומיטים האחרונים בענף main. העדכונים תכופים יותר אך עשויים להיות לא יציבים. ניתן לחזור ל-Release בכל עת.",
|
||||
"releaseTitle": "מעבר לערוץ Release",
|
||||
"releaseMessage": "מעבר ל-Release יעבור לתגית הגרסה היציבה האחרונה. ניתן לחזור ל-Nightly בכל עת.",
|
||||
"switching": "מעבר לערוץ {channel}...",
|
||||
"completed": "המעבר לערוץ {channel} הושלם",
|
||||
"failed": "החלפת ערוץ נכשלה"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "הודעות אחרונות",
|
||||
"empty": "אין כרגע באנרים אחרונים.",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "אין מזהה מתכון זמין",
|
||||
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
||||
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
||||
"createError": "שגיאה ביצירת המתכון:{message}",
|
||||
"createFailed": "יצירת המתכון נכשלה:{error}",
|
||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||
"created": "המתכון נוצר בהצלחה",
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
||||
"enterLoraName": "אנא הזן שם LoRA או תחביר",
|
||||
"reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה",
|
||||
"reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}",
|
||||
"noPromptToSend": "אין הנחיה לשליחה",
|
||||
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
|
||||
"sendFailed": "שליחת המתכון ל-workflow נכשלה",
|
||||
"sendError": "שגיאה בשליחת המתכון ל-workflow",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
||||
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
||||
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
||||
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
||||
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
||||
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
|
||||
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
|
||||
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
|
||||
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
|
||||
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
|
||||
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
||||
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
||||
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
|
||||
"imagesFailed": "{action} תמונות הדוגמה נכשל",
|
||||
"loadError": "שגיאה בטעינת הורדות: {message}",
|
||||
"downloadError": "שגיאת הורדה: {message}"
|
||||
"downloadError": "שגיאת הורדה: {message}",
|
||||
"downloadStopped": "ההורדה בוטלה"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "לא ניתן היה לטעון מילים מאומנות",
|
||||
"tooLong": "מילת טריגר לא תעלה על 100 מילים",
|
||||
"tooMany": "מותרות עד 30 מילות טריגר",
|
||||
"tooLong": "מילת טריגר לא תעלה על 500 מילים",
|
||||
"tooMany": "מותרות עד 100 מילות טריגר",
|
||||
"alreadyExists": "מילת טריגר זו כבר קיימת",
|
||||
"updateSuccess": "מילות הטריגר עודכנו בהצלחה",
|
||||
"updateFailed": "עדכון מילות הטריגר נכשל",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "הגדרת דירוג התוכן נכשלה: {message}",
|
||||
"relinkSuccess": "המודל קושר מחדש ל-Civitai בהצלחה",
|
||||
"relinkFailed": "שגיאה: {message}",
|
||||
"linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace",
|
||||
"linkHfFailed": "שגיאה: {message}",
|
||||
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
|
||||
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
|
||||
"missingHash": "ה-hash של המודל אינו זמין"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "מחיקת {type} נכשלה: {message}",
|
||||
"excludeSuccess": "{type} הוחרג בהצלחה",
|
||||
"excludeFailed": "החרגת {type} נכשלה: {message}",
|
||||
"restoreSuccess": "{type} שוחזר בהצלחה",
|
||||
"restoreFailed": "שחזור {type} נכשל: {message}",
|
||||
"fileNameUpdated": "שם הקובץ עודכן בהצלחה",
|
||||
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
||||
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "הועתק ללוח",
|
||||
"downloadStarted": "ההורדה החלה"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "ספק AI לא הוגדר. הפעל אותו בהגדרות → ספק AI.",
|
||||
"enrichStarted": "מעשיר מטא-דאטה באמצעות AI...",
|
||||
"enrichComplete": "העשרת מטא-דאטה הושלמה: {{summary}}",
|
||||
"enrichFailed": "העשרת מטא-דאטה נכשלה: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "דורש תשומת לב",
|
||||
"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": {
|
||||
"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": {
|
||||
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
||||
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
|
||||
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "זוהה עדכון יישום",
|
||||
|
||||
495
locales/ja.json
495
locales/ja.json
@@ -15,10 +15,14 @@
|
||||
"settings": "設定",
|
||||
"help": "ヘルプ",
|
||||
"add": "追加",
|
||||
"close": "閉じる"
|
||||
"close": "閉じる",
|
||||
"menu": "メニュー",
|
||||
"remove": "削除",
|
||||
"change": "変更"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
"cancelling": "キャンセル中...",
|
||||
"unknown": "不明",
|
||||
"date": "日付",
|
||||
"version": "バージョン",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "お気に入りから削除",
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"notAvailableFromCivitai": "Civitaiでは利用できません",
|
||||
"viewOnHuggingFace": "Hugging Face で見る",
|
||||
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
|
||||
"copyLoRASyntax": "LoRA構文をコピー",
|
||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "プレビューを置換",
|
||||
"copyCheckpointName": "checkpoint名をコピー",
|
||||
"copyEmbeddingName": "embedding名をコピー",
|
||||
"embeddingNameCopied": "Embedding構文をコピーしました",
|
||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用回数"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} バージョン",
|
||||
"viewAllVersions": "ローカルの全バージョンを表示"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "{count} 件のレシピを正常に修復しました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "モデルでグループ化"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "検索...",
|
||||
"placeholders": {
|
||||
"loras": "LoRAを検索...",
|
||||
"recipes": "レシピを検索...",
|
||||
"checkpoints": "checkpointを検索...",
|
||||
"embeddings": "embeddingを検索..."
|
||||
},
|
||||
"placeholder": "検索",
|
||||
"options": "検索オプション",
|
||||
"searchIn": "検索対象:",
|
||||
"notAvailable": "統計ページでは検索は利用できません",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?",
|
||||
"presetNamePlaceholder": "プリセット名...",
|
||||
"baseModel": "ベースモデル",
|
||||
"modelTags": "タグ(上位20)",
|
||||
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
||||
"modelTags": "タグ",
|
||||
"modelTypes": "モデルタイプ",
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||
"noTags": "タグなし",
|
||||
"tagSearchPlaceholder": "タグを検索...",
|
||||
"noTagMatches": "現在の検索に一致するタグはありません。",
|
||||
"autoTags": "自動タグ",
|
||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||
"clearAll": "すべてのフィルタをクリア",
|
||||
"any": "いずれか",
|
||||
"all": "すべて",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "テーマの切り替え",
|
||||
"switchToLight": "ライトテーマに切り替え",
|
||||
"switchToDark": "ダークテーマに切り替え",
|
||||
"switchToAuto": "自動テーマに切り替え"
|
||||
"switchToAuto": "自動テーマに切り替え",
|
||||
"presets": "テーマプリセット",
|
||||
"default": "デフォルト",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "モード",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"auto": "自動"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "更新確認",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiApiKeyConfigured": "設定済み",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
"options": {
|
||||
"com": "civitai.com(SFW のみ)",
|
||||
"red": "civitai.red(制限なし)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "ダウンロードバックエンド",
|
||||
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は推奨の外部ダウンローダープロセスを使用します。",
|
||||
"options": {
|
||||
"python": "Python(内蔵)",
|
||||
"aria2": "aria2(推奨)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "aria2c のパス",
|
||||
"help": "aria2c 実行ファイルへの任意のパスです。空欄のままにすると、システム PATH 上の aria2c を使用します。",
|
||||
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
|
||||
},
|
||||
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai ホスト設定を利用できます",
|
||||
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||
"openSettings": "設定を開く"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "設定フォルダーを開く",
|
||||
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "コンテンツフィルタリング",
|
||||
"downloads": "ダウンロード",
|
||||
"videoSettings": "動画設定",
|
||||
"layoutSettings": "レイアウト設定",
|
||||
"licenseIcons": "ライセンスアイコン",
|
||||
"misc": "その他",
|
||||
"backup": "バックアップ",
|
||||
"folderSettings": "デフォルトルート",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "追加フォルダーパス",
|
||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||
"priorityTags": "優先タグ",
|
||||
"updateFlags": "アップデートフラグ",
|
||||
"versionScope": "アップデートフラグ",
|
||||
"exampleImages": "例画像",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "メタデータ",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "モデルでグループ化",
|
||||
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "デフォルト",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "ホバー時に表示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||
"showVersionOnCard": "カードにバージョンを表示",
|
||||
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
|
||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "例画像を開く",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "モデル名",
|
||||
"fileName": "ファイル名"
|
||||
},
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択",
|
||||
"cardBlurAmount": "カードオーバーレイのぼかし",
|
||||
"cardBlurAmountHelp": "モデルカードとレシピカードのヘッダー・フッターオーバーレイのぼかし強度を調整します(0 = ぼかしなし、20 = 最大ぼかし)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "アクティブライブラリ",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "このパスはすでに設定されています"
|
||||
"duplicatePath": "このパスはすでに設定されています",
|
||||
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
|
||||
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,12 +574,27 @@
|
||||
"downloadLocationHelp": "Civitaiからの例画像を保存するフォルダパスを入力してください",
|
||||
"autoDownload": "例画像の自動ダウンロード",
|
||||
"autoDownloadHelp": "例画像がないモデルの例画像を自動的にダウンロードします(ダウンロード場所の設定が必要)",
|
||||
"openMode": "サンプル画像を開く動作",
|
||||
"openModeHelp": "サーバー上で開くか、対応するローカルパスをコピーするか、カスタム URI を起動するかを選択します。",
|
||||
"openModeOptions": {
|
||||
"system": "サーバー上で開く",
|
||||
"clipboard": "ローカルパスをコピー",
|
||||
"uriTemplate": "カスタム URI を開く"
|
||||
},
|
||||
"localRoot": "ローカルのサンプル画像ルート",
|
||||
"localRootHelp": "サーバーのサンプル画像ディレクトリを反映する任意のローカルまたはマウント済みルートです。空欄の場合はサーバーのパスを再利用します。",
|
||||
"localRootPlaceholder": "例: /Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "URI テンプレートを開く",
|
||||
"uriTemplateHelp": "ファイル URI や Shortcuts リンクなどのカスタムディープリンクを使用します。",
|
||||
"uriTemplatePlaceholder": "例: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "使用可能なプレースホルダー: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||
"openModeWikiLink": "リモートオープンモードの詳細",
|
||||
"optimizeImages": "ダウンロード画像の最適化",
|
||||
"optimizeImagesHelp": "例画像を最適化してファイルサイズを縮小し、読み込み速度を向上させます(メタデータは保持されます)",
|
||||
"download": "ダウンロード",
|
||||
"restartRequired": "再起動が必要"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "アップデートフラグの表示戦略",
|
||||
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
|
||||
"options": {
|
||||
@@ -523,9 +606,19 @@
|
||||
"label": "早期アクセス更新を非表示",
|
||||
"help": "早期アクセスのみの更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "ダウンロードを準備中...",
|
||||
"connecting": "ダウンロードサーバーに接続中...",
|
||||
"completed": "完了",
|
||||
"downloadComplete": "ダウンロードが正常に完了しました"
|
||||
"downloadComplete": "ダウンロードが正常に完了しました",
|
||||
"enableCivarchiveApi": "CivArchive API をメタデータプロバイダーとして有効化",
|
||||
"enableCivarchiveApiHelp": "有効にすると、CivArchive API がモデルメタデータの代替ソースとして使用されます(例:CivitAI から削除されたモデルの場合)。オフにすると、CivArchive のレート制限を完全に回避できます。",
|
||||
"providerOrder": "メタデータプロバイダーのフォールバック順序",
|
||||
"providerOrderHelp": "CivitAI API が常に最初に試行されます。メタデータ検索時の残りのプロバイダーの順序を選択してください。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "アプリレベルのプロキシを有効化",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "パスワード(任意)",
|
||||
"proxyPasswordPlaceholder": "パスワード",
|
||||
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "AIプロバイダー",
|
||||
"provider": "プロバイダー",
|
||||
"providerHelp": "LLMプロバイダーを選択してください。OpenAIとOllamaはプリセットのAPIエンドポイントを使用します。カスタムでは任意のOpenAI互換エンドポイントを指定できます。",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama(ローカル)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "カスタム(OpenAI 互換)"
|
||||
},
|
||||
"apiBase": "APIベースURL",
|
||||
"apiBaseHelp": "LLM APIのベースURL(例:https://api.openai.com/v1)。空の場合はプロバイダーのデフォルトが使用されます。",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "APIキー",
|
||||
"apiKeyHelp": "LLMプロバイダーのAPIキー。ローカルに保存され、選択したLLMプロバイダー以外のサーバーに送信されることはありません。",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "未設定",
|
||||
"apiKeyConfigured": "設定済み",
|
||||
"apiKeySet": "設定",
|
||||
"model": "モデル",
|
||||
"modelHelp": "使用するモデル名(例:deepseek-v4-flash, gemini-2.5-flash, gemma4:12b)。プロバイダーで利用可能なモデルをご確認ください。",
|
||||
"modelPlaceholder": "モデルを選択..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "小さい順",
|
||||
"usage": "使用回数",
|
||||
"usageDesc": "多い",
|
||||
"usageAsc": "少ない"
|
||||
"usageAsc": "少ない",
|
||||
"versionsCount": "ローカルバージョン数",
|
||||
"versionsCountDesc": "バージョン数の多い順",
|
||||
"versionsCountAsc": "バージョン数の少ない順",
|
||||
"versionIdDesc": "最新バージョン順",
|
||||
"random": "ランダム",
|
||||
"randomAction": "シャッフル(ランダム)"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
"autoOrganize": "自動整理を実行",
|
||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||
"deleteAll": "すべてのモデルを削除",
|
||||
"setFavorite": "お気に入りに設定",
|
||||
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
|
||||
"unfavorite": "お気に入りから削除",
|
||||
"deleteAll": "選択したものを削除",
|
||||
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||
"downloadExamples": "例画像をダウンロード",
|
||||
"downloadMissingExamples": "不足分をダウンロード",
|
||||
"reprocessExamples": "すべて再処理",
|
||||
"clear": "選択をクリア",
|
||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||
"sendToWorkflow": "ワークフローに送信",
|
||||
"sections": {
|
||||
"workflow": "ワークフロー",
|
||||
"metadata": "メタデータ",
|
||||
"attributes": "属性",
|
||||
"organize": "整理",
|
||||
"download": "ダウンロード"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "自動整理を初期化中...",
|
||||
"starting": "{type}の自動整理を開始中...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "完了:{success} 移動、{skipped} スキップ、{failures} 失敗",
|
||||
"complete": "自動整理が完了しました",
|
||||
"error": "エラー:{error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitaiデータを更新",
|
||||
"checkUpdates": "更新確認",
|
||||
"relinkCivitai": "Civitaiに再リンク",
|
||||
"linkModel": "モデルをリンク",
|
||||
"linkCivitai": "Civitai にリンク",
|
||||
"linkHuggingFace": "HuggingFace にリンク",
|
||||
"copySyntax": "LoRA構文をコピー",
|
||||
"copyFilename": "モデルファイル名をコピー",
|
||||
"copyRecipeSyntax": "レシピ構文をコピー",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "ワークフローに送信(置換)",
|
||||
"openExamples": "例画像フォルダを開く",
|
||||
"downloadExamples": "例画像をダウンロード",
|
||||
"downloadMissingExamples": "不足分をダウンロード",
|
||||
"reprocessExamples": "すべて再処理",
|
||||
"replacePreview": "プレビューを置換",
|
||||
"setContentRating": "コンテンツレーティングを設定",
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"excludeModel": "モデルを除外",
|
||||
"restoreModel": "モデルを復元",
|
||||
"deleteModel": "モデルを削除",
|
||||
"shareRecipe": "レシピを共有",
|
||||
"viewAllLoras": "すべてのLoRAを表示",
|
||||
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
||||
"deleteRecipe": "レシピを削除"
|
||||
"deleteRecipe": "レシピを削除",
|
||||
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "レシピリストを更新",
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
|
||||
"failed": "レシピの修復に失敗しました: {message}",
|
||||
"missingId": "レシピを修復できません: レシピIDがありません"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "ソースからレシピを再インポート中...",
|
||||
"success": "レシピの再インポートが完了しました",
|
||||
"noSourceUrl": "レシピにソースURLがありません。再インポートできません",
|
||||
"failed": "レシピの再インポートに失敗しました: {message}",
|
||||
"missingId": "レシピを再インポートできません: レシピIDがありません"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "ルート",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"pinSidebar": "サイドバーを固定",
|
||||
"unpinSidebar": "サイドバーの固定を解除",
|
||||
"hideOnThisPage": "このページでサイドバーを非表示",
|
||||
"showSidebar": "サイドバーを表示",
|
||||
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"recursiveOn": "サブフォルダーを含める",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "フォルダが見つかりません",
|
||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "このフォルダのアップデートを確認",
|
||||
"loading": "このフォルダの{type}アップデートを確認中...",
|
||||
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
|
||||
"none": "このフォルダのすべての{type}sは最新です",
|
||||
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "ストレージ",
|
||||
"insights": "インサイト"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "モデル総数",
|
||||
"totalStorage": "ストレージ合計",
|
||||
"totalGenerations": "生成回数合計",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "ユニークタグ",
|
||||
"unusedModels": "未使用モデル",
|
||||
"avgUsesPerModel": "平均使用回数/モデル"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最も使用されているLoRA",
|
||||
"mostUsedCheckpoints": "最も使用されているCheckpoint",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "スマートインサイト",
|
||||
"recommendations": "推奨事項"
|
||||
"recommendations": "推奨事項",
|
||||
"noInsights": "インサイトはありません",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "未使用のLoRAが多数あります",
|
||||
"description": "LoRAの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "未使用のモデルを整理またはアーカイブしてストレージを解放してください。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "未使用のCheckpointを検出",
|
||||
"description": "Checkpointの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "不要なCheckpointを確認して削除を検討してください。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "未使用のEmbeddingが多数あります",
|
||||
"description": "Embeddingの{percent}%({count}/{total})が一度も使用されていません。",
|
||||
"suggestion": "未使用のEmbeddingを整理またはアーカイブしてコレクションを最適化してください。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "大規模コレクションを検出",
|
||||
"description": "モデルコレクションが{size}のストレージを使用しています。",
|
||||
"suggestion": "外部ストレージやクラウドソリューションの使用を検討してください。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "アクティブユーザー",
|
||||
"description": "これまでに{count}回の生成を完了しました!",
|
||||
"suggestion": "モデルを使って素晴らしいコンテンツを作り続けてください。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "コレクション概要",
|
||||
"baseModelDistribution": "ベースモデル分布",
|
||||
"usageTrends": "使用傾向(過去30日)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日付",
|
||||
"usageCount": "使用回数",
|
||||
"fileSizeBytes": "ファイルサイズ(バイト)",
|
||||
"models": "モデル",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "拡散モデル",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "読み込み中...",
|
||||
"noModels": "モデルが見つかりません",
|
||||
"errorLoading": "データ読み込みエラー",
|
||||
"noStorageData": "ストレージデータがありません",
|
||||
"rootFolder": "ルート",
|
||||
"chartLibraryMissing": "Chart.js ライブラリが必要です"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} モデル",
|
||||
"chartUsage": "{name}: {size}, {count} 回使用",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "URLからモデルをダウンロード",
|
||||
"titleWithType": "URLから{type}をダウンロード",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "1行に1つのCivitAI、CivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
|
||||
"selectAll": "すべて選択",
|
||||
"fetchingRepoFiles": "リポジトリのファイルを取得中...",
|
||||
"locationPreview": "ダウンロード場所プレビュー",
|
||||
"useDefaultPath": "デフォルトパスを使用",
|
||||
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||
"alreadyInLibrary": "既にライブラリ内",
|
||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||
"fileSelection": {
|
||||
"title": "ファイル形式を選択",
|
||||
"files": "ファイル",
|
||||
"select": "ファイルを選択"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "無効なCivitai URL形式",
|
||||
"noVersions": "このモデルの利用可能なバージョンがありません"
|
||||
"noVersions": "このモデルの利用可能なバージョンがありません",
|
||||
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
|
||||
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
|
||||
},
|
||||
"status": {
|
||||
"preparing": "ダウンロードを準備中...",
|
||||
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
||||
"downloadingFile": "{type}ファイルをダウンロード中",
|
||||
"finalizing": "ダウンロードを完了中..."
|
||||
"finalizing": "ダウンロードを完了中...",
|
||||
"cancelling": "ダウンロードをキャンセル中...",
|
||||
"cancelled": "ダウンロードをキャンセルしました"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "現在のファイル:",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "モデルが完全に削除されます。",
|
||||
"action": "すべて削除"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "複数のレシピを削除",
|
||||
"message": "選択したすべてのレシピと関連ファイルを削除してもよろしいですか?",
|
||||
"countMessage": "レシピが完全に削除されます。",
|
||||
"action": "すべて削除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "すべての{type}の更新を確認しますか?",
|
||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
|
||||
"root": "ルート"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "HuggingFace にリンク",
|
||||
"infoText": "HuggingFace リポジトリの URL を貼り付けてモデルを関連付けます。AI によるメタデータ補完が有効になります。",
|
||||
"urlLabel": "HuggingFace リポジトリ URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "完全な HuggingFace リポジトリ URL を入力してください。",
|
||||
"confirmAction": "保存&リンク"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Civitaiに再リンク",
|
||||
"warning": "警告:",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "モデル名を編集",
|
||||
"editFileName": "ファイル名を編集",
|
||||
"editBaseModel": "ベースモデルを編集",
|
||||
"editVersionName": "バージョン名を編集",
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"viewOnCivitaiText": "Civitaiで表示",
|
||||
"viewOnHuggingFace": "Hugging Face で見る",
|
||||
"viewOnHuggingFaceText": "Hugging Face で見る",
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "追加メモ",
|
||||
"notesHint": "Enterで保存、Shift+Enterで改行",
|
||||
"addNotesPlaceholder": "メモをここに追加...",
|
||||
"aboutThisVersion": "このバージョンについて"
|
||||
"aboutThisVersion": "このバージョンについて",
|
||||
"baseModelSearchPlaceholder": "ベースモデルを検索…",
|
||||
"baseModelSuggested": "おすすめ",
|
||||
"baseModelNoMatch": "該当するベースモデルがありません"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "メモが正常に保存されました",
|
||||
"saveFailed": "メモの保存に失敗しました"
|
||||
"saveFailed": "メモの保存に失敗しました",
|
||||
"showMore": "もっと見る",
|
||||
"showLess": "折りたたむ"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "プリセットパラメータを追加...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "編集をキャンセル",
|
||||
"save": "変更を保存",
|
||||
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
||||
"editWord": "トリガーワードを編集",
|
||||
"editPlaceholder": "トリガーワードを編集",
|
||||
"copyWord": "トリガーワードをコピー",
|
||||
"deleteWord": "トリガーワードを削除",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "早期アクセス",
|
||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||
"ignored": "無視中",
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||
"onSiteOnly": "サイト内のみ",
|
||||
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"downloadTooltip": "このバージョンをダウンロード",
|
||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||
"delete": "削除",
|
||||
"deleteTooltip": "このローカルバージョンを削除",
|
||||
"ignore": "無視",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||
"error": "バージョンの読み込みに失敗しました。",
|
||||
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
||||
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
|
||||
"confirm": {
|
||||
"delete": "このバージョンをライブラリから削除しますか?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "バージョンを削除しました"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "メタデータ取得サマリー",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失敗",
|
||||
"statSkipped": "スキップ",
|
||||
"statTotal": "スキャン合計",
|
||||
"statDuration": "所要時間",
|
||||
"successMessage": "すべての{count}件の{type}を正常に更新しました",
|
||||
"failedItems": "失敗したアイテム ({count})",
|
||||
"close": "閉じる",
|
||||
"copyReport": "レポートをコピー",
|
||||
"downloadCsv": "CSVをダウンロード",
|
||||
"columnModelName": "モデル名",
|
||||
"columnError": "エラー"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "このタグは既に存在します"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "キーボードナビゲーション:",
|
||||
"shortcuts": {
|
||||
"pageUp": "1ページ上にスクロール",
|
||||
"pageDown": "1ページ下にスクロール",
|
||||
"home": "トップにジャンプ",
|
||||
"end": "ボトムにジャンプ"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初期化中",
|
||||
"message": "ワークスペースを準備中...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||
"modelUpdated": "モデルがワークフローで更新されました",
|
||||
"modelFailed": "モデルノードの更新に失敗しました"
|
||||
"modelFailed": "モデルノードの更新に失敗しました",
|
||||
"embeddingAdded": "Embeddingをワークフローに追加しました",
|
||||
"embeddingFailed": "Embeddingの追加に失敗しました",
|
||||
"promptSent": "プロンプトをワークフローに送信しました",
|
||||
"promptFailed": "プロンプトの送信に失敗しました"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "プロンプト",
|
||||
"replace": "置換",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "ターゲットノードを選択",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "例画像フォルダが開かれました",
|
||||
"openingFolder": "例画像フォルダを開いています",
|
||||
"failedToOpen": "例画像フォルダを開くのに失敗しました",
|
||||
"copiedPath": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}",
|
||||
"copiedUri": "リンクをクリップボードにコピーしました: {{uri}}",
|
||||
"uriClipboardFallback": "リンク: {{uri}}",
|
||||
"setupRequired": "例画像ストレージ",
|
||||
"setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。",
|
||||
"setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。",
|
||||
@@ -1460,6 +1774,12 @@
|
||||
"checkingMessage": "最新バージョンを確認しています。お待ちください。",
|
||||
"showNotifications": "更新通知を表示",
|
||||
"latestBadge": "最新",
|
||||
"latestMain": "Main ブランチ",
|
||||
"channel": "更新チャンネル",
|
||||
"channels": {
|
||||
"release": "リリース",
|
||||
"nightly": "ナイトリー"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "更新を準備中...",
|
||||
"installing": "更新をインストール中...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
||||
"enable": "ナイトリー更新を有効にする"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "ナイトリーチャンネルに切り替え",
|
||||
"nightlyMessage": "ナイトリーに切り替えると、Gitリポジトリが初期化され、mainブランチの最新コミットを追跡します。更新頻度は高くなりますが、不安定な場合があります。いつでもリリース版に戻せます。",
|
||||
"releaseTitle": "リリースチャンネルに切り替え",
|
||||
"releaseMessage": "リリースに切り替えると、最新の安定版タグにチェックアウトされます。いつでもNightlyに戻せます。",
|
||||
"switching": "{channel} チャンネルに切り替え中...",
|
||||
"completed": "{channel} チャンネルに切り替えました",
|
||||
"failed": "チャンネルの切り替えに失敗しました"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最近の通知",
|
||||
"empty": "最近のバナーはありません。",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "レシピIDが利用できません",
|
||||
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
||||
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
||||
"createError": "レシピ作成中にエラーが発生しました:{message}",
|
||||
"createFailed": "レシピの作成に失敗しました:{error}",
|
||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||
"created": "レシピを作成しました",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
||||
"enterLoraName": "LoRA名または構文を入力してください",
|
||||
"reconnectedSuccessfully": "LoRAが正常に再接続されました",
|
||||
"reconnectFailed": "LoRA再接続エラー:{message}",
|
||||
"noPromptToSend": "送信するプロンプトがありません",
|
||||
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
||||
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
||||
"sendError": "レシピのワークフロー送信エラー",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "レシピが選択されていません",
|
||||
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||
"reimporting": "ソースからレシピを再インポート中...",
|
||||
"reimportSuccess": "レシピの再インポートが完了しました",
|
||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
|
||||
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
|
||||
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
|
||||
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
|
||||
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
|
||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "例画像 {action} が完了しました",
|
||||
"imagesFailed": "例画像 {action} が失敗しました",
|
||||
"loadError": "ダウンロード読み込みエラー:{message}",
|
||||
"downloadError": "ダウンロードエラー:{message}"
|
||||
"downloadError": "ダウンロードエラー:{message}",
|
||||
"downloadStopped": "ダウンロードをキャンセルしました"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "学習済みワードを読み込めませんでした",
|
||||
"tooLong": "トリガーワードは100ワードを超えてはいけません",
|
||||
"tooMany": "最大30トリガーワードまで許可されています",
|
||||
"tooLong": "トリガーワードは500ワードを超えてはいけません",
|
||||
"tooMany": "最大100トリガーワードまで許可されています",
|
||||
"alreadyExists": "このトリガーワードは既に存在します",
|
||||
"updateSuccess": "トリガーワードが正常に更新されました",
|
||||
"updateFailed": "トリガーワードの更新に失敗しました",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "コンテンツレーティングの設定に失敗しました:{message}",
|
||||
"relinkSuccess": "モデルがCivitaiに正常に再リンクされました",
|
||||
"relinkFailed": "エラー:{message}",
|
||||
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
|
||||
"linkHfFailed": "エラー:{message}",
|
||||
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
||||
"noCivitaiInfo": "CivitAI情報が利用できません",
|
||||
"missingHash": "モデルハッシュが利用できません"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "{type}の削除に失敗しました:{message}",
|
||||
"excludeSuccess": "{type}が正常に除外されました",
|
||||
"excludeFailed": "{type}の除外に失敗しました:{message}",
|
||||
"restoreSuccess": "{type}を復元しました",
|
||||
"restoreFailed": "{type}の復元に失敗しました: {message}",
|
||||
"fileNameUpdated": "ファイル名が正常に更新されました",
|
||||
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
||||
"previewUpdated": "プレビューが正常に更新されました",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"downloadStarted": "ダウンロードを開始しました"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AIプロバイダーが設定されていません。設定 → AIプロバイダーで有効にしてください。",
|
||||
"enrichStarted": "AIでメタデータを補完中...",
|
||||
"enrichComplete": "メタデータの補完が完了しました:{{summary}}",
|
||||
"enrichFailed": "メタデータの補完に失敗しました:{{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "要注意",
|
||||
"error": "対応が必要"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API キー"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "モデルキャッシュの健全性"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "ファイル名重複競合"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI バージョン"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "再実行",
|
||||
"exportBundle": "パッケージをエクスポート"
|
||||
"exportBundle": "パッケージをエクスポート",
|
||||
"open-settings": "設定を開く",
|
||||
"open-settings-syntax-format": "フルパス構文に切り替え",
|
||||
"repair-cache": "キャッシュを再構築",
|
||||
"resolve-filename-conflicts": "競合を解決",
|
||||
"reload-page": "UI をリロード"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "競合",
|
||||
"version": "バージョン"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
||||
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
|
||||
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "アプリケーション更新が検出されました",
|
||||
|
||||
495
locales/ko.json
495
locales/ko.json
@@ -15,10 +15,14 @@
|
||||
"settings": "설정",
|
||||
"help": "도움말",
|
||||
"add": "추가",
|
||||
"close": "닫기"
|
||||
"close": "닫기",
|
||||
"menu": "메뉴",
|
||||
"remove": "제거",
|
||||
"change": "변경"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
"cancelling": "취소 중...",
|
||||
"unknown": "알 수 없음",
|
||||
"date": "날짜",
|
||||
"version": "버전",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "즐겨찾기에서 제거",
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
|
||||
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
|
||||
"copyLoRASyntax": "LoRA 문법 복사",
|
||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "미리보기 교체",
|
||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||
"copyEmbeddingName": "Embedding 이름 복사",
|
||||
"embeddingNameCopied": "Embedding 구문 복사됨",
|
||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "사용 횟수"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count}개 버전",
|
||||
"viewAllVersions": "모든 로컬 버전 보기"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||
"error": "레시피 복구 실패: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "모델별 그룹화"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "통계"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "검색...",
|
||||
"placeholders": {
|
||||
"loras": "LoRA 검색...",
|
||||
"recipes": "레시피 검색...",
|
||||
"checkpoints": "Checkpoint 검색...",
|
||||
"embeddings": "Embedding 검색..."
|
||||
},
|
||||
"placeholder": "검색",
|
||||
"options": "검색 옵션",
|
||||
"searchIn": "검색 범위:",
|
||||
"notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?",
|
||||
"presetNamePlaceholder": "프리셋 이름...",
|
||||
"baseModel": "베이스 모델",
|
||||
"modelTags": "태그 (상위 20개)",
|
||||
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
||||
"modelTags": "태그",
|
||||
"modelTypes": "모델 유형",
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||
"noTags": "태그 없음",
|
||||
"tagSearchPlaceholder": "태그 검색...",
|
||||
"noTagMatches": "현재 검색과 일치하는 태그가 없습니다.",
|
||||
"autoTags": "자동 태그",
|
||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||
"clearAll": "모든 필터 지우기",
|
||||
"any": "아무",
|
||||
"all": "모두",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "테마 토글",
|
||||
"switchToLight": "라이트 테마로 전환",
|
||||
"switchToDark": "다크 테마로 전환",
|
||||
"switchToAuto": "자동 테마로 전환"
|
||||
"switchToAuto": "자동 테마로 전환",
|
||||
"presets": "테마 프리셋",
|
||||
"default": "기본",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "모드",
|
||||
"light": "라이트",
|
||||
"dark": "다크",
|
||||
"auto": "자동"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiApiKeyConfigured": "설정됨",
|
||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||
"civitaiApiKeySet": "설정",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
"options": {
|
||||
"com": "civitai.com(SFW 전용)",
|
||||
"red": "civitai.red(무제한)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "다운로드 백엔드",
|
||||
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 권장되는 외부 다운로더 프로세스를 사용합니다.",
|
||||
"options": {
|
||||
"python": "Python(내장)",
|
||||
"aria2": "aria2(권장)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "aria2c 경로",
|
||||
"help": "aria2c 실행 파일의 선택적 경로입니다. 비워 두면 시스템 PATH의 aria2c를 사용합니다.",
|
||||
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
|
||||
},
|
||||
"aria2HelpLink": "aria2 다운로드 백엔드 설정 방법 알아보기",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai 호스트 기본 설정 사용 가능",
|
||||
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||
"openSettings": "설정 열기"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "설정 폴더 열기",
|
||||
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "콘텐츠 필터링",
|
||||
"downloads": "다운로드",
|
||||
"videoSettings": "비디오 설정",
|
||||
"layoutSettings": "레이아웃 설정",
|
||||
"licenseIcons": "라이선스 아이콘",
|
||||
"misc": "기타",
|
||||
"backup": "백업",
|
||||
"folderSettings": "기본 루트",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "추가 폴다 경로",
|
||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||
"priorityTags": "우선순위 태그",
|
||||
"updateFlags": "업데이트 표시",
|
||||
"versionScope": "업데이트 표시",
|
||||
"exampleImages": "예시 이미지",
|
||||
"autoOrganize": "자동 정리",
|
||||
"metadata": "메타데이터",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "모델별 그룹화",
|
||||
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
"default": "기본",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "호버 시 표시"
|
||||
},
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||
"showVersionOnCard": "카드에 버전 표시",
|
||||
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
|
||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "예시 이미지 열기",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "모델명",
|
||||
"fileName": "파일명"
|
||||
},
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요",
|
||||
"cardBlurAmount": "카드 오버레이 흐림 강도",
|
||||
"cardBlurAmountHelp": "모델 및 레시피 카드의 헤더와 푸터 오버레이 흐림 강도를 조정합니다 (0 = 흐림 없음, 20 = 최대 흐림)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "활성 라이브러리",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다",
|
||||
"checkpointUnetOverlap": "checkpoints와 diffusion models에 동일한 경로를 사용할 수 없습니다: {paths}",
|
||||
"checkpointUnetOverlapInline": "이 경로는 다른 모델 유형에 이미 사용 중입니다. checkpoints와 diffusion models에 별도의 폴더를 사용하세요."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,12 +574,27 @@
|
||||
"downloadLocationHelp": "Civitai의 예시 이미지가 저장될 폴더 경로를 입력하세요",
|
||||
"autoDownload": "예시 이미지 자동 다운로드",
|
||||
"autoDownloadHelp": "예시 이미지가 없는 모델의 예시 이미지를 자동으로 다운로드합니다 (다운로드 위치 설정 필요)",
|
||||
"openMode": "예시 이미지 열기 동작",
|
||||
"openModeHelp": "서버에서 열지, 매핑된 로컬 경로를 복사할지, 사용자 지정 URI를 실행할지 선택합니다.",
|
||||
"openModeOptions": {
|
||||
"system": "서버에서 열기",
|
||||
"clipboard": "로컬 경로 복사",
|
||||
"uriTemplate": "사용자 지정 URI 열기"
|
||||
},
|
||||
"localRoot": "로컬 예시 이미지 루트",
|
||||
"localRootHelp": "서버 예시 이미지 디렉터리를 반영하는 선택적 로컬 또는 마운트된 루트입니다. 비워 두면 서버 경로를 재사용합니다.",
|
||||
"localRootPlaceholder": "예: /Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "URI 템플릿 열기",
|
||||
"uriTemplateHelp": "파일 URI 또는 Shortcuts 링크 같은 사용자 지정 딥링크를 사용합니다.",
|
||||
"uriTemplatePlaceholder": "예: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "사용 가능한 플레이스홀더: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||
"openModeWikiLink": "원격 열기 모드에 대해 자세히 알아보기",
|
||||
"optimizeImages": "다운로드된 이미지 최적화",
|
||||
"optimizeImagesHelp": "파일 크기를 줄이고 로딩 속도를 향상시키기 위해 예시 이미지를 최적화합니다 (메타데이터는 보존됨)",
|
||||
"download": "다운로드",
|
||||
"restartRequired": "재시작 필요"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "업데이트 표시 전략",
|
||||
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
|
||||
"options": {
|
||||
@@ -523,9 +606,19 @@
|
||||
"label": "얼리 액세스 업데이트 숨기기",
|
||||
"help": "얼리 액세스 업데이트만"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "다운로드 준비 중...",
|
||||
"connecting": "다운로드 서버에 연결 중...",
|
||||
"completed": "완료됨",
|
||||
"downloadComplete": "다운로드가 성공적으로 완료되었습니다"
|
||||
"downloadComplete": "다운로드가 성공적으로 완료되었습니다",
|
||||
"enableCivarchiveApi": "CivArchive API를 메타데이터 제공자로 활성화",
|
||||
"enableCivarchiveApiHelp": "활성화하면 CivArchive API가 모델 메타데이터의 대체 소스로 사용됩니다 (예: CivitAI에서 삭제된 모델의 경우). 비활성화하면 CivArchive의 속도 제한을 완전히 피할 수 있습니다.",
|
||||
"providerOrder": "메타데이터 제공자 폴백 순서",
|
||||
"providerOrderHelp": "CivitAI API가 항상 먼저 시도됩니다. 메타데이터 조회 시 나머지 제공자의 순서를 선택하세요.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "앱 수준 프록시 활성화",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "비밀번호 (선택사항)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "AI 제공자",
|
||||
"provider": "제공자",
|
||||
"providerHelp": "LLM 제공자를 선택하세요. OpenAI와 Ollama는 사전 설정된 API 엔드포인트를 사용합니다. 사용자 정의를 선택하면 모든 OpenAI 호환 엔드포인트를 지정할 수 있습니다.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (로컬)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "사용자 정의 (OpenAI 호환)"
|
||||
},
|
||||
"apiBase": "API 기본 URL",
|
||||
"apiBaseHelp": "LLM API의 기본 URL입니다 (예: https://api.openai.com/v1). 비워두면 제공자 기본값이 사용됩니다.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API 키",
|
||||
"apiKeyHelp": "LLM 제공자의 API 키입니다. 로컬에 저장되며 선택한 LLM 제공자 외의 서버로 전송되지 않습니다.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "설정되지 않음",
|
||||
"apiKeyConfigured": "설정됨",
|
||||
"apiKeySet": "설정",
|
||||
"model": "모델",
|
||||
"modelHelp": "사용할 모델 이름 (예: deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). 제공자에서 사용 가능한 모델을 확인하세요.",
|
||||
"modelPlaceholder": "모델 선택..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "작은 순서",
|
||||
"usage": "사용 횟수",
|
||||
"usageDesc": "많은 순",
|
||||
"usageAsc": "적은 순"
|
||||
"usageAsc": "적은 순",
|
||||
"versionsCount": "로컬 버전 수",
|
||||
"versionsCountDesc": "버전 수 많은 순",
|
||||
"versionsCountAsc": "버전 수 적은 순",
|
||||
"versionIdDesc": "최신 버전순",
|
||||
"random": "랜덤",
|
||||
"randomAction": "셔플 (무작위)"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||
"deleteAll": "모든 모델 삭제",
|
||||
"setFavorite": "즐겨찾기로 설정",
|
||||
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"deleteAll": "선택된 항목 삭제",
|
||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||
"downloadExamples": "예시 이미지 다운로드",
|
||||
"downloadMissingExamples": "누락된 것만 다운로드",
|
||||
"reprocessExamples": "모두 다시 처리",
|
||||
"clear": "선택 지우기",
|
||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||
"sendToWorkflow": "워크플로우로 보내기",
|
||||
"sections": {
|
||||
"workflow": "워크플로우",
|
||||
"metadata": "메타데이터",
|
||||
"attributes": "속성",
|
||||
"organize": "정리",
|
||||
"download": "다운로드"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "자동 정리 초기화 중...",
|
||||
"starting": "{type}에 대한 자동 정리 시작...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "완료: {success}개 이동, {skipped}개 건너뜀, {failures}개 실패",
|
||||
"complete": "자동 정리 완료",
|
||||
"error": "오류: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai 데이터 새로고침",
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"relinkCivitai": "Civitai에 다시 연결",
|
||||
"linkModel": "모델 연결",
|
||||
"linkCivitai": "Civitai에 연결",
|
||||
"linkHuggingFace": "HuggingFace에 연결",
|
||||
"copySyntax": "LoRA 문법 복사",
|
||||
"copyFilename": "모델 파일명 복사",
|
||||
"copyRecipeSyntax": "레시피 문법 복사",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "워크플로로 전송 (교체)",
|
||||
"openExamples": "예시 폴더 열기",
|
||||
"downloadExamples": "예시 이미지 다운로드",
|
||||
"downloadMissingExamples": "누락된 것만 다운로드",
|
||||
"reprocessExamples": "모두 다시 처리",
|
||||
"replacePreview": "미리보기 교체",
|
||||
"setContentRating": "콘텐츠 등급 설정",
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"excludeModel": "모델 제외",
|
||||
"restoreModel": "모델 복원",
|
||||
"deleteModel": "모델 삭제",
|
||||
"shareRecipe": "레시피 공유",
|
||||
"viewAllLoras": "모든 LoRA 보기",
|
||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||
"deleteRecipe": "레시피 삭제"
|
||||
"deleteRecipe": "레시피 삭제",
|
||||
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "레시피 목록 새로고침",
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
|
||||
"failed": "레시피 복구 실패: {message}",
|
||||
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
"success": "레시피를 다시 가져왔습니다",
|
||||
"noSourceUrl": "레시피에 소스 URL이 없어 다시 가져올 수 없습니다",
|
||||
"failed": "레시피 다시 가져오기 실패: {message}",
|
||||
"missingId": "레시피를 다시 가져올 수 없음: 레시피 ID 누락"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "루트",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"pinSidebar": "사이드바 고정",
|
||||
"unpinSidebar": "사이드바 고정 해제",
|
||||
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
||||
"showSidebar": "사이드바 표시",
|
||||
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"recursiveOn": "하위 폴더 포함",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "이 폴더의 업데이트 확인",
|
||||
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
|
||||
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
|
||||
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
|
||||
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "저장소",
|
||||
"insights": "인사이트"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "모델 총계",
|
||||
"totalStorage": "총 저장 공간",
|
||||
"totalGenerations": "총 생성 횟수",
|
||||
"usageRate": "사용률",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "고유 태그",
|
||||
"unusedModels": "미사용 모델",
|
||||
"avgUsesPerModel": "모델당 평균 사용"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "가장 많이 사용된 LoRA",
|
||||
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "스마트 인사이트",
|
||||
"recommendations": "추천"
|
||||
"recommendations": "추천",
|
||||
"noInsights": "인사이트 없음",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "사용하지 않은 LoRA가 많음",
|
||||
"description": "LoRA의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "사용하지 않는 모델을 정리하거나 보관하여 저장 공간을 확보하세요."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "사용하지 않은 Checkpoint 감지",
|
||||
"description": "Checkpoint의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "더 이상 필요하지 않은 Checkpoint를 검토하고 제거하세요."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "사용하지 않은 Embedding이 많음",
|
||||
"description": "Embedding의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||
"suggestion": "사용하지 않는 Embedding을 정리하여 컬렉션을 최적화하세요."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "대규모 컬렉션 감지",
|
||||
"description": "모델 컬렉션이 {size}의 저장 공간을 사용 중입니다.",
|
||||
"suggestion": "더 나은 관리를 위해 외부 저장소나 클라우드 솔루션을 고려하세요."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "활성 사용자",
|
||||
"description": "지금까지 {count}번의 생성을 완료했습니다!",
|
||||
"suggestion": "모델로 계속해서 멋진 콘텐츠를 탐색하고 만들어보세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "컬렉션 개요",
|
||||
"baseModelDistribution": "베이스 모델 분포",
|
||||
"usageTrends": "사용량 트렌드 (최근 30일)",
|
||||
"usageDistribution": "사용량 분포"
|
||||
"usageDistribution": "사용량 분포",
|
||||
"date": "날짜",
|
||||
"usageCount": "사용 횟수",
|
||||
"fileSizeBytes": "파일 크기(바이트)",
|
||||
"models": "모델",
|
||||
"loraUsage": "LoRA 사용량",
|
||||
"checkpointUsage": "Checkpoint 사용량",
|
||||
"embeddingUsage": "Embedding 사용량"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "확산 모델",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "로딩 중...",
|
||||
"noModels": "모델을 찾을 수 없음",
|
||||
"errorLoading": "데이터 로딩 오류",
|
||||
"noStorageData": "저장 데이터 없음",
|
||||
"rootFolder": "루트",
|
||||
"chartLibraryMissing": "Chart.js 라이브러리가 필요합니다"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count}개 모델",
|
||||
"chartUsage": "{name}: {size}, {count}회 사용",
|
||||
"chartPercentage": "{label}: {value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "URL에서 모델 다운로드",
|
||||
"titleWithType": "URL에서 {type} 다운로드",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
|
||||
"selectAll": "모두 선택",
|
||||
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
|
||||
"locationPreview": "다운로드 위치 미리보기",
|
||||
"useDefaultPath": "기본 경로 사용",
|
||||
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||
"fileSelection": {
|
||||
"title": "파일 형식 선택",
|
||||
"files": "개 파일",
|
||||
"select": "파일 선택"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
|
||||
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
|
||||
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "다운로드 준비 중...",
|
||||
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
||||
"downloadingFile": "{type} 파일 다운로드 중",
|
||||
"finalizing": "다운로드 완료 중..."
|
||||
"finalizing": "다운로드 완료 중...",
|
||||
"cancelling": "다운로드 취소 중...",
|
||||
"cancelled": "다운로드가 취소되었습니다"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "현재 파일:",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||
"action": "모두 삭제"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "여러 레시피 삭제",
|
||||
"message": "선택된 모든 레시피와 관련 파일을 삭제하시겠습니까?",
|
||||
"countMessage": "개의 레시피가 영구적으로 삭제됩니다.",
|
||||
"action": "모두 삭제"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
|
||||
"root": "루트"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "HuggingFace에 연결",
|
||||
"infoText": "HuggingFace 저장소 URL을 붙여넣어 모델을 연결합니다. AI 메타데이터 보강 기능을 사용할 수 있습니다.",
|
||||
"urlLabel": "HuggingFace 저장소 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "전체 HuggingFace 저장소 URL을 입력하세요.",
|
||||
"confirmAction": "저장 및 연결"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Civitai에 다시 연결",
|
||||
"warning": "경고:",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "모델명 편집",
|
||||
"editFileName": "파일명 편집",
|
||||
"editBaseModel": "베이스 모델 편집",
|
||||
"editVersionName": "버전명 편집",
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"viewOnCivitaiText": "Civitai에서 보기",
|
||||
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "추가 메모",
|
||||
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
|
||||
"addNotesPlaceholder": "메모를 여기에 추가하세요...",
|
||||
"aboutThisVersion": "이 버전에 대해"
|
||||
"aboutThisVersion": "이 버전에 대해",
|
||||
"baseModelSearchPlaceholder": "베이스 모델 검색…",
|
||||
"baseModelSuggested": "추천",
|
||||
"baseModelNoMatch": "일치하는 베이스 모델 없음"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "메모가 성공적으로 저장됨",
|
||||
"saveFailed": "메모 저장 실패"
|
||||
"saveFailed": "메모 저장 실패",
|
||||
"showMore": "더 보기",
|
||||
"showLess": "접기"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "프리셋 매개변수 추가...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "편집 취소",
|
||||
"save": "변경사항 저장",
|
||||
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
||||
"editWord": "트리거 단어 편집",
|
||||
"editPlaceholder": "트리거 단어 편집",
|
||||
"copyWord": "트리거 단어 복사",
|
||||
"deleteWord": "트리거 단어 삭제",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||
"ignored": "무시됨",
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||
"onSiteOnly": "사이트 내 전용",
|
||||
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"downloadTooltip": "이 버전 다운로드",
|
||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||
"delete": "삭제",
|
||||
"deleteTooltip": "이 로컬 버전 삭제",
|
||||
"ignore": "무시",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||
"error": "버전을 불러오지 못했습니다.",
|
||||
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
||||
"hfGroupInfo": "HuggingFace 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
|
||||
"confirm": {
|
||||
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "버전이 삭제되었습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "메타데이터 가져오기 요약",
|
||||
"statSuccess": "성공",
|
||||
"statFailed": "실패",
|
||||
"statSkipped": "건너뜀",
|
||||
"statTotal": "총 스캔",
|
||||
"statDuration": "소요 시간",
|
||||
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
|
||||
"failedItems": "실패한 항목 ({count})",
|
||||
"close": "닫기",
|
||||
"copyReport": "보고서 복사",
|
||||
"downloadCsv": "CSV 다운로드",
|
||||
"columnModelName": "모델 이름",
|
||||
"columnError": "오류"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "이 태그는 이미 존재합니다"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "키보드 내비게이션:",
|
||||
"shortcuts": {
|
||||
"pageUp": "한 페이지 위로 스크롤",
|
||||
"pageDown": "한 페이지 아래로 스크롤",
|
||||
"home": "맨 위로 이동",
|
||||
"end": "맨 아래로 이동"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "초기화 중",
|
||||
"message": "작업공간을 준비하고 있습니다...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||
"modelFailed": "모델 노드 업데이트 실패"
|
||||
"modelFailed": "모델 노드 업데이트 실패",
|
||||
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
|
||||
"embeddingFailed": "Embedding 추가 실패",
|
||||
"promptSent": "프롬프트를 워크플로에 보냈습니다",
|
||||
"promptFailed": "프롬프트 보내기 실패"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
"lora": "LoRA",
|
||||
"embedding": "임베딩",
|
||||
"prompt": "프롬프트",
|
||||
"replace": "교체",
|
||||
"append": "추가",
|
||||
"selectTargetNode": "대상 노드 선택",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "예시 이미지 폴더가 열렸습니다",
|
||||
"openingFolder": "예시 이미지 폴더를 여는 중",
|
||||
"failedToOpen": "예시 이미지 폴더 열기 실패",
|
||||
"copiedPath": "경로를 클립보드에 복사했습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}",
|
||||
"copiedUri": "링크를 클립보드에 복사했습니다: {{uri}}",
|
||||
"uriClipboardFallback": "링크: {{uri}}",
|
||||
"setupRequired": "예시 이미지 저장소",
|
||||
"setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.",
|
||||
"setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.",
|
||||
@@ -1460,6 +1774,12 @@
|
||||
"checkingMessage": "최신 버전을 확인하는 동안 잠시 기다려주세요.",
|
||||
"showNotifications": "업데이트 알림 표시",
|
||||
"latestBadge": "최신",
|
||||
"latestMain": "Main 브랜치",
|
||||
"channel": "업데이트 채널",
|
||||
"channels": {
|
||||
"release": "릴리스",
|
||||
"nightly": "나이틀리"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "업데이트 준비 중...",
|
||||
"installing": "업데이트 설치 중...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
||||
"enable": "나이틀리 업데이트 활성화"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "나이틀리 채널로 전환",
|
||||
"nightlyMessage": "나이틀리로 전환하면 Git 저장소가 초기화되고 main 브랜치의 최신 커밋을 추적합니다. 업데이트 빈도는 높지만 불안정할 수 있습니다. 언제든지 릴리스로 돌아갈 수 있습니다.",
|
||||
"releaseTitle": "릴리스 채널로 전환",
|
||||
"releaseMessage": "릴리스로 전환하면 최신 안정 버전 태그로 체크아웃됩니다. 언제든지 나이틀리로 돌아갈 수 있습니다.",
|
||||
"switching": "{channel} 채널로 전환 중...",
|
||||
"completed": "{channel} 채널로 전환 완료",
|
||||
"failed": "채널 전환 실패"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "최근 알림",
|
||||
"empty": "최근 배너가 없습니다.",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||
"createError": "레시피 생성 중 오류 발생:{message}",
|
||||
"createFailed": "레시피 생성 실패:{error}",
|
||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||
"created": "레시피가 생성되었습니다",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
||||
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
|
||||
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
|
||||
"reconnectFailed": "LoRA 다시 연결 오류: {message}",
|
||||
"noPromptToSend": "보낼 프롬프트가 없습니다",
|
||||
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
||||
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
||||
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
|
||||
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
|
||||
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
|
||||
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
|
||||
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
|
||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
|
||||
"imagesFailed": "예시 이미지 {action}이(가) 실패했습니다",
|
||||
"loadError": "다운로드 로딩 오류: {message}",
|
||||
"downloadError": "다운로드 오류: {message}"
|
||||
"downloadError": "다운로드 오류: {message}",
|
||||
"downloadStopped": "다운로드가 취소되었습니다"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "학습된 단어를 로딩할 수 없습니다",
|
||||
"tooLong": "트리거 단어는 100단어를 초과할 수 없습니다",
|
||||
"tooMany": "최대 30개의 트리거 단어만 허용됩니다",
|
||||
"tooLong": "트리거 단어는 500단어를 초과할 수 없습니다",
|
||||
"tooMany": "최대 100개의 트리거 단어만 허용됩니다",
|
||||
"alreadyExists": "이 트리거 단어는 이미 존재합니다",
|
||||
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
|
||||
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "콘텐츠 등급 설정 실패: {message}",
|
||||
"relinkSuccess": "모델이 Civitai에 성공적으로 다시 연결되었습니다",
|
||||
"relinkFailed": "오류: {message}",
|
||||
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
|
||||
"linkHfFailed": "오류: {message}",
|
||||
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
||||
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
||||
"missingHash": "모델 해시를 사용할 수 없습니다"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "{type} 삭제 실패: {message}",
|
||||
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
|
||||
"excludeFailed": "{type} 제외 실패: {message}",
|
||||
"restoreSuccess": "{type} 복원 완료",
|
||||
"restoreFailed": "{type} 복원 실패: {message}",
|
||||
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
|
||||
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
||||
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "클립보드에 복사됨",
|
||||
"downloadStarted": "다운로드 시작됨"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 제공자가 설정되지 않았습니다. 설정 → AI 제공자에서 활성화하세요.",
|
||||
"enrichStarted": "AI로 메타데이터 보강 중...",
|
||||
"enrichComplete": "메타데이터 보강 완료: {{summary}}",
|
||||
"enrichFailed": "메타데이터 보강 실패: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "주의 필요",
|
||||
"error": "조치 필요"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 키"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "모델 캐시 상태"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "파일명 중복 충돌"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 버전"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "다시 실행",
|
||||
"exportBundle": "번들 내보내기"
|
||||
"exportBundle": "번들 내보내기",
|
||||
"open-settings": "설정 열기",
|
||||
"open-settings-syntax-format": "전체 경로 구문으로 전환",
|
||||
"repair-cache": "캐시 재구축",
|
||||
"resolve-filename-conflicts": "충돌 해결",
|
||||
"reload-page": "UI 새로고침"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "충돌",
|
||||
"version": "버전"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "진단 로드 실패: {message}",
|
||||
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||
"repairFailed": "캐시 재구성 실패: {message}",
|
||||
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
||||
"exportFailed": "진단 번들 내보내기 실패: {message}",
|
||||
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "애플리케이션 업데이트 감지",
|
||||
|
||||
497
locales/ru.json
497
locales/ru.json
@@ -15,10 +15,14 @@
|
||||
"settings": "Настройки",
|
||||
"help": "Справка",
|
||||
"add": "Добавить",
|
||||
"close": "Закрыть"
|
||||
"close": "Закрыть",
|
||||
"menu": "Меню",
|
||||
"remove": "Удалить",
|
||||
"change": "Изменить"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
"cancelling": "Отмена...",
|
||||
"unknown": "Неизвестно",
|
||||
"date": "Дата",
|
||||
"version": "Версия",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "Удалить из избранного",
|
||||
"viewOnCivitai": "Посмотреть на Civitai",
|
||||
"notAvailableFromCivitai": "Недоступно на Civitai",
|
||||
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
|
||||
"copyLoRASyntax": "Копировать синтаксис LoRA",
|
||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "Заменить превью",
|
||||
"copyCheckpointName": "Копировать имя checkpoint",
|
||||
"copyEmbeddingName": "Копировать имя embedding",
|
||||
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Количество использований"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} версий",
|
||||
"viewAllVersions": "Показать все локальные версии"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "Успешно восстановлено {count} рецептов.",
|
||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||
"error": "Ошибка восстановления рецептов: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Группировать по модели"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "Статистика"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск...",
|
||||
"placeholders": {
|
||||
"loras": "Поиск LoRAs...",
|
||||
"recipes": "Поиск рецептов...",
|
||||
"checkpoints": "Поиск checkpoints...",
|
||||
"embeddings": "Поиск embeddings..."
|
||||
},
|
||||
"placeholder": "Поиск",
|
||||
"options": "Опции поиска",
|
||||
"searchIn": "Искать в:",
|
||||
"notAvailable": "Поиск недоступен на странице статистики",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?",
|
||||
"presetNamePlaceholder": "Имя пресета...",
|
||||
"baseModel": "Базовая модель",
|
||||
"modelTags": "Теги (Топ 20)",
|
||||
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
|
||||
"modelTags": "Теги",
|
||||
"modelTypes": "Типы моделей",
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||
"noTags": "Без тегов",
|
||||
"tagSearchPlaceholder": "Поиск тегов...",
|
||||
"noTagMatches": "Нет тегов, соответствующих текущему поиску.",
|
||||
"autoTags": "Авто-теги",
|
||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||
"clearAll": "Очистить все фильтры",
|
||||
"any": "Любой",
|
||||
"all": "Все",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "Переключить тему",
|
||||
"switchToLight": "Переключить на светлую тему",
|
||||
"switchToDark": "Переключить на тёмную тему",
|
||||
"switchToAuto": "Переключить на автоматическую тему"
|
||||
"switchToAuto": "Переключить на автоматическую тему",
|
||||
"presets": "Предустановки тем",
|
||||
"default": "По умолчанию",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "Режим",
|
||||
"light": "Светлый",
|
||||
"dark": "Тёмный",
|
||||
"auto": "Авто"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Проверить обновления",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"civitaiApiKeyConfigured": "Настроен",
|
||||
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||
"civitaiApiKeySet": "Настроить",
|
||||
"civitaiHost": {
|
||||
"label": "Хост Civitai",
|
||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||
"options": {
|
||||
"com": "civitai.com (только SFW)",
|
||||
"red": "civitai.red (без ограничений)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "Бэкенд загрузки",
|
||||
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует рекомендуемый внешний процесс загрузки.",
|
||||
"options": {
|
||||
"python": "Python (встроенный)",
|
||||
"aria2": "aria2 (рекомендуемый)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "Путь к aria2c",
|
||||
"help": "Необязательный путь к исполняемому файлу aria2c. Оставьте пустым, чтобы использовать aria2c из системного PATH.",
|
||||
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
|
||||
},
|
||||
"aria2HelpLink": "Узнайте, как настроить сервер загрузки aria2",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Доступна настройка хоста Civitai",
|
||||
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
|
||||
"openSettings": "Открыть настройки"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Открыть папку настроек",
|
||||
"tooltip": "Открыть папку, содержащую settings.json",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Фильтрация контента",
|
||||
"downloads": "Загрузки",
|
||||
"videoSettings": "Настройки видео",
|
||||
"layoutSettings": "Настройки макета",
|
||||
"licenseIcons": "Значки лицензии",
|
||||
"misc": "Разное",
|
||||
"backup": "Резервные копии",
|
||||
"folderSettings": "Корневые папки",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||
"priorityTags": "Приоритетные теги",
|
||||
"updateFlags": "Метки обновлений",
|
||||
"versionScope": "Метки обновлений",
|
||||
"exampleImages": "Примеры изображений",
|
||||
"autoOrganize": "Автоорганизация",
|
||||
"metadata": "Метаданные",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Группировать по модели",
|
||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
"default": "По умолчанию",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "Показать при наведении"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||
"showVersionOnCard": "Показывать версию на карточке",
|
||||
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
|
||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Открыть примеры изображений",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "Название модели",
|
||||
"fileName": "Имя файла"
|
||||
},
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели",
|
||||
"cardBlurAmount": "Размытие наложения карточек",
|
||||
"cardBlurAmountHelp": "Настройте интенсивность размытия наложений верхнего и нижнего колонтитулов на карточках моделей и рецептов (0 = без размытия, 20 = максимальное размытие)."
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Активная библиотека",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Этот путь уже настроен"
|
||||
"duplicatePath": "Этот путь уже настроен",
|
||||
"checkpointUnetOverlap": "Нельзя использовать один и тот же путь для checkpoints и diffusion models: {paths}",
|
||||
"checkpointUnetOverlapInline": "Этот путь уже используется для другого типа модели. Используйте отдельные папки для checkpoints и diffusion models."
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,12 +574,27 @@
|
||||
"downloadLocationHelp": "Введите путь к папке, где будут сохраняться примеры изображений с Civitai",
|
||||
"autoDownload": "Автозагрузка примеров изображений",
|
||||
"autoDownloadHelp": "Автоматически загружать примеры изображений для моделей, у которых их нет (требует настройки места загрузки)",
|
||||
"openMode": "Действие открытия примеров изображений",
|
||||
"openModeHelp": "Выберите, будет ли действие открывать папку на сервере, копировать сопоставленный локальный путь или запускать пользовательский URI.",
|
||||
"openModeOptions": {
|
||||
"system": "Открыть на сервере",
|
||||
"clipboard": "Скопировать локальный путь",
|
||||
"uriTemplate": "Открыть пользовательский URI"
|
||||
},
|
||||
"localRoot": "Локальный корень примеров изображений",
|
||||
"localRootHelp": "Необязательный локальный или смонтированный корневой путь, отражающий каталог примеров изображений на сервере. Если оставить пустым, будет использован путь сервера.",
|
||||
"localRootPlaceholder": "Пример: /Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "Шаблон URI для открытия",
|
||||
"uriTemplateHelp": "Используйте пользовательскую deep link-ссылку, например file URI или ссылку Shortcuts.",
|
||||
"uriTemplatePlaceholder": "Пример: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "Доступные плейсхолдеры: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
|
||||
"openModeWikiLink": "Подробнее об удаленных режимах открытия",
|
||||
"optimizeImages": "Оптимизировать загруженные изображения",
|
||||
"optimizeImagesHelp": "Оптимизировать примеры изображений для уменьшения размера файла и улучшения скорости загрузки (метаданные будут сохранены)",
|
||||
"download": "Загрузить",
|
||||
"restartRequired": "Требует перезапуска"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Стратегия меток обновлений",
|
||||
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
|
||||
"options": {
|
||||
@@ -523,9 +606,19 @@
|
||||
"label": "Скрыть обновления раннего доступа",
|
||||
"help": "Только обновления раннего доступа"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Использовать обновлённые значки лицензии",
|
||||
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "Включить архив метаданных",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "Подготовка к загрузке...",
|
||||
"connecting": "Подключение к серверу загрузки...",
|
||||
"completed": "Завершено",
|
||||
"downloadComplete": "Загрузка успешно завершена"
|
||||
"downloadComplete": "Загрузка успешно завершена",
|
||||
"enableCivarchiveApi": "Включить CivArchive API как источник метаданных",
|
||||
"enableCivarchiveApiHelp": "При включении CivArchive API используется как резервный источник метаданных моделей (например, для моделей, удалённых с CivitAI). Отключите, чтобы полностью избежать ограничений скорости CivArchive.",
|
||||
"providerOrder": "Порядок резервных источников метаданных",
|
||||
"providerOrderHelp": "CivitAI API всегда проверяется первым. Выберите порядок остальных источников при поиске метаданных.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Включить прокси на уровне приложения",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "Пароль (необязательно)",
|
||||
"proxyPasswordPlaceholder": "пароль",
|
||||
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "Поставщик ИИ",
|
||||
"provider": "Поставщик",
|
||||
"providerHelp": "Выберите поставщика LLM. OpenAI и Ollama используют предустановленные API-эндпоинты. Пользовательский позволяет указать любой совместимый с OpenAI эндпоинт.",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama (локальный)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "Пользовательский (совместимый с OpenAI)"
|
||||
},
|
||||
"apiBase": "Базовый URL API",
|
||||
"apiBaseHelp": "Базовый URL для LLM API (например, https://api.openai.com/v1). Оставьте пустым, чтобы использовать значение по умолчанию.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API-ключ",
|
||||
"apiKeyHelp": "Ваш API-ключ поставщика LLM. Хранится локально и никогда не отправляется на другие серверы, кроме выбранного поставщика LLM.",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "Не задан",
|
||||
"apiKeyConfigured": "Настроен",
|
||||
"apiKeySet": "Настроить",
|
||||
"model": "Модель",
|
||||
"modelHelp": "Имя модели для использования (например, deepseek-v4-flash, gemini-2.5-flash, gemma4:12b). Проверьте доступные модели у вашего поставщика.",
|
||||
"modelPlaceholder": "Выберите модель..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "Наименьшим",
|
||||
"usage": "Число использований",
|
||||
"usageDesc": "Больше",
|
||||
"usageAsc": "Меньше"
|
||||
"usageAsc": "Меньше",
|
||||
"versionsCount": "Локальные версии",
|
||||
"versionsCountDesc": "Сначала больше версий",
|
||||
"versionsCountAsc": "Сначала меньше версий",
|
||||
"versionIdDesc": "Сначала новые версии",
|
||||
"random": "Случайно",
|
||||
"randomAction": "Перемешать"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "Установить рейтинг контента для всех",
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||
"deleteAll": "Удалить все модели",
|
||||
"setFavorite": "Добавить в избранное",
|
||||
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
|
||||
"unfavorite": "Удалить из избранного",
|
||||
"deleteAll": "Удалить выбранные",
|
||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||
"downloadExamples": "Загрузить примеры изображений",
|
||||
"downloadMissingExamples": "Скачать недостающие",
|
||||
"reprocessExamples": "Обработать всё заново",
|
||||
"clear": "Очистить выбор",
|
||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||
"sendToWorkflow": "Отправить в Workflow",
|
||||
"sections": {
|
||||
"workflow": "Workflow",
|
||||
"metadata": "Метаданные",
|
||||
"attributes": "Атрибуты",
|
||||
"organize": "Организовать",
|
||||
"download": "Скачать"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Инициализация автоматической организации...",
|
||||
"starting": "Запуск автоматической организации для {type}...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "Завершено: {success} перемещено, {skipped} пропущено, {failures} не удалось",
|
||||
"complete": "Автоматическая организация завершена",
|
||||
"error": "Ошибка: {error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Обновить данные Civitai",
|
||||
"checkUpdates": "Проверить обновления",
|
||||
"relinkCivitai": "Пересвязать с Civitai",
|
||||
"linkModel": "Связать модель",
|
||||
"linkCivitai": "Пересвязать с Civitai",
|
||||
"linkHuggingFace": "Связать с HuggingFace",
|
||||
"copySyntax": "Копировать синтаксис LoRA",
|
||||
"copyFilename": "Копировать имя файла модели",
|
||||
"copyRecipeSyntax": "Копировать синтаксис рецепта",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "Отправить в Workflow (Заменить)",
|
||||
"openExamples": "Открыть папку примеров",
|
||||
"downloadExamples": "Загрузить примеры изображений",
|
||||
"downloadMissingExamples": "Скачать недостающие",
|
||||
"reprocessExamples": "Обработать всё заново",
|
||||
"replacePreview": "Заменить превью",
|
||||
"setContentRating": "Установить рейтинг контента",
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"excludeModel": "Исключить модель",
|
||||
"restoreModel": "Восстановить модель",
|
||||
"deleteModel": "Удалить модель",
|
||||
"shareRecipe": "Поделиться рецептом",
|
||||
"viewAllLoras": "Посмотреть все LoRAs",
|
||||
"downloadMissingLoras": "Загрузить отсутствующие LoRAs",
|
||||
"deleteRecipe": "Удалить рецепт"
|
||||
"deleteRecipe": "Удалить рецепт",
|
||||
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список рецептов",
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "Рецепт уже последней версии, восстановление не требуется",
|
||||
"failed": "Не удалось восстановить рецепт: {message}",
|
||||
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Переимпорт рецепта из источника...",
|
||||
"success": "Рецепт успешно переимпортирован",
|
||||
"noSourceUrl": "У рецепта нет URL источника, переимпорт невозможен",
|
||||
"failed": "Не удалось переимпортировать рецепт: {message}",
|
||||
"missingId": "Невозможно переимпортировать рецепт: отсутствует ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"pinSidebar": "Закрепить боковую панель",
|
||||
"unpinSidebar": "Открепить боковую панель",
|
||||
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
||||
"showSidebar": "Показать боковую панель",
|
||||
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"recursiveOn": "Включать вложенные папки",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "Папки не найдены",
|
||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Проверить обновления в этой папке",
|
||||
"loading": "Проверка обновлений {type} в этой папке...",
|
||||
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
|
||||
"none": "Все {type}s в этой папке актуальны",
|
||||
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "Хранение",
|
||||
"insights": "Аналитика"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "Всего моделей",
|
||||
"totalStorage": "Всего хранилища",
|
||||
"totalGenerations": "Всего генераций",
|
||||
"usageRate": "Коэффициент использования",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Контрольные точки",
|
||||
"embeddings": "Эмбеддинги",
|
||||
"uniqueTags": "Уникальные теги",
|
||||
"unusedModels": "Неиспользуемые модели",
|
||||
"avgUsesPerModel": "Сред. использований/модель"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "Наиболее используемые LoRAs",
|
||||
"mostUsedCheckpoints": "Наиболее используемые Checkpoints",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "Умная аналитика",
|
||||
"recommendations": "Рекомендации"
|
||||
"recommendations": "Рекомендации",
|
||||
"noInsights": "Нет доступных данных",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "Большое количество неиспользуемых LoRA",
|
||||
"description": "{percent}% ваших LoRA ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Рассмотрите возможность организации или архивирования неиспользуемых моделей для освобождения места."
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "Обнаружены неиспользуемые контрольные точки",
|
||||
"description": "{percent}% ваших контрольных точек ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Проверьте и удалите ненужные контрольные точки."
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "Большое количество неиспользуемых эмбеддингов",
|
||||
"description": "{percent}% ваших эмбеддингов ({count}/{total}) никогда не использовались.",
|
||||
"suggestion": "Организуйте или архивируйте неиспользуемые эмбеддинги для оптимизации коллекции."
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "Обнаружена большая коллекция",
|
||||
"description": "Ваша коллекция моделей использует {size} хранилища.",
|
||||
"suggestion": "Рассмотрите внешнее хранилище или облачные решения для лучшей организации."
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "Активный пользователь",
|
||||
"description": "Вы завершили {count} генераций!",
|
||||
"suggestion": "Продолжайте исследовать и создавать удивительный контент с вашими моделями."
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "Обзор коллекции",
|
||||
"baseModelDistribution": "Распределение базовых моделей",
|
||||
"usageTrends": "Тенденции использования (за последние 30 дней)",
|
||||
"usageDistribution": "Распределение использования"
|
||||
"usageDistribution": "Распределение использования",
|
||||
"date": "Дата",
|
||||
"usageCount": "Количество использований",
|
||||
"fileSizeBytes": "Размер файла (байты)",
|
||||
"models": "Модели",
|
||||
"loraUsage": "Использование LoRA",
|
||||
"checkpointUsage": "Использование Checkpoint",
|
||||
"embeddingUsage": "Использование Embedding"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Контрольная точка",
|
||||
"diffusion_model": "Диффузионная модель",
|
||||
"embedding": "Эмбеддинги"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "Загрузка...",
|
||||
"noModels": "Модели не найдены",
|
||||
"errorLoading": "Ошибка загрузки данных",
|
||||
"noStorageData": "Нет данных о хранилище",
|
||||
"rootFolder": "Корень",
|
||||
"chartLibraryMissing": "Для графика требуется библиотека Chart.js"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}: {count} моделей",
|
||||
"chartUsage": "{name}: {size}, {count} использований",
|
||||
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "Скачать модель по URL",
|
||||
"titleWithType": "Скачать {type} по URL",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
|
||||
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
|
||||
"selectAll": "Выбрать все",
|
||||
"fetchingRepoFiles": "Получение файлов репозитория...",
|
||||
"locationPreview": "Предпросмотр места загрузки",
|
||||
"useDefaultPath": "Использовать путь по умолчанию",
|
||||
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||
"alreadyInLibrary": "Уже в библиотеке",
|
||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||
"fileSelection": {
|
||||
"title": "Выбрать формат файла",
|
||||
"files": "файлов",
|
||||
"select": "Выбрать файл"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Неверный формат URL Civitai",
|
||||
"noVersions": "Нет доступных версий для этой модели"
|
||||
"noVersions": "Нет доступных версий для этой модели",
|
||||
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
|
||||
"noModelFiles": "В этом репозитории не найдено файлов моделей."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Подготовка загрузки...",
|
||||
"downloadedPreview": "Превью изображение загружено",
|
||||
"downloadingFile": "Загрузка файла {type}",
|
||||
"finalizing": "Завершение загрузки..."
|
||||
"finalizing": "Завершение загрузки...",
|
||||
"cancelling": "Отмена загрузки...",
|
||||
"cancelled": "Загрузка отменена"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Текущий файл:",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "моделей будут удалены навсегда.",
|
||||
"action": "Удалить все"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "Удалить несколько рецептов",
|
||||
"message": "Вы уверены, что хотите удалить все выбранные рецепты и связанные с ними файлы?",
|
||||
"countMessage": "рецептов будут удалены навсегда.",
|
||||
"action": "Удалить все"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Проверить обновления для всех {typePlural}?",
|
||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
|
||||
"root": "Корень"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Связать с HuggingFace",
|
||||
"infoText": "Вставьте URL репозитория HuggingFace, чтобы связать эту модель с её источником. Это позволит обогащать метаданные с помощью ИИ.",
|
||||
"urlLabel": "URL репозитория HuggingFace:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Введите полный URL репозитория HuggingFace.",
|
||||
"confirmAction": "Сохранить и связать"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Пересвязать с Civitai",
|
||||
"warning": "Предупреждение:",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "Редактировать название модели",
|
||||
"editFileName": "Редактировать имя файла",
|
||||
"editBaseModel": "Редактировать базовую модель",
|
||||
"editVersionName": "Редактировать название версии",
|
||||
"viewOnCivitai": "Посмотреть на Civitai",
|
||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||
"viewOnHuggingFaceText": "Открыть Hugging Face",
|
||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "Дополнительные заметки",
|
||||
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
||||
"addNotesPlaceholder": "Добавьте ваши заметки здесь...",
|
||||
"aboutThisVersion": "Об этой версии"
|
||||
"aboutThisVersion": "Об этой версии",
|
||||
"baseModelSearchPlaceholder": "Поиск базовой модели…",
|
||||
"baseModelSuggested": "Предполагаемые",
|
||||
"baseModelNoMatch": "Нет подходящих базовых моделей"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "Заметки успешно сохранены",
|
||||
"saveFailed": "Не удалось сохранить заметки"
|
||||
"saveFailed": "Не удалось сохранить заметки",
|
||||
"showMore": "Показать больше",
|
||||
"showLess": "Свернуть"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "Добавить предустановленный параметр...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "Отменить редактирование",
|
||||
"save": "Сохранить изменения",
|
||||
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
||||
"editWord": "Редактировать триггерное слово",
|
||||
"editPlaceholder": "Редактировать триггерное слово",
|
||||
"copyWord": "Копировать триггерное слово",
|
||||
"deleteWord": "Удалить триггерное слово",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||
"ignored": "Игнорируется",
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||
"onSiteOnly": "Только на Сайте",
|
||||
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"downloadTooltip": "Скачать эту версию",
|
||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||
"delete": "Удалить",
|
||||
"deleteTooltip": "Удалить эту локальную версию",
|
||||
"ignore": "Игнорировать",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "Для этой модели пока нет истории версий.",
|
||||
"error": "Не удалось загрузить версии.",
|
||||
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
||||
"hfGroupInfo": "Это группа моделей HuggingFace. Откройте библиотеку, чтобы увидеть все версии в сетке.",
|
||||
"confirm": {
|
||||
"delete": "Удалить эту версию из библиотеки?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "Версия удалена"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "Сводка получения метаданных",
|
||||
"statSuccess": "Успешно",
|
||||
"statFailed": "Ошибка",
|
||||
"statSkipped": "Пропущено",
|
||||
"statTotal": "Всего проверено",
|
||||
"statDuration": "Длительность",
|
||||
"successMessage": "Все {count} {type}s успешно обновлены",
|
||||
"failedItems": "Ошибочные элементы ({count})",
|
||||
"close": "Закрыть",
|
||||
"copyReport": "Копировать отчет",
|
||||
"downloadCsv": "Скачать CSV",
|
||||
"columnModelName": "Имя модели",
|
||||
"columnError": "Ошибка"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "Этот тег уже существует"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "Навигация с клавиатуры:",
|
||||
"shortcuts": {
|
||||
"pageUp": "Прокрутить на страницу вверх",
|
||||
"pageDown": "Прокрутить на страницу вниз",
|
||||
"home": "Перейти к началу",
|
||||
"end": "Перейти к концу"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "Инициализация",
|
||||
"message": "Подготовка вашего рабочего пространства...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||
"noTargetNodeSelected": "Целевой узел не выбран",
|
||||
"modelUpdated": "Модель обновлена в workflow",
|
||||
"modelFailed": "Не удалось обновить узел модели"
|
||||
"modelFailed": "Не удалось обновить узел модели",
|
||||
"embeddingAdded": "Embedding добавлен в workflow",
|
||||
"embeddingFailed": "Не удалось добавить embedding",
|
||||
"promptSent": "Запрос отправлен в workflow",
|
||||
"promptFailed": "Не удалось отправить запрос"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Эмбеддинг",
|
||||
"prompt": "Запрос",
|
||||
"replace": "Заменить",
|
||||
"append": "Добавить",
|
||||
"selectTargetNode": "Выберите целевой узел",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "Папка с примерами изображений открыта",
|
||||
"openingFolder": "Открытие папки с примерами изображений",
|
||||
"failedToOpen": "Не удалось открыть папку с примерами изображений",
|
||||
"copiedPath": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}",
|
||||
"copiedUri": "Ссылка скопирована в буфер обмена: {{uri}}",
|
||||
"uriClipboardFallback": "Ссылка: {{uri}}",
|
||||
"setupRequired": "Хранилище примеров изображений",
|
||||
"setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.",
|
||||
"setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.",
|
||||
@@ -1459,7 +1773,13 @@
|
||||
"checkingUpdates": "Проверка обновлений...",
|
||||
"checkingMessage": "Пожалуйста, подождите, пока мы проверяем последнюю версию.",
|
||||
"showNotifications": "Показывать уведомления об обновлениях",
|
||||
"latestBadge": "Последний",
|
||||
"latestBadge": "Последняя",
|
||||
"latestMain": "Ветка main",
|
||||
"channel": "Канал обновлений",
|
||||
"channels": {
|
||||
"release": "Релиз",
|
||||
"nightly": "Nightly"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "Подготовка обновления...",
|
||||
"installing": "Установка обновления...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
||||
"enable": "Включить ночные обновления"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "Переключиться на Nightly",
|
||||
"nightlyMessage": "Переключение на Nightly инициализирует Git-репозиторий и отслеживает последние коммиты ветки main. Обновления чаще, но могут быть нестабильными. Вы можете вернуться к Release в любое время.",
|
||||
"releaseTitle": "Переключиться на Release",
|
||||
"releaseMessage": "Переключение на Release выполнит checkout последнего стабильного тега. Вы можете вернуться к Nightly в любое время.",
|
||||
"switching": "Переключение на канал {channel}...",
|
||||
"completed": "Успешно переключено на канал {channel}",
|
||||
"failed": "Не удалось переключить канал"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Недавние уведомления",
|
||||
"empty": "Недавних баннеров нет.",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "ID рецепта недоступен",
|
||||
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
||||
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
||||
"createError": "Ошибка при создании рецепта:{message}",
|
||||
"createFailed": "Не удалось создать рецепт:{error}",
|
||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||
"created": "Рецепт успешно создан",
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
||||
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
|
||||
"reconnectedSuccessfully": "LoRA успешно переподключена",
|
||||
"reconnectFailed": "Ошибка переподключения LoRA: {message}",
|
||||
"noPromptToSend": "Нет запроса для отправки",
|
||||
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
|
||||
"sendFailed": "Не удалось отправить рецепт в workflow",
|
||||
"sendError": "Ошибка отправки рецепта в workflow",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "Рецепты не выбраны",
|
||||
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||
"reimporting": "Переимпорт рецепта из источника...",
|
||||
"reimportSuccess": "Рецепт успешно переимпортирован",
|
||||
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
||||
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
|
||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
|
||||
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
|
||||
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
|
||||
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
|
||||
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
|
||||
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "Примеры изображений {action} завершены",
|
||||
"imagesFailed": "Примеры изображений {action} не удались",
|
||||
"loadError": "Ошибка загрузки downloads: {message}",
|
||||
"downloadError": "Ошибка загрузки: {message}"
|
||||
"downloadError": "Ошибка загрузки: {message}",
|
||||
"downloadStopped": "Загрузка отменена"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "Не удалось загрузить обученные слова",
|
||||
"tooLong": "Триггерное слово не должно превышать 100 слов",
|
||||
"tooMany": "Максимум 30 триггерных слов разрешено",
|
||||
"tooLong": "Триггерное слово не должно превышать 500 слов",
|
||||
"tooMany": "Максимум 100 триггерных слов разрешено",
|
||||
"alreadyExists": "Это триггерное слово уже существует",
|
||||
"updateSuccess": "Триггерные слова успешно обновлены",
|
||||
"updateFailed": "Не удалось обновить триггерные слова",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "Не удалось установить рейтинг контента: {message}",
|
||||
"relinkSuccess": "Модель успешно пересвязана с Civitai",
|
||||
"relinkFailed": "Ошибка: {message}",
|
||||
"linkHfSuccess": "Модель успешно связана с HuggingFace",
|
||||
"linkHfFailed": "Ошибка: {message}",
|
||||
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
|
||||
"noCivitaiInfo": "Информация CivitAI недоступна",
|
||||
"missingHash": "Хеш модели недоступен"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "Не удалось удалить {type}: {message}",
|
||||
"excludeSuccess": "{type} успешно исключен",
|
||||
"excludeFailed": "Не удалось исключить {type}: {message}",
|
||||
"restoreSuccess": "{type} успешно восстановлен",
|
||||
"restoreFailed": "Не удалось восстановить {type}: {message}",
|
||||
"fileNameUpdated": "Имя файла успешно обновлено",
|
||||
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
||||
"previewUpdated": "Превью успешно обновлено",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "Скопировано в буфер обмена",
|
||||
"downloadStarted": "Загрузка начата"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "Поставщик ИИ не настроен. Включите его в Настройки → Поставщик ИИ.",
|
||||
"enrichStarted": "Обогащение метаданных с помощью ИИ...",
|
||||
"enrichComplete": "Обогащение метаданных завершено: {{summary}}",
|
||||
"enrichFailed": "Ошибка обогащения метаданных: {{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "Требует внимания",
|
||||
"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": {
|
||||
"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": {
|
||||
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
||||
"repairSuccess": "Перестройка кэша завершена.",
|
||||
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
|
||||
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "Обнаружено обновление приложения",
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
"settings": "设置",
|
||||
"help": "帮助",
|
||||
"add": "添加",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"menu": "菜单",
|
||||
"remove": "移除",
|
||||
"change": "更换"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
"cancelling": "取消中...",
|
||||
"unknown": "未知",
|
||||
"date": "日期",
|
||||
"version": "版本",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "从收藏移除",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"notAvailableFromCivitai": "Civitai 上不可用",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
|
||||
"copyLoRASyntax": "复制 LoRA 语法",
|
||||
"checkpointNameCopied": "检查点名称已复制",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "替换预览",
|
||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||
"copyEmbeddingName": "复制 Embedding 名称",
|
||||
"embeddingNameCopied": "已复制 Embedding 语法",
|
||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次数"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} 个版本",
|
||||
"viewAllVersions": "查看所有本地版本"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "成功修复了 {count} 个配方。",
|
||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||
"error": "配方修复失败:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "按模型分组"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "统计"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索...",
|
||||
"placeholders": {
|
||||
"loras": "搜索 LoRA...",
|
||||
"recipes": "搜索配方...",
|
||||
"checkpoints": "搜索 Checkpoint...",
|
||||
"embeddings": "搜索 Embedding..."
|
||||
},
|
||||
"placeholder": "搜索",
|
||||
"options": "搜索选项",
|
||||
"searchIn": "搜索范围:",
|
||||
"notAvailable": "统计页面不可用搜索",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?",
|
||||
"presetNamePlaceholder": "预设名称...",
|
||||
"baseModel": "基础模型",
|
||||
"modelTags": "标签(前20)",
|
||||
"baseModelSearchPlaceholder": "搜索基础模型...",
|
||||
"modelTags": "标签",
|
||||
"modelTypes": "模型类型",
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||
"noTags": "无标签",
|
||||
"tagSearchPlaceholder": "搜索标签...",
|
||||
"noTagMatches": "没有匹配当前搜索的标签。",
|
||||
"autoTags": "自动标签",
|
||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||
"clearAll": "清除所有筛选",
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "切换主题",
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题",
|
||||
"switchToAuto": "切换到自动主题"
|
||||
"switchToAuto": "切换到自动主题",
|
||||
"presets": "主题预设",
|
||||
"default": "默认",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "模式",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"auto": "自动"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "检查更新",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiApiKeyConfigured": "已配置",
|
||||
"civitaiApiKeyNotConfigured": "未配置",
|
||||
"civitaiApiKeySet": "设置",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
"options": {
|
||||
"com": "civitai.com(仅 SFW)",
|
||||
"red": "civitai.red(无限制)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "下载后端",
|
||||
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用推荐的外部下载进程。",
|
||||
"options": {
|
||||
"python": "Python(内置)",
|
||||
"aria2": "aria2(推荐)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "aria2c 路径",
|
||||
"help": "可选的 aria2c 可执行文件路径。留空则使用系统 PATH 中的 aria2c。",
|
||||
"placeholder": "留空则使用 PATH 中的 aria2c"
|
||||
},
|
||||
"aria2HelpLink": "了解如何配置 aria2 下载后端",
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 Civitai 站点偏好设置",
|
||||
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||
"openSettings": "打开设置"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "打开设置文件夹",
|
||||
"tooltip": "打开包含 settings.json 的文件夹",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "内容过滤",
|
||||
"downloads": "下载",
|
||||
"videoSettings": "视频设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"licenseIcons": "许可协议图标",
|
||||
"misc": "其他",
|
||||
"backup": "备份",
|
||||
"folderSettings": "默认根目录",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "额外文件夹路径",
|
||||
"downloadPathTemplates": "下载路径模板",
|
||||
"priorityTags": "优先标签",
|
||||
"updateFlags": "更新标记",
|
||||
"versionScope": "版本范围",
|
||||
"exampleImages": "示例图片",
|
||||
"autoOrganize": "自动整理",
|
||||
"metadata": "元数据",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分组",
|
||||
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "默认",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "悬停时显示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||
"showVersionOnCard": "在卡片上显示版本",
|
||||
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
|
||||
"modelCardFooterAction": "模型卡片按钮操作",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "打开示例图片",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "模型名称",
|
||||
"fileName": "文件名"
|
||||
},
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容",
|
||||
"cardBlurAmount": "卡片叠加模糊强度",
|
||||
"cardBlurAmountHelp": "调整模型和配方卡片上页眉和页脚叠加层的模糊强度(0 = 无模糊,20 = 最大模糊)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "活动库",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
||||
"saveError": "更新额外文件夹路径失败:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路径已配置"
|
||||
"duplicatePath": "此路径已配置",
|
||||
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路径:{paths}",
|
||||
"checkpointUnetOverlapInline": "此路径已被用于另一种模型类型。请为 checkpoints 和 diffusion models 使用不同的文件夹。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,26 +574,51 @@
|
||||
"downloadLocationHelp": "输入保存从 Civitai 下载的示例图片的文件夹路径",
|
||||
"autoDownload": "自动下载示例图片",
|
||||
"autoDownloadHelp": "自动为没有示例图片的模型下载示例图片(需设置下载位置)",
|
||||
"openMode": "打开示例图片操作",
|
||||
"openModeHelp": "选择是在服务器上打开、复制映射后的本地路径,还是启动自定义 URI。",
|
||||
"openModeOptions": {
|
||||
"system": "在服务器上打开",
|
||||
"clipboard": "复制本地路径",
|
||||
"uriTemplate": "打开自定义 URI"
|
||||
},
|
||||
"localRoot": "本地示例图片根目录",
|
||||
"localRootHelp": "可选的本地或挂载根目录,用于映射服务器上的示例图片目录。若留空,则复用服务器路径。",
|
||||
"localRootPlaceholder": "例如:/Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "打开 URI 模板",
|
||||
"uriTemplateHelp": "使用自定义深链接,例如文件 URI 或 Shortcuts 链接。",
|
||||
"uriTemplatePlaceholder": "例如:shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "可用占位符:{{local_path}}、{{encoded_local_path}}、{{relative_path}}、{{encoded_relative_path}}、{{file_uri}}、{{encoded_file_uri}}",
|
||||
"openModeWikiLink": "了解远程打开模式",
|
||||
"optimizeImages": "优化下载图片",
|
||||
"optimizeImagesHelp": "优化示例图片以减少文件大小并提升加载速度(保留元数据)",
|
||||
"download": "下载",
|
||||
"restartRequired": "需要重启"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "更新标记策略",
|
||||
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
|
||||
"versionGrouping": {
|
||||
"label": "版本分组",
|
||||
"help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
|
||||
"options": {
|
||||
"sameBase": "按基础模型匹配更新",
|
||||
"any": "显示任何可用更新"
|
||||
"sameBase": "按基础模型分组",
|
||||
"any": "显示所有版本"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "隐藏抢先体验更新",
|
||||
"help": "抢先体验更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版许可协议图标",
|
||||
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "启用元数据归档数据库",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "正在准备下载...",
|
||||
"connecting": "正在连接下载服务器...",
|
||||
"completed": "已完成",
|
||||
"downloadComplete": "下载成功完成"
|
||||
"downloadComplete": "下载成功完成",
|
||||
"enableCivarchiveApi": "启用 CivArchive API 作为元数据提供者",
|
||||
"enableCivarchiveApiHelp": "开启后,CivArchive API 将作为模型元数据的备用来源(例如用于已从 CivitAI 删除的模型)。关闭可完全避免 CivArchive 的速率限制。",
|
||||
"providerOrder": "元数据提供者回退顺序",
|
||||
"providerOrderHelp": "CivitAI API 始终优先尝试。选择查找元数据时其余提供者的顺序。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "启用应用级代理",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "密码 (可选)",
|
||||
"proxyPasswordPlaceholder": "密码",
|
||||
"proxyPasswordHelp": "代理认证的密码 (如果需要)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "AI 提供商",
|
||||
"provider": "提供商",
|
||||
"providerHelp": "选择您的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许您指定任何兼容 OpenAI 的端点。",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama(本地)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "自定义(OpenAI 兼容)"
|
||||
},
|
||||
"apiBase": "API 基础地址",
|
||||
"apiBaseHelp": "LLM API 的基础地址。选择预设或输入自定义地址,下拉框显示所有支持的提供商预设。",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API 密钥",
|
||||
"apiKeyHelp": "LLM 提供商的 API 密钥。本地存储,除您选择的 LLM 提供商外不会发送到任何服务器。",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "未设置",
|
||||
"apiKeyConfigured": "已配置",
|
||||
"apiKeySet": "设置",
|
||||
"model": "模型",
|
||||
"modelHelp": "要使用的模型。从下拉框选择(从提供商获取)或输入自定义模型名称。",
|
||||
"modelPlaceholder": "选择一个模型..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次数",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
"usageAsc": "最少",
|
||||
"versionsCount": "本地版本数",
|
||||
"versionsCountDesc": "版本数从多到少",
|
||||
"versionsCountAsc": "版本数从少到多",
|
||||
"versionIdDesc": "最新版本优先",
|
||||
"random": "随机",
|
||||
"randomAction": "随机排序(洗牌)"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "为所选中设置内容评级",
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"repairMetadata": "修复所选中元数据",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||
"deleteAll": "删除选中模型",
|
||||
"setFavorite": "设为收藏",
|
||||
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
|
||||
"unfavorite": "取消收藏",
|
||||
"deleteAll": "删除已选",
|
||||
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||
"downloadExamples": "下载示例图片",
|
||||
"downloadMissingExamples": "下载缺失的",
|
||||
"reprocessExamples": "重新处理全部",
|
||||
"clear": "清除选择",
|
||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||
"sendToWorkflow": "发送到工作流",
|
||||
"sections": {
|
||||
"workflow": "工作流",
|
||||
"metadata": "元数据",
|
||||
"attributes": "属性",
|
||||
"organize": "整理",
|
||||
"download": "下载"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "正在初始化自动整理...",
|
||||
"starting": "正在为 {type} 启动自动整理...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "完成:已移动 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
|
||||
"complete": "自动整理已完成",
|
||||
"error": "错误:{error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "AI HF 元数据增强"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 数据",
|
||||
"checkUpdates": "检查更新",
|
||||
"relinkCivitai": "重新关联到 Civitai",
|
||||
"linkModel": "链接模型",
|
||||
"linkCivitai": "链接到 Civitai",
|
||||
"linkHuggingFace": "链接到 HuggingFace",
|
||||
"copySyntax": "复制 LoRA 语法",
|
||||
"copyFilename": "复制模型文件名",
|
||||
"copyRecipeSyntax": "复制配方语法",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "发送到工作流(替换)",
|
||||
"openExamples": "打开示例文件夹",
|
||||
"downloadExamples": "下载示例图片",
|
||||
"downloadMissingExamples": "下载缺失的",
|
||||
"reprocessExamples": "重新处理全部",
|
||||
"replacePreview": "替换预览",
|
||||
"setContentRating": "设置内容评级",
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "恢复模型",
|
||||
"deleteModel": "删除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "查看所有 LoRA",
|
||||
"downloadMissingLoras": "下载缺失的 LoRA",
|
||||
"deleteRecipe": "删除配方"
|
||||
"deleteRecipe": "删除配方",
|
||||
"enrichHfAgent": "AI HF 元数据增强"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -685,9 +839,9 @@
|
||||
"title": "从图片或 URL 导入配方",
|
||||
"urlLocalPath": "URL / 本地路径",
|
||||
"uploadImage": "上传图片",
|
||||
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。",
|
||||
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL,或本地文件路径以导入为配方。",
|
||||
"imageUrlOrPath": "图片 URL 或文件路径:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
|
||||
"fetchImage": "获取图片",
|
||||
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
|
||||
"selectImage": "选择图片",
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新配方列表",
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "配方已是最新版本,无需修复",
|
||||
"failed": "修复配方失败:{message}",
|
||||
"missingId": "无法修复配方:缺少配方 ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "正在从源重新导入配方...",
|
||||
"success": "配方已从源重新导入成功",
|
||||
"noSourceUrl": "配方没有源URL,无法重新导入",
|
||||
"failed": "重新导入配方失败:{message}",
|
||||
"missingId": "无法重新导入配方:缺少配方ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "根目录",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"pinSidebar": "固定侧边栏",
|
||||
"unpinSidebar": "取消固定侧边栏",
|
||||
"hideOnThisPage": "隐藏此页面侧边栏",
|
||||
"showSidebar": "显示侧边栏",
|
||||
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"recursiveOn": "包含子文件夹",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "未找到文件夹",
|
||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "检查此文件夹的更新",
|
||||
"loading": "正在检查此文件夹中的{type}更新...",
|
||||
"success": "在此文件夹中找到 {count} 个{type}更新",
|
||||
"none": "此文件夹中的所有{type}都是最新版本",
|
||||
"error": "检查文件夹{type}更新失败: {message}"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "存储",
|
||||
"insights": "洞察"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "模型总数",
|
||||
"totalStorage": "总存储空间",
|
||||
"totalGenerations": "总生成次数",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "唯一标签",
|
||||
"unusedModels": "未使用模型",
|
||||
"avgUsesPerModel": "平均使用次数/模型"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最常用 LoRA",
|
||||
"mostUsedCheckpoints": "最常用 Checkpoint",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "智能洞察",
|
||||
"recommendations": "推荐"
|
||||
"recommendations": "推荐",
|
||||
"noInsights": "暂无可用洞察",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "大量未使用的 LoRA",
|
||||
"description": "你的 LoRA 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "考虑整理或归档未使用的模型以释放存储空间。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "检测到未使用的 Checkpoint",
|
||||
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "审查并考虑删除不再需要的 Checkpoint。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "大量未使用的 Embedding",
|
||||
"description": "你的 Embedding 中有 {percent}%({count}/{total})从未被使用过。",
|
||||
"suggestion": "考虑整理或归档未使用的 Embedding 以优化你的收藏。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "检测到大型收藏",
|
||||
"description": "你的模型收藏正在使用 {size} 的存储空间。",
|
||||
"suggestion": "考虑使用外部存储或云解决方案以获得更好的组织。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "活跃用户",
|
||||
"description": "你已经完成了 {count} 次生成!",
|
||||
"suggestion": "继续探索并用你的模型创作精彩内容。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "收藏概览",
|
||||
"baseModelDistribution": "基础模型分布",
|
||||
"usageTrends": "使用趋势(最近30天)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日期",
|
||||
"usageCount": "使用次数",
|
||||
"fileSizeBytes": "文件大小(字节)",
|
||||
"models": "模型",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "扩散模型",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "加载中...",
|
||||
"noModels": "未找到模型",
|
||||
"errorLoading": "数据加载失败",
|
||||
"noStorageData": "暂无存储数据",
|
||||
"rootFolder": "根目录",
|
||||
"chartLibraryMissing": "需要 Chart.js 库来显示图表"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}:{count} 个模型",
|
||||
"chartUsage": "{name}:{size},{count} 次使用",
|
||||
"chartPercentage": "{label}:{value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "从 URL 下载模型",
|
||||
"titleWithType": "从 URL 下载 {type}",
|
||||
"url": "Civitai URL",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行输入一个 CivitAI、CivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
|
||||
"selectHfFiles": "选择从此仓库下载的文件:",
|
||||
"selectAll": "全选",
|
||||
"fetchingRepoFiles": "正在获取仓库文件...",
|
||||
"locationPreview": "下载位置预览",
|
||||
"useDefaultPath": "使用默认路径",
|
||||
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||
"alreadyInLibrary": "已存在于库中",
|
||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||
"fileSelection": {
|
||||
"title": "选择文件格式",
|
||||
"files": "个文件",
|
||||
"select": "选择文件"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "无效的 Civitai URL 格式",
|
||||
"noVersions": "此模型没有可用版本"
|
||||
"noVersions": "此模型没有可用版本",
|
||||
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||
"noModelFiles": "在此仓库中未找到模型文件。"
|
||||
},
|
||||
"status": {
|
||||
"preparing": "正在准备下载...",
|
||||
"downloadedPreview": "预览图片已下载",
|
||||
"downloadingFile": "正在下载 {type} 文件",
|
||||
"finalizing": "正在完成下载..."
|
||||
"finalizing": "正在完成下载...",
|
||||
"cancelling": "取消下载中...",
|
||||
"cancelled": "下载已取消"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "当前文件:",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "模型将被永久删除。",
|
||||
"action": "全部删除"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "删除多个配方",
|
||||
"message": "你确定要删除所有选中的配方及其相关文件吗?",
|
||||
"countMessage": "配方将被永久删除。",
|
||||
"action": "全部删除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "检查所有 {type} 的更新?",
|
||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
|
||||
"root": "根目录"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "链接到 HuggingFace",
|
||||
"infoText": "粘贴 HuggingFace 仓库 URL 以关联此模型。关联后可启用 AI 元数据增强功能。",
|
||||
"urlLabel": "HuggingFace 仓库 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "请输入完整的 HuggingFace 仓库 URL。",
|
||||
"confirmAction": "保存并链接"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "重新关联到 Civitai",
|
||||
"warning": "警告:",
|
||||
@@ -1090,9 +1359,9 @@
|
||||
},
|
||||
"proceedText": "仅在你确定需要此操作时继续。",
|
||||
"urlLabel": "Civitai 模型 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||
"helpText": {
|
||||
"title": "粘贴任意 Civitai 模型 URL。支持格式:",
|
||||
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "编辑模型名称",
|
||||
"editFileName": "编辑文件名",
|
||||
"editBaseModel": "编辑基础模型",
|
||||
"editVersionName": "编辑版本名称",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
"openFileLocation": "打开文件位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "附加备注",
|
||||
"notesHint": "回车保存,Shift+回车换行",
|
||||
"addNotesPlaceholder": "在此添加你的备注...",
|
||||
"aboutThisVersion": "关于此版本"
|
||||
"aboutThisVersion": "关于此版本",
|
||||
"baseModelSearchPlaceholder": "搜索基础模型…",
|
||||
"baseModelSuggested": "推荐",
|
||||
"baseModelNoMatch": "没有匹配的基础模型"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "备注保存成功",
|
||||
"saveFailed": "备注保存失败"
|
||||
"saveFailed": "备注保存失败",
|
||||
"showMore": "展开",
|
||||
"showLess": "收起"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "添加预设参数...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "取消编辑",
|
||||
"save": "保存更改",
|
||||
"addPlaceholder": "输入或点击下方建议添加",
|
||||
"editWord": "编辑触发词",
|
||||
"editPlaceholder": "编辑触发词",
|
||||
"copyWord": "复制触发词",
|
||||
"deleteWord": "删除触发词",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "抢先体验",
|
||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已关闭更新通知"
|
||||
"ignoredTooltip": "此版本已关闭更新通知",
|
||||
"onSiteOnly": "仅站内生成",
|
||||
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"downloadTooltip": "下载此版本",
|
||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||
"delete": "删除",
|
||||
"deleteTooltip": "删除此本地版本",
|
||||
"ignore": "忽略",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "该模型还没有版本历史。",
|
||||
"error": "加载版本失败。",
|
||||
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
||||
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
|
||||
"confirm": {
|
||||
"delete": "从库中删除此版本?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "版本已删除"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "元数据获取摘要",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失败",
|
||||
"statSkipped": "已跳过",
|
||||
"statTotal": "总计扫描",
|
||||
"statDuration": "耗时",
|
||||
"successMessage": "全部 {count} 个 {type} 更新成功!",
|
||||
"failedItems": "失败项目 ({count})",
|
||||
"close": "关闭",
|
||||
"copyReport": "复制报告",
|
||||
"downloadCsv": "下载 CSV",
|
||||
"columnModelName": "模型名称",
|
||||
"columnError": "错误"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "该标签已存在"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "键盘导航:",
|
||||
"shortcuts": {
|
||||
"pageUp": "向上一页滚动",
|
||||
"pageDown": "向下一页滚动",
|
||||
"home": "跳到顶部",
|
||||
"end": "跳到底部"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初始化",
|
||||
"message": "正在准备你的工作空间...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||
"noTargetNodeSelected": "未选择目标节点",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型节点失败"
|
||||
"modelFailed": "更新模型节点失败",
|
||||
"embeddingAdded": "Embedding 已追加到工作流",
|
||||
"embeddingFailed": "添加 Embedding 失败",
|
||||
"promptSent": "提示词已发送到工作流",
|
||||
"promptFailed": "提示词发送失败"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "提示词",
|
||||
"replace": "替换",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "选择目标节点",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "示例图片文件夹已打开",
|
||||
"openingFolder": "正在打开示例图片文件夹",
|
||||
"failedToOpen": "打开示例图片文件夹失败",
|
||||
"copiedPath": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}",
|
||||
"copiedUri": "链接已复制到剪贴板:{{uri}}",
|
||||
"uriClipboardFallback": "链接:{{uri}}",
|
||||
"setupRequired": "示例图片存储",
|
||||
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
|
||||
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
|
||||
@@ -1460,6 +1774,12 @@
|
||||
"checkingMessage": "请稍候,正在检查最新版本。",
|
||||
"showNotifications": "显示更新通知",
|
||||
"latestBadge": "最新",
|
||||
"latestMain": "Main 分支",
|
||||
"channel": "更新频道",
|
||||
"channels": {
|
||||
"release": "稳定版",
|
||||
"nightly": "Nightly"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "正在准备更新...",
|
||||
"installing": "正在安装更新...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
||||
"enable": "启用 Nightly 更新"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "切换到 Nightly",
|
||||
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||
"releaseTitle": "切换到稳定版",
|
||||
"releaseMessage": "切换到稳定版将检出最新的发布标签。可随时切换回每日构建版。",
|
||||
"switching": "正在切换到 {channel} 频道...",
|
||||
"completed": "已切换到 {channel} 频道",
|
||||
"failed": "切换频道失败"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最近的通知",
|
||||
"empty": "暂无最近的横幅通知。",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "无配方 ID",
|
||||
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
||||
"copyFailed": "复制配方语法出错:{message}",
|
||||
"createError": "创建配方时出错:{message}",
|
||||
"createFailed": "创建配方失败:{error}",
|
||||
"createMissingData": "缺少创建配方所需的数据",
|
||||
"created": "配方创建成功",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
||||
"enterLoraName": "请输入 LoRA 名称或语法",
|
||||
"reconnectedSuccessfully": "LoRA 重新连接成功",
|
||||
"reconnectFailed": "LoRA 重新连接出错:{message}",
|
||||
"noPromptToSend": "没有可发送的提示词",
|
||||
"cannotSend": "无法发送配方:缺少配方 ID",
|
||||
"sendFailed": "发送配方到工作流失败",
|
||||
"sendError": "发送配方到工作流出错",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||
"noRecipesSelected": "未选择任何配方",
|
||||
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||
"reimporting": "正在从源重新导入配方...",
|
||||
"reimportSuccess": "配方已从源重新导入成功",
|
||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||
"reimportBulkFailed": "重新导入某些配方失败",
|
||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
|
||||
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
|
||||
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
|
||||
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
|
||||
"bulkFavoriteFailed": "更新收藏状态失败",
|
||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "示例图片{action}完成",
|
||||
"imagesFailed": "示例图片{action}失败",
|
||||
"loadError": "加载下载项出错:{message}",
|
||||
"downloadError": "下载错误:{message}"
|
||||
"downloadError": "下载错误:{message}",
|
||||
"downloadStopped": "下载已取消"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "加载文件夹树失败",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "无法加载训练词",
|
||||
"tooLong": "触发词不能超过100个词",
|
||||
"tooMany": "最多允许30个触发词",
|
||||
"tooLong": "触发词不能超过500个词",
|
||||
"tooMany": "最多允许100个触发词",
|
||||
"alreadyExists": "该触发词已存在",
|
||||
"updateSuccess": "触发词更新成功",
|
||||
"updateFailed": "触发词更新失败",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "设置内容评级失败:{message}",
|
||||
"relinkSuccess": "模型已成功重新关联到 Civitai",
|
||||
"relinkFailed": "错误:{message}",
|
||||
"linkHfSuccess": "模型已成功链接到 HuggingFace",
|
||||
"linkHfFailed": "错误:{message}",
|
||||
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
||||
"noCivitaiInfo": "无 CivitAI 信息",
|
||||
"missingHash": "模型哈希不可用"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "删除 {type} 失败:{message}",
|
||||
"excludeSuccess": "{type} 排除成功",
|
||||
"excludeFailed": "排除 {type} 失败:{message}",
|
||||
"restoreSuccess": "{type} 已成功恢复",
|
||||
"restoreFailed": "恢复 {type} 失败:{message}",
|
||||
"fileNameUpdated": "文件名更新成功",
|
||||
"fileRenameFailed": "重命名文件失败:{error}",
|
||||
"previewUpdated": "预览图片更新成功",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"downloadStarted": "下载已开始"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 提供商未配置。请在 设置 → AI 提供商 中进行配置。",
|
||||
"enrichStarted": "正在使用 AI 增强元数据...",
|
||||
"enrichComplete": "元数据增强完成:{{summary}}",
|
||||
"enrichFailed": "元数据增强失败:{{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "需要关注",
|
||||
"error": "需要处理"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 密钥"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "模型缓存健康状态"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "文件名重复冲突"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新检查",
|
||||
"exportBundle": "导出诊断包"
|
||||
"exportBundle": "导出诊断包",
|
||||
"open-settings": "打开设置",
|
||||
"open-settings-syntax-format": "切换为完整路径语法",
|
||||
"repair-cache": "重建缓存",
|
||||
"resolve-filename-conflicts": "解决冲突",
|
||||
"reload-page": "刷新 UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "冲突详情",
|
||||
"version": "版本信息"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "加载诊断结果失败:{message}",
|
||||
"repairSuccess": "缓存重建完成。",
|
||||
"repairFailed": "缓存重建失败:{message}",
|
||||
"exportSuccess": "诊断包已导出。",
|
||||
"exportFailed": "导出诊断包失败:{message}"
|
||||
"exportFailed": "导出诊断包失败:{message}",
|
||||
"conflictsResolved": "已解决 {count} 个文件名冲突。",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "检测到应用更新",
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
"settings": "設定",
|
||||
"help": "說明",
|
||||
"add": "新增",
|
||||
"close": "關閉"
|
||||
"close": "關閉",
|
||||
"menu": "選單",
|
||||
"remove": "移除",
|
||||
"change": "更換"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
"cancelling": "取消中...",
|
||||
"unknown": "未知",
|
||||
"date": "日期",
|
||||
"version": "版本",
|
||||
@@ -101,6 +105,7 @@
|
||||
"removeFromFavorites": "移除收藏",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"notAvailableFromCivitai": "Civitai 不提供",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
|
||||
"copyLoRASyntax": "複製 LoRA 語法",
|
||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||
@@ -110,6 +115,7 @@
|
||||
"replacePreview": "更換預覽圖",
|
||||
"copyCheckpointName": "複製檢查點名稱",
|
||||
"copyEmbeddingName": "複製嵌入名稱",
|
||||
"embeddingNameCopied": "已複製 Embedding 語法",
|
||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||
},
|
||||
@@ -140,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次數"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} 個版本",
|
||||
"viewAllVersions": "檢視所有本地版本"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -175,6 +185,12 @@
|
||||
"success": "成功修復 {count} 個配方。",
|
||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||
"error": "配方修復失敗:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "按模型分組"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -187,13 +203,7 @@
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜尋...",
|
||||
"placeholders": {
|
||||
"loras": "搜尋 LoRA...",
|
||||
"recipes": "搜尋配方...",
|
||||
"checkpoints": "搜尋 checkpoint...",
|
||||
"embeddings": "搜尋 embedding..."
|
||||
},
|
||||
"placeholder": "搜尋",
|
||||
"options": "搜尋選項",
|
||||
"searchIn": "搜尋範圍:",
|
||||
"notAvailable": "統計頁面無法搜尋",
|
||||
@@ -222,12 +232,19 @@
|
||||
"presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?",
|
||||
"presetNamePlaceholder": "預設名稱...",
|
||||
"baseModel": "基礎模型",
|
||||
"modelTags": "標籤(前 20)",
|
||||
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
||||
"modelTags": "標籤",
|
||||
"modelTypes": "模型類型",
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||
"noTags": "無標籤",
|
||||
"tagSearchPlaceholder": "搜尋標籤...",
|
||||
"noTagMatches": "沒有符合目前搜尋的標籤。",
|
||||
"autoTags": "自動標籤",
|
||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||
"clearAll": "清除所有篩選",
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
@@ -238,7 +255,18 @@
|
||||
"toggle": "切換主題",
|
||||
"switchToLight": "切換至淺色主題",
|
||||
"switchToDark": "切換至深色主題",
|
||||
"switchToAuto": "自動主題"
|
||||
"switchToAuto": "自動主題",
|
||||
"presets": "主題預設",
|
||||
"default": "預設",
|
||||
"nord": "Nord",
|
||||
"midnight": "Midnight",
|
||||
"monokai": "Monokai",
|
||||
"dracula": "Dracula",
|
||||
"solarized": "Solarized",
|
||||
"mode": "模式",
|
||||
"light": "淺色",
|
||||
"dark": "深色",
|
||||
"auto": "自動"
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
@@ -250,6 +278,36 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiApiKeyConfigured": "已設定",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
"options": {
|
||||
"com": "civitai.com(僅 SFW)",
|
||||
"red": "civitai.red(無限制)"
|
||||
}
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "下載後端",
|
||||
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用推薦的外部下載程序。",
|
||||
"options": {
|
||||
"python": "Python(內建)",
|
||||
"aria2": "aria2(推薦)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
"label": "aria2c 路徑",
|
||||
"help": "可選的 aria2c 可執行檔路徑。留空則使用系統 PATH 中的 aria2c。",
|
||||
"placeholder": "留空則使用 PATH 中的 aria2c"
|
||||
},
|
||||
"aria2HelpLink": "了解如何設定 aria2 下載後端",
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 Civitai 站點偏好設定",
|
||||
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
|
||||
"openSettings": "開啟設定"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "開啟設定資料夾",
|
||||
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||
@@ -260,8 +318,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "內容過濾",
|
||||
"downloads": "下載",
|
||||
"videoSettings": "影片設定",
|
||||
"layoutSettings": "版面設定",
|
||||
"licenseIcons": "許可協議圖標",
|
||||
"misc": "其他",
|
||||
"backup": "備份",
|
||||
"folderSettings": "預設根目錄",
|
||||
@@ -269,7 +329,7 @@
|
||||
"extraFolderPaths": "額外資料夾路徑",
|
||||
"downloadPathTemplates": "下載路徑範本",
|
||||
"priorityTags": "優先標籤",
|
||||
"updateFlags": "更新標記",
|
||||
"versionScope": "版本範圍",
|
||||
"exampleImages": "範例圖片",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "中繼資料",
|
||||
@@ -374,6 +434,8 @@
|
||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分組",
|
||||
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "預設",
|
||||
@@ -395,6 +457,8 @@
|
||||
"hover": "滑鼠懸停顯示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||
"showVersionOnCard": "在卡片上顯示版本",
|
||||
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
|
||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "開啟範例圖片",
|
||||
@@ -406,7 +470,9 @@
|
||||
"modelName": "模型名稱",
|
||||
"fileName": "檔案名稱"
|
||||
},
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容",
|
||||
"cardBlurAmount": "卡片疊加模糊強度",
|
||||
"cardBlurAmountHelp": "調整模型和配方卡片上頁首和頁尾疊加層的模糊強度(0 = 無模糊,20 = 最大模糊)。"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "使用中的資料庫",
|
||||
@@ -441,7 +507,9 @@
|
||||
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路徑已設定"
|
||||
"duplicatePath": "此路徑已設定",
|
||||
"checkpointUnetOverlap": "checkpoints 和 diffusion models 不能使用相同的路徑:{paths}",
|
||||
"checkpointUnetOverlapInline": "此路徑已被用於另一種模型類型。請為 checkpoints 和 diffusion models 使用不同的資料夾。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -506,12 +574,27 @@
|
||||
"downloadLocationHelp": "輸入從 Civitai 下載範例圖片要儲存的資料夾路徑",
|
||||
"autoDownload": "自動下載範例圖片",
|
||||
"autoDownloadHelp": "自動為沒有範例圖片的模型下載範例圖片(需設定下載位置)",
|
||||
"openMode": "開啟範例圖片動作",
|
||||
"openModeHelp": "選擇是在伺服器上開啟、複製對應的本機路徑,或啟動自訂 URI。",
|
||||
"openModeOptions": {
|
||||
"system": "在伺服器上開啟",
|
||||
"clipboard": "複製本機路徑",
|
||||
"uriTemplate": "開啟自訂 URI"
|
||||
},
|
||||
"localRoot": "本機範例圖片根目錄",
|
||||
"localRootHelp": "可選的本機或掛載根目錄,用於對應伺服器上的範例圖片目錄。若留白,則會重用伺服器路徑。",
|
||||
"localRootPlaceholder": "例如:/Volumes/ComfyUI/example_images",
|
||||
"uriTemplate": "開啟 URI 範本",
|
||||
"uriTemplateHelp": "使用自訂深層連結,例如檔案 URI 或 Shortcuts 連結。",
|
||||
"uriTemplatePlaceholder": "例如:shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
|
||||
"uriTemplatePlaceholders": "可用佔位符:{{local_path}}、{{encoded_local_path}}、{{relative_path}}、{{encoded_relative_path}}、{{file_uri}}、{{encoded_file_uri}}",
|
||||
"openModeWikiLink": "了解遠端開啟模式",
|
||||
"optimizeImages": "最佳化下載圖片",
|
||||
"optimizeImagesHelp": "最佳化範例圖片以減少檔案大小並提升載入速度(會保留原有的 metadata)",
|
||||
"download": "下載",
|
||||
"restartRequired": "需要重新啟動"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "更新標記策略",
|
||||
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
|
||||
"options": {
|
||||
@@ -523,9 +606,19 @@
|
||||
"label": "隱藏搶先體驗更新",
|
||||
"help": "搶先體驗更新"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版許可協議圖標",
|
||||
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
|
||||
},
|
||||
"misc": {
|
||||
"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": {
|
||||
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
||||
@@ -549,7 +642,13 @@
|
||||
"preparing": "準備下載中...",
|
||||
"connecting": "正在連接下載伺服器...",
|
||||
"completed": "已完成",
|
||||
"downloadComplete": "下載成功完成"
|
||||
"downloadComplete": "下載成功完成",
|
||||
"enableCivarchiveApi": "啟用 CivArchive API 作為中繼資料提供者",
|
||||
"enableCivarchiveApiHelp": "開啟後,CivArchive API 將作為模型中繼資料的備用來源(例如用於已從 CivitAI 刪除的模型)。關閉可完全避免 CivArchive 的速率限制。",
|
||||
"providerOrder": "中繼資料提供者回退順序",
|
||||
"providerOrderHelp": "CivitAI API 始終優先嘗試。選擇查詢中繼資料時其餘提供者的順序。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "啟用應用程式代理",
|
||||
@@ -568,6 +667,33 @@
|
||||
"proxyPassword": "密碼(選填)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
|
||||
},
|
||||
"aiProvider": {
|
||||
"title": "AI 提供者",
|
||||
"provider": "提供者",
|
||||
"providerHelp": "選擇您的 LLM 提供者。OpenAI 和 Ollama 使用預設 API 端點。自訂允許您指定任何相容 OpenAI 的端點。",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama(本地)",
|
||||
"deepseek": "DeepSeek",
|
||||
"groq": "Groq",
|
||||
"openrouter": "OpenRouter",
|
||||
"google": "Gemini",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"custom": "自訂(OpenAI 相容)"
|
||||
},
|
||||
"apiBase": "API 基礎網址",
|
||||
"apiBaseHelp": "LLM API 的基礎網址。選擇預設或輸入自訂網址,下拉選單顯示所有支援的提供者預設。",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API 金鑰",
|
||||
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
|
||||
"apiKeyPlaceholder": "[TODO: Translate] sk-...",
|
||||
"apiKeyNotSet": "未設定",
|
||||
"apiKeyConfigured": "已設定",
|
||||
"apiKeySet": "設定",
|
||||
"model": "模型",
|
||||
"modelHelp": "要使用的模型。從下拉選單選擇(從提供者取得)或輸入自訂模型名稱。",
|
||||
"modelPlaceholder": "選擇一個模型..."
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -585,12 +711,16 @@
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次數",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
"usageAsc": "最少",
|
||||
"versionsCount": "本地版本數",
|
||||
"versionsCountDesc": "版本數從多到少",
|
||||
"versionsCountAsc": "版本數從少到多",
|
||||
"versionIdDesc": "最新版本優先",
|
||||
"random": "隨機",
|
||||
"randomAction": "隨機排序(洗牌)"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||
},
|
||||
@@ -631,16 +761,32 @@
|
||||
"setContentRating": "為全部設定內容分級",
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"repairMetadata": "修復所選中元數據",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||
"deleteAll": "刪除全部模型",
|
||||
"setFavorite": "設為收藏",
|
||||
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
|
||||
"unfavorite": "取消收藏",
|
||||
"deleteAll": "刪除所選",
|
||||
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||
"downloadExamples": "下載範例圖片",
|
||||
"downloadMissingExamples": "下載缺少的",
|
||||
"reprocessExamples": "重新處理全部",
|
||||
"clear": "清除選取",
|
||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||
"sendToWorkflow": "發送到工作流",
|
||||
"sections": {
|
||||
"workflow": "工作流",
|
||||
"metadata": "元數據",
|
||||
"attributes": "屬性",
|
||||
"organize": "整理",
|
||||
"download": "下載"
|
||||
},
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "正在初始化自動整理...",
|
||||
"starting": "正在開始自動整理 {type}...",
|
||||
@@ -649,12 +795,15 @@
|
||||
"completed": "完成:已移動 {success},已略過 {skipped},失敗 {failures}",
|
||||
"complete": "自動整理完成",
|
||||
"error": "錯誤:{error}"
|
||||
}
|
||||
},
|
||||
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 資料",
|
||||
"checkUpdates": "檢查更新",
|
||||
"relinkCivitai": "重新連結 Civitai",
|
||||
"linkModel": "連結模型",
|
||||
"linkCivitai": "連結到 Civitai",
|
||||
"linkHuggingFace": "連結到 HuggingFace",
|
||||
"copySyntax": "複製 LoRA 語法",
|
||||
"copyFilename": "複製模型檔名",
|
||||
"copyRecipeSyntax": "複製配方語法",
|
||||
@@ -662,16 +811,21 @@
|
||||
"sendToWorkflowReplace": "傳送到工作流(取代)",
|
||||
"openExamples": "開啟範例資料夾",
|
||||
"downloadExamples": "下載範例圖片",
|
||||
"downloadMissingExamples": "下載缺少的",
|
||||
"reprocessExamples": "重新處理全部",
|
||||
"replacePreview": "更換預覽圖",
|
||||
"setContentRating": "設定內容分級",
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "還原模型",
|
||||
"deleteModel": "刪除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "檢視全部 LoRA",
|
||||
"downloadMissingLoras": "下載缺少的 LoRA",
|
||||
"deleteRecipe": "刪除配方"
|
||||
"deleteRecipe": "刪除配方",
|
||||
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -752,8 +906,6 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理配方列表",
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||
},
|
||||
@@ -794,6 +946,13 @@
|
||||
"skipped": "配方已是最新版本,無需修復",
|
||||
"failed": "修復配方失敗:{message}",
|
||||
"missingId": "無法修復配方:缺少配方 ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "正在從來源重新匯入配方...",
|
||||
"success": "配方已從來源重新匯入成功",
|
||||
"noSourceUrl": "配方沒有來源URL,無法重新匯入",
|
||||
"failed": "重新匯入配方失敗:{message}",
|
||||
"missingId": "無法重新匯入配方:缺少配方ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
@@ -872,8 +1031,9 @@
|
||||
"sidebar": {
|
||||
"modelRoot": "根目錄",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"pinSidebar": "固定側邊欄",
|
||||
"unpinSidebar": "取消固定側邊欄",
|
||||
"hideOnThisPage": "隱藏此頁面側邊欄",
|
||||
"showSidebar": "顯示側邊欄",
|
||||
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"recursiveOn": "包含子資料夾",
|
||||
@@ -893,6 +1053,13 @@
|
||||
"empty": {
|
||||
"noFolders": "未找到資料夾",
|
||||
"dragHint": "將項目拖到此處以建立資料夾"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "檢查此資料夾的更新",
|
||||
"loading": "正在檢查此資料夾中的{type}更新...",
|
||||
"success": "在此資料夾中找到 {count} 個{type}更新",
|
||||
"none": "此資料夾中的所有{type}都是最新版本",
|
||||
"error": "檢查資料夾{type}更新失敗: {message}"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -904,6 +1071,18 @@
|
||||
"storage": "儲存空間",
|
||||
"insights": "洞察"
|
||||
},
|
||||
"metrics": {
|
||||
"totalModels": "模型總數",
|
||||
"totalStorage": "總儲存空間",
|
||||
"totalGenerations": "總生成次數",
|
||||
"usageRate": "使用率",
|
||||
"loras": "LoRA",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"uniqueTags": "唯一標籤",
|
||||
"unusedModels": "未使用模型",
|
||||
"avgUsesPerModel": "平均使用次數/模型"
|
||||
},
|
||||
"usage": {
|
||||
"mostUsedLoras": "最常用的 LoRA",
|
||||
"mostUsedCheckpoints": "最常用的 Checkpoint",
|
||||
@@ -921,13 +1100,77 @@
|
||||
},
|
||||
"insights": {
|
||||
"smartInsights": "智慧洞察",
|
||||
"recommendations": "推薦"
|
||||
"recommendations": "推薦",
|
||||
"noInsights": "暫無可用洞察",
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "大量未使用的 LoRA",
|
||||
"description": "你的 LoRA 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "檢測到未使用的 Checkpoint",
|
||||
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "大量未使用的 Embedding",
|
||||
"description": "你的 Embedding 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "檢測到大型收藏",
|
||||
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
|
||||
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "活躍用戶",
|
||||
"description": "你已經完成了 {count} 次生成!",
|
||||
"suggestion": "繼續探索並用你的模型創作精彩內容。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"collectionOverview": "收藏總覽",
|
||||
"baseModelDistribution": "基礎模型分布",
|
||||
"usageTrends": "使用趨勢(最近 30 天)",
|
||||
"usageDistribution": "使用分布"
|
||||
"usageDistribution": "使用分布",
|
||||
"date": "日期",
|
||||
"usageCount": "使用次數",
|
||||
"fileSizeBytes": "檔案大小(位元組)",
|
||||
"models": "模型",
|
||||
"loraUsage": "LoRA 使用量",
|
||||
"checkpointUsage": "Checkpoint 使用量",
|
||||
"embeddingUsage": "Embedding 使用量"
|
||||
},
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"locon": "LyCORIS",
|
||||
"dora": "DoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "擴散模型",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"placeholders": {
|
||||
"loading": "載入中...",
|
||||
"noModels": "找不到模型",
|
||||
"errorLoading": "資料載入失敗",
|
||||
"noStorageData": "暫無儲存資料",
|
||||
"rootFolder": "根目錄",
|
||||
"chartLibraryMissing": "需要 Chart.js 函式庫來顯示圖表"
|
||||
},
|
||||
"tooltips": {
|
||||
"tagCount": "{tag}:{count} 個模型",
|
||||
"chartUsage": "{name}:{size},{count} 次使用",
|
||||
"chartPercentage": "{label}:{value}({pct}%)"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
@@ -937,9 +1180,12 @@
|
||||
"download": {
|
||||
"title": "從網址下載模型",
|
||||
"titleWithType": "從網址下載 {type}",
|
||||
"url": "Civitai 網址",
|
||||
"civitaiUrl": "Civitai 網址:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行輸入一個 CivitAI、CivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
|
||||
"selectHfFiles": "選擇從此倉庫下載的檔案:",
|
||||
"selectAll": "全選",
|
||||
"fetchingRepoFiles": "正在獲取倉庫檔案...",
|
||||
"locationPreview": "下載位置預覽",
|
||||
"useDefaultPath": "使用預設路徑",
|
||||
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
||||
@@ -961,15 +1207,24 @@
|
||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||
"alreadyInLibrary": "已在庫存",
|
||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||
"fileSelection": {
|
||||
"title": "選擇檔案格式",
|
||||
"files": "個檔案",
|
||||
"select": "選擇檔案"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Civitai 網址格式無效",
|
||||
"noVersions": "此模型無可用版本"
|
||||
"noVersions": "此模型無可用版本",
|
||||
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||
"noModelFiles": "在此倉庫中未找到模型檔案。"
|
||||
},
|
||||
"status": {
|
||||
"preparing": "準備下載中...",
|
||||
"downloadedPreview": "已下載預覽圖片",
|
||||
"downloadingFile": "正在下載 {type} 檔案",
|
||||
"finalizing": "完成下載中..."
|
||||
"finalizing": "完成下載中...",
|
||||
"cancelling": "取消下載中...",
|
||||
"cancelled": "下載已取消"
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "目前檔案:",
|
||||
@@ -1025,6 +1280,12 @@
|
||||
"countMessage": "模型將被永久刪除。",
|
||||
"action": "全部刪除"
|
||||
},
|
||||
"bulkDeleteRecipes": {
|
||||
"title": "刪除多個配方",
|
||||
"message": "您確定要刪除所有選取的配方及其相關檔案嗎?",
|
||||
"countMessage": "配方將被永久刪除。",
|
||||
"action": "全部刪除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "要檢查所有 {type} 的更新嗎?",
|
||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||
@@ -1079,6 +1340,14 @@
|
||||
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
|
||||
"root": "根目錄"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "連結到 HuggingFace",
|
||||
"infoText": "貼上 HuggingFace 倉庫 URL 以關聯此模型。關聯後可啟用 AI 中繼資料增強功能。",
|
||||
"urlLabel": "HuggingFace 倉庫 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "請輸入完整的 HuggingFace 倉庫 URL。",
|
||||
"confirmAction": "儲存並連結"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "重新連結至 Civitai",
|
||||
"warning": "警告:",
|
||||
@@ -1105,8 +1374,11 @@
|
||||
"editModelName": "編輯模型名稱",
|
||||
"editFileName": "編輯檔案名稱",
|
||||
"editBaseModel": "編輯基礎模型",
|
||||
"editVersionName": "編輯版本名稱",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
@@ -1132,11 +1404,16 @@
|
||||
"additionalNotes": "附加備註",
|
||||
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
|
||||
"addNotesPlaceholder": "在此新增備註...",
|
||||
"aboutThisVersion": "關於此版本"
|
||||
"aboutThisVersion": "關於此版本",
|
||||
"baseModelSearchPlaceholder": "搜尋基礎模型…",
|
||||
"baseModelSuggested": "推薦",
|
||||
"baseModelNoMatch": "沒有符合的基礎模型"
|
||||
},
|
||||
"notes": {
|
||||
"saved": "備註已儲存",
|
||||
"saveFailed": "儲存備註失敗"
|
||||
"saveFailed": "儲存備註失敗",
|
||||
"showMore": "展開",
|
||||
"showLess": "收起"
|
||||
},
|
||||
"usageTips": {
|
||||
"addPresetParameter": "新增預設參數...",
|
||||
@@ -1157,6 +1434,8 @@
|
||||
"cancel": "取消編輯",
|
||||
"save": "儲存變更",
|
||||
"addPlaceholder": "輸入或點擊下方建議",
|
||||
"editWord": "編輯觸發詞",
|
||||
"editPlaceholder": "編輯觸發詞",
|
||||
"copyWord": "複製觸發詞",
|
||||
"deleteWord": "刪除觸發詞",
|
||||
"suggestions": {
|
||||
@@ -1239,12 +1518,15 @@
|
||||
"earlyAccess": "搶先體驗",
|
||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已關閉更新通知"
|
||||
"ignoredTooltip": "此版本已關閉更新通知",
|
||||
"onSiteOnly": "僅站內生成",
|
||||
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"downloadTooltip": "下載此版本",
|
||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||
"delete": "刪除",
|
||||
"deleteTooltip": "刪除此本地版本",
|
||||
"ignore": "忽略",
|
||||
@@ -1273,6 +1555,7 @@
|
||||
"empty": "此模型尚無版本歷史。",
|
||||
"error": "載入版本失敗。",
|
||||
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
||||
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
|
||||
"confirm": {
|
||||
"delete": "要從庫中刪除此版本嗎?"
|
||||
},
|
||||
@@ -1284,6 +1567,36 @@
|
||||
"versionDeleted": "已刪除此版本"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadataFetchSummary": {
|
||||
"title": "元資料獲取摘要",
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失敗",
|
||||
"statSkipped": "已跳過",
|
||||
"statTotal": "總計掃描",
|
||||
"statDuration": "耗時",
|
||||
"successMessage": "全部 {count} 個 {type} 更新成功!",
|
||||
"failedItems": "失敗項目 ({count})",
|
||||
"close": "關閉",
|
||||
"copyReport": "複製報告",
|
||||
"downloadCsv": "下載 CSV",
|
||||
"columnModelName": "模型名稱",
|
||||
"columnError": "錯誤"
|
||||
},
|
||||
"downloadBatchSummary": {
|
||||
"title": "[TODO: Translate] Batch Download Summary",
|
||||
"statSuccess": "[TODO: Translate] Success",
|
||||
"statFailed": "[TODO: Translate] Failed",
|
||||
"statTotal": "[TODO: Translate] Total",
|
||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
||||
"failed": "[TODO: Translate] Download failed",
|
||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
||||
"columnName": "[TODO: Translate] Model Name",
|
||||
"columnError": "[TODO: Translate] Error",
|
||||
"close": "[TODO: Translate] Close",
|
||||
"copyReport": "[TODO: Translate] Copy Report",
|
||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
||||
}
|
||||
},
|
||||
"modelTags": {
|
||||
@@ -1297,15 +1610,6 @@
|
||||
"duplicate": "此標籤已存在"
|
||||
}
|
||||
},
|
||||
"keyboard": {
|
||||
"navigation": "鍵盤導覽:",
|
||||
"shortcuts": {
|
||||
"pageUp": "向上捲動一頁",
|
||||
"pageDown": "向下捲動一頁",
|
||||
"home": "跳至頂部",
|
||||
"end": "跳至底部"
|
||||
}
|
||||
},
|
||||
"initialization": {
|
||||
"title": "初始化",
|
||||
"message": "正在準備您的工作區...",
|
||||
@@ -1393,11 +1697,17 @@
|
||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||
"noTargetNodeSelected": "未選擇目標節點",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型節點失敗"
|
||||
"modelFailed": "更新模型節點失敗",
|
||||
"embeddingAdded": "Embedding 已附加到工作流",
|
||||
"embeddingFailed": "傳送 Embedding 到工作流失敗",
|
||||
"promptSent": "提示詞已發送到工作流",
|
||||
"promptFailed": "提示詞發送失敗"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "提示詞",
|
||||
"replace": "取代",
|
||||
"append": "附加",
|
||||
"selectTargetNode": "選擇目標節點",
|
||||
@@ -1407,6 +1717,10 @@
|
||||
"opened": "範例圖片資料夾已開啟",
|
||||
"openingFolder": "正在開啟範例圖片資料夾",
|
||||
"failedToOpen": "開啟範例圖片資料夾失敗",
|
||||
"copiedPath": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}",
|
||||
"copiedUri": "連結已複製到剪貼簿:{{uri}}",
|
||||
"uriClipboardFallback": "連結:{{uri}}",
|
||||
"setupRequired": "範例圖片儲存",
|
||||
"setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。",
|
||||
"setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。",
|
||||
@@ -1460,6 +1774,12 @@
|
||||
"checkingMessage": "請稍候,正在檢查最新版本。",
|
||||
"showNotifications": "顯示更新通知",
|
||||
"latestBadge": "最新",
|
||||
"latestMain": "Main 分支",
|
||||
"channel": "更新頻道",
|
||||
"channels": {
|
||||
"release": "稳定版",
|
||||
"nightly": "Nightly"
|
||||
},
|
||||
"updateProgress": {
|
||||
"preparing": "正在準備更新...",
|
||||
"installing": "正在安裝更新...",
|
||||
@@ -1480,6 +1800,15 @@
|
||||
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
||||
"enable": "啟用 Nightly 更新"
|
||||
},
|
||||
"channelSwitch": {
|
||||
"nightlyTitle": "切换到 Nightly",
|
||||
"nightlyMessage": "切换到 Nightly 将初始化 Git 仓库并跟踪 main 分支的最新提交。更新更频繁但可能不稳定,可随时切回稳定版。",
|
||||
"releaseTitle": "切换到稳定版",
|
||||
"releaseMessage": "切換到穩定版將檢出最新的發布標籤。可隨時切換回每日構建版。",
|
||||
"switching": "正在切換到 {channel} 頻道...",
|
||||
"completed": "已切換到 {channel} 頻道",
|
||||
"failed": "切換頻道失敗"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最新通知",
|
||||
"empty": "目前沒有最近的橫幅通知。",
|
||||
@@ -1570,12 +1899,17 @@
|
||||
"noRecipeId": "無配方 ID",
|
||||
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
||||
"copyFailed": "複製配方語法錯誤:{message}",
|
||||
"createError": "建立配方時發生錯誤:{message}",
|
||||
"createFailed": "建立配方失敗:{error}",
|
||||
"createMissingData": "缺少建立配方所需的資料",
|
||||
"created": "配方建立成功",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
||||
"enterLoraName": "請輸入 LoRA 名稱或語法",
|
||||
"reconnectedSuccessfully": "LoRA 重新連結成功",
|
||||
"reconnectFailed": "LoRA 重新連結錯誤:{message}",
|
||||
"noPromptToSend": "沒有可發送的提示詞",
|
||||
"cannotSend": "無法傳送配方:缺少配方 ID",
|
||||
"sendFailed": "傳送配方到工作流失敗",
|
||||
"sendError": "傳送配方到工作流錯誤",
|
||||
@@ -1608,6 +1942,13 @@
|
||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||
"noRecipesSelected": "未選取任何食譜",
|
||||
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||
"reimporting": "正在從來源重新匯入配方...",
|
||||
"reimportSuccess": "配方已從來源重新匯入成功",
|
||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||
"reimportBulkFailed": "重新匯入某些配方失敗",
|
||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||
},
|
||||
@@ -1638,6 +1979,11 @@
|
||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
|
||||
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
|
||||
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
|
||||
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
|
||||
"bulkFavoriteFailed": "更新收藏狀態失敗",
|
||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||
@@ -1717,7 +2063,8 @@
|
||||
"imagesCompleted": "範例圖片{action}完成",
|
||||
"imagesFailed": "範例圖片{action}失敗",
|
||||
"loadError": "載入下載時發生錯誤:{message}",
|
||||
"downloadError": "下載錯誤:{message}"
|
||||
"downloadError": "下載錯誤:{message}",
|
||||
"downloadStopped": "下載已取消"
|
||||
},
|
||||
"import": {
|
||||
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
||||
@@ -1728,8 +2075,8 @@
|
||||
},
|
||||
"triggerWords": {
|
||||
"loadFailed": "無法載入訓練詞",
|
||||
"tooLong": "觸發詞不可超過 100 個字",
|
||||
"tooMany": "最多允許 30 個觸發詞",
|
||||
"tooLong": "觸發詞不可超過 500 個字",
|
||||
"tooMany": "最多允許 100 個觸發詞",
|
||||
"alreadyExists": "此觸發詞已存在",
|
||||
"updateSuccess": "觸發詞已更新",
|
||||
"updateFailed": "更新觸發詞失敗",
|
||||
@@ -1762,6 +2109,8 @@
|
||||
"contentRatingFailed": "設定內容分級失敗:{message}",
|
||||
"relinkSuccess": "模型已成功重新連結至 Civitai",
|
||||
"relinkFailed": "錯誤:{message}",
|
||||
"linkHfSuccess": "模型已成功連結到 HuggingFace",
|
||||
"linkHfFailed": "錯誤:{message}",
|
||||
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
||||
"noCivitaiInfo": "無 CivitAI 資訊",
|
||||
"missingHash": "模型雜湊不可用"
|
||||
@@ -1790,6 +2139,8 @@
|
||||
"deleteFailed": "刪除 {type} 失敗:{message}",
|
||||
"excludeSuccess": "{type} 已成功排除",
|
||||
"excludeFailed": "排除 {type} 失敗:{message}",
|
||||
"restoreSuccess": "{type} 已成功還原",
|
||||
"restoreFailed": "還原 {type} 失敗:{message}",
|
||||
"fileNameUpdated": "檔案名稱已成功更新",
|
||||
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
||||
"previewUpdated": "預覽圖片已成功更新",
|
||||
@@ -1818,7 +2169,15 @@
|
||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"copiedToClipboard": "已複製到剪貼簿",
|
||||
"downloadStarted": "下載已開始"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 提供者尚未設定。請在 設定 → AI 提供者 中進行設定。",
|
||||
"enrichStarted": "正在使用 AI 增強中繼資料...",
|
||||
"enrichComplete": "中繼資料增強完成:{{summary}}",
|
||||
"enrichFailed": "中繼資料增強失敗:{{error}}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
@@ -1838,18 +2197,52 @@
|
||||
"warning": "需要注意",
|
||||
"error": "需要處理"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 金鑰"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "模型快取健康狀態"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "檔案名稱重複衝突"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新執行",
|
||||
"exportBundle": "匯出套件"
|
||||
"exportBundle": "匯出套件",
|
||||
"open-settings": "開啟設定",
|
||||
"open-settings-syntax-format": "切換為完整路徑語法",
|
||||
"repair-cache": "重建快取",
|
||||
"resolve-filename-conflicts": "解決衝突",
|
||||
"reload-page": "重新載入 UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "衝突詳情",
|
||||
"version": "版本"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "載入診斷失敗:{message}",
|
||||
"repairSuccess": "快取重建完成。",
|
||||
"repairFailed": "快取重建失敗:{message}",
|
||||
"exportSuccess": "診斷套件已匯出。",
|
||||
"exportFailed": "匯出診斷套件失敗:{message}"
|
||||
"exportFailed": "匯出診斷套件失敗:{message}",
|
||||
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
|
||||
"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": {
|
||||
"versionMismatch": {
|
||||
"title": "偵測到應用程式更新",
|
||||
|
||||
248
py/config.py
248
py/config.py
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import platform
|
||||
import posixpath
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import folder_paths # type: ignore
|
||||
@@ -7,6 +8,8 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||
import logging
|
||||
import json
|
||||
import urllib.parse
|
||||
import sys as _sys
|
||||
import types as _types
|
||||
import time
|
||||
|
||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
||||
@@ -25,21 +28,57 @@ standalone_mode = (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_root_identity(path: str) -> str:
|
||||
"""Normalize a root path for comparisons across slash styles."""
|
||||
|
||||
normalized = posixpath.normpath(path.strip().replace("\\", "/"))
|
||||
if len(normalized) >= 2 and normalized[1] == ":":
|
||||
return normalized.lower()
|
||||
return normalized
|
||||
|
||||
|
||||
def _resolve_valid_default_root(
|
||||
current: str, primary_paths: List[str], name: str
|
||||
current: str, primary_paths: List[str], allowed_paths: List[str], name: str
|
||||
) -> str:
|
||||
"""Return a valid default root from the current primary path set."""
|
||||
"""Return a valid default root from the current primary/extra path set."""
|
||||
|
||||
valid_paths = [path for path in primary_paths if isinstance(path, str) and path.strip()]
|
||||
if not valid_paths:
|
||||
return ""
|
||||
fallback_paths: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for path in allowed_paths:
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
stripped = path.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
identity = _normalize_root_identity(stripped)
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
fallback_paths.append(stripped)
|
||||
|
||||
if current in valid_paths:
|
||||
allowed = {_normalize_root_identity(path) for path in fallback_paths}
|
||||
|
||||
if current and _normalize_root_identity(current) in allowed:
|
||||
return current
|
||||
|
||||
if not valid_paths:
|
||||
if not fallback_paths:
|
||||
return ""
|
||||
if current:
|
||||
logger.info(
|
||||
"Repaired stale %s from '%s' to '%s' because it is not present in primary or extra roots",
|
||||
name,
|
||||
current,
|
||||
fallback_paths[0],
|
||||
)
|
||||
else:
|
||||
logger.info("Auto-setting %s to '%s'", name, fallback_paths[0])
|
||||
return fallback_paths[0]
|
||||
|
||||
if current:
|
||||
logger.info(
|
||||
"Repaired stale %s from '%s' to '%s'",
|
||||
"Repaired stale %s from '%s' to '%s' because it is not present in primary or extra roots",
|
||||
name,
|
||||
current,
|
||||
valid_paths[0],
|
||||
@@ -135,6 +174,11 @@ class Config:
|
||||
self.extra_unet_roots: List[str] = []
|
||||
self.extra_embeddings_roots: List[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.
|
||||
self._load_extra_paths_from_settings()
|
||||
|
||||
# Scan symbolic links during initialization
|
||||
self._initialize_symlink_mappings()
|
||||
|
||||
@@ -142,6 +186,98 @@ class Config:
|
||||
# Save the paths to settings.json when running in ComfyUI mode
|
||||
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 via ``folder_paths.get_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
|
||||
|
||||
# Always read recipes_path — it is independent of extra folder paths
|
||||
# and must be set before any early returns below.
|
||||
recipes_path = library_config.get("recipes_path", "")
|
||||
if isinstance(recipes_path, str) and recipes_path:
|
||||
self.recipes_path = recipes_path
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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):
|
||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||
try:
|
||||
@@ -223,42 +359,120 @@ class Config:
|
||||
"Failed to rename legacy 'default' library: %s", rename_error
|
||||
)
|
||||
|
||||
# Clean up a stale "default" library entry that has no meaningful
|
||||
# paths configured (e.g. leftover bootstrap artifact). This only
|
||||
# fires when "comfyui" already exists so we never delete the last
|
||||
# remaining library.
|
||||
if (
|
||||
"default" in libraries
|
||||
and "comfyui" in libraries
|
||||
and isinstance(default_library, Mapping)
|
||||
):
|
||||
default_folder_paths = _normalize_library_folder_paths(
|
||||
default_library
|
||||
)
|
||||
default_extra_paths = default_library.get("extra_folder_paths", {})
|
||||
has_meaningful_paths = bool(default_folder_paths) or bool(
|
||||
default_extra_paths
|
||||
) or any(
|
||||
default_library.get(key)
|
||||
for key in (
|
||||
"default_lora_root",
|
||||
"default_checkpoint_root",
|
||||
"default_unet_root",
|
||||
"default_embedding_root",
|
||||
"recipes_path",
|
||||
)
|
||||
)
|
||||
if not has_meaningful_paths:
|
||||
try:
|
||||
settings_service.delete_library("default")
|
||||
libraries_changed = True
|
||||
logger.info(
|
||||
"Removed stale 'default' library entry "
|
||||
"with no meaningful paths configured"
|
||||
)
|
||||
libraries = settings_service.get_libraries()
|
||||
comfy_library = libraries.get("comfyui", {})
|
||||
except Exception as delete_error:
|
||||
logger.debug(
|
||||
"Failed to remove stale 'default' library: %s",
|
||||
delete_error,
|
||||
)
|
||||
|
||||
default_lora_root = _resolve_valid_default_root(
|
||||
comfy_library.get("default_lora_root", ""),
|
||||
list(self.loras_roots or []),
|
||||
list(self.loras_roots or [])
|
||||
+ list(comfy_library.get("extra_folder_paths", {}).get("loras", []) or []),
|
||||
"default_lora_root",
|
||||
)
|
||||
|
||||
default_checkpoint_root = _resolve_valid_default_root(
|
||||
comfy_library.get("default_checkpoint_root", ""),
|
||||
list(self.checkpoints_roots or []),
|
||||
list(self.checkpoints_roots or [])
|
||||
+ list(comfy_library.get("extra_folder_paths", {}).get("checkpoints", []) or []),
|
||||
"default_checkpoint_root",
|
||||
)
|
||||
|
||||
default_embedding_root = _resolve_valid_default_root(
|
||||
comfy_library.get("default_embedding_root", ""),
|
||||
list(self.embeddings_roots or []),
|
||||
list(self.embeddings_roots or [])
|
||||
+ list(comfy_library.get("extra_folder_paths", {}).get("embeddings", []) or []),
|
||||
"default_embedding_root",
|
||||
)
|
||||
|
||||
metadata = dict(comfy_library.get("metadata", {}))
|
||||
metadata.setdefault("display_name", "ComfyUI")
|
||||
metadata["source"] = "comfyui"
|
||||
extra_folder_paths = {}
|
||||
if isinstance(comfy_library, Mapping):
|
||||
existing_extra_paths = comfy_library.get("extra_folder_paths", {})
|
||||
if isinstance(existing_extra_paths, Mapping):
|
||||
extra_folder_paths = {
|
||||
key: list(value) if isinstance(value, list) else []
|
||||
for key, value in existing_extra_paths.items()
|
||||
}
|
||||
|
||||
active_library_name = settings_service.get_active_library_name()
|
||||
should_activate = (
|
||||
active_library_name == "comfyui"
|
||||
or self._should_activate_comfy_library(libraries, libraries_changed)
|
||||
)
|
||||
|
||||
settings_service.upsert_library(
|
||||
"comfyui",
|
||||
folder_paths=target_folder_paths,
|
||||
extra_folder_paths=extra_folder_paths,
|
||||
default_lora_root=default_lora_root,
|
||||
default_checkpoint_root=default_checkpoint_root,
|
||||
default_embedding_root=default_embedding_root,
|
||||
metadata=metadata,
|
||||
activate=True,
|
||||
activate=should_activate,
|
||||
)
|
||||
|
||||
logger.info("Updated 'comfyui' library with current folder paths")
|
||||
if should_activate:
|
||||
logger.info("Updated 'comfyui' library with current folder paths")
|
||||
else:
|
||||
logger.info(
|
||||
"Updated 'comfyui' library with current folder paths without activating it"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save folder paths: {e}")
|
||||
|
||||
def _should_activate_comfy_library(
|
||||
self, libraries: Mapping[str, Any], libraries_changed: bool
|
||||
) -> bool:
|
||||
"""Return whether startup sync should make the ComfyUI library active."""
|
||||
|
||||
if libraries_changed:
|
||||
return True
|
||||
if not libraries:
|
||||
return True
|
||||
return "comfyui" in libraries and len(libraries) == 1
|
||||
|
||||
def _is_link(self, path: str) -> bool:
|
||||
try:
|
||||
if os.path.islink(path):
|
||||
@@ -1210,4 +1424,20 @@ class Config:
|
||||
|
||||
|
||||
# Global config instance
|
||||
config = Config()
|
||||
# NOTE: Guard against re-import. When ServiceRegistry.get_lora_scanner() triggers
|
||||
# a fresh import of lora_scanner → config, we must NOT re-execute Config.__init__()
|
||||
# (which re-scans all roots, re-registers libraries, etc.).
|
||||
#
|
||||
# Strategy: store the config instance in a dedicated sentinel module
|
||||
# ('_lm_config_cache') that is NEVER removed from sys.modules (its key does
|
||||
# NOT start with 'py.'), so it survives re-imports of py.* modules.
|
||||
_CONFIG_SENTINEL = "_lm_config_cache"
|
||||
if _CONFIG_SENTINEL in _sys.modules:
|
||||
# Re-import: reuse the existing singleton from the sentinel.
|
||||
config: Config = _sys.modules[_CONFIG_SENTINEL].config # type: ignore[valid-type]
|
||||
else:
|
||||
config: Config = Config()
|
||||
# Register the sentinel so re-imports of py.config find us.
|
||||
_sentinel_mod = _types.ModuleType(_CONFIG_SENTINEL)
|
||||
_sentinel_mod.config = config
|
||||
_sys.modules[_CONFIG_SENTINEL] = _sentinel_mod
|
||||
|
||||
@@ -33,6 +33,7 @@ from .utils.example_images_migration import ExampleImagesMigration
|
||||
from .services.websocket_manager import ws_manager
|
||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||
from .middleware.csp_middleware import relax_csp_for_remote_media
|
||||
from .middleware.error_middleware import api_json_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +77,11 @@ class LoraManager:
|
||||
"""Initialize and register all routes using the new refactored architecture"""
|
||||
app = PromptServer.instance.app
|
||||
|
||||
# Register JSON error middleware for /api/* routes as the outermost
|
||||
# middleware so it catches errors from all other middlewares.
|
||||
if api_json_error not in app.middlewares:
|
||||
app.middlewares.insert(0, api_json_error)
|
||||
|
||||
if relax_csp_for_remote_media not in app.middlewares:
|
||||
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
|
||||
# see and extend the restrictive header instead of being overwritten by it.
|
||||
@@ -184,44 +190,15 @@ class LoraManager:
|
||||
async def _initialize_services(cls):
|
||||
"""Initialize all services using the ServiceRegistry"""
|
||||
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
|
||||
await ServiceRegistry.get_civitai_client()
|
||||
|
||||
# Register DownloadManager with ServiceRegistry
|
||||
await ServiceRegistry.get_download_manager()
|
||||
|
||||
# Initialize DownloadQueueService for persistent queue/history
|
||||
await ServiceRegistry.get_download_queue_service()
|
||||
|
||||
await ServiceRegistry.get_backup_service()
|
||||
|
||||
from .services.metadata_service import initialize_metadata_providers
|
||||
@@ -231,6 +208,10 @@ class LoraManager:
|
||||
# Initialize WebSocket manager
|
||||
await ServiceRegistry.get_websocket_manager()
|
||||
|
||||
# Preload LLM model catalog (background task, non-blocking)
|
||||
from .services.llm_service import LLMService
|
||||
await LLMService.get_instance()
|
||||
|
||||
# Initialize scanners in background
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
@@ -459,5 +440,21 @@ class LoraManager:
|
||||
try:
|
||||
logger.info("LoRA Manager: Cleaning up services")
|
||||
|
||||
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||
# when the server shuts down (e.g. Ctrl+C on WSL).
|
||||
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||
scanner = ServiceRegistry.get_service_sync(name)
|
||||
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||
scanner.cancel_task()
|
||||
logger.debug("LoRA Manager: Cancelled %s", name)
|
||||
|
||||
# Close shared aiohttp sessions to avoid "Unclosed client session" warnings
|
||||
try:
|
||||
from py.routes.handlers.hf_handlers import close_hf_api_session
|
||||
await close_hf_api_session()
|
||||
except Exception as exc:
|
||||
logger.debug("Error closing HF API session: %s", exc)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
"""Constants used by the metadata collector"""
|
||||
|
||||
# Sentinel value for clip_skip to distinguish "unconnected / widget default"
|
||||
# from "user wired value 0". Both ComfyUI CLIPSetLastLayer (-24..-1) and
|
||||
# A1111 conventions treat 0 as meaningless for clip skipping, but users may
|
||||
# explicitly wire 0 to the overwrite node to express "no clip skip / default".
|
||||
CLIP_SKIP_SENTINEL = -25
|
||||
|
||||
# Metadata categories
|
||||
MODELS = "models"
|
||||
PROMPTS = "prompts"
|
||||
SAMPLING = "sampling"
|
||||
LORAS = "loras"
|
||||
EMBEDDINGS = "embeddings"
|
||||
SIZE = "size"
|
||||
IMAGES = "images"
|
||||
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
|
||||
OVERWRITE = "overwrite" # Manual metadata overwrite from MetadataOverwriteLM node
|
||||
|
||||
# Field names that the MetadataOverwriteLM node and its extractor share
|
||||
METADATA_OVERWRITE_FIELDS = (
|
||||
"prompt", "negative_prompt", "seed", "steps", "cfg_scale",
|
||||
"sampler", "scheduler", "model", "loras", "size",
|
||||
"clip_skip", "additional_data",
|
||||
)
|
||||
|
||||
# Complete list of categories to track
|
||||
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES]
|
||||
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, EMBEDDINGS, SIZE, IMAGES, OVERWRITE]
|
||||
|
||||
@@ -83,7 +83,8 @@ class MetadataHook:
|
||||
|
||||
# Record inputs before execution
|
||||
if node_id is not None:
|
||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
||||
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||
registry.record_node_execution(node_id, class_type, input_data_all, None, return_types=return_types)
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||
|
||||
@@ -114,7 +115,8 @@ class MetadataHook:
|
||||
|
||||
# Record outputs after execution
|
||||
if node_id is not None:
|
||||
registry.update_node_execution(node_id, class_type, results)
|
||||
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||
registry.update_node_execution(node_id, class_type, results, return_types=return_types)
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||
|
||||
@@ -135,10 +137,13 @@ class MetadataHook:
|
||||
# Store the dynprompt reference for node lookups
|
||||
if hasattr(prompt, 'original_prompt'):
|
||||
registry.set_current_prompt(prompt)
|
||||
|
||||
|
||||
# Store extra_data for accessing full workflow node properties
|
||||
registry.set_extra_data(extra_data)
|
||||
|
||||
# Execute the original function
|
||||
return original_execute(*args, **kwargs)
|
||||
|
||||
|
||||
# Replace the functions
|
||||
execution._map_node_over_list = map_node_over_list_with_metadata
|
||||
execution.execute = execute_with_prompt_tracking
|
||||
@@ -163,7 +168,8 @@ class MetadataHook:
|
||||
class_type = obj.__class__.__name__
|
||||
node_id = unique_id
|
||||
if node_id is not None:
|
||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
||||
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||
registry.record_node_execution(node_id, class_type, input_data_all, None, return_types=return_types)
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||
|
||||
@@ -180,7 +186,8 @@ class MetadataHook:
|
||||
class_type = obj.__class__.__name__
|
||||
node_id = unique_id
|
||||
if node_id is not None:
|
||||
registry.update_node_execution(node_id, class_type, results)
|
||||
return_types = getattr(obj, 'RETURN_TYPES', None)
|
||||
registry.update_node_execution(node_id, class_type, results, return_types=return_types)
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||
|
||||
@@ -202,6 +209,9 @@ class MetadataHook:
|
||||
if hasattr(prompt, 'original_prompt'):
|
||||
registry.set_current_prompt(prompt)
|
||||
|
||||
# Store extra_data for accessing full workflow node properties
|
||||
registry.set_extra_data(extra_data)
|
||||
|
||||
# Execute the original function
|
||||
return await original_execute(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -1,15 +1,68 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from .constants import IMAGES
|
||||
|
||||
# Check if running in standalone mode
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER, OVERWRITE
|
||||
from .node_extractors import NODE_EXTRACTORS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys that identify metadata hint marks stored in node.properties.lm_marker_role
|
||||
_META_MARK_PREFIX = "meta_"
|
||||
_MARK_PRIMARY_MODEL = "primary_model"
|
||||
_MARK_PRIMARY_SAMPLER = "primary_sampler"
|
||||
_MARK_POSITIVE_PROMPT = "positive_prompt"
|
||||
_MARK_NEGATIVE_PROMPT = "negative_prompt"
|
||||
|
||||
class MetadataProcessor:
|
||||
"""Process and format collected metadata"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_user_marks(metadata):
|
||||
"""Scan workflow nodes (from extra_data.extra_pnginfo.workflow) for user-assigned
|
||||
metadata hint marks stored in node.properties.lm_marker_role.
|
||||
|
||||
Returns a dict mapping mark type keys to node IDs.
|
||||
Example: {'primary_model': '42', 'primary_sampler': '17'}
|
||||
"""
|
||||
marks: dict[str, str] = {}
|
||||
|
||||
# Primary source: extra_data.extra_pnginfo.workflow.nodes (has full properties)
|
||||
extra_data = metadata.get("extra_data")
|
||||
if extra_data and isinstance(extra_data, dict):
|
||||
extra_pnginfo = extra_data.get("extra_pnginfo", {})
|
||||
if isinstance(extra_pnginfo, dict):
|
||||
workflow = extra_pnginfo.get("workflow", {})
|
||||
nodes = workflow.get("nodes", [])
|
||||
for node in nodes:
|
||||
node_id = str(node.get("id", ""))
|
||||
role = node.get("properties", {}).get("lm_marker_role", "")
|
||||
if role.startswith(_META_MARK_PREFIX):
|
||||
mark_type = role[len(_META_MARK_PREFIX):]
|
||||
if mark_type in marks:
|
||||
logger.warning(
|
||||
"Duplicate meta hint '%s': node %s (previous: %s), "
|
||||
"last match wins",
|
||||
mark_type, node_id, marks[mark_type],
|
||||
)
|
||||
marks[mark_type] = node_id
|
||||
|
||||
# Fallback: try prompt.original_prompt (API-only submissions may not have workflow)
|
||||
if not marks:
|
||||
prompt = metadata.get("current_prompt")
|
||||
if prompt and getattr(prompt, "original_prompt", None):
|
||||
for node_id, node_data in prompt.original_prompt.items():
|
||||
role = node_data.get("properties", {}).get("lm_marker_role", "")
|
||||
if role.startswith(_META_MARK_PREFIX):
|
||||
mark_type = role[len(_META_MARK_PREFIX):]
|
||||
marks[mark_type] = node_id
|
||||
|
||||
return marks
|
||||
|
||||
@staticmethod
|
||||
def find_primary_sampler(metadata, downstream_id=None):
|
||||
"""
|
||||
@@ -352,50 +405,101 @@ class MetadataProcessor:
|
||||
|
||||
# Check if we have stored conditioning objects for this sampler
|
||||
if sampler_id in metadata.get(PROMPTS, {}) and (
|
||||
"pos_conditioning" in metadata[PROMPTS][sampler_id] or
|
||||
"neg_conditioning" in metadata[PROMPTS][sampler_id]):
|
||||
|
||||
"pos_conditioning" in metadata[PROMPTS][sampler_id] or
|
||||
"neg_conditioning" in metadata[PROMPTS][sampler_id]
|
||||
):
|
||||
pos_conditioning = metadata[PROMPTS][sampler_id].get("pos_conditioning")
|
||||
neg_conditioning = metadata[PROMPTS][sampler_id].get("neg_conditioning")
|
||||
|
||||
# Helper function to recursively find prompt text for a conditioning object
|
||||
def find_prompt_text_for_conditioning(conditioning_obj, is_positive=True):
|
||||
|
||||
def extend_unique(target, values):
|
||||
for value in values:
|
||||
if value and value not in target:
|
||||
target.append(value)
|
||||
|
||||
# Helper function to recursively find prompt texts for a conditioning object.
|
||||
# Transform nodes can map one output conditioning to multiple source conditionings.
|
||||
def find_prompt_texts_for_conditioning(
|
||||
conditioning_obj, is_positive=True, visited=None
|
||||
):
|
||||
if conditioning_obj is None:
|
||||
return ""
|
||||
|
||||
return []
|
||||
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
conditioning_id = id(conditioning_obj)
|
||||
if conditioning_id in visited:
|
||||
return []
|
||||
visited.add(conditioning_id)
|
||||
|
||||
prompt_texts = []
|
||||
|
||||
# Try to match conditioning objects with those stored by extractors
|
||||
for prompt_node_id, prompt_data in metadata[PROMPTS].items():
|
||||
# For nodes with single conditioning output
|
||||
if "conditioning" in prompt_data:
|
||||
if id(prompt_data["conditioning"]) == id(conditioning_obj):
|
||||
return prompt_data.get("text", "")
|
||||
|
||||
# For nodes with separate pos_conditioning and neg_conditioning outputs (like TSC_EfficientLoader)
|
||||
if is_positive and "positive_encoded" in prompt_data:
|
||||
if id(prompt_data["positive_encoded"]) == id(conditioning_obj):
|
||||
if "positive_text" in prompt_data:
|
||||
return prompt_data["positive_text"]
|
||||
else:
|
||||
orig_conditioning = prompt_data.get("orig_pos_cond", None)
|
||||
if orig_conditioning is not None:
|
||||
# Recursively find the prompt text for the original conditioning
|
||||
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=True)
|
||||
|
||||
if not is_positive and "negative_encoded" in prompt_data:
|
||||
if id(prompt_data["negative_encoded"]) == id(conditioning_obj):
|
||||
if "negative_text" in prompt_data:
|
||||
return prompt_data["negative_text"]
|
||||
else:
|
||||
orig_conditioning = prompt_data.get("orig_neg_cond", None)
|
||||
if orig_conditioning is not None:
|
||||
# Recursively find the prompt text for the original conditioning
|
||||
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=False)
|
||||
|
||||
return ""
|
||||
|
||||
if not isinstance(prompt_data, dict):
|
||||
continue
|
||||
|
||||
# For CLIP text nodes with a single conditioning output.
|
||||
if id(prompt_data.get("conditioning")) == conditioning_id:
|
||||
text = prompt_data.get("text", "")
|
||||
if text:
|
||||
extend_unique(prompt_texts, [text])
|
||||
|
||||
# Generic provenance for passthrough/transform/combine nodes.
|
||||
for source in prompt_data.get("conditioning_sources", []):
|
||||
if id(source.get("output")) != conditioning_id:
|
||||
continue
|
||||
for input_conditioning in source.get("inputs", []):
|
||||
extend_unique(
|
||||
prompt_texts,
|
||||
find_prompt_texts_for_conditioning(
|
||||
input_conditioning, is_positive, visited
|
||||
),
|
||||
)
|
||||
|
||||
# For nodes with separate pos_conditioning and neg_conditioning outputs
|
||||
# like TSC_EfficientLoader and existing ControlNet-style metadata.
|
||||
if (
|
||||
is_positive
|
||||
and id(prompt_data.get("positive_encoded")) == conditioning_id
|
||||
):
|
||||
if prompt_data.get("positive_text"):
|
||||
extend_unique(prompt_texts, [prompt_data["positive_text"]])
|
||||
else:
|
||||
extend_unique(
|
||||
prompt_texts,
|
||||
find_prompt_texts_for_conditioning(
|
||||
prompt_data.get("orig_pos_cond"),
|
||||
is_positive=True,
|
||||
visited=visited,
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
not is_positive
|
||||
and id(prompt_data.get("negative_encoded")) == conditioning_id
|
||||
):
|
||||
if prompt_data.get("negative_text"):
|
||||
extend_unique(prompt_texts, [prompt_data["negative_text"]])
|
||||
else:
|
||||
extend_unique(
|
||||
prompt_texts,
|
||||
find_prompt_texts_for_conditioning(
|
||||
prompt_data.get("orig_neg_cond"),
|
||||
is_positive=False,
|
||||
visited=visited,
|
||||
),
|
||||
)
|
||||
|
||||
return prompt_texts
|
||||
|
||||
# Find prompt texts using the helper function
|
||||
result["prompt"] = find_prompt_text_for_conditioning(pos_conditioning, is_positive=True)
|
||||
result["negative_prompt"] = find_prompt_text_for_conditioning(neg_conditioning, is_positive=False)
|
||||
result["prompt"] = ", ".join(
|
||||
find_prompt_texts_for_conditioning(pos_conditioning, is_positive=True)
|
||||
)
|
||||
result["negative_prompt"] = ", ".join(
|
||||
find_prompt_texts_for_conditioning(neg_conditioning, is_positive=False)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -420,20 +524,57 @@ class MetadataProcessor:
|
||||
"checkpoint": None,
|
||||
"loras": "",
|
||||
"size": None,
|
||||
"clip_skip": None
|
||||
"clip_skip": None,
|
||||
"additional_data": "",
|
||||
}
|
||||
|
||||
# Get the prompt object for node relationship tracing
|
||||
prompt = metadata.get("current_prompt")
|
||||
|
||||
# Find the primary KSampler node
|
||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
||||
|
||||
# Directly get checkpoint from metadata instead of tracing
|
||||
# Pass primary_sampler_id to avoid redundant calculation
|
||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
||||
if checkpoint:
|
||||
params["checkpoint"] = checkpoint
|
||||
|
||||
# ---- User marks: override heuristic inference with user-assigned hints ----
|
||||
user_marks = MetadataProcessor._get_user_marks(metadata)
|
||||
|
||||
# Find the primary KSampler node (user mark takes priority)
|
||||
primary_sampler_id = None
|
||||
primary_sampler = None
|
||||
if _MARK_PRIMARY_SAMPLER in user_marks:
|
||||
marked_id = user_marks[_MARK_PRIMARY_SAMPLER]
|
||||
sampler_data = metadata.get(SAMPLING, {}).get(marked_id)
|
||||
if sampler_data and sampler_data.get(IS_SAMPLER):
|
||||
primary_sampler_id = marked_id
|
||||
primary_sampler = sampler_data
|
||||
else:
|
||||
logger.warning(
|
||||
"User-marked primary sampler %s has no runtime metadata, "
|
||||
"falling back to heuristic",
|
||||
marked_id,
|
||||
)
|
||||
if primary_sampler is None:
|
||||
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
|
||||
|
||||
# Resolve checkpoint / model (user mark takes priority)
|
||||
if _MARK_PRIMARY_MODEL in user_marks:
|
||||
marked_id = user_marks[_MARK_PRIMARY_MODEL]
|
||||
if marked_id in metadata.get(MODELS, {}):
|
||||
params["checkpoint"] = metadata[MODELS][marked_id].get("name")
|
||||
else:
|
||||
extra_data = metadata.get("extra_data")
|
||||
extra_pnginfo = extra_data.get("extra_pnginfo", {}) if extra_data and isinstance(extra_data, dict) else {}
|
||||
workflow = extra_pnginfo.get("workflow", {}) if isinstance(extra_pnginfo, dict) else {}
|
||||
node_type = "unknown"
|
||||
for n in workflow.get("nodes", []):
|
||||
if str(n.get("id", "")) == marked_id:
|
||||
node_type = n.get("type", "unknown")
|
||||
break
|
||||
logger.warning(
|
||||
"User-marked primary model %s (type=%s, registered=%s) has no runtime metadata, "
|
||||
"falling back to heuristic",
|
||||
marked_id, node_type, node_type in NODE_EXTRACTORS,
|
||||
)
|
||||
if params["checkpoint"] is None:
|
||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
|
||||
if checkpoint:
|
||||
params["checkpoint"] = checkpoint
|
||||
|
||||
# Check if guidance parameter exists in any sampling node
|
||||
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
|
||||
@@ -488,7 +629,22 @@ class MetadataProcessor:
|
||||
|
||||
# For SamplerCustom, handle any additional parameters
|
||||
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
|
||||
|
||||
|
||||
# ---- User marks: override prompts with explicitly tagged nodes ----
|
||||
prompts_data = metadata.get(PROMPTS, {})
|
||||
if _MARK_POSITIVE_PROMPT in user_marks:
|
||||
pos_id = user_marks[_MARK_POSITIVE_PROMPT]
|
||||
if pos_id in prompts_data:
|
||||
prompt_text = prompts_data[pos_id].get("text") or prompts_data[pos_id].get("positive_text")
|
||||
if prompt_text:
|
||||
params["prompt"] = prompt_text
|
||||
if _MARK_NEGATIVE_PROMPT in user_marks:
|
||||
neg_id = user_marks[_MARK_NEGATIVE_PROMPT]
|
||||
if neg_id in prompts_data:
|
||||
prompt_text = prompts_data[neg_id].get("text") or prompts_data[neg_id].get("negative_text")
|
||||
if prompt_text:
|
||||
params["negative_prompt"] = prompt_text
|
||||
|
||||
# Size extraction is same for all sampler types
|
||||
# Check if the sampler itself has size information (from latent_image)
|
||||
if primary_sampler_id in metadata.get(SIZE, {}):
|
||||
@@ -509,9 +665,34 @@ class MetadataProcessor:
|
||||
|
||||
params["loras"] = " ".join(lora_parts)
|
||||
|
||||
# Set default clip_skip value
|
||||
params["clip_skip"] = "1" # Common default
|
||||
|
||||
# Extract clip_skip from any SAMPLING node that provides it
|
||||
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"
|
||||
|
||||
# ---- Apply manual metadata overwrites ----
|
||||
for overwrite_info in metadata.get(OVERWRITE, {}).values():
|
||||
overwrite_params = overwrite_info.get("parameters", {})
|
||||
for key, value in overwrite_params.items():
|
||||
if key == "clip_skip":
|
||||
# Accept any value from overwrite node (sentinel -25 already
|
||||
# filtered upstream). Needed because falsy check treats 0
|
||||
# as "not set" even though 0 is a valid wired input here.
|
||||
params[key] = value
|
||||
elif value: # truthy check — only overwrite when user provided a real value
|
||||
params[key] = value
|
||||
|
||||
# Bridge: the overwrite node exposes the field as "model" (more accurate),
|
||||
# but the internal pipeline key remains "checkpoint" for backward compatibility
|
||||
# with A1111 metadata format and downstream consumers.
|
||||
if params.get("model"):
|
||||
params["checkpoint"] = params["model"]
|
||||
del params["model"]
|
||||
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
from nodes import NODE_CLASS_MAPPINGS # type: ignore
|
||||
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
||||
from .constants import METADATA_CATEGORIES, IMAGES
|
||||
from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE
|
||||
|
||||
|
||||
class MetadataRegistry:
|
||||
@@ -61,6 +61,7 @@ class MetadataRegistry:
|
||||
{
|
||||
"execution_order": [],
|
||||
"current_prompt": None, # Will store the prompt object
|
||||
"extra_data": None, # Will store the API extra_data for workflow metadata
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
)
|
||||
@@ -75,6 +76,11 @@ class MetadataRegistry:
|
||||
# Store the prompt in the metadata for later relationship tracing
|
||||
self.prompt_metadata[self.current_prompt_id]["current_prompt"] = prompt
|
||||
|
||||
def set_extra_data(self, extra_data):
|
||||
"""Store the API extra_data (contains extra_pnginfo.workflow with node properties)"""
|
||||
if self.current_prompt_id and self.current_prompt_id in self.prompt_metadata:
|
||||
self.prompt_metadata[self.current_prompt_id]["extra_data"] = extra_data
|
||||
|
||||
def get_metadata(self, prompt_id=None):
|
||||
"""Get collected metadata for a prompt"""
|
||||
key = prompt_id if prompt_id is not None else self.current_prompt_id
|
||||
@@ -122,20 +128,28 @@ class MetadataRegistry:
|
||||
cache_key = f"{node_id}:{class_type}"
|
||||
|
||||
# Check if this node type is relevant for metadata collection
|
||||
if class_type in NODE_EXTRACTORS:
|
||||
if class_type in NODE_EXTRACTORS or cache_key in self.node_cache:
|
||||
# Check if we have cached metadata for this node
|
||||
if cache_key in self.node_cache:
|
||||
cached_data = self.node_cache[cache_key]
|
||||
|
||||
# Detect bypass (mode=4) / mute (mode=2) — these nodes
|
||||
# were intentionally disabled and should not contribute
|
||||
# overwrite values from a previous execution's cache.
|
||||
node_mode = node_data.get("mode", 0)
|
||||
node_is_disabled = node_mode in (2, 4)
|
||||
|
||||
# Apply cached metadata to the current metadata
|
||||
for category in self.metadata_categories:
|
||||
if category == OVERWRITE and node_is_disabled:
|
||||
continue
|
||||
if category in cached_data and node_id in cached_data[category]:
|
||||
if node_id not in metadata[category]:
|
||||
metadata[category][node_id] = cached_data[category][
|
||||
node_id
|
||||
]
|
||||
|
||||
def record_node_execution(self, node_id, class_type, inputs, outputs):
|
||||
def record_node_execution(self, node_id, class_type, inputs, outputs, return_types=None):
|
||||
"""Record information about a node's execution"""
|
||||
if not self.current_prompt_id:
|
||||
return
|
||||
@@ -158,17 +172,18 @@ class MetadataRegistry:
|
||||
|
||||
# Extract node-specific metadata
|
||||
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
||||
extractor.extract(
|
||||
node_id,
|
||||
processed_inputs,
|
||||
outputs,
|
||||
self.prompt_metadata[self.current_prompt_id],
|
||||
)
|
||||
if extractor is GenericNodeExtractor:
|
||||
extractor.extract(node_id, processed_inputs, outputs,
|
||||
self.prompt_metadata[self.current_prompt_id],
|
||||
return_types=return_types)
|
||||
else:
|
||||
extractor.extract(node_id, processed_inputs, outputs,
|
||||
self.prompt_metadata[self.current_prompt_id])
|
||||
|
||||
# Cache this node's metadata
|
||||
self._cache_node_metadata(node_id, class_type)
|
||||
|
||||
def update_node_execution(self, node_id, class_type, outputs):
|
||||
def update_node_execution(self, node_id, class_type, outputs, return_types=None):
|
||||
"""Update node metadata with output information"""
|
||||
if not self.current_prompt_id:
|
||||
return
|
||||
@@ -179,9 +194,17 @@ class MetadataRegistry:
|
||||
# Use the same extractor to update with outputs
|
||||
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
|
||||
if hasattr(extractor, "update"):
|
||||
extractor.update(
|
||||
node_id, processed_outputs, self.prompt_metadata[self.current_prompt_id]
|
||||
)
|
||||
if extractor is GenericNodeExtractor:
|
||||
extractor.update(
|
||||
node_id, processed_outputs,
|
||||
self.prompt_metadata[self.current_prompt_id],
|
||||
return_types=return_types,
|
||||
)
|
||||
else:
|
||||
extractor.update(
|
||||
node_id, processed_outputs,
|
||||
self.prompt_metadata[self.current_prompt_id],
|
||||
)
|
||||
|
||||
# Update the cached metadata for this node
|
||||
self._cache_node_metadata(node_id, class_type)
|
||||
|
||||
@@ -2,7 +2,8 @@ import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER, OVERWRITE
|
||||
from .overwrite_utils import collect_overwrite_params
|
||||
|
||||
|
||||
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
||||
@@ -31,11 +32,78 @@ class NodeMetadataExtractor:
|
||||
pass
|
||||
|
||||
class GenericNodeExtractor(NodeMetadataExtractor):
|
||||
"""Default extractor for nodes without specific handling"""
|
||||
"""Fallback extractor with type-signature-based detection.
|
||||
|
||||
When a node is not in the NODE_EXTRACTORS registry, the hook layer
|
||||
passes ``return_types`` from ``obj.RETURN_TYPES``:
|
||||
|
||||
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
|
||||
are checked for a model file name and stored as checkpoint metadata.
|
||||
* ``CONDITIONING`` output: common text input fields are checked for
|
||||
prompt text and stored as prompt metadata.
|
||||
"""
|
||||
|
||||
# Input field names that carry a model path in loader-style nodes.
|
||||
_MODEL_NAME_FIELDS = (
|
||||
"ckpt_name", "unet_name", "model_path", "model_name", "gguf_name",
|
||||
)
|
||||
|
||||
# Extensions used by checkpoint_scanner.py — only record values that look
|
||||
# like real model filenames to avoid capturing unrelated string fields.
|
||||
_MODEL_EXTENSIONS = {
|
||||
".ckpt", ".pt", ".pt2", ".bin", ".pth", ".safetensors", ".pkl", ".sft", ".gguf",
|
||||
}
|
||||
|
||||
# Input field names that may carry prompt text in encoder-style nodes.
|
||||
_TEXT_FIELDS = ("text", "clip_l", "t5xxl", "prompt", "positive", "negative")
|
||||
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
pass
|
||||
|
||||
def extract(node_id, inputs, outputs, metadata, return_types=None):
|
||||
if return_types is None:
|
||||
return
|
||||
|
||||
# — MODEL loader detection (checkpoint / UNET / GGUF) —
|
||||
if "MODEL" in return_types or any("MODEL" in str(t) for t in return_types):
|
||||
for field in GenericNodeExtractor._MODEL_NAME_FIELDS:
|
||||
val = inputs.get(field)
|
||||
if val and isinstance(val, str) and val.strip():
|
||||
name = val.strip()
|
||||
if not any(name.lower().endswith(ext) for ext in GenericNodeExtractor._MODEL_EXTENSIONS):
|
||||
continue
|
||||
_store_checkpoint_metadata(metadata, node_id, name)
|
||||
return
|
||||
|
||||
# — CONDITIONING encoder detection (CLIPTextEncode, Flux, custom) —
|
||||
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
|
||||
text = None
|
||||
for field in GenericNodeExtractor._TEXT_FIELDS:
|
||||
val = inputs.get(field)
|
||||
if val and isinstance(val, str) and val.strip():
|
||||
text = val.strip()
|
||||
break
|
||||
if text:
|
||||
prompt_data = metadata.setdefault(PROMPTS, {})
|
||||
prompt_data[node_id] = {
|
||||
"text": text,
|
||||
"node_id": node_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata, return_types=None):
|
||||
if return_types is None:
|
||||
return
|
||||
if "CONDITIONING" not in return_types and not any(
|
||||
"CONDITIONING" in str(t) for t in return_types
|
||||
):
|
||||
return
|
||||
if node_id not in metadata.get(PROMPTS, {}):
|
||||
return
|
||||
if outputs and isinstance(outputs, list) and len(outputs) > 0:
|
||||
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
|
||||
cond = outputs[0][0]
|
||||
if cond is not None:
|
||||
metadata[PROMPTS][node_id]["conditioning"] = cond
|
||||
|
||||
class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -144,6 +212,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
metadata[PROMPTS][node_id]["positive_encoded"] = positive_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):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -163,6 +343,251 @@ class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
||||
conditioning = outputs[0][0]
|
||||
metadata[PROMPTS][node_id]["conditioning"] = conditioning
|
||||
|
||||
|
||||
class MyOriginalWaifuTextExtractor(NodeMetadataExtractor):
|
||||
"""Extractor for ComfyUI-MyOriginalWaifu TextProvider nodes."""
|
||||
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
positive_text = inputs.get("positive", "")
|
||||
negative_text = inputs.get("negative", "")
|
||||
|
||||
if positive_text or negative_text:
|
||||
metadata[PROMPTS][node_id] = {
|
||||
"positive_text": positive_text,
|
||||
"negative_text": negative_text,
|
||||
"node_id": node_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata):
|
||||
output_tuple = _first_output_tuple(outputs)
|
||||
if not output_tuple or len(output_tuple) < 2:
|
||||
return
|
||||
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
prompt_metadata["positive_text"] = output_tuple[0]
|
||||
prompt_metadata["negative_text"] = output_tuple[1]
|
||||
|
||||
|
||||
class MyOriginalWaifuClipExtractor(NodeMetadataExtractor):
|
||||
"""Extractor for ComfyUI-MyOriginalWaifu ClipProvider nodes."""
|
||||
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
positive_text = inputs.get("positive", "")
|
||||
negative_text = inputs.get("negative", "")
|
||||
|
||||
if positive_text or negative_text:
|
||||
metadata[PROMPTS][node_id] = {
|
||||
"positive_text": positive_text,
|
||||
"negative_text": negative_text,
|
||||
"node_id": node_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata):
|
||||
output_tuple = _first_output_tuple(outputs)
|
||||
if not output_tuple or len(output_tuple) < 2:
|
||||
return
|
||||
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
prompt_metadata["positive_encoded"] = output_tuple[0]
|
||||
prompt_metadata["negative_encoded"] = output_tuple[1]
|
||||
|
||||
|
||||
def _ensure_prompt_metadata(metadata, node_id):
|
||||
if node_id not in metadata[PROMPTS]:
|
||||
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||
return metadata[PROMPTS][node_id]
|
||||
|
||||
|
||||
def _first_output_tuple(outputs):
|
||||
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||
return None
|
||||
first_output = outputs[0]
|
||||
if isinstance(first_output, tuple):
|
||||
return first_output
|
||||
return None
|
||||
|
||||
|
||||
def _record_conditioning_source(
|
||||
metadata, node_id, output_conditioning, input_conditionings
|
||||
):
|
||||
if output_conditioning is None:
|
||||
return
|
||||
|
||||
sources = [
|
||||
conditioning for conditioning in input_conditionings if conditioning is not None
|
||||
]
|
||||
if not sources:
|
||||
return
|
||||
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
prompt_metadata.setdefault("conditioning_sources", []).append(
|
||||
{
|
||||
"output": output_conditioning,
|
||||
"inputs": sources,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_variable_name(inputs):
|
||||
for key in ("key", "name", "variable_name", "tag", "text"):
|
||||
value = inputs.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _get_node_variable_name(metadata, node_id, inputs):
|
||||
variable_name = _get_variable_name(inputs)
|
||||
if variable_name:
|
||||
return variable_name
|
||||
|
||||
prompt = metadata.get("current_prompt")
|
||||
original_prompt = getattr(prompt, "original_prompt", None)
|
||||
if not original_prompt or node_id not in original_prompt:
|
||||
return None
|
||||
|
||||
node_data = original_prompt[node_id]
|
||||
variable_name = _get_variable_name(node_data.get("inputs", {}))
|
||||
if variable_name:
|
||||
return variable_name
|
||||
|
||||
widgets_values = node_data.get("widgets_values", [])
|
||||
if widgets_values and isinstance(widgets_values[0], str):
|
||||
return widgets_values[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ControlNetApplyAdvancedExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
if inputs.get("positive") is not None:
|
||||
prompt_metadata["orig_pos_cond"] = inputs["positive"]
|
||||
if inputs.get("negative") is not None:
|
||||
prompt_metadata["orig_neg_cond"] = inputs["negative"]
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata):
|
||||
output_tuple = _first_output_tuple(outputs)
|
||||
if not output_tuple:
|
||||
return
|
||||
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
positive_input = prompt_metadata.get("orig_pos_cond")
|
||||
negative_input = prompt_metadata.get("orig_neg_cond")
|
||||
|
||||
if len(output_tuple) >= 1:
|
||||
prompt_metadata["positive_encoded"] = output_tuple[0]
|
||||
_record_conditioning_source(
|
||||
metadata, node_id, output_tuple[0], [positive_input]
|
||||
)
|
||||
if len(output_tuple) >= 2:
|
||||
prompt_metadata["negative_encoded"] = output_tuple[1]
|
||||
_record_conditioning_source(
|
||||
metadata, node_id, output_tuple[1], [negative_input]
|
||||
)
|
||||
|
||||
|
||||
class ConditioningCombineExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
input_conditionings = []
|
||||
for input_name in inputs:
|
||||
if (
|
||||
input_name.startswith("conditioning")
|
||||
and inputs[input_name] is not None
|
||||
):
|
||||
input_conditionings.append(inputs[input_name])
|
||||
|
||||
if input_conditionings:
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
prompt_metadata["orig_conditionings"] = input_conditionings
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata):
|
||||
output_tuple = _first_output_tuple(outputs)
|
||||
if not output_tuple or len(output_tuple) < 1:
|
||||
return
|
||||
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
output_conditioning = output_tuple[0]
|
||||
prompt_metadata["conditioning"] = output_conditioning
|
||||
_record_conditioning_source(
|
||||
metadata,
|
||||
node_id,
|
||||
output_conditioning,
|
||||
prompt_metadata.get("orig_conditionings", []),
|
||||
)
|
||||
|
||||
|
||||
class SetNodeExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
variable_name = _get_node_variable_name(metadata, node_id, inputs)
|
||||
conditioning = inputs.get("CONDITIONING")
|
||||
if conditioning is None:
|
||||
conditioning = inputs.get("conditioning")
|
||||
if conditioning is None:
|
||||
return
|
||||
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
prompt_metadata["conditioning"] = conditioning
|
||||
if variable_name:
|
||||
prompt_metadata["variable_name"] = variable_name
|
||||
metadata[PROMPTS].setdefault("__conditioning_variables__", {})[
|
||||
variable_name
|
||||
] = conditioning
|
||||
|
||||
|
||||
class GetNodeExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
variable_name = _get_node_variable_name(metadata, node_id, inputs or {})
|
||||
if variable_name:
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
prompt_metadata["variable_name"] = variable_name
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata):
|
||||
output_tuple = _first_output_tuple(outputs)
|
||||
if not output_tuple or len(output_tuple) < 1:
|
||||
return
|
||||
|
||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||
output_conditioning = output_tuple[0]
|
||||
prompt_metadata["conditioning"] = output_conditioning
|
||||
|
||||
variable_name = prompt_metadata.get("variable_name")
|
||||
if not variable_name:
|
||||
return
|
||||
|
||||
input_conditioning = metadata[PROMPTS].get("__conditioning_variables__", {}).get(
|
||||
variable_name
|
||||
)
|
||||
_record_conditioning_source(
|
||||
metadata, node_id, output_conditioning, [input_conditioning]
|
||||
)
|
||||
|
||||
# Base Sampler Extractor to reduce code redundancy
|
||||
class BaseSamplerExtractor(NodeMetadataExtractor):
|
||||
"""Base extractor for sampler nodes with common functionality"""
|
||||
@@ -544,6 +969,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
class LoraTextLoaderManagerExtractor(NodeMetadataExtractor):
|
||||
"""Extract LoRA metadata from LoraTextLoaderLM (LoRA Text Loader).
|
||||
|
||||
The node accepts a `lora_syntax` STRING containing <lora:name:strength> tags
|
||||
(same format as the ComfyUI prompt), plus an optional `lora_stack`.
|
||||
This extractor parses the syntax string using the same regex as the node.
|
||||
"""
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
active_loras = []
|
||||
|
||||
# Process lora_stack if available (optional input)
|
||||
if "lora_stack" in inputs:
|
||||
lora_stack = inputs.get("lora_stack", [])
|
||||
for item in lora_stack:
|
||||
# lora_stack entries are (path, model_strength, clip_strength) tuples
|
||||
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": round(float(model_strength), 2)
|
||||
})
|
||||
|
||||
# Process lora_syntax string input
|
||||
if "lora_syntax" in inputs:
|
||||
lora_syntax = inputs.get("lora_syntax", "")
|
||||
if lora_syntax and isinstance(lora_syntax, str):
|
||||
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||
matches = re.findall(pattern, lora_syntax, re.IGNORECASE)
|
||||
for match in matches:
|
||||
lora_name = match[0]
|
||||
model_strength = float(match[1])
|
||||
active_loras.append({
|
||||
"name": lora_name,
|
||||
"strength": round(model_strength, 2)
|
||||
})
|
||||
|
||||
if active_loras:
|
||||
metadata[LORAS][node_id] = {
|
||||
"lora_list": active_loras,
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
|
||||
class FluxGuidanceExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -748,6 +1222,28 @@ class CR_ApplyControlNetStackExtractor(NodeMetadataExtractor):
|
||||
metadata[PROMPTS][node_id]["positive_encoded"] = transformed_positive
|
||||
metadata[PROMPTS][node_id]["negative_encoded"] = transformed_negative
|
||||
|
||||
class MetadataOverwriteExtractor(NodeMetadataExtractor):
|
||||
"""Extract manually specified metadata from MetadataOverwriteLM node.
|
||||
|
||||
Stores truthy input values under the OVERWRITE category so that
|
||||
extract_generation_params can merge them over the inferred params.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
overwrite_params = collect_overwrite_params(inputs)
|
||||
|
||||
if overwrite_params:
|
||||
metadata.setdefault(OVERWRITE, {})
|
||||
metadata[OVERWRITE][node_id] = {
|
||||
"parameters": overwrite_params,
|
||||
"node_id": node_id,
|
||||
}
|
||||
|
||||
|
||||
# Registry of node-specific extractors
|
||||
# Keys are node class names
|
||||
NODE_EXTRACTORS = {
|
||||
@@ -768,9 +1264,12 @@ NODE_EXTRACTORS = {
|
||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
||||
# ComfyUI-Easy-Use pre-sampling / seed
|
||||
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
|
||||
"easySeed": EasySeedExtractor, # easy seed
|
||||
# Loaders
|
||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
||||
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
|
||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||
@@ -780,10 +1279,13 @@ NODE_EXTRACTORS = {
|
||||
"GGUFLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
||||
"DiffusionModelLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
|
||||
"CheckpointLoaderKJ": CheckpointLoaderExtractor, # KJNodes
|
||||
"CheckpointLoaderLM": CheckpointLoaderExtractor, # LoRA Manager
|
||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
|
||||
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||
"TensorRTLoader": TensorRTLoaderExtractor,
|
||||
# Conditioning
|
||||
@@ -796,6 +1298,12 @@ NODE_EXTRACTORS = {
|
||||
"smZ_CLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/shiimizu/ComfyUI_smZNodes
|
||||
"CR_ApplyControlNetStack": CR_ApplyControlNetStackExtractor, # Add CR_ApplyControlNetStack
|
||||
"PCTextEncode": CLIPTextEncodeExtractor, # From https://github.com/asagi4/comfyui-prompt-control
|
||||
"TextProvider": MyOriginalWaifuTextExtractor, # ComfyUI-MyOriginalWaifu
|
||||
"ClipProvider": MyOriginalWaifuClipExtractor, # ComfyUI-MyOriginalWaifu
|
||||
"ControlNetApplyAdvanced": ControlNetApplyAdvancedExtractor,
|
||||
"ConditioningCombine": ConditioningCombineExtractor,
|
||||
"SetNode": SetNodeExtractor,
|
||||
"GetNode": GetNodeExtractor,
|
||||
# Latent
|
||||
"EmptyLatentImage": ImageSizeExtractor,
|
||||
# Flux
|
||||
@@ -803,5 +1311,7 @@ NODE_EXTRACTORS = {
|
||||
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
||||
# Image
|
||||
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
|
||||
# Metadata overwrite
|
||||
"MetadataOverwriteLM": MetadataOverwriteExtractor,
|
||||
# Add other nodes as needed
|
||||
}
|
||||
|
||||
42
py/metadata_collector/overwrite_utils.py
Normal file
42
py/metadata_collector/overwrite_utils.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Shared helpers for Metadata Overwrite node metadata collection.
|
||||
|
||||
Used by both the MetadataOverwriteLM node (execution time) and the
|
||||
MetadataOverwriteExtractor (hook time) so the conversion/filtering logic
|
||||
cannot drift between the two paths.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..utils.utils import model_patcher_to_name
|
||||
from .constants import CLIP_SKIP_SENTINEL, METADATA_OVERWRITE_FIELDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def collect_overwrite_params(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert node input values into non-default overwrite parameters.
|
||||
|
||||
For most fields, a falsy value (empty string, 0) means "not set" and is
|
||||
skipped. clip_skip uses a dedicated sentinel (-25) so that a wired value
|
||||
of 0 is preserved. The ``model`` field accepts either a manual string or
|
||||
a wired MODEL (ModelPatcher) connection; in the latter case the source
|
||||
model name is extracted from the patcher's ``cached_patcher_init`` and
|
||||
stored as a ComfyUI-style relative path.
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
for key in METADATA_OVERWRITE_FIELDS:
|
||||
value = values.get(key)
|
||||
if key == "model" and not isinstance(value, str):
|
||||
value = model_patcher_to_name(value)
|
||||
if value is None:
|
||||
logger.warning(
|
||||
"Could not extract model name from wired MODEL input "
|
||||
"(no cached_patcher_init); model metadata overwrite skipped"
|
||||
)
|
||||
if key == "clip_skip":
|
||||
if value != CLIP_SKIP_SENTINEL:
|
||||
result[key] = value
|
||||
elif value:
|
||||
result[key] = value
|
||||
return result
|
||||
233
py/metadata_ops/__init__.py
Normal file
233
py/metadata_ops/__init__.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Metadata operations — thin in-process wrappers around LoRA Manager internal services.
|
||||
|
||||
All functions are simple Python async functions that delegate to the
|
||||
appropriate internal service. They use **relative imports** within the
|
||||
``py`` package, so ``sys.modules`` caching works normally and there is no
|
||||
risk of double import or circular dependencies.
|
||||
|
||||
Usage (in-process, primary)::
|
||||
|
||||
from py.metadata_ops import list_base_models, read_metadata
|
||||
|
||||
models = await list_base_models()
|
||||
meta = await read_metadata("/path/to/model.safetensors")
|
||||
|
||||
Usage (subprocess, debugging / external)::
|
||||
|
||||
python -m py.metadata_ops base-models list
|
||||
python -m py.metadata_ops metadata read /path/to/model.safetensors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCANNER_TYPE_MAP: dict[str, str] = {
|
||||
"get_lora_scanner": "lora",
|
||||
"get_checkpoint_scanner": "checkpoint",
|
||||
"get_embedding_scanner": "embedding",
|
||||
}
|
||||
|
||||
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
||||
|
||||
|
||||
async def _find_model_entry(
|
||||
model_path: str,
|
||||
) -> tuple[object, object, str | None] | tuple[None, None, None]:
|
||||
"""Iterate all scanners and return the first (scanner, entry, getter_name)
|
||||
that owns *model_path*. Returns ``(None, None, None)`` when no scanner
|
||||
claims it.
|
||||
"""
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
normalized = os.path.normpath(model_path)
|
||||
for getter_name in SCANNER_GETTER_NAMES:
|
||||
getter = getattr(ServiceRegistry, getter_name, None)
|
||||
if getter is None:
|
||||
continue
|
||||
try:
|
||||
scanner = await getter()
|
||||
if scanner is None:
|
||||
continue
|
||||
cache = await scanner.get_cached_data()
|
||||
for entry in cache.raw_data:
|
||||
if os.path.normpath(entry.get("file_path", "")) == normalized:
|
||||
return scanner, entry, getter_name
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Scanner %s check failed for %s: %s",
|
||||
getter_name, model_path, exc,
|
||||
)
|
||||
return None, None, None
|
||||
|
||||
|
||||
async def _find_scanner_for_model(
|
||||
model_path: str,
|
||||
) -> tuple[object, object] | tuple[None, None]:
|
||||
"""Find the (scanner, cache_entry) responsible for *model_path*."""
|
||||
scanner, entry, _ = await _find_model_entry(model_path)
|
||||
return scanner, entry
|
||||
|
||||
|
||||
async def identify_model_type(model_path: str) -> str:
|
||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
|
||||
``\"embedding\"``) for *model_path*.
|
||||
|
||||
Falls back to ``\"lora\"`` when unknown.
|
||||
"""
|
||||
_, _, getter_name = await _find_model_entry(model_path)
|
||||
return SCANNER_TYPE_MAP[getter_name] if getter_name else "lora"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_base_models(limit: int = 0) -> List[str]:
|
||||
"""Return all valid CivitAI base model names.
|
||||
|
||||
Uses ``CivitaiBaseModelService.get_base_models()`` which merges a
|
||||
hardcoded list (``SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS``) with remote
|
||||
models fetched from the CivitAI API. Never empty — the hardcoded
|
||||
fallback always provides a complete set.
|
||||
|
||||
The result is sorted alphabetically. Pass *limit* = 0 for all models.
|
||||
"""
|
||||
from ..services.civitai_base_model_service import (
|
||||
CivitaiBaseModelService,
|
||||
)
|
||||
|
||||
try:
|
||||
service = await CivitaiBaseModelService.get_instance()
|
||||
response = await service.get_base_models()
|
||||
names: List[str] = response.get("models", [])
|
||||
except Exception as exc:
|
||||
logger.warning("list_base_models failed: %s", exc)
|
||||
names = []
|
||||
if limit > 0:
|
||||
return names[:limit]
|
||||
return names
|
||||
|
||||
|
||||
async def read_metadata(model_path: str) -> Dict[str, Any]:
|
||||
"""Load the full metadata payload for *model_path* from disk.
|
||||
|
||||
Returns an empty dict when the metadata file does not exist or cannot
|
||||
be parsed — never raises.
|
||||
"""
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
try:
|
||||
return await MetadataManager.load_metadata_payload(model_path) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("read_metadata failed for %s: %s", model_path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
async def apply_metadata_updates(
|
||||
model_path: str,
|
||||
updates: Dict[str, Any],
|
||||
) -> List[str]:
|
||||
"""Merge *updates* into the model's on-disk metadata and persist.
|
||||
|
||||
Returns the list of field names that actually changed.
|
||||
"""
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
metadata = await read_metadata(model_path)
|
||||
updated_fields: List[str] = []
|
||||
for key, value in updates.items():
|
||||
old = metadata.get(key)
|
||||
if old != value:
|
||||
metadata[key] = value
|
||||
updated_fields.append(key)
|
||||
if updated_fields:
|
||||
await MetadataManager.save_metadata(model_path, metadata)
|
||||
return updated_fields
|
||||
|
||||
|
||||
async def download_preview(
|
||||
model_path: str,
|
||||
url: str,
|
||||
*,
|
||||
target_width: int = 480,
|
||||
quality: int = 85,
|
||||
) -> str | None:
|
||||
"""Download a preview image from *url*, optimise to .webp, and save it.
|
||||
|
||||
The output file is placed alongside the model file with a ``.webp``
|
||||
extension. Returns the local file path on success, ``None`` on failure.
|
||||
"""
|
||||
from ..services.downloader import get_downloader
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
|
||||
if not url or not url.strip():
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
preview_dir = os.path.dirname(model_path)
|
||||
output_path = os.path.join(preview_dir, base_name + ".webp")
|
||||
|
||||
downloader = await get_downloader()
|
||||
|
||||
# Try in-memory download + optimise first
|
||||
success, content, _headers = await downloader.download_to_memory(
|
||||
url, use_auth=False,
|
||||
)
|
||||
if success and content:
|
||||
try:
|
||||
optimized_data, _ = ExifUtils.optimize_image(
|
||||
image_data=content,
|
||||
target_width=target_width,
|
||||
format="webp",
|
||||
quality=quality,
|
||||
preserve_metadata=False,
|
||||
)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(optimized_data)
|
||||
return output_path
|
||||
except Exception as exc:
|
||||
logger.warning("Preview optimisation failed, saving raw: %s", exc)
|
||||
# Fall through to raw save
|
||||
|
||||
# Fallback: download directly to file
|
||||
try:
|
||||
ok, _ = await downloader.download_file(url, output_path, use_auth=False)
|
||||
if ok:
|
||||
return output_path
|
||||
except Exception as exc:
|
||||
logger.warning("Preview fallback download failed for %s: %s", model_path, exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def refresh_cache(model_path: str) -> bool:
|
||||
"""Invalidate and reload the scanner cache entry for *model_path*.
|
||||
|
||||
Returns ``True`` when the model was found and the cache was refreshed.
|
||||
"""
|
||||
scanner, entry = await _find_scanner_for_model(model_path)
|
||||
if scanner is None:
|
||||
logger.warning("refresh_cache: no scanner found for %s", model_path)
|
||||
return False
|
||||
try:
|
||||
metadata = await read_metadata(model_path)
|
||||
if not metadata:
|
||||
logger.warning("refresh_cache: no metadata for %s", model_path)
|
||||
return False
|
||||
await scanner.update_single_model_cache(model_path, model_path, metadata)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("refresh_cache failed for %s: %s", model_path, exc)
|
||||
return False
|
||||
113
py/metadata_ops/__main__.py
Normal file
113
py/metadata_ops/__main__.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Subprocess entry point for ``metadata_ops`` (debugging / external use).
|
||||
|
||||
Usage::
|
||||
|
||||
python -m py.metadata_ops base-models list [--limit N]
|
||||
python -m py.metadata_ops metadata read <path>
|
||||
python -m py.metadata_ops metadata update <path> --json '{...}'
|
||||
python -m py.metadata_ops preview download <path> --url <url>
|
||||
python -m py.metadata_ops cache refresh <path>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="lmcli", description="LoRA Manager Agent CLI")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# base-models list
|
||||
base_models = sub.add_parser("base-models", aliases=["bm"])
|
||||
base_models_cmds = base_models.add_subparsers(dest="subcommand", required=True)
|
||||
base_models_list = base_models_cmds.add_parser("list")
|
||||
base_models_list.add_argument(
|
||||
"--limit", type=int, default=0, help="Max number of models (0 = all)"
|
||||
)
|
||||
|
||||
# metadata read
|
||||
meta = sub.add_parser("metadata", aliases=["md"])
|
||||
meta_cmds = meta.add_subparsers(dest="subcommand", required=True)
|
||||
meta_read = meta_cmds.add_parser("read")
|
||||
meta_read.add_argument("path", type=str, help="Model file path")
|
||||
|
||||
# metadata update
|
||||
meta_update = meta_cmds.add_parser("update")
|
||||
meta_update.add_argument("path", type=str, help="Model file path")
|
||||
meta_update.add_argument(
|
||||
"--json",
|
||||
type=str,
|
||||
required=True,
|
||||
help='JSON object of fields to update, e.g. \'{"base_model": "SDXL 1.0"}\'',
|
||||
)
|
||||
|
||||
# preview download
|
||||
prev = sub.add_parser("preview", aliases=["pv"])
|
||||
prev_cmds = prev.add_subparsers(dest="subcommand", required=True)
|
||||
prev_dl = prev_cmds.add_parser("download")
|
||||
prev_dl.add_argument("path", type=str, help="Model file path")
|
||||
prev_dl.add_argument("--url", type=str, required=True, help="Preview image URL")
|
||||
|
||||
# cache refresh
|
||||
cache = sub.add_parser("cache")
|
||||
cache_cmds = cache.add_subparsers(dest="subcommand", required=True)
|
||||
cache_refresh = cache_cmds.add_parser("refresh")
|
||||
cache_refresh.add_argument("path", type=str, help="Model file path")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
async def _run(args: argparse.Namespace) -> Any:
|
||||
from . import ( # lazy import so startup is fast
|
||||
list_base_models,
|
||||
read_metadata,
|
||||
apply_metadata_updates,
|
||||
download_preview,
|
||||
refresh_cache,
|
||||
)
|
||||
|
||||
cmd = args.command
|
||||
sub = args.subcommand
|
||||
|
||||
if cmd in ("base-models", "bm") and sub == "list":
|
||||
return await list_base_models(limit=args.limit)
|
||||
|
||||
if cmd in ("metadata", "md") and sub == "read":
|
||||
return await read_metadata(args.path)
|
||||
|
||||
if cmd in ("metadata", "md") and sub == "update":
|
||||
updates: Dict[str, Any] = json.loads(args.json)
|
||||
return await apply_metadata_updates(args.path, updates)
|
||||
|
||||
if cmd in ("preview", "pv") and sub == "download":
|
||||
return await download_preview(args.path, args.url)
|
||||
|
||||
if cmd == "cache" and sub == "refresh":
|
||||
return await refresh_cache(args.path)
|
||||
|
||||
raise ValueError(f"Unknown command: {cmd} {sub}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
result = asyncio.run(_run(args))
|
||||
# Always print as JSON so callers can parse reliably
|
||||
if isinstance(result, list):
|
||||
for item in result:
|
||||
print(item)
|
||||
elif isinstance(result, dict):
|
||||
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
|
||||
print()
|
||||
else:
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
".avif",
|
||||
".jxl",
|
||||
".mp4"
|
||||
)
|
||||
|
||||
|
||||
76
py/middleware/error_middleware.py
Normal file
76
py/middleware/error_middleware.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""JSON error middleware for API routes.
|
||||
|
||||
Ensures all responses to /api/* requests return valid JSON that the
|
||||
browser-extension frontend can JSON.parse() without crashing, even when
|
||||
the route does not exist (404) or the handler raises an exception (500).
|
||||
|
||||
Extension consumers call response.json() unconditionally — an HTML error
|
||||
page causes ``SyntaxError: unexpected end of data`` that leaks into the
|
||||
popup UI as a toast notification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def api_json_error(
|
||||
request: web.Request,
|
||||
handler: Callable[[web.Request], Awaitable[web.Response]],
|
||||
) -> web.Response:
|
||||
"""Return JSON ``{"success": false, "error": "..."}`` for API errors.
|
||||
|
||||
Only intercepts paths starting with ``/api/`` — all other routes
|
||||
(frontend pages, static files, WebSocket upgrades) pass through
|
||||
unchanged.
|
||||
"""
|
||||
if not request.path.startswith("/api/"):
|
||||
return await handler(request)
|
||||
|
||||
try:
|
||||
response = await handler(request)
|
||||
return response
|
||||
except web.HTTPException as exc:
|
||||
# Let redirects (301, 302, 307, 308) propagate — they are not errors.
|
||||
if exc.status < 400:
|
||||
raise
|
||||
|
||||
# Preview 404 is routine (file deleted from disk) — not worth a warning.
|
||||
logger_method = logger.warning
|
||||
if request.path.startswith("/api/lm/previews") and exc.status == 404:
|
||||
logger_method = logger.debug
|
||||
|
||||
logger_method(
|
||||
"API %s %s returned HTTP %d: %s",
|
||||
request.method,
|
||||
request.path,
|
||||
exc.status,
|
||||
exc.reason,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"{exc.status}: {exc.reason}"},
|
||||
status=exc.status,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"API %s %s raised unhandled exception: %s",
|
||||
request.method,
|
||||
request.path,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"500: Internal Server Error ({type(exc).__name__})",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
117
py/nodes/create_hook_lora.py
Normal file
117
py/nodes/create_hook_lora.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Create Hook LoRA (LoraManager) — multi-LoRA hook node compatible with ComfyUI's built-in hook pipeline.
|
||||
|
||||
Produces ``("HOOKS",)`` output that chains seamlessly with downstream hook consumers
|
||||
(ConditioningSetProperties, SetHookKeyframes, CombineHooks, SetClipHooks, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..utils.utils import get_lora_info_absolute
|
||||
from .utils import (
|
||||
FlexibleOptionalInputType,
|
||||
any_type,
|
||||
apply_lora_syntax_format,
|
||||
get_loras_list,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateHookLoraLM:
|
||||
NAME = "Create Hook LoRA (LoraManager)"
|
||||
CATEGORY = "Lora Manager/hooks"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
"AUTOCOMPLETE_TEXT_LORAS",
|
||||
{
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": (
|
||||
"Search and select LoRAs. Each LoRA gets its own "
|
||||
"model/clip strength. Hooks chain with prev_hooks."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
||||
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||
FUNCTION = "create_hook"
|
||||
|
||||
def create_hook(self, text: str, **kwargs):
|
||||
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
|
||||
|
||||
Each active LoRA from the widget is loaded and wrapped in a WeightHook
|
||||
via :func:`comfy.hooks.create_hook_lora`. All hooks are combined into a
|
||||
single group and returned alongside trigger words and a human-readable
|
||||
summary of the active LoRAs.
|
||||
"""
|
||||
del text # used by the frontend widget only
|
||||
|
||||
# Lazy imports: comfy is not available in CI/test environment at module level
|
||||
import comfy.hooks # type: ignore # noqa: C0415
|
||||
import comfy.utils # type: ignore # noqa: C0415
|
||||
|
||||
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
|
||||
|
||||
hook_group = prev_hooks.clone() if prev_hooks is not None else comfy.hooks.HookGroup()
|
||||
|
||||
all_trigger_words: list[str] = []
|
||||
active_loras: list[tuple[str, float, float]] = []
|
||||
|
||||
for lora in get_loras_list(kwargs):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
|
||||
lora_name = apply_lora_syntax_format(lora["name"])
|
||||
model_strength = float(lora["strength"])
|
||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||
|
||||
# Skip useless no-op entries (both strengths are zero)
|
||||
if model_strength == 0.0 and clip_strength == 0.0:
|
||||
continue
|
||||
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
if not lora_path or not os.path.isfile(lora_path):
|
||||
logger.warning("LoRA '%s' not found — skipping", lora_name)
|
||||
continue
|
||||
|
||||
try:
|
||||
lora_weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
|
||||
lora_hooks = comfy.hooks.create_hook_lora(
|
||||
lora=lora_weights,
|
||||
strength_model=model_strength,
|
||||
strength_clip=clip_strength,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to load LoRA '%s' — skipping", lora_name)
|
||||
continue
|
||||
hook_group = hook_group.clone_and_combine(lora_hooks)
|
||||
|
||||
active_loras.append((lora_name, model_strength, clip_strength))
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# Format trigger words (group mode separator)
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
# Format active LoRAs summary
|
||||
formatted_loras = []
|
||||
for name, model_s, clip_s in active_loras:
|
||||
if abs(model_s - clip_s) > 0.001:
|
||||
formatted_loras.append(
|
||||
f"<lora:{name}:{model_s}:{clip_s}>"
|
||||
)
|
||||
else:
|
||||
formatted_loras.append(f"<lora:{name}:{model_s}>")
|
||||
active_loras_text = " ".join(formatted_loras)
|
||||
|
||||
return (hook_group, trigger_words_text, active_loras_text)
|
||||
45
py/nodes/lora_info.py
Normal file
45
py/nodes/lora_info.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Lora Info display node — pure frontend node for showing selected LoRA info.
|
||||
|
||||
This node does NOT participate in workflow execution. Its single optional
|
||||
"lora_source" input exists solely as a wire-connection anchor so that the
|
||||
frontend can traverse the graph and push selection data to connected info nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class LoraInfoLM:
|
||||
"""Display node that shows filename and notes for the selected LoRA."""
|
||||
|
||||
NAME = "Lora Info (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = (
|
||||
"Displays information (filename, notes) about the currently selected "
|
||||
"LoRA. Connect any output from a LoRA Loader or Stacker to the "
|
||||
"lora_source input, then select a LoRA in the source widget — the "
|
||||
"info updates automatically. Does not affect workflow execution."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
OUTPUT_NODE = False
|
||||
FUNCTION = "noop"
|
||||
|
||||
def noop(self, **kwargs):
|
||||
# This node is display-only — no workflow execution needed.
|
||||
return ()
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
LoraInfoLM.NAME: LoraInfoLM,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
LoraInfoLM.NAME: "Lora Info (LoraManager)",
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
import comfy.sd # type: ignore
|
||||
import comfy.utils # type: ignore
|
||||
@@ -9,10 +8,12 @@ from ..utils.utils import get_lora_info_absolute
|
||||
from .utils import (
|
||||
FlexibleOptionalInputType,
|
||||
any_type,
|
||||
apply_lora_syntax_format,
|
||||
detect_nunchaku_model_kind,
|
||||
extract_lora_name,
|
||||
get_loras_list,
|
||||
nunchaku_load_lora,
|
||||
parse_lora_syntax,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -52,7 +53,7 @@ def _collect_widget_entries(kwargs):
|
||||
for lora in get_loras_list(kwargs):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
lora_name = lora["name"]
|
||||
lora_name = apply_lora_syntax_format(lora["name"])
|
||||
model_strength = float(lora["strength"])
|
||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
@@ -188,25 +189,10 @@ class LoraTextLoaderLM:
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras_from_text"
|
||||
|
||||
def parse_lora_syntax(self, text):
|
||||
"""Parse LoRA syntax from text input."""
|
||||
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||
|
||||
loras = []
|
||||
for match in matches:
|
||||
model_strength = float(match[1])
|
||||
loras.append({
|
||||
"name": match[0],
|
||||
"model_strength": model_strength,
|
||||
"clip_strength": float(match[2]) if match[2] else model_strength,
|
||||
})
|
||||
return loras
|
||||
|
||||
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
|
||||
"""Load LoRAs based on text syntax input."""
|
||||
lora_entries = _collect_stack_entries(lora_stack)
|
||||
for lora in self.parse_lora_syntax(lora_syntax):
|
||||
for lora in parse_lora_syntax(lora_syntax):
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
|
||||
lora_entries.append({
|
||||
"name": lora["name"],
|
||||
|
||||
@@ -1,26 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_STACK_INPUT_PATTERN = re.compile(r"^lora_stack(?:_([ab])|(\d+))$")
|
||||
|
||||
|
||||
def _is_stack_input(name: str) -> bool:
|
||||
return bool(_STACK_INPUT_PATTERN.match(name))
|
||||
|
||||
|
||||
def _stack_slot_number(name: str) -> int:
|
||||
"""Numeric slot used to order stack inputs; legacy a/b map to 1/2."""
|
||||
match = _STACK_INPUT_PATTERN.match(name)
|
||||
if not match:
|
||||
return -1
|
||||
letter, digits = match.group(1), match.group(2)
|
||||
if digits is not None:
|
||||
return int(digits)
|
||||
return 1 if letter == "a" else 2
|
||||
|
||||
|
||||
class _LoraStackOptionalInputs:
|
||||
"""Lookup that preserves explicit optional inputs and dynamic lora_stack slots."""
|
||||
|
||||
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
|
||||
self._explicit_inputs = explicit_inputs
|
||||
|
||||
def __contains__(self, item: object) -> bool:
|
||||
if not isinstance(item, str):
|
||||
return False
|
||||
return item in self._explicit_inputs or _is_stack_input(item)
|
||||
|
||||
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
|
||||
if key in self._explicit_inputs:
|
||||
return self._explicit_inputs[key]
|
||||
if _is_stack_input(key):
|
||||
return (
|
||||
"LORA_STACK",
|
||||
{
|
||||
"tooltip": "A LoRA stack to combine. Connect to add more inputs.",
|
||||
},
|
||||
)
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class LoraStackCombinerLM:
|
||||
NAME = "Lora Stack Combiner (LoraManager)"
|
||||
CATEGORY = "Lora Manager/stackers"
|
||||
DESCRIPTION = (
|
||||
"Combines multiple LoRA stacks into a single stack. "
|
||||
"Supports dynamic inputs: connect a stack to add more inputs."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
optional_inputs: dict[str, tuple[str, dict[str, Any]]] = {
|
||||
"lora_stack1": (
|
||||
"LORA_STACK",
|
||||
{
|
||||
"tooltip": "A LoRA stack to combine. Connect to add more inputs.",
|
||||
},
|
||||
),
|
||||
"lora_stack2": (
|
||||
"LORA_STACK",
|
||||
{
|
||||
"tooltip": "A LoRA stack to combine. Connect to add more inputs.",
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||
optional_inputs = _LoraStackOptionalInputs(optional_inputs) # type: ignore[assignment]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"lora_stack_a": ("LORA_STACK",),
|
||||
"lora_stack_b": ("LORA_STACK",),
|
||||
},
|
||||
"required": {},
|
||||
"optional": optional_inputs,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK",)
|
||||
RETURN_NAMES = ("LORA_STACK",)
|
||||
FUNCTION = "combine_stacks"
|
||||
|
||||
def combine_stacks(self, lora_stack_a, lora_stack_b):
|
||||
combined_stack = []
|
||||
def combine_stacks(self, lora_stack1=None, lora_stack2=None, **kwargs):
|
||||
stacks = {
|
||||
"lora_stack1": lora_stack1,
|
||||
"lora_stack2": lora_stack2,
|
||||
}
|
||||
for key, value in kwargs.items():
|
||||
if _is_stack_input(key) and value is not None:
|
||||
stacks[key] = value
|
||||
|
||||
if lora_stack_a:
|
||||
combined_stack.extend(lora_stack_a)
|
||||
if lora_stack_b:
|
||||
combined_stack.extend(lora_stack_b)
|
||||
combined_stack = []
|
||||
for key in sorted(stacks, key=_stack_slot_number):
|
||||
stack = stacks[key]
|
||||
if stack:
|
||||
combined_stack.extend(stack)
|
||||
|
||||
return (combined_stack,)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
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
|
||||
|
||||
@@ -48,7 +48,7 @@ class LoraStackerLM:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
lora_name = apply_lora_syntax_format(lora['name'])
|
||||
model_strength = float(lora['strength'])
|
||||
# Get clip strength - use model strength as default if not specified
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
62
py/nodes/lora_syntax_to_path.py
Normal file
62
py/nodes/lora_syntax_to_path.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Node to resolve `<lora:name:strength>` syntax to absolute file system paths.
|
||||
|
||||
Takes the loaded_loras / active_loras STRING output from LoraLoaderLM or
|
||||
LoraStackerLM and resolves each lora name to its absolute path on disk via
|
||||
the scanner cache. Unknown names are returned as-is.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from ..utils.utils import get_lora_info_absolute
|
||||
from .utils import parse_lora_syntax
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoraSyntaxToPath:
|
||||
NAME = "LoRA Syntax → Path (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"lora_syntax": (
|
||||
"STRING",
|
||||
{
|
||||
"forceInput": True,
|
||||
"multiline": True,
|
||||
"tooltip": (
|
||||
"<lora:name:strength> formatted text from "
|
||||
"loaded_loras / active_loras output"
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("paths",)
|
||||
FUNCTION = "resolve"
|
||||
|
||||
def resolve(self, lora_syntax: str) -> tuple[str]:
|
||||
"""Parse <lora:...> syntax and resolve each name to its absolute path."""
|
||||
if not lora_syntax or not lora_syntax.strip():
|
||||
logger.info("Received empty lora_syntax input")
|
||||
return ("",)
|
||||
|
||||
parsed = parse_lora_syntax(lora_syntax)
|
||||
if not parsed:
|
||||
logger.info("No valid <lora:...> entries found in input")
|
||||
return ("",)
|
||||
|
||||
paths: list[str] = []
|
||||
for entry in parsed:
|
||||
try:
|
||||
absolute_path, _ = get_lora_info_absolute(entry["name"])
|
||||
paths.append(absolute_path)
|
||||
except Exception:
|
||||
logger.warning("Failed to resolve lora '%s', skipping", entry["name"])
|
||||
continue
|
||||
|
||||
return ("\n".join(paths),)
|
||||
169
py/nodes/metadata_overwrite.py
Normal file
169
py/nodes/metadata_overwrite.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Metadata Overwrite node — allows users to manually specify generation parameters
|
||||
that override the automatically collected/inferred metadata.
|
||||
|
||||
Most inputs have falsy defaults (empty string / 0) which are skipped.
|
||||
clip_skip uses a sentinel default (-25) so that a wired value of 0 is
|
||||
preserved — both ComfyUI and A1111 conventions have no meaningful 0 value,
|
||||
but users may wire 0 to express "no clip skip / default".
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..metadata_collector.constants import CLIP_SKIP_SENTINEL as _CLIP_SKIP_SENTINEL
|
||||
from ..metadata_collector.overwrite_utils import collect_overwrite_params
|
||||
|
||||
|
||||
class MetadataOverwriteLM:
|
||||
NAME = "Metadata Overwrite (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = (
|
||||
"Manually specify generation parameters to override automatically collected "
|
||||
"metadata. Only filled/connected inputs will take effect — empty defaults "
|
||||
"are ignored."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> dict[str, Any]:
|
||||
return {
|
||||
"optional": {
|
||||
"prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "Positive prompt. Only overwrites when non-empty.",
|
||||
},
|
||||
),
|
||||
"negative_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": "Negative prompt. Only overwrites when non-empty.",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"control_after_generate": False,
|
||||
"tooltip": "Seed value. Only overwrites when > 0.",
|
||||
},
|
||||
),
|
||||
"steps": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 10000,
|
||||
"tooltip": "Number of steps. Only overwrites when > 0.",
|
||||
},
|
||||
),
|
||||
"cfg_scale": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 100.0,
|
||||
"tooltip": "CFG scale. Only overwrites when > 0.",
|
||||
},
|
||||
),
|
||||
"sampler": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Sampler name. Only overwrites when non-empty.",
|
||||
},
|
||||
),
|
||||
"scheduler": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Scheduler name. Only overwrites when non-empty.",
|
||||
},
|
||||
),
|
||||
"model": (
|
||||
"STRING,MODEL",
|
||||
{
|
||||
"default": "",
|
||||
"widgetType": "STRING",
|
||||
"tooltip": (
|
||||
"The checkpoint or diffusion model (UNet) used "
|
||||
"for generation. Fill in the name manually or "
|
||||
"connect a MODEL output — the model name is then "
|
||||
"extracted automatically. Only overwrites when "
|
||||
"non-empty."
|
||||
),
|
||||
},
|
||||
),
|
||||
"loras": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": (
|
||||
"LoRA syntax, e.g. <lora:name:strength> "
|
||||
"or <lora:name:model_strength:clip_strength>, "
|
||||
"separated by spaces. Only overwrites when non-empty."
|
||||
),
|
||||
},
|
||||
),
|
||||
"size": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Image size in WIDTHxHEIGHT format (e.g. 512x768). "
|
||||
"Only overwrites when non-empty."
|
||||
),
|
||||
},
|
||||
),
|
||||
"clip_skip": (
|
||||
"INT",
|
||||
{
|
||||
"default": _CLIP_SKIP_SENTINEL,
|
||||
"min": -25,
|
||||
"max": 24,
|
||||
"tooltip": (
|
||||
"Clip skip (ComfyUI: -24..-1, A1111: 1+). "
|
||||
"Default -25 means not set — any other value "
|
||||
"overwrites."
|
||||
),
|
||||
},
|
||||
),
|
||||
"additional_data": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"tooltip": (
|
||||
"Additional data to embed in the image metadata. "
|
||||
"Inserted between Clip skip and Model hash in the "
|
||||
"A1111-compatible parameters string. "
|
||||
'Example: "Copyright": "Some license info"'
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("METADATA",)
|
||||
RETURN_NAMES = ("metadata",)
|
||||
FUNCTION = "collect_metadata"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def collect_metadata(self, **kwargs: Any) -> tuple[dict[str, Any]]:
|
||||
"""Collect non-default input values into a metadata dict.
|
||||
|
||||
For most fields, a falsy value (empty string, 0) means "not set"
|
||||
and is skipped. clip_skip uses a dedicated sentinel (-25) so that
|
||||
a wired value of 0 is preserved and reaches the metadata pipeline.
|
||||
|
||||
The ``model`` field accepts either a manual string or a wired MODEL
|
||||
(ModelPatcher) connection; in the latter case the underlying model
|
||||
name is extracted from the patcher's ``cached_patcher_init`` and
|
||||
stored as a ComfyUI-style relative path.
|
||||
"""
|
||||
return (collect_overwrite_params(kwargs),)
|
||||
@@ -1,16 +1,171 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
import numpy as np
|
||||
import folder_paths # type: ignore
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..metadata_collector.metadata_processor import MetadataProcessor
|
||||
from ..metadata_collector import get_metadata
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
|
||||
from PIL import Image, PngImagePlugin
|
||||
import piexif
|
||||
import logging
|
||||
|
||||
# Civitai-compatible sampler name mapping: ComfyUI internal → A1111 display name
|
||||
CIVITAI_SAMPLER_MAP = {
|
||||
"euler": "Euler",
|
||||
"euler_ancestral": "Euler a",
|
||||
"lms": "LMS",
|
||||
"heun": "Heun",
|
||||
"dpm_2": "DPM2",
|
||||
"dpm_2_ancestral": "DPM2 a",
|
||||
"dpmpp_2s_ancestral": "DPM++ 2S a",
|
||||
"dpmpp_2m": "DPM++ 2M",
|
||||
"dpmpp_sde": "DPM++ SDE",
|
||||
"dpmpp_sde_gpu": "DPM++ SDE",
|
||||
"dpmpp_2m_sde": "DPM++ 2M SDE",
|
||||
"dpmpp_2m_sde_gpu": "DPM++ 2M SDE",
|
||||
"dpmpp_3m_sde": "DPM++ 3M SDE",
|
||||
"dpm_fast": "DPM fast",
|
||||
"dpm_adaptive": "DPM adaptive",
|
||||
"ddim": "DDIM",
|
||||
"plms": "PLMS",
|
||||
"uni_pc_bh2": "UniPC",
|
||||
"uni_pc": "UniPC",
|
||||
"lcm": "LCM",
|
||||
}
|
||||
|
||||
# Base model display name → AIR URN slug
|
||||
# Sourced from civitai source: src/shared/constants/basemodel.constants.ts
|
||||
BASE_MODEL_AIR_SLUG = {
|
||||
# Stable Diffusion family
|
||||
"SD 1.4": "sd1",
|
||||
"SD 1.5": "sd1",
|
||||
"SD 1.5 LCM": "sd1",
|
||||
"SD 1.5 Hyper": "sd1",
|
||||
"SD 2.0": "sd2",
|
||||
"SD 2.0 768": "sd2",
|
||||
"SD 2.1": "sd2",
|
||||
"SD 2.1 768": "sd2",
|
||||
"SD 2.1 Unclip": "sd2",
|
||||
"SD 3.0": "sd3",
|
||||
"SD 3.5": "sd35",
|
||||
"SD 3.5 Large": "sd35",
|
||||
"SD 3.5 Large Turbo": "sd35",
|
||||
"SD 3.5 Medium": "sd35",
|
||||
"SDXL 0.9": "sdxl",
|
||||
"SDXL 1.0": "sdxl",
|
||||
"SDXL 1.0 LCM": "sdxl",
|
||||
"SDXL Lightning": "sdxl",
|
||||
"SDXL Hyper": "sdxl",
|
||||
"SDXL Turbo": "sdxl",
|
||||
"SDXL Distilled": "sdxldistilled",
|
||||
"Stable Cascade": "scascade",
|
||||
"Stable Video Diffusion": "svd",
|
||||
"SVD": "svd",
|
||||
"SVD XT": "svdxt",
|
||||
|
||||
# SDXL community fine-tunes
|
||||
"Pony": "pony",
|
||||
"Pony Diffusion": "pony",
|
||||
"Illustrious": "illustrious",
|
||||
"NoobAI": "noobai",
|
||||
"Animagine": "illustrious",
|
||||
|
||||
# Flux family
|
||||
"Flux.1": "flux1",
|
||||
"Flux.1 D": "flux1",
|
||||
"Flux.1 S": "flux1",
|
||||
"Flux.1 Krea": "fluxkrea",
|
||||
"Flux.1 Kontext": "flux1kontext",
|
||||
"Flux.2": "flux2",
|
||||
"Flux.2 D": "flux2",
|
||||
"Flux.2 Klein 9B": "flux2klein_9b",
|
||||
"Flux.2 Klein 9B Base": "flux2klein_9b_base",
|
||||
"Flux.2 Klein 4B": "flux2klein_4b",
|
||||
"Flux.2 Klein 4B Base": "flux2klein_4b_base",
|
||||
|
||||
# Other image models (sorted alphabetically)
|
||||
"AuraFlow": "auraflow",
|
||||
"Chroma": "chroma",
|
||||
"HiDream": "hidream",
|
||||
"HiDream-O1": "hidream-o1",
|
||||
"Hunyuan DiT": "hydit1",
|
||||
"Hunyuan Video": "hyv1",
|
||||
"Kolors": "kolors",
|
||||
"Lumina": "lumina",
|
||||
"Mochi": "mochi",
|
||||
"ODOR": "odor",
|
||||
"PixArt Alpha": "pixarta",
|
||||
"PixArt Sigma": "pixarte",
|
||||
"Playground v2": "playgroundv2",
|
||||
"Playground v2.5": "playgroundv2",
|
||||
"Pony Diffusion V7": "ponyv7",
|
||||
|
||||
# Video models
|
||||
"CogVideoX": "cogvideox",
|
||||
"LTX Video": "ltxv",
|
||||
"LTX Video 2": "ltxv2",
|
||||
"LTX Video 2.3": "ltxv23",
|
||||
"Wan Video": "wanvideo",
|
||||
"Wan Video 1.3B T2V": "wanvideo_13b_t2v",
|
||||
"Wan Video 14B T2V": "wanvideo_14b_t2v",
|
||||
"Wan Video 14B I2V 480p": "wanvideo_14b_i2v_480p",
|
||||
"Wan Video 14B I2V 720p": "wanvideo_14b_i2v_720p",
|
||||
|
||||
# Third-party / proprietary image models
|
||||
"Boogu": "boogu",
|
||||
"Ernie": "ernie",
|
||||
"Grok": "grok",
|
||||
"HappyHorse": "happyhorse",
|
||||
"Ideogram": "ideogram",
|
||||
"Ideogram 4.0": "ideogram",
|
||||
"Imagen": "imagen4",
|
||||
"Imagen 4": "imagen4",
|
||||
"Krea": "krea2",
|
||||
"Krea 2": "krea2",
|
||||
"Lens": "lens",
|
||||
"MAI": "mai",
|
||||
"Nano Banana": "nanobanana",
|
||||
"OpenAI": "openai",
|
||||
"Reve": "reve",
|
||||
"Reve 2": "reve",
|
||||
"Reve 2.1": "reve",
|
||||
"Seedream": "seedream",
|
||||
"Sora": "sora2",
|
||||
"Sora 2": "sora2",
|
||||
"Veo": "veo3",
|
||||
"Veo 2": "veo3",
|
||||
"Veo 3": "veo3",
|
||||
"ZImageTurbo": "zimageturbo",
|
||||
"ZImageBase": "zimagebase",
|
||||
"ZImage": "zimagebase",
|
||||
|
||||
# Third-party video models
|
||||
"Hailuo by MiniMax": "minimax",
|
||||
"Haiper": "haiper",
|
||||
"Kling": "kling",
|
||||
"Lightricks": "lightricks",
|
||||
"Seedance": "seedance",
|
||||
"Vidu": "vidu",
|
||||
|
||||
# Qwen family
|
||||
"Qwen": "qwen",
|
||||
"Qwen 2": "qwen2",
|
||||
|
||||
# Anima
|
||||
"Anima": "anima",
|
||||
|
||||
# Special
|
||||
"Upscaler": "upscaler",
|
||||
"Other": "other",
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -65,11 +220,29 @@ class SaveImageLM:
|
||||
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.",
|
||||
},
|
||||
),
|
||||
"webp_method": (
|
||||
"INT",
|
||||
{
|
||||
"default": 6,
|
||||
"min": 0,
|
||||
"max": 6,
|
||||
"tooltip": "WebP compression method (0-6). 0=fastest/largest, 6=slowest/smallest. Only applies when file_format is 'webp'.",
|
||||
},
|
||||
),
|
||||
"jpeg_subsampling": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 2,
|
||||
"tooltip": "JPEG chroma subsampling level. 0=4:4:4 (best quality), 1=4:2:2, 2=4:2:0 (smallest files). Only applies when file_format is 'jpeg'.",
|
||||
},
|
||||
),
|
||||
"embed_workflow": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.",
|
||||
"tooltip": "When enabled, saved images store the complete workflow. Drag the image back into ComfyUI to restore the original node graph. PNG and WebP only.",
|
||||
},
|
||||
),
|
||||
"save_with_metadata": (
|
||||
@@ -79,6 +252,13 @@ class SaveImageLM:
|
||||
"tooltip": "When enabled, embeds generation parameters into the saved image metadata. Disable to skip writing generation metadata.",
|
||||
},
|
||||
),
|
||||
"add_loras_to_prompt": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "When enabled, appends the LoRA syntax line (e.g. <lora:name:strength>) after the positive prompt in the saved metadata.",
|
||||
},
|
||||
),
|
||||
"add_counter_to_filename": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
@@ -86,6 +266,13 @@ class SaveImageLM:
|
||||
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images.",
|
||||
},
|
||||
),
|
||||
"save_as_recipe": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Also saves each generated image as a LoRA Manager recipe.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"id": "UNIQUE_ID",
|
||||
@@ -130,148 +317,197 @@ class SaveImageLM:
|
||||
|
||||
return None
|
||||
|
||||
def format_metadata(self, metadata_dict):
|
||||
"""Format metadata in the requested format similar to userComment example"""
|
||||
if not metadata_dict:
|
||||
return ""
|
||||
def _resolve_model_cache_entry(self, scanner_type: str, name: str):
|
||||
"""Resolve model hash, civitai metadata, and base_model from scanner cache.
|
||||
Returns (hash_str, civitai_dict, base_model_str). All values are empty defaults when not found."""
|
||||
scanner = ServiceRegistry.get_service_sync(scanner_type)
|
||||
if scanner is None or not name:
|
||||
return "", {}, ""
|
||||
|
||||
# Helper function to only add parameter if value is not None
|
||||
def add_param_if_not_none(param_list, label, value):
|
||||
if value is not None:
|
||||
param_list.append(f"{label}: {value}")
|
||||
entry = self._get_cached_model_by_name(scanner, name)
|
||||
if entry is None:
|
||||
basename = os.path.splitext(os.path.basename(name))[0]
|
||||
hash_val = scanner.get_hash_by_filename(basename)
|
||||
return (hash_val or "").lower(), {}, ""
|
||||
|
||||
hash_val = (entry.get("sha256") or "").lower()
|
||||
civitai = entry.get("civitai") or {}
|
||||
base_model = entry.get("base_model") or ""
|
||||
return hash_val, civitai, base_model
|
||||
|
||||
@staticmethod
|
||||
def _get_civitai_sampler_name(sampler_name: str, scheduler: str) -> str:
|
||||
if sampler_name in CIVITAI_SAMPLER_MAP:
|
||||
civitai_name = CIVITAI_SAMPLER_MAP[sampler_name]
|
||||
if scheduler == "karras":
|
||||
civitai_name += " Karras"
|
||||
elif scheduler == "exponential":
|
||||
civitai_name += " Exponential"
|
||||
return civitai_name
|
||||
else:
|
||||
if scheduler and scheduler != "normal":
|
||||
return f"{sampler_name}_{scheduler}"
|
||||
return sampler_name
|
||||
|
||||
@staticmethod
|
||||
def _build_air_string(base_model: str, model_type: str, model_id: int, version_id: int) -> str:
|
||||
slug = BASE_MODEL_AIR_SLUG.get(base_model, "other")
|
||||
type_lower = model_type.lower() if model_type else "other"
|
||||
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
|
||||
|
||||
def format_metadata(self, metadata_dict: dict, add_loras_to_prompt: bool = False) -> str:
|
||||
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
|
||||
if not metadata_dict: return ""
|
||||
|
||||
# Extract the prompt and negative prompt
|
||||
prompt = metadata_dict.get("prompt", "")
|
||||
negative_prompt = metadata_dict.get("negative_prompt", "")
|
||||
|
||||
# Extract loras from the prompt if present
|
||||
steps = metadata_dict.get("steps")
|
||||
cfg = metadata_dict.get("guidance")
|
||||
if cfg is None:
|
||||
cfg = metadata_dict.get("cfg_scale")
|
||||
if cfg is None:
|
||||
cfg = metadata_dict.get("cfg")
|
||||
seed = metadata_dict.get("seed")
|
||||
size = metadata_dict.get("size")
|
||||
sampler = metadata_dict.get("sampler") or ""
|
||||
scheduler = metadata_dict.get("scheduler") or "normal"
|
||||
checkpoint = metadata_dict.get("checkpoint") or ""
|
||||
loras_text = metadata_dict.get("loras", "")
|
||||
lora_hashes = {}
|
||||
clip_skip = metadata_dict.get("clip_skip")
|
||||
|
||||
# If loras are found, add them on a new line after the prompt
|
||||
# Parse LoRA entries from <lora:name:strength> format
|
||||
lora_entries: list[tuple[str, float]] = []
|
||||
if loras_text:
|
||||
prompt_with_loras = f"{prompt}\n{loras_text}"
|
||||
for match in re.findall(r"<lora:([^:]+):([^>]+)>", loras_text):
|
||||
lora_name, strength_str = match
|
||||
try:
|
||||
strength = float(strength_str)
|
||||
except (ValueError, TypeError):
|
||||
strength = 1.0
|
||||
lora_entries.append((lora_name, strength))
|
||||
|
||||
# Extract lora names from the format <lora:name:strength>
|
||||
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", loras_text)
|
||||
# Resolve checkpoint hash and Civitai data from local cache
|
||||
ckpt_hash, ckpt_civitai, ckpt_base_model = "", {}, ""
|
||||
ckpt_display_name = ""
|
||||
if checkpoint:
|
||||
ckpt_hash, ckpt_civitai, ckpt_base_model = self._resolve_model_cache_entry(
|
||||
"checkpoint_scanner", checkpoint
|
||||
)
|
||||
ckpt_display_name = os.path.splitext(os.path.basename(checkpoint))[0]
|
||||
|
||||
# Get hash for each lora
|
||||
for lora_name, strength in lora_matches:
|
||||
hash_value = self.get_lora_hash(lora_name)
|
||||
if hash_value:
|
||||
lora_hashes[lora_name] = hash_value
|
||||
else:
|
||||
prompt_with_loras = prompt
|
||||
# Resolve LoRA hash and Civitai data from local cache
|
||||
loras_data: list[dict] = []
|
||||
for lora_name, strength in lora_entries:
|
||||
lora_hash, lora_civitai, lora_base_model = self._resolve_model_cache_entry(
|
||||
"lora_scanner", lora_name
|
||||
)
|
||||
loras_data.append({
|
||||
"name": lora_name,
|
||||
"strength": strength,
|
||||
"hash": lora_hash,
|
||||
"civitai": lora_civitai,
|
||||
"base_model": lora_base_model,
|
||||
})
|
||||
|
||||
# Format the first part (prompt and loras)
|
||||
metadata_parts = [prompt_with_loras]
|
||||
# Build Hashes JSON (A1111 / Civitai standard format)
|
||||
hashes: dict[str, str] = {}
|
||||
if ckpt_hash:
|
||||
hashes["model"] = ckpt_hash[:10].upper()
|
||||
for lora in loras_data:
|
||||
if lora["hash"]:
|
||||
hashes[f"LORA:{lora['name']}"] = lora["hash"][:10].upper()
|
||||
|
||||
# Add negative prompt
|
||||
# Build Civitai resources JSON array
|
||||
civitai_resources: list[dict] = []
|
||||
if ckpt_civitai.get("id", 0) > 0:
|
||||
ckpt_resource: dict = {}
|
||||
ckpt_type = (ckpt_civitai.get("model") or {}).get("type", "Checkpoint")
|
||||
model_id = ckpt_civitai.get("modelId", 0)
|
||||
version_id = ckpt_civitai.get("id", 0)
|
||||
if model_id and version_id:
|
||||
ckpt_resource["air"] = self._build_air_string(
|
||||
ckpt_base_model, ckpt_type, int(model_id), int(version_id)
|
||||
)
|
||||
elif version_id:
|
||||
ckpt_resource["modelVersionId"] = int(version_id)
|
||||
if ckpt_civitai.get("name"):
|
||||
ckpt_resource["versionName"] = ckpt_civitai["name"]
|
||||
if ckpt_resource:
|
||||
civitai_resources.append(ckpt_resource)
|
||||
|
||||
for lora in loras_data:
|
||||
lora_civitai = lora["civitai"]
|
||||
if not lora_civitai or lora_civitai.get("id", 0) <= 0:
|
||||
continue
|
||||
lora_resource: dict = {"weight": lora["strength"]}
|
||||
lora_type = (lora_civitai.get("model") or {}).get("type", "LORA")
|
||||
model_id = lora_civitai.get("modelId", 0)
|
||||
version_id = lora_civitai.get("id", 0)
|
||||
if model_id and version_id:
|
||||
lora_resource["air"] = self._build_air_string(
|
||||
lora["base_model"], lora_type, int(model_id), int(version_id)
|
||||
)
|
||||
elif version_id:
|
||||
lora_resource["modelVersionId"] = int(version_id)
|
||||
if lora_civitai.get("name"):
|
||||
lora_resource["versionName"] = lora_civitai["name"]
|
||||
civitai_resources.append(lora_resource)
|
||||
|
||||
sampler_name = CIVITAI_SAMPLER_MAP.get(sampler, sampler) if sampler else None
|
||||
|
||||
scheduler_mapping = {
|
||||
"normal": "Normal",
|
||||
"karras": "Karras",
|
||||
"exponential": "Exponential",
|
||||
"sgm_uniform": "SGM Uniform",
|
||||
"sgm_quadratic": "SGM Quadratic",
|
||||
}
|
||||
scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None
|
||||
|
||||
# Build output lines
|
||||
prompt_line = prompt if prompt else ""
|
||||
if add_loras_to_prompt and loras_text:
|
||||
prompt_line = f"{prompt_line}\n{loras_text}" if prompt_line else loras_text
|
||||
lines = [prompt_line] if prompt_line else [""]
|
||||
if negative_prompt:
|
||||
metadata_parts.append(f"Negative prompt: {negative_prompt}")
|
||||
lines.append(f"Negative prompt: {negative_prompt}")
|
||||
|
||||
# Format the second part (generation parameters)
|
||||
params = []
|
||||
|
||||
# Add standard parameters in the correct order
|
||||
if "steps" in metadata_dict:
|
||||
add_param_if_not_none(params, "Steps", metadata_dict.get("steps"))
|
||||
|
||||
# Combine sampler and scheduler information
|
||||
sampler_name = None
|
||||
scheduler_name = None
|
||||
|
||||
if "sampler" in metadata_dict:
|
||||
sampler = metadata_dict.get("sampler")
|
||||
# Convert ComfyUI sampler names to user-friendly names
|
||||
sampler_mapping = {
|
||||
"euler": "Euler",
|
||||
"euler_ancestral": "Euler a",
|
||||
"dpm_2": "DPM2",
|
||||
"dpm_2_ancestral": "DPM2 a",
|
||||
"heun": "Heun",
|
||||
"dpm_fast": "DPM fast",
|
||||
"dpm_adaptive": "DPM adaptive",
|
||||
"lms": "LMS",
|
||||
"dpmpp_2s_ancestral": "DPM++ 2S a",
|
||||
"dpmpp_sde": "DPM++ SDE",
|
||||
"dpmpp_sde_gpu": "DPM++ SDE",
|
||||
"dpmpp_2m": "DPM++ 2M",
|
||||
"dpmpp_2m_sde": "DPM++ 2M SDE",
|
||||
"dpmpp_2m_sde_gpu": "DPM++ 2M SDE",
|
||||
"ddim": "DDIM",
|
||||
}
|
||||
sampler_name = sampler_mapping.get(sampler, sampler)
|
||||
|
||||
if "scheduler" in metadata_dict:
|
||||
scheduler = metadata_dict.get("scheduler")
|
||||
scheduler_mapping = {
|
||||
"normal": "Simple",
|
||||
"karras": "Karras",
|
||||
"exponential": "Exponential",
|
||||
"sgm_uniform": "SGM Uniform",
|
||||
"sgm_quadratic": "SGM Quadratic",
|
||||
}
|
||||
scheduler_name = scheduler_mapping.get(scheduler, scheduler)
|
||||
|
||||
# Add combined sampler and scheduler information
|
||||
params: list[str] = []
|
||||
if steps is not None:
|
||||
params.append(f"Steps: {steps}")
|
||||
if sampler_name:
|
||||
if scheduler_name:
|
||||
params.append(f"Sampler: {sampler_name} {scheduler_name}")
|
||||
else:
|
||||
params.append(f"Sampler: {sampler_name}")
|
||||
if cfg is not None:
|
||||
params.append(f"CFG scale: {cfg}")
|
||||
if seed is not None:
|
||||
params.append(f"Seed: {seed}")
|
||||
if size:
|
||||
params.append(f"Size: {size}")
|
||||
if clip_skip is not None:
|
||||
try:
|
||||
params.append(f"Clip skip: {abs(int(clip_skip))}")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
additional_data = metadata_dict.get("additional_data", "")
|
||||
if additional_data:
|
||||
params.append(additional_data)
|
||||
if ckpt_hash:
|
||||
params.append(f"Model hash: {ckpt_hash[:10].upper()}")
|
||||
if ckpt_display_name:
|
||||
params.append(f"Model: {ckpt_display_name}")
|
||||
if hashes:
|
||||
params.append(f"Hashes: {json.dumps(hashes, separators=(',', ':'))}")
|
||||
params.append("Version: ComfyUI")
|
||||
if civitai_resources:
|
||||
params.append(
|
||||
f"Civitai resources: {json.dumps(civitai_resources, separators=(',', ':'))}"
|
||||
)
|
||||
|
||||
# CFG scale (Use guidance if available, otherwise fall back to cfg_scale or cfg)
|
||||
if "guidance" in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("guidance"))
|
||||
elif "cfg_scale" in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg_scale"))
|
||||
elif "cfg" in metadata_dict:
|
||||
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg"))
|
||||
|
||||
# Seed
|
||||
if "seed" in metadata_dict:
|
||||
add_param_if_not_none(params, "Seed", metadata_dict.get("seed"))
|
||||
|
||||
# Size
|
||||
if "size" in metadata_dict:
|
||||
add_param_if_not_none(params, "Size", metadata_dict.get("size"))
|
||||
|
||||
# Model info
|
||||
if "checkpoint" in metadata_dict:
|
||||
# Ensure checkpoint is a string before processing
|
||||
checkpoint = metadata_dict.get("checkpoint")
|
||||
if checkpoint is not None:
|
||||
# Get model hash
|
||||
model_hash = self.get_checkpoint_hash(checkpoint)
|
||||
|
||||
# Extract basename without path
|
||||
checkpoint_name = os.path.basename(checkpoint)
|
||||
# Remove extension if present
|
||||
checkpoint_name = os.path.splitext(checkpoint_name)[0]
|
||||
|
||||
# Add model hash if available
|
||||
if model_hash:
|
||||
params.append(
|
||||
f"Model hash: {model_hash[:10]}, Model: {checkpoint_name}"
|
||||
)
|
||||
else:
|
||||
params.append(f"Model: {checkpoint_name}")
|
||||
|
||||
# Add LoRA hashes if available
|
||||
if lora_hashes:
|
||||
lora_hash_parts = []
|
||||
for lora_name, hash_value in lora_hashes.items():
|
||||
lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}")
|
||||
|
||||
if lora_hash_parts:
|
||||
params.append(f'Lora hashes: "{", ".join(lora_hash_parts)}"')
|
||||
|
||||
# Combine all parameters with commas
|
||||
metadata_parts.append(", ".join(params))
|
||||
|
||||
# Join all parts with a new line
|
||||
return "\n".join(metadata_parts)
|
||||
lines.append(", ".join(params))
|
||||
return "\n".join(lines)
|
||||
|
||||
# credit to nkchocoai
|
||||
# Add format_filename method to handle pattern substitution
|
||||
@@ -286,7 +522,12 @@ class SaveImageLM:
|
||||
key = parts[0]
|
||||
|
||||
if key == "seed" and "seed" in metadata_dict:
|
||||
filename = filename.replace(segment, str(metadata_dict.get("seed", "")))
|
||||
seed_value = metadata_dict.get("seed")
|
||||
if seed_value is not None:
|
||||
filename = filename.replace(segment, str(seed_value))
|
||||
else:
|
||||
# Fallback if seed was not captured by metadata collector
|
||||
filename = filename.replace(segment, "0")
|
||||
elif key == "width" and "size" in metadata_dict:
|
||||
size = metadata_dict.get("size", "x")
|
||||
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
||||
@@ -297,12 +538,14 @@ class SaveImageLM:
|
||||
filename = filename.replace(segment, str(h))
|
||||
elif key == "pprompt" and "prompt" in metadata_dict:
|
||||
prompt = metadata_dict.get("prompt", "").replace("\n", " ")
|
||||
prompt = sanitize_folder_name(prompt)
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
filename = filename.replace(segment, prompt.strip())
|
||||
elif key == "nprompt" and "negative_prompt" in metadata_dict:
|
||||
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ")
|
||||
prompt = sanitize_folder_name(prompt)
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
prompt = prompt[:length]
|
||||
@@ -316,6 +559,7 @@ class SaveImageLM:
|
||||
model = "model_unavailable"
|
||||
else:
|
||||
model = os.path.splitext(os.path.basename(model_value))[0]
|
||||
model = sanitize_folder_name(model)
|
||||
if len(parts) >= 2:
|
||||
length = int(parts[1])
|
||||
model = model[:length]
|
||||
@@ -346,6 +590,203 @@ class SaveImageLM:
|
||||
|
||||
return filename
|
||||
|
||||
@staticmethod
|
||||
def _get_cached_model_by_name(scanner, name):
|
||||
cache = getattr(scanner, "_cache", None)
|
||||
if cache is None or not name:
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
name,
|
||||
os.path.basename(name),
|
||||
os.path.splitext(os.path.basename(name))[0],
|
||||
]
|
||||
for model in getattr(cache, "raw_data", []):
|
||||
file_name = model.get("file_name")
|
||||
if file_name in candidates:
|
||||
return model
|
||||
return None
|
||||
|
||||
def _build_recipe_loras(self, recipe_scanner, lora_stack):
|
||||
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", lora_stack or "")
|
||||
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
||||
loras_data = []
|
||||
base_model_counts = {}
|
||||
|
||||
for name, strength in lora_matches:
|
||||
lora_info = self._get_cached_model_by_name(lora_scanner, name)
|
||||
civitai = (lora_info or {}).get("civitai") or {}
|
||||
civitai_model = civitai.get("model") or {}
|
||||
try:
|
||||
parsed_strength = float(strength)
|
||||
except (TypeError, ValueError):
|
||||
parsed_strength = 1.0
|
||||
|
||||
loras_data.append(
|
||||
{
|
||||
"file_name": name,
|
||||
"strength": parsed_strength,
|
||||
"hash": ((lora_info or {}).get("sha256") or "").lower(),
|
||||
"modelVersionId": civitai.get("id", 0),
|
||||
"modelName": civitai_model.get("name", name) if lora_info else "",
|
||||
"modelVersionName": civitai.get("name", "") if lora_info else "",
|
||||
"isDeleted": False,
|
||||
"exclude": False,
|
||||
}
|
||||
)
|
||||
|
||||
base_model = (lora_info or {}).get("base_model")
|
||||
if base_model:
|
||||
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||
|
||||
return lora_matches, loras_data, base_model_counts
|
||||
|
||||
def _build_recipe_checkpoint(self, recipe_scanner, checkpoint_raw):
|
||||
if not isinstance(checkpoint_raw, str) or not checkpoint_raw.strip():
|
||||
return None
|
||||
|
||||
checkpoint_name = checkpoint_raw.strip()
|
||||
file_name = os.path.splitext(os.path.basename(checkpoint_name))[0]
|
||||
checkpoint_scanner = getattr(recipe_scanner, "_checkpoint_scanner", None)
|
||||
checkpoint_info = self._get_cached_model_by_name(
|
||||
checkpoint_scanner, checkpoint_name
|
||||
)
|
||||
|
||||
if not checkpoint_info:
|
||||
return {
|
||||
"type": "checkpoint",
|
||||
"name": checkpoint_name,
|
||||
"file_name": file_name,
|
||||
"hash": self.get_checkpoint_hash(checkpoint_name) or "",
|
||||
}
|
||||
|
||||
civitai = checkpoint_info.get("civitai") or {}
|
||||
civitai_model = civitai.get("model") or {}
|
||||
file_path = checkpoint_info.get("file_path") or checkpoint_info.get("path") or ""
|
||||
cached_file_name = (
|
||||
checkpoint_info.get("file_name")
|
||||
or (os.path.splitext(os.path.basename(file_path))[0] if file_path else "")
|
||||
or file_name
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "checkpoint",
|
||||
"modelId": civitai_model.get("id", 0),
|
||||
"modelVersionId": civitai.get("id", 0),
|
||||
"name": civitai_model.get("name")
|
||||
or checkpoint_info.get("model_name")
|
||||
or checkpoint_name,
|
||||
"version": civitai.get("name", ""),
|
||||
"hash": (
|
||||
checkpoint_info.get("sha256") or checkpoint_info.get("hash") or ""
|
||||
).lower(),
|
||||
"file_name": cached_file_name,
|
||||
"modelName": civitai_model.get("name", ""),
|
||||
"modelVersionName": civitai.get("name", ""),
|
||||
"baseModel": checkpoint_info.get("base_model")
|
||||
or civitai.get("baseModel", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _derive_recipe_name(lora_matches):
|
||||
recipe_name_parts = [
|
||||
f"{name.strip()}-{float(strength):.2f}" for name, strength in lora_matches[:3]
|
||||
]
|
||||
return "_".join(recipe_name_parts) or "recipe"
|
||||
|
||||
@staticmethod
|
||||
def _sync_recipe_cache(recipe_scanner, recipe_data, json_path):
|
||||
cache = getattr(recipe_scanner, "_cache", None)
|
||||
if cache is not None:
|
||||
cache.raw_data.append(recipe_data)
|
||||
cache.sorted_by_name = sorted(
|
||||
cache.raw_data, key=lambda item: item.get("title", "").lower()
|
||||
)
|
||||
cache.sorted_by_date = sorted(
|
||||
cache.raw_data,
|
||||
key=lambda item: (
|
||||
item.get("modified", item.get("created_date", 0)),
|
||||
item.get("file_path", ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
recipe_scanner._update_folder_metadata(cache)
|
||||
recipe_scanner._update_fts_index_for_recipe(recipe_data, "add")
|
||||
|
||||
recipe_id = str(recipe_data.get("id", ""))
|
||||
if recipe_id:
|
||||
recipe_scanner._json_path_map[recipe_id] = json_path
|
||||
persistent_cache = getattr(recipe_scanner, "_persistent_cache", None)
|
||||
if persistent_cache:
|
||||
persistent_cache.update_recipe(recipe_data, json_path)
|
||||
|
||||
def _save_image_as_recipe(self, file_path, metadata_dict):
|
||||
if not metadata_dict:
|
||||
raise ValueError("No generation metadata found")
|
||||
|
||||
recipe_scanner = ServiceRegistry.get_service_sync("recipe_scanner")
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipes_dir = recipe_scanner.recipes_dir
|
||||
if not recipes_dir:
|
||||
raise RuntimeError("Recipes directory unavailable")
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
|
||||
recipe_id = str(uuid.uuid4())
|
||||
optimized_image, extension = ExifUtils.optimize_image(
|
||||
image_data=file_path,
|
||||
target_width=CARD_PREVIEW_WIDTH,
|
||||
format="webp",
|
||||
quality=85,
|
||||
preserve_metadata=True,
|
||||
)
|
||||
image_path = os.path.normpath(os.path.join(recipes_dir, f"{recipe_id}{extension}"))
|
||||
with open(image_path, "wb") as file_obj:
|
||||
file_obj.write(optimized_image)
|
||||
|
||||
lora_stack = metadata_dict.get("loras", "")
|
||||
lora_matches, loras_data, base_model_counts = self._build_recipe_loras(
|
||||
recipe_scanner, lora_stack
|
||||
)
|
||||
checkpoint_entry = self._build_recipe_checkpoint(
|
||||
recipe_scanner, metadata_dict.get("checkpoint")
|
||||
)
|
||||
most_common_base_model = (
|
||||
max(base_model_counts.items(), key=lambda item: item[1])[0]
|
||||
if base_model_counts
|
||||
else ""
|
||||
)
|
||||
current_time = time.time()
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": image_path,
|
||||
"title": self._derive_recipe_name(lora_matches),
|
||||
"modified": current_time,
|
||||
"created_date": current_time,
|
||||
"base_model": most_common_base_model
|
||||
or (checkpoint_entry or {}).get("baseModel", ""),
|
||||
"loras": loras_data,
|
||||
"gen_params": {
|
||||
key: value
|
||||
for key, value in metadata_dict.items()
|
||||
if key not in ["checkpoint", "loras"]
|
||||
},
|
||||
"loras_stack": lora_stack,
|
||||
"fingerprint": calculate_recipe_fingerprint(loras_data),
|
||||
}
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
|
||||
json_path = os.path.normpath(
|
||||
os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||
)
|
||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||
self._sync_recipe_cache(recipe_scanner, recipe_data, json_path)
|
||||
|
||||
def save_images(
|
||||
self,
|
||||
images,
|
||||
@@ -356,9 +797,13 @@ class SaveImageLM:
|
||||
extra_pnginfo=None,
|
||||
lossless_webp=True,
|
||||
quality=100,
|
||||
webp_method=6,
|
||||
jpeg_subsampling=0,
|
||||
embed_workflow=False,
|
||||
save_with_metadata=True,
|
||||
add_counter_to_filename=True,
|
||||
save_as_recipe=False,
|
||||
add_loras_to_prompt=False,
|
||||
):
|
||||
"""Save images with metadata"""
|
||||
results = []
|
||||
@@ -367,7 +812,7 @@ class SaveImageLM:
|
||||
raw_metadata = get_metadata()
|
||||
metadata_dict = MetadataProcessor.to_dict(raw_metadata, id)
|
||||
|
||||
metadata = self.format_metadata(metadata_dict)
|
||||
metadata = self.format_metadata(metadata_dict, add_loras_to_prompt)
|
||||
|
||||
# Process filename_prefix with pattern substitution
|
||||
filename_prefix = self.format_filename(filename_prefix, metadata_dict)
|
||||
@@ -390,7 +835,7 @@ class SaveImageLM:
|
||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||
|
||||
# Generate filename with counter if needed
|
||||
base_filename = filename
|
||||
base_filename = filename.replace("%batch_num%", str(i))
|
||||
if add_counter_to_filename:
|
||||
# Use counter + i to ensure unique filenames for all images in batch
|
||||
current_counter = counter + i
|
||||
@@ -409,15 +854,14 @@ class SaveImageLM:
|
||||
elif file_format == "jpeg":
|
||||
file = base_filename + ".jpg"
|
||||
file_extension = ".jpg"
|
||||
save_kwargs = {"quality": quality, "optimize": True}
|
||||
save_kwargs = {"quality": quality, "optimize": True, "subsampling": jpeg_subsampling}
|
||||
elif file_format == "webp":
|
||||
file = base_filename + ".webp"
|
||||
file_extension = ".webp"
|
||||
# Add optimization param to control performance
|
||||
save_kwargs = {
|
||||
"quality": quality,
|
||||
"lossless": lossless_webp,
|
||||
"method": 0,
|
||||
"method": webp_method,
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unsupported file format: {file_format}")
|
||||
@@ -477,6 +921,14 @@ class SaveImageLM:
|
||||
|
||||
img.save(file_path, format="WEBP", **save_kwargs)
|
||||
|
||||
if save_as_recipe:
|
||||
try:
|
||||
self._save_image_as_recipe(file_path, metadata_dict)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to save image as recipe: %s", e, exc_info=True
|
||||
)
|
||||
|
||||
results.append(
|
||||
{"filename": file, "subfolder": subfolder, "type": self.type}
|
||||
)
|
||||
@@ -496,9 +948,13 @@ class SaveImageLM:
|
||||
extra_pnginfo=None,
|
||||
lossless_webp=True,
|
||||
quality=100,
|
||||
webp_method=6,
|
||||
jpeg_subsampling=0,
|
||||
embed_workflow=False,
|
||||
save_with_metadata=True,
|
||||
add_counter_to_filename=True,
|
||||
save_as_recipe=False,
|
||||
add_loras_to_prompt=False,
|
||||
):
|
||||
"""Process and save image with metadata"""
|
||||
# Make sure the output directory exists
|
||||
@@ -524,9 +980,13 @@ class SaveImageLM:
|
||||
extra_pnginfo,
|
||||
lossless_webp,
|
||||
quality,
|
||||
webp_method,
|
||||
jpeg_subsampling,
|
||||
embed_workflow,
|
||||
save_with_metadata,
|
||||
add_counter_to_filename,
|
||||
save_as_recipe,
|
||||
add_loras_to_prompt,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -76,6 +76,9 @@ class TriggerWordToggleLM:
|
||||
# Filter out empty strings and return as set
|
||||
return set(word for word in words if word)
|
||||
|
||||
def _group_has_child_items(self, item):
|
||||
return isinstance(item, dict) and isinstance(item.get("items"), list)
|
||||
|
||||
def process_trigger_words(
|
||||
self,
|
||||
id,
|
||||
@@ -112,7 +115,11 @@ class TriggerWordToggleLM:
|
||||
|
||||
if isinstance(trigger_data, list):
|
||||
if group_mode:
|
||||
if allow_strength_adjustment:
|
||||
if any(self._group_has_child_items(item) for item in trigger_data):
|
||||
filtered_groups = self._process_group_items(
|
||||
trigger_data, allow_strength_adjustment
|
||||
)
|
||||
elif allow_strength_adjustment:
|
||||
parsed_items = [
|
||||
self._parse_trigger_item(
|
||||
item, allow_strength_adjustment
|
||||
@@ -174,6 +181,41 @@ class TriggerWordToggleLM:
|
||||
|
||||
return (filtered_triggers,)
|
||||
|
||||
def _process_group_items(self, trigger_data, allow_strength_adjustment):
|
||||
filtered_groups = []
|
||||
|
||||
for item in trigger_data:
|
||||
group = self._parse_trigger_item(item, allow_strength_adjustment)
|
||||
if not group["text"] or not group["active"]:
|
||||
continue
|
||||
|
||||
raw_items = item.get("items") if isinstance(item, dict) else None
|
||||
if isinstance(raw_items, list):
|
||||
active_items = []
|
||||
for raw_item in raw_items:
|
||||
child = self._parse_trigger_item(
|
||||
raw_item, allow_strength_adjustment=False
|
||||
)
|
||||
if child["text"] and child["active"]:
|
||||
active_items.append(child["text"])
|
||||
|
||||
if not active_items:
|
||||
continue
|
||||
|
||||
group_text = ", ".join(active_items)
|
||||
else:
|
||||
group_text = group["text"]
|
||||
|
||||
filtered_groups.append(
|
||||
self._format_word_output(
|
||||
group_text,
|
||||
group["strength"],
|
||||
allow_strength_adjustment,
|
||||
)
|
||||
)
|
||||
|
||||
return filtered_groups
|
||||
|
||||
def _parse_trigger_item(self, item, allow_strength_adjustment):
|
||||
text = (item.get("text") or "").strip()
|
||||
active = bool(item.get("active", False))
|
||||
|
||||
@@ -7,6 +7,21 @@ from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_c
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reload_gguf_unet(
|
||||
unet_path: str, weight_dtype: str, disable_dynamic: bool = False
|
||||
) -> object:
|
||||
"""Reload a GGUF diffusion model from disk (cached_patcher_init factory).
|
||||
|
||||
Mirrors the GGUF branch of UNETLoaderLM.load_unet so ModelPatcher
|
||||
deepclone/dynamic machinery can rebuild GGUF models with the correct
|
||||
GGMLOps. ``disable_dynamic`` is accepted for signature compatibility
|
||||
with core ComfyUI loaders.
|
||||
"""
|
||||
loader = UNETLoaderLM()
|
||||
model, = loader._load_gguf_unet(unet_path, unet_path, weight_dtype)
|
||||
return model
|
||||
|
||||
|
||||
class UNETLoaderLM:
|
||||
"""UNET Loader with support for extra folder paths
|
||||
|
||||
@@ -196,6 +211,12 @@ class UNETLoaderLM:
|
||||
# Wrap with GGUFModelPatcher
|
||||
model = GGUFModelPatcher.clone(model)
|
||||
|
||||
# Register a reload factory so the MODEL carries its source path
|
||||
# (cached_patcher_init) like core ComfyUI loaders do — required
|
||||
# for model-name extraction downstream and for ModelPatcher
|
||||
# deepclone/dynamic machinery.
|
||||
model.cached_patcher_init = (_reload_gguf_unet, (unet_path, weight_dtype))
|
||||
|
||||
return (model,)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -36,6 +36,7 @@ any_type = AnyType("*")
|
||||
|
||||
# Common methods extracted from lora_loader.py and lora_stacker.py
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import copy
|
||||
import sys
|
||||
@@ -44,11 +45,48 @@ import folder_paths # type: ignore
|
||||
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):
|
||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
||||
# Get the basename without extension
|
||||
basename = os.path.basename(lora_path)
|
||||
return os.path.splitext(basename)[0]
|
||||
normalized = lora_path.replace("\\", "/")
|
||||
basename = os.path.basename(normalized)
|
||||
name_no_ext = 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 parse_lora_syntax(text: str) -> list[dict]:
|
||||
"""Parse <lora:name:strength> syntax from text input into a list of dicts.
|
||||
|
||||
Each entry contains: name, model_strength, clip_strength.
|
||||
Supports both ``<lora:name:strength>`` and ``<lora:name:model_strength:clip_strength>``.
|
||||
"""
|
||||
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||
loras = []
|
||||
for match in matches:
|
||||
model_strength = float(match[1])
|
||||
loras.append({
|
||||
"name": match[0],
|
||||
"model_strength": model_strength,
|
||||
"clip_strength": float(match[2]) if match[2] else model_strength,
|
||||
})
|
||||
return loras
|
||||
|
||||
|
||||
def get_loras_list(kwargs):
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
import os
|
||||
from ..utils.utils import get_lora_info_absolute
|
||||
from ..config import config
|
||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||
import logging
|
||||
|
||||
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:
|
||||
NAME = "WanVideo Lora Select (LoraManager)"
|
||||
CATEGORY = "Lora Manager/stackers"
|
||||
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
# 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
|
||||
lora_item = {
|
||||
"path": folder_paths.get_full_path("loras", lora_path),
|
||||
"path": lora_path,
|
||||
"strength": model_strength,
|
||||
"name": lora_path.split(".")[0],
|
||||
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||
"blocks": selected_blocks,
|
||||
"layer_filter": layer_filter,
|
||||
"low_mem_load": low_mem_load,
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
import os
|
||||
from ..utils.utils import get_lora_info_absolute
|
||||
from ..config import config
|
||||
from .utils import any_type
|
||||
import logging
|
||||
|
||||
# 初始化日志记录器
|
||||
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:
|
||||
# 节点在UI中显示的名称
|
||||
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
|
||||
else:
|
||||
continue
|
||||
|
||||
lora_path, trigger_words = get_lora_info(lora_name_raw)
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
|
||||
|
||||
lora_item = {
|
||||
"path": folder_paths.get_full_path("loras", lora_path),
|
||||
"path": lora_path,
|
||||
"strength": model_strength,
|
||||
"name": lora_path.split(".")[0],
|
||||
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||
"blocks": selected_blocks,
|
||||
"layer_filter": layer_filter,
|
||||
"low_mem_load": low_mem_load,
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from abc import ABC, abstractmethod
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
if not civitai_info or error_msg == "Model not found":
|
||||
# Model not found or deleted
|
||||
lora_entry['isDeleted'] = True
|
||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||
# CivitAI may fail to resolve a hash that is still being
|
||||
# computed (known CivitAI issue). Before marking as deleted,
|
||||
# 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
|
||||
|
||||
# Get model type and validate
|
||||
@@ -173,6 +216,20 @@ class RecipeMetadataParser(ABC):
|
||||
checkpoint['isDeleted'] = True
|
||||
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']:
|
||||
checkpoint['name'] = civitai_data['model']['name']
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
from .merger import GenParamsMerger
|
||||
from .base import RecipeMetadataParser
|
||||
from ..services.metadata_service import get_default_metadata_provider
|
||||
from ..utils.civitai_utils import extract_civitai_image_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,54 +16,65 @@ class RecipeEnricher:
|
||||
async def enrich_recipe(
|
||||
recipe: Dict[str, 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:
|
||||
"""
|
||||
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||
|
||||
|
||||
Args:
|
||||
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||
civitai_client: Authenticated Civitai client instance.
|
||||
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:
|
||||
bool: True if the recipe was modified, False otherwise.
|
||||
"""
|
||||
updated = False
|
||||
gen_params = recipe.get("gen_params", {})
|
||||
|
||||
# 1. Fetch Civitai Info if available
|
||||
|
||||
# 1. Obtain Civitai metadata
|
||||
civitai_meta = None
|
||||
model_version_id = None
|
||||
|
||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||
|
||||
# Check if it's a Civitai image URL
|
||||
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
|
||||
if image_id_match:
|
||||
image_id = image_id_match.group(1)
|
||||
try:
|
||||
image_info = await civitai_client.get_image_info(image_id)
|
||||
if image_info:
|
||||
# Handle nested meta often found in Civitai API responses
|
||||
raw_meta = image_info.get("meta")
|
||||
if isinstance(raw_meta, dict):
|
||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||
civitai_meta = raw_meta["meta"]
|
||||
else:
|
||||
civitai_meta = raw_meta
|
||||
|
||||
model_version_id = image_info.get("modelVersionId")
|
||||
|
||||
# If not at top level, check resources in meta
|
||||
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
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||
model_version_id = prefetched_model_version_id
|
||||
|
||||
source_path = recipe.get("source_path", "")
|
||||
|
||||
if prefetched_civitai_meta_raw is not None:
|
||||
raw_meta = prefetched_civitai_meta_raw
|
||||
if isinstance(raw_meta, dict):
|
||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||
civitai_meta = raw_meta["meta"]
|
||||
else:
|
||||
civitai_meta = raw_meta
|
||||
else:
|
||||
image_id = extract_civitai_image_id(str(source_path))
|
||||
if image_id:
|
||||
try:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
image_id, source_url=str(source_path)
|
||||
)
|
||||
if image_info:
|
||||
raw_meta = image_info.get("meta")
|
||||
if isinstance(raw_meta, dict):
|
||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||
civitai_meta = raw_meta["meta"]
|
||||
else:
|
||||
civitai_meta = raw_meta
|
||||
|
||||
model_version_id = image_info.get("modelVersionId")
|
||||
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
|
||||
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||
@@ -179,27 +190,42 @@ class RecipeEnricher:
|
||||
existing_cp = recipe.get("checkpoint")
|
||||
if existing_cp is None:
|
||||
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)
|
||||
# 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")
|
||||
resolved_base_model = checkpoint_data.get("baseModel")
|
||||
resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
|
||||
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"]
|
||||
if is_generic and resolved_base_model != current_base_model:
|
||||
recipe["base_model"] = resolved_base_model
|
||||
|
||||
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
|
||||
formatted_checkpoint = {
|
||||
"type": "checkpoint",
|
||||
"modelId": checkpoint_data.get("modelId"),
|
||||
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
|
||||
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
|
||||
}
|
||||
# Remove None values
|
||||
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
||||
|
||||
|
||||
# 2. Only format and save checkpoint if it has real data (not just type after type rejection)
|
||||
has_checkpoint_data = any([
|
||||
checkpoint_data.get("modelId"),
|
||||
checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||
checkpoint_data.get("name"),
|
||||
checkpoint_data.get("version"),
|
||||
])
|
||||
if has_checkpoint_data:
|
||||
formatted_checkpoint = {
|
||||
"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
|
||||
else:
|
||||
# Fallback to name extraction if we don't already have one
|
||||
|
||||
@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
if model_hash_from_hashes:
|
||||
metadata["model_hash"] = model_hash_from_hashes
|
||||
|
||||
# Extract Lora hashes in alternative format
|
||||
# Extract Lora hashes in alternative format.
|
||||
# Run unconditionally (not just as fallback) so that
|
||||
# non-empty hashes from Lora hashes fill in the gaps left
|
||||
# by empty values in the Hashes JSON dict. Some WebUI
|
||||
# builds write real hash values only to Lora hashes and
|
||||
# leave the Hashes JSON values empty.
|
||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||
if not hashes_match and lora_hashes_match:
|
||||
if lora_hashes_match:
|
||||
try:
|
||||
lora_hashes_str = lora_hashes_match.group(1)
|
||||
lora_hash_entries = lora_hashes_str.split(', ')
|
||||
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
|
||||
|
||||
# Parse each lora hash entry (format: "name: hash")
|
||||
for entry in lora_hash_entries:
|
||||
if ': ' in entry:
|
||||
lora_name, lora_hash = entry.split(': ', 1)
|
||||
# Add as lora type in the same format as regular hashes
|
||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
||||
|
||||
lora_hash = lora_hash.strip()
|
||||
if not lora_hash:
|
||||
# Skip entries without a hash value
|
||||
continue
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
# Add as lora type in the same format as
|
||||
# regular hashes. Only override an
|
||||
# existing entry if its value is empty
|
||||
# (Lora hashes is the more reliable
|
||||
# source when Hashes JSON has blanks).
|
||||
key = f"lora:{lora_name}"
|
||||
existing = metadata["hashes"].get(key, "")
|
||||
if not existing:
|
||||
metadata["hashes"][key] = lora_hash
|
||||
|
||||
# Remove lora hashes from params section
|
||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||
except Exception as e:
|
||||
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Only process lora or hypernet types
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
continue
|
||||
|
||||
# Skip entries without a hash value — they can't be
|
||||
# resolved via CivitAI and would only produce a
|
||||
# useless "Deleted" entry in the recipe.
|
||||
if not lora_hash:
|
||||
continue
|
||||
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
|
||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Try to get info from Civitai
|
||||
if metadata_provider:
|
||||
try:
|
||||
if lora_hash:
|
||||
# If we have hash, use it for lookup
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
else:
|
||||
civitai_info = None
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Dict, Any, Union
|
||||
from ..base import RecipeMetadataParser
|
||||
from ..constants import GEN_PARAM_KEYS
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,7 +74,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
return False
|
||||
|
||||
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]:
|
||||
"""Parse metadata from Civitai image format
|
||||
|
||||
@@ -81,6 +83,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
user_comment: The metadata from the image (dict)
|
||||
recipe_scanner: Optional recipe scanner service
|
||||
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:
|
||||
Dict containing parsed recipe data
|
||||
@@ -185,8 +189,77 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
# Process standard resources array
|
||||
if "resources" in metadata and isinstance(metadata["resources"], list):
|
||||
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
|
||||
if resource.get("type", "lora") == "lora":
|
||||
if resource_type == "lora":
|
||||
lora_hash = resource.get("hash", "")
|
||||
|
||||
# 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
|
||||
if lora_entry["hash"] and metadata_provider:
|
||||
try:
|
||||
civitai_info = (
|
||||
await metadata_provider.get_model_by_hash(lora_hash)
|
||||
if lora_hash and metadata_provider:
|
||||
local_cached = local_cache.get(lora_hash) if local_cache else None
|
||||
if local_cached:
|
||||
self._populate_entry_from_cache(
|
||||
lora_entry, local_cached
|
||||
)
|
||||
|
||||
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"]:
|
||||
# Track by version ID for deduplication
|
||||
if lora_entry.get("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}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
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
|
||||
if lora_hash:
|
||||
@@ -430,11 +514,21 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Process modelVersionIds from Civitai image API
|
||||
# These are model version IDs returned at root level when meta doesn't contain resources
|
||||
if "modelVersionIds" in metadata and isinstance(
|
||||
metadata["modelVersionIds"], list
|
||||
# Process modelVersionIds from Civitai image API.
|
||||
# These are version IDs returned at root level of the API response.
|
||||
# When resources or civitaiResources are already present in metadata
|
||||
# (which they are when ?withMeta=true is passed), those sections have
|
||||
# complete hash/type information — modelVersionIds is a fallback for
|
||||
# when meta is null and only the flat ID list is available. Skipping
|
||||
# it here avoids duplicates: the same file hash often resolves to
|
||||
# different version IDs via hash lookup (resources) vs the original
|
||||
# version ID in modelVersionIds, and both paths would create entries.
|
||||
if (
|
||||
"modelVersionIds" in metadata
|
||||
and isinstance(metadata["modelVersionIds"], list)
|
||||
and not result.get("loras")
|
||||
):
|
||||
|
||||
for version_id in metadata["modelVersionIds"]:
|
||||
version_id_str = str(version_id)
|
||||
|
||||
@@ -442,6 +536,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
if version_id_str in added_loras:
|
||||
continue
|
||||
|
||||
# Skip if this version ID is already the recipe's checkpoint
|
||||
# (resolved earlier from embedded resources/Model hash,
|
||||
# avoiding a duplicate CivitAI API call).
|
||||
existing_model = result.get("model")
|
||||
if existing_model and str(existing_model.get("id")) == version_id_str:
|
||||
continue
|
||||
|
||||
# Initialize lora entry with version ID
|
||||
lora_entry = {
|
||||
"id": version_id,
|
||||
@@ -475,9 +576,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
# Not a LoRA — try as checkpoint (only if we
|
||||
# don't already have one). Reuses the same
|
||||
# civitai_info from the API call above so no
|
||||
# extra query is made.
|
||||
if result["model"] is None:
|
||||
checkpoint_entry = {
|
||||
"id": version_id,
|
||||
"modelId": 0,
|
||||
"name": "Unknown Model",
|
||||
"version": "",
|
||||
"type": "checkpoint",
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": "",
|
||||
"hash": "",
|
||||
"thumbnailUrl": (
|
||||
"/loras_static/images/no-preview.png"
|
||||
),
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
cp_populated = await (
|
||||
self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry, civitai_info
|
||||
)
|
||||
)
|
||||
if cp_populated.get("modelId"):
|
||||
result["model"] = cp_populated
|
||||
continue # Not a LoRA, don't add to loras
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching Civitai info for model version {version_id}: {e}"
|
||||
@@ -625,3 +757,41 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
||||
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
|
||||
|
||||
@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
|
||||
|
||||
def _find_model_file(self, files):
|
||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
||||
return next((file for file in files if file.get("type") == "Model" and file.get("primary") is True), None)
|
||||
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
|
||||
|
||||
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
||||
"""Expose handlers for subclasses or tests."""
|
||||
|
||||
165
py/routes/handlers/agent_handlers.py
Normal file
165
py/routes/handlers/agent_handlers.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""HTTP route handlers for agent skill endpoints.
|
||||
|
||||
These handlers expose the :class:`AgentService` via HTTP, allowing the
|
||||
frontend to list available skills and execute them on selected models.
|
||||
Progress is reported via WebSocket broadcast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...services.agent import AgentService, AgentProgressReporter
|
||||
from ...services.llm_service import LLMNotConfiguredError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentHandler:
|
||||
"""HTTP handler for agent skill operations."""
|
||||
|
||||
def __init__(self, agent_service: AgentService | None = None) -> None:
|
||||
self._agent_service = agent_service
|
||||
|
||||
async def _ensure_service(self) -> AgentService:
|
||||
if self._agent_service is None:
|
||||
self._agent_service = await AgentService.get_instance()
|
||||
return self._agent_service
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/lm/agent/skills
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_agent_skills(self, request: web.Request) -> web.Response:
|
||||
"""Return a list of available agent skills."""
|
||||
|
||||
service = await self._ensure_service()
|
||||
skills = await service.list_skills()
|
||||
return web.json_response({"skills": skills})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/lm/agent/execute/{skill_name}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def execute_agent_skill(self, request: web.Request) -> web.Response:
|
||||
"""Execute an agent skill on the provided model paths.
|
||||
|
||||
Request body::
|
||||
|
||||
{"model_paths": ["/path/to/model1.safetensors", ...], "options": {}}
|
||||
|
||||
Returns immediately with a task ID. Execution runs in the
|
||||
background; progress and completion are pushed via WebSocket
|
||||
events of type ``agent_progress``.
|
||||
"""
|
||||
|
||||
skill_name = request.match_info.get("skill_name", "")
|
||||
if not skill_name:
|
||||
return web.json_response(
|
||||
{"error": "Skill name is required"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
|
||||
model_paths = body.get("model_paths", [])
|
||||
if not model_paths or not isinstance(model_paths, list):
|
||||
return web.json_response(
|
||||
{"error": "model_paths must be a non-empty array"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
service = await self._ensure_service()
|
||||
|
||||
# Validate LLM configuration early for skills that need it
|
||||
# (fail fast rather than after starting background work)
|
||||
try:
|
||||
from ...services.llm_service import LLMService
|
||||
|
||||
llm = await LLMService.get_instance()
|
||||
if not llm.is_configured():
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "LLM provider is not configured. "
|
||||
"Enable it in Settings → AI Provider.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to check LLM configuration: %s", exc)
|
||||
|
||||
# Launch execution in the background
|
||||
progress_reporter = AgentProgressReporter()
|
||||
logger.info(
|
||||
"LLM enrichment '%s' starting for %d model(s)",
|
||||
skill_name, len(model_paths),
|
||||
)
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
result = await service.execute_skill(
|
||||
skill_name=skill_name,
|
||||
input_data={"model_paths": model_paths},
|
||||
progress_callback=progress_reporter,
|
||||
)
|
||||
logger.info(
|
||||
"LLM enrichment '%s' finished: success=%s, summary='%s', errors=%s",
|
||||
skill_name, result.success, result.summary, result.errors,
|
||||
)
|
||||
except LLMNotConfiguredError as exc:
|
||||
logger.warning("LLM enrichment '%s' not configured: %s", skill_name, exc)
|
||||
await progress_reporter.on_progress(
|
||||
{
|
||||
"type": "agent_progress",
|
||||
"skill": skill_name,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("LLM enrichment '%s' failed: %s", skill_name, exc, exc_info=True)
|
||||
await progress_reporter.on_progress(
|
||||
{
|
||||
"type": "agent_progress",
|
||||
"skill": skill_name,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
|
||||
# Fire and forget — progress comes via WebSocket
|
||||
asyncio.create_task(_run())
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "started",
|
||||
"skill": skill_name,
|
||||
"model_count": len(model_paths),
|
||||
}
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/lm/agent/cancel
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def cancel_agent_skill(self, request: web.Request) -> web.Response:
|
||||
"""Cancel a running agent skill.
|
||||
|
||||
NOTE: Cancellation is a stub for now — the AgentService processes
|
||||
models sequentially and does not yet support mid-execution
|
||||
cancellation. This endpoint exists for API completeness.
|
||||
"""
|
||||
|
||||
# TODO: implement cooperative cancellation in AgentService
|
||||
return web.json_response(
|
||||
{"status": "acknowledged", "note": "Cancellation not yet implemented"},
|
||||
status=200,
|
||||
)
|
||||
508
py/routes/handlers/hf_handlers.py
Normal file
508
py/routes/handlers/hf_handlers.py
Normal file
@@ -0,0 +1,508 @@
|
||||
"""Handlers for Hugging Face model listing and download.
|
||||
|
||||
Minimal MVP implementation — uses direct HTTP to the HF API for file
|
||||
listing and the project's existing aiohttp-based Downloader for
|
||||
downloading. No huggingface_hub dependency required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
from ...config import config
|
||||
from ...services.downloader import (
|
||||
DownloadProgress,
|
||||
get_downloader,
|
||||
)
|
||||
from ...services.aria2_downloader import Aria2Downloader
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
from ...services.websocket_manager import ws_manager
|
||||
from ...utils.constants import MODEL_FILE_EXTENSIONS
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_MODEL_CLASS = LoraMetadata
|
||||
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
|
||||
|
||||
# Shared aiohttp session for HF API calls (created on first use)
|
||||
_hf_api_session: aiohttp.ClientSession | None = None
|
||||
|
||||
|
||||
async def _get_hf_api_session() -> aiohttp.ClientSession:
|
||||
"""Get or create the shared aiohttp session for HF API calls."""
|
||||
global _hf_api_session # needed because we reassign the module-level name
|
||||
if _hf_api_session is None or _hf_api_session.closed:
|
||||
_hf_api_session = aiohttp.ClientSession(
|
||||
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return _hf_api_session
|
||||
|
||||
|
||||
async def close_hf_api_session() -> None:
|
||||
"""Close the shared HF API session, if it was ever created."""
|
||||
global _hf_api_session
|
||||
if _hf_api_session is not None and not _hf_api_session.closed:
|
||||
await _hf_api_session.close()
|
||||
_hf_api_session = None
|
||||
|
||||
|
||||
def _infer_model_type(model_root: str) -> tuple[Any, str]:
|
||||
"""Determine model class and scanner by matching ``model_root`` against the
|
||||
configured root paths for each model type (from ``Config``).
|
||||
|
||||
The ``model_root`` value comes from the frontend's model-root dropdown,
|
||||
which is populated from the current page's scanner roots. By checking
|
||||
which scanner's root list it belongs to, we avoid fragile heuristics
|
||||
like substring-matching path names.
|
||||
"""
|
||||
norm = os.path.normpath(model_root).replace(os.sep, "/")
|
||||
|
||||
# LoRA roots
|
||||
for p in (config.loras_roots or []) + (config.extra_loras_roots or []):
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return LoraMetadata, "get_lora_scanner"
|
||||
|
||||
# Checkpoint / UNet roots
|
||||
for p in (
|
||||
(config.checkpoints_roots or [])
|
||||
+ (config.extra_checkpoints_roots or [])
|
||||
+ (config.unet_roots or [])
|
||||
+ (config.extra_unet_roots or [])
|
||||
):
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return CheckpointMetadata, "get_checkpoint_scanner"
|
||||
|
||||
# Embedding roots
|
||||
for p in (config.embeddings_roots or []) + (config.extra_embeddings_roots or []):
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return EmbeddingMetadata, "get_embedding_scanner"
|
||||
|
||||
# Fallback — should not happen in normal use
|
||||
logger.warning(
|
||||
"Could not determine model type for root '%s'; defaulting to LoRA",
|
||||
model_root,
|
||||
)
|
||||
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
|
||||
|
||||
|
||||
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
|
||||
"""Create a proper .metadata.json and add the model to the scanner cache.
|
||||
|
||||
Uses ``MetadataManager.create_default_metadata()`` which computes the
|
||||
SHA256 hash, extracts safetensors header metadata (base_model), and
|
||||
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
|
||||
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
|
||||
register the model in the in-memory scanner cache so it appears
|
||||
immediately without a full filesystem walk.
|
||||
"""
|
||||
try:
|
||||
hf_url = f"https://huggingface.co/{repo}"
|
||||
model_class, scanner_getter_name = _infer_model_type(model_root)
|
||||
|
||||
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
|
||||
metadata = await MetadataManager.create_default_metadata(
|
||||
dest_path, model_class=model_class
|
||||
)
|
||||
if metadata is None:
|
||||
logger.warning("create_default_metadata returned None for %s", dest_path)
|
||||
return
|
||||
|
||||
# 2. Overlay HF-specific fields
|
||||
metadata._unknown_fields["hf_url"] = hf_url
|
||||
metadata.from_civitai = False # HF models are not from CivitAI
|
||||
|
||||
metadata_dict = metadata.to_dict()
|
||||
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
|
||||
del metadata_dict["trainedWords"]
|
||||
|
||||
# 3. Save metadata atomically
|
||||
await MetadataManager.save_metadata(dest_path, metadata_dict)
|
||||
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||
|
||||
# 4. Determine relative folder path for cache
|
||||
# model_root is an absolute path; dest_path is under it
|
||||
folder = ""
|
||||
if os.path.isabs(model_root) and dest_path.startswith(model_root):
|
||||
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
|
||||
folder = rel.replace(os.sep, "/") if rel != "." else ""
|
||||
|
||||
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
|
||||
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||
if scanner_getter is not None:
|
||||
scanner = await scanner_getter()
|
||||
if scanner is not None:
|
||||
metadata_dict = metadata.to_dict()
|
||||
metadata_dict["hf_url"] = hf_url
|
||||
await scanner.add_model_to_cache(metadata_dict, folder)
|
||||
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
|
||||
|
||||
|
||||
def _find_matching_root(dest_dir: str) -> str | None:
|
||||
"""Walk up *dest_dir* to find which configured scanner root it belongs to."""
|
||||
norm = os.path.normpath(dest_dir).replace(os.sep, "/")
|
||||
all_roots = []
|
||||
for root_list in (
|
||||
config.loras_roots or [],
|
||||
config.extra_loras_roots or [],
|
||||
config.checkpoints_roots or [],
|
||||
config.extra_checkpoints_roots or [],
|
||||
config.unet_roots or [],
|
||||
config.extra_unet_roots or [],
|
||||
config.embeddings_roots or [],
|
||||
config.extra_embeddings_roots or [],
|
||||
):
|
||||
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
|
||||
# Find the longest matching prefix
|
||||
match: str | None = None
|
||||
for root in all_roots:
|
||||
if norm.startswith(root):
|
||||
if match is None or len(root) > len(match):
|
||||
match = root
|
||||
return match
|
||||
|
||||
|
||||
async def _add_to_scanner_cache(dest_path: str, metadata: dict[str, Any]) -> None:
|
||||
model_dir = os.path.dirname(dest_path)
|
||||
model_root = _find_matching_root(model_dir)
|
||||
if not model_root:
|
||||
raise ValueError(f"File path {dest_path} is not within any configured scanner root")
|
||||
scanner_getter_name = _infer_model_type(model_root)[1]
|
||||
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||
if scanner_getter is None:
|
||||
raise RuntimeError(f"Scanner getter '{scanner_getter_name}' not found in ServiceRegistry")
|
||||
scanner = await scanner_getter()
|
||||
if scanner is None:
|
||||
raise RuntimeError(f"Scanner '{scanner_getter_name}' returned None")
|
||||
await scanner.update_single_model_cache(dest_path, dest_path, metadata)
|
||||
|
||||
|
||||
class HfHandler:
|
||||
"""Handle Hugging Face model browsing and download."""
|
||||
|
||||
async def set_hf_url(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
payload: dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
|
||||
|
||||
file_path = (payload.get("file_path") or "").strip()
|
||||
hf_url = (payload.get("hf_url") or "").strip()
|
||||
|
||||
if not file_path or not hf_url:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing required fields: 'file_path' and 'hf_url'"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
m = re.match(r"^https?://huggingface\.co/([^/]+/[^/]+)/?$", hf_url)
|
||||
if not m:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Invalid HuggingFace URL. Expected format: https://huggingface.co/user/repo",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"File not found: {file_path}"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
model_root = _find_matching_root(os.path.dirname(file_path))
|
||||
if not model_root:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "File is not within any configured model directory. Cannot link to HuggingFace.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
existing = await MetadataManager.load_metadata_payload(file_path)
|
||||
if existing.get("hf_url") == hf_url:
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": "hf_url already set",
|
||||
"hf_url": hf_url,
|
||||
})
|
||||
|
||||
existing["hf_url"] = hf_url
|
||||
existing["from_civitai"] = False
|
||||
await MetadataManager.save_metadata(file_path, existing)
|
||||
|
||||
await _add_to_scanner_cache(file_path, existing)
|
||||
|
||||
logger.info("Set hf_url=%s for %s", hf_url, file_path)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"hf_url set to {hf_url}",
|
||||
"hf_url": hf_url,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Failed to set hf_url for %s: %s", file_path, exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
|
||||
"""List model-weight files from a HF repo with real file sizes.
|
||||
|
||||
Uses the HF tree API endpoint which returns accurate file sizes
|
||||
(including LFS-tracked files), unlike the model info endpoint.
|
||||
"""
|
||||
repo = request.query.get("repo", "").strip()
|
||||
if not repo or "/" not in repo:
|
||||
return web.json_response(
|
||||
{"error": "Missing or invalid 'repo' parameter (expected user/repo)"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
url = f"https://huggingface.co/api/models/{repo}/tree/main"
|
||||
|
||||
try:
|
||||
session = await _get_hf_api_session()
|
||||
async with session.get(url) as resp:
|
||||
if resp.status == 404:
|
||||
return web.json_response(
|
||||
{"error": f"Repo '{repo}' not found"}, status=404
|
||||
)
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
return web.json_response(
|
||||
{"error": f"HF API error {resp.status}: {text[:200]}"},
|
||||
status=resp.status,
|
||||
)
|
||||
tree: list[dict[str, Any]] = await resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to fetch HF repo files: %s", exc)
|
||||
return web.json_response({"error": str(exc)}, status=502)
|
||||
|
||||
files: list[dict[str, Any]] = []
|
||||
for entry in tree:
|
||||
path: str = entry.get("path", "")
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext not in MODEL_FILE_EXTENSIONS:
|
||||
continue
|
||||
size = entry.get("size", 0) or 0
|
||||
if size == 0 and "lfs" in entry:
|
||||
size = entry["lfs"].get("size", 0) or 0
|
||||
files.append({
|
||||
"filename": path,
|
||||
"size": size,
|
||||
})
|
||||
|
||||
files.sort(key=lambda f: f["size"], reverse=True)
|
||||
return web.json_response(files)
|
||||
|
||||
async def download_hf_model(self, request: web.Request) -> web.Response:
|
||||
"""Download a single file from Hugging Face into the model directory.
|
||||
|
||||
POST JSON body::
|
||||
|
||||
{
|
||||
"repo": "dx8152/Flux2-Klein-9B-Consistency",
|
||||
"filename": "Flux2-Klein-9B-consistency-V2.safetensors",
|
||||
"revision": "main",
|
||||
"model_root": "loras",
|
||||
"relative_path": "",
|
||||
"use_default_paths": false,
|
||||
"download_id": "optional-batch-id"
|
||||
}
|
||||
|
||||
If ``download_id`` is provided, real-time progress (bytes, speed,
|
||||
percentage) is broadcast via the WebSocket progress system, matching
|
||||
the CivitAI download experience.
|
||||
|
||||
Respects the ``download_backend`` setting (``aria2`` or ``default``).
|
||||
"""
|
||||
try:
|
||||
payload: dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
repo = (payload.get("repo") or "").strip()
|
||||
filename = (payload.get("filename") or "").strip()
|
||||
revision = (payload.get("revision") or "main").strip()
|
||||
model_root = (payload.get("model_root") or "").strip()
|
||||
relative_path = (payload.get("relative_path") or "").strip()
|
||||
use_default_paths = bool(payload.get("use_default_paths", False))
|
||||
download_id: str | None = payload.get("download_id")
|
||||
|
||||
logger.info(
|
||||
"download_hf_model: repo=%s file=%s root=%s download_id=%s",
|
||||
repo, filename, model_root, download_id,
|
||||
)
|
||||
|
||||
if not repo or not filename:
|
||||
return web.json_response(
|
||||
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
|
||||
)
|
||||
|
||||
# Validate repo format — must be user/repo_name
|
||||
if repo.count("/") != 1 or not re.match(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", repo):
|
||||
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||
author, repo_name = repo.split("/", 1)
|
||||
if ".." in (author, repo_name) or "." in (author, repo_name):
|
||||
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||
|
||||
# Validate filename — must not contain path traversal
|
||||
if ".." in filename:
|
||||
return web.json_response({"error": "Invalid filename"}, status=400)
|
||||
|
||||
# Validate relative_path — must not be absolute or escape base directory
|
||||
if relative_path:
|
||||
if os.path.isabs(relative_path):
|
||||
return web.json_response({"error": "relative_path must not be absolute"}, status=400)
|
||||
if ".." in relative_path.split("/") or "\\" in relative_path:
|
||||
return web.json_response({"error": "Invalid relative_path"}, status=400)
|
||||
|
||||
# Use model_root directly as the base directory — same approach as
|
||||
# CivitAI's download path (download_manager.py). No realpath, no
|
||||
# allowed-roots validation, no path-traversal check; those are
|
||||
# unnecessary when the frontend sends the path from its own dropdown
|
||||
# (populated from scanner roots). Using the "business path" directly
|
||||
# keeps dest_path consistent with scanner roots so that later folder
|
||||
# derivation (in _save_hf_metadata) works correctly.
|
||||
if os.path.isabs(model_root):
|
||||
base_dir = os.path.normpath(model_root)
|
||||
else:
|
||||
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
|
||||
|
||||
if use_default_paths:
|
||||
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
|
||||
elif relative_path:
|
||||
target_dir = os.path.join(base_dir, relative_path)
|
||||
else:
|
||||
target_dir = base_dir
|
||||
|
||||
# Strip HF repo subdirectory — "diffusion_models/xxx.safetensors"
|
||||
# is an HF repo convention, not meaningful for local storage.
|
||||
file_base = os.path.basename(filename)
|
||||
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
dest_path = os.path.join(target_dir, file_base)
|
||||
|
||||
# Check if already exists (simple skip)
|
||||
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
|
||||
logger.info("download_hf_model: file already exists, skipping — %s", dest_path)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"File already exists: {dest_path}",
|
||||
"path": dest_path,
|
||||
})
|
||||
|
||||
# Build HF resolve URL
|
||||
resolve_url = (
|
||||
f"https://huggingface.co/{repo}/resolve/{revision}/{filename}"
|
||||
)
|
||||
|
||||
# Set up progress callback if download_id is provided
|
||||
progress_callback = None
|
||||
if download_id:
|
||||
|
||||
async def _progress_callback(
|
||||
progress: float | DownloadProgress,
|
||||
snapshot: DownloadProgress | None = None,
|
||||
) -> None:
|
||||
percent = 0.0
|
||||
metrics = snapshot if isinstance(snapshot, DownloadProgress) else None
|
||||
|
||||
if isinstance(progress, DownloadProgress):
|
||||
percent = progress.percent_complete
|
||||
metrics = progress
|
||||
elif isinstance(snapshot, DownloadProgress):
|
||||
percent = snapshot.percent_complete
|
||||
else:
|
||||
percent = float(progress)
|
||||
|
||||
broadcast: dict[str, Any] = {
|
||||
"status": "progress",
|
||||
"progress": round(percent),
|
||||
}
|
||||
if metrics:
|
||||
broadcast["bytes_downloaded"] = metrics.bytes_downloaded
|
||||
broadcast["total_bytes"] = metrics.total_bytes
|
||||
broadcast["bytes_per_second"] = metrics.bytes_per_second
|
||||
|
||||
await ws_manager.broadcast_download_progress(download_id, broadcast)
|
||||
|
||||
progress_callback = _progress_callback
|
||||
|
||||
# Respect download backend setting (aria2 vs default)
|
||||
download_backend = (
|
||||
get_settings_manager().get("download_backend", "default")
|
||||
)
|
||||
|
||||
if download_backend == "aria2":
|
||||
aria2 = await Aria2Downloader.get_instance()
|
||||
aid = download_id or f"hf_{repo}_{filename}"
|
||||
try:
|
||||
hf_success, hf_result = await aria2.download_file(
|
||||
url=resolve_url,
|
||||
save_path=dest_path,
|
||||
download_id=aid,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if hf_success:
|
||||
await _save_hf_metadata(dest_path, repo, model_root)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Downloaded to {dest_path}",
|
||||
"path": dest_path,
|
||||
})
|
||||
else:
|
||||
return web.json_response(
|
||||
{"success": False, "error": hf_result or "aria2 download failed"},
|
||||
status=500,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("HF download (aria2) failed: %s", exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
|
||||
# Default: use built-in aiohttp Downloader
|
||||
downloader = await get_downloader()
|
||||
try:
|
||||
success, result = await downloader.download_file(
|
||||
url=resolve_url,
|
||||
save_path=dest_path,
|
||||
use_auth=False,
|
||||
allow_resume=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if success:
|
||||
await _save_hf_metadata(dest_path, repo, model_root)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Downloaded to {result}",
|
||||
"path": result,
|
||||
})
|
||||
else:
|
||||
return web.json_response(
|
||||
{"success": False, "error": result or "Download failed"},
|
||||
status=500,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("HF download failed: %s", exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,10 @@ import jinja2
|
||||
|
||||
from ...config import config
|
||||
from ...services.download_coordinator import DownloadCoordinator
|
||||
from ...services.connectivity_guard import (
|
||||
OFFLINE_FRIENDLY_MESSAGE,
|
||||
is_expected_offline_error,
|
||||
)
|
||||
from ...services.metadata_sync_service import MetadataSyncService
|
||||
from ...services.model_file_service import ModelMoveService
|
||||
from ...services.preview_asset_service import PreviewAssetService
|
||||
@@ -33,6 +37,7 @@ from ...services.use_cases import (
|
||||
)
|
||||
from ...services.websocket_manager import WebSocketManager
|
||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
||||
from ...services.download_queue_service import DownloadQueueService
|
||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||
from ...utils.civitai_utils import resolve_license_payload
|
||||
from ...utils.file_utils import calculate_sha256
|
||||
@@ -149,6 +154,14 @@ class ModelPageView:
|
||||
)
|
||||
self._template_env._i18n_filter_added = True # type: ignore[attr-defined]
|
||||
|
||||
from ...services.llm_service import PROVIDER_PRESETS
|
||||
|
||||
# Provider presets are embedded directly (local, no await needed).
|
||||
# Provider model catalogs are fetched asynchronously by the
|
||||
# frontend via GET /api/lm/llm/provider-models so page rendering
|
||||
# never blocks on the remote model catalog (which can take up to
|
||||
# 30s on cold cache).
|
||||
|
||||
template_context = {
|
||||
"is_initializing": is_initializing,
|
||||
"settings": self._settings,
|
||||
@@ -156,6 +169,8 @@ class ModelPageView:
|
||||
"folders": [],
|
||||
"t": self._server_i18n.get_translation,
|
||||
"version": self._get_app_version(),
|
||||
"provider_presets_json": json.dumps(PROVIDER_PRESETS),
|
||||
"provider_models_json": "{}",
|
||||
}
|
||||
|
||||
if not is_initializing:
|
||||
@@ -198,11 +213,17 @@ class ModelListingHandler:
|
||||
result = await self._service.get_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
formatted_raw = [
|
||||
await self._service.format_response(entry)
|
||||
for entry in result["items"]
|
||||
]
|
||||
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||
# Note: "total" intentionally remains the pre-filter count to reflect
|
||||
# the true number of models in the cache; corrupted entries are rare
|
||||
# and adjusting total would cause pagination drift on every page.
|
||||
formatted_items = [item for item in formatted_raw if item is not None]
|
||||
formatted_result = {
|
||||
"items": [
|
||||
await self._service.format_response(item)
|
||||
for item in result["items"]
|
||||
],
|
||||
"items": formatted_items,
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
@@ -224,6 +245,48 @@ class ModelListingHandler:
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def get_excluded_models(self, request: web.Request) -> web.Response:
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
params = self._parse_common_params(request)
|
||||
# group_by_model is meaningless for excluded view; strip it
|
||||
params.pop("group_by_model", None)
|
||||
result = await self._service.get_excluded_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
formatted_raw = [
|
||||
await self._service.format_response(entry)
|
||||
for entry in result["items"]
|
||||
]
|
||||
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||
# "total" stays at the pre-filter count; see get_models for rationale.
|
||||
formatted_items = [item for item in formatted_raw if item is not None]
|
||||
formatted_result = {
|
||||
"items": formatted_items,
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"total_pages": result["total_pages"],
|
||||
}
|
||||
format_duration = time.perf_counter() - format_start
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
self._logger.debug(
|
||||
"Request for %s/excluded took %.3fs (formatting: %.3fs)",
|
||||
self._service.model_type,
|
||||
duration,
|
||||
format_duration,
|
||||
)
|
||||
return web.json_response(formatted_result)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrieving excluded %ss: %s",
|
||||
self._service.model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
def _parse_common_params(self, request: web.Request) -> Dict:
|
||||
page = int(request.query.get("page", "1"))
|
||||
page_size = min(int(request.query.get("page_size", "20")), 100)
|
||||
@@ -261,6 +324,15 @@ class ModelListingHandler:
|
||||
for tag in exclude_tags:
|
||||
if tag:
|
||||
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"
|
||||
|
||||
search_options = {
|
||||
@@ -316,6 +388,21 @@ class ModelListingHandler:
|
||||
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
|
||||
group_by_model = (
|
||||
request.query.get("group_by_model", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# View-local-versions filter: show all local versions of a specific model
|
||||
# Accepts either a CivitAI modelId (int) or a HF group key like "hf:user/repo"
|
||||
civitai_model_id = request.query.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
try:
|
||||
civitai_model_id = int(civitai_model_id)
|
||||
except (TypeError, ValueError):
|
||||
# Keep as string — could be an HF group key (e.g. "hf:user/repo")
|
||||
pass
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -327,6 +414,7 @@ class ModelListingHandler:
|
||||
"fuzzy_search": fuzzy_search,
|
||||
"base_models": base_models,
|
||||
"tags": tag_filters,
|
||||
"auto_tags": auto_tag_filters,
|
||||
"tag_logic": tag_logic,
|
||||
"search_options": search_options,
|
||||
"hash_filters": hash_filters,
|
||||
@@ -338,6 +426,8 @@ class ModelListingHandler:
|
||||
"name_pattern_include": name_pattern_include,
|
||||
"name_pattern_exclude": name_pattern_exclude,
|
||||
"name_pattern_use_regex": name_pattern_use_regex,
|
||||
"group_by_model": group_by_model,
|
||||
"civitai_model_id": civitai_model_id,
|
||||
**self._parse_specific_params(request),
|
||||
}
|
||||
|
||||
@@ -392,6 +482,21 @@ class ModelManagementHandler:
|
||||
self._logger.error("Error excluding model: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
|
||||
async def unexclude_model(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get("file_path")
|
||||
if not file_path:
|
||||
return web.Response(text="Model path is required", status=400)
|
||||
|
||||
result = await self._lifecycle_service.unexclude_model(file_path)
|
||||
return web.json_response(result)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error restoring model: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
|
||||
async def fetch_civitai(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -434,6 +539,7 @@ class ModelManagementHandler:
|
||||
# Update model_data with new hash
|
||||
model_data["sha256"] = sha256
|
||||
model_data["hash_status"] = "completed"
|
||||
hash_status = "completed"
|
||||
else:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "No SHA256 hash found"}, status=400
|
||||
@@ -441,6 +547,32 @@ class ModelManagementHandler:
|
||||
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
|
||||
# hydrate_model_data replaces model_data with .metadata.json content,
|
||||
# which may lack sha256. Restore from cache and persist the fix.
|
||||
if not model_data.get("sha256"):
|
||||
if sha256:
|
||||
model_data["sha256"] = sha256
|
||||
model_data["hash_status"] = model_data.get("hash_status", hash_status)
|
||||
data_to_save = model_data.copy()
|
||||
data_to_save.pop("folder", None)
|
||||
await MetadataManager.save_metadata(file_path, data_to_save)
|
||||
else:
|
||||
sha256 = await calculate_sha256(file_path)
|
||||
if sha256:
|
||||
model_data["sha256"] = sha256.lower()
|
||||
model_data["hash_status"] = "completed"
|
||||
data_to_save = model_data.copy()
|
||||
data_to_save.pop("folder", None)
|
||||
await MetadataManager.save_metadata(file_path, data_to_save)
|
||||
else:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Failed to compute SHA256 hash for model",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
|
||||
success, error = await self._metadata_sync.fetch_and_update_model(
|
||||
sha256=model_data["sha256"],
|
||||
file_path=file_path,
|
||||
@@ -450,10 +582,25 @@ class ModelManagementHandler:
|
||||
if not success:
|
||||
return web.json_response({"success": False, "error": error})
|
||||
|
||||
formatted_metadata = await self._service.format_response(model_data)
|
||||
return web.json_response({"success": True, "metadata": formatted_metadata})
|
||||
formatted = await self._service.format_response(model_data)
|
||||
if formatted is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model entry is corrupted (missing file_path)"},
|
||||
status=500,
|
||||
)
|
||||
return web.json_response({"success": True, "metadata": formatted})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error fetching from CivitAI: %s", exc, exc_info=True)
|
||||
if is_expected_offline_error(str(exc)):
|
||||
return web.json_response(
|
||||
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||
status=503,
|
||||
)
|
||||
self._logger.error(
|
||||
"Error fetching from CivitAI for %s: %s",
|
||||
locals().get("file_path", "unknown"),
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def relink_civitai(self, request: web.Request) -> web.Response:
|
||||
@@ -499,6 +646,11 @@ class ModelManagementHandler:
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
if is_expected_offline_error(str(exc)):
|
||||
return web.json_response(
|
||||
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||
status=503,
|
||||
)
|
||||
self._logger.error("Error re-linking to CivitAI: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
@@ -713,7 +865,7 @@ class ModelManagementHandler:
|
||||
|
||||
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,
|
||||
updates=metadata_updates,
|
||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||
@@ -724,7 +876,12 @@ class ModelManagementHandler:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
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:
|
||||
self._logger.error("Error saving metadata: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
@@ -741,14 +898,16 @@ class ModelManagementHandler:
|
||||
if not isinstance(new_tags, list):
|
||||
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,
|
||||
new_tags=new_tags,
|
||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||
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:
|
||||
self._logger.error("Error adding tags: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
@@ -848,6 +1007,8 @@ class ModelQueryHandler:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
top_tags = await self._service.get_top_tags(limit)
|
||||
return web.json_response({"success": True, "tags": top_tags})
|
||||
except Exception as exc:
|
||||
@@ -856,10 +1017,26 @@ class ModelQueryHandler:
|
||||
{"success": False, "error": "Internal server error"}, status=500
|
||||
)
|
||||
|
||||
async def search_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
query = request.query.get("q", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
tags = await self._service.search_tags(query, limit)
|
||||
return web.json_response({"success": True, "tags": tags})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error searching tags: %s", exc, exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Internal server error"}, status=500
|
||||
)
|
||||
|
||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 1 or limit > 100:
|
||||
if limit < 0 or limit > 100:
|
||||
limit = 20
|
||||
base_models = await self._service.get_base_models(limit)
|
||||
return web.json_response({"success": True, "base_models": base_models})
|
||||
@@ -991,10 +1168,12 @@ class ModelQueryHandler:
|
||||
# Sort: originals first, copies last
|
||||
sorted_models = self._sort_duplicate_group(filtered)
|
||||
|
||||
# Format response
|
||||
# Format response, filtering out corrupted entries (issue #730)
|
||||
group = {"hash": sha256, "models": []}
|
||||
for model in sorted_models:
|
||||
group["models"].append(await self._service.format_response(model))
|
||||
formatted = await self._service.format_response(model)
|
||||
if formatted is not None:
|
||||
group["models"].append(formatted)
|
||||
|
||||
# Only include groups with 2+ models after filtering
|
||||
if len(group["models"]) > 1:
|
||||
@@ -1095,6 +1274,12 @@ class ModelQueryHandler:
|
||||
|
||||
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||
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()
|
||||
result = []
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
@@ -1105,9 +1290,9 @@ class ModelQueryHandler:
|
||||
(m for m in cache.raw_data if m["file_path"] == path), None
|
||||
)
|
||||
if model:
|
||||
group["models"].append(
|
||||
await self._service.format_response(model)
|
||||
)
|
||||
formatted = await self._service.format_response(model)
|
||||
if formatted is not None:
|
||||
group["models"].append(formatted)
|
||||
hash_val = self._service.scanner.get_hash_by_filename(filename)
|
||||
if hash_val:
|
||||
main_path = self._service.get_path_by_hash(hash_val)
|
||||
@@ -1117,9 +1302,9 @@ class ModelQueryHandler:
|
||||
None,
|
||||
)
|
||||
if main_model:
|
||||
group["models"].insert(
|
||||
0, await self._service.format_response(main_model)
|
||||
)
|
||||
formatted = await self._service.format_response(main_model)
|
||||
if formatted is not None:
|
||||
group["models"].insert(0, formatted)
|
||||
if group["models"]:
|
||||
result.append(group)
|
||||
return web.json_response(
|
||||
@@ -1142,9 +1327,13 @@ class ModelQueryHandler:
|
||||
text=f"{self._service.model_type.capitalize()} file name is required",
|
||||
status=400,
|
||||
)
|
||||
notes = await self._service.get_model_notes(model_name)
|
||||
if notes is not None:
|
||||
return web.json_response({"success": True, "notes": notes})
|
||||
result = await self._service.get_model_notes(model_name)
|
||||
if result is not None:
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"notes": result["notes"],
|
||||
"file_path": result["file_path"],
|
||||
})
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
@@ -1180,9 +1369,28 @@ class ModelQueryHandler:
|
||||
}
|
||||
if include_license_flags:
|
||||
model_data = await self._service.get_model_info_by_name(model_name)
|
||||
license_flags = (model_data or {}).get("license_flags")
|
||||
if license_flags is not None:
|
||||
response_payload["license_flags"] = int(license_flags)
|
||||
# Only return license_flags when real CivitAI model license
|
||||
# data exists. This mirrors ModelModal's guard
|
||||
# (modelData?.civitai?.model) so the preview tooltip never
|
||||
# shows misleading license icons for HF or other models
|
||||
# without actual license metadata.
|
||||
civitai_data = (model_data or {}).get("civitai") or {}
|
||||
has_license_data = (
|
||||
isinstance(civitai_data, dict)
|
||||
and isinstance(civitai_data.get("model"), dict)
|
||||
)
|
||||
if has_license_data:
|
||||
license_flags = (model_data or {}).get("license_flags")
|
||||
if license_flags is not None:
|
||||
response_payload["license_flags"] = int(license_flags)
|
||||
# Include the user's license icon style preference so the
|
||||
# ComfyUI tooltip can pick the right set without a separate
|
||||
# API call.
|
||||
try:
|
||||
settings = get_settings_manager()
|
||||
response_payload["use_new_license_icons"] = settings.get("use_new_license_icons", True)
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response(response_payload)
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1384,6 +1592,21 @@ class ModelDownloadHandler:
|
||||
)
|
||||
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:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
@@ -1464,6 +1687,303 @@ class ModelDownloadHandler:
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Download queue / history handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
queue = await service.get_queue()
|
||||
stats = await service.get_stats()
|
||||
return web.json_response({"success": True, "queue": queue, "stats": stats})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def add_to_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
import uuid
|
||||
|
||||
download_id = request.query.get("download_id") or str(uuid.uuid4())
|
||||
model_id_str = request.query.get("model_id")
|
||||
model_version_id_str = request.query.get("model_version_id")
|
||||
model_name = request.query.get("model_name", "")
|
||||
version_name = request.query.get("version_name", "")
|
||||
thumbnail_url = request.query.get("thumbnail_url", "")
|
||||
source = request.query.get("source")
|
||||
file_params_json = request.query.get("file_params")
|
||||
|
||||
model_id = int(model_id_str) if model_id_str else None
|
||||
model_version_id = int(model_version_id_str) if model_version_id_str else None
|
||||
file_params = json.loads(file_params_json) if file_params_json else None
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.add_to_queue(
|
||||
download_id=download_id,
|
||||
model_id=model_id,
|
||||
model_version_id=model_version_id,
|
||||
model_name=model_name,
|
||||
version_name=version_name,
|
||||
thumbnail_url=thumbnail_url,
|
||||
source=source,
|
||||
file_params=file_params,
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error adding to download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def remove_from_download_queue(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
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
removed = await service.remove_from_queue(download_id)
|
||||
return web.json_response({"success": removed})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error removing from download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_queue_item_to_top(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
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
moved = await service.move_to_top(download_id)
|
||||
return web.json_response({"success": moved})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error moving queue item to top: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_queue_item_to_end(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
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
moved = await service.move_to_end(download_id)
|
||||
return web.json_response({"success": moved})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error moving queue item to end: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def clear_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cleared = await service.clear_queue(status_filter=status_filter)
|
||||
return web.json_response({"success": True, "cleared": cleared})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error clearing download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_download_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = min(int(request.query.get("limit", "50")), 500)
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
result = await service.get_history(
|
||||
limit=limit, offset=offset, status_filter=status_filter
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"items": result["items"],
|
||||
"total": result["total"],
|
||||
"limit": result["limit"],
|
||||
"offset": result["offset"],
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def clear_download_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cleared = await service.clear_history(status_filter=status_filter)
|
||||
return web.json_response({"success": True, "cleared": cleared})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error clearing download history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
id_str = request.query.get("id")
|
||||
item_id = int(id_str) if id_str else None
|
||||
|
||||
if not download_id and not item_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "id or download_id is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
deleted = await service.delete_history_item(
|
||||
id=item_id, download_id=download_id
|
||||
)
|
||||
return web.json_response({"success": deleted})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error deleting download history item: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
id_str = request.query.get("id")
|
||||
item_id = int(id_str) if id_str else None
|
||||
|
||||
if not download_id and not item_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "id or download_id is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.retry_from_history(
|
||||
item_id=item_id, download_id=download_id
|
||||
)
|
||||
if item is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "History item not found or not retryable"},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrying download from history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def retry_all_failed_downloads(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
retry_count = await service.retry_all_failed()
|
||||
return web.json_response({"success": True, "retry_count": retry_count})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrying all failed downloads: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def complete_download_in_queue(self, request: web.Request) -> web.Response:
|
||||
"""Atomically move a download from queue to history with terminal status."""
|
||||
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
|
||||
)
|
||||
status = request.query.get("status", "completed")
|
||||
error = request.query.get("error")
|
||||
file_path = request.query.get("file_path")
|
||||
try:
|
||||
bytes_downloaded = int(request.query.get("bytes_downloaded", "0"))
|
||||
except (TypeError, ValueError):
|
||||
bytes_downloaded = 0
|
||||
total_bytes_raw = request.query.get("total_bytes")
|
||||
total_bytes = int(total_bytes_raw) if total_bytes_raw else None
|
||||
completed_at_raw = request.query.get("completed_at")
|
||||
completed_at = float(completed_at_raw) if completed_at_raw else None
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.complete_download(
|
||||
download_id=download_id,
|
||||
status=status,
|
||||
error=error,
|
||||
file_path=file_path,
|
||||
bytes_downloaded=bytes_downloaded,
|
||||
total_bytes=total_bytes,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
if item is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"}, status=404
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error completing download: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_download_stats(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
stats = await service.get_stats()
|
||||
return web.json_response({"success": True, "stats": stats})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download stats: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def update_download_queue_status(self, request: web.Request) -> web.Response:
|
||||
"""Update the status of a queue item (non-terminal transitions).
|
||||
|
||||
Supported transitions include ``queued → downloading``,
|
||||
``downloading → paused``, ``paused → downloading``, etc.
|
||||
Terminal transitions (``completed``, ``failed``, ``canceled``)
|
||||
should use ``complete_download_in_queue`` instead.
|
||||
"""
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
status = request.query.get("status")
|
||||
if not download_id or not status:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "download_id and status are required",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
service = await DownloadQueueService.get_instance()
|
||||
updated = await service.update_status(download_id, status)
|
||||
if not updated:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error updating download queue status: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelCivitaiHandler:
|
||||
"""CivitAI integration endpoints."""
|
||||
@@ -1505,7 +2025,9 @@ class ModelCivitaiHandler:
|
||||
return web.json_response(result)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error in fetch_all_civitai for %ss: %s", self._service.model_type, exc
|
||||
"Error in fetch_all_civitai for %ss: %s",
|
||||
self._service.model_type, exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
|
||||
@@ -1807,6 +2329,11 @@ class ModelUpdateHandler:
|
||||
status=429,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
if is_expected_offline_error(str(exc)):
|
||||
return web.json_response(
|
||||
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||
status=503,
|
||||
)
|
||||
self._logger.error("Failed to fetch license info: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
@@ -1867,6 +2394,10 @@ class ModelUpdateHandler:
|
||||
if 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()
|
||||
if provider is None:
|
||||
return web.json_response(
|
||||
@@ -1881,6 +2412,7 @@ class ModelUpdateHandler:
|
||||
provider,
|
||||
force_refresh=force_refresh,
|
||||
target_model_ids=target_model_ids or None,
|
||||
folder_path=folder_path,
|
||||
)
|
||||
if self._service.scanner.is_cancelled():
|
||||
return web.json_response(
|
||||
@@ -1895,15 +2427,29 @@ class ModelUpdateHandler:
|
||||
{"success": False, "error": str(exc) or "Rate limited"}, status=429
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.error(
|
||||
"Failed to refresh model updates: %s", exc, exc_info=True
|
||||
)
|
||||
if is_expected_offline_error(str(exc)):
|
||||
return web.json_response(
|
||||
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
|
||||
status=503,
|
||||
)
|
||||
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
|
||||
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 = []
|
||||
for record in records.values():
|
||||
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))
|
||||
|
||||
return web.json_response(
|
||||
@@ -2350,6 +2896,7 @@ class ModelUpdateHandler:
|
||||
"shouldIgnore": version.should_ignore,
|
||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||
"isEarlyAccess": is_early_access,
|
||||
"usageControl": version.usage_control,
|
||||
"filePath": context.get("file_path"),
|
||||
"fileName": context.get("file_name"),
|
||||
}
|
||||
@@ -2437,8 +2984,10 @@ class ModelHandlerSet:
|
||||
return {
|
||||
"handle_models_page": self.page_view.handle,
|
||||
"get_models": self.listing.get_models,
|
||||
"get_excluded_models": self.listing.get_excluded_models,
|
||||
"delete_model": self.management.delete_model,
|
||||
"exclude_model": self.management.exclude_model,
|
||||
"unexclude_model": self.management.unexclude_model,
|
||||
"fetch_civitai": self.management.fetch_civitai,
|
||||
"fetch_all_civitai": self.civitai.fetch_all_civitai,
|
||||
"relink_civitai": self.management.relink_civitai,
|
||||
@@ -2450,6 +2999,7 @@ class ModelHandlerSet:
|
||||
"bulk_delete_models": self.management.bulk_delete_models,
|
||||
"verify_duplicates": self.management.verify_duplicates,
|
||||
"get_top_tags": self.query.get_top_tags,
|
||||
"search_tags": self.query.search_tags,
|
||||
"get_base_models": self.query.get_base_models,
|
||||
"get_model_types": self.query.get_model_types,
|
||||
"scan_models": self.query.scan_models,
|
||||
@@ -2462,9 +3012,24 @@ class ModelHandlerSet:
|
||||
"download_model": self.download.download_model,
|
||||
"download_model_get": self.download.download_model_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,
|
||||
"resume_download_get": self.download.resume_download_get,
|
||||
"get_download_progress": self.download.get_download_progress,
|
||||
"get_download_queue": self.download.get_download_queue,
|
||||
"add_to_download_queue": self.download.add_to_download_queue,
|
||||
"remove_from_download_queue": self.download.remove_from_download_queue,
|
||||
"move_queue_item_to_top": self.download.move_queue_item_to_top,
|
||||
"move_queue_item_to_end": self.download.move_queue_item_to_end,
|
||||
"clear_download_queue": self.download.clear_download_queue,
|
||||
"get_download_history": self.download.get_download_history,
|
||||
"clear_download_history": self.download.clear_download_history,
|
||||
"delete_download_history_item": self.download.delete_download_history_item,
|
||||
"retry_download_from_history": self.download.retry_download_from_history,
|
||||
"retry_all_failed_downloads": self.download.retry_all_failed_downloads,
|
||||
"complete_download_in_queue": self.download.complete_download_in_queue,
|
||||
"get_download_stats": self.download.get_download_stats,
|
||||
"update_download_queue_status": self.download.update_download_queue_status,
|
||||
"get_civitai_versions": self.civitai.get_civitai_versions,
|
||||
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
|
||||
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import mimetypes
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,6 +14,12 @@ from ...config import config as global_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK_SIZE = 1024 * 1024 # 1 MB — balance between streaming iteration overhead and per-chunk memory
|
||||
|
||||
# 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:
|
||||
"""Serve preview assets for the active library at request time."""
|
||||
@@ -46,10 +54,90 @@ class PreviewHandler:
|
||||
|
||||
if not resolved.is_file():
|
||||
logger.debug("Preview file not found at %s", str(resolved))
|
||||
asyncio.create_task(self._cleanup_stale_preview_url(normalized))
|
||||
raise web.HTTPNotFound(text="Preview file not found")
|
||||
|
||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
|
||||
# aiohttp's FileResponse handles range requests, content headers, and
|
||||
# uses kernel sendfile (zero-copy DMA) on Linux/macOS. On Windows it
|
||||
# uses IOCP-based _sendfile_native which can crash when the client
|
||||
# disconnects mid-transfer during fast scrolling. The _stream_file()
|
||||
# fallback is kept for a future compat toggle.
|
||||
#
|
||||
# Set explicit Cache-Control so the browser can cache video (and image)
|
||||
# previews across VirtualScroller recycling cycles. Without this,
|
||||
# Chrome does not cache 206 Partial Content responses for <video>
|
||||
# elements, causing the same video to be re-downloaded on every scroll.
|
||||
resp = web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
return resp
|
||||
|
||||
async def _cleanup_stale_preview_url(self, normalized_preview_path: str) -> None:
|
||||
"""Fire-and-forget: clear stale preview_url from all model caches.
|
||||
|
||||
When a preview file is no longer on disk, remove its reference from
|
||||
every cached entry so subsequent list API responses return an empty
|
||||
``preview_url``, letting the frontend show the no-preview placeholder.
|
||||
"""
|
||||
try:
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
|
||||
for service_name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||
scanner = ServiceRegistry.get_service_sync(service_name)
|
||||
if scanner is None or not hasattr(scanner, "_cache"):
|
||||
continue
|
||||
cache = getattr(scanner, "_cache", None)
|
||||
if cache is None or not hasattr(cache, "clear_preview_by_path"):
|
||||
continue
|
||||
cleared = await cache.clear_preview_by_path(normalized_preview_path)
|
||||
if cleared and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
logger.info(
|
||||
"Cleared stale preview_url for %d %s entries (%s)",
|
||||
cleared,
|
||||
service_name,
|
||||
normalized_preview_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to clean up stale preview_url: %s", exc)
|
||||
|
||||
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
|
||||
|
||||
# Allow browser caching: video previews rarely change during a session.
|
||||
# The frontend already appends ?t={version} to bust cache on update.
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
|
||||
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"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,11 @@ class RouteDefinition:
|
||||
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||
RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"),
|
||||
RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"),
|
||||
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
@@ -36,12 +39,15 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
||||
RouteDefinition("GET", "/api/lm/update-lora-code", "get_update_lora_code"),
|
||||
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
||||
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
||||
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||
RouteDefinition("GET", "/api/lm/update-node-widget", "get_update_node_widget"),
|
||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/model-version-download-status",
|
||||
@@ -89,6 +95,29 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||
),
|
||||
# Hugging Face model endpoints
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/set-hf-url", "set_hf_url"
|
||||
),
|
||||
# Agent skill endpoints
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/agent/skills", "get_agent_skills"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/agent/execute/{skill_name}", "execute_agent_skill"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/agent/cancel", "cancel_agent_skill"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ from .handlers.misc_handlers import (
|
||||
build_service_registry_adapter,
|
||||
)
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
from .handlers.hf_handlers import HfHandler
|
||||
from .handlers.agent_handlers import AgentHandler
|
||||
from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -136,6 +138,8 @@ class MiscRoutes:
|
||||
doctor = DoctorHandler(settings_service=self._settings)
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
base_model = BaseModelHandlerSet()
|
||||
hf_handler = HfHandler()
|
||||
agent_handler = AgentHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -155,6 +159,8 @@ class MiscRoutes:
|
||||
doctor=doctor,
|
||||
example_workflows=example_workflows,
|
||||
base_model=base_model,
|
||||
hf_handler=hf_handler,
|
||||
agent_handler=agent_handler,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ class RouteDefinition:
|
||||
|
||||
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
|
||||
@@ -44,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
|
||||
@@ -99,11 +102,46 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
||||
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/skip-download", "skip_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/download-progress/{download_id}", "get_download_progress"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/queue", "get_download_queue"),
|
||||
RouteDefinition("GET", "/api/lm/downloads/queue/add", "add_to_download_queue"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/remove", "remove_from_download_queue"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/move-to-top", "move_queue_item_to_top"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/move-to-end", "move_queue_item_to_end"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/clear", "clear_download_queue"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/history", "get_download_history"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/clear", "clear_download_history"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/delete", "delete_download_history_item"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/retry", "retry_download_from_history"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/retry-all", "retry_all_failed_downloads"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/stats", "get_download_stats"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/status", "update_download_queue_status"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
|
||||
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/search-tags", "search_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||
@@ -58,6 +59,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||
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("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
||||
RouteDefinition(
|
||||
@@ -70,6 +72,16 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
|
||||
),
|
||||
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"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from ..config import config
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.model_query import normalize_sub_type, resolve_sub_type
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||
from ..utils.usage_stats import UsageStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -140,6 +142,21 @@ class StatsRoutes:
|
||||
# Get usage statistics
|
||||
usage_data = await self.usage_stats.get_stats()
|
||||
|
||||
# CivitAI model type distribution across all model types
|
||||
# Use the same logic as the filter panel: normalize_sub_type(resolve_sub_type(entry))
|
||||
# with sub-type validation per model type
|
||||
model_types_counter: Counter[str] = Counter()
|
||||
for entry in lora_cache.raw_data:
|
||||
ntype = normalize_sub_type(resolve_sub_type(entry))
|
||||
if ntype and ntype in VALID_LORA_SUB_TYPES:
|
||||
model_types_counter[ntype] += 1
|
||||
for entry in checkpoint_cache.raw_data:
|
||||
ntype = normalize_sub_type(resolve_sub_type(entry))
|
||||
if ntype and ntype in VALID_CHECKPOINT_SUB_TYPES:
|
||||
model_types_counter[ntype] += 1
|
||||
# Embeddings: always count as "embedding" regardless of CivitAI sub-type
|
||||
model_types_counter['embedding'] = len(embedding_cache.raw_data)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'data': {
|
||||
@@ -154,7 +171,8 @@ class StatsRoutes:
|
||||
'total_generations': usage_data.get('total_executions', 0),
|
||||
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
|
||||
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
|
||||
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
|
||||
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {})),
|
||||
'model_types_distribution': dict(model_types_counter.most_common())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -459,9 +477,12 @@ class StatsRoutes:
|
||||
if unused_lora_percent > 50:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'High Number of Unused LoRAs',
|
||||
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
|
||||
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
|
||||
'key': 'insights.unusedLoras.high',
|
||||
'params': {
|
||||
'percent': f'{unused_lora_percent:.1f}',
|
||||
'count': str(unused_loras),
|
||||
'total': str(total_loras)
|
||||
}
|
||||
})
|
||||
|
||||
if total_checkpoints > 0:
|
||||
@@ -469,9 +490,12 @@ class StatsRoutes:
|
||||
if unused_checkpoint_percent > 30:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'Unused Checkpoints Detected',
|
||||
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
|
||||
'suggestion': 'Review and consider removing checkpoints you no longer need.'
|
||||
'key': 'insights.unusedCheckpoints.detected',
|
||||
'params': {
|
||||
'percent': f'{unused_checkpoint_percent:.1f}',
|
||||
'count': str(unused_checkpoints),
|
||||
'total': str(total_checkpoints)
|
||||
}
|
||||
})
|
||||
|
||||
if total_embeddings > 0:
|
||||
@@ -479,9 +503,12 @@ class StatsRoutes:
|
||||
if unused_embedding_percent > 50:
|
||||
insights.append({
|
||||
'type': 'warning',
|
||||
'title': 'High Number of Unused Embeddings',
|
||||
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
|
||||
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
|
||||
'key': 'insights.unusedEmbeddings.high',
|
||||
'params': {
|
||||
'percent': f'{unused_embedding_percent:.1f}',
|
||||
'count': str(unused_embeddings),
|
||||
'total': str(total_embeddings)
|
||||
}
|
||||
})
|
||||
|
||||
# Storage insights
|
||||
@@ -492,18 +519,20 @@ class StatsRoutes:
|
||||
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
||||
insights.append({
|
||||
'type': 'info',
|
||||
'title': 'Large Collection Detected',
|
||||
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
|
||||
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
|
||||
'key': 'insights.collection.large',
|
||||
'params': {
|
||||
'size': self._format_size(total_size)
|
||||
}
|
||||
})
|
||||
|
||||
# Recent activity insight
|
||||
if usage_data.get('total_executions', 0) > 100:
|
||||
insights.append({
|
||||
'type': 'success',
|
||||
'title': 'Active User',
|
||||
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
|
||||
'suggestion': 'Keep exploring and creating amazing content with your models.'
|
||||
'key': 'insights.activity.active',
|
||||
'params': {
|
||||
'count': str(usage_data['total_executions'])
|
||||
}
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
import toml
|
||||
import git
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -11,11 +10,111 @@ from typing import Dict, List
|
||||
|
||||
from ..utils.settings_paths import ensure_settings_file
|
||||
from ..services.downloader import get_downloader
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||
|
||||
# User-managed directories that live inside the plugin folder (portable
|
||||
# mode) and must survive a Git-based update. ``git clean -fd`` would
|
||||
# otherwise delete them because they are untracked and, in released tags,
|
||||
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||
# regardless of whether it is ignored.
|
||||
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
||||
|
||||
|
||||
def _clean_excludes() -> List[str]:
|
||||
"""Build the ``-e`` arguments for ``git clean`` from :data:`_PRESERVE_DIRS`."""
|
||||
excludes: List[str] = []
|
||||
for name in _PRESERVE_DIRS:
|
||||
excludes.append('-e')
|
||||
excludes.append(name)
|
||||
# For directories, also exclude nested matches explicitly
|
||||
# (``-e dir`` alone matches the dir entry; ``-e dir/**`` guards
|
||||
# contents under all git versions as defense-in-depth).
|
||||
excludes.append('-e')
|
||||
excludes.append(f'{name}/**')
|
||||
return excludes
|
||||
|
||||
|
||||
def _stage_preserved_items(plugin_root: str) -> tuple[str, list[str]]:
|
||||
"""Move preserved user-data items to a temp directory outside *plugin_root*.
|
||||
|
||||
This ensures that ``git reset --hard``, ``git clean -fd``, and ZIP-based
|
||||
replacement cannot touch these files even when ``-e`` exclusion patterns
|
||||
are mishandled (e.g. on Windows where forward-slash patterns may not
|
||||
match backslash-prefixed paths in some Git builds, or where file locks
|
||||
prevent deletion/recreation).
|
||||
|
||||
Returns:
|
||||
``(backup_root, staged_names)``: the temp directory path and the
|
||||
list of item names that were successfully moved.
|
||||
"""
|
||||
backup_root = tempfile.mkdtemp(prefix='lora_manager_update_')
|
||||
staged: list[str] = []
|
||||
for name in _PRESERVE_DIRS:
|
||||
src = os.path.join(plugin_root, name)
|
||||
if not os.path.lexists(src):
|
||||
continue
|
||||
dst = os.path.join(backup_root, name)
|
||||
try:
|
||||
shutil.move(src, dst)
|
||||
staged.append(name)
|
||||
logger.debug("Staged '%s' for update safety", name)
|
||||
except OSError:
|
||||
# ``shutil.move`` may fail on Windows if a file handle inside
|
||||
# the directory is still open (e.g. a SQLite WAL file). Fall
|
||||
# back to copy-then-remove.
|
||||
logger.debug("Move failed for '%s', falling back to copy", name)
|
||||
try:
|
||||
if os.path.isdir(src) and not os.path.islink(src):
|
||||
shutil.copytree(src, dst, symlinks=True)
|
||||
shutil.rmtree(src, ignore_errors=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
os.remove(src)
|
||||
staged.append(name)
|
||||
logger.info("Copied (then removed) '%s' for update safety", name)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not stage '%s': %s (will rely on git -e / skip lists)", name, exc
|
||||
)
|
||||
return backup_root, staged
|
||||
|
||||
|
||||
def _restore_preserved_items(plugin_root: str, backup_root: str, staged: list[str]) -> None:
|
||||
"""Move staged items back from *backup_root* into *plugin_root*.
|
||||
|
||||
Any leftover placeholder at the destination (created by git checkout or
|
||||
ZIP extraction) is removed before the move.
|
||||
"""
|
||||
for name in staged:
|
||||
src = os.path.join(backup_root, name)
|
||||
dst = os.path.join(plugin_root, name)
|
||||
try:
|
||||
if os.path.lexists(dst):
|
||||
if os.path.isdir(dst) and not os.path.islink(dst):
|
||||
shutil.rmtree(dst, ignore_errors=True)
|
||||
else:
|
||||
os.remove(dst)
|
||||
shutil.move(src, dst)
|
||||
logger.debug("Restored '%s' after update", name)
|
||||
except OSError:
|
||||
logger.debug("Move failed restoring '%s', falling back to copy", name)
|
||||
try:
|
||||
if os.path.isdir(src) and not os.path.islink(src):
|
||||
shutil.copytree(src, dst, symlinks=True, dirs_exist_ok=True)
|
||||
shutil.rmtree(src, ignore_errors=True)
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
os.remove(src)
|
||||
logger.info("Copied '%s' back after update", name)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to restore '%s': %s", name, exc)
|
||||
shutil.rmtree(backup_root, ignore_errors=True)
|
||||
|
||||
|
||||
|
||||
class UpdateRoutes:
|
||||
"""Routes for handling plugin update checks"""
|
||||
@@ -26,6 +125,7 @@ class UpdateRoutes:
|
||||
app.router.add_get('/api/lm/check-updates', UpdateRoutes.check_updates)
|
||||
app.router.add_get('/api/lm/version-info', UpdateRoutes.get_version_info)
|
||||
app.router.add_post('/api/lm/perform-update', UpdateRoutes.perform_update)
|
||||
app.router.add_post('/api/lm/switch-channel', UpdateRoutes.switch_channel)
|
||||
|
||||
@staticmethod
|
||||
async def check_updates(request):
|
||||
@@ -44,10 +144,17 @@ class UpdateRoutes:
|
||||
|
||||
# Fetch remote version from GitHub
|
||||
if nightly:
|
||||
remote_version, changelog = await UpdateRoutes._get_nightly_version()
|
||||
releases = None
|
||||
local_hash = git_info.get('short_hash', '')
|
||||
nightly_version, releases_result = await asyncio.gather(
|
||||
UpdateRoutes._get_nightly_version(local_hash),
|
||||
UpdateRoutes._get_remote_version()
|
||||
)
|
||||
remote_version, _, behind_by, commit_date = nightly_version
|
||||
_, changelog, releases = releases_result
|
||||
else:
|
||||
remote_version, changelog, releases = await UpdateRoutes._get_remote_version()
|
||||
behind_by = 0
|
||||
commit_date = ''
|
||||
|
||||
# Compare versions
|
||||
if nightly:
|
||||
@@ -60,6 +167,10 @@ class UpdateRoutes:
|
||||
remote_version.replace('v', '')
|
||||
)
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
|
||||
|
||||
response_data = {
|
||||
'success': True,
|
||||
'current_version': local_version,
|
||||
@@ -67,13 +178,13 @@ class UpdateRoutes:
|
||||
'update_available': update_available,
|
||||
'changelog': changelog,
|
||||
'git_info': git_info,
|
||||
'nightly': nightly
|
||||
'nightly': nightly,
|
||||
'has_git': has_git,
|
||||
'releases': releases,
|
||||
'behind_by': behind_by,
|
||||
'commit_date': commit_date
|
||||
}
|
||||
|
||||
# Include releases list for stable mode
|
||||
if releases is not None:
|
||||
response_data['releases'] = releases
|
||||
|
||||
return web.json_response(response_data)
|
||||
|
||||
except NETWORK_EXCEPTIONS as e:
|
||||
@@ -105,9 +216,14 @@ class UpdateRoutes:
|
||||
# Format: version-short_hash
|
||||
version_string = f"{local_version}-{short_hash}"
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
has_git = os.path.exists(os.path.join(plugin_root, '.git'))
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'version': version_string
|
||||
'version': version_string,
|
||||
'has_git': has_git
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -135,20 +251,22 @@ class UpdateRoutes:
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings_backup = f.read()
|
||||
logger.info("Backed up settings.json")
|
||||
logger.debug("Backed up settings.json (%d bytes)", len(settings_backup))
|
||||
|
||||
git_folder = os.path.join(plugin_root, '.git')
|
||||
if os.path.exists(git_folder):
|
||||
# Git update
|
||||
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
||||
else:
|
||||
# Fallback: Download ZIP and replace files
|
||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||
try:
|
||||
git_folder = os.path.join(plugin_root, '.git')
|
||||
if os.path.exists(git_folder):
|
||||
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
||||
else:
|
||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||
finally:
|
||||
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||
|
||||
if settings_backup and success:
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
f.write(settings_backup)
|
||||
logger.info("Restored settings.json")
|
||||
logger.debug("Restored settings.json content (%d bytes)", len(settings_backup))
|
||||
|
||||
if success:
|
||||
return web.json_response({
|
||||
@@ -169,6 +287,164 @@ class UpdateRoutes:
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
async def switch_channel(request):
|
||||
"""
|
||||
Switch between release and nightly update channels.
|
||||
|
||||
ZIP/CNR install → Nightly: git init + checkout main (one-way upgrade)
|
||||
Git install → Release: git checkout latest tag (.git preserved)
|
||||
ZIP/CNR install → Release: ZIP download (no .git, stays in ZIP mode)
|
||||
Git install → Nightly: git checkout main + pull
|
||||
"""
|
||||
try:
|
||||
body = await request.json() if request.has_body else {}
|
||||
channel = body.get('channel', '')
|
||||
|
||||
if channel not in ('release', 'nightly'):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'Invalid channel: {channel}. Must be "release" or "nightly".'
|
||||
})
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
|
||||
settings_path = ensure_settings_file(logger)
|
||||
settings_backup = None
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings_backup = f.read()
|
||||
logger.debug("Backed up settings.json before channel switch (%d bytes)", len(settings_backup))
|
||||
|
||||
staged_backup_dir, staged_items = _stage_preserved_items(plugin_root)
|
||||
try:
|
||||
git_folder = os.path.join(plugin_root, '.git')
|
||||
|
||||
if channel == 'nightly':
|
||||
git_backup = None
|
||||
if os.path.exists(git_folder):
|
||||
git_backup = UpdateRoutes._backup_git(git_folder, 'nightly')
|
||||
|
||||
success = False
|
||||
new_version = ''
|
||||
try:
|
||||
if os.path.exists(git_folder):
|
||||
success, new_version = await UpdateRoutes._perform_git_update(
|
||||
plugin_root, nightly=True
|
||||
)
|
||||
else:
|
||||
success, new_version = UpdateRoutes._init_git_repo(plugin_root)
|
||||
finally:
|
||||
UpdateRoutes._restore_git(git_backup, git_folder, success, 'nightly')
|
||||
else:
|
||||
success = False
|
||||
new_version = ''
|
||||
if os.path.exists(git_folder):
|
||||
success, new_version = await UpdateRoutes._perform_git_update(
|
||||
plugin_root, nightly=False
|
||||
)
|
||||
else:
|
||||
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||
if os.path.exists(tracking_file):
|
||||
os.remove(tracking_file)
|
||||
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||
finally:
|
||||
_restore_preserved_items(plugin_root, staged_backup_dir, staged_items)
|
||||
|
||||
if settings_backup and success:
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
f.write(settings_backup)
|
||||
logger.debug("Restored settings.json content after channel switch (%d bytes)", len(settings_backup))
|
||||
|
||||
if success:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'channel': channel,
|
||||
'new_version': new_version,
|
||||
'message': f'Switched to {channel} channel'
|
||||
})
|
||||
else:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'Failed to switch to {channel} channel'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to switch channel: %s", e, exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _init_git_repo(plugin_root: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Initialize a Git repository in a ZIP-installed plugin folder.
|
||||
Clones the remote history and checks out main branch.
|
||||
"""
|
||||
try:
|
||||
import git
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"GitPython is not available: cannot initialize git repo. "
|
||||
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
|
||||
)
|
||||
return False, ""
|
||||
|
||||
clean_excludes = _clean_excludes()
|
||||
|
||||
try:
|
||||
repo = git.Repo.init(plugin_root)
|
||||
origin = repo.create_remote(
|
||||
'origin',
|
||||
'https://github.com/willmiao/ComfyUI-Lora-Manager.git'
|
||||
)
|
||||
origin.fetch()
|
||||
|
||||
repo.create_head('main', origin.refs.main)
|
||||
repo.git.checkout('main', '--force')
|
||||
repo.git.reset('--hard')
|
||||
repo.git.clean('-fd', *clean_excludes)
|
||||
|
||||
tracking_file = os.path.join(plugin_root, '.tracking')
|
||||
if os.path.exists(tracking_file):
|
||||
os.remove(tracking_file)
|
||||
logger.info("Removed .tracking file (now in git mode)")
|
||||
|
||||
new_version = f"main-{repo.head.commit.hexsha[:7]}"
|
||||
logger.info("Initialized git repo on main branch: %s", new_version)
|
||||
return True, new_version
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize git repo: %s", e, exc_info=True)
|
||||
return False, ""
|
||||
|
||||
@staticmethod
|
||||
def _backup_git(git_folder, label):
|
||||
try:
|
||||
backup_dir = tempfile.mkdtemp()
|
||||
backup = os.path.join(backup_dir, '.git')
|
||||
shutil.copytree(git_folder, backup)
|
||||
logger.info("Backed up .git before switching to %s", label)
|
||||
return backup
|
||||
except Exception as e:
|
||||
logger.error("Failed to backup .git before %s switch: %s", label, e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _restore_git(git_backup, git_folder, success, label):
|
||||
if git_backup and not success:
|
||||
try:
|
||||
if os.path.exists(git_folder):
|
||||
shutil.rmtree(git_folder)
|
||||
shutil.copytree(git_backup, git_folder)
|
||||
logger.info("Restored .git after failed %s switch", label)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore .git after %s switch: %s", label, e)
|
||||
if git_backup:
|
||||
shutil.rmtree(os.path.dirname(git_backup), ignore_errors=True)
|
||||
|
||||
@staticmethod
|
||||
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
||||
"""
|
||||
@@ -212,8 +488,18 @@ class UpdateRoutes:
|
||||
|
||||
zip_path = tmp_zip_path
|
||||
|
||||
# Skip both settings.json, civitai and model cache folder
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
||||
# Close the downloaded-versions SQLite connection before cleaning,
|
||||
# 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)
|
||||
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=list(_PRESERVE_DIRS))
|
||||
|
||||
# Extract ZIP to temp dir
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
@@ -222,16 +508,17 @@ class UpdateRoutes:
|
||||
# Find extracted folder (GitHub ZIP contains a root folder)
|
||||
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 = set(_PRESERVE_DIRS)
|
||||
for item in os.listdir(extracted_root):
|
||||
if item == 'settings.json' or item == 'civitai':
|
||||
if item in skip_items:
|
||||
continue
|
||||
src = os.path.join(extracted_root, item)
|
||||
dst = os.path.join(plugin_root, item)
|
||||
if os.path.isdir(src):
|
||||
if os.path.exists(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:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
@@ -239,15 +526,17 @@ class UpdateRoutes:
|
||||
# for ComfyUI Manager to work properly
|
||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||
tracking_files = []
|
||||
skip_tracked = set(_PRESERVE_DIRS) - {'settings.json'}
|
||||
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)
|
||||
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
|
||||
for file in files:
|
||||
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
||||
# Skip settings.json and any file under civitai
|
||||
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep):
|
||||
# Skip settings.json and any file under user data dirs
|
||||
if rel_path == 'settings.json' or rel_path.split(os.sep)[0] in skip_tracked:
|
||||
continue
|
||||
tracking_files.append(rel_path.replace("\\", "/"))
|
||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||
@@ -260,7 +549,8 @@ class UpdateRoutes:
|
||||
except Exception as e:
|
||||
logger.error(f"ZIP update failed: {e}", exc_info=True)
|
||||
return False, ""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _clean_plugin_folder(plugin_root, skip_files=None):
|
||||
skip_files = skip_files or []
|
||||
for item in os.listdir(plugin_root):
|
||||
@@ -273,41 +563,54 @@ class UpdateRoutes:
|
||||
os.remove(path)
|
||||
|
||||
@staticmethod
|
||||
async def _get_nightly_version() -> tuple[str, List[str]]:
|
||||
"""
|
||||
Fetch latest commit from main branch
|
||||
"""
|
||||
async def _get_nightly_version(local_hash: str = "") -> tuple[str, List[str], int, str]:
|
||||
repo_owner = "willmiao"
|
||||
repo_name = "ComfyUI-Lora-Manager"
|
||||
|
||||
# Use GitHub API to fetch the latest commit from main branch
|
||||
|
||||
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/commits/main"
|
||||
|
||||
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
success, data = await downloader.make_request('GET', github_url, custom_headers={'Accept': 'application/vnd.github+json'})
|
||||
|
||||
success, data = await downloader.make_request(
|
||||
'GET', github_url,
|
||||
custom_headers={'Accept': 'application/vnd.github+json'}
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Failed to fetch GitHub commit: {data}")
|
||||
return "main", []
|
||||
|
||||
commit_sha = data.get('sha', '')[:7] # Short hash
|
||||
logger.warning("Failed to fetch GitHub commit: %s", data)
|
||||
return "main", [], 0, ""
|
||||
|
||||
commit_sha = data.get('sha', '')[:7]
|
||||
commit_message = data.get('commit', {}).get('message', '')
|
||||
|
||||
# Format as "main-{short_hash}"
|
||||
commit_date = data.get('commit', {}).get('committer', {}).get('date', '')[:10]
|
||||
|
||||
version = f"main-{commit_sha}"
|
||||
|
||||
# Use commit message as changelog
|
||||
changelog = [commit_message] if commit_message else []
|
||||
|
||||
return version, changelog
|
||||
|
||||
|
||||
behind_by = 0
|
||||
if local_hash and local_hash not in ('unknown', 'stable'):
|
||||
compare_url = (
|
||||
f"https://api.github.com/repos/{repo_owner}/{repo_name}"
|
||||
f"/compare/{local_hash}...main"
|
||||
)
|
||||
c_ok, c_data = await downloader.make_request(
|
||||
'GET', compare_url,
|
||||
custom_headers={'Accept': 'application/vnd.github+json'}
|
||||
)
|
||||
if c_ok:
|
||||
if c_data.get('status') in ('ahead', 'diverged'):
|
||||
behind_by = c_data.get('ahead_by', 0)
|
||||
else:
|
||||
behind_by = c_data.get('behind_by', 0)
|
||||
|
||||
return version, changelog, behind_by, commit_date
|
||||
|
||||
except NETWORK_EXCEPTIONS as e:
|
||||
logger.warning("Unable to reach GitHub for nightly version: %s", e)
|
||||
return "main", []
|
||||
return "main", [], 0, ""
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching nightly version: {e}", exc_info=True)
|
||||
return "main", []
|
||||
logger.error("Error fetching nightly version: %s", e, exc_info=True)
|
||||
return "main", [], 0, ""
|
||||
|
||||
@staticmethod
|
||||
def _compare_nightly_versions(local_git_info: Dict[str, str], remote_version: str) -> bool:
|
||||
@@ -342,6 +645,17 @@ class UpdateRoutes:
|
||||
Returns:
|
||||
tuple: (success, new_version)
|
||||
"""
|
||||
try:
|
||||
import git
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"GitPython is not available: the git executable was not found in PATH. "
|
||||
"Install git or set $GIT_PYTHON_GIT_EXECUTABLE to the git binary path."
|
||||
)
|
||||
return False, ""
|
||||
|
||||
clean_excludes = _clean_excludes()
|
||||
|
||||
try:
|
||||
# Open the Git repository
|
||||
repo = git.Repo(plugin_root)
|
||||
@@ -353,8 +667,9 @@ class UpdateRoutes:
|
||||
if nightly:
|
||||
# Reset to discard any local changes
|
||||
repo.git.reset('--hard')
|
||||
# Clean untracked files
|
||||
repo.git.clean('-fd')
|
||||
# Clean untracked files, but preserve user-managed directories
|
||||
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||
repo.git.clean('-fd', *clean_excludes)
|
||||
|
||||
# Switch to main branch and pull latest
|
||||
main_branch = 'main'
|
||||
@@ -371,8 +686,9 @@ class UpdateRoutes:
|
||||
else:
|
||||
# Reset to discard any local changes
|
||||
repo.git.reset('--hard')
|
||||
# Clean untracked files
|
||||
repo.git.clean('-fd')
|
||||
# Clean untracked files, but preserve user-managed directories
|
||||
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||
repo.git.clean('-fd', *clean_excludes)
|
||||
|
||||
# Get latest release tag
|
||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
||||
@@ -438,6 +754,7 @@ class UpdateRoutes:
|
||||
if not os.path.exists(os.path.join(plugin_root, '.git')):
|
||||
return git_info
|
||||
|
||||
import git
|
||||
repo = git.Repo(plugin_root)
|
||||
commit = repo.head.commit
|
||||
git_info['commit_hash'] = commit.hexsha
|
||||
|
||||
27
py/services/agent/__init__.py
Normal file
27
py/services/agent/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""LLM-powered metadata enrichment pipeline infrastructure.
|
||||
|
||||
This package provides the orchestration layer for LLM-powered features.
|
||||
Skills define *what* to do (prompt template). The :class:`AgentService`
|
||||
handles *how* (LLM calls, context gathering, validation, progress).
|
||||
|
||||
NOTE: The current implementation is a code-driven pipeline, not a true
|
||||
agent loop. Future agent orchestration (LLM-driven tool selection) will
|
||||
live alongside this package with its own namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .skill_definition import SkillDefinition, SkillPermissions
|
||||
from .skill_registry import SkillRegistry
|
||||
from .agent_service import AgentService, AgentProgressReporter, SkillResult
|
||||
from .post_processor import PostProcessor
|
||||
|
||||
__all__ = [
|
||||
"AgentProgressReporter",
|
||||
"AgentService",
|
||||
"PostProcessor",
|
||||
"SkillDefinition",
|
||||
"SkillPermissions",
|
||||
"SkillRegistry",
|
||||
"SkillResult",
|
||||
]
|
||||
489
py/services/agent/agent_service.py
Normal file
489
py/services/agent/agent_service.py
Normal file
@@ -0,0 +1,489 @@
|
||||
"""Pipeline orchestration service.
|
||||
|
||||
The :class:`AgentService` coordinates LLM-powered pipeline execution:
|
||||
|
||||
1. Look up the pipeline definition in :class:`SkillRegistry`
|
||||
2. Validate input against its ``input_schema``
|
||||
3. Prepare context via :mod:`~py.metadata_ops` (read metadata, list base models, fetch HF README)
|
||||
4. If ``llm_required``: call :class:`LLMService` with the rendered prompt
|
||||
5. Post-process via :class:`PostProcessor` (delegates I/O to :mod:`~py.metadata_ops`)
|
||||
6. Broadcast progress and completion via :class:`WebSocketManager`
|
||||
|
||||
Pipeline definitions (*skills*) describe *what* to do (prompt template).
|
||||
The AgentService handles *how* (LLM calls, context gathering, validation,
|
||||
progress).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
import os
|
||||
|
||||
from ...config import config
|
||||
from ..llm_service import LLMService
|
||||
from ..websocket_manager import ws_manager
|
||||
from .post_processor import PostProcessor
|
||||
from .skill_registry import SkillRegistry
|
||||
from .skills.enrich_hf_metadata.readme_processor import (
|
||||
clean_readme_for_llm,
|
||||
extract_relevant_section,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentProgressReporter:
|
||||
"""Protocol-compatible progress reporter backed by WebSocket broadcast."""
|
||||
|
||||
async def on_progress(self, payload: Dict[str, Any]) -> None:
|
||||
await ws_manager.broadcast(payload)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillResult:
|
||||
"""Outcome of a skill execution."""
|
||||
|
||||
success: bool
|
||||
updated_models: List[Dict[str, Any]] = field(default_factory=list)
|
||||
errors: List[str] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
def _validate_schema(data: Any, schema: Dict[str, Any], path: str = "") -> List[str]:
|
||||
"""Minimal JSON schema validator.
|
||||
|
||||
Supports a subset of JSON Schema: ``type``, ``properties``, ``required``,
|
||||
``items``, ``enum``. Returns a list of error messages (empty = valid).
|
||||
"""
|
||||
|
||||
errors: List[str] = []
|
||||
if not schema:
|
||||
return errors
|
||||
|
||||
expected_type = schema.get("type")
|
||||
if expected_type:
|
||||
type_map = {
|
||||
"string": str,
|
||||
"number": (int, float),
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"array": list,
|
||||
"object": dict,
|
||||
"null": type(None),
|
||||
}
|
||||
expected_py = type_map.get(expected_type)
|
||||
if expected_py is not None and not isinstance(data, expected_py):
|
||||
errors.append(f"{path or 'root'}: expected {expected_type}, got {type(data).__name__}")
|
||||
return errors
|
||||
|
||||
if expected_type == "object" and isinstance(data, dict):
|
||||
properties = schema.get("properties", {})
|
||||
required = schema.get("required", [])
|
||||
for req_key in required:
|
||||
if req_key not in data:
|
||||
errors.append(f"{path or 'root'}: missing required property '{req_key}'")
|
||||
for key, value in data.items():
|
||||
if key in properties:
|
||||
errors.extend(_validate_schema(value, properties[key], f"{path}.{key}"))
|
||||
|
||||
if expected_type == "array" and isinstance(data, list):
|
||||
items_schema = schema.get("items")
|
||||
if items_schema:
|
||||
for i, item in enumerate(data):
|
||||
errors.extend(_validate_schema(item, items_schema, f"{path}[{i}]"))
|
||||
|
||||
if "enum" in schema and data not in schema["enum"]:
|
||||
errors.append(f"{path or 'root'}: value '{data}' not in enum {schema['enum']}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Prompt template rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_prompt(template: str, variables: Dict[str, Any]) -> str:
|
||||
"""Render a prompt template with ``{{variable}}`` placeholders.
|
||||
|
||||
Uses simple regex substitution — no Jinja2 dependency needed.
|
||||
"""
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
key = match.group(1).strip()
|
||||
value = variables.get(key, "")
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False, indent=2)
|
||||
return str(value)
|
||||
|
||||
return re.sub(r"\{\{(\w+)\}\}", replace, template)
|
||||
|
||||
|
||||
class AgentService:
|
||||
"""Orchestrate agent skill execution.
|
||||
|
||||
Usage::
|
||||
|
||||
service = await AgentService.get_instance()
|
||||
result = await service.execute_skill(
|
||||
skill_name="enrich_hf_metadata",
|
||||
input_data={"model_paths": ["/path/to/model.safetensors"]},
|
||||
progress_callback=AgentProgressReporter(),
|
||||
)
|
||||
"""
|
||||
|
||||
_instance: Optional["AgentService"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
skill_registry: Optional[SkillRegistry] = None,
|
||||
llm_service: Optional[LLMService] = None,
|
||||
) -> None:
|
||||
self._registry = skill_registry
|
||||
self._llm_service = llm_service
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "AgentService":
|
||||
"""Return the lazily-initialised global ``AgentService``."""
|
||||
|
||||
if cls._instance is None:
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls(
|
||||
skill_registry=await SkillRegistry.get_instance(),
|
||||
llm_service=await LLMService.get_instance(),
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""Reset the cached singleton — primarily for tests."""
|
||||
|
||||
cls._instance = None
|
||||
|
||||
async def _ensure_registry(self) -> SkillRegistry:
|
||||
if self._registry is None:
|
||||
self._registry = await SkillRegistry.get_instance()
|
||||
return self._registry
|
||||
|
||||
async def _ensure_llm(self) -> LLMService:
|
||||
if self._llm_service is None:
|
||||
self._llm_service = await LLMService.get_instance()
|
||||
return self._llm_service
|
||||
|
||||
async def list_skills(self) -> List[Dict[str, Any]]:
|
||||
"""Return a JSON-serialisable list of available skills."""
|
||||
|
||||
registry = await self._ensure_registry()
|
||||
return [
|
||||
{
|
||||
"name": s.name,
|
||||
"title": s.title,
|
||||
"description": s.description,
|
||||
"llm_required": s.llm_required,
|
||||
"model_type_filter": s.model_type_filter,
|
||||
}
|
||||
for s in registry.list_skills()
|
||||
]
|
||||
|
||||
async def execute_skill(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
input_data: Dict[str, Any],
|
||||
progress_callback: Optional[AgentProgressReporter] = None,
|
||||
) -> SkillResult:
|
||||
"""Execute a pipeline (skill) on the given models.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the pipeline to execute
|
||||
input_data: Input validated against the pipeline's ``input_schema``
|
||||
progress_callback: Optional WebSocket progress reporter
|
||||
|
||||
Returns:
|
||||
:class:`SkillResult` with success status and updated model info
|
||||
"""
|
||||
|
||||
registry = await self._ensure_registry()
|
||||
skill = registry.get_skill(skill_name)
|
||||
if skill is None:
|
||||
return SkillResult(
|
||||
success=False,
|
||||
errors=[f"Skill not found: {skill_name}"],
|
||||
summary=f"Skill '{skill_name}' does not exist",
|
||||
)
|
||||
|
||||
input_errors = _validate_schema(input_data, skill.input_schema)
|
||||
if input_errors:
|
||||
return SkillResult(
|
||||
success=False,
|
||||
errors=input_errors,
|
||||
summary=f"Invalid input: {'; '.join(input_errors)}",
|
||||
)
|
||||
|
||||
model_paths = input_data.get("model_paths", [])
|
||||
if not model_paths:
|
||||
return SkillResult(
|
||||
success=False,
|
||||
errors=["No model_paths provided"],
|
||||
summary="No models to process",
|
||||
)
|
||||
|
||||
total = len(model_paths)
|
||||
processed = 0
|
||||
success_count = 0
|
||||
skipped_count = 0
|
||||
updated_models: List[Dict[str, Any]] = []
|
||||
errors: List[str] = []
|
||||
post_processor = PostProcessor()
|
||||
|
||||
await self._emit_progress(
|
||||
progress_callback, skill_name, status="started",
|
||||
total=total, processed=0, success=0,
|
||||
)
|
||||
|
||||
llm = await self._ensure_llm()
|
||||
llm_configured = llm.is_configured() if skill.llm_required else True
|
||||
|
||||
for model_path in model_paths:
|
||||
model_filename = os.path.basename(model_path)
|
||||
logger.info(
|
||||
"[%s] [%d/%d] %s",
|
||||
skill_name, processed + 1, total, model_filename,
|
||||
)
|
||||
updated_data: Dict[str, Any] = {}
|
||||
skip_model = False
|
||||
try:
|
||||
from ...metadata_ops import read_metadata
|
||||
metadata = await read_metadata(model_path)
|
||||
|
||||
# Fast-fail: enrich_hf_metadata requires hf_url to have HF README context
|
||||
if skill_name == "enrich_hf_metadata" and not metadata.get("hf_url", ""):
|
||||
logger.info(
|
||||
"[%s] SKIP %s — no hf_url in metadata",
|
||||
skill_name, model_filename,
|
||||
)
|
||||
skipped_count += 1
|
||||
skip_model = True
|
||||
|
||||
if not skip_model:
|
||||
prompt_vars: Dict[str, Any] = {"model_path": model_path}
|
||||
if skill.llm_required and llm_configured:
|
||||
prompt_vars = await self._build_prompt_context(
|
||||
skill_name, model_path, metadata, registry, llm,
|
||||
)
|
||||
|
||||
llm_response: Optional[Dict[str, Any]] = None
|
||||
if skill.llm_required and llm_configured:
|
||||
prompt_template = registry.load_prompt(skill_name)
|
||||
rendered = _render_prompt(prompt_template, prompt_vars)
|
||||
llm_response = await llm.chat_completion_json(
|
||||
system_prompt=prompt_vars.get(
|
||||
"system_prompt",
|
||||
"You are a helpful assistant that extracts structured metadata.",
|
||||
),
|
||||
user_prompt=rendered,
|
||||
)
|
||||
if llm_response:
|
||||
logger.info(
|
||||
"[%s] [%d/%d] %s → base_model=%s confidence=%s",
|
||||
skill_name, processed + 1, total, model_filename,
|
||||
(llm_response.get("base_model") or "?")[:50],
|
||||
llm_response.get("confidence", "?"),
|
||||
)
|
||||
|
||||
model_result = await post_processor.process(
|
||||
skill_name=skill_name,
|
||||
model_path=model_path,
|
||||
llm_output=llm_response or {},
|
||||
metadata=metadata,
|
||||
readme_content=prompt_vars.get("readme_content_full", ""),
|
||||
)
|
||||
|
||||
if model_result.get("success", True):
|
||||
success_count += 1
|
||||
uf = model_result.get("updated_fields", [])
|
||||
if uf:
|
||||
updated_models.append({"path": model_path, "updated_fields": uf})
|
||||
updated_data = model_result.get("updates", {})
|
||||
if "preview_url" in updated_data and updated_data["preview_url"]:
|
||||
updated_data["preview_url"] = config.get_preview_static_url(
|
||||
updated_data["preview_url"]
|
||||
)
|
||||
else:
|
||||
errors.extend(
|
||||
model_result.get("errors", [model_result.get("error", "Unknown error")])
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Skill %s failed for %s: %s", skill_name, model_path, exc)
|
||||
errors.append(f"{model_path}: {exc}")
|
||||
|
||||
processed += 1
|
||||
await self._emit_progress(
|
||||
progress_callback, skill_name, status="processing",
|
||||
total=total, processed=processed, success=success_count,
|
||||
skipped=skipped_count,
|
||||
current_path=model_path,
|
||||
updated_data=updated_data,
|
||||
)
|
||||
|
||||
result = SkillResult(
|
||||
success=success_count > 0,
|
||||
updated_models=updated_models,
|
||||
errors=errors,
|
||||
summary=f"Processed {processed}/{total} models, {success_count} succeeded, {skipped_count} skipped",
|
||||
)
|
||||
|
||||
await self._emit_progress(
|
||||
progress_callback, skill_name, status="completed",
|
||||
total=total, processed=processed, success=success_count,
|
||||
skipped=skipped_count,
|
||||
updated_models=updated_models, errors=errors, summary=result.summary,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Base model grouping (keeps the prompt compact)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_base_models(models: List[str]) -> str:
|
||||
"""Format the base model list as a flat, one-per-line list.
|
||||
|
||||
Attempts to group by family consistently degraded LLM extraction
|
||||
accuracy — the LLM finds individual model names harder to spot
|
||||
in comma-separated groups than in a simple ``- Name`` list.
|
||||
"""
|
||||
return "\n".join(f"- {m}" for m in models)
|
||||
|
||||
async def _build_prompt_context(
|
||||
self,
|
||||
skill_name: str,
|
||||
model_path: str,
|
||||
metadata: Dict[str, Any],
|
||||
registry: SkillRegistry,
|
||||
llm: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gather variables for the skill's prompt template.
|
||||
|
||||
Reads metadata, fetches the HF README (if applicable), lists available
|
||||
base models, loads user priority tags, and returns a dict that maps to
|
||||
``{{variable}}`` placeholders in ``prompt.md``.
|
||||
"""
|
||||
from ...metadata_ops import identify_model_type, list_base_models
|
||||
from ..settings_manager import SettingsManager
|
||||
|
||||
context: Dict[str, Any] = {
|
||||
"model_path": model_path,
|
||||
"model_basename": "",
|
||||
"hf_url": "",
|
||||
"repo": "",
|
||||
"readme_content": "",
|
||||
"readme_content_full": "",
|
||||
"current_metadata": {},
|
||||
"base_models": [],
|
||||
"priority_tags": "",
|
||||
}
|
||||
|
||||
# Extract model basename (filename without extension) for the LLM
|
||||
# to use when locating the matching section in collection repos.
|
||||
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
context["model_basename"] = raw_basename or ""
|
||||
|
||||
context["current_metadata"] = {
|
||||
"file_name": metadata.get("file_name", ""),
|
||||
"base_model": metadata.get("base_model", ""),
|
||||
"tags": metadata.get("tags", []),
|
||||
"modelDescription": metadata.get("modelDescription", ""),
|
||||
"trainedWords": metadata.get("trainedWords", []),
|
||||
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
||||
"size": metadata.get("size", 0),
|
||||
}
|
||||
|
||||
hf_url = metadata.get("hf_url", "")
|
||||
context["hf_url"] = hf_url
|
||||
repo = self._extract_repo_from_url(hf_url) if hf_url else ""
|
||||
context["repo"] = repo or ""
|
||||
if repo:
|
||||
readme = await self._fetch_readme(repo)
|
||||
# Trim README to the section relevant to this model file
|
||||
# (collection repos often have multiple models in one README).
|
||||
if readme and raw_basename:
|
||||
trimmed = extract_relevant_section(readme, raw_basename)
|
||||
cleaned = clean_readme_for_llm(trimmed) if trimmed else ""
|
||||
else:
|
||||
cleaned = clean_readme_for_llm(readme) if readme else ""
|
||||
context["readme_content"] = cleaned if cleaned else "(README not available)"
|
||||
context["readme_content_full"] = readme or ""
|
||||
|
||||
try:
|
||||
raw_models = await list_base_models()
|
||||
context["base_models"] = self._format_base_models(raw_models)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to list base models: %s", exc)
|
||||
context["base_models"] = "</not available>"
|
||||
|
||||
# Determine model type and load the corresponding priority_tags
|
||||
try:
|
||||
model_type = await identify_model_type(model_path)
|
||||
context["model_type"] = model_type
|
||||
settings = SettingsManager()
|
||||
priority_config = settings.get_priority_tag_config()
|
||||
context["priority_tags"] = priority_config.get(model_type, "")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to load priority tags: %s", exc)
|
||||
context["model_type"] = "lora"
|
||||
context["priority_tags"] = ""
|
||||
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _extract_repo_from_url(hf_url: str) -> Optional[str]:
|
||||
"""Extract ``user/repo`` from a HuggingFace URL."""
|
||||
if not hf_url:
|
||||
return None
|
||||
m = re.match(r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_readme(repo: str) -> str:
|
||||
"""Fetch README.md from HuggingFace (tries ``main``, then ``master``)."""
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as session:
|
||||
for branch in ("main", "master"):
|
||||
url = f"https://huggingface.co/{repo}/raw/{branch}/README.md"
|
||||
try:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.text()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to fetch README from %s: %s", url, exc)
|
||||
return ""
|
||||
|
||||
async def _emit_progress(
|
||||
self,
|
||||
callback: Optional[AgentProgressReporter],
|
||||
skill_name: str,
|
||||
*,
|
||||
status: str,
|
||||
**extra: Any,
|
||||
) -> None:
|
||||
"""Send a progress update via WebSocket (if callback is set)."""
|
||||
payload: Dict[str, Any] = {"type": "agent_progress", "skill": skill_name, "status": status}
|
||||
payload.update(extra)
|
||||
if callback is not None:
|
||||
await callback.on_progress(payload)
|
||||
336
py/services/agent/post_processor.py
Normal file
336
py/services/agent/post_processor.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Post-processing engine for skill pipeline outputs.
|
||||
|
||||
The :class:`PostProcessor` takes the LLM's structured JSON output and applies
|
||||
it to a model's on-disk metadata via the :mod:`~py.metadata_ops` functions.
|
||||
|
||||
It handles all the skill-specific business logic — conditions, transformations,
|
||||
and orchestration of multiple side-effects (write metadata, download preview,
|
||||
refresh cache). All actual I/O is delegated to :mod:`~py.metadata_ops`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostProcessor:
|
||||
"""Deterministic post-processor for skill pipeline outputs.
|
||||
|
||||
Usage (called by :class:`~py.services.agent.agent_service.AgentService`)::
|
||||
|
||||
processor = PostProcessor()
|
||||
result = await processor.process(
|
||||
skill_name="enrich_hf_metadata",
|
||||
model_path="/path/to/model.safetensors",
|
||||
llm_output={...},
|
||||
metadata={...}, # from metadata_ops.read_metadata()
|
||||
)
|
||||
"""
|
||||
|
||||
async def process(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
model_path: str,
|
||||
llm_output: Dict[str, Any],
|
||||
metadata: Dict[str, Any],
|
||||
readme_content: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Route *llm_output* to the correct skill post-processor.
|
||||
|
||||
*readme_content* is optional raw markdown content (e.g. HF README)
|
||||
that is converted to HTML and stored as ``modelDescription`` for
|
||||
the description tab.
|
||||
|
||||
Returns a dict with keys ``success`` (bool), ``updated_fields`` (list),
|
||||
``preview_downloaded`` (bool), and ``errors`` (list).
|
||||
"""
|
||||
if skill_name == "enrich_hf_metadata":
|
||||
return await self._process_enrich_hf_metadata(
|
||||
model_path, llm_output, metadata, readme_content,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"updated_fields": [],
|
||||
"errors": [f"No post-processor registered for skill: {skill_name}"],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# enrich_hf_metadata
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _process_enrich_hf_metadata(
|
||||
self,
|
||||
model_path: str,
|
||||
llm_output: Dict[str, Any],
|
||||
metadata: Dict[str, Any],
|
||||
readme_content: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
from ...metadata_ops import (
|
||||
apply_metadata_updates,
|
||||
download_preview,
|
||||
refresh_cache,
|
||||
)
|
||||
from .skills.enrich_hf_metadata.readme_processor import (
|
||||
convert_readme_to_html,
|
||||
extract_gallery_images,
|
||||
extract_gallery_table_images,
|
||||
extract_relevant_section,
|
||||
extract_simple_markdown_images,
|
||||
extract_html_img_tags,
|
||||
extract_repo_from_hf_url,
|
||||
)
|
||||
|
||||
updated_fields: List[str] = []
|
||||
preview_downloaded = False
|
||||
|
||||
# -- Determine whether this is an HF-sourced model -----------------
|
||||
is_hf_model = not metadata.get("from_civitai", True)
|
||||
|
||||
# -- Collect updates -----------------------------------------------
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
# base_model
|
||||
new_base = (llm_output.get("base_model") or "").strip()
|
||||
current_base = metadata.get("base_model", "") or ""
|
||||
if new_base and self._should_overwrite(current_base, is_hf_model):
|
||||
updates["base_model"] = new_base
|
||||
|
||||
# trigger words → civitai.trainedWords
|
||||
new_triggers = llm_output.get("trigger_words", [])
|
||||
trigger_words_empty = True
|
||||
if isinstance(new_triggers, list):
|
||||
cleaned = [t.strip() for t in new_triggers if t.strip()]
|
||||
cleaned = [t for t in cleaned if t.lower() not in ("none", "null", "n/a")]
|
||||
trigger_words_empty = not cleaned
|
||||
current_civitai = metadata.get("civitai") or {}
|
||||
current_triggers = current_civitai.get("trainedWords") or []
|
||||
if self._should_overwrite_list(current_triggers, is_hf_model):
|
||||
trig_civitai = dict(current_civitai)
|
||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||
trig_civitai.update(updates["civitai"])
|
||||
trig_civitai["trainedWords"] = cleaned
|
||||
updates["civitai"] = trig_civitai
|
||||
|
||||
# modelDescription — from raw README content (converted to HTML)
|
||||
if readme_content and is_hf_model:
|
||||
converted = convert_readme_to_html(readme_content)
|
||||
if converted:
|
||||
updates["modelDescription"] = converted
|
||||
|
||||
# short_description → civitai.description (for "About this version")
|
||||
short_desc = (llm_output.get("short_description") or "").strip()
|
||||
if short_desc and is_hf_model:
|
||||
current_civitai = metadata.get("civitai") or {}
|
||||
desc_civitai = dict(current_civitai)
|
||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||
desc_civitai.update(updates["civitai"])
|
||||
desc_civitai["description"] = short_desc
|
||||
updates["civitai"] = desc_civitai
|
||||
|
||||
# gallery images → civitai.images (from YAML frontmatter widget entries
|
||||
# and Sample Gallery markdown tables in the README body)
|
||||
gallery_images: List[Dict[str, Any]] = []
|
||||
if readme_content and is_hf_model:
|
||||
hf_url = metadata.get("hf_url", "") or ""
|
||||
repo = extract_repo_from_hf_url(hf_url)
|
||||
if repo:
|
||||
rec_w = llm_output.get("recommended_width") or 0
|
||||
rec_h = llm_output.get("recommended_height") or 0
|
||||
|
||||
# 1. Widget images (YAML frontmatter)
|
||||
gallery = extract_gallery_images(
|
||||
readme_content, repo,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
)
|
||||
|
||||
# 2. Sample Gallery table images (markdown body), deduplicated
|
||||
existing_urls = {img["url"] for img in gallery if img.get("url")}
|
||||
table_images = extract_gallery_table_images(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
)
|
||||
existing_urls.update(img["url"] for img in table_images if img.get("url"))
|
||||
|
||||
# 3. Simple markdown images `` in the body
|
||||
simple_images = extract_simple_markdown_images(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
)
|
||||
existing_urls.update(img["url"] for img in simple_images if img.get("url"))
|
||||
|
||||
# 4. HTML `<img>` tags (used by many collection repos)
|
||||
html_images = extract_html_img_tags(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
)
|
||||
|
||||
all_images = gallery + table_images + simple_images + html_images
|
||||
if all_images:
|
||||
gallery_images = all_images
|
||||
current_civitai = metadata.get("civitai") or {}
|
||||
gallery_civitai = dict(current_civitai)
|
||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||
gallery_civitai.update(updates["civitai"])
|
||||
gallery_civitai["images"] = all_images
|
||||
updates["civitai"] = gallery_civitai
|
||||
|
||||
# tags
|
||||
new_tags = llm_output.get("tags", [])
|
||||
if isinstance(new_tags, list) and new_tags:
|
||||
existing_tags = metadata.get("tags") or []
|
||||
merged = self._merge_tags(existing_tags, new_tags)
|
||||
if len(merged) > len(existing_tags) or is_hf_model:
|
||||
updates["tags"] = merged
|
||||
|
||||
# metadata_source & llm_enriched_at (always set)
|
||||
updates["metadata_source"] = "agent:enrich_hf_metadata"
|
||||
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Store LLM confidence in metadata so it's accessible for evaluation
|
||||
raw_confidence = (llm_output.get("confidence") or "").strip()
|
||||
if raw_confidence:
|
||||
updates["_llm_confidence"] = raw_confidence
|
||||
|
||||
# Fallback: extract instance_prompt from YAML frontmatter when the LLM
|
||||
# returned empty trigger words but the README has instance_prompt.
|
||||
if trigger_words_empty:
|
||||
instance_prompt = _extract_yaml_instance_prompt(readme_content)
|
||||
if instance_prompt:
|
||||
current_civitai = metadata.get("civitai") or {}
|
||||
trig_civitai = dict(current_civitai)
|
||||
if "civitai" in updates and isinstance(updates["civitai"], dict):
|
||||
trig_civitai.update(updates["civitai"])
|
||||
trig_civitai["trainedWords"] = [instance_prompt]
|
||||
updates["civitai"] = trig_civitai
|
||||
|
||||
preview_remote_url = (llm_output.get("preview_url") or "").strip()
|
||||
# Fallback: if the LLM couldn't find a preview image in the cleaned
|
||||
# README, find the first gallery image from the *model-specific
|
||||
# section* of the README (not the repo-wide first image, which
|
||||
# belongs to a different model in collection repos).
|
||||
if not preview_remote_url and readme_content and is_hf_model:
|
||||
model_basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
relevant_section = extract_relevant_section(
|
||||
readme_content, model_basename,
|
||||
)
|
||||
if relevant_section and relevant_section != readme_content:
|
||||
for img in gallery_images:
|
||||
img_url = img.get("url", "")
|
||||
if img_url and img_url in relevant_section:
|
||||
preview_remote_url = img_url
|
||||
break
|
||||
# Last resort: use the first gallery image from the full README.
|
||||
if not preview_remote_url and gallery_images:
|
||||
preview_remote_url = gallery_images[0].get("url", "")
|
||||
current_preview = metadata.get("preview_url") or ""
|
||||
if preview_remote_url and not (current_preview and os.path.exists(current_preview)):
|
||||
local_path = await download_preview(model_path, preview_remote_url)
|
||||
if local_path:
|
||||
preview_downloaded = True
|
||||
updates["preview_url"] = local_path
|
||||
|
||||
# notes — plain-text summary of usage info from the LLM
|
||||
new_notes = (llm_output.get("notes") or "").strip()
|
||||
if new_notes:
|
||||
updates["notes"] = new_notes
|
||||
|
||||
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4})
|
||||
raw_tips = (llm_output.get("usage_tips") or "").strip()
|
||||
if raw_tips and raw_tips != "{}":
|
||||
try:
|
||||
json.loads(raw_tips)
|
||||
updates["usage_tips"] = raw_tips
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning(
|
||||
"LLM returned invalid usage_tips JSON: %s", raw_tips[:200]
|
||||
)
|
||||
|
||||
if updates:
|
||||
updated_fields = await apply_metadata_updates(model_path, updates)
|
||||
|
||||
# -- Refresh scanner cache ------------------------------------------
|
||||
if updated_fields or preview_downloaded:
|
||||
await refresh_cache(model_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"updated_fields": updated_fields,
|
||||
"preview_downloaded": preview_downloaded,
|
||||
"updates": updates,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _should_overwrite(current_value: str, is_hf_model: bool) -> bool:
|
||||
"""Return ``True`` when a scalar field should be overwritten."""
|
||||
return is_hf_model or not current_value or current_value.lower() in (
|
||||
"", "unknown",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _should_overwrite_list(current_list: List[str], is_hf_model: bool) -> bool:
|
||||
"""Return ``True`` when a list field should be overwritten."""
|
||||
return is_hf_model or not current_list
|
||||
|
||||
@staticmethod
|
||||
def _merge_tags(existing: List[str], new: List[str]) -> List[str]:
|
||||
"""Merge *new* tags into *existing*, all lowercased.
|
||||
|
||||
This matches the behaviour of :class:`TagUpdateService` which
|
||||
normalises every tag to lowercase for case-insensitive dedup.
|
||||
"""
|
||||
merged: List[str] = []
|
||||
seen: set = set()
|
||||
for tag in list(existing) + list(new):
|
||||
t = tag.strip().lower()
|
||||
if t and t not in seen:
|
||||
merged.append(t)
|
||||
seen.add(t)
|
||||
return merged
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_yaml_instance_prompt(readme_content: str) -> str:
|
||||
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
|
||||
|
||||
Returns the prompt text, or empty string if not found. Handles
|
||||
``null`` / ``~`` YAML null values by returning empty string.
|
||||
"""
|
||||
if not readme_content or not readme_content.startswith("---"):
|
||||
return ""
|
||||
|
||||
# Find end of frontmatter
|
||||
end = readme_content.find("---", 3)
|
||||
if end == -1:
|
||||
return ""
|
||||
frontmatter = readme_content[3:end]
|
||||
|
||||
for line in frontmatter.split("\n"):
|
||||
line = line.strip()
|
||||
m = re.match(r"^instance_prompt:\s*(.*)", line)
|
||||
if m:
|
||||
val = m.group(1).strip().strip('"').strip("'")
|
||||
if val.lower() in ("null", "~", "none", ""):
|
||||
return ""
|
||||
return val
|
||||
|
||||
return ""
|
||||
45
py/services/agent/skill_definition.py
Normal file
45
py/services/agent/skill_definition.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Skill definition data structures.
|
||||
|
||||
Each skill is described by a :class:`SkillDefinition` that declares its
|
||||
input/output schemas, whether it needs an LLM call, and what permissions
|
||||
its post-processor has.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillPermissions:
|
||||
"""Declarative permission scope for a skill's post-processor.
|
||||
|
||||
These are auditable constraints — the :class:`AgentService` checks them
|
||||
before invoking the handler. They are defense-in-depth, not a sandbox.
|
||||
"""
|
||||
|
||||
write_metadata: bool = True
|
||||
write_previews: bool = True
|
||||
network_domains: Tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDefinition:
|
||||
"""Immutable description of an agent skill."""
|
||||
|
||||
name: str
|
||||
title: str
|
||||
description: str
|
||||
llm_required: bool
|
||||
input_schema: Dict[str, Any] = field(default_factory=dict)
|
||||
output_schema: Dict[str, Any] = field(default_factory=dict)
|
||||
model_type_filter: Optional[List[str]] = None
|
||||
permissions: SkillPermissions = field(default_factory=SkillPermissions)
|
||||
|
||||
def applies_to_model_type(self, model_type: str) -> bool:
|
||||
"""Return ``True`` if this skill can run on the given model type."""
|
||||
|
||||
if self.model_type_filter is None:
|
||||
return True
|
||||
return model_type in self.model_type_filter
|
||||
210
py/services/agent/skill_registry.py
Normal file
210
py/services/agent/skill_registry.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Discovery and loading of prompt-based skills.
|
||||
|
||||
Skills live in ``py/services/agent/skills/<name>/`` directories. Each
|
||||
directory must contain a ``prompt.md`` file with YAML frontmatter::
|
||||
|
||||
---
|
||||
name: my_skill
|
||||
title: "My Skill"
|
||||
description: "What this skill does"
|
||||
llm_required: true
|
||||
---
|
||||
|
||||
Prompt template with ``{{variable}}`` placeholders.
|
||||
|
||||
Legacy ``SKILL.md`` files are also supported for backward compatibility.
|
||||
|
||||
The registry scans the skills directory on first access and caches results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from .skill_definition import SkillDefinition, SkillPermissions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Directory where built-in skills are stored
|
||||
_SKILLS_DIR = Path(__file__).parent / "skills"
|
||||
|
||||
#: Preferred file names for prompt definition files (tried in order).
|
||||
#: ``prompt.md`` is the current convention; ``SKILL.md`` is the legacy name
|
||||
#: kept for backward compatibility.
|
||||
_PROMPT_FILE_NAMES: tuple[str, ...] = ("prompt.md", "SKILL.md")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontmatter parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FRONTMATTER_RE = re.compile(
|
||||
r"^---\s*\n(.*?\n)---\s*\n?(.*)", re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _parse_skill_file(path: Path) -> tuple[dict, str]:
|
||||
"""Read a prompt definition file (``prompt.md`` or legacy ``SKILL.md``) and
|
||||
return (frontmatter_dict, body_text).
|
||||
|
||||
Raises ``ValueError`` if the file lacks valid YAML frontmatter.
|
||||
"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
raise ValueError(f"Missing or invalid YAML frontmatter in {path}")
|
||||
frontmatter = yaml.safe_load(m.group(1))
|
||||
if not isinstance(frontmatter, dict):
|
||||
raise ValueError(f"Frontmatter in {path} is not a mapping")
|
||||
body = m.group(2).strip()
|
||||
return frontmatter, body
|
||||
|
||||
|
||||
class SkillRegistry:
|
||||
"""Discover and load agent skills from the filesystem."""
|
||||
|
||||
_instance: Optional["SkillRegistry"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, skills_dir: Path = _SKILLS_DIR) -> None:
|
||||
self._skills_dir = skills_dir
|
||||
self._skills: Dict[str, SkillDefinition] = {}
|
||||
self._loaded: bool = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Singleton access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "SkillRegistry":
|
||||
"""Return the lazily-initialised global ``SkillRegistry``."""
|
||||
|
||||
if cls._instance is None:
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
registry = cls()
|
||||
registry._discover()
|
||||
cls._instance = registry
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""Reset the cached singleton — primarily for tests."""
|
||||
|
||||
cls._instance = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _find_prompt_file(skill_dir: Path) -> Path | None:
|
||||
"""Return the first prompt definition file that exists in *skill_dir*.
|
||||
|
||||
Tries ``_PROMPT_FILE_NAMES`` in order so that new conventions
|
||||
(``prompt.md``) take precedence while legacy ``SKILL.md`` files
|
||||
still load without changes.
|
||||
"""
|
||||
for name in _PROMPT_FILE_NAMES:
|
||||
candidate = skill_dir / name
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def _discover(self) -> None:
|
||||
"""Scan the skills directory and load all valid skill definitions."""
|
||||
|
||||
self._skills.clear()
|
||||
if not self._skills_dir.is_dir():
|
||||
logger.warning("Skills directory does not exist: %s", self._skills_dir)
|
||||
self._loaded = True
|
||||
return
|
||||
|
||||
for entry in sorted(self._skills_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
prompt_file = self._find_prompt_file(entry)
|
||||
if prompt_file is None:
|
||||
continue
|
||||
try:
|
||||
definition = self._load_skill_definition(prompt_file)
|
||||
if definition is not None:
|
||||
self._skills[definition.name] = definition
|
||||
logger.debug("Loaded skill: %s", definition.name)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load skill from %s: %s", prompt_file, exc)
|
||||
|
||||
self._loaded = True
|
||||
logger.info("Discovered %d prompt-based skills", len(self._skills))
|
||||
|
||||
def _load_skill_definition(self, path: Path) -> Optional[SkillDefinition]:
|
||||
"""Parse a prompt definition file's frontmatter into a
|
||||
:class:`SkillDefinition`."""
|
||||
|
||||
try:
|
||||
data, _body = _parse_skill_file(path)
|
||||
except (ValueError, yaml.YAMLError) as exc:
|
||||
logger.warning("Failed to parse prompt file %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
if "name" not in data:
|
||||
logger.warning("Prompt file %s missing required 'name' field", path)
|
||||
return None
|
||||
|
||||
perm_data = data.get("permissions", {})
|
||||
permissions = SkillPermissions(
|
||||
write_metadata=perm_data.get("write_metadata", True),
|
||||
write_previews=perm_data.get("write_previews", True),
|
||||
network_domains=tuple(perm_data.get("network_domains", [])),
|
||||
)
|
||||
|
||||
return SkillDefinition(
|
||||
name=data["name"],
|
||||
title=data.get("title", data["name"]),
|
||||
description=data.get("description", ""),
|
||||
llm_required=data.get("llm_required", False),
|
||||
input_schema=data.get("input_schema", {}),
|
||||
output_schema=data.get("output_schema", {}),
|
||||
model_type_filter=data.get("model_type_filter"),
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_skills(self) -> List[SkillDefinition]:
|
||||
"""Return all discovered skill definitions."""
|
||||
|
||||
if not self._loaded:
|
||||
self._discover()
|
||||
return list(self._skills.values())
|
||||
|
||||
def get_skill(self, name: str) -> Optional[SkillDefinition]:
|
||||
"""Return the skill definition for ``name``, or ``None`` if not found."""
|
||||
|
||||
if not self._loaded:
|
||||
self._discover()
|
||||
return self._skills.get(name)
|
||||
|
||||
def load_prompt(self, name: str) -> str:
|
||||
"""Load and return the prompt template body for the named skill."""
|
||||
|
||||
skill_dir = self._skills_dir / name
|
||||
skill_path = self._find_prompt_file(skill_dir)
|
||||
if skill_path is None:
|
||||
raise FileNotFoundError(
|
||||
f"Prompt file not found for skill '{name}' in {skill_dir} "
|
||||
f"(tried {list(_PROMPT_FILE_NAMES)})"
|
||||
)
|
||||
try:
|
||||
_frontmatter, body = _parse_skill_file(skill_path)
|
||||
return body
|
||||
except (ValueError, yaml.YAMLError) as exc:
|
||||
raise ValueError(f"Failed to parse prompt from {skill_path}: {exc}") from exc
|
||||
165
py/services/agent/skills/enrich_hf_metadata/prompt.md
Normal file
165
py/services/agent/skills/enrich_hf_metadata/prompt.md
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: enrich_hf_metadata
|
||||
title: "Enrich Metadata from HuggingFace"
|
||||
description: >
|
||||
Parse the HuggingFace model card via LLM to extract description, trigger
|
||||
words, base model, tags, and preview image URL.
|
||||
llm_required: true
|
||||
---
|
||||
|
||||
You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a HuggingFace model card (README.md).
|
||||
|
||||
## Model Information
|
||||
|
||||
- **Repository**: {{hf_url}}
|
||||
- **Model file path**: {{model_path}}
|
||||
- **Model filename**: {{model_basename}}
|
||||
- **Repository ID**: {{repo}}
|
||||
|
||||
## Current Metadata (may be incomplete)
|
||||
|
||||
```json
|
||||
{{current_metadata}}
|
||||
```
|
||||
|
||||
## User Priority Tags Reference
|
||||
|
||||
The user has configured the following list of **meaningful tag categories** for this model type (`{{model_type}}`):
|
||||
|
||||
```
|
||||
{{priority_tags}}
|
||||
```
|
||||
|
||||
These are the subjects, styles, and concepts the user considers useful for categorization. Use this list as a **reference** when evaluating tags (see the **tags** section below).
|
||||
|
||||
## Available Base Models
|
||||
|
||||
The following base models are currently valid in this system. Use the EXACT
|
||||
name listed — do not invent aliases or modify variant suffixes.
|
||||
|
||||
{{base_models}}
|
||||
|
||||
## HuggingFace README Content
|
||||
|
||||
```
|
||||
{{readme_content}}
|
||||
```
|
||||
|
||||
## Extraction Instructions
|
||||
|
||||
Extract the following information from the README content above:
|
||||
|
||||
### base_model
|
||||
The base model this model was trained on. Use EXACTLY one of the names from the **Available Base Models** list above. Do not invent new names or use aliases.
|
||||
|
||||
Check the YAML frontmatter for ``base_model:`` first. If the frontmatter has no ``base_model:``, look at the **model filename** (``{{model_basename}}``), YAML ``tags:``, README title and first paragraph for clues — the base model family is often embedded in the name
|
||||
|
||||
### trigger_words
|
||||
The trigger words or activation prompts needed to use this LoRA. Look for:
|
||||
- `instance_prompt:` in the YAML frontmatter
|
||||
- Phrases like "trigger word:", "trigger:", "use this prompt:", "activation prompt:"
|
||||
- In collection repos: the trigger section **specific to this model file** (look near matching download links or anchor IDs)
|
||||
- Example prompts at the start (usually the first word or phrase before any description)
|
||||
Return as an array of strings. If none found, return an empty array `[]`. **Never** return `["None"]` or any placeholder value — a truly empty list means no trigger words exist.
|
||||
|
||||
### short_description
|
||||
A concise 1-2 sentence summary of what this model does. Extract from the "Model description" section or the first paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Return empty string if the README is too minimal.
|
||||
|
||||
### tags
|
||||
3-8 relevant tags for categorizing this model. **Quality over quantity.**
|
||||
|
||||
Sources to consider:
|
||||
- The YAML frontmatter `tags:` list (filter out technical ones — see below)
|
||||
- The subject, style, character, or concept the model represents
|
||||
- The model filename itself may give clues (e.g. "pokemon", "anime", "pixelart")
|
||||
|
||||
**Critical filtering rules — apply them strictly:**
|
||||
|
||||
1. **Exclude technical/generic tags.** Reject any tag that describes the model's **training methodology, framework, architecture, or modality** rather than its content. Examples to exclude: `text-to-image`, `diffusers`, `lora`, `dreambooth`, `diffusers-training`, `flux`, `sdxl`, `checkpoint`, `pytorch`, `safetensors`, `fine-tuning`, `stable-diffusion`, and any variant of these.
|
||||
|
||||
2. **Cross-reference against the priority_tags reference.** Only include a tag if it meaningfully describes what the model actually creates (subject, style, character type) and is semantically close to one of the priority_tags. If none of the README's tags match meaningful categories, prefer returning a smaller set or an empty array over including low-value tags.
|
||||
|
||||
3. **All lowercase, no spaces, no hyphens** (use single words like `"photorealistic"`, `"anime"`, `"character"`).
|
||||
|
||||
Return empty array if no meaningful content tags remain after filtering.
|
||||
|
||||
### recommended_width, recommended_height
|
||||
The recommended image generation resolution for this model, in pixels. Look for sections like "Best Dimensions", "Recommended size", "Suggested resolution", or similar phrasing in the README. Prefer the explicitly marked "Best" or default resolution. If the table/list has multiple entries (e.g. "768 x 1024 (Best)" and "1024 x 1024 (Default)"), use the one marked "Best". Return integers. If no resolution can be determined, return 0 for both.
|
||||
|
||||
### preview_url
|
||||
The URL of the most suitable preview image from the README. Look for:
|
||||
- Image tags near the section matching the model filename (`{{model_basename}}`)
|
||||
- The YAML frontmatter `widget:` section (which often has `output.url` fields)
|
||||
- In collection repos: the sample images listed **under the section** for this specific model version
|
||||
- Generic `` in the body
|
||||
Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL as `https://huggingface.co/{{repo}}/resolve/main/{filename}`. If no suitable image is found, return an empty string.
|
||||
|
||||
### notes
|
||||
A plain-text summary of the model card's key practical usage information. Combine trigger words, style modifiers, recommended parameters (steps, CFG, resolution, sampler), and any setup tips into a readable paragraph. For collection repos, focus on the **specific model version** matching `{{model_basename}}`. Return empty string if the README has no useful usage info.
|
||||
|
||||
### usage_tips
|
||||
A JSON string with structured usage recommendations. Extract from the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5"). Possible fields (include only those you can determine):
|
||||
|
||||
```json
|
||||
{
|
||||
"strength_min": 0.85,
|
||||
"strength_max": 1.4,
|
||||
"strength_range": "0.85-1.4",
|
||||
"strength": 0.6,
|
||||
"clip_strength": 0.5,
|
||||
"clip_skip": 2
|
||||
}
|
||||
```
|
||||
|
||||
Return the JSON string (e.g. `'{"strength_min":0.85,"strength_max":1.4}'`). Return `"{}"` if nothing useful is found.
|
||||
|
||||
### confidence
|
||||
Your confidence level in the extracted data:
|
||||
- "high" — most fields were explicitly stated in the README
|
||||
- "medium" — some fields were inferred from context
|
||||
- "low" — most fields are guesses based on limited information
|
||||
|
||||
## Important: Handling Collection Repos (multiple model files)
|
||||
|
||||
Many HuggingFace repos contain **multiple model files** in a single repository
|
||||
(e.g. a "LoRA collection" with different styles/characters in separate files).
|
||||
|
||||
The model file currently being enriched is: **`{{model_basename}}`**
|
||||
|
||||
To find the correct section in the README:
|
||||
|
||||
1. **Search for download links** containing the filename — the surrounding paragraph is your section.
|
||||
2. **Search for anchor IDs** (`<a id="...">`) or section headings whose text matches words from the filename.
|
||||
3. **Search for HTML headings** (`<h1>`, `<h2>`, `<span>`) containing parts of the filename.
|
||||
4. If no match is found, use the full README as usual — the model may be the only one in the repo.
|
||||
|
||||
When a matching section IS found, prefer metadata from that section.
|
||||
When no section matches (e.g. single-model repos or repos without per-file sections),
|
||||
extract metadata from the full README normally. Do not return empty data just
|
||||
because the filename doesn't appear in the README.
|
||||
|
||||
## Output Format
|
||||
|
||||
Return ONLY a JSON object with exactly these fields (no markdown fences, no extra text):
|
||||
|
||||
```json
|
||||
{
|
||||
"model_path": "{{model_path}}",
|
||||
"base_model": "<canonical name or empty string>",
|
||||
"trigger_words": ["<word1>", "<word2>"],
|
||||
"short_description": "<1-2 sentence summary>",
|
||||
"tags": ["<tag1>", "<tag2>"],
|
||||
"recommended_width": 768,
|
||||
"recommended_height": 1024,
|
||||
"preview_url": "<image URL or empty string>",
|
||||
"notes": "<plain-text usage summary or empty string>",
|
||||
"usage_tips": "<JSON string like '{\"strength_min\":0.85,\"strength_max\":1.4}' or '{}'>",
|
||||
"confidence": "<high|medium|low>"
|
||||
}
|
||||
```
|
||||
|
||||
Important:
|
||||
- Only include the JSON object, no other text
|
||||
- If a field cannot be determined, use an empty string or empty array
|
||||
- Do not fabricate information not supported by the README
|
||||
- Never use placeholder values like `"None"` or `"unknown"` for missing data — use empty string or empty array
|
||||
1179
py/services/agent/skills/enrich_hf_metadata/readme_processor.py
Normal file
1179
py/services/agent/skills/enrich_hf_metadata/readme_processor.py
Normal file
File diff suppressed because it is too large
Load Diff
679
py/services/aria2_downloader.py
Normal file
679
py/services/aria2_downloader.py
Normal file
@@ -0,0 +1,679 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
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 = (
|
||||
"https://civitai.com/api/download/",
|
||||
"https://civitai.red/api/download/",
|
||||
)
|
||||
|
||||
|
||||
class Aria2Error(RuntimeError):
|
||||
"""Raised when aria2 integration fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Aria2Transfer:
|
||||
"""Track an aria2 download registered by the Python coordinator."""
|
||||
|
||||
gid: str
|
||||
save_path: str
|
||||
|
||||
|
||||
class Aria2Downloader:
|
||||
"""Manage an aria2 RPC daemon for recommended model downloads."""
|
||||
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "Aria2Downloader":
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
self._process: Optional[asyncio.subprocess.Process] = None
|
||||
self._rpc_port: Optional[int] = None
|
||||
self._rpc_secret = ""
|
||||
self._rpc_url = ""
|
||||
self._rpc_session: Optional[aiohttp.ClientSession] = None
|
||||
self._rpc_session_lock = asyncio.Lock()
|
||||
self._process_lock = asyncio.Lock()
|
||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||
self._poll_interval = 0.5
|
||||
self._state_store = Aria2TransferStateStore()
|
||||
self._stderr_reader_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._process is not None and self._process.returncode is None
|
||||
|
||||
async def download_file(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
*,
|
||||
download_id: str,
|
||||
progress_callback=None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Download a file using aria2 RPC and wait for completion."""
|
||||
|
||||
await self._ensure_process()
|
||||
save_path = os.path.abspath(save_path)
|
||||
transfer = self._transfers.get(download_id)
|
||||
if transfer is None or os.path.abspath(transfer.save_path) != save_path:
|
||||
gid = await self._schedule_download(
|
||||
url,
|
||||
save_path,
|
||||
download_id=download_id,
|
||||
headers=headers,
|
||||
)
|
||||
transfer = Aria2Transfer(gid=gid, save_path=save_path)
|
||||
self._transfers[download_id] = transfer
|
||||
|
||||
try:
|
||||
while True:
|
||||
status = await self._get_status_with_retry(download_id)
|
||||
if status is None:
|
||||
return False, "aria2 download not found"
|
||||
|
||||
snapshot = self._build_progress_snapshot(status)
|
||||
if progress_callback is not None:
|
||||
await self._dispatch_progress(progress_callback, snapshot)
|
||||
|
||||
state = status.get("status", "")
|
||||
if state == "complete":
|
||||
completed_path = self._resolve_completed_path(status, save_path)
|
||||
return True, completed_path
|
||||
if state == "error":
|
||||
return False, status.get("errorMessage") or "aria2 download failed"
|
||||
if state == "removed":
|
||||
return False, "Download was cancelled"
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
finally:
|
||||
self._transfers.pop(download_id, None)
|
||||
|
||||
async def _get_status_with_retry(
|
||||
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Call get_status with retry for transient RPC failures.
|
||||
|
||||
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
|
||||
``None`` immediately when the download_id is not tracked (a missing
|
||||
transfer is not a transient condition, so retrying is pointless).
|
||||
|
||||
A single failed RPC call should not immediately fail the download,
|
||||
because aria2 may be temporarily busy (e.g. finalizing multiple
|
||||
concurrent downloads) and a retry will often succeed.
|
||||
"""
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await self.get_status(download_id)
|
||||
except Aria2Error as exc:
|
||||
last_exc = exc
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
|
||||
attempt + 1, max_retries, download_id, exc,
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
raise Aria2Error(
|
||||
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
async def _schedule_download(
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
*,
|
||||
download_id: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> str:
|
||||
save_dir = os.path.dirname(save_path)
|
||||
out_name = os.path.basename(save_path)
|
||||
|
||||
Path(save_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
resolved_url = url
|
||||
request_headers = headers
|
||||
if headers and url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES):
|
||||
resolved_url = await self._resolve_authenticated_redirect_url(url, headers)
|
||||
if resolved_url != url:
|
||||
request_headers = None
|
||||
logger.debug(
|
||||
"Resolved Civitai download %s to signed URL for aria2",
|
||||
download_id,
|
||||
)
|
||||
|
||||
options: Dict[str, str] = {
|
||||
"dir": save_dir,
|
||||
"out": out_name,
|
||||
"continue": "true",
|
||||
"max-connection-per-server": "4",
|
||||
"split": "4",
|
||||
"min-split-size": "1M",
|
||||
"allow-overwrite": "true",
|
||||
"auto-file-renaming": "false",
|
||||
"file-allocation": "none",
|
||||
}
|
||||
|
||||
# Pass proxy to aria2 so the actual file transfer goes through the
|
||||
# same proxy used by the aiohttp-based URL resolution step above.
|
||||
downloader = await get_downloader()
|
||||
if downloader.proxy_url:
|
||||
options["all-proxy"] = downloader.proxy_url
|
||||
|
||||
if request_headers:
|
||||
options["header"] = [
|
||||
f"{key}: {value}" for key, value in request_headers.items()
|
||||
]
|
||||
|
||||
logger.debug(
|
||||
"Submitting aria2 download %s -> %s (auth=%s, civitai_signed=%s)",
|
||||
download_id,
|
||||
save_path,
|
||||
bool(request_headers),
|
||||
resolved_url != url,
|
||||
)
|
||||
|
||||
try:
|
||||
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||
except Exception as exc:
|
||||
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
|
||||
|
||||
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
|
||||
await self._state_store.upsert(
|
||||
download_id,
|
||||
{
|
||||
"gid": gid,
|
||||
"save_path": save_path,
|
||||
"status": "downloading",
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
return gid
|
||||
|
||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the raw aria2 status payload for a known download."""
|
||||
|
||||
transfer = self._transfers.get(download_id)
|
||||
if transfer is None:
|
||||
return None
|
||||
|
||||
keys = [
|
||||
"gid",
|
||||
"status",
|
||||
"totalLength",
|
||||
"completedLength",
|
||||
"downloadSpeed",
|
||||
"errorMessage",
|
||||
"files",
|
||||
]
|
||||
try:
|
||||
status = await self._rpc_call("aria2.tellStatus", [transfer.gid, keys])
|
||||
except Exception as exc:
|
||||
raise Aria2Error(f"Failed to query aria2 download status: {exc}") from exc
|
||||
|
||||
if isinstance(status, dict):
|
||||
return status
|
||||
return None
|
||||
|
||||
async def get_status_by_gid(self, gid: str) -> Optional[Dict[str, Any]]:
|
||||
keys = [
|
||||
"gid",
|
||||
"status",
|
||||
"totalLength",
|
||||
"completedLength",
|
||||
"downloadSpeed",
|
||||
"errorMessage",
|
||||
"files",
|
||||
]
|
||||
try:
|
||||
status = await self._rpc_call("aria2.tellStatus", [gid, keys])
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
if "cannot be found" in message.lower() or "not found" in message.lower():
|
||||
return None
|
||||
raise Aria2Error(f"Failed to query aria2 download status: {exc}") from exc
|
||||
|
||||
if isinstance(status, dict):
|
||||
return status
|
||||
return None
|
||||
|
||||
async def restore_transfer(self, download_id: str, gid: str, save_path: str) -> None:
|
||||
await self._ensure_process()
|
||||
self._transfers[download_id] = Aria2Transfer(
|
||||
gid=gid,
|
||||
save_path=os.path.abspath(save_path),
|
||||
)
|
||||
|
||||
async def reassign_transfer(
|
||||
self, from_download_id: str, to_download_id: str
|
||||
) -> Optional[Aria2Transfer]:
|
||||
transfer = self._transfers.get(from_download_id)
|
||||
if transfer is None:
|
||||
return None
|
||||
|
||||
self._transfers[to_download_id] = transfer
|
||||
if from_download_id != to_download_id:
|
||||
self._transfers.pop(from_download_id, None)
|
||||
return transfer
|
||||
|
||||
async def has_transfer(self, download_id: str) -> bool:
|
||||
return download_id in self._transfers
|
||||
|
||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||
transfer = self._transfers.get(download_id)
|
||||
if transfer is None:
|
||||
return {"success": False, "error": "Download task not found"}
|
||||
|
||||
try:
|
||||
await self._rpc_call("aria2.forcePause", [transfer.gid])
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
await self._state_store.upsert(download_id, {"status": "paused"})
|
||||
return {"success": True, "message": "Download paused successfully"}
|
||||
|
||||
async def resume_download(self, download_id: str) -> Dict[str, Any]:
|
||||
transfer = self._transfers.get(download_id)
|
||||
if transfer is None:
|
||||
return {"success": False, "error": "Download task not found"}
|
||||
|
||||
try:
|
||||
await self._rpc_call("aria2.unpause", [transfer.gid])
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
await self._state_store.upsert(download_id, {"status": "downloading"})
|
||||
return {"success": True, "message": "Download resumed successfully"}
|
||||
|
||||
async def cancel_download(self, download_id: str) -> Dict[str, Any]:
|
||||
transfer = self._transfers.get(download_id)
|
||||
if transfer is None:
|
||||
return {"success": False, "error": "Download task not found"}
|
||||
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [transfer.gid])
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
await self._state_store.remove(download_id)
|
||||
return {"success": True, "message": "Download cancelled successfully"}
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Shut down the RPC process and session."""
|
||||
|
||||
# Cancel the background stderr reader first so it stops reading
|
||||
# from the pipe before the subprocess is terminated.
|
||||
if self._stderr_reader_task is not None:
|
||||
self._stderr_reader_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
self._stderr_reader_task = None
|
||||
|
||||
if self._rpc_session is not None:
|
||||
await self._rpc_session.close()
|
||||
self._rpc_session = None
|
||||
|
||||
process = self._process
|
||||
self._process = None
|
||||
self._transfers.clear()
|
||||
|
||||
if process is None:
|
||||
return
|
||||
|
||||
if process.returncode is None:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
async def _drain_stderr(self) -> None:
|
||||
"""Continuously drain aria2's stderr pipe so it never blocks.
|
||||
|
||||
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
|
||||
blocks, which freezes the entire ``aria2c`` process — including its
|
||||
RPC handler. This background task reads lines from stderr as they
|
||||
arrive and forwards them to Python's logger.
|
||||
"""
|
||||
try:
|
||||
assert self._process is not None and self._process.stderr is not None
|
||||
async for line in self._process.stderr:
|
||||
text = line.decode("utf-8", errors="replace").rstrip()
|
||||
if text:
|
||||
logger.debug("aria2 stderr: %s", text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
||||
try:
|
||||
result = callback(snapshot, snapshot)
|
||||
except TypeError:
|
||||
result = callback(snapshot.percent_complete)
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
elif hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
def _build_progress_snapshot(self, status: Dict[str, Any]) -> DownloadProgress:
|
||||
completed = self._parse_int(status.get("completedLength"))
|
||||
total = self._parse_int(status.get("totalLength"))
|
||||
speed = float(self._parse_int(status.get("downloadSpeed")))
|
||||
percent = 0.0
|
||||
if total > 0:
|
||||
percent = (completed / total) * 100.0
|
||||
|
||||
return DownloadProgress(
|
||||
percent_complete=max(0.0, min(percent, 100.0)),
|
||||
bytes_downloaded=completed,
|
||||
total_bytes=total or None,
|
||||
bytes_per_second=speed,
|
||||
timestamp=datetime.now().timestamp(),
|
||||
)
|
||||
|
||||
def _resolve_completed_path(self, status: Dict[str, Any], default_path: str) -> str:
|
||||
files = status.get("files")
|
||||
if isinstance(files, list) and files:
|
||||
first = files[0]
|
||||
if isinstance(first, dict):
|
||||
candidate = first.get("path")
|
||||
if isinstance(candidate, str) and candidate:
|
||||
return candidate
|
||||
return default_path
|
||||
|
||||
@staticmethod
|
||||
def _parse_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
async def _resolve_authenticated_redirect_url(
|
||||
self,
|
||||
url: str,
|
||||
headers: Dict[str, str],
|
||||
) -> str:
|
||||
downloader = await get_downloader()
|
||||
session = await downloader.session
|
||||
request_headers = dict(downloader.default_headers)
|
||||
request_headers.update(headers)
|
||||
request_headers["Accept-Encoding"] = "identity"
|
||||
|
||||
try:
|
||||
async with session.get(
|
||||
url,
|
||||
headers=request_headers,
|
||||
allow_redirects=False,
|
||||
proxy=downloader.proxy_url,
|
||||
) as response:
|
||||
if response.status in {301, 302, 303, 307, 308}:
|
||||
location = response.headers.get("Location")
|
||||
if location:
|
||||
return location
|
||||
raise Aria2Error(
|
||||
"Authenticated Civitai redirect did not include a Location header"
|
||||
)
|
||||
|
||||
if response.status == 200:
|
||||
return url
|
||||
|
||||
body = await response.text()
|
||||
raise Aria2Error(
|
||||
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
||||
)
|
||||
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(
|
||||
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
||||
) from exc
|
||||
|
||||
async def _ensure_process(self) -> None:
|
||||
async with self._process_lock:
|
||||
if self.is_running and await self._ping():
|
||||
return
|
||||
|
||||
await self.close()
|
||||
|
||||
executable = self._resolve_executable()
|
||||
self._rpc_port = self._find_free_port()
|
||||
self._rpc_secret = secrets.token_hex(16)
|
||||
self._rpc_url = f"http://127.0.0.1:{self._rpc_port}/jsonrpc"
|
||||
|
||||
command = [
|
||||
executable,
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-all=false",
|
||||
f"--rpc-listen-port={self._rpc_port}",
|
||||
f"--rpc-secret={self._rpc_secret}",
|
||||
"--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",
|
||||
"--auto-file-renaming=false",
|
||||
"--file-allocation=none",
|
||||
"--max-concurrent-downloads=5",
|
||||
"--continue=true",
|
||||
"--daemon=false",
|
||||
"--quiet=true",
|
||||
f"--stop-with-process={os.getpid()}",
|
||||
]
|
||||
|
||||
logger.info("Starting aria2 RPC daemon from %s", executable)
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
await self._wait_until_ready()
|
||||
|
||||
# Drain aria2's stderr in a background task so the pipe buffer
|
||||
# never fills up. If the pipe blocks, aria2 itself freezes and
|
||||
# cannot respond to RPC — this was the root cause of the
|
||||
# "Failed to query aria2 download status" timeout bug.
|
||||
# Must start AFTER _wait_until_ready to avoid a race where the
|
||||
# drain task consumes aria2's early-exit error message before
|
||||
# _wait_until_ready can read it.
|
||||
self._stderr_reader_task = asyncio.create_task(
|
||||
self._drain_stderr()
|
||||
)
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
settings = get_settings_manager()
|
||||
configured_path = (settings.get("aria2c_path") or "").strip()
|
||||
candidate = configured_path or "aria2c"
|
||||
|
||||
resolved = shutil.which(candidate)
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
if configured_path and os.path.isfile(configured_path) and os.access(
|
||||
configured_path, os.X_OK
|
||||
):
|
||||
return configured_path
|
||||
|
||||
raise Aria2Error(
|
||||
"aria2c executable was not found. Install aria2 or configure aria2c_path."
|
||||
)
|
||||
|
||||
async def _wait_until_ready(self) -> None:
|
||||
assert self._process is not None
|
||||
|
||||
start_time = asyncio.get_running_loop().time()
|
||||
last_error = ""
|
||||
while asyncio.get_running_loop().time() - start_time < 10.0:
|
||||
if self._process.returncode is not None:
|
||||
stderr_output = ""
|
||||
if self._process.stderr is not None:
|
||||
try:
|
||||
stderr_output = (
|
||||
await asyncio.wait_for(self._process.stderr.read(), timeout=0.2)
|
||||
).decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
stderr_output = ""
|
||||
raise Aria2Error(
|
||||
f"aria2 RPC process exited early with code {self._process.returncode}: {stderr_output.strip()}"
|
||||
)
|
||||
|
||||
try:
|
||||
if await self._ping():
|
||||
return
|
||||
except Exception as exc: # pragma: no cover - startup race
|
||||
last_error = str(exc)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
raise Aria2Error(
|
||||
f"Timed out waiting for aria2 RPC to become ready{': ' + last_error if last_error else ''}"
|
||||
)
|
||||
|
||||
async def _ping(self) -> bool:
|
||||
try:
|
||||
result = await self._rpc_call("aria2.getVersion", [])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return isinstance(result, dict)
|
||||
|
||||
async def _rpc_call(self, method: str, params: list[Any]) -> Any:
|
||||
if not self._rpc_url:
|
||||
raise Aria2Error("aria2 RPC endpoint is not initialized")
|
||||
|
||||
session = await self._get_rpc_session()
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": secrets.token_hex(8),
|
||||
"method": method,
|
||||
"params": [f"token:{self._rpc_secret}", *params],
|
||||
}
|
||||
|
||||
async with session.post(self._rpc_url, json=payload) as response:
|
||||
text = await response.text()
|
||||
|
||||
try:
|
||||
body = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
body = None
|
||||
|
||||
if body is None:
|
||||
if response.status != 200:
|
||||
raise Aria2Error(
|
||||
f"aria2 RPC returned status {response.status} with non-JSON body: {text}"
|
||||
)
|
||||
raise Aria2Error(f"Invalid aria2 RPC response: {text}")
|
||||
|
||||
if "error" in body:
|
||||
error = body["error"] or {}
|
||||
code = error.get("code") if isinstance(error, dict) else None
|
||||
message = error.get("message") if isinstance(error, dict) else str(error)
|
||||
logger.error(
|
||||
"aria2 RPC %s failed with HTTP %s, code=%s, message=%s",
|
||||
method,
|
||||
response.status,
|
||||
code,
|
||||
message,
|
||||
)
|
||||
status_message = (
|
||||
f"aria2 RPC {method} failed with status {response.status}: {message}"
|
||||
if response.status != 200
|
||||
else message
|
||||
)
|
||||
raise Aria2Error(status_message or "Unknown aria2 RPC error")
|
||||
|
||||
if response.status != 200:
|
||||
logger.error(
|
||||
"aria2 RPC %s returned unexpected HTTP status %s without error payload: %s",
|
||||
method,
|
||||
response.status,
|
||||
body,
|
||||
)
|
||||
raise Aria2Error(
|
||||
f"aria2 RPC {method} returned unexpected status {response.status}"
|
||||
)
|
||||
|
||||
return body.get("result")
|
||||
|
||||
async def _get_rpc_session(self) -> aiohttp.ClientSession:
|
||||
if self._rpc_session is None or self._rpc_session.closed:
|
||||
async with self._rpc_session_lock:
|
||||
if self._rpc_session is None or self._rpc_session.closed:
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=None, sock_connect=10, sock_read=60
|
||||
)
|
||||
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
||||
return self._rpc_session
|
||||
|
||||
@staticmethod
|
||||
def _find_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
async def get_aria2_downloader() -> Aria2Downloader:
|
||||
"""Get the singleton aria2 downloader."""
|
||||
|
||||
return await Aria2Downloader.get_instance()
|
||||
108
py/services/aria2_transfer_state.py
Normal file
108
py/services/aria2_transfer_state.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
|
||||
|
||||
def get_aria2_state_path() -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
state_dir = os.path.join(base_dir, "aria2")
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
return os.path.join(state_dir, "downloads.json")
|
||||
|
||||
|
||||
class Aria2TransferStateStore:
|
||||
"""Persist aria2 transfer metadata needed for restart recovery."""
|
||||
|
||||
_locks_by_path: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
def __init__(self, state_path: Optional[str] = None) -> None:
|
||||
self._state_path = os.path.abspath(state_path or get_aria2_state_path())
|
||||
self._lock = self._locks_by_path.setdefault(self._state_path, asyncio.Lock())
|
||||
|
||||
def _read_all_unlocked(self) -> Dict[str, Dict[str, Any]]:
|
||||
try:
|
||||
with open(self._state_path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
|
||||
normalized: Dict[str, Dict[str, Any]] = {}
|
||||
for download_id, entry in data.items():
|
||||
if isinstance(download_id, str) and isinstance(entry, dict):
|
||||
normalized[download_id] = entry
|
||||
return normalized
|
||||
|
||||
def _write_all_unlocked(self, data: Dict[str, Dict[str, Any]]) -> None:
|
||||
directory = os.path.dirname(self._state_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
temp_path = f"{self._state_path}.tmp"
|
||||
with open(temp_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, ensure_ascii=True, indent=2, sort_keys=True)
|
||||
os.replace(temp_path, self._state_path)
|
||||
|
||||
async def load_all(self) -> Dict[str, Dict[str, Any]]:
|
||||
async with self._lock:
|
||||
return deepcopy(self._read_all_unlocked())
|
||||
|
||||
async def get(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||
async with self._lock:
|
||||
return deepcopy(self._read_all_unlocked().get(download_id))
|
||||
|
||||
async def upsert(self, download_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
async with self._lock:
|
||||
data = self._read_all_unlocked()
|
||||
current = data.get(download_id, {})
|
||||
current.update(payload)
|
||||
data[download_id] = current
|
||||
self._write_all_unlocked(data)
|
||||
return deepcopy(current)
|
||||
|
||||
async def remove(self, download_id: str) -> None:
|
||||
async with self._lock:
|
||||
data = self._read_all_unlocked()
|
||||
if download_id in data:
|
||||
del data[download_id]
|
||||
self._write_all_unlocked(data)
|
||||
|
||||
async def find_by_save_path(
|
||||
self, save_path: str, *, exclude_download_id: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
normalized_target = os.path.abspath(save_path)
|
||||
async with self._lock:
|
||||
data = self._read_all_unlocked()
|
||||
for download_id, entry in data.items():
|
||||
if exclude_download_id and download_id == exclude_download_id:
|
||||
continue
|
||||
candidate = entry.get("save_path")
|
||||
if isinstance(candidate, str) and os.path.abspath(candidate) == normalized_target:
|
||||
result = dict(entry)
|
||||
result["download_id"] = download_id
|
||||
return result
|
||||
return None
|
||||
|
||||
async def reassign(self, from_download_id: str, to_download_id: str) -> Optional[Dict[str, Any]]:
|
||||
async with self._lock:
|
||||
data = self._read_all_unlocked()
|
||||
existing = data.get(from_download_id)
|
||||
if existing is None:
|
||||
return None
|
||||
updated = dict(existing)
|
||||
updated["download_id"] = to_download_id
|
||||
data[to_download_id] = updated
|
||||
if from_download_id != to_download_id:
|
||||
data.pop(from_download_id, None)
|
||||
self._write_all_unlocked(data)
|
||||
return deepcopy(updated)
|
||||
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)
|
||||
@@ -141,6 +141,16 @@ class BackupService:
|
||||
)
|
||||
)
|
||||
|
||||
stats_path = os.path.join(get_settings_dir(create=True), "stats", "lora_manager_stats.json")
|
||||
if os.path.exists(stats_path):
|
||||
targets.append(
|
||||
(
|
||||
"usage_stats",
|
||||
"stats/lora_manager_stats.json",
|
||||
stats_path,
|
||||
)
|
||||
)
|
||||
|
||||
return targets
|
||||
|
||||
@staticmethod
|
||||
@@ -348,6 +358,8 @@ class BackupService:
|
||||
if kind == "model_update":
|
||||
filename = os.path.basename(archive_member)
|
||||
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename)
|
||||
if kind == "usage_stats":
|
||||
return os.path.join(get_settings_dir(create=True), "stats", "lora_manager_stats.json")
|
||||
return None
|
||||
|
||||
async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -20,6 +21,7 @@ from .model_query import (
|
||||
resolve_sub_type,
|
||||
)
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.civitai_utils import build_civitai_model_page_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +78,7 @@ class BaseModelService(ABC):
|
||||
base_models: list = None,
|
||||
model_types: list = None,
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
auto_tags: Optional[Dict[str, str]] = None,
|
||||
search_options: dict = None,
|
||||
hash_filters: dict = None,
|
||||
favorites_only: bool = False,
|
||||
@@ -94,9 +97,117 @@ class BaseModelService(ABC):
|
||||
sorted_data = await self._fetch_with_usage_sort(sort_params)
|
||||
else:
|
||||
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
|
||||
initial_count = len(sorted_data)
|
||||
|
||||
# Optionally filter by civitai model ID (shows all local versions of a specific model)
|
||||
civitai_model_id = kwargs.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
sorted_data = [
|
||||
item for item in sorted_data
|
||||
if self._extract_group_key(item) == civitai_model_id
|
||||
]
|
||||
# VLM mode: always sort by version ID descending (newest version first),
|
||||
# regardless of the current sort_by preference.
|
||||
# Fall back to modified timestamp for non-CivitAI sources.
|
||||
sorted_data.sort(
|
||||
key=lambda x: self._extract_version_id(x)
|
||||
or x.get("modified", 0)
|
||||
or 0,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Optionally group by civitai modelId, showing only the latest version per model
|
||||
dedup_lost = 0
|
||||
if kwargs.get("group_by_model") and civitai_model_id is None:
|
||||
# Determine whether to further sub-group by base model
|
||||
# When version_grouping is "same_base", versions with different
|
||||
# base models are effectively different groups — the dedup key
|
||||
# needs to include base_model so the version count and VLM flow
|
||||
# stay consistent (card shows correct count for its base model).
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
dedup_map = {} # (modelId [,base_model]) -> (item, version_or_modified)
|
||||
version_counter = {} # same-key -> count
|
||||
standalone = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_group_key(item)
|
||||
if mid is None:
|
||||
standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
# Count all versions per key
|
||||
version_counter[key] = version_counter.get(key, 0) + 1
|
||||
# Prefer CivitAI version_id; fall back to modified timestamp
|
||||
vid = self._extract_version_id(item)
|
||||
if vid is None:
|
||||
vid = item.get("modified", 0) or 0
|
||||
if key not in dedup_map or vid > dedup_map[key][1]:
|
||||
dedup_map[key] = (item, vid)
|
||||
# Attach version_count to each surviving grouped item (shallow copy
|
||||
# to avoid mutating cached dicts — the cache is shared across requests)
|
||||
for key, (item, vid) in dedup_map.items():
|
||||
item = dict(item)
|
||||
item["version_count"] = version_counter[key]
|
||||
dedup_map[key] = (item, vid)
|
||||
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
|
||||
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
|
||||
|
||||
# Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand)
|
||||
if sort_params.key == "versions_count" and civitai_model_id is None:
|
||||
reverse = sort_params.order == "desc"
|
||||
if kwargs.get("group_by_model"):
|
||||
# Grouped mode: items are already dedup'd with version_count attached
|
||||
sorted_data.sort(
|
||||
key=lambda x: (
|
||||
x.get("version_count", 0),
|
||||
(x.get("model_name") or x.get("file_name") or "").lower(),
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
reverse=reverse,
|
||||
)
|
||||
else:
|
||||
# Non-grouped mode: group internally, sort groups by count, expand
|
||||
# Respect the version_grouping setting (same logic as grouped dedup)
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
model_groups: Dict[Any, List[Dict]] = {}
|
||||
ungrouped_standalone: List[Dict] = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_group_key(item)
|
||||
if mid is None:
|
||||
ungrouped_standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
model_groups.setdefault(key, []).append(item)
|
||||
# Sort versions within each group by version id (descending);
|
||||
# fall back to modified timestamp for non-CivitAI sources.
|
||||
for items in model_groups.values():
|
||||
items.sort(
|
||||
key=lambda x: self._extract_version_id(x)
|
||||
or x.get("modified", 0)
|
||||
or 0,
|
||||
reverse=True,
|
||||
)
|
||||
# Sort groups by version count
|
||||
sorted_groups = sorted(
|
||||
model_groups.values(),
|
||||
key=lambda items: len(items),
|
||||
reverse=reverse,
|
||||
)
|
||||
# Flatten: grouped items first, standalone items last
|
||||
sorted_data = []
|
||||
for items in sorted_groups:
|
||||
sorted_data.extend(items)
|
||||
sorted_data.extend(ungrouped_standalone)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
if hash_filters:
|
||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||
@@ -109,6 +220,7 @@ class BaseModelService(ABC):
|
||||
base_models=base_models,
|
||||
model_types=model_types,
|
||||
tags=tags,
|
||||
auto_tags=auto_tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
tag_logic=tag_logic,
|
||||
@@ -164,7 +276,7 @@ class BaseModelService(ABC):
|
||||
overall_duration = time.perf_counter() - overall_start
|
||||
logger.debug(
|
||||
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
||||
"Counts: initial=%d, post_filter=%d, final=%d",
|
||||
"Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
|
||||
self.__class__.__name__,
|
||||
overall_duration,
|
||||
fetch_duration,
|
||||
@@ -173,11 +285,63 @@ class BaseModelService(ABC):
|
||||
pagination_duration,
|
||||
annotate_duration,
|
||||
initial_count,
|
||||
dedup_lost,
|
||||
post_filter_count,
|
||||
final_count,
|
||||
)
|
||||
return paginated
|
||||
|
||||
async def get_excluded_paginated_data(
|
||||
self,
|
||||
page: int,
|
||||
page_size: int,
|
||||
sort_by: str = "name",
|
||||
search: str = None,
|
||||
fuzzy_search: bool = False,
|
||||
search_options: dict = None,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
"""Get paginated excluded model data."""
|
||||
excluded_paths = list(self.scanner.get_excluded_models())
|
||||
excluded_entries: List[Dict[str, Any]] = []
|
||||
stale_paths: List[str] = []
|
||||
|
||||
for file_path in excluded_paths:
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
stale_paths.append(file_path)
|
||||
continue
|
||||
|
||||
entry = await self._build_excluded_entry(file_path)
|
||||
if entry:
|
||||
excluded_entries.append(entry)
|
||||
else:
|
||||
stale_paths.append(file_path)
|
||||
|
||||
if stale_paths:
|
||||
current_excluded = getattr(self.scanner, "_excluded_models", None)
|
||||
if isinstance(current_excluded, list):
|
||||
stale_set = set(stale_paths)
|
||||
self.scanner._excluded_models = [
|
||||
path for path in current_excluded if path not in stale_set
|
||||
]
|
||||
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
|
||||
if callable(persist_current_cache):
|
||||
await persist_current_cache()
|
||||
|
||||
excluded_entries = self._sort_entries(excluded_entries, sort_by)
|
||||
|
||||
if search:
|
||||
excluded_entries = await self._apply_search_filters(
|
||||
excluded_entries,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
)
|
||||
|
||||
paginated = self._paginate(excluded_entries, page, page_size)
|
||||
paginated["items"] = await self._annotate_update_flags(paginated["items"])
|
||||
return paginated
|
||||
|
||||
async def _fetch_with_usage_sort(self, sort_params):
|
||||
"""Fetch data sorted by usage count (desc/asc)."""
|
||||
cache = await self.cache_repository.get_cache()
|
||||
@@ -217,6 +381,68 @@ class BaseModelService(ABC):
|
||||
)
|
||||
return annotated
|
||||
|
||||
def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
|
||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||
key_name = sort_params.key
|
||||
|
||||
if key_name == "date":
|
||||
key_fn = lambda item: (
|
||||
float(item.get("modified", 0.0) or 0.0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
elif key_name == "random":
|
||||
# Seeded random shuffle: same seed -> same order (stable pagination)
|
||||
rng = random.Random(sort_params.seed or "random")
|
||||
result = list(data)
|
||||
rng.shuffle(result)
|
||||
return result
|
||||
elif key_name == "size":
|
||||
key_fn = lambda item: (
|
||||
int(item.get("size", 0) or 0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
elif key_name == "usage":
|
||||
key_fn = lambda item: (
|
||||
int(item.get("usage_count", 0) or 0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
else:
|
||||
key_fn = lambda item: (
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
|
||||
return sorted(data, key=key_fn, reverse=sort_params.order == "desc")
|
||||
|
||||
async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
|
||||
root_path = self.scanner._find_root_for_file(file_path)
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path,
|
||||
self.metadata_class,
|
||||
)
|
||||
if should_skip:
|
||||
return None
|
||||
|
||||
if metadata is None:
|
||||
metadata = await self.scanner._create_default_metadata(file_path)
|
||||
if metadata is None:
|
||||
return None
|
||||
|
||||
metadata = self.scanner.adjust_metadata(metadata, file_path, root_path)
|
||||
folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace(
|
||||
os.path.sep, "/"
|
||||
)
|
||||
entry = self.scanner._build_cache_entry(metadata, folder=folder)
|
||||
entry = self.scanner.adjust_cached_entry(entry)
|
||||
entry["exclude"] = True
|
||||
return entry
|
||||
|
||||
async def _apply_hash_filters(
|
||||
self, data: List[Dict], hash_filters: Dict
|
||||
) -> List[Dict]:
|
||||
@@ -246,6 +472,7 @@ class BaseModelService(ABC):
|
||||
base_models: list = None,
|
||||
model_types: list = None,
|
||||
tags: Optional[Dict[str, str]] = None,
|
||||
auto_tags: Optional[Dict[str, str]] = None,
|
||||
favorites_only: bool = False,
|
||||
search_options: dict = None,
|
||||
tag_logic: str = "any",
|
||||
@@ -259,6 +486,7 @@ class BaseModelService(ABC):
|
||||
base_models=base_models,
|
||||
model_types=model_types,
|
||||
tags=tags,
|
||||
auto_tags=auto_tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=normalized_options,
|
||||
tag_logic=tag_logic,
|
||||
@@ -378,7 +606,7 @@ class BaseModelService(ABC):
|
||||
if not ordered_ids:
|
||||
return annotated
|
||||
|
||||
strategy_value = self.settings.get("update_flag_strategy")
|
||||
strategy_value = self.settings.get("version_grouping")
|
||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||
strategy = strategy_value.strip().lower()
|
||||
else:
|
||||
@@ -485,6 +713,33 @@ class BaseModelService(ABC):
|
||||
|
||||
return annotated
|
||||
|
||||
@staticmethod
|
||||
def _extract_hf_group_key(item: Dict) -> Optional[str]:
|
||||
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
|
||||
hf_url = item.get("hf_url") if isinstance(item, dict) else None
|
||||
if not hf_url or not isinstance(hf_url, str):
|
||||
return None
|
||||
m = re.match(
|
||||
r"https?://huggingface\.co/([^/]+/[^/]+)", hf_url.strip()
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
return f"hf:{m.group(1)}"
|
||||
|
||||
@staticmethod
|
||||
def _extract_group_key(item: Dict) -> Union[int, str, None]:
|
||||
"""Return the group identity key: CivitAI modelId (int) or HF repo (str).
|
||||
|
||||
Preference order:
|
||||
1. CivitAI ``modelId`` (int)
|
||||
2. HF repo identity ``hf:{owner}/{repo}`` (str)
|
||||
3. ``None`` (no known grouping source)
|
||||
"""
|
||||
mid = BaseModelService._extract_model_id(item)
|
||||
if mid is not None:
|
||||
return mid
|
||||
return BaseModelService._extract_hf_group_key(item)
|
||||
|
||||
@staticmethod
|
||||
def _extract_model_id(item: Dict) -> Optional[int]:
|
||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||
@@ -579,8 +834,12 @@ class BaseModelService(ABC):
|
||||
}
|
||||
|
||||
@abstractmethod
|
||||
async def format_response(self, model_data: Dict) -> Dict:
|
||||
"""Format model data for API response - must be implemented by subclasses"""
|
||||
async def format_response(self, model_data: Dict) -> Optional[Dict]:
|
||||
"""Format model data for API response - must be implemented by subclasses.
|
||||
|
||||
Subclasses should return None for corrupted entries so the handler
|
||||
layer can filter them out. See issue #730.
|
||||
"""
|
||||
pass
|
||||
|
||||
# Common service methods that delegate to scanner
|
||||
@@ -588,6 +847,12 @@ class BaseModelService(ABC):
|
||||
"""Get top tags sorted by frequency"""
|
||||
return await self.scanner.get_top_tags(limit)
|
||||
|
||||
async def search_tags(
|
||||
self, query: str, limit: int = 50
|
||||
) -> List[Dict]:
|
||||
"""Search tags by substring, sorted by frequency"""
|
||||
return await self.scanner.search_tags(query, limit)
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict]:
|
||||
"""Get base models sorted by frequency"""
|
||||
return await self.scanner.get_base_models(limit)
|
||||
@@ -739,13 +1004,21 @@ class BaseModelService(ABC):
|
||||
|
||||
return unified_tree
|
||||
|
||||
async def get_model_notes(self, model_name: str) -> Optional[str]:
|
||||
"""Get notes for a specific model file"""
|
||||
async def get_model_notes(self, model_name: str) -> Optional[dict]:
|
||||
"""Get notes and file_path for a specific model file.
|
||||
|
||||
Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path
|
||||
syntax (``Anima/character/OWSMianne_ANIMA_V1``).
|
||||
"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model["file_name"] == model_name:
|
||||
return model.get("notes", "")
|
||||
file_name = model.get("file_name", "")
|
||||
if file_name == model_name or model_name.endswith("/" + file_name) or model_name.endswith("\\" + file_name):
|
||||
return {
|
||||
"notes": model.get("notes", ""),
|
||||
"file_path": model.get("file_path", ""),
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@@ -753,30 +1026,86 @@ class BaseModelService(ABC):
|
||||
"""Get the static preview URL for a model file"""
|
||||
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:
|
||||
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")
|
||||
if preview_url:
|
||||
from ..config import config
|
||||
|
||||
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"
|
||||
|
||||
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||
"""Get the Civitai URL for a model file"""
|
||||
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:
|
||||
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", {})
|
||||
model_id = civitai_data.get("modelId")
|
||||
version_id = civitai_data.get("id")
|
||||
|
||||
if model_id:
|
||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_id}"
|
||||
civitai_host = self.settings.get("civitai_host", "civitai.com")
|
||||
civitai_url = build_civitai_model_page_url(
|
||||
model_id,
|
||||
version_id,
|
||||
host=civitai_host,
|
||||
)
|
||||
|
||||
return {
|
||||
"civitai_url": civitai_url,
|
||||
@@ -784,6 +1113,27 @@ class BaseModelService(ABC):
|
||||
"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}
|
||||
|
||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||
@@ -791,12 +1141,41 @@ class BaseModelService(ABC):
|
||||
|
||||
Listing/search endpoints return lightweight cache entries; this method performs
|
||||
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
||||
|
||||
As a beneficial side effect, the in-memory and persistent caches are
|
||||
opportunistically synchronised with the on-disk metadata — this keeps the
|
||||
caches fresh even when a ``.metadata.json`` file was edited outside of the
|
||||
normal save path (e.g. manually or by an external script).
|
||||
"""
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path, self.metadata_class
|
||||
)
|
||||
if should_skip or metadata is 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)
|
||||
)
|
||||
|
||||
# Opportunistically sync the in-memory + persistent caches.
|
||||
# The .metadata.json disk read is already paid for; the sync only
|
||||
# performs work when the cache is actually stale, and uses targeted,
|
||||
# in-place operations to minimise overhead even with large model sets.
|
||||
#
|
||||
# Fire-and-forget by design: the task is intentionally untracked.
|
||||
# sync_cache_from_metadata handles its own errors internally.
|
||||
asyncio.create_task(
|
||||
self.scanner.sync_cache_from_metadata(
|
||||
file_path, metadata.to_dict()
|
||||
)
|
||||
)
|
||||
|
||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||
|
||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||
|
||||
@@ -224,7 +224,7 @@ class BatchImportService:
|
||||
return False
|
||||
|
||||
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:
|
||||
return True
|
||||
return False
|
||||
@@ -523,6 +523,10 @@ class BatchImportService:
|
||||
if payload.get("checkpoint"):
|
||||
metadata["checkpoint"] = payload["checkpoint"]
|
||||
|
||||
nsfw = payload.get("preview_nsfw_level")
|
||||
if isinstance(nsfw, int) and nsfw > 0:
|
||||
metadata["preview_nsfw_level"] = nsfw
|
||||
|
||||
image_bytes = None
|
||||
image_base64 = payload.get("image_base64")
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -36,6 +37,9 @@ class CheckpointScanner(ModelScanner):
|
||||
file_extensions=file_extensions,
|
||||
hash_index=ModelHashIndex(),
|
||||
)
|
||||
if not hasattr(self, "_hash_calculation_lock"):
|
||||
self._hash_calculation_lock = asyncio.Lock()
|
||||
self._hash_calculation_tasks: dict[str, asyncio.Task[Optional[str]]] = {}
|
||||
|
||||
async def _create_default_metadata(
|
||||
self, file_path: str
|
||||
@@ -88,7 +92,7 @@ class CheckpointScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
|
||||
"""Calculate hash for a checkpoint on-demand.
|
||||
"""Calculate hash for a checkpoint on-demand with per-file singleflight.
|
||||
|
||||
Args:
|
||||
file_path: Path to the model file
|
||||
@@ -96,14 +100,73 @@ class CheckpointScanner(ModelScanner):
|
||||
Returns:
|
||||
SHA256 hash string, or None if calculation failed
|
||||
"""
|
||||
from ..utils.file_utils import calculate_sha256
|
||||
|
||||
try:
|
||||
real_path = os.path.realpath(file_path)
|
||||
if not os.path.exists(real_path):
|
||||
logger.error(f"File not found for hash calculation: {file_path}")
|
||||
return None
|
||||
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if (
|
||||
metadata is not None
|
||||
and metadata.hash_status == "completed"
|
||||
and metadata.sha256
|
||||
):
|
||||
# Ensure the in-memory hash index is populated even when
|
||||
# the hash was already computed and persisted to the metadata
|
||||
# file. Without this, usage tracking (and any other caller
|
||||
# that queries get_hash_by_filename first) will miss on every
|
||||
# lookup and keep calling back into this method, creating a
|
||||
# tight loop that never populates the index.
|
||||
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
||||
return metadata.sha256
|
||||
|
||||
async with self._hash_calculation_lock:
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if (
|
||||
metadata is not None
|
||||
and metadata.hash_status == "completed"
|
||||
and metadata.sha256
|
||||
):
|
||||
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
||||
return metadata.sha256
|
||||
|
||||
task = self._hash_calculation_tasks.get(real_path)
|
||||
if task is None:
|
||||
task = asyncio.create_task(
|
||||
self._run_hash_calculation_task(file_path, real_path)
|
||||
)
|
||||
self._hash_calculation_tasks[real_path] = task
|
||||
|
||||
return await asyncio.shield(task)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
async def _run_hash_calculation_task(
|
||||
self, file_path: str, real_path: str
|
||||
) -> Optional[str]:
|
||||
"""Run a hash calculation task and remove it from the in-flight map."""
|
||||
try:
|
||||
return await self._calculate_hash_for_model_uncached(file_path, real_path)
|
||||
finally:
|
||||
task = asyncio.current_task()
|
||||
async with self._hash_calculation_lock:
|
||||
if self._hash_calculation_tasks.get(real_path) is task:
|
||||
del self._hash_calculation_tasks[real_path]
|
||||
|
||||
async def _calculate_hash_for_model_uncached(
|
||||
self, file_path: str, real_path: str
|
||||
) -> Optional[str]:
|
||||
"""Calculate hash for a checkpoint without checking in-flight tasks."""
|
||||
from ..utils.file_utils import calculate_sha256
|
||||
|
||||
try:
|
||||
# Load current metadata
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
@@ -120,6 +183,9 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
# Check if hash is already calculated
|
||||
if metadata.hash_status == "completed" and metadata.sha256:
|
||||
# Populate the in-memory hash index even for pre-computed
|
||||
# hashes, mirroring the fix in calculate_hash_for_model.
|
||||
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
||||
return metadata.sha256
|
||||
|
||||
# Update status to calculating
|
||||
@@ -138,6 +204,20 @@ class CheckpointScanner(ModelScanner):
|
||||
# Update hash index
|
||||
self._hash_index.add_entry(sha256.lower(), file_path)
|
||||
|
||||
# Update the in-memory cache entry so that subsequent
|
||||
# _persist_current_cache / _save_persistent_cache calls
|
||||
# write the hash back to the SQLite models table. Without
|
||||
# this the hash only lives in the metadata file and the
|
||||
# in-memory hash index, both of which are lost across
|
||||
# restarts, causing the same re-computation loop on the
|
||||
# next session.
|
||||
if self._cache is not None and self._cache.raw_data:
|
||||
for entry in self._cache.raw_data:
|
||||
if entry.get("file_path") == file_path:
|
||||
entry["sha256"] = sha256.lower()
|
||||
entry["hash_status"] = "completed"
|
||||
break
|
||||
|
||||
logger.info(f"Hash calculated for checkpoint: {file_path}")
|
||||
return sha256
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
from ..utils.models import CheckpointMetadata
|
||||
from ..config import config
|
||||
|
||||
@@ -20,20 +21,37 @@ class CheckpointService(BaseModelService):
|
||||
"""
|
||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
||||
"""Format Checkpoint data for API response"""
|
||||
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
|
||||
"""Format Checkpoint data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = checkpoint_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted checkpoint entry (missing file_path): %s",
|
||||
checkpoint_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
||||
|
||||
|
||||
file_name = checkpoint_data.get("file_name") or ""
|
||||
model_name = checkpoint_data.get("model_name") or file_name
|
||||
folder = checkpoint_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": checkpoint_data["model_name"],
|
||||
"file_name": checkpoint_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
||||
"base_model": checkpoint_data.get("base_model", ""),
|
||||
"folder": checkpoint_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": checkpoint_data.get("sha256", ""),
|
||||
"file_path": checkpoint_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": checkpoint_data.get("size", 0),
|
||||
"modified": checkpoint_data.get("modified", ""),
|
||||
"tags": checkpoint_data.get("tags", []),
|
||||
@@ -42,9 +60,13 @@ class CheckpointService(BaseModelService):
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||
"version_count": checkpoint_data.get("version_count"),
|
||||
"hf_url": checkpoint_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -186,6 +186,22 @@ class CivArchiveClient:
|
||||
if "metadata" in file_data:
|
||||
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:
|
||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||
elif file_data.get("model_version_id") is not None:
|
||||
@@ -213,6 +229,20 @@ class CivArchiveClient:
|
||||
for file_data in candidates:
|
||||
if isinstance(file_data, dict):
|
||||
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
|
||||
|
||||
def _transform_version(
|
||||
@@ -274,6 +304,20 @@ class CivArchiveClient:
|
||||
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
|
||||
if model_id is None or version_id is None:
|
||||
continue
|
||||
# CivitAI / CivArchive model IDs are small integers (typically ≤ 7
|
||||
# digits). Reject suspiciously large values that indicate the API
|
||||
# returned a malformed payload (e.g. a hash reinterpreted as an ID)
|
||||
# to avoid pointless HTTP 500 errors from CivArchive.
|
||||
_MAX_VALID_CIVITAI_ID = 100_000_000
|
||||
try:
|
||||
if int(model_id) >= _MAX_VALID_CIVITAI_ID or int(version_id) >= _MAX_VALID_CIVITAI_ID:
|
||||
logger.debug(
|
||||
"Skipping implausible CivArchive model_id=%s / version_id=%s",
|
||||
model_id, version_id,
|
||||
)
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
resolved = await self.get_model_version(model_id, version_id)
|
||||
if resolved:
|
||||
return resolved
|
||||
@@ -297,7 +341,7 @@ class CivArchiveClient:
|
||||
if resolved:
|
||||
return resolved, None
|
||||
|
||||
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||
logger.debug("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||
return None, "No version data found"
|
||||
|
||||
except RateLimitError:
|
||||
@@ -387,7 +431,7 @@ class CivArchiveClient:
|
||||
|
||||
if version_id is not None:
|
||||
raw_id = version_data.get("id")
|
||||
if raw_id != version_id:
|
||||
if raw_id is not None and str(raw_id) != str(version_id):
|
||||
logger.warning(
|
||||
"Requested version %s doesn't match default version %s for model %s",
|
||||
version_id,
|
||||
|
||||
@@ -30,7 +30,7 @@ class CivitaiBaseModelService:
|
||||
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
|
||||
|
||||
# Civitai API endpoint for enums
|
||||
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums"
|
||||
CIVITAI_ENUMS_URL = "https://civitai.red/api/v1/enums"
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> CivitaiBaseModelService:
|
||||
@@ -193,6 +193,10 @@ class CivitaiBaseModelService:
|
||||
"zimageturbo": "ZIT",
|
||||
"zimagebase": "ZIB",
|
||||
"anima": "ANI",
|
||||
"ernie": "ERNI",
|
||||
"ernie turbo": "ETRB",
|
||||
"nucleus": "NUCL",
|
||||
"krea 2": "KR2",
|
||||
"svd": "SVD",
|
||||
"ltxv": "LTXV",
|
||||
"ltxv2": "LTV2",
|
||||
@@ -209,6 +213,18 @@ class CivitaiBaseModelService:
|
||||
"wan video 2.2 i2v-a14b": "WAN",
|
||||
"wan video 2.5 t2v": "WAN",
|
||||
"wan video 2.5 i2v": "WAN",
|
||||
"wan video 2.7": "WAN",
|
||||
"wan image 2.7": "WI27",
|
||||
"ace audio": "ACE",
|
||||
"boogu": "BOOG",
|
||||
"grok": "GROK",
|
||||
"happyhorse": "HAPP",
|
||||
"hidream-o1": "HIO1",
|
||||
"lens": "LENS",
|
||||
"mai": "MAI",
|
||||
"upscaler": "UPSC",
|
||||
"ideogram 4.0": "ID40",
|
||||
"qwen 2": "QWN2",
|
||||
}
|
||||
|
||||
if lower_name in special_cases:
|
||||
@@ -388,6 +404,7 @@ class CivitaiBaseModelService:
|
||||
"LTXV2",
|
||||
"LTXV 2.3",
|
||||
"CogVideoX",
|
||||
"HappyHorse",
|
||||
"Mochi",
|
||||
"Hunyuan Video",
|
||||
"Wan Video",
|
||||
@@ -400,15 +417,25 @@ class CivitaiBaseModelService:
|
||||
"Wan Video 2.2 I2V-A14B",
|
||||
"Wan Video 2.5 T2V",
|
||||
"Wan Video 2.5 I2V",
|
||||
"Wan Image 2.7",
|
||||
"Wan Video 2.7",
|
||||
],
|
||||
"Other Models": [
|
||||
"ACE Audio",
|
||||
"Illustrious",
|
||||
"Pony",
|
||||
"Pony V7",
|
||||
"Boogu",
|
||||
"HiDream",
|
||||
"HiDream-O1",
|
||||
"Ideogram 4.0",
|
||||
"Qwen",
|
||||
"Qwen 2",
|
||||
"AuraFlow",
|
||||
"Chroma",
|
||||
"Grok",
|
||||
"Lens",
|
||||
"MAI",
|
||||
"ZImageTurbo",
|
||||
"ZImageBase",
|
||||
"PixArt a",
|
||||
@@ -418,6 +445,11 @@ class CivitaiBaseModelService:
|
||||
"Kolors",
|
||||
"NoobAI",
|
||||
"Anima",
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"Nucleus",
|
||||
"Krea 2",
|
||||
"Upscaler",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,14 @@ import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||
from .connectivity_guard import (
|
||||
OFFLINE_FRIENDLY_MESSAGE,
|
||||
is_expected_offline_error,
|
||||
is_offline_cooldown_error,
|
||||
)
|
||||
from .model_metadata_provider import (
|
||||
CivitaiModelMetadataProvider,
|
||||
ModelMetadataProviderManager,
|
||||
@@ -13,6 +20,12 @@ from ..utils.civitai_utils import resolve_license_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Best-effort cache for creator model counts, keyed by lowercase username.
|
||||
# Values are (monotonic timestamp, count or None); None results are cached
|
||||
# too so repeated failures don't hammer the API.
|
||||
_CREATOR_COUNT_CACHE_TTL_SECONDS = 600
|
||||
_creator_model_count_cache: Dict[str, Tuple[float, Optional[int]]] = {}
|
||||
|
||||
|
||||
class CivitaiClient:
|
||||
_instance = None
|
||||
@@ -39,7 +52,18 @@ class CivitaiClient:
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self.base_url = "https://civitai.com/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:
|
||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
@@ -49,20 +73,57 @@ class CivitaiClient:
|
||||
use_auth: bool = False,
|
||||
**kwargs,
|
||||
) -> 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()
|
||||
success, result = await downloader.make_request(
|
||||
method,
|
||||
url,
|
||||
use_auth=use_auth,
|
||||
**kwargs,
|
||||
)
|
||||
if not success and isinstance(result, RateLimitError):
|
||||
if result.provider is None:
|
||||
result.provider = "civitai_api"
|
||||
raise result
|
||||
return success, result
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
method,
|
||||
url,
|
||||
use_auth=use_auth,
|
||||
**kwargs,
|
||||
)
|
||||
if success:
|
||||
return True, result
|
||||
|
||||
if isinstance(result, RateLimitError):
|
||||
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
|
||||
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
||||
@@ -121,6 +182,8 @@ class CivitaiClient:
|
||||
)
|
||||
if not success:
|
||||
message = str(version)
|
||||
if is_expected_offline_error(message):
|
||||
return None, OFFLINE_FRIENDLY_MESSAGE
|
||||
if "not found" in message.lower():
|
||||
return None, "Model not found"
|
||||
|
||||
@@ -161,6 +224,9 @@ class CivitaiClient:
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
if is_expected_offline_error(str(e)):
|
||||
logger.debug("Preview download skipped due to offline state.")
|
||||
return False
|
||||
logger.error(f"Download Error: {str(e)}")
|
||||
return False
|
||||
|
||||
@@ -186,11 +252,36 @@ class CivitaiClient:
|
||||
|
||||
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]:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
success, result = await self._make_request(
|
||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
# Also return model type along with versions
|
||||
@@ -202,7 +293,17 @@ class CivitaiClient:
|
||||
message = self._extract_error_message(result)
|
||||
if message and "not found" in message.lower():
|
||||
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
|
||||
if is_expected_offline_error(message):
|
||||
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
||||
return None
|
||||
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)
|
||||
return None
|
||||
except RateLimitError:
|
||||
@@ -237,7 +338,7 @@ class CivitaiClient:
|
||||
"GET",
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={"ids": query},
|
||||
params={"ids": query, "nsfw": "true"},
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
@@ -316,6 +417,25 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -346,10 +466,14 @@ class CivitaiClient:
|
||||
|
||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||
success, data = await self._make_request(
|
||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return data
|
||||
if is_expected_offline_error(data):
|
||||
return None
|
||||
logger.warning(f"Failed to fetch model data for model {model_id}")
|
||||
return None
|
||||
|
||||
@@ -358,10 +482,14 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/model-versions/{version_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
if is_expected_offline_error(version):
|
||||
return None
|
||||
|
||||
logger.warning(f"Failed to fetch version by id {version_id}")
|
||||
return None
|
||||
@@ -371,10 +499,14 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
if is_expected_offline_error(version):
|
||||
return None
|
||||
|
||||
logger.warning(f"Failed to fetch version by hash {model_hash}")
|
||||
return None
|
||||
@@ -450,20 +582,33 @@ class CivitaiClient:
|
||||
- The model version data or None if not found
|
||||
- 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:
|
||||
url = f"{self.base_url}/model-versions/{version_id}"
|
||||
|
||||
logger.debug(f"Resolving DNS for model version info: {url}")
|
||||
logger.debug("Resolving Civitai model version info: %s", url)
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
logger.debug(
|
||||
f"Successfully fetched model version info for: {version_id}"
|
||||
)
|
||||
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||
self._remove_comfy_metadata(result)
|
||||
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
|
||||
|
||||
# Handle specific error cases
|
||||
if is_expected_offline_error(result):
|
||||
return None, OFFLINE_FRIENDLY_MESSAGE
|
||||
if "not found" in str(result):
|
||||
error_msg = f"Model not found"
|
||||
logger.warning(f"Model version not found: {version_id} - {error_msg}")
|
||||
@@ -479,48 +624,67 @@ class CivitaiClient:
|
||||
logger.error(error_msg)
|
||||
return None, error_msg
|
||||
|
||||
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
||||
async def get_image_info(
|
||||
self, image_id: str, source_url: str | None = None
|
||||
) -> Optional[Dict]:
|
||||
"""Fetch image information from Civitai API
|
||||
|
||||
Args:
|
||||
image_id: The Civitai image ID
|
||||
source_url: Original image page URL. Accepted for caller compatibility;
|
||||
API requests always target ``civitai.red``.
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The image data or None if not found
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
requested_id = int(image_id)
|
||||
|
||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||
url = self._build_image_info_url(image_id)
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
if result and "items" in result and isinstance(result["items"], list):
|
||||
items = result["items"]
|
||||
|
||||
# First, try to find the item with matching ID
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("id") == requested_id:
|
||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
||||
return item
|
||||
|
||||
# No matching ID found - log warning with details about returned items
|
||||
returned_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and "id" in item
|
||||
]
|
||||
logger.warning(
|
||||
f"CivitAI API returned no matching image for requested ID {image_id}. "
|
||||
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
|
||||
f"This may indicate the image was deleted, hidden, or there is a database lag."
|
||||
if not success:
|
||||
if is_expected_offline_error(result):
|
||||
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.warning(f"No image found with ID: {image_id}")
|
||||
logger.error(
|
||||
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||
image_id,
|
||||
result,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
|
||||
if result and "items" in result and isinstance(result["items"], list):
|
||||
items = result["items"]
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("id") == requested_id:
|
||||
logger.debug(
|
||||
"Successfully fetched image info for ID %s from civitai.red",
|
||||
image_id,
|
||||
)
|
||||
return item
|
||||
|
||||
returned_ids = [
|
||||
item.get("id")
|
||||
for item in items
|
||||
if isinstance(item, dict) and "id" in item
|
||||
]
|
||||
|
||||
logger.warning(
|
||||
"CivitAI API returned no matching image for requested ID %s from civitai.red. Returned %d item(s) with IDs: %s. This may indicate the image was deleted, hidden, or there is a database lag.",
|
||||
image_id,
|
||||
len(items),
|
||||
returned_ids,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning("No image found with ID: %s", image_id)
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
@@ -533,22 +697,99 @@ class CivitaiClient:
|
||||
logger.error(error_msg)
|
||||
return None
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
"""Fetch all models for a specific Civitai user."""
|
||||
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, cursor: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch one page (up to 100 models) for a specific Civitai user.
|
||||
|
||||
Returns ``{"items": [...], "nextCursor": <str|None>}`` on success,
|
||||
or None on failure. Pass ``cursor`` (from a previous response's
|
||||
``nextCursor``) to fetch subsequent pages.
|
||||
"""
|
||||
if not username:
|
||||
return None
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"username": username,
|
||||
"nsfw": "true",
|
||||
"limit": 100,
|
||||
"sort": "Newest",
|
||||
"period": "AllTime",
|
||||
}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/models?username={username}"
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
success, result = await self._make_request(
|
||||
"GET",
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if not success:
|
||||
if is_expected_offline_error(result):
|
||||
logger.info("User model fetch skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
||||
return None
|
||||
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||
return None
|
||||
|
||||
items = result.get("items") if isinstance(result, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
items = []
|
||||
|
||||
for model in items:
|
||||
versions = model.get("modelVersions")
|
||||
@@ -557,9 +798,68 @@ class CivitaiClient:
|
||||
for version in versions:
|
||||
self._remove_comfy_metadata(version)
|
||||
|
||||
return items
|
||||
next_cursor: Optional[str] = None
|
||||
metadata = result.get("metadata") if isinstance(result, dict) else None
|
||||
if isinstance(metadata, dict):
|
||||
raw_cursor = metadata.get("nextCursor")
|
||||
if raw_cursor is not None:
|
||||
next_cursor = str(raw_cursor)
|
||||
|
||||
return {"items": items, "nextCursor": next_cursor}
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Error fetching models for %s: %s", username, exc)
|
||||
return None
|
||||
|
||||
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||
"""Best-effort lookup of a creator's published model count.
|
||||
|
||||
Uses the ``/creators`` endpoint (a contains-match query), picking the
|
||||
entry whose username matches exactly (case-insensitive). Returns None
|
||||
on any failure; never raises. Results (including None) are cached
|
||||
for ``_CREATOR_COUNT_CACHE_TTL_SECONDS``.
|
||||
"""
|
||||
if not username:
|
||||
return None
|
||||
|
||||
cache_key = username.lower()
|
||||
cached = _creator_model_count_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
cached_at, cached_count = cached
|
||||
if time.monotonic() - cached_at < _CREATOR_COUNT_CACHE_TTL_SECONDS:
|
||||
return cached_count
|
||||
|
||||
count: Optional[int] = None
|
||||
try:
|
||||
success, result = await self._make_request(
|
||||
"GET",
|
||||
f"{self.base_url}/creators",
|
||||
use_auth=True,
|
||||
params={"query": username, "limit": 10},
|
||||
)
|
||||
|
||||
if success and isinstance(result, dict):
|
||||
creators = result.get("items")
|
||||
if isinstance(creators, list):
|
||||
for creator in creators:
|
||||
if not isinstance(creator, dict):
|
||||
continue
|
||||
creator_name = creator.get("username")
|
||||
if not isinstance(creator_name, str):
|
||||
continue
|
||||
if creator_name.lower() != cache_key:
|
||||
continue
|
||||
model_count = creator.get("modelCount")
|
||||
if isinstance(model_count, (int, float)) and not isinstance(
|
||||
model_count, bool
|
||||
):
|
||||
count = int(model_count)
|
||||
break
|
||||
except Exception as exc: # best-effort only, never propagate
|
||||
logger.debug(
|
||||
"Failed to fetch creator model count for %s: %s", username, exc
|
||||
)
|
||||
|
||||
_creator_model_count_cache[cache_key] = (time.monotonic(), count)
|
||||
return count
|
||||
|
||||
204
py/services/connectivity_guard.py
Normal file
204
py/services/connectivity_guard.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""In-memory connectivity guard to suppress repeated network retries when offline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import logging
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OFFLINE_COOLDOWN_ERROR = "offline_cooldown"
|
||||
OFFLINE_FRIENDLY_MESSAGE = "Network offline, will retry automatically later"
|
||||
|
||||
|
||||
def is_offline_cooldown_error(value: Any) -> bool:
|
||||
"""Return True when a response payload represents guard short-circuit."""
|
||||
return isinstance(value, str) and value == OFFLINE_COOLDOWN_ERROR
|
||||
|
||||
|
||||
def is_expected_offline_error(value: Any) -> bool:
|
||||
"""Return True when payload is an expected offline-related result."""
|
||||
if is_offline_cooldown_error(value):
|
||||
return True
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
normalized = value.lower()
|
||||
return "network offline" in normalized or "offline" in normalized
|
||||
|
||||
|
||||
class ConnectivityGuard:
|
||||
"""Tracks network failures and gates outbound requests during cooldown."""
|
||||
|
||||
_instance: "ConnectivityGuard | None" = None
|
||||
_instance_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "ConnectivityGuard":
|
||||
async with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
self._default_destination = "__global__"
|
||||
self._destination_states: dict[str, _DestinationState] = {
|
||||
self._default_destination: _DestinationState()
|
||||
}
|
||||
self.base_backoff_seconds = 30
|
||||
self.max_backoff_seconds = 300
|
||||
self.failure_threshold = 3
|
||||
|
||||
@property
|
||||
def online(self) -> bool:
|
||||
return self._state_for_destination(None).online
|
||||
|
||||
@online.setter
|
||||
def online(self, value: bool) -> None:
|
||||
self._state_for_destination(None).online = value
|
||||
|
||||
@property
|
||||
def failure_count(self) -> int:
|
||||
return self._state_for_destination(None).failure_count
|
||||
|
||||
@failure_count.setter
|
||||
def failure_count(self, value: int) -> None:
|
||||
self._state_for_destination(None).failure_count = value
|
||||
|
||||
@property
|
||||
def cooldown_until(self) -> datetime | None:
|
||||
return self._state_for_destination(None).cooldown_until
|
||||
|
||||
@cooldown_until.setter
|
||||
def cooldown_until(self, value: datetime | None) -> None:
|
||||
self._state_for_destination(None).cooldown_until = value
|
||||
|
||||
def _now(self) -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
def _normalize_destination(self, destination: str | None) -> str:
|
||||
if destination is None or not destination.strip():
|
||||
return self._default_destination
|
||||
return destination.lower().strip()
|
||||
|
||||
def _state_for_destination(self, destination: str | None) -> "_DestinationState":
|
||||
destination_key = self._normalize_destination(destination)
|
||||
if destination_key not in self._destination_states:
|
||||
self._destination_states[destination_key] = _DestinationState()
|
||||
return self._destination_states[destination_key]
|
||||
|
||||
def in_cooldown(self, destination: str | None = None) -> bool:
|
||||
state = self._state_for_destination(destination)
|
||||
if state.cooldown_until is None:
|
||||
return False
|
||||
return self._now() < state.cooldown_until
|
||||
|
||||
def cooldown_remaining_seconds(self, destination: str | None = None) -> float:
|
||||
state = self._state_for_destination(destination)
|
||||
if state.cooldown_until is None:
|
||||
return 0.0
|
||||
return max(0.0, (state.cooldown_until - self._now()).total_seconds())
|
||||
|
||||
def should_block_request(self, destination: str | None = None) -> bool:
|
||||
return self.in_cooldown(destination)
|
||||
|
||||
def register_success(self, destination: str | None = None) -> None:
|
||||
destination_key = self._normalize_destination(destination)
|
||||
state = self._state_for_destination(destination_key)
|
||||
was_offline = (not state.online) or state.cooldown_until is not None
|
||||
state.online = True
|
||||
state.failure_count = 0
|
||||
state.cooldown_until = None
|
||||
if was_offline:
|
||||
logger.info(
|
||||
"Connectivity restored for destination '%s'; requests resumed.",
|
||||
destination_key,
|
||||
)
|
||||
|
||||
def register_network_failure(
|
||||
self, exc: Exception, destination: str | None = None
|
||||
) -> None:
|
||||
destination_key = self._normalize_destination(destination)
|
||||
state = self._state_for_destination(destination_key)
|
||||
state.online = False
|
||||
state.failure_count += 1
|
||||
|
||||
if state.failure_count < self.failure_threshold:
|
||||
logger.debug(
|
||||
"Network failure tracked for destination '%s' (%d/%d): %s",
|
||||
destination_key,
|
||||
state.failure_count,
|
||||
self.failure_threshold,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
retry_step = state.failure_count - self.failure_threshold
|
||||
backoff = min(
|
||||
self.max_backoff_seconds,
|
||||
self.base_backoff_seconds * (2**retry_step),
|
||||
)
|
||||
should_log_warning = not self.in_cooldown(destination_key)
|
||||
state.cooldown_until = self._now() + timedelta(seconds=backoff)
|
||||
|
||||
if should_log_warning:
|
||||
logger.warning(
|
||||
"Connectivity offline for destination '%s'; enter cooldown for %ss after %d network failures.",
|
||||
destination_key,
|
||||
int(backoff),
|
||||
state.failure_count,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Cooldown still active for destination '%s'; failure_count=%d, backoff=%ss.",
|
||||
destination_key,
|
||||
state.failure_count,
|
||||
int(backoff),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_network_unreachable_error(exc: Exception) -> bool:
|
||||
"""Return whether the exception should count as connectivity failure."""
|
||||
if isinstance(exc, asyncio.CancelledError):
|
||||
return False
|
||||
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
asyncio.TimeoutError,
|
||||
TimeoutError,
|
||||
ConnectionRefusedError,
|
||||
socket.gaierror,
|
||||
aiohttp.ServerTimeoutError,
|
||||
aiohttp.ConnectionTimeoutError,
|
||||
aiohttp.ClientConnectorError,
|
||||
aiohttp.ClientConnectionError,
|
||||
),
|
||||
):
|
||||
return True
|
||||
|
||||
if isinstance(exc, OSError) and exc.errno in {
|
||||
errno.ENETUNREACH,
|
||||
errno.EHOSTUNREACH,
|
||||
errno.ETIMEDOUT,
|
||||
errno.ECONNREFUSED,
|
||||
}:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DestinationState:
|
||||
online: bool = True
|
||||
failure_count: int = 0
|
||||
cooldown_until: datetime | None = None
|
||||
@@ -110,6 +110,23 @@ class DownloadCoordinator:
|
||||
|
||||
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]:
|
||||
"""Pause an active download and notify listeners."""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
938
py/services/download_queue_service.py
Normal file
938
py/services/download_queue_service.py
Normal file
@@ -0,0 +1,938 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_database_path() -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
history_dir = os.path.join(base_dir, "download_history")
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
return os.path.join(history_dir, "download_queue.sqlite")
|
||||
|
||||
|
||||
class DownloadQueueService:
|
||||
"""Persistent download queue and history manager backed by SQLite.
|
||||
|
||||
Provides a singleton interface for managing a download queue and
|
||||
corresponding history table, both stored in a single SQLite database
|
||||
under the cache directory.
|
||||
"""
|
||||
|
||||
_instance: Optional[DownloadQueueService] = None
|
||||
_class_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
_SCHEMA_TABLES = """
|
||||
CREATE TABLE IF NOT EXISTS download_queue (
|
||||
download_id TEXT PRIMARY KEY,
|
||||
model_id INTEGER,
|
||||
model_version_id INTEGER,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
source TEXT,
|
||||
file_params TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
priority INTEGER DEFAULT 0,
|
||||
progress INTEGER DEFAULT 0,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
bytes_per_second REAL DEFAULT 0.0,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
added_at REAL NOT NULL,
|
||||
started_at REAL,
|
||||
completed_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dq_status ON download_queue(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dq_added ON download_queue(added_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_id TEXT,
|
||||
model_id INTEGER,
|
||||
model_version_id INTEGER,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
completed_at REAL NOT NULL,
|
||||
is_already_exists INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
||||
"""
|
||||
|
||||
_CREATE_UNIQUE_INDEX = """
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
|
||||
ON download_history(download_id) WHERE download_id IS NOT NULL;
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> DownloadQueueService:
|
||||
"""Return the singleton instance, creating it if necessary."""
|
||||
async with cls._class_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
await cls._instance.deduplicate()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None) -> None:
|
||||
self._db_path = db_path or _resolve_database_path()
|
||||
self._lock = asyncio.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._schema_initialized = False
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
|
||||
def _ensure_directory(self) -> None:
|
||||
directory = os.path.dirname(self._db_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
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:
|
||||
if self._schema_initialized:
|
||||
return
|
||||
with self._connect() as conn:
|
||||
conn.executescript(self._SCHEMA_TABLES)
|
||||
|
||||
# Creating the unique index on download_history.download_id can
|
||||
# fail if pre-existing rows have duplicate values (e.g. from a
|
||||
# previous version that lacked the index). Deduplicate first so
|
||||
# that the migration does not crash on startup.
|
||||
if not self._index_exists(conn, "idx_dh_download_id"):
|
||||
self._remove_duplicate_download_ids(conn)
|
||||
conn.executescript(self._CREATE_UNIQUE_INDEX)
|
||||
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
|
||||
@staticmethod
|
||||
def _index_exists(conn: sqlite3.Connection, name: str) -> bool:
|
||||
return conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
|
||||
(name,),
|
||||
).fetchone() is not None
|
||||
|
||||
@staticmethod
|
||||
def _remove_duplicate_download_ids(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM download_history
|
||||
WHERE download_id IS NOT NULL
|
||||
GROUP BY download_id
|
||||
)
|
||||
AND download_id IS NOT NULL
|
||||
""")
|
||||
|
||||
def get_database_path(self) -> str:
|
||||
"""Return the resolved database file 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queue methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def add_to_queue(
|
||||
self,
|
||||
download_id: str,
|
||||
model_id: Optional[int] = None,
|
||||
model_version_id: Optional[int] = None,
|
||||
model_name: str = "",
|
||||
version_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
source: Optional[str] = None,
|
||||
file_params: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a new download into the queue.
|
||||
|
||||
Returns the inserted row as a dict (or an empty dict if the
|
||||
download_id already exists in the queue or has a terminal
|
||||
record in history).
|
||||
"""
|
||||
now = time.time()
|
||||
file_params_json = json.dumps(file_params) if file_params is not None else None
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
# Reject download_ids that already have a terminal record in history.
|
||||
history_row = conn.execute(
|
||||
"SELECT 1 FROM download_history WHERE download_id = ? LIMIT 1",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if history_row is not None:
|
||||
return {}
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
download_id,
|
||||
model_id,
|
||||
model_version_id,
|
||||
model_name,
|
||||
version_name,
|
||||
thumbnail_url,
|
||||
source,
|
||||
file_params_json,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(row) if row else {}
|
||||
|
||||
async def get_queue(self) -> list[dict[str, Any]]:
|
||||
"""Return all items in the queue ordered by priority then added time."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_queue ORDER BY priority DESC, added_at ASC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def get_queued_count(self) -> int:
|
||||
"""Return the number of items with status ``'queued'``."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_queue WHERE status = 'queued'"
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
download_id: str,
|
||||
status: str,
|
||||
**extra: Any,
|
||||
) -> bool:
|
||||
"""Update the status and/or extra fields of a queue item.
|
||||
|
||||
Accepted extra keyword arguments:
|
||||
``progress``, ``error``, ``file_path``, ``bytes_downloaded``,
|
||||
``total_bytes``, ``bytes_per_second``.
|
||||
|
||||
Returns ``True`` if a row was updated.
|
||||
"""
|
||||
allowed_extra = {
|
||||
"progress",
|
||||
"error",
|
||||
"file_path",
|
||||
"bytes_downloaded",
|
||||
"total_bytes",
|
||||
"bytes_per_second",
|
||||
}
|
||||
|
||||
set_clauses: list[str] = ["status = ?"]
|
||||
params: list[Any] = [status]
|
||||
now = time.time()
|
||||
|
||||
if status in ("downloading",):
|
||||
set_clauses.append("started_at = COALESCE(started_at, ?)")
|
||||
params.append(now)
|
||||
if status in ("completed", "failed", "canceled"):
|
||||
set_clauses.append("completed_at = ?")
|
||||
params.append(now)
|
||||
|
||||
for key, value in extra.items():
|
||||
if key in allowed_extra:
|
||||
set_clauses.append(f"{key} = ?")
|
||||
params.append(value)
|
||||
|
||||
params.append(download_id)
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
f"UPDATE download_queue SET {', '.join(set_clauses)} "
|
||||
"WHERE download_id = ?",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def remove_from_queue(self, download_id: str) -> bool:
|
||||
"""Remove a single item from the queue by download_id.
|
||||
|
||||
Returns ``True`` if a row was deleted.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def move_to_top(self, download_id: str) -> bool:
|
||||
"""Move an item to the front of the queue (highest priority).
|
||||
|
||||
Returns ``True`` if the item was found and updated.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT priority FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
max_row = conn.execute(
|
||||
"SELECT MAX(priority) AS mx FROM download_queue"
|
||||
).fetchone()
|
||||
max_priority: int = max_row["mx"] if max_row["mx"] is not None else 0
|
||||
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
|
||||
(max_priority + 1, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
async def move_to_end(self, download_id: str) -> bool:
|
||||
"""Move an item to the end of the queue (lowest priority).
|
||||
|
||||
Returns ``True`` if the item was found and updated.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT priority FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
min_row = conn.execute(
|
||||
"SELECT MIN(priority) AS mn FROM download_queue"
|
||||
).fetchone()
|
||||
min_priority: int = min_row["mn"] if min_row["mn"] is not None else 0
|
||||
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
|
||||
(min_priority - 1, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
async def clear_queue(self, status_filter: Optional[str] = None) -> int:
|
||||
"""Remove items from the queue.
|
||||
|
||||
When *status_filter* is provided only items with that status are
|
||||
deleted. Returns the number of deleted rows.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if status_filter is not None:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_queue WHERE status = ?",
|
||||
(status_filter,),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute("DELETE FROM download_queue")
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def complete_download(
|
||||
self,
|
||||
download_id: str,
|
||||
status: str = "completed",
|
||||
error: Optional[str] = None,
|
||||
file_path: Optional[str] = None,
|
||||
bytes_downloaded: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
completed_at: Optional[float] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Atomically move a download from the queue into the history table.
|
||||
|
||||
Looks up the queue record by ``download_id``, deletes it from the
|
||||
queue, and inserts a corresponding history entry with the given
|
||||
terminal status (``completed``, ``failed``, or ``canceled``).
|
||||
|
||||
When *completed_at* is provided it is used as the completion
|
||||
timestamp; otherwise ``time.time()`` is used.
|
||||
|
||||
Returns the original queue record (before deletion) on success,
|
||||
or ``None`` if the download was not found in the queue.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
now = completed_at if completed_at is not None else time.time()
|
||||
conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["download_id"],
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
|
||||
async def pop_next_download(self) -> Optional[dict[str, Any]]:
|
||||
"""Atomically fetch and mark the next queued item as ``downloading``.
|
||||
|
||||
The item with the highest priority (and earliest ``added_at``
|
||||
among ties) whose status is ``'queued'`` is selected, set to
|
||||
``'downloading'``, and returned as a dict. Returns ``None`` if
|
||||
the queue is empty.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM download_queue
|
||||
WHERE status = 'queued'
|
||||
ORDER BY priority DESC, added_at ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
download_id = row["download_id"]
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET status = 'downloading', "
|
||||
"started_at = COALESCE(started_at, ?) "
|
||||
"WHERE download_id = ?",
|
||||
(now, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
updated = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(updated) if updated else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# History methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def add_to_history(
|
||||
self,
|
||||
download_id: Optional[str] = None,
|
||||
model_id: Optional[int] = None,
|
||||
model_version_id: Optional[int] = None,
|
||||
model_name: str = "",
|
||||
version_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
status: str = "completed",
|
||||
error: Optional[str] = None,
|
||||
file_path: Optional[str] = None,
|
||||
bytes_downloaded: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
is_already_exists: int = 0,
|
||||
) -> int:
|
||||
"""Insert a record into the download history.
|
||||
|
||||
Returns the ``id`` (AUTOINCREMENT primary key) of the newly
|
||||
inserted row.
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at, is_already_exists
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
download_id,
|
||||
model_id,
|
||||
model_version_id,
|
||||
model_name,
|
||||
version_name,
|
||||
thumbnail_url,
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
now,
|
||||
is_already_exists,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid or 0
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status_filter: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a page of download history entries.
|
||||
|
||||
Returns a dict with keys ``items``, ``total``, ``limit``, and
|
||||
``offset``.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
if status_filter is not None:
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
|
||||
(status_filter,),
|
||||
).fetchone()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history WHERE status = ? "
|
||||
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
|
||||
(status_filter, limit, offset),
|
||||
).fetchall()
|
||||
else:
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history"
|
||||
).fetchone()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history "
|
||||
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [dict(row) for row in rows],
|
||||
"total": count_row["cnt"] if count_row else 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
async def delete_history_item(
|
||||
self, id: Optional[int] = None, download_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete a single history entry by *download_id* (preferred) or *id*.
|
||||
|
||||
Returns ``True`` if a row was deleted.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if download_id:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_history WHERE download_id = ?",
|
||||
(download_id,),
|
||||
)
|
||||
elif id is not None:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(id,),
|
||||
)
|
||||
else:
|
||||
return False
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def clear_history(
|
||||
self,
|
||||
status_filter: Optional[str] = None,
|
||||
before_timestamp: Optional[float] = None,
|
||||
) -> int:
|
||||
"""Remove history entries matching the optional filters.
|
||||
|
||||
Both ``status_filter`` and ``before_timestamp`` can be combined
|
||||
(AND logic). Returns the number of deleted rows.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if status_filter is not None:
|
||||
clauses.append("status = ?")
|
||||
params.append(status_filter)
|
||||
if before_timestamp is not None:
|
||||
clauses.append("completed_at < ?")
|
||||
params.append(before_timestamp)
|
||||
|
||||
where = ""
|
||||
if clauses:
|
||||
where = " WHERE " + " AND ".join(clauses)
|
||||
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM download_history{where}",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def get_history_count(self, status_filter: Optional[str] = None) -> int:
|
||||
"""Return the number of history entries, optionally filtered by status."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if status_filter is not None:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
|
||||
(status_filter,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history"
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Retry
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def retry_from_history(
|
||||
self,
|
||||
item_id: Optional[int] = None,
|
||||
download_id: Optional[str] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Re-queue a failed or canceled download from history.
|
||||
|
||||
Looks up the history record by *download_id* (preferred) or
|
||||
*item_id*. If the status is ``failed`` or ``canceled`` a new
|
||||
queue entry is created with the same model metadata and a fresh
|
||||
download id, and the original history entry is **deleted** to
|
||||
prevent exponential growth when the retried item is later
|
||||
canceled or fails again and re-retried.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if download_id:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_history WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
elif item_id is not None:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_history WHERE id = ?",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
else:
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
status = str(row["status"])
|
||||
if status not in ("failed", "canceled"):
|
||||
return None
|
||||
|
||||
import uuid
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
conn.commit()
|
||||
queued = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(new_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(queued) if queued else None
|
||||
|
||||
async def retry_all_failed(self) -> int:
|
||||
"""Re-queue all failed and canceled downloads from history.
|
||||
|
||||
Each history entry is **deleted** after being re-queued so that
|
||||
repeated retry-all calls do not cause exponential growth.
|
||||
|
||||
Returns the number of items that were re-queued.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history WHERE status IN ('failed', 'canceled')"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
import uuid
|
||||
|
||||
now = time.time()
|
||||
count = 0
|
||||
for row in rows:
|
||||
new_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
count += 1
|
||||
conn.commit()
|
||||
|
||||
return count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_stats(self) -> dict[str, int]:
|
||||
"""Return aggregate counts across both tables.
|
||||
|
||||
Returns a dict with keys ``queued``, ``downloading``, ``paused``
|
||||
(all from the queue table) and ``completed``, ``failed``,
|
||||
``canceled`` (all from the history table).
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
queue_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS cnt FROM download_queue GROUP BY status"
|
||||
).fetchall()
|
||||
queue_stats: dict[str, int] = {}
|
||||
for row in queue_rows:
|
||||
queue_stats[str(row["status"])] = row["cnt"]
|
||||
|
||||
history_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS cnt FROM download_history GROUP BY status"
|
||||
).fetchall()
|
||||
history_stats: dict[str, int] = {}
|
||||
for row in history_rows:
|
||||
history_stats[str(row["status"])] = row["cnt"]
|
||||
|
||||
return {
|
||||
"queued": queue_stats.get("queued", 0),
|
||||
"downloading": queue_stats.get("downloading", 0),
|
||||
"paused": queue_stats.get("paused", 0),
|
||||
"completed": history_stats.get("completed", 0),
|
||||
"failed": history_stats.get("failed", 0),
|
||||
"canceled": history_stats.get("canceled", 0),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Deduplication (one-time cleanup for bug #980)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def deduplicate(self) -> dict[str, int]:
|
||||
"""Remove duplicate entries caused by the retry-amplification bug.
|
||||
|
||||
The bug (issue #980) caused the same download to appear N times in
|
||||
both the queue and history tables when ``retry_all_failed`` was
|
||||
called repeatedly without deleting the original history rows.
|
||||
|
||||
This method is called **once** when the singleton is first created.
|
||||
It is idempotent — after the first run there will be no duplicates
|
||||
to remove, so subsequent calls are a no-op.
|
||||
|
||||
Returns a dict with the count of removed rows per table.
|
||||
"""
|
||||
result: dict[str, int] = {
|
||||
"removed_history": 0,
|
||||
"removed_queue": 0,
|
||||
"removed_orphan_queue": 0,
|
||||
}
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
# 1. History: for each (model_id, model_version_id, status) triplet
|
||||
# keep only the row with the highest id (most recently inserted).
|
||||
conn.execute("""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM download_history
|
||||
GROUP BY model_id, model_version_id, status
|
||||
)
|
||||
""")
|
||||
result["removed_history"] = conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 2. Cross-status dedup: for each (model_id, model_version_id),
|
||||
# keep only the entry with the highest-priority terminal status.
|
||||
# Priority: completed (3) > failed (2) > canceled (1).
|
||||
# This prevents the same model version from having both a
|
||||
# 'failed' and a 'canceled' entry (or a 'completed' alongside
|
||||
# either) after the bug-created duplicates are removed.
|
||||
conn.execute("""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT dh.id
|
||||
FROM download_history dh
|
||||
INNER JOIN (
|
||||
SELECT model_id, model_version_id,
|
||||
MAX(CASE status
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'canceled' THEN 1
|
||||
ELSE 0
|
||||
END) AS best_prio
|
||||
FROM download_history
|
||||
GROUP BY model_id, model_version_id
|
||||
) best
|
||||
ON dh.model_id = best.model_id
|
||||
AND dh.model_version_id = best.model_version_id
|
||||
AND CASE dh.status
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'canceled' THEN 1
|
||||
ELSE 0
|
||||
END = best.best_prio
|
||||
GROUP BY dh.model_id, dh.model_version_id
|
||||
HAVING dh.id = MAX(dh.id)
|
||||
)
|
||||
""")
|
||||
result["removed_history"] += conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 3. Queue: for each (model_id, model_version_id) keep only the
|
||||
# row with the latest added_at (most recently enqueued).
|
||||
conn.execute("""
|
||||
DELETE FROM download_queue
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM download_queue
|
||||
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||
GROUP BY model_id, model_version_id
|
||||
)
|
||||
AND status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||
""")
|
||||
result["removed_queue"] = conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 4. Remove orphaned queue entries — items that were re-queued
|
||||
# (source='retry') but whose model version already has a
|
||||
# terminal history entry. These are artifacts of the buggy
|
||||
# retry cycle that were never cleaned up.
|
||||
conn.execute("""
|
||||
DELETE FROM download_queue
|
||||
WHERE source = 'retry'
|
||||
AND (model_id, model_version_id) IN (
|
||||
SELECT model_id, model_version_id
|
||||
FROM download_history
|
||||
WHERE status IN ('failed', 'canceled')
|
||||
)
|
||||
AND status IN ('queued', 'waiting')
|
||||
""")
|
||||
result["removed_orphan_queue"] = conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
conn.commit()
|
||||
|
||||
logger.info(
|
||||
"Deduplicate: removed %s history rows, %s queue rows, "
|
||||
"%s orphaned queue rows",
|
||||
result["removed_history"],
|
||||
result["removed_queue"],
|
||||
result["removed_orphan_queue"],
|
||||
)
|
||||
return result
|
||||
@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
|
||||
self._db_path = db_path or _resolve_database_path()
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._lock = asyncio.Lock()
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
self._schema_initialized = False
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
|
||||
conn.row_factory = sqlite3.Row
|
||||
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:
|
||||
if self._schema_initialized:
|
||||
return
|
||||
@@ -89,6 +96,21 @@ class DownloadedVersionHistoryService:
|
||||
def get_database_path(self) -> str:
|
||||
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:
|
||||
try:
|
||||
value = self._settings.get_active_library_name()
|
||||
@@ -116,33 +138,33 @@ class DownloadedVersionHistoryService:
|
||||
timestamp = time.time()
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
normalized_model_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
source,
|
||||
file_path,
|
||||
active_library_name,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
normalized_model_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
source,
|
||||
file_path,
|
||||
active_library_name,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_downloaded_bulk(
|
||||
self,
|
||||
@@ -180,26 +202,26 @@ class DownloadedVersionHistoryService:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
conn.commit()
|
||||
conn = self._get_conn()
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
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_version_id = _normalize_int(version_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
@@ -208,28 +230,28 @@ class DownloadedVersionHistoryService:
|
||||
timestamp = time.time()
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 1
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
self._get_active_library_name(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 1
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
self._get_active_library_name(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
@@ -238,15 +260,15 @@ class DownloadedVersionHistoryService:
|
||||
return False
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT is_deleted_override
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
).fetchone()
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT is_deleted_override
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
).fetchone()
|
||||
return bool(row) and not bool(row["is_deleted_override"])
|
||||
|
||||
async def get_downloaded_version_ids(
|
||||
@@ -258,16 +280,16 @@ class DownloadedVersionHistoryService:
|
||||
return []
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||
ORDER BY version_id ASC
|
||||
""",
|
||||
(normalized_type, normalized_model_id),
|
||||
).fetchall()
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||
ORDER BY version_id ASC
|
||||
""",
|
||||
(normalized_type, normalized_model_id),
|
||||
).fetchall()
|
||||
return [int(row["version_id"]) for row in rows]
|
||||
|
||||
async def get_downloaded_version_ids_bulk(
|
||||
@@ -291,17 +313,17 @@ class DownloadedVersionHistoryService:
|
||||
params: list[object] = [normalized_type, *normalized_model_ids]
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT model_id, version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ?
|
||||
AND model_id IN ({placeholders})
|
||||
AND is_deleted_override = 0
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT model_id, version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ?
|
||||
AND model_id IN ({placeholders})
|
||||
AND is_deleted_override = 0
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
result: dict[int, set[int]] = {}
|
||||
for row in rows:
|
||||
|
||||
@@ -13,18 +13,63 @@ This module provides a centralized download service with:
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import ssl
|
||||
import aiohttp
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from .connectivity_guard import (
|
||||
OFFLINE_COOLDOWN_ERROR,
|
||||
OFFLINE_FRIENDLY_MESSAGE,
|
||||
ConnectivityGuard,
|
||||
)
|
||||
from .errors import RateLimitError
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _parse_retry_after(value: str) -> int:
|
||||
"""Parse a Retry-After header value into seconds.
|
||||
|
||||
Supports both integer seconds and HTTP-date formats.
|
||||
Returns a default of 60 seconds on invalid/missing input.
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
return 60
|
||||
|
||||
value = value.strip()
|
||||
try:
|
||||
return max(1, int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
parsed = parsedate_to_datetime(value)
|
||||
now = datetime.now().astimezone()
|
||||
delta = (parsed - now).total_seconds()
|
||||
return max(1, int(delta))
|
||||
except (ValueError, OverflowError, OSError):
|
||||
return 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadProgress:
|
||||
"""Snapshot of a download transfer at a moment in time."""
|
||||
@@ -138,7 +183,7 @@ class Downloader:
|
||||
self.chunk_size = (
|
||||
16 * 1024 * 1024
|
||||
) # 16MB chunks to balance I/O reduction and memory usage
|
||||
self.max_retries = 5
|
||||
self.max_retries = self._resolve_max_retries()
|
||||
self.base_delay = 2.0 # Base delay for exponential backoff
|
||||
self.session_timeout = 300 # 5 minutes
|
||||
self.stall_timeout = self._resolve_stall_timeout()
|
||||
@@ -192,6 +237,18 @@ class Downloader:
|
||||
|
||||
return max(30.0, timeout_value)
|
||||
|
||||
def _resolve_max_retries(self) -> int:
|
||||
"""Determine max retry count from environment while preserving defaults."""
|
||||
default_retries = 5
|
||||
raw_value = os.environ.get("COMFYUI_DOWNLOAD_MAX_RETRIES")
|
||||
|
||||
try:
|
||||
retries = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
retries = default_retries
|
||||
|
||||
return max(0, retries)
|
||||
|
||||
def _should_refresh_session(self) -> bool:
|
||||
"""Check if session should be refreshed"""
|
||||
if self._session is None:
|
||||
@@ -213,17 +270,19 @@ class Downloader:
|
||||
|
||||
Note: This is private and caller MUST hold self._session_lock.
|
||||
"""
|
||||
# Close existing session if any
|
||||
if self._session is not None:
|
||||
try:
|
||||
await self._session.close()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(f"Error closing previous session: {e}")
|
||||
finally:
|
||||
self._session = None
|
||||
# Snapshot and clear old session reference before creating the new
|
||||
# one. This ensures self._session is always valid (or None, which
|
||||
# triggers a fresh creation) and avoids a race where concurrent
|
||||
# requests hold a reference to a session whose connector has been
|
||||
# torn down by a premature close() call — the root cause of the
|
||||
# intermittent "NoneType has no attribute connect" crash.
|
||||
old_session = self._session
|
||||
self._session = None
|
||||
|
||||
# Check for app-level proxy settings
|
||||
proxy_url = None
|
||||
proxy_url = None # http(s) proxy, passed via the per-request `proxy=` kwarg
|
||||
socks_proxy_url = None # SOCKS proxy, handled via aiohttp-socks connector
|
||||
app_proxy_active = False
|
||||
settings_manager = get_settings_manager()
|
||||
if settings_manager.get("proxy_enabled", False):
|
||||
proxy_host = settings_manager.get("proxy_host", "").strip()
|
||||
@@ -235,9 +294,19 @@ class Downloader:
|
||||
if proxy_host and proxy_port:
|
||||
# Build proxy URL
|
||||
if proxy_username and proxy_password:
|
||||
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
|
||||
full_proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
|
||||
else:
|
||||
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
full_proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
|
||||
app_proxy_active = True
|
||||
# aiohttp cannot tunnel SOCKS via the per-request `proxy=` kwarg
|
||||
# (it would send HTTP to the SOCKS port and fail parsing the
|
||||
# SOCKS handshake reply). SOCKS must be handled by an
|
||||
# aiohttp-socks ProxyConnector instead.
|
||||
if proxy_type.startswith("socks"):
|
||||
socks_proxy_url = full_proxy_url
|
||||
else:
|
||||
proxy_url = full_proxy_url
|
||||
|
||||
logger.debug(
|
||||
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
|
||||
@@ -247,14 +316,41 @@ class Downloader:
|
||||
logger.debug(
|
||||
"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
|
||||
connector = aiohttp.TCPConnector(
|
||||
ssl=True,
|
||||
connector_kwargs = dict(
|
||||
ssl=ssl_context,
|
||||
limit=8, # Concurrent connections
|
||||
ttl_dns_cache=300, # DNS cache timeout
|
||||
force_close=False, # Keep connections for reuse
|
||||
enable_cleanup_closed=True,
|
||||
)
|
||||
if socks_proxy_url:
|
||||
# Route all traffic through the SOCKS proxy via aiohttp-socks. The
|
||||
# connector tunnels every connection, so no per-request `proxy=` is
|
||||
# used (and must not be — see self._proxy_url below).
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"A SOCKS proxy is configured but the 'aiohttp-socks' package "
|
||||
"is not installed. Install it with: pip install aiohttp-socks"
|
||||
) from e
|
||||
connector = ProxyConnector.from_url(socks_proxy_url, **connector_kwargs)
|
||||
else:
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
|
||||
# Configure timeout parameters
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
@@ -265,15 +361,24 @@ class Downloader:
|
||||
|
||||
self._session = aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
trust_env=proxy_url
|
||||
is None, # Only use system proxy if no app-level proxy is set
|
||||
# Only fall back to system/env proxy when no app-level proxy is active
|
||||
trust_env=not app_proxy_active,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Store proxy URL for use in requests
|
||||
# Store proxy URL for per-request use. Stays None for SOCKS because the
|
||||
# ProxyConnector already tunnels everything; passing proxy= for SOCKS
|
||||
# would re-trigger the original aiohttp parse error.
|
||||
self._proxy_url = proxy_url
|
||||
self._session_created_at = datetime.now()
|
||||
|
||||
# Close the previous session now that the replacement is live.
|
||||
if old_session is not None:
|
||||
try:
|
||||
await old_session.close()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(f"Error closing previous session: {e}")
|
||||
|
||||
logger.debug(
|
||||
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
|
||||
bool(proxy_url),
|
||||
@@ -334,6 +439,7 @@ class Downloader:
|
||||
logger.info(f"Resuming download from offset {resume_offset} bytes")
|
||||
|
||||
total_size = 0
|
||||
range_redirect_retry_urls: set[str] = set()
|
||||
|
||||
while retry_count <= self.max_retries:
|
||||
try:
|
||||
@@ -372,6 +478,23 @@ class Downloader:
|
||||
if response.status == 200:
|
||||
# Full content response
|
||||
if resume_offset > 0:
|
||||
redirected_url = str(response.url)
|
||||
if (
|
||||
allow_resume
|
||||
and response.history
|
||||
and redirected_url
|
||||
and redirected_url != url
|
||||
and redirected_url not in range_redirect_retry_urls
|
||||
):
|
||||
range_redirect_retry_urls.add(redirected_url)
|
||||
logger.info(
|
||||
"Range request was not honored after redirect; retrying final URL directly: %s",
|
||||
redirected_url,
|
||||
)
|
||||
url = redirected_url
|
||||
response.release()
|
||||
continue
|
||||
|
||||
# Server doesn't support ranges, restart from beginning
|
||||
logger.warning(
|
||||
"Server doesn't support range requests, restarting download"
|
||||
@@ -571,37 +694,53 @@ class Downloader:
|
||||
expected_size = total_size if total_size > 0 else None
|
||||
|
||||
integrity_error: Optional[str] = None
|
||||
resumable_incomplete = False
|
||||
if final_size <= 0:
|
||||
integrity_error = "Downloaded file is empty"
|
||||
elif expected_size is not None and final_size != expected_size:
|
||||
integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
|
||||
resumable_incomplete = (
|
||||
allow_resume
|
||||
and part_path != save_path
|
||||
and final_size > 0
|
||||
and final_size < expected_size
|
||||
)
|
||||
|
||||
if integrity_error is not None:
|
||||
logger.error(
|
||||
log_fn = logger.warning if resumable_incomplete else logger.error
|
||||
log_fn(
|
||||
"Download integrity check failed for %s: %s",
|
||||
save_path,
|
||||
integrity_error,
|
||||
)
|
||||
|
||||
# Remove the corrupted payload so future attempts start fresh
|
||||
if os.path.exists(part_path):
|
||||
try:
|
||||
os.remove(part_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete corrupted download %s: %s",
|
||||
part_path,
|
||||
remove_error,
|
||||
)
|
||||
if part_path != save_path and os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete target file %s after integrity error: %s",
|
||||
save_path,
|
||||
remove_error,
|
||||
)
|
||||
if resumable_incomplete:
|
||||
logger.info(
|
||||
"Preserving incomplete download for resume: %s (%s/%s bytes)",
|
||||
part_path,
|
||||
final_size,
|
||||
expected_size,
|
||||
)
|
||||
else:
|
||||
# Remove corrupted payloads that cannot be safely resumed.
|
||||
if os.path.exists(part_path):
|
||||
try:
|
||||
os.remove(part_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete corrupted download %s: %s",
|
||||
part_path,
|
||||
remove_error,
|
||||
)
|
||||
if part_path != save_path and os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete target file %s after integrity error: %s",
|
||||
save_path,
|
||||
remove_error,
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
if retry_count <= self.max_retries:
|
||||
@@ -611,9 +750,18 @@ class Downloader:
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
resume_offset = 0
|
||||
total_size = 0
|
||||
await self._create_session()
|
||||
if resumable_incomplete and os.path.exists(part_path):
|
||||
resume_offset = os.path.getsize(part_path)
|
||||
total_size = expected_size or 0
|
||||
logger.info(
|
||||
"Will resume incomplete download from byte %s",
|
||||
resume_offset,
|
||||
)
|
||||
else:
|
||||
resume_offset = 0
|
||||
total_size = 0
|
||||
async with self._session_lock:
|
||||
await self._create_session()
|
||||
continue
|
||||
|
||||
return False, integrity_error
|
||||
@@ -676,6 +824,17 @@ class Downloader:
|
||||
DownloadRestartRequested,
|
||||
) as e:
|
||||
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(
|
||||
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
||||
)
|
||||
@@ -692,7 +851,8 @@ class Downloader:
|
||||
logger.info(f"Will resume from byte {resume_offset}")
|
||||
|
||||
# Refresh session to get new connection
|
||||
await self._create_session()
|
||||
async with self._session_lock:
|
||||
await self._create_session()
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Max retries exceeded for download: {e}")
|
||||
@@ -743,6 +903,11 @@ class Downloader:
|
||||
Returns:
|
||||
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
|
||||
"""
|
||||
guard = await ConnectivityGuard.get_instance()
|
||||
destination = self._guard_destination(url)
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_FRIENDLY_MESSAGE, None
|
||||
|
||||
try:
|
||||
session = await self.session
|
||||
# Debug log for proxy mode at request time
|
||||
@@ -765,6 +930,7 @@ class Downloader:
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
content = await response.read()
|
||||
guard.register_success(destination)
|
||||
if return_headers:
|
||||
return True, content, dict(response.headers)
|
||||
else:
|
||||
@@ -778,11 +944,30 @@ class Downloader:
|
||||
elif response.status == 404:
|
||||
error_msg = "File not found"
|
||||
return False, error_msg, None
|
||||
elif response.status == 429:
|
||||
raw_retry_after = response.headers.get("Retry-After")
|
||||
retry_after = _parse_retry_after(raw_retry_after or "")
|
||||
if raw_retry_after:
|
||||
logger.warning(
|
||||
"Rate limited (429) for %s, Retry-After: %ss", url, retry_after
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Rate limited (429) for %s, no Retry-After header; defaulting to %ss",
|
||||
url, retry_after,
|
||||
)
|
||||
return False, f"Rate limited (429), retry after {retry_after}s", None
|
||||
else:
|
||||
error_msg = f"Download failed with status {response.status}"
|
||||
return False, error_msg, None
|
||||
|
||||
except Exception as e:
|
||||
if guard.is_network_unreachable_error(e):
|
||||
guard.register_network_failure(e, destination)
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_FRIENDLY_MESSAGE, None
|
||||
logger.debug("Network unavailable during memory download: %s", e)
|
||||
return False, str(e), None
|
||||
logger.error(f"Error downloading to memory from {url}: {e}")
|
||||
return False, str(e), None
|
||||
|
||||
@@ -803,6 +988,11 @@ class Downloader:
|
||||
Returns:
|
||||
Tuple[bool, Union[Dict, str]]: (success, headers dict or error message)
|
||||
"""
|
||||
guard = await ConnectivityGuard.get_instance()
|
||||
destination = self._guard_destination(url)
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_COOLDOWN_ERROR
|
||||
|
||||
try:
|
||||
session = await self.session
|
||||
# Debug log for proxy mode at request time
|
||||
@@ -824,11 +1014,18 @@ class Downloader:
|
||||
url, headers=headers, proxy=self.proxy_url
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
guard.register_success(destination)
|
||||
return True, dict(response.headers)
|
||||
else:
|
||||
return False, f"Head request failed with status {response.status}"
|
||||
|
||||
except Exception as e:
|
||||
if guard.is_network_unreachable_error(e):
|
||||
guard.register_network_failure(e, destination)
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_COOLDOWN_ERROR
|
||||
logger.debug("Network unavailable during header probe: %s", e)
|
||||
return False, str(e)
|
||||
logger.error(f"Error getting headers from {url}: {e}")
|
||||
return False, str(e)
|
||||
|
||||
@@ -853,6 +1050,11 @@ class Downloader:
|
||||
Returns:
|
||||
Tuple[bool, Union[Dict, str]]: (success, response data or error message)
|
||||
"""
|
||||
guard = await ConnectivityGuard.get_instance()
|
||||
destination = self._guard_destination(url)
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_COOLDOWN_ERROR
|
||||
|
||||
try:
|
||||
session = await self.session
|
||||
# Debug log for proxy mode at request time
|
||||
@@ -876,6 +1078,7 @@ class Downloader:
|
||||
method, url, headers=headers, **kwargs
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
guard.register_success(destination)
|
||||
# Try to parse as JSON, fall back to text
|
||||
try:
|
||||
data = await response.json()
|
||||
@@ -906,6 +1109,12 @@ class Downloader:
|
||||
return False, f"Request failed with status {response.status}"
|
||||
|
||||
except Exception as e:
|
||||
if guard.is_network_unreachable_error(e):
|
||||
guard.register_network_failure(e, destination)
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_COOLDOWN_ERROR
|
||||
logger.debug("Network unavailable for %s %s: %s", method, url, e)
|
||||
return False, str(e)
|
||||
logger.error(f"Error making {method} request to {url}: {e}")
|
||||
return False, str(e)
|
||||
|
||||
@@ -956,6 +1165,14 @@ class Downloader:
|
||||
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
|
||||
return max(0.0, delta.total_seconds())
|
||||
|
||||
@staticmethod
|
||||
def _guard_destination(url: str) -> str:
|
||||
"""Build per-destination connectivity guard scope from request URL."""
|
||||
parsed_url = urlparse(url)
|
||||
if parsed_url.hostname:
|
||||
return parsed_url.hostname.lower()
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Global instance accessor
|
||||
async def get_downloader() -> Downloader:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
from ..utils.models import EmbeddingMetadata
|
||||
from ..config import config
|
||||
|
||||
@@ -20,20 +21,37 @@ class EmbeddingService(BaseModelService):
|
||||
"""
|
||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
||||
"""Format Embedding data for API response"""
|
||||
async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
|
||||
"""Format Embedding data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = embedding_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted embedding entry (missing file_path): %s",
|
||||
embedding_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = embedding_data.get("sub_type", "embedding")
|
||||
|
||||
|
||||
file_name = embedding_data.get("file_name") or ""
|
||||
model_name = embedding_data.get("model_name") or file_name
|
||||
folder = embedding_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": embedding_data["model_name"],
|
||||
"file_name": embedding_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
||||
"base_model": embedding_data.get("base_model", ""),
|
||||
"folder": embedding_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": embedding_data.get("sha256", ""),
|
||||
"file_path": embedding_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": embedding_data.get("size", 0),
|
||||
"modified": embedding_data.get("modified", ""),
|
||||
"tags": embedding_data.get("tags", []),
|
||||
@@ -42,9 +60,13 @@ class EmbeddingService(BaseModelService):
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"exclude": bool(embedding_data.get("exclude", False)),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||
"version_count": embedding_data.get("version_count"),
|
||||
"hf_url": embedding_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -25,3 +25,21 @@ class ResourceNotFoundError(RuntimeError):
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMNotConfiguredError(RuntimeError):
|
||||
"""Raised when an LLM-dependent operation is attempted but no provider is configured."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMRateLimitError(RateLimitError):
|
||||
"""Raised when the LLM provider rejects a request due to rate limiting."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMResponseError(RuntimeError):
|
||||
"""Raised when the LLM returns an unparseable or schema-invalid response."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
734
py/services/llm_service.py
Normal file
734
py/services/llm_service.py
Normal file
@@ -0,0 +1,734 @@
|
||||
"""Centralized LLM API client with BYOK (bring-your-own-key) provider support.
|
||||
|
||||
Reads provider configuration from :class:`SettingsManager` and makes
|
||||
OpenAI-compatible ``/chat/completions`` calls. Supports any provider that
|
||||
implements the OpenAI Chat Completions API surface area (OpenAI, Ollama,
|
||||
vLLM, LM Studio, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .errors import LLMNotConfiguredError, LLMRateLimitError, LLMResponseError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model catalog sourced from opencode's maintained model registry.
|
||||
# maps provider_id -> list of model IDs.
|
||||
# ---------------------------------------------------------------------------
|
||||
_MODEL_CATALOG_URL = "https://models.dev/api.json"
|
||||
|
||||
# In-memory cache: maps provider slug -> list of model ID strings.
|
||||
_catalog_cache: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
# Per-model max output token limits parsed from the catalog.
|
||||
# ``{provider_id: {model_id: max_output_tokens}}``.
|
||||
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
|
||||
async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||
"""Fetch and parse the model catalog.
|
||||
|
||||
Returns ``{provider_id: [model_id, ...]}`` and also populates
|
||||
:data:`_model_output_limits` with per-model ``limit.output`` values
|
||||
for use by :func:`_get_model_max_output`.
|
||||
|
||||
The JSON at ``_MODEL_CATALOG_URL`` is a dict keyed by provider slug; each
|
||||
value has a ``models`` sub-dict keyed by model ID. The result is cached
|
||||
in memory after the first successful fetch.
|
||||
Subsequent calls return the cached data immediately.
|
||||
"""
|
||||
global _catalog_cache, _model_output_limits
|
||||
if _catalog_cache is not None:
|
||||
return _catalog_cache
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||
async with session.get(_MODEL_CATALOG_URL) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||
return _catalog_cache or {}
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||
return _catalog_cache or {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||
return _catalog_cache or {}
|
||||
|
||||
result: Dict[str, List[str]] = {}
|
||||
output_limits: Dict[str, Dict[str, int]] = {}
|
||||
for provider_id, provider_info in data.items():
|
||||
if not isinstance(provider_info, dict):
|
||||
continue
|
||||
models_dict = provider_info.get("models")
|
||||
if not isinstance(models_dict, dict):
|
||||
continue
|
||||
model_ids: List[str] = []
|
||||
provider_limits: Dict[str, int] = {}
|
||||
for mid, model_info in models_dict.items():
|
||||
if not isinstance(mid, str):
|
||||
continue
|
||||
model_ids.append(mid)
|
||||
if isinstance(model_info, dict):
|
||||
limit = model_info.get("limit")
|
||||
if isinstance(limit, dict):
|
||||
output = limit.get("output")
|
||||
if isinstance(output, (int, float)) and output > 0:
|
||||
provider_limits[mid] = int(output)
|
||||
if model_ids:
|
||||
result[provider_id] = model_ids
|
||||
if provider_limits:
|
||||
output_limits[provider_id] = provider_limits
|
||||
|
||||
_catalog_cache = result
|
||||
_model_output_limits = output_limits
|
||||
logger.debug(
|
||||
"Loaded model catalog: %d providers, %d total models "
|
||||
"(%d providers have output limits)",
|
||||
len(result),
|
||||
sum(len(m) for m in result.values()),
|
||||
len(output_limits),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
|
||||
"""Return the model's max output token limit from the catalog, or ``None``.
|
||||
|
||||
Returns ``None`` when the provider or model is not found in the catalog
|
||||
(e.g. local Ollama models, custom models, or user-typed model names).
|
||||
Callers should fall back to a safe default.
|
||||
"""
|
||||
return _model_output_limits.get(provider, {}).get(model)
|
||||
|
||||
|
||||
# Short timeout for Ollama's local API
|
||||
_OLLAMA_API_TIMEOUT = aiohttp.ClientTimeout(total=8)
|
||||
|
||||
|
||||
async def fetch_ollama_models(api_base: str) -> List[str]:
|
||||
"""Fetch locally available models from a running Ollama instance.
|
||||
|
||||
Uses Ollama's OpenAI-compatible ``GET {api_base}/models`` endpoint.
|
||||
Returns an empty list if Ollama is not reachable (not running).
|
||||
"""
|
||||
url = f"{api_base.rstrip('/')}/models"
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
||||
return []
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
|
||||
logger.debug("Ollama not reachable at %s: %s", api_base, exc)
|
||||
return []
|
||||
|
||||
raw = data.get("data") if isinstance(data, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
return [
|
||||
str(entry["id"]) for entry in raw
|
||||
if isinstance(entry, dict) and isinstance(entry.get("id"), str)
|
||||
]
|
||||
|
||||
|
||||
async def get_provider_model_ids(provider_id: str) -> List[str]:
|
||||
"""Return the list of known model IDs for *provider_id* from the catalog.
|
||||
|
||||
The catalog is loaded on first call and cached thereafter. If the
|
||||
provider is not found an empty list is returned (never raises).
|
||||
"""
|
||||
catalog = await _load_model_catalog()
|
||||
return catalog.get(provider_id, [])
|
||||
|
||||
|
||||
async def get_all_provider_models(
|
||||
provider_ids: List[str],
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Return model lists for a subset of providers in one call.
|
||||
|
||||
Loads the catalog (cached) and returns only the requested providers.
|
||||
Handy for embedding lightweight data into the template context.
|
||||
"""
|
||||
catalog = await _load_model_catalog()
|
||||
return {
|
||||
pid: catalog.get(pid, [])
|
||||
for pid in provider_ids
|
||||
}
|
||||
|
||||
|
||||
# Provider preset definitions.
|
||||
# Each entry contains display metadata and defaults for the UI.
|
||||
# The key is the internal provider id stored in ``llm_provider``.
|
||||
# Models are NOT listed here — they come from the opencode model catalog at
|
||||
# runtime (see :func:`get_provider_model_ids`).
|
||||
PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"openai": {
|
||||
"name": "OpenAI",
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
"ollama": {
|
||||
"name": "Ollama (local)",
|
||||
"api_base": "http://localhost:11434/v1",
|
||||
"requires_key": False,
|
||||
},
|
||||
"deepseek": {
|
||||
"name": "DeepSeek",
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
"groq": {
|
||||
"name": "Groq",
|
||||
"api_base": "https://api.groq.com/openai/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
"openrouter": {
|
||||
"name": "OpenRouter",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
"google": {
|
||||
"name": "Gemini",
|
||||
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"requires_key": True,
|
||||
},
|
||||
"opencode-go": {
|
||||
"name": "OpenCode Go",
|
||||
"api_base": "https://opencode.ai/zen/go/v1",
|
||||
"requires_key": True,
|
||||
},
|
||||
# "custom" is handled specially (no preset api_base, requires user input)
|
||||
}
|
||||
|
||||
# Legacy lookup derived from PROVIDER_PRESETS for backward compat.
|
||||
_PROVIDER_DEFAULTS: Dict[str, str] = {
|
||||
pid: info["api_base"]
|
||||
for pid, info in PROVIDER_PRESETS.items()
|
||||
if info.get("api_base")
|
||||
}
|
||||
|
||||
# Request timeout for LLM calls (seconds)
|
||||
_LLM_TIMEOUT = aiohttp.ClientTimeout(total=120)
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""Centralized LLM API client.
|
||||
|
||||
All LLM-based enrichment features call through this service so
|
||||
that BYOK config, retry logic, and error handling live in one place.
|
||||
"""
|
||||
|
||||
_instance: Optional["LLMService"] = None
|
||||
_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, settings_service) -> None:
|
||||
self._settings = settings_service
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Singleton access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "LLMService":
|
||||
"""Return the lazily-initialised global ``LLMService`` instance."""
|
||||
|
||||
if cls._instance is None:
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
cls._instance = cls(get_settings_manager())
|
||||
# Start preloading the model catalog in the background so
|
||||
# the settings UI never blocks on it. The catalog is
|
||||
# cached after the first fetch (see _load_model_catalog).
|
||||
asyncio.create_task(_load_model_catalog())
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls) -> None:
|
||||
"""Reset the cached singleton — primarily for tests."""
|
||||
|
||||
cls._instance = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuration helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
"""Read the current LLM configuration from settings."""
|
||||
|
||||
return {
|
||||
"provider": self._settings.get("llm_provider", "openai"),
|
||||
"api_key": self._settings.get("llm_api_key", ""),
|
||||
"api_base": self._settings.get("llm_api_base", ""),
|
||||
"model": self._settings.get("llm_model", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _provider_requires_key(provider: str) -> bool:
|
||||
"""Return ``False`` when the given provider id does not need an API key."""
|
||||
preset = PROVIDER_PRESETS.get(provider, {})
|
||||
return bool(preset.get("requires_key", True))
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Return ``True`` when the LLM provider is minimally configured.
|
||||
|
||||
A provider is considered configured when ``llm_model`` is set,
|
||||
an API key is configured for providers that require one (e.g.
|
||||
Ollama does not), and an API base URL is set for providers that
|
||||
have no preset default (e.g. ``custom``).
|
||||
"""
|
||||
|
||||
cfg = self._get_config()
|
||||
has_model = bool(cfg["model"])
|
||||
has_key = bool(cfg["api_key"]) or not self._provider_requires_key(cfg["provider"])
|
||||
has_base = bool(cfg["api_base"]) or bool(_PROVIDER_DEFAULTS.get(cfg["provider"]))
|
||||
return has_model and has_key and has_base
|
||||
|
||||
def _resolve_api_base(self, provider: str, api_base: str) -> str:
|
||||
"""Resolve the API base URL for the given provider.
|
||||
|
||||
If ``api_base`` is explicitly set (non-empty), it takes priority.
|
||||
Otherwise the default from :data:`PROVIDER_PRESETS` is used.
|
||||
"""
|
||||
|
||||
if api_base:
|
||||
return api_base.rstrip("/")
|
||||
return _PROVIDER_DEFAULTS.get(provider, "").rstrip("/")
|
||||
|
||||
def _build_headers(self, api_key: str) -> Dict[str, str]:
|
||||
"""Build HTTP headers for the LLM API request."""
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
def _ensure_configured(self) -> Dict[str, Any]:
|
||||
"""Validate configuration and return it, or raise.
|
||||
|
||||
A provider is considered configured when ``llm_model`` is set,
|
||||
an API key is configured for providers that require one, and
|
||||
an API base URL is set for providers without a preset default.
|
||||
"""
|
||||
|
||||
cfg = self._get_config()
|
||||
has_model = bool(cfg["model"])
|
||||
needs_key = self._provider_requires_key(cfg["provider"])
|
||||
has_key = bool(cfg["api_key"]) or not needs_key
|
||||
has_base = bool(cfg["api_base"]) or bool(_PROVIDER_DEFAULTS.get(cfg["provider"]))
|
||||
if not (has_model and has_key and has_base):
|
||||
parts = []
|
||||
if not has_model:
|
||||
parts.append("No LLM model specified")
|
||||
if not has_key and needs_key:
|
||||
parts.append("No LLM API key configured")
|
||||
if not has_base:
|
||||
parts.append(
|
||||
f"No API base URL for provider '{cfg['provider']}'"
|
||||
)
|
||||
detail = "; ".join(parts) if parts else "LLM provider is not configured"
|
||||
raise LLMNotConfiguredError(
|
||||
f"{detail}. Configure it in Settings → AI Provider."
|
||||
)
|
||||
return cfg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core API call
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def chat_completion(
|
||||
self,
|
||||
*,
|
||||
messages: List[Dict[str, str]],
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.3,
|
||||
response_format: Optional[Dict[str, Any]] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
retry_on_rate_limit: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Call the configured LLM provider's ``/chat/completions`` endpoint.
|
||||
|
||||
Args:
|
||||
messages: OpenAI-format message list
|
||||
model: Override the configured model name
|
||||
temperature: Sampling temperature
|
||||
response_format: Optional ``{"type": "json_object"}`` for structured output
|
||||
max_tokens: Optional max output tokens
|
||||
retry_on_rate_limit: Retry once after a 429 with backoff
|
||||
|
||||
Returns:
|
||||
Dict with ``content`` (str), ``usage`` (dict), ``model`` (str)
|
||||
|
||||
Raises:
|
||||
LLMNotConfiguredError: Provider not enabled / missing config
|
||||
LLMRateLimitError: Rate limited and retry exhausted
|
||||
LLMResponseError: Non-200 response or parse failure
|
||||
"""
|
||||
|
||||
cfg = self._ensure_configured()
|
||||
api_base = self._resolve_api_base(cfg["provider"], cfg["api_base"])
|
||||
model_name = model or cfg["model"]
|
||||
|
||||
is_ollama = cfg["provider"] == "ollama"
|
||||
|
||||
if is_ollama:
|
||||
# Use Ollama's native /api/chat endpoint which does NOT expose
|
||||
# a separate reasoning/thinking field (the model's full output
|
||||
# lands directly in message.content). The OpenAI-compatible
|
||||
# endpoint splits thinking into the "reasoning" field, making
|
||||
# content empty when thinking consumes all available tokens.
|
||||
base = api_base.rstrip("/")
|
||||
if base.endswith("/v1"):
|
||||
base = base[:-3]
|
||||
url = f"{base}/api/chat"
|
||||
else:
|
||||
url = f"{api_base}/chat/completions"
|
||||
|
||||
payload: Dict[str, Any]
|
||||
if is_ollama:
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
# Suppress separate thinking trace — thinking still happens
|
||||
# internally (accuracy preserved) but output goes directly to
|
||||
# message.content instead of being split across content +
|
||||
# thinking. Without this the model can exhaust num_predict
|
||||
# on thinking alone and leave content empty.
|
||||
"think": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
# 8K context is sufficient for metadata enrichment
|
||||
# (prompt ~2-5K, output ~0.2-1K tokens). The old 32K
|
||||
# value was excessive for this use case and increased
|
||||
# Ollama VRAM usage unnecessarily.
|
||||
"num_ctx": 8192,
|
||||
},
|
||||
}
|
||||
if response_format is not None:
|
||||
payload["format"] = "json"
|
||||
if max_tokens is not None:
|
||||
payload["options"]["num_predict"] = max_tokens
|
||||
else:
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if response_format is not None:
|
||||
payload["response_format"] = response_format
|
||||
if max_tokens is not None:
|
||||
payload["max_tokens"] = max_tokens
|
||||
|
||||
if is_ollama:
|
||||
logger.info(
|
||||
"Ollama request: model=%s num_ctx=%s num_predict=%s format=%s think=%s",
|
||||
payload.get("model"),
|
||||
payload.get("options", {}).get("num_ctx"),
|
||||
payload.get("options", {}).get("num_predict"),
|
||||
payload.get("format", "none"),
|
||||
payload.get("think"),
|
||||
)
|
||||
|
||||
headers = self._build_headers(cfg["api_key"])
|
||||
|
||||
attempt = 0
|
||||
max_attempts = 2 if retry_on_rate_limit else 1
|
||||
while attempt < max_attempts:
|
||||
attempt += 1
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_LLM_TIMEOUT) as session:
|
||||
async with session.post(
|
||||
url, json=payload, headers=headers
|
||||
) as resp:
|
||||
if resp.status == 429:
|
||||
if attempt < max_attempts:
|
||||
retry_after = float(
|
||||
resp.headers.get("Retry-After", "5")
|
||||
)
|
||||
logger.warning(
|
||||
"LLM rate limited, retrying after %.1fs",
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
raise LLMRateLimitError(
|
||||
f"LLM provider rate limited (HTTP 429)",
|
||||
provider=cfg["provider"],
|
||||
)
|
||||
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
raise LLMResponseError(
|
||||
f"LLM API returned HTTP {resp.status}: "
|
||||
f"{body[:500]}"
|
||||
)
|
||||
|
||||
data = await resp.json()
|
||||
|
||||
except aiohttp.ClientError as exc:
|
||||
raise LLMResponseError(f"Network error calling LLM API: {exc}") from exc
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
if is_ollama:
|
||||
content = (data.get("message") or {}).get("content") or ""
|
||||
usage = {"completion_tokens": data.get("eval_count", 0)}
|
||||
finish_reason = data.get("done_reason", "")
|
||||
if not content:
|
||||
logger.warning(
|
||||
"LLM returned empty content. Provider=ollama, "
|
||||
"done_reason=%s, eval_count=%s",
|
||||
finish_reason,
|
||||
data.get("eval_count", 0),
|
||||
)
|
||||
else:
|
||||
content = data["choices"][0]["message"].get("content") or ""
|
||||
usage = data.get("usage", {})
|
||||
if not content:
|
||||
logger.warning(
|
||||
"LLM returned empty content. Full response truncated: %s",
|
||||
json.dumps(data, ensure_ascii=False)[:1000],
|
||||
)
|
||||
return {
|
||||
"content": content,
|
||||
"usage": usage,
|
||||
"model": data.get("model", model_name),
|
||||
}
|
||||
except (KeyError, IndexError) as exc:
|
||||
raise LLMResponseError(
|
||||
f"Unexpected LLM response structure: {json.dumps(data)[:500]}"
|
||||
) from exc
|
||||
|
||||
# Should not reach here, but satisfy type checker
|
||||
raise LLMRateLimitError("Rate limit retry exhausted", provider=cfg["provider"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Structured output convenience
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def chat_completion_json(
|
||||
self,
|
||||
*,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Call the LLM with ``response_format=json_object`` and return parsed JSON.
|
||||
|
||||
``max_tokens`` is resolved in this order:
|
||||
1. Explicit caller-supplied ``max_tokens``
|
||||
2. Per-model ``limit.output`` from the model catalog
|
||||
3. A safe default of 4096 (sufficient for metadata enrichment)
|
||||
|
||||
If the response content is empty or not valid JSON, attempts
|
||||
:func:`_try_salvage_json` before raising.
|
||||
|
||||
Args:
|
||||
system_prompt: System-level instructions
|
||||
user_prompt: User-level query
|
||||
model: Override the configured model name
|
||||
temperature: Sampling temperature
|
||||
max_tokens: Optional max output tokens
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict from the LLM response
|
||||
|
||||
Raises:
|
||||
LLMNotConfiguredError: Provider not configured
|
||||
LLMRateLimitError: Rate limited
|
||||
LLMResponseError: Empty response or JSON parse failure
|
||||
"""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
# Resolve max_tokens: caller override → catalog lookup → safe default
|
||||
if max_tokens is None:
|
||||
cfg = self._get_config()
|
||||
effective_max = _get_model_max_output(cfg["provider"], cfg["model"])
|
||||
else:
|
||||
effective_max = max_tokens
|
||||
if effective_max is None:
|
||||
effective_max = 4096
|
||||
|
||||
# Use json_schema (not json_object) for broader provider compatibility:
|
||||
# LM Studio and some other OpenAI-compatible servers reject
|
||||
# json_object but accept json_schema. {"type": "object"} is
|
||||
# functionally equivalent — it accepts any JSON object without
|
||||
# constraining specific fields.
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "metadata",
|
||||
"schema": {"type": "object"},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
result = await self.chat_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
response_format=response_format,
|
||||
max_tokens=effective_max,
|
||||
)
|
||||
except LLMResponseError as e:
|
||||
# Only fall back when the provider rejects the response_format
|
||||
# type value (e.g. "'response_format.type' must be..."). Avoid
|
||||
# catching unrelated 400 errors whose body happens to mention
|
||||
# "response_format" (e.g. "model does not support
|
||||
# response_format restrictions on this endpoint").
|
||||
if "'response_format.type'" not in str(e).lower():
|
||||
raise
|
||||
logger.info(
|
||||
"Provider rejected response_format, retrying without it. "
|
||||
"Falling back to prompt-only JSON mode. Error: %s",
|
||||
e,
|
||||
)
|
||||
result = await self.chat_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
response_format=None,
|
||||
max_tokens=effective_max,
|
||||
)
|
||||
|
||||
content = result.get("content", "") or ""
|
||||
if not content:
|
||||
raise LLMResponseError(
|
||||
"LLM returned empty content. "
|
||||
f"Raw response: {json.dumps(result)[:500]}"
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
logger.debug(
|
||||
"LLM raw content: %s",
|
||||
json.dumps(parsed, ensure_ascii=False)[:2000],
|
||||
)
|
||||
return parsed
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
logger.info(
|
||||
"LLM raw response (first 800 chars): %s",
|
||||
content[:800],
|
||||
)
|
||||
|
||||
# Last resort: attempt to salvage partial/truncated JSON
|
||||
salvaged = _try_salvage_json(content)
|
||||
if salvaged is not None:
|
||||
logger.warning(
|
||||
"LLM JSON salvaged from partial content (%d chars raw)",
|
||||
len(content),
|
||||
)
|
||||
return salvaged
|
||||
|
||||
raise LLMResponseError(
|
||||
f"LLM response could not be parsed as JSON: {content[:200]}"
|
||||
)
|
||||
|
||||
|
||||
def _try_salvage_json(raw: str) -> Dict[str, Any] | None:
|
||||
"""Attempt to repair and parse a truncated JSON string.
|
||||
|
||||
Handles common truncation patterns:
|
||||
|
||||
* Incomplete string value at the end (``"foo`` → ``"foo"``)
|
||||
* Missing closing ``}`` or ``]`` (respecting nesting order)
|
||||
* Trailing comma before closing bracket
|
||||
* Extra text after the JSON object (e.g. markdown fences)
|
||||
|
||||
Returns the parsed dict on success, ``None`` if repair is impossible.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
text = raw.strip()
|
||||
|
||||
# Strip markdown fences if the LLM wrapped the JSON
|
||||
if text.startswith("```"):
|
||||
end = text.find("\n")
|
||||
text = text[end + 1:] if end != -1 else text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3].rstrip()
|
||||
|
||||
# Find the first '{' and strip everything before it
|
||||
start = text.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
text = text[start:]
|
||||
|
||||
# Try to close an incomplete string at the end (e.g. ``"https://huggingf``)
|
||||
# Pattern: ends mid-string (last quote is open)
|
||||
if text.count('"') % 2 == 1:
|
||||
text += '"'
|
||||
|
||||
# Ensure trailing commas before closing braces work
|
||||
text = _strip_trailing_commas(text)
|
||||
|
||||
# Walk through the text character by character to find unclosed
|
||||
# brackets and close them in the correct (LIFO) order.
|
||||
# We ignore brackets inside quoted strings.
|
||||
stack: list[str] = []
|
||||
in_string = False
|
||||
escape = False
|
||||
for ch in text:
|
||||
if escape:
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch in ("{", "["):
|
||||
stack.append(ch)
|
||||
elif ch == "}":
|
||||
if stack and stack[-1] == "{":
|
||||
stack.pop()
|
||||
else:
|
||||
return None # Unmatched closer — unrecoverable
|
||||
elif ch == "]":
|
||||
if stack and stack[-1] == "[":
|
||||
stack.pop()
|
||||
else:
|
||||
return None
|
||||
|
||||
# Close remaining open brackets in reverse order
|
||||
for opener in reversed(stack):
|
||||
text += "}" if opener == "{" else "]"
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _strip_trailing_commas(text: str) -> str:
|
||||
"""Remove commas that appear before a closing brace/bracket."""
|
||||
import re as _re
|
||||
text = _re.sub(r",\s*}", "}", text)
|
||||
text = _re.sub(r",\s*]", "]", text)
|
||||
return text
|
||||
@@ -5,6 +5,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .model_query import resolve_sub_type
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
from ..utils.models import LoraMetadata
|
||||
from ..config import config
|
||||
|
||||
@@ -23,23 +24,41 @@ class LoraService(BaseModelService):
|
||||
"""
|
||||
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, lora_data: Dict) -> Dict:
|
||||
"""Format LoRA data for API response"""
|
||||
async def format_response(self, lora_data: Dict) -> Optional[Dict]:
|
||||
"""Format LoRA data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out instead of crashing the
|
||||
whole listing request. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = lora_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted LoRA entry (missing file_path): %s",
|
||||
lora_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||
# Normalize to lowercase for consistent API responses
|
||||
sub_type = resolve_sub_type(lora_data).lower()
|
||||
|
||||
file_name = lora_data.get("file_name") or ""
|
||||
model_name = lora_data.get("model_name") or file_name
|
||||
folder = lora_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": lora_data["model_name"],
|
||||
"file_name": lora_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(
|
||||
lora_data.get("preview_url", "")
|
||||
),
|
||||
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
||||
"base_model": lora_data.get("base_model", ""),
|
||||
"folder": lora_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": lora_data.get("sha256", ""),
|
||||
"file_path": lora_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": lora_data.get("size", 0),
|
||||
"modified": lora_data.get("modified", ""),
|
||||
"tags": lora_data.get("tags", []),
|
||||
@@ -48,6 +67,7 @@ class LoraService(BaseModelService):
|
||||
"usage_tips": lora_data.get("usage_tips", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"exclude": bool(lora_data.get("exclude", False)),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(
|
||||
lora_data.get("skip_metadata_refresh", False)
|
||||
@@ -56,6 +76,9 @@ class LoraService(BaseModelService):
|
||||
"civitai": self.filter_civitai_data(
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
),
|
||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||
"version_count": lora_data.get("version_count"),
|
||||
"hf_url": lora_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
@@ -248,12 +271,16 @@ class LoraService(BaseModelService):
|
||||
return letters
|
||||
|
||||
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
|
||||
"""Get trigger words for a specific LoRA file"""
|
||||
"""Get trigger words for a specific LoRA file.
|
||||
|
||||
Supports both simple names and full-path syntax.
|
||||
"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
for lora in cache.raw_data:
|
||||
if lora["file_name"] == lora_name:
|
||||
civitai_data = lora.get("civitai", {})
|
||||
file_name = lora.get("file_name", "")
|
||||
if file_name == lora_name or lora_name.endswith("/" + file_name) or lora_name.endswith("\\" + file_name):
|
||||
civitai_data = lora.get("civitai") or {}
|
||||
return civitai_data.get("trainedWords", [])
|
||||
|
||||
return []
|
||||
@@ -309,8 +336,23 @@ class LoraService(BaseModelService):
|
||||
"""Return cached raw metadata for a LoRA matching the given filename."""
|
||||
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 []:
|
||||
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 None
|
||||
@@ -398,7 +440,10 @@ class LoraService(BaseModelService):
|
||||
locked_loras = locked_loras[:target_count]
|
||||
|
||||
# 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 = [
|
||||
l for l in available_loras if l["file_name"] not in locked_names
|
||||
]
|
||||
@@ -453,7 +498,7 @@ class LoraService(BaseModelService):
|
||||
|
||||
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,
|
||||
"clipStrength": clip_str,
|
||||
"active": True,
|
||||
@@ -669,8 +714,9 @@ class LoraService(BaseModelService):
|
||||
# Return minimal data needed for cycling
|
||||
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"]),
|
||||
"folder": lora.get("folder", ""),
|
||||
}
|
||||
for lora in available_loras
|
||||
]
|
||||
|
||||
@@ -15,6 +15,17 @@ from .service_registry import ServiceRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROVIDER_DISPLAY_NAMES = {
|
||||
"civitai_api": "CivitAI",
|
||||
"civarchive_api": "CivArchive",
|
||||
"sqlite": "Archive DB",
|
||||
}
|
||||
|
||||
_PRESET_PROVIDER_ORDERS = {
|
||||
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
|
||||
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
|
||||
}
|
||||
|
||||
async def initialize_metadata_providers():
|
||||
"""Initialize and configure all metadata providers based on settings"""
|
||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||
@@ -26,7 +37,9 @@ async def initialize_metadata_providers():
|
||||
# Get settings
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
|
||||
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||
|
||||
providers = []
|
||||
|
||||
# Initialize archive database provider if enabled
|
||||
@@ -59,27 +72,48 @@ async def initialize_metadata_providers():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
|
||||
|
||||
# Register CivArchive provider, and all add to fallback providers
|
||||
try:
|
||||
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
||||
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
||||
provider_manager.register_provider('civarchive_api', civarchive_provider)
|
||||
providers.append(('civarchive_api', civarchive_provider))
|
||||
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
||||
# Register CivArchive provider when enabled. Civitai API is always
|
||||
# preferred (better metadata); CivArchive mainly recovers metadata for
|
||||
# models deleted from Civitai, so it can be turned off to avoid its long
|
||||
# rate-limit windows entirely.
|
||||
if enable_civarchive_api:
|
||||
try:
|
||||
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
||||
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
||||
provider_manager.register_provider('civarchive_api', civarchive_provider)
|
||||
providers.append(('civarchive_api', civarchive_provider))
|
||||
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
||||
else:
|
||||
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
|
||||
|
||||
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
|
||||
# civitai_api is always first (better metadata); the remaining providers
|
||||
# are arranged by the configured preset. Providers that are not
|
||||
# registered (disabled/unavailable) are simply skipped, so each preset
|
||||
# degrades gracefully.
|
||||
desired_order = _PRESET_PROVIDER_ORDERS.get(
|
||||
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
|
||||
)
|
||||
|
||||
# Set up fallback provider based on available providers
|
||||
if len(providers) > 1:
|
||||
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
|
||||
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api'])
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api'])
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'sqlite'])
|
||||
|
||||
for name in desired_order:
|
||||
ordered_providers.extend([p for p in providers if p[0] == name])
|
||||
# Include any provider not covered by the preset (defensive) at the end
|
||||
for p in providers:
|
||||
if p not in ordered_providers:
|
||||
ordered_providers.append(p)
|
||||
|
||||
if ordered_providers:
|
||||
fallback_provider = FallbackMetadataProvider(ordered_providers)
|
||||
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
|
||||
logger.debug(
|
||||
"Metadata fallback provider order: %s",
|
||||
", ".join(name for name, _ in ordered_providers),
|
||||
)
|
||||
elif len(providers) == 1:
|
||||
# Only one provider available, set it as default
|
||||
provider_name, provider = providers[0]
|
||||
@@ -96,11 +130,30 @@ async def update_metadata_providers():
|
||||
# Get current settings
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||
|
||||
# Reinitialize all providers with new settings
|
||||
provider_manager = await initialize_metadata_providers()
|
||||
|
||||
logger.info(f"Updated metadata providers, archive_db enabled: {enable_archive_db}")
|
||||
# Build effective provider chain for logging (use actually-registered
|
||||
# providers, not just settings, so a failed init is reflected correctly)
|
||||
registered = set(provider_manager.providers.keys())
|
||||
desired = _PRESET_PROVIDER_ORDERS.get(
|
||||
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
|
||||
)
|
||||
chain = " → ".join(
|
||||
_PROVIDER_DISPLAY_NAMES[p]
|
||||
for p in desired
|
||||
if p in registered and p in _PROVIDER_DISPLAY_NAMES
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
|
||||
enable_archive_db,
|
||||
enable_civarchive_api,
|
||||
chain,
|
||||
)
|
||||
return provider_manager
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update metadata providers: {e}")
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
|
||||
from ..services.settings_manager import SettingsManager
|
||||
from ..utils.civitai_utils import resolve_license_payload
|
||||
from ..utils.model_utils import determine_base_model
|
||||
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
|
||||
from .errors import RateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -208,20 +209,40 @@ class MetadataSyncService:
|
||||
error_msg = "CivitAI model is deleted and no archive provider is available"
|
||||
return False, error_msg
|
||||
else:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
is_hf_source = bool(model_data.get("hf_url"))
|
||||
if is_hf_source:
|
||||
# HF-sourced model: only check CivitAI API directly.
|
||||
# CivArchive is almost guaranteed to have no record, and
|
||||
# hitting it wastes rate-limit budget.
|
||||
# Use a distinct provider name ("civitai_api" not None) so
|
||||
# downstream code does NOT interpret a "Model not found"
|
||||
# response as civitai_api_not_found — which would mark the
|
||||
# model civitai_deleted=True when it was never on CivitAI.
|
||||
try:
|
||||
provider_attempts.append(("civitai_api", await self._get_provider("civitai_api")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve civitai_api provider: %s", exc)
|
||||
if not provider_attempts:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
|
||||
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||
provider_used: Optional[str] = None
|
||||
last_error: Optional[str] = None
|
||||
civitai_api_not_found = False
|
||||
any_rate_limited = False
|
||||
|
||||
for provider_name, provider in provider_attempts:
|
||||
try:
|
||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
|
||||
raise
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
provider_name or provider.__class__.__name__,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
any_rate_limited = True
|
||||
continue
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
||||
civitai_metadata_candidate, error = None, str(exc)
|
||||
@@ -257,6 +278,14 @@ class MetadataSyncService:
|
||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||
needs_save = True
|
||||
|
||||
# When the model was already classified as "not on CivitAI" via
|
||||
# .metadata.json (civitai_deleted=True) but the SQLite cache is
|
||||
# stale (because the pre-fix code never persisted these flags),
|
||||
# ensure the flags are written to the scanner cache + SQLite.
|
||||
if not needs_save and model_data.get("civitai_deleted") is True:
|
||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||
needs_save = True
|
||||
|
||||
# Save metadata if any state was updated
|
||||
if needs_save:
|
||||
data_to_save = model_data.copy()
|
||||
@@ -265,6 +294,7 @@ class MetadataSyncService:
|
||||
if "last_checked_at" not in data_to_save:
|
||||
data_to_save["last_checked_at"] = datetime.now().timestamp()
|
||||
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
||||
await update_cache_func(file_path, file_path, data_to_save)
|
||||
|
||||
default_error = (
|
||||
"CivitAI model is deleted and metadata archive DB is not enabled"
|
||||
@@ -274,11 +304,19 @@ class MetadataSyncService:
|
||||
else "No provider returned metadata"
|
||||
)
|
||||
|
||||
resolved_error = last_error or default_error
|
||||
if any_rate_limited and "Rate limited" not in resolved_error:
|
||||
resolved_error = "Rate limited"
|
||||
if is_expected_offline_error(resolved_error):
|
||||
resolved_error = OFFLINE_FRIENDLY_MESSAGE
|
||||
|
||||
error_msg = (
|
||||
f"Error fetching metadata: {last_error or default_error} "
|
||||
f"(model_name={model_data.get('model_name', '')})"
|
||||
f"Error fetching metadata: {resolved_error} "
|
||||
f"(file={os.path.basename(file_path)}, sha256={sha256})"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
# Use case layer (BulkMetadataRefreshUseCase) logs failed models at WARNING level,
|
||||
# so this level is demoted to DEBUG to avoid duplicate user-visible logging.
|
||||
logger.debug(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
model_data["from_civitai"] = True
|
||||
@@ -347,6 +385,9 @@ class MetadataSyncService:
|
||||
return False, error_msg
|
||||
except Exception as exc: # pragma: no cover - error path
|
||||
error_msg = f"Error fetching metadata: {exc}"
|
||||
if is_expected_offline_error(str(exc)):
|
||||
logger.info(OFFLINE_FRIENDLY_MESSAGE)
|
||||
return False, OFFLINE_FRIENDLY_MESSAGE
|
||||
logger.error(error_msg, exc_info=True)
|
||||
return False, error_msg
|
||||
|
||||
@@ -400,7 +441,18 @@ class MetadataSyncService:
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
for key, value in updates.items():
|
||||
if isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||
if key == "tags" and isinstance(value, list):
|
||||
# Normalize tags: trim, lowercase, deduplicate
|
||||
normalized = []
|
||||
seen = set()
|
||||
for tag in value:
|
||||
if isinstance(tag, str):
|
||||
t = tag.strip().lower()
|
||||
if t and t not in seen:
|
||||
normalized.append(t)
|
||||
seen.add(t)
|
||||
metadata[key] = normalized
|
||||
elif isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||
metadata[key].update(value)
|
||||
else:
|
||||
metadata[key] = value
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import random
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -18,6 +19,8 @@ SUPPORTED_SORT_MODES = [
|
||||
('size', 'desc'),
|
||||
('usage', 'asc'),
|
||||
('usage', 'desc'),
|
||||
('versions_count', 'asc'),
|
||||
('versions_count', 'desc'),
|
||||
]
|
||||
# Is this in use?
|
||||
|
||||
@@ -36,8 +39,8 @@ class ModelCache:
|
||||
|
||||
def __post_init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
# Cache for last sort: (sort_key, order) -> sorted list
|
||||
self._last_sort: Tuple[str, str] = (None, None)
|
||||
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
||||
self._last_sort: Tuple[Optional[str], str, Optional[str]] = (None, "asc", None)
|
||||
self._last_sorted_data: List[Dict] = []
|
||||
self._normalize_raw_data()
|
||||
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
|
||||
@@ -201,9 +204,9 @@ class ModelCache:
|
||||
async def resort(self):
|
||||
"""Resort cached data according to last sort mode if set"""
|
||||
async with self._lock:
|
||||
if self._last_sort != (None, None):
|
||||
sort_key, order = self._last_sort
|
||||
sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
||||
if self._last_sort[0] is not None:
|
||||
sort_key, order, seed = self._last_sort
|
||||
sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
||||
self._last_sorted_data = sorted_data
|
||||
# Update folder list
|
||||
# else: do nothing
|
||||
@@ -216,7 +219,7 @@ class ModelCache:
|
||||
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
self.rebuild_version_index()
|
||||
|
||||
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
||||
def _sort_data(self, data: List[Dict], sort_key: str, order: str, seed: Optional[str] = None) -> List[Dict]:
|
||||
"""Sort data by sort_key and order"""
|
||||
start_time = time.perf_counter()
|
||||
reverse = (order == 'desc')
|
||||
@@ -263,6 +266,24 @@ class ModelCache:
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'random':
|
||||
# Random shuffle seeded for stable pagination: the same seed
|
||||
# always yields the same order, so successive page requests
|
||||
# stay consistent while browsing.
|
||||
rng = random.Random(seed or 'random')
|
||||
result = list(data)
|
||||
rng.shuffle(result)
|
||||
elif sort_key == 'versions_count':
|
||||
# Pre-dedup sort: fall back to name sort.
|
||||
# Actual re-sort by version_count happens in get_paginated_data after dedup.
|
||||
result = natsorted(
|
||||
data,
|
||||
key=lambda x: (
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
else:
|
||||
# Fallback: no sort
|
||||
result = list(data)
|
||||
@@ -272,15 +293,16 @@ class ModelCache:
|
||||
logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
|
||||
return result
|
||||
|
||||
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]:
|
||||
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc', seed: Optional[str] = None) -> List[Dict]:
|
||||
"""Get sorted data by sort_key and order, using cache if possible"""
|
||||
async with self._lock:
|
||||
if (sort_key, order) == self._last_sort:
|
||||
cache_key = (sort_key, order, seed)
|
||||
if cache_key == self._last_sort:
|
||||
return self._last_sorted_data
|
||||
|
||||
start_time = time.perf_counter()
|
||||
sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
||||
self._last_sort = (sort_key, order)
|
||||
sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
||||
self._last_sort = cache_key
|
||||
self._last_sorted_data = sorted_data
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
@@ -300,8 +322,8 @@ class ModelCache:
|
||||
self.name_display_mode = normalized
|
||||
|
||||
if self._last_sort[0] == 'name':
|
||||
sort_key, order = self._last_sort
|
||||
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
||||
sort_key, order, seed = self._last_sort
|
||||
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
||||
|
||||
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
||||
"""Update preview_url for a specific model in all cached data
|
||||
@@ -324,4 +346,25 @@ class ModelCache:
|
||||
else:
|
||||
return False # Model not found
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
async def clear_preview_by_path(self, preview_file_path: str) -> int:
|
||||
"""Clear ``preview_url`` for every cached entry referencing a file path.
|
||||
|
||||
When a preview file has been deleted from disk, this removes its
|
||||
reference from all matching cache entries so the next list-API
|
||||
response returns an empty ``preview_url`` instead of a stale URL
|
||||
that produces 404s.
|
||||
|
||||
Returns the number of entries that were updated.
|
||||
"""
|
||||
normalized = preview_file_path.replace("\\", "/")
|
||||
cleared = 0
|
||||
async with self._lock:
|
||||
for item in self.raw_data:
|
||||
cached_url = item.get("preview_url", "")
|
||||
if cached_url.replace("\\", "/") == normalized:
|
||||
item["preview_url"] = ""
|
||||
item["preview_nsfw_level"] = 0
|
||||
cleared += 1
|
||||
return cleared
|
||||
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -493,6 +494,9 @@ class ModelMoveService:
|
||||
Dictionary with move result
|
||||
"""
|
||||
try:
|
||||
_require_path_in_library_roots(file_path, self.scanner, label="Source path")
|
||||
_require_path_in_library_roots(target_path, self.scanner, label="Target path")
|
||||
|
||||
if use_default_paths:
|
||||
# Find the model in cache to get metadata
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
@@ -7,6 +7,7 @@ class ModelHashIndex:
|
||||
def __init__(self):
|
||||
self._hash_to_path: Dict[str, str] = {}
|
||||
self._filename_to_hash: Dict[str, str] = {}
|
||||
self._autov2_to_path: Dict[str, str] = {}
|
||||
# New data structures for tracking duplicates
|
||||
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
||||
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
||||
@@ -63,6 +64,9 @@ class ModelHashIndex:
|
||||
# Add new mappings
|
||||
self._hash_to_path[sha256] = file_path
|
||||
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:
|
||||
"""Extract filename without extension from path"""
|
||||
@@ -79,6 +83,12 @@ class ModelHashIndex:
|
||||
hash_val = h
|
||||
break
|
||||
|
||||
if hash_val is None:
|
||||
for h, paths in self._duplicate_hashes.items():
|
||||
if file_path in paths:
|
||||
hash_val = h
|
||||
break
|
||||
|
||||
# If we didn't find a hash, nothing to do
|
||||
if not hash_val:
|
||||
return
|
||||
@@ -151,7 +161,12 @@ class ModelHashIndex:
|
||||
del self._duplicate_filenames[filename]
|
||||
if filename in self._filename_to_hash:
|
||||
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:
|
||||
"""Remove entry by hash"""
|
||||
sha256 = sha256.lower()
|
||||
@@ -171,6 +186,10 @@ class ModelHashIndex:
|
||||
# Remove hash-to-path mapping
|
||||
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
|
||||
for path_to_remove in paths_to_remove:
|
||||
fname = self._get_filename_from_path(path_to_remove)
|
||||
@@ -189,13 +208,24 @@ class ModelHashIndex:
|
||||
# If only one entry remains, it's no longer a duplicate
|
||||
del self._duplicate_filenames[fname]
|
||||
|
||||
def has_hash(self, sha256: str) -> bool:
|
||||
"""Check if hash exists in index"""
|
||||
return sha256.lower() in self._hash_to_path
|
||||
|
||||
def get_path(self, sha256: str) -> Optional[str]:
|
||||
"""Get file path for a hash"""
|
||||
return self._hash_to_path.get(sha256.lower())
|
||||
def has_hash(self, hash_value: str) -> bool:
|
||||
"""Check if hash exists in index (SHA256 or AutoV2)"""
|
||||
normalized = hash_value.lower()
|
||||
if normalized in self._hash_to_path:
|
||||
return True
|
||||
if len(normalized) == 10:
|
||||
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]:
|
||||
"""Get hash for a file path"""
|
||||
@@ -203,13 +233,16 @@ class ModelHashIndex:
|
||||
return self._filename_to_hash.get(filename)
|
||||
|
||||
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)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries"""
|
||||
self._hash_to_path.clear()
|
||||
self._filename_to_hash.clear()
|
||||
self._autov2_to_path.clear()
|
||||
self._duplicate_hashes.clear()
|
||||
self._duplicate_filenames.clear()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user