mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
Compare commits
88 Commits
v1.2.2
...
d4b82d98b2
| Author | SHA1 | Date | |
|---|---|---|---|
| d4b82d98b2 | |||
| 8c1c1691e3 | |||
| e14a084f0d | |||
| c55c6f0a41 | |||
| 7d963b27b5 | |||
| eba03800b9 | |||
| bf497d5144 | |||
| 369613f811 | |||
| 9eeebac40b | |||
| b9a516c9f8 | |||
| ef7fa7d3dd | |||
| 9c67dbbf15 | |||
| 16b0bdf70a | |||
| e09fe5888b | |||
| f67689b0f9 | |||
| 1d6da1787a | |||
| d572292142 | |||
| 1b1a8d63db | |||
| a0a5b13ab0 | |||
| 779bd18e75 | |||
| 01137eed88 | |||
| c6c44b741a | |||
| 5095b23eb2 | |||
| cc25bb3dc2 | |||
| 3b54a13cae | |||
| 9bbe57ee85 | |||
| 4938faa049 | |||
| cc8eedcff7 | |||
| 9734df15b4 | |||
| 2ceb1e2850 | |||
| 942717f0b6 | |||
| 0f160e157f | |||
| e9e9ee20c6 | |||
| f0ee30fc68 | |||
| 51de85a6ca | |||
| 4064ea7d3a | |||
| 35b291ab19 | |||
| e711e643f1 | |||
| db38ad80e6 | |||
| 326df32933 | |||
| 31ef9ffa06 | |||
| 38d4c59b4c | |||
| b9bf006998 | |||
| 5ab0e88abc | |||
| 84146b62fd | |||
| adeb40bfff | |||
| 8a21837ca2 | |||
| 3302147a43 | |||
| 4d87ae7637 | |||
| b1a653f18f | |||
| 6fe0543d2e | |||
| 931dfbe1d3 | |||
| b5c1331911 | |||
| 37f2cba72d | |||
| 0e789cb38c | |||
| f3b3393a16 | |||
| 480a3f4ea5 | |||
| 69a62d739c | |||
| 28fbb86dce | |||
| f88fe2665c | |||
| 3592eab48c | |||
| 1dbdf5b00c | |||
| fc3b2d7c13 | |||
| f2a7297cb9 | |||
| 57729375b6 | |||
| fa7ce725c1 | |||
| 27da7b3ca3 | |||
| 3070838a42 | |||
| fe160134d0 | |||
| 6d3f82976f | |||
| 91b2735dad | |||
| 3112869a21 | |||
| aa630bf85b | |||
| e0052cd237 | |||
| 3cdc5ba7a2 | |||
| 04485e384f | |||
| a03dc4002f | |||
| cc9d3bff42 | |||
| 2672b3331b | |||
| 4963bf2b2e | |||
| 51cad6f852 | |||
| 1b5cbbbaa0 | |||
| e747946f7a | |||
| 53fa22f39c | |||
| 82b34097fb | |||
| a7995db009 | |||
| 5ae4aef30e | |||
| 08023f0cd9 |
@@ -15,6 +15,7 @@ node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
recipe_cache/
|
||||
|
||||
# agent / dev tooling
|
||||
.opencode/
|
||||
|
||||
@@ -166,7 +166,7 @@ The system runs in two modes:
|
||||
|
||||
### Model Types & Routes
|
||||
|
||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
|
||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*`, `/other/*` patterns
|
||||
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
||||
- Request handlers in `py/routes/handlers/` implement route logic
|
||||
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
||||
@@ -190,6 +190,8 @@ The system runs in two modes:
|
||||
|
||||
- `py/config.py` manages folder paths for models and handles symlink mappings
|
||||
- Auto-saves paths to `settings.json` in ComfyUI mode
|
||||
- `settings.json.example` is intentionally minimal (see Important Notes); all
|
||||
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
|
||||
|
||||
### Frontend UI Architecture
|
||||
|
||||
@@ -250,6 +252,12 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
|
||||
## Important Notes
|
||||
|
||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
|
||||
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
|
||||
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
|
||||
(model-category folders, `default_*_root`, `auto_organize_exclusions`, etc.)
|
||||
to this file unless the user explicitly asks for it. Defaults belong in
|
||||
`DEFAULT_SETTINGS` in `py/services/settings_manager.py`.
|
||||
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||
- Symlinks require normalized paths.
|
||||
**Business paths vs real paths**: All stored paths and operation routing use the
|
||||
|
||||
+246
-235
@@ -7,190 +7,199 @@
|
||||
],
|
||||
"allSupporters": [
|
||||
"Takkan",
|
||||
"2018cfh",
|
||||
"megakirbs",
|
||||
"Brennok",
|
||||
"Charles Blakemore",
|
||||
"2018cfh",
|
||||
"Rob Williams",
|
||||
"Insomnia Art Designs",
|
||||
"Charles Blakemore",
|
||||
"Arlecchino Shion",
|
||||
"Insomnia Art Designs",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"stone9k",
|
||||
"Kiba",
|
||||
"onesecondinosaur",
|
||||
"Skalabananen",
|
||||
"Sterilized",
|
||||
"Polymorphic Indeterminate",
|
||||
"Liam MacDougal",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"Rosenthal",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"レプサイ",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"Mozzel",
|
||||
"fnkylove",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Marc Whiffen",
|
||||
"Dogwalkerbr",
|
||||
"Birdy",
|
||||
"Kiba",
|
||||
"quarz",
|
||||
"$MetaSamsara",
|
||||
"jean jahren",
|
||||
"Reno Lam",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"JSST",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"Anthony+Rizzo",
|
||||
"W+K+White",
|
||||
"ClockDaemon",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Nolife_M",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"SG",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"RedrockVP",
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"FinalyFree",
|
||||
"Fyf",
|
||||
"レプサイ",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"fnkylove",
|
||||
"Luc Job",
|
||||
"corde",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"wildnut",
|
||||
"Edgar Tejeda",
|
||||
"Sterilized",
|
||||
"BadassArabianMofo",
|
||||
"Dogwalkerbr",
|
||||
"quarz",
|
||||
"MiraiKuriyamaSy",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"jean jahren",
|
||||
"AM Kuro",
|
||||
"JSST",
|
||||
"Akira HentAI",
|
||||
"otaku fra",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"wackop",
|
||||
"Phil",
|
||||
"Greenmoustache",
|
||||
"Carl G.",
|
||||
"wfpearl",
|
||||
"jeaness",
|
||||
"Dsperado",
|
||||
"Baekdoosixt",
|
||||
"Jack B Nimble",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"bh",
|
||||
"Marlon Daniels",
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"Olive",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"SarcasticHashtag",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"Some Guy Named Barry",
|
||||
"M Postkasse",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Big Red",
|
||||
"Serge Bekenkamp",
|
||||
"AIJimmy",
|
||||
"Luc Job",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Bishoujoker",
|
||||
"dan",
|
||||
"aai",
|
||||
"wildnut",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"itismyelement",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Karl P.",
|
||||
"Akira HentAI",
|
||||
"MiraiKuriyamaSy",
|
||||
"LarsesFPC",
|
||||
"otaku fra",
|
||||
"andrew.tappan",
|
||||
"Weird_With_A_Beard",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Greenmoustache",
|
||||
"fancypants",
|
||||
"jeaness",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"Jwk0205",
|
||||
"Bro Xie",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Some Guy Named Barry",
|
||||
"jinxedx",
|
||||
"M Postkasse",
|
||||
"AELOX",
|
||||
"Dankin-Pics",
|
||||
"Nicfit23",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"DogmaR34",
|
||||
"Matt Wenzel",
|
||||
"Frank Nitty",
|
||||
"Christopher Michel",
|
||||
"runte3221",
|
||||
"Serge Bekenkamp",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"dl0901dm",
|
||||
"Antonio Pontes",
|
||||
"kushiroK9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Paul Kroll",
|
||||
"Fraser Cross",
|
||||
"Bas Imagineer",
|
||||
"Dušan Ryban",
|
||||
"Adam Taylor",
|
||||
"Weird_With_A_Beard",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"fancypants",
|
||||
"John+Edwards",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"Bro Xie",
|
||||
"seed123_AIart",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"AELOX",
|
||||
"Dankin-Pics",
|
||||
"Nicfit23",
|
||||
"Cristian Vazquez",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"DogmaR34",
|
||||
"Frank Nitty",
|
||||
"The Magic Noob",
|
||||
"Christopher Michel",
|
||||
"runte3221",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"dl0901dm",
|
||||
"Antonio Pontes",
|
||||
"Bruce",
|
||||
"kushiroK9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Blackfish95",
|
||||
"Tori",
|
||||
"Mouthlessman",
|
||||
"Paul Kroll",
|
||||
"Fraser Cross",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
"Dušan Ryban",
|
||||
"Adam Taylor",
|
||||
"decoy",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
@@ -201,39 +210,38 @@
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
"Tyrswood",
|
||||
"linnfrey",
|
||||
"griffin+dahlberg",
|
||||
"John+Edwards",
|
||||
"ElitaSSJ4",
|
||||
"Matt+J",
|
||||
"Josef Lanzl",
|
||||
"New folder (1)",
|
||||
"seed123_AIart",
|
||||
"Error_Rule34_Not_found",
|
||||
"Roslynd",
|
||||
"Geolog",
|
||||
"Neco28",
|
||||
"Resist's Creations - Spicy Edition 🔥",
|
||||
"David Ortega",
|
||||
"Wolffen",
|
||||
"Cristian Vazquez",
|
||||
"The Magic Noob",
|
||||
"Jeff",
|
||||
"nwalker94",
|
||||
"Bruce",
|
||||
"Kevin Christopher",
|
||||
"Chad Idk",
|
||||
"Tori",
|
||||
"dd",
|
||||
"John Statham",
|
||||
"sjon kreutz",
|
||||
"Metryman55",
|
||||
"AlexDuKaNa",
|
||||
"decoy",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"MJG",
|
||||
"linnfrey",
|
||||
"griffin+dahlberg",
|
||||
"ElitaSSJ4",
|
||||
"Matt+J",
|
||||
"Josef Lanzl",
|
||||
"New folder (1)",
|
||||
"sanborondon",
|
||||
"Error_Rule34_Not_found",
|
||||
"jcay015",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Geolog",
|
||||
"Neco28",
|
||||
"Eris3D",
|
||||
"Resist's Creations - Spicy Edition 🔥",
|
||||
"David Ortega",
|
||||
"Wolffen",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"nwalker94",
|
||||
"James Coleman",
|
||||
"Kevin Christopher",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Metryman55",
|
||||
"AlexDuKaNa",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
@@ -248,37 +256,41 @@
|
||||
"Kland",
|
||||
"Hailshem",
|
||||
"Naomi Hale Danchi",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"sanborondon",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Brian Buie",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"RAIDiation",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Eris3D",
|
||||
"Sadlip",
|
||||
"Gooohokrbe",
|
||||
"m",
|
||||
"OldBones",
|
||||
"Pierce McBride",
|
||||
"Zach Gonser",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"James Coleman",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Martial",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Rops Alot",
|
||||
"Sam",
|
||||
"Penfore",
|
||||
"Gordon Cole",
|
||||
"Ace Ventura",
|
||||
"AbstractAss",
|
||||
"David LaVallee",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
@@ -297,15 +309,19 @@
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Adam Rinehart",
|
||||
"gzmzmvp",
|
||||
"takyamtom",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Aberr",
|
||||
"Gregory Kozhemiak",
|
||||
"Brian Buie",
|
||||
"aezin",
|
||||
"Sadlip",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
@@ -315,17 +331,12 @@
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"SloanSteddyAI",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Steven Owens",
|
||||
"hexxish",
|
||||
"Derek Baker",
|
||||
"Atilla Berke Pekduyar",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Decx _",
|
||||
"Ed Wang",
|
||||
"Saya",
|
||||
"Xeeosat",
|
||||
@@ -333,18 +344,10 @@
|
||||
"四糸凜音",
|
||||
"esthe",
|
||||
"FrxzenSnxw",
|
||||
"Crocket",
|
||||
"chriphost",
|
||||
"ResidentDeviant",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Ginnie",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"IamAyam",
|
||||
"TheD1rtyD03",
|
||||
@@ -356,17 +359,25 @@
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"Sora+Yori",
|
||||
"Duk3+Rand0m",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"D",
|
||||
"David Schenck",
|
||||
"Wolfe7D1",
|
||||
"Aberr",
|
||||
"Andrew Marshall",
|
||||
"Taylor Funk",
|
||||
"elleshar666",
|
||||
"Gerald Welly",
|
||||
"Tee Gee",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"tarek helmi",
|
||||
"Kauffy",
|
||||
"Max Marklund",
|
||||
@@ -376,13 +387,15 @@
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"hexxish",
|
||||
"lh qwe",
|
||||
"conner",
|
||||
"Xenon Xue",
|
||||
"Michael Anthony Scott",
|
||||
"notedfakes",
|
||||
"Princess Bright Eyes",
|
||||
"Michael Scott",
|
||||
"Solixer",
|
||||
"Jimmy Borup",
|
||||
"Wes Sims",
|
||||
"Donor4115",
|
||||
"Filippo Ferrari",
|
||||
@@ -393,11 +406,19 @@
|
||||
"momokai",
|
||||
"몽타주",
|
||||
"kudari",
|
||||
"Whitepinetrader",
|
||||
"OrganicArtifact",
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"CHKeeho80",
|
||||
"nanana",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
"ghoulars",
|
||||
"null",
|
||||
"Beau",
|
||||
"redcarrot",
|
||||
"powerbot99",
|
||||
"Fthehappy",
|
||||
"J",
|
||||
"Jeff+Kesemeyer",
|
||||
@@ -407,39 +428,32 @@
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"D",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"Sora+Yori",
|
||||
"Duk3+Rand0m",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"준희 김",
|
||||
"りん あめ",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Tomohiro Baba",
|
||||
"Noora",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Kor",
|
||||
"Bryan Rutkowski",
|
||||
"Noah",
|
||||
"Xenon Xue",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"DarkSunset",
|
||||
"Edward Ten Eyck",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
"Solixer",
|
||||
"Pete Pain",
|
||||
"Nathan",
|
||||
"Jimmy Borup",
|
||||
"tedcor",
|
||||
"RHopkirk",
|
||||
"jinksta187",
|
||||
"Fotek Design",
|
||||
"Maxim",
|
||||
"Manu Thetug",
|
||||
"Lyavph",
|
||||
"Nihongasuki",
|
||||
@@ -450,8 +464,14 @@
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"Inversity",
|
||||
"Whitepinetrader",
|
||||
"Vir",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
"2turbo",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"tanjin90",
|
||||
"sternenkrieger",
|
||||
"Pascalou",
|
||||
"Patrick+Bryan",
|
||||
"lighthawke",
|
||||
@@ -468,23 +488,17 @@
|
||||
"Bob+Barker",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
"ghoulars",
|
||||
"redcarrot",
|
||||
"null",
|
||||
"Beau",
|
||||
"powerbot99",
|
||||
"Ko-fi+Supporter",
|
||||
"lrdchs2",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Kalli Core",
|
||||
"Ben D",
|
||||
"Draven T",
|
||||
"marioandluigi",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"Leslie Andrew Ridings",
|
||||
"Aquatic Coffee",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
@@ -492,25 +506,27 @@
|
||||
"StudOx Tech",
|
||||
"yves.poezevara",
|
||||
"Jarrid Lee",
|
||||
"Kor",
|
||||
"Poophead27 Blyat",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Focuschannel",
|
||||
"Boba Smith",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"ivistorm",
|
||||
"Anthony Faxlandez",
|
||||
"Sauv",
|
||||
"TenaciousD",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"Zeeble",
|
||||
"Pat Hen",
|
||||
"Pete Pain",
|
||||
"Draconach",
|
||||
"Tigon",
|
||||
"ItsGeneralButtNaked",
|
||||
"Jordan Shaw",
|
||||
"RHopkirk",
|
||||
"g unit",
|
||||
"Maxim",
|
||||
"Dkom22",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Distortik",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
@@ -518,11 +534,22 @@
|
||||
"Marcus thronico",
|
||||
"zenobeus",
|
||||
"ryoma",
|
||||
"dg",
|
||||
"Stryker",
|
||||
"smart.edge5178",
|
||||
"Menard",
|
||||
"SomeDude",
|
||||
"raf8osz",
|
||||
"Gold_miner_ego",
|
||||
"bakeliteboy",
|
||||
"TequiTequi",
|
||||
"Homero+Banda",
|
||||
"Nick",
|
||||
"Monix",
|
||||
"Trolinka",
|
||||
"PredragR",
|
||||
"Clauzmak",
|
||||
"Nerick",
|
||||
"SundayRage",
|
||||
"matter",
|
||||
"SRCRCOSS",
|
||||
@@ -539,13 +566,6 @@
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
"2turbo",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"tanjin90",
|
||||
"sternenkrieger",
|
||||
"boston666",
|
||||
"cocona",
|
||||
"Obsidian.Studios",
|
||||
@@ -553,52 +573,53 @@
|
||||
"Aquaneo",
|
||||
"blikkies",
|
||||
"JBsuede",
|
||||
"shira1011",
|
||||
"Wolf and Fox Legends",
|
||||
"ゼクス、六",
|
||||
"Neko Desco",
|
||||
"Vinarus",
|
||||
"Josh Snyder",
|
||||
"Shock Shockor",
|
||||
"Goldwaters",
|
||||
"Leslie Andrew Ridings",
|
||||
"Zude",
|
||||
"Poophead27 Blyat",
|
||||
"Room Light",
|
||||
"Kyler",
|
||||
"Justin Blaylock",
|
||||
"aRtFuL_DodGeR",
|
||||
"Snorklebort",
|
||||
"TheFusion",
|
||||
"MR.Bear",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Steven",
|
||||
"Borte",
|
||||
"Sage Himeros",
|
||||
"yyuvuvu",
|
||||
"Billy Gladky",
|
||||
"Nomki",
|
||||
"Probis",
|
||||
"Jack Lawfield",
|
||||
"SkibidiRizzler",
|
||||
"Maxon - Plans",
|
||||
"Kalle Björk",
|
||||
"ItsGeneralButtNaked",
|
||||
"Karlanx",
|
||||
"operationancut",
|
||||
"Nacho Ferrando",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Dkom22",
|
||||
"Youguang",
|
||||
"andrewzpong",
|
||||
"BossGame",
|
||||
"lrdchs",
|
||||
"Tree Tagger",
|
||||
"Janik",
|
||||
"AIVORY3D",
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"dg",
|
||||
"POPPIN",
|
||||
"meatyalien",
|
||||
"Tony+V",
|
||||
"draganjankovic1975dj528",
|
||||
"kinz",
|
||||
"YoruHime",
|
||||
"Mark+Staaf",
|
||||
"Michael+Fürmann",
|
||||
@@ -611,17 +632,7 @@
|
||||
"thomasand01",
|
||||
"Shiba+Sama",
|
||||
"Celestial+Kitten",
|
||||
"TequiTequi",
|
||||
"Homero+Banda",
|
||||
"bakeliteboy",
|
||||
"Nick",
|
||||
"Gold_miner_ego",
|
||||
"IshouI;_;",
|
||||
"Monix",
|
||||
"Trolinka",
|
||||
"PredragR",
|
||||
"Clauzmak",
|
||||
"Nerick",
|
||||
"SAVEagleBasement",
|
||||
"Adam+Spreer",
|
||||
"BillyBoy84",
|
||||
@@ -629,18 +640,17 @@
|
||||
"Welkor",
|
||||
"dubious1one",
|
||||
"Brandon Thomas",
|
||||
"Dustin Hendel",
|
||||
"moranqianlong",
|
||||
"Wolf and Fox Legends",
|
||||
"ゼクス、六",
|
||||
"Liberation",
|
||||
"Ninja Tom",
|
||||
"75marc",
|
||||
"Elemnt",
|
||||
"Bradley Turner",
|
||||
"swra",
|
||||
"JollRodrigo",
|
||||
"Oliverfish",
|
||||
"uruksayshi",
|
||||
"Room Light",
|
||||
"Patryk Serious",
|
||||
"nk8",
|
||||
"Kyron Mahan",
|
||||
@@ -648,17 +658,18 @@
|
||||
"Nimhloth",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Tsani Prodanov",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"J M",
|
||||
"Slacks",
|
||||
"Glenn Hoetker",
|
||||
"JackJohnnyJim",
|
||||
"Khánh Đặng",
|
||||
"Michael Hicks",
|
||||
"Homero Banda",
|
||||
"Michael Docherty",
|
||||
"yyuvuvu",
|
||||
"Nomki",
|
||||
"MadGod",
|
||||
"GhostyGhost",
|
||||
"Paul Hartsuyker",
|
||||
"elitassj",
|
||||
"Never_M",
|
||||
@@ -667,6 +678,7 @@
|
||||
"Andrew Wilkinson",
|
||||
"David",
|
||||
"floeki75pad",
|
||||
"TheJohnes",
|
||||
"deadwishd",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
@@ -675,7 +687,6 @@
|
||||
"xybrightsummer",
|
||||
"jreedatchison",
|
||||
"PhilW",
|
||||
"Janik",
|
||||
"Cruel",
|
||||
"MRBlack",
|
||||
"Kiyoe",
|
||||
@@ -685,6 +696,15 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"Daevalus",
|
||||
"Milky+Mai",
|
||||
"Krash",
|
||||
"PP",
|
||||
"thababydjac",
|
||||
"belligerencebk",
|
||||
"tortor",
|
||||
"Peter",
|
||||
"T",
|
||||
"zipzorpp",
|
||||
"Anton",
|
||||
"actual",
|
||||
@@ -706,11 +726,7 @@
|
||||
"plonk",
|
||||
"Anvil+Girl",
|
||||
"Kotetsu",
|
||||
"meatyalien",
|
||||
"Tony+V",
|
||||
"draganjankovic1975dj528",
|
||||
"miduzza",
|
||||
"kinz",
|
||||
"Somebody",
|
||||
"てぃんてぃんひーろー",
|
||||
"you+halo9",
|
||||
@@ -727,12 +743,12 @@
|
||||
"4IXplr0r3r",
|
||||
"hayden",
|
||||
"ahoystan",
|
||||
"Civitaier",
|
||||
"BakunyuuWaifu",
|
||||
"edk",
|
||||
"Dustin Hendel",
|
||||
"Joey Leto",
|
||||
"Anagra Nouma",
|
||||
"tafapayo",
|
||||
"Bradley Turner",
|
||||
"ja s",
|
||||
"Doug Mason",
|
||||
"scoreswazey",
|
||||
@@ -747,8 +763,8 @@
|
||||
"David Murcko",
|
||||
"Justin Defer",
|
||||
"Ben Brogger",
|
||||
"Tsani Prodanov",
|
||||
"Jack Dole",
|
||||
"dsffsdfsdfsdfsdfsdf",
|
||||
"V Bj",
|
||||
"Rj Joplin",
|
||||
"Kurt",
|
||||
@@ -757,15 +773,13 @@
|
||||
"Taylor Dominy",
|
||||
"Faith",
|
||||
"Bouya shaka",
|
||||
"Michael Hicks",
|
||||
"Maso",
|
||||
"MadGod",
|
||||
"Kevin Wallace",
|
||||
"GhostyGhost",
|
||||
"ChicRic",
|
||||
"Bastard-Sama",
|
||||
"mercur",
|
||||
"Sunny",
|
||||
"Somebody",
|
||||
"inusanorthcape",
|
||||
"Kane Sturzebecher",
|
||||
"Yavizu3d",
|
||||
@@ -776,7 +790,6 @@
|
||||
"Evgeniya Smolentseva",
|
||||
"Raf Stahelin",
|
||||
"Вячеслав Маринин",
|
||||
"TheJohnes",
|
||||
"Cola Matthew",
|
||||
"OniNoKen",
|
||||
"Iain Wisely",
|
||||
@@ -819,6 +832,12 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"mcmalt",
|
||||
"cesasol",
|
||||
"Null",
|
||||
"fdfac",
|
||||
"Eli",
|
||||
"Somebody",
|
||||
"8/4",
|
||||
"ivan.morgado.siles",
|
||||
"SEI",
|
||||
@@ -830,16 +849,7 @@
|
||||
"gdfgfdgfds",
|
||||
"Benjamin+Doerr",
|
||||
"D",
|
||||
"Daevalus",
|
||||
"MilkyMai",
|
||||
"Krash",
|
||||
"PP",
|
||||
"babydjac",
|
||||
"belligerencebk",
|
||||
"tortor",
|
||||
"Cryphius",
|
||||
"Peter+Timothy+Stover",
|
||||
"Joel+Magnusson",
|
||||
"Connor+Hall",
|
||||
"Macho+Grump",
|
||||
"Morcoddd",
|
||||
@@ -879,13 +889,11 @@
|
||||
"proto merp",
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"Civitaier",
|
||||
"Hans Meier",
|
||||
"jboul",
|
||||
"Michael Eid",
|
||||
"Super Sigma Reborne",
|
||||
"Veloce",
|
||||
"Joey Leto",
|
||||
"Bob barker",
|
||||
"Michael Rivera",
|
||||
"karim ben brik",
|
||||
@@ -916,6 +924,7 @@
|
||||
"DrB",
|
||||
"wknight",
|
||||
"Moneymaker412K",
|
||||
"Jacid",
|
||||
"unkeiknown",
|
||||
"Towelie",
|
||||
"Alex Ross",
|
||||
@@ -926,10 +935,12 @@
|
||||
"john Greene",
|
||||
"jimyjomson",
|
||||
"JaeHyun Jang",
|
||||
"sbone",
|
||||
"BigBoss",
|
||||
"Chase Kwon",
|
||||
"Bob Ling",
|
||||
"Inyoshu",
|
||||
"nick Meadows",
|
||||
"Chad Barnes",
|
||||
"redlines3",
|
||||
"Adam Gardner",
|
||||
@@ -944,6 +955,7 @@
|
||||
"Somebody",
|
||||
"Somebody",
|
||||
"Somebody",
|
||||
"Somebody",
|
||||
"CoffeeMage",
|
||||
"Ken+Suzuki",
|
||||
"hannibal",
|
||||
@@ -954,8 +966,7 @@
|
||||
"L C",
|
||||
"Dude",
|
||||
"Somebody",
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 954
|
||||
"totalCount": 965
|
||||
}
|
||||
+124
-8
@@ -62,27 +62,143 @@ Environment variable overrides: `LLM_API_KEY`, `LLM_MODEL`, `LLM_API_BASE`, `LLM
|
||||
|
||||
### enrich_hf_metadata
|
||||
|
||||
Enriches HuggingFace-downloaded models with metadata extracted by an LLM from the HF model card.
|
||||
Enriches models linked to an external model site with metadata extracted by an LLM from the site's model card (README).
|
||||
|
||||
**Entry point**: Right-click context menu → "Enrich Metadata (Agent)"
|
||||
**Entry point**: Right-click context menu → "Enrich Metadata with AI"
|
||||
|
||||
**Supported model sources**:
|
||||
|
||||
| Platform | Link | AI enrichment | Direct download |
|
||||
| --- | --- | --- | --- |
|
||||
| Hugging Face | yes | yes | yes |
|
||||
| ModelScope (`modelscope.cn`) | yes | yes | yes |
|
||||
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
|
||||
| TensorArt | yes | no (see below) | no |
|
||||
|
||||
`modelscope.cn` and `modelscope.ai` are **separate catalogues, not mirrors** — a
|
||||
repository published on one is routinely absent from the other — so each is
|
||||
registered as its own source (`ModelScopeSource` / `ModelScopeIntlSource` in
|
||||
`py/services/model_sources/modelscope.py`). The host therefore decides which
|
||||
API and CDN a model resolves against, and the two deployments get separate
|
||||
version groups (`ms:` / `msai:`) and default download directories. Keep the two
|
||||
tables in `modelSourceHelpers.js` and `registry.py` in step when adding a site.
|
||||
|
||||
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
|
||||
|
||||
**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
|
||||
1. Reads the model's `.metadata.json` to get the source (`source_platform` + `source_url`, or the legacy `hf_url`)
|
||||
2. Fetches the model card through the provider in `py/services/model_sources/` — the README via `fetch_model_card()`, plus any extras the site keeps outside it via `fetch_model_card_context()`
|
||||
3. Sends the README + site-provided extras + 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)
|
||||
- `modelDescription` — the site's author description (if any) followed by the README rendered as HTML
|
||||
- `tags` — merged with existing tags, deduplicated
|
||||
- `civitai.images` — example images
|
||||
- `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)
|
||||
5. Downloads and optimizes a preview image, using the per-file example image the
|
||||
site publishes when the README has none
|
||||
6. Updates the scanner cache
|
||||
7. Broadcasts WebSocket progress events
|
||||
|
||||
#### Site-provided card extras (`fetch_model_card_context`)
|
||||
|
||||
A model card is not always just `README.md`. ModelScope keeps the author's
|
||||
summary (`Description`), the site-curated tags (`OfficialTags`), and — per
|
||||
published version — the model filenames together with that file's example
|
||||
images (`MuseInfo.versions[].coverImages`) and trigger words in its
|
||||
model-detail API. AIGC repositories there often ship an auto-generated
|
||||
boilerplate README and put everything useful in `Description`, so reading only
|
||||
the README yields almost nothing.
|
||||
|
||||
Providers opt in by overriding `ModelSource.fetch_model_card_context()`, which
|
||||
returns a `ModelCardContext`. The wanted file is identified by its sha256 when
|
||||
the caller knows it (the scanner already records one) and by **basename**
|
||||
otherwise, so each checkpoint in a collection repo gets its own images — and
|
||||
keeps getting them after the user renames the weights, which is the only
|
||||
identifier a rename cannot invalidate. Sites with no such extras inherit an
|
||||
empty context, and the pipeline behaves exactly as before.
|
||||
|
||||
The README and the repository metadata describe the whole repository, not one
|
||||
file, so `execute_skill()` creates a `ModelSourceCache` for the duration of a
|
||||
run and passes it down. Enriching the eight checkpoints of one ModelScope
|
||||
repository costs two HTTP requests instead of sixteen; only the per-file
|
||||
selection is redone for each file. Nothing is cached across runs, and download
|
||||
URLs never go through it.
|
||||
|
||||
#### Deterministic data is applied whether or not an LLM is configured
|
||||
|
||||
`AgentService._load_source_card()` runs for every source-backed enrichment, and
|
||||
the post-processor applies what it returns before the LLM output is merged. A
|
||||
user with **no** provider configured therefore still gets the author summary,
|
||||
the example images, the preview, the site-curated tags, the trigger words and
|
||||
the README rendered as the model description.
|
||||
|
||||
The LLM is always consulted when one is configured — invoking **Enrich Metadata
|
||||
with AI** must call the provider every time, and the site data is never treated
|
||||
as a reason to skip it. The deterministic values act as fallbacks that fill
|
||||
gaps the LLM leaves behind:
|
||||
|
||||
| Field | Deterministic source | LLM role |
|
||||
| --- | --- | --- |
|
||||
| `model_name` | site display name (`Name`), written only while the value is still the file stem | — |
|
||||
| `modelDescription` | author summary + README as HTML | — |
|
||||
| `civitai.name` | the matched version's label (`modelVersion.showName`) | — |
|
||||
| `civitai.images` | site example images, then README images | — |
|
||||
| `preview_url` | first available example image | may propose one from the README |
|
||||
| `tags` | site-curated tags, always merged in | proposes additional content tags |
|
||||
| `civitai.description` | author summary | richer 1-2 sentence summary wins |
|
||||
| `base_model` | site hints resolved against the canonical vocabulary (`py/services/agent/base_model_resolver.py`) | mapping it is the LLM's job; the resolver only fills in when the LLM returns nothing |
|
||||
| `trainedWords` | per-file site trigger words, then YAML `instance_prompt` | primary extraction |
|
||||
| `usage_tips` | regex over an explicitly stated strength range | primary extraction |
|
||||
| `notes` | — | LLM-only |
|
||||
|
||||
Models with no source, an unknown source, or a source without model-card access (TensorArt) are skipped with an explicit reason and counted in the run summary.
|
||||
|
||||
**Model types**: LoRA, Checkpoint, Embedding
|
||||
|
||||
### Download-time hydration
|
||||
|
||||
The same deterministic mapping runs automatically when a model is downloaded
|
||||
from a model source, so a ModelScope or Hugging Face download lands with the
|
||||
populated card a CivitAI download produces instead of a bare filename and
|
||||
hash. Nothing needs to be triggered by hand and no provider is called.
|
||||
|
||||
`py/services/model_sources/hydration.py` owns this path:
|
||||
|
||||
* `_save_source_metadata()` in `py/routes/handlers/model_source_handlers.py`
|
||||
creates the sidecar (hash, source link, scanner-cache entry) and then calls
|
||||
`hydrate_from_source()`. It also runs for a file that was already on disk, so
|
||||
models downloaded before this existed get topped up on the next attempt.
|
||||
* Metadata is created through the **owning scanner**
|
||||
(`scanner._create_default_metadata()`) rather than
|
||||
`MetadataManager.create_default_metadata()`, so the per-type lazy-hash rule
|
||||
applies: `CheckpointScanner` and `OtherScanner` store
|
||||
`hash_status="pending"` with an empty `sha256` for their multi-GB files, and
|
||||
the generic helper would read a 10 GB checkpoint end to end inside the
|
||||
download request. Hydration copes with the empty hash — `_matching_versions()`
|
||||
falls back to the repository basename, which the download just wrote.
|
||||
* Hydration reuses `PostProcessor` with an empty `llm_output`, so the two paths
|
||||
cannot drift apart. It reports `metadata_source = "source:<platform>"` rather
|
||||
than the skill's `agent:enrich_hf_metadata`, and — because no provider ran —
|
||||
it does not stamp `llm_enriched_at`.
|
||||
* `model_name` is only written while it still equals the file stem: once a user
|
||||
renames a model, that choice is kept.
|
||||
* Only a model whose stored `source_platform`/`source_url` match the repository
|
||||
being downloaded is updated; a local file that merely shares a name must not
|
||||
receive another model's card.
|
||||
* The README and repository payload describe the *repository*, so a short-lived
|
||||
process-wide `ModelSourceCache` (`shared_source_cache`, 300 s, 32 entries)
|
||||
keeps a batch over one repository to two HTTP requests.
|
||||
* Every failure — unreachable site, changed payload shape, broken post-processor
|
||||
— is logged and swallowed. Metadata hydration can never fail a download.
|
||||
* Neither stage advances the byte counter, so both are announced to the
|
||||
progress UI (`_report_phase()` → `{"status": "metadata", "stage": ...}`) as
|
||||
they start. Without that the bar sits at 100% reporting `0 B/s` for several
|
||||
seconds and the download looks stuck. `stage` and `platform` are
|
||||
machine-readable; the wording is localised in `LoadingManager`.
|
||||
|
||||
## Adding a New Skill
|
||||
|
||||
### 1. Create the skill directory
|
||||
@@ -129,7 +245,7 @@ Use `{{variable}}` placeholders that will be replaced with data from the `prepar
|
||||
```markdown
|
||||
You are an expert assistant...
|
||||
|
||||
Model URL: {{hf_url}}
|
||||
Model URL: {{source_url}}
|
||||
README content:
|
||||
{{readme_content}}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
|
||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||
`locales/`.
|
||||
|
||||
Source of truth: `locales/en.json` (10 locales, 1810 leaf keys; all locales share the exact
|
||||
Source of truth: `locales/en.json` (10 locales, 2025 leaf keys; all locales share the exact
|
||||
same key structure).
|
||||
|
||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
@@ -13,6 +13,58 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> stale-text, and untranslated-block fixes described in §2–§6 were applied across all locales
|
||||
> (commits `3c3ac49f` … `fd1227d3`). The tables below are now the **normative target state**,
|
||||
> not a to-do list — future edits should preserve these renderings and only add what is new.
|
||||
>
|
||||
> **Status (2026-09, Other Models):** the `other` model type (VAE / Upscaler / Text Encoder /
|
||||
> CLIP Vision / ControlNet) and the Other Models opt-in toggles added 36 new keys; all of them
|
||||
> are now translated in all 9 locales (terminology in §2 "Other Models feature"). There are no
|
||||
> remaining `[TODO: Translate]` placeholders in any locale.
|
||||
>
|
||||
> **Status (2026-09, revision):** `other.disabled.description`, `banners.otherModels.content` and
|
||||
> `settings.folderSettings.enableOtherModelsHelp` were refreshed in `en.json` to name all five
|
||||
> sub_types (they had listed four, which read as "these are what enabling manages") and
|
||||
> re-translated in all 9 locales in the same pass. `clip_vision` and `controlnet` are now both
|
||||
> opt-in, so the first two describe **capability** and the third the **master switch**, not the
|
||||
> default set — keep all three enumerating the full five (`VAE / upscaler / text encoder /
|
||||
> CLIP vision / ControlNet` in `en`; locale slash-list casing follows each file's existing
|
||||
> `VAE / Upscaler / Text Encoder / …` style, de compounds as `CLIP-Vision- und ControlNet-Ordner`).
|
||||
>
|
||||
> **Status (2026-09, "no folders found" state):** the Other Models page gained an *enabled but
|
||||
> nothing to scan* empty state with 6 new keys (`other.noPaths.*`); translated in all 9 locales
|
||||
> in the same pass. The `folder_paths` JSON snippet shown in that state lives in
|
||||
> `templates/other.html`, **not** in the locale files, so it is never translated — only the
|
||||
> surrounding prose is. Terminology added in §2.
|
||||
>
|
||||
> **Status (2026-09, model sources):** models can now be linked to ModelScope and TensorArt
|
||||
> alongside Hugging Face, which added 15 keys (`modelCard.actions.viewOnSource`,
|
||||
> `loras.contextMenu.linkModelSource`, `modals.linkModelSource.*`,
|
||||
> `modals.model.versions.sourceGroupInfo`, `toast.contextMenu.enrichNeedsSource`,
|
||||
> `toast.contextMenu.enrichUnsupportedSource`) and refreshed the two `enrichHfAgent` labels,
|
||||
> which had hardcoded "HF" for a button that now also enriches ModelScope models. The
|
||||
> `modals.linkModelSource.urlPlaceholder` value stays byte-identical to `en.json` (it is a URL,
|
||||
> the §6 exception). Terminology in §2, "Model source feature".
|
||||
>
|
||||
> **Status (2026-09, folder sidebar):** the model-root sidebar gained on-disk folder management
|
||||
> (create / rename / delete folders, show empty folders, tree vs list view) plus its `...`
|
||||
> view-options menu, adding 35 `sidebar.*` keys. Those were the only `[TODO: Translate]`
|
||||
> placeholders left behind by the feature series, and all 35 are now translated in all 9
|
||||
> locales, so the "no remaining placeholders" claim above holds again. Terminology in §2,
|
||||
> "Folder sidebar feature".
|
||||
>
|
||||
> **Status (2026-09, chip reordering):** model tags and trigger words now share one drag/`⠿`
|
||||
> grip reorder affordance, which added the single `common.reorder.dragHandle` key (it lives
|
||||
> under `common` because both editors render it). All 9 locales are translated (renderings in
|
||||
> §2, "Chip reordering"). Reordering is pointer-only by design: an `Alt + Arrow` shortcut was
|
||||
> prototyped and removed because it collided with the browser's Alt + Arrow handling and the
|
||||
> modal's arrow-key navigation.
|
||||
|
||||
> **Status (2026-09, standalone no-paths guidance):** the standalone branch of the
|
||||
> `other.noPaths` empty state now shows the real `settings.json` path plus an
|
||||
> `other.noPaths.openSettingsFolder` button (each locale reuses its
|
||||
> `settings.openSettingsFileLocation.label` rendering), and `descriptionStandalone` was
|
||||
> reworded in `en.json` — from "none of the configured folders exist on disk" to "no
|
||||
> other-model folders were found; add the folder keys you need to the `folder_paths`
|
||||
> section" — and re-translated in all 9 locales. The `on disk` phrase now survives only in
|
||||
> the ComfyUI variant (`descriptionComfyUI`).
|
||||
|
||||
---
|
||||
|
||||
@@ -222,6 +274,123 @@ and must be normalized. `en` = keep the English word as-is.
|
||||
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
|
||||
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
|
||||
|
||||
### Other Models feature (VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet)
|
||||
|
||||
The `other` model type exposes five sub_types. They are **model-type names**, so they follow
|
||||
R3 and stay in Latin in every locale. The `settings.folderSettings.subType*` values are
|
||||
therefore **intentionally byte-identical to `en.json`** (same precedent as
|
||||
`settings.priorityTags.modelTypes` / `checkpoints.modelTypes.checkpoint`) — a §6 sweep must
|
||||
not "fix" them.
|
||||
|
||||
| Term | Rendering | Note |
|
||||
|---|---|---|
|
||||
| VAE | `VAE` everywhere | acronym, always upper-case |
|
||||
| Upscaler | `Upscaler` everywhere | CivitAI `ModelType` name |
|
||||
| Text Encoder | `Text Encoder` everywhere | de compounds as `Text-Encoder-Stammordner` |
|
||||
| CLIP Vision | `CLIP Vision` everywhere | de compounds as `CLIP-Vision-Stammordner` |
|
||||
| ControlNet | `ControlNet` everywhere | brand casing, capital N |
|
||||
|
||||
In prose these names sit next to localized nouns the same way `Diffusion Model` does
|
||||
(zh `VAE 根目录`, ja `VAEルート`, ko `VAE 루트`, ru `Корневая папка VAE`).
|
||||
|
||||
**"Other Models" is the page/feature name, not a model type — translate it:**
|
||||
|
||||
| Locale | `other.title` | `header.navigation.other` |
|
||||
|---|---|---|
|
||||
| fr | Autres modèles | Autres |
|
||||
| zh-CN | 其他模型 | 其他 |
|
||||
| zh-TW | 其他模型 | 其他 |
|
||||
| ja | その他のモデル | その他 |
|
||||
| ko | 기타 모델 | 기타 |
|
||||
| de | Weitere Modelle | Andere |
|
||||
| es | Otros modelos | Otros |
|
||||
| ru | Другие модели | Другое |
|
||||
| he | מודלים אחרים | אחרים |
|
||||
|
||||
`settings.folderSettings.otherSubTypes` ("Managed Types") must name **model** types, matching
|
||||
each locale's `header.filter.modelTypes` rendering (zh `管理的模型类型`, ja `管理するモデルタイプ`,
|
||||
de `Verwaltete Modelltypen`, …).
|
||||
|
||||
The "no folders found" empty state (`other.noPaths.*`) uses two phrases that must stay
|
||||
consistent whenever that copy is edited. `folder key` means the `folder_paths` key name
|
||||
(`vae`, `upscale_models`, … — Latin per the table above); `on disk` means the folder must
|
||||
physically exist:
|
||||
|
||||
| Phrase | Rendering |
|
||||
|---|---|
|
||||
| folder key | zh-CN 文件夹键 · zh-TW 資料夾鍵 · ja フォルダーキー · ko 폴더 키 · fr clé de dossier · de Ordnerschlüssel · es clave de carpeta · ru ключ папки · he מפתח תיקייה |
|
||||
| on disk | zh-CN 在磁盘上 · zh-TW 在磁碟上 · ja ディスク上 · ko 디스크에 · fr sur le disque · de auf dem Datenträger · es en el disco · ru на диске · he בדיסק |
|
||||
|
||||
`settings.json` and `ComfyUI` stay verbatim in every locale; "reload this page" / "restart
|
||||
LoRA Manager" reuse each locale's existing restart wording (`settings.extraFolderPaths.*`).
|
||||
|
||||
### Model source feature (Hugging Face / ModelScope / TensorArt)
|
||||
|
||||
A model file can be linked to the page of an external model site. **Hugging Face**,
|
||||
**ModelScope** and **TensorArt** are brand names and stay Latin in every locale (R3); the
|
||||
generic nouns around them are translated:
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| model source | zh-CN 模型来源 · zh-TW 模型來源 · ja モデルソース · ko 모델 소스 · fr source de modèle · de Modellquelle · es fuente de modelo · ru источник модели · he מקור מודל |
|
||||
| model page | zh-CN 模型页面 · zh-TW 模型頁面 · ja モデルページ · ko 모델 페이지 · fr page du modèle · de Modellseite · es página del modelo · ru страница модели · he עמוד המודל |
|
||||
| model card | zh-CN 模型卡 · zh-TW 模型卡 · ja モデルカード · ko 모델 카드 · fr fiche de modèle · de Modellkarte · es ficha de modelo · ru карточка модели · he כרטיס מודל |
|
||||
| AI enrichment (noun) | reuse the existing pair per locale: zh-CN 增强 · zh-TW 增強 · ja 補完 · ko 보강 · fr enrichissement (par IA) · de Anreicherung (KI-) · es enriquecimiento (con IA) · ru обогащение (с помощью ИИ) · he העשרה (AI) |
|
||||
|
||||
`modelCard.actions.viewOnSource` ("View on {source}") follows each locale's existing
|
||||
`viewOnHuggingFace` pattern — de `Auf … ansehen`, ru `Открыть …`, he `צפייה ב-…`,
|
||||
ja `… で見る`, ko `…에서 보기`, zh `在 … 查看`, fr `Voir sur …`, es `Ver en …`. `{source}` is
|
||||
replaced at runtime with the untranslated platform name, so the brand never appears inside the
|
||||
translated text.
|
||||
|
||||
`modals.linkModelSource.enrichNote` states the rule that only sites exposing a readable model
|
||||
card can be enriched and names TensorArt as the current exception. Keep the parenthetical
|
||||
exception in sync if another link-only source is ever added — the sentence is deliberately
|
||||
phrased as a rule, not as an apology for one site.
|
||||
|
||||
The context-menu and bulk-operation enrichment entry points read **"Enrich Metadata with AI"**
|
||||
in `en`, not "Enrich HF Metadata": they cover ModelScope as well, so no locale may reintroduce
|
||||
an `HF` qualifier in `loras.contextMenu.enrichHfAgent` / `loras.bulkOperations.enrichHfAgent`
|
||||
(the key names keep the historical `Hf`; only the values changed).
|
||||
|
||||
### Folder sidebar feature (create / rename / delete folders, empty folders, view options)
|
||||
|
||||
The model-root sidebar manages on-disk folders. "Folder" reuses the noun already fixed in §2
|
||||
(the `folder key` row); the rest is new surface:
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| folder | zh-CN 文件夹 · zh-TW 資料夾 · ja フォルダ · ko 폴더 · fr dossier · de Ordner · es carpeta · ru папка · he תיקייה |
|
||||
| model root (as in "no model root is configured") | zh-CN 模型根目录 · zh-TW 模型根目錄 · ja モデルルート · ko 모델 루트 · fr racine de modèle · de Modell-Stammverzeichnis · es raíz de modelo · ru корневая папка моделей · he שורש מודלים — note `sidebar.modelRoot` alone is the shorter 根目录 / 根目錄 / ルート / 루트 / Racine / Stammverzeichnis / Raíz / Корень / שורש |
|
||||
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
|
||||
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
|
||||
|
||||
Deleting a folder **never cascades over model files** — the backend refuses it and
|
||||
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
|
||||
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
|
||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||
|
||||
### Chip reordering (model tags / trigger words)
|
||||
|
||||
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
|
||||
`⠿` grip where the chip body is click-to-edit), so the copy sits in `common.reorder.dragHandle`
|
||||
instead of a feature namespace. It is used twice per editor: as the grip tooltip and as the
|
||||
hint shown in the edit controls row. There is deliberately **no keyboard shortcut** — an
|
||||
`Alt + Arrow` binding fought the browser's own Alt + Arrow handling and the modal's arrow-key
|
||||
navigation, so reordering is pointer-only and the grip is a decorative, non-focusable
|
||||
affordance. Do not reintroduce a shortcut or a "position X of Y" screen-reader string without
|
||||
re-adding the corresponding keys.
|
||||
|
||||
`dragHandle` is a fragment, not a sentence: it labels both the grip and the hint, so keep it
|
||||
short and imperative and do not append a keyboard hint in any locale.
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| drag to reorder | zh-CN 拖拽以调整顺序 · zh-TW 拖曳以調整順序 · ja ドラッグして並べ替え · ko 드래그하여 순서 변경 · fr Glisser pour réordonner · de Zum Neuordnen ziehen · es Arrastra para reordenar · ru Перетащите, чтобы изменить порядок · he גרור כדי לשנות סדר |
|
||||
|
||||
The grip itself is an icon and is never translated.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
||||
@@ -312,8 +481,9 @@ blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipe
|
||||
The only values that remain intentionally identical to `en.json` are non-translatable:
|
||||
URL/path placeholders (`https://…`, `C:/…`), numeric presets (`5 (1080p), 6 (2K), 8 (4K)`),
|
||||
example token lists (`character, concept, style(toon|toon_style)`), service/provider names
|
||||
(`CivitAI → CivArchive → Archive DB`), and the external playlist title
|
||||
(`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
|
||||
(`CivitAI → CivArchive → Archive DB`), model-type names (`settings.priorityTags.modelTypes.*`,
|
||||
`settings.folderSettings.subTypeVae` … `subTypeControlnet` — see §2), and the external playlist
|
||||
title (`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
|
||||
|
||||
Rule for `uiHelpers.workflow.noPromptTargets`: the second line (`Mark as → Send Prompt
|
||||
Target`) quotes literal ComfyUI context-menu items — keep those menu labels in English in
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
# Plan: "Other Models" Page — Unified Management for VAE / Upscaler / Text Encoder / etc.
|
||||
|
||||
**Status:** v2 — **Phase 1 implemented** (2026-09-12, commits `27da7b3c` backend + `fa7ce725` frontend; verified live against a running ComfyUI instance: scan/hash/sub_type-derivation/fetch/previews all green). **Phase 2 implemented** (2026-09-12, per §9 design; full pytest + vitest green). **Phase 3 implemented** (§11: opt-in management toggles; default off). **i18n done** (2026-09-13): all 36 new keys translated in the 9 non-English locales — the `[TODO: Translate]` placeholders left by the sync script during development are gone (see `docs/i18n-translation-guidelines.md` §2, "Other Models feature"). **Default set revised (pre-release):** only `vae` / `upscaler` / `text_encoder` are managed by default — `clip_vision` and `controlnet` are both opt-in (§2, §11.1.1).
|
||||
**Scope (Phase 1):** scan + manage (list, search, filter, tags, folders, preview, rename, move, delete/exclude, CivitAI metadata fetch) for a new model type `other`, exposed as a new web page. **Phase 2 (§9):** one-click download from CivitAI for these types.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Today the manager supports three model types:
|
||||
|
||||
| page | model_type | sub_types |
|
||||
|---|---|---|
|
||||
| `/loras` | `lora` | `lora`, `locon`, `dora` |
|
||||
| `/checkpoints` | `checkpoint` | `checkpoint`, `diffusion_model` |
|
||||
| `/embeddings` | `embedding` | `embedding` |
|
||||
|
||||
Add a fourth page that manages "everything else" — VAE, upscalers, text encoders / CLIP, CLIP vision, optionally ControlNet — with a folder→sub_type mapping table so new ComfyUI folder categories can be added later by configuration, not code.
|
||||
|
||||
## 2. Locked Decisions
|
||||
|
||||
1. **Architecture: one scanner + one service + one page, sub_type derived by location.**
|
||||
Replicates the checkpoint pattern (`CheckpointScanner` aggregates `checkpoints` + `unet` roots and derives `checkpoint` vs `diffusion_model` from the root containing the file, `py/services/checkpoint_scanner.py:384-415`). One `OtherScanner` aggregates all enabled folder roots; `resolve_sub_type_for_path()` maps each root to a sub_type. No per-category scanners.
|
||||
|
||||
2. **Naming: internal `model_type = "other"`, route prefix `/other`, page id `other`.**
|
||||
- `misc` is rejected: `py/routes/misc_routes.py` already owns that name for system/settings routes (`/api/lm/settings`, `/api/lm/doctor/*`).
|
||||
- `components` is rejected: `templates/components/` and `static/js/components/` directories would make `components.html` / `components.js` confusing neighbors.
|
||||
- `other` matches CivitAI's `Other` fallback type semantics. The **display name** is an i18n string (`other.title`, e.g. "Other Models") and can be renamed later without touching code.
|
||||
|
||||
3. **sub_type values:** snake_case, aligned with CivitAI `ModelType` semantics:
|
||||
|
||||
| sub_type | ComfyUI `folder_paths` key(s) | CivitAI ModelType | enabled by default |
|
||||
|---|---|---|---|
|
||||
| `vae` | `vae` | `VAE` | yes |
|
||||
| `upscaler` | `upscale_models` | `Upscaler` | yes |
|
||||
| `text_encoder` | `text_encoders`, `clip` (legacy) | `TextEncoder` (CLIP is retired upstream) | yes |
|
||||
| `clip_vision` | `clip_vision` | `CLIPVision` | no (mapping present, opt-in) |
|
||||
| `controlnet` | `controlnet` | `Controlnet` | no (mapping present, opt-in) |
|
||||
|
||||
New folder categories = one line in the mapping table (see §4.1).
|
||||
|
||||
**Why only three are on by default** (revised in Phase 3, before release):
|
||||
VAE, upscalers and text encoders are dependency-style assets every pipeline
|
||||
needs, and "which one am I actually using" is the recurring problem they
|
||||
solve. `clip_vision` and `controlnet` are workflow-driven instead
|
||||
(IPAdapter/SVD image conditioning; per-workflow ControlNet variants), and
|
||||
ControlNet libraries routinely run to dozens of files, so both are treated
|
||||
symmetrically as opt-in. Enumerating all five as "the default set" was not
|
||||
defensible on demand breadth alone.
|
||||
|
||||
4. **Phase 1 = scan/manage only.** Downloads from CivitAI (`download_manager.py` type mapping, default-root settings keys, download routing) are Phase 2 (§9). CivitAI **metadata fetch** for existing files IS in Phase 1 (hash-based lookup is type-agnostic; only the type-validation hook needs new values).
|
||||
|
||||
5. **Out of scope (default off, revisit later):** usage statistics buckets, recipe matching (`recipe_scanner.py` only merges lora+checkpoint scanners), statistics page, embeddings re-classification (stays its own page — merging would be a breaking change).
|
||||
|
||||
## 3. Why This Works With Minimal Churn
|
||||
|
||||
- `ModelScanner` (`py/services/model_scanner.py:93`) is specialized entirely via constructor params (`model_type`, `model_class`, `file_extensions`) + optional hooks (`adjust_metadata`, `adjust_cached_entry`, `resolve_sub_type_for_path`, `model_scanner.py:1429-1443`).
|
||||
- `BaseModelService` subclasses can be one method (`EmbeddingService` implements only `format_response`, `py/services/embedding_service.py:12`).
|
||||
- Routes: `ModelServiceFactory.register_model_type()` (`py/services/model_service_factory.py:120-136`) + `COMMON_ROUTE_DEFINITIONS` (`py/routes/model_route_registrar.py:23-149`) generate the full `/api/lm/{prefix}/*` surface (~50 endpoints) plus the `GET /{prefix}` page route.
|
||||
- `PersistentModelCache` (`py/services/persistent_model_cache.py:526-606`) is a single `models` table keyed `(model_type, file_path)` with `model_type` as free text — **zero schema change**.
|
||||
- Frontend `apiConfig.js` (`static/js/api/apiConfig.js:51`) generates all endpoints from the model-type string; `ModelCard.js:670-675` renders the sub_type badge from data; the checkpoints page already demonstrates the "one page, multiple sub_types" filter (`header.html:298`).
|
||||
|
||||
## 4. Backend Changes
|
||||
|
||||
### 4.1 New constants — `py/utils/constants.py`
|
||||
|
||||
```python
|
||||
# folder_paths key -> sub_type; single source of truth for extensibility
|
||||
OTHER_MODEL_FOLDER_SUBTYPES = {
|
||||
"vae": "vae",
|
||||
"upscale_models": "upscaler",
|
||||
"text_encoders": "text_encoder",
|
||||
"clip": "text_encoder", # legacy ComfyUI key
|
||||
"clip_vision": "clip_vision",
|
||||
"controlnet": "controlnet",
|
||||
}
|
||||
DEFAULT_OTHER_MODEL_FOLDERS = ("vae", "upscale_models", "text_encoders", "clip", "clip_vision")
|
||||
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
|
||||
# CivitAI model.type values accepted for this page (fetch-metadata validation)
|
||||
VALID_OTHER_CIVITAI_TYPES = {"vae", "upscaler", "textencoder", "clipvision", "controlnet", "other"}
|
||||
```
|
||||
|
||||
Also extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:90`) if user-model queries should include these types.
|
||||
|
||||
### 4.2 New files (mirror the embedding/checkpoint implementations)
|
||||
|
||||
1. **`py/utils/models.py`** — add `OtherModelMetadata(BaseModelMetadata)`: default `sub_type="vae"` placeholder overridden by scanner hook; `from_civitai_info` mapping CivitAI types → our sub_types (`TextEncoder`→`text_encoder`, `CLIPVision`→`clip_vision`, `Upscaler`→`upscaler`, `VAE`→`vae`, `Controlnet`→`controlnet`, else `other`-ish fallback to folder-derived sub_type).
|
||||
2. **`py/services/other_scanner.py`** — `OtherScanner(ModelScanner)`:
|
||||
- `model_type="other"`, extensions: reuse the checkpoint set (`safetensors/pt/pt2/bin/pth/pkl/sft/gguf`).
|
||||
- `get_model_roots()`: iterate `OTHER_MODEL_FOLDER_SUBTYPES` ∩ enabled keys, pull each from `config` (§4.3); dedupe; build `root → sub_type` map (normalized abspaths; multiple keys may share a sub_type).
|
||||
- Implement all three hooks like `CheckpointScanner` (`checkpoint_scanner.py:384-415`): `resolve_sub_type_for_path` by longest-prefix root match, `adjust_metadata`, `adjust_cached_entry` (sub_type is re-derived on cache load, never persisted).
|
||||
- **Lazy hashing, checkpoint-style**: text encoders (T5-XXL ≈ 10 GB) make eager sha256 painful. Copy the `hash_status="pending"` + singleflight `calculate_hash_for_model` pattern from `CheckpointScanner`.
|
||||
3. **`py/services/other_model_service.py`** — `OtherModelService(BaseModelService)`, `format_response` only (no usage_count, like `EmbeddingService`).
|
||||
4. **`py/routes/other_routes.py`** — `OtherRoutes(BaseModelRoutes)`, `template_name="other.html"`, hooks:
|
||||
- `_validate_civitai_model_type` → `VALID_OTHER_CIVITAI_TYPES`
|
||||
- `_get_expected_model_types`, `_parse_specific_params` (no type-specific download params in Phase 1)
|
||||
- `initialize_services()` on `app.on_startup` pulling `ServiceRegistry.get_other_scanner()`.
|
||||
|
||||
### 4.3 `py/config.py`
|
||||
|
||||
- New `other_roots` property: for each enabled key in `OTHER_MODEL_FOLDER_SUBTYPES`, `folder_paths.get_folder_paths(key)` (plugin mode) — standalone mode needs nothing new: `MockFolderPaths` (`standalone.py:66-105`) already serves arbitrary keys from `settings.json.folder_paths`.
|
||||
- Follow the existing per-type recipe: an `_prepare_other_paths()` (dedupe + symlink registration; also **cross-scanner overlap detection** — warn if an `other` root is already covered by checkpoints/unet/embedding roots, mirroring the checkpoint/unet overlap check).
|
||||
- Wire into: `_apply_library_paths`, `_symlink_roots()`, `_rebuild_preview_roots()` (hard requirement — preview images are served per registered root), `save_folder_paths_to_settings()`.
|
||||
|
||||
### 4.4 Existing-file edits (the "type string scatter" — each is a small branch/entry)
|
||||
|
||||
| file | change |
|
||||
|---|---|
|
||||
| `py/services/model_service_factory.py:120` | register `("other", OtherModelService, OtherRoutes)` in `register_default_model_types()` |
|
||||
| `py/services/service_registry.py` | add `get_other_scanner()` (mirror `:297` `get_embedding_scanner`) |
|
||||
| `py/services/model_scanner.py:67` | `PAGE_TYPE_MAP['other'] = 'other'` (WebSocket progress) |
|
||||
| `py/services/base_model_service.py:896-906` | `get_model_types()` branch → `VALID_OTHER_SUB_TYPES` |
|
||||
| `py/lora_manager.py` | `_initialize_services` scanner task list (`:219-242`), `_cleanup` cancel list (`:463`), `_cleanup_backup_files` roots (`:327-330`) |
|
||||
| `py/routes/handlers/misc_handlers.py` | `scanner_getters` (`:657-661`) + `scanner_factories` (`:757-759`) so Doctor / init-status / refresh-all see the new scanner |
|
||||
| `py/services/pending_delete_service.py` | `_PAGE_TYPE` map (`:57-61`) + scanner getter list (`:983-985`) |
|
||||
| `py/metadata_ops/__init__.py:36-38` | `SCANNER_TYPE_MAP['other']` |
|
||||
| `settings.json.example` | document optional `folder_paths` keys: `vae`, `upscale_models`, `text_encoders`, `clip_vision` |
|
||||
|
||||
**Explicitly NOT touched in Phase 1:** `py/services/download_manager.py`, `py/services/download_routing.py`, `py/services/settings_manager.py` default-root keys, `py/routes/stats_routes.py`, `py/utils/usage_stats.py`, `py/services/recipe_scanner.py`, `py/metadata_collector/`, `py/nodes/`.
|
||||
|
||||
**Zero-change confirmations (verified):** `PersistentModelCache`, `ModelUpdateService`, `DownloadedVersionHistoryService`, `MetadataSyncService` + provider chain (type-agnostic hash lookups), `ModelFileService` / `ModelMoveService` / `ModelLifecycleService` (scanner + model_type injected), `ModelCache` / `ModelHashIndex`, `AutoV3BackfillService`.
|
||||
|
||||
## 5. Frontend Changes
|
||||
|
||||
1. **`static/js/api/apiConfig.js`** — `MODEL_TYPES.OTHER = 'other'`; `MODEL_CONFIG.other` entry (displayName, singularName, `supportsMove`, `supportsBulkOperations`; no letter filter); endpoints come free from `getApiEndpoints()` (`:51`).
|
||||
2. **`static/js/api/otherApi.js`** — thin `OtherApiClient extends BaseModelApiClient` (mirror `embeddingApi.js`); register in `modelApiFactory.js`.
|
||||
3. **`static/js/other.js`** — page entry (mirror `embeddings.js`): `appCore.initialize()` + `createPageControls('other')` + `initializePageFeatures()` + `ModelDuplicatesManager` + `initActiveFiltersSync('other')`.
|
||||
4. **Controls & context menu** — `OtherControls extends PageControls` and `OtherContextMenu` (start from the embedding variants — the smallest); add branches in the two factories (`components/controls/index.js:15`, `components/ContextMenu/index.js:15`). Context-menu template block lives in `templates/other.html` (`{% block additional_components %}`, the checkpoints/embeddings pattern — do NOT touch the shared `context_menu.html`).
|
||||
5. **`templates/other.html`** — copy `embeddings.html`: same content blocks (controls + breadcrumb + duplicates banner + folder sidebar + `#modelGrid`), `data-page="other"`, main script `/loras_static/js/other.js`.
|
||||
6. **`templates/components/header.html`** — nav entry (`:23-43`, active when `request.path.startswith('/other')`); enable the `modelTypes` sub_type filter panel for `other` (`:298-305` pattern from checkpoints); check search-options panel conditions (`:199-224`).
|
||||
7. **`static/js/utils/constants.js`** — `MODEL_SUBTYPE_ABBREVIATIONS` (`:115`): `vae→VAE`, `upscaler→UPS`, `text_encoder→TE`, `clip_vision→CV`, `controlnet→CN`; matching `MODEL_SUBTYPE_DISPLAY_NAMES` (`:99`). (Unknown fallback already uppercases 4 chars, but explicit mappings read better.)
|
||||
8. **`static/js/core.js:110` `getPageType()`** — verify `data-page="other"` flows through `state.pages` generically; add only if the page list is enumerated anywhere.
|
||||
9. No change to `web/comfyui/top_menu_extension.js` (it opens `/loras`; page-to-page nav is the header bar).
|
||||
|
||||
## 6. i18n
|
||||
|
||||
- `locales/en.json`: add `other.title` (e.g. "Other Models") + minimal `other.contextMenu.*` / `other.modelTypes.*` keys; reuse `modelCard.*`, `loras.contextMenu.*`, `common.*` wherever possible (the established pattern — checkpoints/embeddings already reuse lora keys).
|
||||
- Run `python scripts/sync_translation_keys.py`; leave `[TODO: Translate]` placeholders in other locales (per `docs/i18n-translation-guidelines.md` §7 — do not translate proactively).
|
||||
|
||||
## 7. Testing
|
||||
|
||||
Follow existing conventions (`pytest.ini`, `tests/frontend/` vitest):
|
||||
|
||||
1. **Backend (pytest, async where needed):**
|
||||
- `OtherScanner` root aggregation + `resolve_sub_type_for_path` (file under `vae/` root → `vae`; `text_encoders` and legacy `clip` both → `text_encoder`; disabled `controlnet` root not scanned).
|
||||
- Cache round-trip: sub_type re-derived via `adjust_cached_entry` (not persisted).
|
||||
- Lazy hash: `hash_status="pending"` default; `calculate_hash_for_model` singleflight.
|
||||
- `OtherRoutes` registration smoke test: `/api/lm/other/...` endpoints exist; `_validate_civitai_model_type` accepts `vae`/`upscaler`/`textencoder`, rejects `lora`.
|
||||
- Config: `other_roots` in both modes (mock `folder_paths`, and standalone `settings.json.folder_paths`).
|
||||
2. **Frontend (vitest + jsdom, `tests/frontend/`):**
|
||||
- `apiConfig`: `getApiEndpoints('other')` URL shapes; `modelApiFactory` returns the Other client.
|
||||
- `ModelCard` badge rendering for new sub_types.
|
||||
- `createPageControls('other')` / `createPageContextMenu('other')` factories.
|
||||
3. **Manual UI verification by the user** (per AGENTS.md — no sandbox/browser automation): page loads, scans a real library, sub_type filter + badges, context menu actions.
|
||||
|
||||
## 8. Execution Order
|
||||
|
||||
1. `constants.py` + `OtherModelMetadata` + `config.py` roots
|
||||
2. `OtherScanner` (+ registry, factory, `PAGE_TYPE_MAP`) → scanner unit tests green
|
||||
3. `OtherModelService` + `OtherRoutes` + handler/registrar wiring + `lora_manager.py` lifecycle → route tests green
|
||||
4. Doctor/pending-delete/metadata-ops scatter entries
|
||||
5. Template + header nav + frontend API/controls/context-menu/card badges → vitest green
|
||||
6. i18n keys + sync script
|
||||
7. `pytest` + `npm test` full runs; hand to user for manual UI check
|
||||
|
||||
## 9. Phase 2 Detailed Design — CivitAI Downloads for `other`
|
||||
|
||||
Designed 2026-09-12 against the Phase-1 code on this branch; decisions marked **[locked]** follow the same recommendations the feature owner approved for Phase 1.
|
||||
|
||||
### 9.1 Download pipeline touch points
|
||||
|
||||
Flow: `POST /api/lm/download-model` (`py/routes/model_route_registrar.py:104`; GET variant `:105` for the browser extension) → `ModelDownloadHandler.download_model` (`model_handlers.py:1740`) → `DownloadModelUseCase.execute` → `DownloadCoordinator.schedule_download` → `DownloadManager.download_from_civitai` (`download_manager.py:386`) → `_execute_original_download` (`:1415`). Inside, seven scatter points need an `other` branch:
|
||||
|
||||
1. **Type map** (`:1496-1507`): accept `model.type.lower() in VALID_OTHER_CIVITAI_TYPES` → `model_type = "other"` (reuses the Phase-1 set, incl. `"other"` itself).
|
||||
2. **Early version-exists gate** (`:1436-1463`): add `other_scanner.check_model_version_exists`.
|
||||
3. **File-level exists gate** (`:1640-1655` → `_find_local_file_entry` `:320-346` → `_get_scanner_for_model_type` `:230-236`): add explicit `other` branch. **Trap**: the function currently falls through to the lora scanner for unknown types — `"other"` would silently dedupe against loras. Also narrow the fall-through to `"lora"` only / raise on unknown.
|
||||
4. **Version-level fallback gate** (`:1656-1688`): add `elif model_type == "other"`.
|
||||
5. **Default-root selection** (`:1690-1727`): for `other`, first resolve sub_type (§9.2), then read `default_other_roots[sub_type]` (§9.3); if sub_type is undecidable or no default root configured → error guiding the user to pick a folder explicitly.
|
||||
6. **Metadata class selection** (`:1909-1928`) + `_build_metadata_for_resume` (`:969-981`): add `OtherModelMetadata.from_civitai_info` branches.
|
||||
7. **Post-download cache write** (`_execute_download_pipeline` `:2622-2679`): add `other` scanner branch; `adjust_metadata` re-derives sub_type from the on-disk root automatically. `_get_supported_extensions_for_type` (`:2720-2744`): `other` reuses the checkpoint extension set.
|
||||
|
||||
Hooks: `_record_downloaded_version_history` (model_type is free text — zero change); `_sync_downloaded_version` (`:1984` → scanner dispatch `:2130-2135`) add `other`; `py/utils/example_images_download_manager.py` scanner dispatch at `:411-421`, `:591-601`, `:1089+` — add `other` at all three (silent no-scanner otherwise).
|
||||
|
||||
Path templates: `get_download_path_template("other")` is unset, so `other` resolves to a **flat** layout (empty template) — downloads land directly under the resolved sub_type root. This is deliberate: other-model roots are already split per sub_type (`default_other_roots`), and `priority_tags` has no `other` entry, so `{first_tag}` would fall back to an arbitrary CivitAI tag and scatter files into unstable folders. Users who want nesting can still set `download_path_templates["other"]` in `settings.json`. See `DEFAULT_DOWNLOAD_PATH_TEMPLATES` (`py/utils/constants.py`) and `DEFAULT_PATH_TEMPLATES` (`static/js/utils/constants.js`).
|
||||
|
||||
### 9.2 File-level routing (model.type / file.type → sub_type) **[locked]**
|
||||
|
||||
Table-driven, mirroring Phase 1. New in `py/utils/constants.py`:
|
||||
|
||||
```python
|
||||
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
|
||||
"VAE": "vae", "Upscaler": "upscaler", "Text Encoder": "text_encoder",
|
||||
"Vision Encoder": "clip_vision", "CLIPVision": "clip_vision",
|
||||
"ControlNet": "controlnet",
|
||||
}
|
||||
```
|
||||
|
||||
`download_routing.py` gains `resolve_other_download_sub_type(civitai_model_type, file_types, selected_file_type=None)` with fixed priority:
|
||||
|
||||
1. **Explicit user file pick** (`file_params` from #1058's `_resolve_target_file`) — if the picked file's type maps, it wins even when model.type is `Checkpoint`.
|
||||
2. **model.type** via the existing `CIVITAI_TYPE_TO_OTHER_SUB_TYPE` (`constants.py:120-127`).
|
||||
3. **file.type fallback** — only when model.type maps to nothing (e.g. model.type `Other` or retired `CLIP`). MUST NOT override a mapped model.type: checkpoint models routinely bundle VAE/Text Encoder component files, and unconditional file-type routing would misroute them.
|
||||
4. Still undecidable → `None`; `use_default_paths` errors and the UI offers all other roots for manual selection.
|
||||
|
||||
HTTP: extend `DownloadRoutingHandler.get_download_routing` (`download_routing_handlers.py:23`) with an `other` branch returning `{root_kind: "other", sub_type: ...}`; add `GET /api/lm/other/roots_by_subtype` in `OtherRoutes.setup_specific_routes` (data from `config._prepare_other_paths`'s per-key roots, aggregating `text_encoders` + legacy `clip` under `text_encoder`).
|
||||
|
||||
### 9.3 Settings: single dict key `default_other_roots` **[locked]**
|
||||
|
||||
Rejected: four flat keys (`default_vae_root`…) — each flat key costs ~13 touch points in `settings_manager.py` (defaults `:82-85`, `_check_and_auto_set` `:890-895`, `set()` `:1621-1628`, `_update_active_library_entry` `:738-805`, upsert/create signatures `:1953-2132`, `_build_library_payload` `:552-612`, `_sync_active_library_to_root` `:519-547`, three library constructors, frontend `DEFAULT_SETTINGS_BASE`), repeated per future sub_type.
|
||||
|
||||
Chosen: one mapping key `default_other_roots: {sub_type: path}`, copying the `extra_folder_paths` precedent (generic Mapping handling at `:533-535`, `:573-578`, `:763-767`). `_check_and_auto_set` generalizes to per-sub_type candidates (union over that sub_type's folder keys — `text_encoder` → `text_encoders` + `clip`). `set()` validates keys against `VALID_OTHER_SUB_TYPES`.
|
||||
|
||||
Also fix the Phase-1 omission: add `"other_scanner"` to `_notify_library_change` (`:2150-2156`) and `_notify_model_name_display_change` (`:1795-1800`) — otherwise switching libraries leaves the other page stale.
|
||||
|
||||
### 9.4 Settings UI
|
||||
|
||||
- `templates/components/modals/settings/library.html:34-40`: sub_type selectors after the existing four `setting_select`s (Jinja loop; controlnet selector only when `enabled_other_folders` includes it). Dict-subkey save helper `saveOtherRootSetting(subType, value)` alongside the flat `saveSelectSetting`.
|
||||
- `static/js/managers/SettingsManager.js:1547-1697`: `loadOtherRoots()` mirroring `loadUnetRoots()`, fed by `/api/lm/other/roots_by_subtype`; current values from `state.global.settings.default_other_roots`. `state/index.js:24` `DEFAULT_SETTINGS_BASE` += `default_other_roots: {}`.
|
||||
- Optional: one `other` row in the download-path-template block (`library.html:153-211`).
|
||||
- i18n: `settings.folderSettings.*` keys into `locales/en.json` + sync script; other locales keep `[TODO: Translate]`.
|
||||
- Settings GET (`misc_handlers.py:1528-1536`) already returns all non-sensitive keys — new key reaches the frontend for free.
|
||||
|
||||
### 9.5 Frontend download entry
|
||||
|
||||
- `templates/components/controls.html:83`: drop the `page_id != 'other'` exclusion on the download button (keyboard shortcut D self-enables via `PageControls.js:196-198`).
|
||||
- `OtherControls.js:22-55`: add `showDownloadModal: () => downloadManager.showDownloadModal()` (mirror `EmbeddingsControls.js:43-45`).
|
||||
- `DownloadManager.js` `proceedToLocationContent` (`:955-1017`): add `_resolveOtherSubType()` (mirror `_resolveIsDiffusionModel` `:1026`): selected file type → `/api/lm/download/routing` → `otherApiClient.fetchModelRoots(subType)` (new); default-root preselect reads `default_other_roots[subType]` instead of `` `default_${singularType}_root` `` (`:974`). Undecidable → list all other roots (`/api/lm/other/roots`) for manual pick; an explicit save_dir skips backend default-root logic, so the two paths cannot disagree.
|
||||
- `ModelVersionsTab` download buttons are modelType-generic and already work via `getModelApiClient('other')`; context menu has no CivitAI download entry — no change.
|
||||
- Version-list type validation (`get_civitai_versions` → `_validate_civitai_model_type`) already accepts `VALID_OTHER_CIVITAI_TYPES` from Phase 1.
|
||||
|
||||
### 9.6 CivitAI type mapping decisions **[locked]**
|
||||
|
||||
- Download accepts exactly `VALID_OTHER_CIVITAI_TYPES` (`VAE, Upscaler, TextEncoder, CLIP, CLIPVision, Controlnet, Other`) — reuse the Phase-1 tables; do NOT create new ones.
|
||||
- Extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:133-137`) with the 7 aliases, and point them at the other scanner / `"other"` history bucket in `misc_handlers.py` (`type_scanner_map` `:2793-2797`, `downloaded_version_map` `:2821-2827`) — otherwise creator pages silently filter these models while downloads claim support.
|
||||
- Fix (small Phase-1 bug): `OtherModelMetadata.from_civitai_info` (`py/utils/models.py:343`) reads `version_info.get("type")`, but the type lives at `version["model"]["type"]` — the mapping never fires and always degrades to the placeholder. Read `version_info.get("model", {}).get("type")` instead. (`CheckpointMetadata:290` has the same shape; leave it alone here.)
|
||||
|
||||
### 9.7 Tests
|
||||
|
||||
Existing base: `tests/services/test_download_manager_basic.py` (incl. `test_download_rejects_unsupported_model_type` `:1336`), `test_download_manager_error.py`, `test_download_manager_concurrent.py`, `tests/integration/test_download_flow.py`, `tests/services/test_settings_manager.py`; frontend `tests/frontend/managers/downloadManager.routing.test.js`, `settingsManager.library.test.js`.
|
||||
|
||||
Add: (1) `resolve_other_download_sub_type` unit tests — every priority tier, bundled-component anti-misrouting, undecidable → None, civarchive-shaped payload; (2) download_manager — six model.types accepted → other scanner (mock), unknown still rejected, no lora-scanner fall-through, per-sub_type default roots + unconfigured error, resume metadata, extension set; (3) settings_manager — `default_other_roots` defaults/auto-set (incl. text_encoder dual-key union)/library sync/upsert passthrough/illegal sub_type rejection; (4) routes — `/api/lm/download/routing` other branch, `roots_by_subtype` shape; (5) example-images dispatch accepts `other` (3 sites); (6) vitest — `_resolveOtherSubType` + root select + default preselect, `loadOtherRoots`; (7) user-models existsLocally for VAE.
|
||||
|
||||
### 9.8 Phase 2 file list
|
||||
|
||||
Backend: `py/utils/constants.py`, `py/services/download_routing.py`, `py/routes/handlers/download_routing_handlers.py`, `py/services/download_manager.py`, `py/utils/example_images_download_manager.py`, `py/services/settings_manager.py`, `py/utils/models.py`, `py/routes/other_routes.py`, `py/routes/handlers/misc_handlers.py`, `settings.json.example`.
|
||||
Frontend/templates: `templates/components/controls.html`, `static/js/components/controls/OtherControls.js`, `static/js/managers/DownloadManager.js`, `static/js/api/otherApi.js`, `templates/components/modals/settings/library.html`, `static/js/managers/SettingsManager.js`, `static/js/state/index.js`, `locales/en.json` + sync.
|
||||
|
||||
## 10. Risks / Open Questions
|
||||
|
||||
- **Root overlap**: a user may point `text_encoders` at a directory already scanned as checkpoints/unet. Realpath dedup inside one scanner won't catch cross-scanner overlap → the `_prepare_other_paths` overlap warning (§4.3) is the mitigation; duplicate cards across pages are cosmetic, not corrupting (cache keyed by `(model_type, file_path)`).
|
||||
- **Huge text encoders + lazy hash**: CivitAI fetch for a pending-hash model must trigger on-demand hash like checkpoints do — verify that flow (`calculate_hash_for_model`) is reachable from the `other` routes' fetch-metadata handler.
|
||||
- **Retired CivitAI types**: `CLIP`/`CLIPVision` are retired upstream (grandfathered for existing models); metadata fetch must tolerate both retired and current types — `VALID_OTHER_CIVITAI_TYPES` includes them deliberately.
|
||||
- **Standalone users** must add the new `folder_paths` keys to `settings.json` themselves; document in `settings.json.example` and the feature doc.
|
||||
- **Page display name** is i18n-only; if "Other Models" tests poorly, rename `other.title` without code changes.
|
||||
|
||||
### Phase 2 risks
|
||||
|
||||
- **Bundled component files**: checkpoint models routinely ship VAE/Text Encoder component files — file.type routing must stay a fallback (or explicit user pick), never an override (§9.2 priority is load-bearing; test it).
|
||||
- **`_get_scanner_for_model_type` lora fall-through** (`download_manager.py:236`): without an explicit `other` branch, dedupe checks run against the lora scanner — the most insidious trap in Phase 2.
|
||||
- **text_encoder dual folder keys** (`text_encoders` + legacy `clip`): default-root candidates, `roots_by_subtype`, and auto-set must all merge both keys; miss one and the default-root dropdown comes up empty.
|
||||
- **Undecidable sub_type** (model.type `Other` + unknown file types): must error and ask, never silently default to the vae folder.
|
||||
- **Lazy hash after download**: downloads carry CivitAI SHA256 (no recompute needed) — ensure the post-download cache write doesn't leave `hash_status="pending"`, or the next metadata fetch re-hashes a 10 GB file.
|
||||
- **CivArchive source**: same `_execute_original_download` path, same payload shape — cover it once in tests.
|
||||
|
||||
## 11. Phase 3 — Opt-in Management Toggles (implemented)
|
||||
|
||||
Designed 2026-09-13 against the Phase-1/2 code. Other Models is **opt-in**: after
|
||||
Phase 3 the feature ships disabled, so no other-model folder is scanned and the
|
||||
page shows an "enable" empty state until the user turns it on.
|
||||
|
||||
### 11.1 Settings (global, not per-library)
|
||||
|
||||
| key | type | default | meaning |
|
||||
|---|---|---|---|
|
||||
| `enable_other_models` | bool | `false` | master switch |
|
||||
| `enabled_other_sub_types` | list[str] | `["vae","upscaler","text_encoder"]` | allow-list; `clip_vision` and `controlnet` are opt-in (see §2) |
|
||||
|
||||
`enabled_other_folders` (the unreleased, additive, no-UI backend key) was removed
|
||||
and replaced by the sub_type-level allow-list; there is no migration because the
|
||||
feature never shipped. `text_encoder` expands to `text_encoders` + legacy `clip`
|
||||
via `OTHER_SUB_TYPE_FOLDER_KEYS`.
|
||||
|
||||
The default allow-list lives on five surfaces that must stay in sync:
|
||||
`DEFAULT_ENABLED_OTHER_SUB_TYPES` (`py/utils/constants.py`), `DEFAULT_SETTINGS`
|
||||
(`py/services/settings_manager.py`), the two `DEFAULT_SETTINGS_BASE` /
|
||||
`createDefaultSettings` lists (`static/js/state/index.js`), the
|
||||
`updateOtherModelsControls()` fallback (`static/js/managers/SettingsManager.js`)
|
||||
and the server-rendered Jinja fallback
|
||||
(`templates/components/modals/settings/library.html`).
|
||||
|
||||
### 11.1.1 Legacy key handling in `Config._init_other_paths`
|
||||
|
||||
ComfyUI's `folder_paths` rewrites legacy names before every access (`clip` →
|
||||
`text_encoders`, `unet` → `diffusion_models`) and registers both legacy
|
||||
directories under the canonical key, so `get_folder_paths("clip")` returns
|
||||
exactly the same list as `get_folder_paths("text_encoders")`. Querying both keys
|
||||
made the overlap guard fire twice with `please fix your path configuration` for a
|
||||
configuration the user cannot fix. `Config._collapse_legacy_folder_keys()` now
|
||||
drops a key when the host exposes `map_legacy` and resolves it to another queried
|
||||
key, and `_prepare_other_paths()` downgrades a same-`sub_type` duplicate to
|
||||
`debug` (a cross-`sub_type` collision still warns). In standalone mode
|
||||
`MockFolderPaths` has no `map_legacy` and its keys are independent
|
||||
`settings.json` entries, so every key is still queried there.
|
||||
|
||||
`settings.json.example` intentionally stays minimal (only `use_portable_settings`,
|
||||
`civitai_api_key`, and the four core `folder_paths` keys: `loras`, `checkpoints`,
|
||||
`unet`, `embeddings`). Optional keys — including the other-model folder paths and
|
||||
`enable_other_models` — are NOT documented there; they live in `DEFAULT_SETTINGS`
|
||||
and reach the user's `settings.json` on demand. This supersedes the Phase-1/Phase-2
|
||||
notes that proposed adding the other-model folder keys to the example.
|
||||
|
||||
### 11.2 Behaviour matrix
|
||||
|
||||
| state | scan | nav / `/other` | other downloads | `default_other_roots` | Doctor / refresh-all |
|
||||
|---|---|---|---|---|---|
|
||||
| master off | nothing (`other_roots == []`) | nav entry hidden (`nav-item--hidden`); `/other` still renders the disabled empty state + Enable button; one-time dismissible announcement banner on first visit | rejected | preserved, never auto-set | scanner skipped |
|
||||
| sub_type off | that sub_type's folder keys excluded | page keeps working, type disappears from data | auto-routing refused (manual folder still allowed) | preserved, not preselected | normal |
|
||||
| all on (after enabling) | Phase-1/2 behaviour | normal | normal | normal | normal |
|
||||
|
||||
### 11.3 Backend touch points
|
||||
|
||||
- `py/utils/constants.py` — `DEFAULT_ENABLED_OTHER_SUB_TYPES`, `OTHER_SUB_TYPE_FOLDER_KEYS`, `normalize_other_sub_types`.
|
||||
- `py/config.py` — `_get_enabled_other_folder_keys()` is the single scan gate (master switch + allow-list); new `refresh_other_roots()` rebuilds roots + preview roots on toggle.
|
||||
- `py/services/settings_manager.py` — new defaults, `set()` normalization, `is_other_models_enabled()` / `get_enabled_other_sub_types()` / `is_other_sub_type_enabled()`, and `_apply_other_model_settings_change()` which reapplies config and calls `other_scanner.on_library_changed(reconcile=True)`.
|
||||
- `py/services/model_scanner.py` — `_should_keep_cached_entry()` hydration hook (default keep) plus `on_library_changed(reconcile=...)` / `initialize_in_background(reconcile=...)`; the hook filters `raw_data` and the hash/autov3 index rows.
|
||||
- `py/services/other_scanner.py` — drops persisted entries whose folder is no longer a managed root (sub_type is location-derived, so config is the source of truth).
|
||||
- `py/routes/other_routes.py` — `_validate_civitai_model_type` rejects everything while off / mapped-but-disabled sub_types; `_get_page_context_provider()` injects `other_disabled` into the template.
|
||||
- `py/routes/handlers/model_handlers.py` + `base_model_routes.py` — optional `page_context_provider` hook on `ModelPageView`.
|
||||
- `py/routes/handlers/download_routing_handlers.py` — returns `{sub_type: None, disabled: true, reason}` instead of guessing.
|
||||
- `py/services/download_manager.py` — rejects other-type downloads while off; disabled sub_type refuses default-path routing with a "pick a folder" error.
|
||||
- `py/routes/handlers/misc_handlers.py` — Doctor / init-status / refresh-all skip the other scanner while off (`_active_scanner_factories` / `_active_scanner_getters`).
|
||||
- `py/services/pending_delete_service.py` — deliberately untouched: the scanner stays registered so staged deletes still merge.
|
||||
|
||||
### 11.4 Frontend
|
||||
|
||||
Discoverability: the nav entry is hidden while the feature is off, and three
|
||||
lightweight surfaces replace it — a one-time announcement banner, the download
|
||||
toast, and the settings toggle itself.
|
||||
|
||||
- `templates/components/header.html` + `static/css/components/header.css` — `nav-item--hidden` class (server-rendered when off, client-toggled after enabling) and the `fa-shapes` icon.
|
||||
- `templates/other.html` — `other_disabled` branch in `content` + `main_script`; page-scoped CSS for the empty state.
|
||||
- `static/js/other_disabled.js` — boots `appCore` (shared header) and delegates to the shared enable helper.
|
||||
- `static/js/utils/otherModels.js` — shared `enableOtherModels()` (POST settings + reload) and `openOtherModelsSettings()` (settings modal on the Library section); used by the disabled page, the banner and the download modal.
|
||||
- `static/js/managers/BannerService.js` — `other-models-announcement` banner (only when off and not dismissed; `priority: 0`, dismissal persisted via `dismissed_banners`) with Enable / Open Settings actions; `removeOtherModelsAnnouncement()` drops it without persisting a dismissal.
|
||||
- `templates/components/modals/settings/library.html` + `SettingsManager.updateOtherModelsControls()` / `saveEnabledOtherSubTypes()` / `updateOtherModelsNavVisibility()` — master toggle + five sub_type checkboxes; unchecked/disabled sub_types have their default-root select disabled.
|
||||
- `static/js/managers/DownloadManager.js` — a disabled routing answer surfaces a `showActionToast` with an "Enable Other Models" action (opening settings) and falls back to manual selection.
|
||||
- i18n: `settings.folderSettings.*`, `other.disabled.*` and `banners.otherModels.*` keys in `locales/en.json` + `scripts/sync_translation_keys.py` (other locales keep `[TODO: Translate]`).
|
||||
|
||||
### 11.5 Cache consistency
|
||||
|
||||
- Disabling purges rows from the in-memory view at hydration time (the
|
||||
`_should_keep_cached_entry` hook) and from SQLite on the reconcile triggered by
|
||||
the toggle; the `.metadata.json` sidecars survive, so re-enabling rescans
|
||||
without recomputing hashes (critical for multi-GB text encoders).
|
||||
- Enabling triggers a reconcile so newly managed roots are scanned immediately.
|
||||
- Editing `settings.json` while the server is stopped is still covered by the
|
||||
hydration hook, so disabled types never appear after a restart.
|
||||
|
||||
### 11.6 Tests
|
||||
|
||||
Backend: opt-in fixtures added to the other-related suites; new coverage for
|
||||
"default off scans nothing", per-sub_type gating, routing/download rejection,
|
||||
`_should_keep_cached_entry`, settings normalization and `other_disabled` page
|
||||
context. Frontend: `updateOtherModelsControls` / `saveEnabledOtherSubTypes` and
|
||||
the disabled-page enable flow.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Reconcile 的 Windows 大小写回退分支 - 待验证清单
|
||||
|
||||
> **状态**: 待 Windows 环境验证 | **创建日期**: 2026-09-11
|
||||
> **相关文件**: `py/services/model_scanner.py` (`ModelScanner._reconcile_cache`)
|
||||
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
|
||||
realpath 全量遍历改成按需触发(详见下方"已完成")。优化后,一次零变更 Refresh 在 5 万文件库上从
|
||||
~1400 ms 降到 ~120 ms。
|
||||
|
||||
清理过程中发现**唯一一处遗留的可疑点**:Windows 专属的大小写不敏感回退分支。它无法在 Linux 上验证,
|
||||
因此单独记录,留待 Windows 机器上确认。
|
||||
|
||||
---
|
||||
|
||||
## 待验证分支(现状)
|
||||
|
||||
`py/services/model_scanner.py` 中 `_reconcile_cache()` 的 walk 循环内:
|
||||
|
||||
```python
|
||||
# Try case-insensitive match on Windows
|
||||
if os.name == 'nt':
|
||||
lower_path = file_path.lower()
|
||||
matched = False
|
||||
for cached_path in cached_paths: # 每个未命中文件都全量扫一遍缓存
|
||||
if cached_path.lower() == lower_path:
|
||||
found_paths.add(cached_path)
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
continue
|
||||
```
|
||||
|
||||
它排在精确匹配(`file_path in cached_paths`)和 realpath 别名匹配之后,只有**未命中**的文件才会走到。
|
||||
|
||||
### 为什么可疑
|
||||
|
||||
1. **可能不可达**:Windows 上 `os.path.realpath()` 会返回磁盘上的真实大小写,因此"缓存路径大小写与磁盘
|
||||
不一致"的情形,理论上已经被上一步的 realpath 别名匹配覆盖。若如此,这段就是纯冗余代码。
|
||||
2. **一旦可达就是 O(N×M)**:每个未命中文件都要遍历全部 `cached_paths` 做小写比较。若某种路径写法让
|
||||
整个库都变成"未命中"(例如缓存里的盘符/大小写形式与 walk 结果系统性不一致),一次 Refresh 会退化
|
||||
成 文件数 × 缓存条目数 次字符串比较,比真实 IO 还贵。
|
||||
3. **没有测试覆盖**:`tests/services/test_model_scanner.py` 没有任何针对该分支的用例(它在 Linux 上
|
||||
被 `os.name == 'nt'` 短路,无法覆盖)。
|
||||
|
||||
---
|
||||
|
||||
## 待办
|
||||
|
||||
- [ ] **验证可达性**:在 Windows 上构造"缓存路径与磁盘真实大小写不一致"的场景,确认 realpath 别名匹配
|
||||
是否已经命中,即上面的 `if os.name == 'nt'` 分支是否还有进入的必要。
|
||||
- [ ] **若不可达 / 冗余**:删除该分支,并在删除处留注释说明 realpath 已覆盖大小写归一(附验证记录)。
|
||||
- [ ] **若可达**:保留语义但改成 O(1)——预先构建一次 `lower_path -> cached_path` 映射(与
|
||||
`cached_real_paths` 同样按需、懒构建),把内层全量扫描换成一次字典查询。
|
||||
- [ ] **补一个 Windows-only 的回归测试**(`pytest.mark.skipif(os.name != "nt", ...)`),锁定最终结论。
|
||||
- [ ] 把验证结论回填到本文件,并同步更新状态行。
|
||||
|
||||
---
|
||||
|
||||
## 验证方法(Windows)
|
||||
|
||||
1. **构造不一致的大小写**:让缓存里的 `file_path` 与磁盘实际路径大小写不同(例如改过盘符/目录大小写,
|
||||
或从另一台机器迁移了 `settings.json` 与持久化缓存),然后在 UI 点 Refresh。
|
||||
2. **看后端日志判据**:
|
||||
- 若 realpath 已覆盖 → 日志应显示 `Cache reconciliation completed in X seconds. Added 0, removed 0 models.`,
|
||||
且**没有** `Found N new files to process` / `Processing <path>`。
|
||||
- 若回退分支在起作用 → 同样应该是 `Added 0, removed 0`(因为 `found_paths` 被补上),这是"分支可达"
|
||||
的证据;反之若出现大量 `Processing ...` 并重新 hash,说明连回退分支也没命中,问题更严重
|
||||
(缓存路径被当成了新文件 + 旧条目被删)。
|
||||
3. **跑测试**:`python -m pytest tests/services/test_model_scanner.py -k reconcile`(该文件在 Windows 上会
|
||||
真实执行 `os.name == 'nt'` 分支)。
|
||||
4. **量化**:如果需要,可在 `_reconcile_cache` 里临时插桩统计该分支的进入次数与内层迭代次数,确认是否为 0。
|
||||
|
||||
---
|
||||
|
||||
## 已完成(本轮优化,供对照)
|
||||
|
||||
同一次清理里已经落地并验证的部分(Linux,5 万文件库):
|
||||
|
||||
- `cached_real_paths` 别名映射改为**首次未命中时**懒构建(原来每次 Refresh 都对全部缓存条目算一次 realpath)。
|
||||
- 每个文件的 `realpath` 移到精确命中检查**之后**(原来对每个文件都算,命中即丢弃)。
|
||||
- `get_model_roots()` 在新增文件处理阶段只快照一次(原来每个新文件重读一次)。
|
||||
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`),
|
||||
零变更且缓存干净时跳过;快照本身含重复路径时仍会自愈。
|
||||
|
||||
结果:零变更 Refresh 5 万文件 **~1400 ms → ~120 ms**;根目录顺序/符号链接别名翻转场景仍是
|
||||
`re-processed=0`(不重新读 metadata、不重新 hash)。测试:`tests/services/test_model_scanner.py`
|
||||
47 项、全量后端 2567 项全部通过。
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"reorder": {
|
||||
"dragHandle": "Zum Neuordnen ziehen"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "Auf CivitAI anzeigen",
|
||||
"notAvailableFromCivitai": "Nicht auf CivitAI verfügbar",
|
||||
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||
"viewOnSource": "Auf {source} ansehen",
|
||||
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
|
||||
"copyLoRASyntax": "LoRA-Syntax kopieren",
|
||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
||||
"modelNameCopied": "Modellname kopiert",
|
||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "Rezepte lokalen Modellen neu zuordnen",
|
||||
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
||||
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
||||
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
|
||||
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
||||
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
|
||||
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "Rezepte",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Andere",
|
||||
"statistics": "Statistiken"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultVaeRoot": "VAE-Stammordner",
|
||||
"defaultVaeRootHelp": "Legen Sie den Standard-VAE-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultUpscalerRoot": "Upscaler-Stammordner",
|
||||
"defaultUpscalerRootHelp": "Legen Sie den Standard-Upscaler-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultTextEncoderRoot": "Text-Encoder-Stammordner",
|
||||
"defaultTextEncoderRootHelp": "Legen Sie den Standard-Text-Encoder-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultClipVisionRoot": "CLIP-Vision-Stammordner",
|
||||
"defaultClipVisionRootHelp": "Legen Sie den Standard-CLIP-Vision-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultControlnetRoot": "ControlNet-Stammordner",
|
||||
"defaultControlnetRootHelp": "Legen Sie den Standard-ControlNet-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"enableOtherModels": "Verwaltung weiterer Modelle",
|
||||
"enableOtherModelsHelp": "Wenn deaktiviert, werden VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Ordner nicht gescannt, die Seite für weitere Modelle bleibt deaktiviert und diese Modelltypen können nicht heruntergeladen werden.",
|
||||
"otherSubTypes": "Verwaltete Modelltypen",
|
||||
"otherSubTypesHelp": "Wählen Sie, welche Kategorien weiterer Modelle gescannt und auf der Seite für weitere Modelle angezeigt werden.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Rezepte-Speicherpfad",
|
||||
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "Automatische Organisation abgeschlossen",
|
||||
"error": "Fehler: {error}"
|
||||
},
|
||||
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
|
||||
"enrichHfAgent": "Metadaten mit KI anreichern"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "CivitAI-Daten aktualisieren",
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"linkModel": "Modell verknüpfen",
|
||||
"linkCivitai": "Mit CivitAI neu verknüpfen",
|
||||
"linkHuggingFace": "Mit HuggingFace verknüpfen",
|
||||
"linkModelSource": "Mit Modellquelle verknüpfen",
|
||||
"copySyntax": "LoRA-Syntax kopieren",
|
||||
"copyFilename": "Modell-Dateiname kopieren",
|
||||
"copyRecipeSyntax": "Rezept-Syntax kopieren",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||
"deleteRecipe": "Rezept löschen",
|
||||
"enrichHfAgent": "HF-Metadaten mit KI anreichern"
|
||||
"enrichHfAgent": "Metadaten mit KI anreichern"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "Basismodell",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding-Modelle"
|
||||
},
|
||||
"other": {
|
||||
"title": "Weitere Modelle",
|
||||
"disabled": {
|
||||
"title": "Die Verwaltung weiterer Modelle ist deaktiviert",
|
||||
"description": "Aktivieren Sie die Option, um VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien zu scannen und zu verwalten und sie von CivitAI herunterzuladen.",
|
||||
"enableButton": "Weitere Modelle aktivieren",
|
||||
"hint": "Sie können die verwalteten Modelltypen später unter Einstellungen > Bibliothek ändern.",
|
||||
"enableFailed": "Aktivierung weiterer Modelle fehlgeschlagen",
|
||||
"downloadBlocked": "Die Verwaltung weiterer Modelle ist für diesen Modelltyp deaktiviert. Aktivieren Sie sie unter Einstellungen > Bibliothek, um diese Datei herunterzuladen.",
|
||||
"enableAction": "Weitere Modelle aktivieren"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Keine Ordner für weitere Modelle gefunden",
|
||||
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber es wurden keine Ordner für weitere Modelle gefunden. Fügen Sie die benötigten Ordnerschlüssel zum Abschnitt folder_paths Ihrer settings.json hinzu und starten Sie LoRA Manager neu.",
|
||||
"hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.",
|
||||
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
|
||||
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
|
||||
"openSettings": "Einstellungen öffnen",
|
||||
"openSettingsFolder": "Einstellungsordner öffnen"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
"collapseAllDisabled": "In der Listenansicht nicht verfügbar",
|
||||
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
|
||||
"showSidebar": "Seitenleiste anzeigen",
|
||||
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
|
||||
"switchToListView": "Zur Listenansicht wechseln",
|
||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||
"viewOptions": "Ansichtsoptionen",
|
||||
"treeView": "Baumansicht",
|
||||
"listView": "Listenansicht",
|
||||
"recursiveOn": "Unterordner einbeziehen",
|
||||
"recursiveOff": "Nur aktueller Ordner",
|
||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||
"createFolder": "Neuer Ordner",
|
||||
"newSubfolder": "Neuer Unterordner",
|
||||
"showEmptyFolders": "Leere Ordner anzeigen",
|
||||
"createFolderResult": {
|
||||
"success": "Ordner \"{name}\" erstellt",
|
||||
"failed": "Ordner konnte nicht erstellt werden: {message}",
|
||||
"unsupported": "Das Erstellen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
},
|
||||
"deleteFolder": "Ordner löschen",
|
||||
"deleteFolderModal": {
|
||||
"title": "Ordner löschen?",
|
||||
"message": "Der Ordner und sein gesamter Inhalt werden endgültig vom Datenträger gelöscht.",
|
||||
"folderLabel": "Ordner",
|
||||
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
|
||||
"notEmptyTitle": "Ordner ist nicht leer",
|
||||
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"confirm": "Ordner löschen"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Ordner \"{name}\" gelöscht",
|
||||
"successWithFiles": "Ordner \"{name}\" sowie {count} weitere(s) Element(e) gelöscht",
|
||||
"restored": "Ordner wiederhergestellt",
|
||||
"failed": "Ordner konnte nicht gelöscht werden: {message}",
|
||||
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
||||
"unsupported": "Das Löschen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
},
|
||||
"renameFolder": "Ordner umbenennen",
|
||||
"renameFolderResult": {
|
||||
"success": "Ordner umbenannt in \"{name}\"",
|
||||
"failed": "Ordner konnte nicht umbenannt werden: {message}",
|
||||
"targetExists": "Ein Ordner mit diesem Namen ist hier bereits vorhanden",
|
||||
"busy": "In diesem Ordner steht noch eine Löschung aus. Warten Sie, bis das Zeitfenster für das Rückgängigmachen abgelaufen ist.",
|
||||
"unsupported": "Das Umbenennen von Ordnern wird auf dieser Seite nicht unterstützt",
|
||||
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
|
||||
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
|
||||
"newFolderName": "Neuer Ordnername",
|
||||
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
|
||||
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
|
||||
"invalidFolderName": "Ordnername enthält ungültige Zeichen",
|
||||
"noDragState": "Kein ausstehender Ziehvorgang gefunden"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
||||
"createHint": "Klicken Sie oben auf „Neuer Ordner“, um Ordner zu erstellen"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Auf Updates in diesem Ordner prüfen",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "Modell von URL herunterladen",
|
||||
"titleWithType": "{type} von URL herunterladen",
|
||||
"civitaiUrl": "CivitAI URL:",
|
||||
"civitaiUrl": "Modell-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.",
|
||||
"urlHint": "Geben Sie eine CivitAI-, CivArchive-, Hugging Face- oder ModelScope-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...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "In Bibliothek"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Ungültiges CivitAI URL-Format",
|
||||
"invalidUrl": "Ungültiges Modell-URL-Format",
|
||||
"noVersions": "Keine Versionen für dieses Modell verfügbar",
|
||||
"mixedSources": "CivitAI- und Hugging Face-URLs können nicht in derselben Charge gemischt werden.",
|
||||
"mixedSources": "CivitAI- und Hugging Face-/ModelScope-URLs können nicht in derselben Charge gemischt werden.",
|
||||
"noModelFiles": "In diesem Repository wurden keine Modelldateien gefunden."
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "Aktuelle Datei:",
|
||||
"downloading": "Wird heruntergeladen: {name}",
|
||||
"metadata": "Metadaten: {name}",
|
||||
"indexingFile": "Modelldatei wird gelesen...",
|
||||
"fetchingSourceMetadata": "Metadaten werden von {source} abgerufen...",
|
||||
"fetchingMetadata": "Metadaten werden abgerufen...",
|
||||
"transferred": "Heruntergeladen: {downloaded} / {total}",
|
||||
"transferredSimple": "Heruntergeladen: {downloaded}",
|
||||
"transferredUnknown": "Heruntergeladen: --",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
|
||||
"downloadButton": "{count} LoRA(s) herunterladen"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Rezepte neu zuordnen",
|
||||
"messageGlobal": "Alle Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||
"messageSingle": "Dieses Rezept wird mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||
"messageBulk": "{count} ausgewählte Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||
"relaxedLabel": "Fehlende Modelle auch per Dateiname neu verbinden",
|
||||
"relaxedDescription": "Diese Modelle könnten auch per Download behoben werden — der Download ist genauer. Übereinstimmungen verknüpfen möglicherweise eine andere Version; sie werden zur Überprüfung aufgelistet und können rückgängig gemacht werden.",
|
||||
"confirmButton": "Neu zuordnen"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Rückgängig",
|
||||
"undone": "Rückgängig gemacht",
|
||||
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Zusammenfassung der Neuordnung",
|
||||
"successMessage": "{entries} Einträge zugeordnet",
|
||||
"failed": "Neuordnung fehlgeschlagen",
|
||||
"completedWithWarnings": "Neuordnung abgeschlossen — Überprüfung empfohlen",
|
||||
"cancelledNote": "Der Vorgang wurde vorzeitig abgebrochen — die Zahlen sind unvollständig.",
|
||||
"statMatched": "Zugeordnete Einträge",
|
||||
"statReview": "Zu überprüfen",
|
||||
"statUnresolved": "Nicht zugeordnet",
|
||||
"statErrors": "Fehler",
|
||||
"reviewSection": "Dateinamen-Übereinstimmungen zur Überprüfung ({count})",
|
||||
"columnRecipe": "Rezept",
|
||||
"columnEntry": "Eintrag",
|
||||
"columnFile": "Zugeordnete Datei",
|
||||
"columnUndo": "Rückgängig",
|
||||
"copyReport": "Bericht kopieren",
|
||||
"close": "Schließen",
|
||||
"scope_global": "Alle Rezepte",
|
||||
"scope_bulk": "Ausgewählte Rezepte",
|
||||
"scope_single": "Einzelnes Rezept"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Lokale Beispielbilder",
|
||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"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:",
|
||||
"linkModelSource": {
|
||||
"title": "Mit Modellquelle verknüpfen",
|
||||
"infoText": "Fügen Sie die URL der Modellseite ein, um dieses Modell seiner Quelle zuzuordnen. Die Verknüpfung ermöglicht die KI-gestützte Metadatenanreicherung für Modelle von Hugging Face und ModelScope.",
|
||||
"urlLabel": "URL der Modellseite:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Geben Sie die vollständige URL des HuggingFace-Repositorys ein.",
|
||||
"helpText": "Geben Sie die vollständige URL der Modellseite ein. Unterstützte Websites:",
|
||||
"enrichNote": "Die KI-Anreicherung benötigt eine lesbare Modellkarte. Websites, die keine bereitstellen (derzeit TensorArt), können nur verknüpft werden.",
|
||||
"urlRequired": "Bitte geben Sie die URL der Modellseite ein.",
|
||||
"invalidUrl": "Nicht unterstützte URL. Unterstützte Websites: Hugging Face, ModelScope, TensorArt.",
|
||||
"linking": "Modellquelle wird verknüpft...",
|
||||
"confirmAction": "Speichern & Verknüpfen"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,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.",
|
||||
"sourceGroupInfo": "Dies ist eine {source}-Modellgruppe. Öffnen Sie die Bibliothek, um alle Versionen im Raster zu sehen.",
|
||||
"confirm": {
|
||||
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "Embedding Manager wird initialisiert",
|
||||
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Manager für weitere Modelle wird initialisiert",
|
||||
"message": "Modell-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Rezept Manager wird initialisiert",
|
||||
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||
"created": "Rezept erfolgreich erstellt",
|
||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||
"unresolvableMarkedForReconnect": "{count} nicht auflösbare Einträge markiert — sie können jetzt mit einem lokalen LoRA neu verbunden werden.",
|
||||
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
|
||||
"noNextRecipe": "Kein weiteres Rezept verfügbar",
|
||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
||||
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
||||
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
|
||||
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
||||
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
||||
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
||||
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
||||
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
||||
"otherRootsFailed": "Fehler beim Laden der Stammverzeichnisse weiterer Modelle: {message}",
|
||||
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
|
||||
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
|
||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "Modell erfolgreich über CivitArchive neu verknüpft",
|
||||
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
|
||||
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
|
||||
"missingHash": "Modell-Hash nicht verfügbar"
|
||||
"missingHash": "Modell-Hash nicht verfügbar",
|
||||
"enrichNeedsSource": "Verknüpfen Sie dieses Modell zuerst mit einer Modellquelle (Modell verknüpfen → Mit Modellquelle verknüpfen)",
|
||||
"enrichUnsupportedSource": "Die KI-Anreicherung ist für {source}-Modelle nicht verfügbar"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "Beispielbilder-Pfad erfolgreich aktualisiert",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "Cache wird neu aufgebaut...",
|
||||
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
|
||||
"retry": "Wiederholen"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "Die Verwaltung weiterer Modelle ist verfügbar",
|
||||
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
|
||||
"enable": "Weitere Modelle aktivieren",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+164
-31
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"reorder": {
|
||||
"dragHandle": "Drag to reorder"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "View on CivitAI",
|
||||
"notAvailableFromCivitai": "Not available from CivitAI",
|
||||
"viewOnHuggingFace": "View on Hugging Face",
|
||||
"viewOnSource": "View on {source}",
|
||||
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
|
||||
"copyLoRASyntax": "Copy LoRA Syntax",
|
||||
"checkpointNameCopied": "Checkpoint name copied",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "Copy checkpoint name",
|
||||
"copyEmbeddingName": "Copy embedding name",
|
||||
"embeddingNameCopied": "Embedding syntax copied",
|
||||
"modelNameCopied": "Model name copied",
|
||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "Rematch recipes to local models",
|
||||
"loading": "Rematching recipes to local models...",
|
||||
"success": "Matched {entries} entries across {recipes} recipes",
|
||||
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
||||
"allFailed": "Rematch failed for {failures} of {total} recipes",
|
||||
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
|
||||
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
|
||||
"error": "Recipe rematch failed: {message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "Recipes",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Other",
|
||||
"statistics": "Stats"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||
"defaultEmbeddingRoot": "Embedding Root",
|
||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
||||
"defaultVaeRoot": "VAE Root",
|
||||
"defaultVaeRootHelp": "Set default VAE root directory for downloads, imports and moves",
|
||||
"defaultUpscalerRoot": "Upscaler Root",
|
||||
"defaultUpscalerRootHelp": "Set default upscaler root directory for downloads, imports and moves",
|
||||
"defaultTextEncoderRoot": "Text Encoder Root",
|
||||
"defaultTextEncoderRootHelp": "Set default text encoder root directory for downloads, imports and moves",
|
||||
"defaultClipVisionRoot": "CLIP Vision Root",
|
||||
"defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves",
|
||||
"defaultControlnetRoot": "ControlNet Root",
|
||||
"defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves",
|
||||
"enableOtherModels": "Other Models Management",
|
||||
"enableOtherModelsHelp": "When off, VAE / upscaler / text encoder / CLIP vision / ControlNet folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
|
||||
"otherSubTypes": "Managed Types",
|
||||
"otherSubTypesHelp": "Choose which other-model categories are scanned and shown on the Other Models page.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Recipes Storage Path",
|
||||
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "Auto-organize complete",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enrich HF Metadata (AI)"
|
||||
"enrichHfAgent": "Enrich Metadata with AI"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Refresh CivitAI Data",
|
||||
"checkUpdates": "Check Updates",
|
||||
"linkModel": "Link Model",
|
||||
"linkCivitai": "Link to CivitAI",
|
||||
"linkHuggingFace": "Link to HuggingFace",
|
||||
"linkModelSource": "Link to Model Source",
|
||||
"copySyntax": "Copy LoRA Syntax",
|
||||
"copyFilename": "Copy Model Filename",
|
||||
"copyRecipeSyntax": "Copy Recipe Syntax",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "View All LoRAs",
|
||||
"downloadMissingLoras": "Download Missing LoRAs",
|
||||
"deleteRecipe": "Delete Recipe",
|
||||
"enrichHfAgent": "Enrich HF Metadata (AI)"
|
||||
"enrichHfAgent": "Enrich Metadata with AI"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "Base Model",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "Open File Location",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding Models"
|
||||
},
|
||||
"other": {
|
||||
"title": "Other Models",
|
||||
"disabled": {
|
||||
"title": "Other Models management is off",
|
||||
"description": "Enable it to scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files, and to download them from CivitAI.",
|
||||
"enableButton": "Enable Other Models",
|
||||
"hint": "You can change the managed model types later in Settings > Library.",
|
||||
"enableFailed": "Failed to enable Other Models",
|
||||
"downloadBlocked": "Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
|
||||
"enableAction": "Enable Other Models"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "No other-model folders found",
|
||||
"descriptionStandalone": "Other Models management is on, but no other-model folders were found. Add the folder keys you need to the folder_paths section of your settings.json, then restart LoRA Manager.",
|
||||
"hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.",
|
||||
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
|
||||
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
|
||||
"openSettings": "Open Settings",
|
||||
"openSettingsFolder": "Open Settings Folder"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Root",
|
||||
"collapseAll": "Collapse All Folders",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"hideOnThisPage": "Hide sidebar on this page",
|
||||
"showSidebar": "Show sidebar",
|
||||
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
|
||||
"switchToListView": "Switch to List View",
|
||||
"switchToTreeView": "Switch to Tree View",
|
||||
"viewOptions": "View options",
|
||||
"treeView": "Tree view",
|
||||
"listView": "List view",
|
||||
"recursiveOn": "Include subfolders",
|
||||
"recursiveOff": "Current folder only",
|
||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"createFolder": "New folder",
|
||||
"newSubfolder": "New subfolder",
|
||||
"showEmptyFolders": "Show empty folders",
|
||||
"createFolderResult": {
|
||||
"success": "Folder \"{name}\" created",
|
||||
"failed": "Failed to create folder: {message}",
|
||||
"unsupported": "Folder creation is not supported on this page",
|
||||
"noRoot": "No model root is configured"
|
||||
},
|
||||
"deleteFolder": "Delete folder",
|
||||
"deleteFolderModal": {
|
||||
"title": "Delete folder?",
|
||||
"message": "The folder and everything inside it will be permanently removed from disk.",
|
||||
"folderLabel": "Folder",
|
||||
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
|
||||
"notEmptyTitle": "Folder is not empty",
|
||||
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"confirm": "Delete folder"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Folder \"{name}\" deleted",
|
||||
"successWithFiles": "Folder \"{name}\" deleted along with {count} other item(s)",
|
||||
"restored": "Folder restored",
|
||||
"failed": "Failed to delete folder: {message}",
|
||||
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
|
||||
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||
"unsupported": "Folder deletion is not supported on this page",
|
||||
"noRoot": "No model root is configured"
|
||||
},
|
||||
"renameFolder": "Rename folder",
|
||||
"renameFolderResult": {
|
||||
"success": "Folder renamed to \"{name}\"",
|
||||
"failed": "Failed to rename folder: {message}",
|
||||
"targetExists": "A folder with that name already exists here",
|
||||
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
|
||||
"unsupported": "Folder renaming is not supported on this page",
|
||||
"noRoot": "No model root is configured"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Unable to determine destination path for move.",
|
||||
"moveUnsupported": "Move is not supported for this item.",
|
||||
"createFolderHint": "Release to create new folder",
|
||||
"newFolderName": "New folder name",
|
||||
"folderNameHint": "Press Enter to confirm, Escape to cancel",
|
||||
"emptyFolderName": "Please enter a folder name",
|
||||
"invalidFolderName": "Folder name contains invalid characters",
|
||||
"noDragState": "No pending drag operation found"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "No folders found",
|
||||
"dragHint": "Drag items here to create folders"
|
||||
"createHint": "Click the New Folder button above to create folders"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Check for updates in this folder",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "Download Model from URL",
|
||||
"titleWithType": "Download {type} from URL",
|
||||
"civitaiUrl": "CivitAI URL(s):",
|
||||
"civitaiUrl": "Model URL(s):",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.",
|
||||
"urlHint": "Enter one CivitAI, CivArchive, Hugging Face, or ModelScope URL per line. Supports multiple URLs for batch download.",
|
||||
"selectHfFiles": "Select file(s) to download from this repository:",
|
||||
"selectAll": "Select All",
|
||||
"fetchingRepoFiles": "Fetching repository files...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "In Library"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Invalid CivitAI URL format",
|
||||
"invalidUrl": "Invalid model URL format",
|
||||
"noVersions": "No versions available for this model",
|
||||
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
|
||||
"mixedSources": "Cannot mix CivitAI and Hugging Face / ModelScope URLs in the same batch.",
|
||||
"noModelFiles": "No model files found in this repository."
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "Current file:",
|
||||
"downloading": "Downloading: {name}",
|
||||
"metadata": "Metadata: {name}",
|
||||
"indexingFile": "Reading model file...",
|
||||
"fetchingSourceMetadata": "Fetching metadata from {source}...",
|
||||
"fetchingMetadata": "Fetching metadata...",
|
||||
"transferred": "Transferred: {downloaded} / {total}",
|
||||
"transferredSimple": "Transferred: {downloaded}",
|
||||
"transferredUnknown": "Transferred: --",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
|
||||
"downloadButton": "Download {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Rematch Recipes",
|
||||
"messageGlobal": "All recipes will be scanned against your local model library.",
|
||||
"messageSingle": "This recipe will be scanned against your local model library.",
|
||||
"messageBulk": "{count} selected recipe(s) will be scanned against your local model library.",
|
||||
"relaxedLabel": "Also reconnect missing models by file name",
|
||||
"relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.",
|
||||
"confirmButton": "Rematch"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Undo",
|
||||
"undone": "Undone",
|
||||
"undoFailed": "Failed to undo rematch: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Rematch Summary",
|
||||
"successMessage": "Matched {entries} entries",
|
||||
"failed": "Rematch failed",
|
||||
"completedWithWarnings": "Rematch completed — review recommended",
|
||||
"cancelledNote": "Run cancelled before completion — counts are partial.",
|
||||
"statMatched": "Matched entries",
|
||||
"statReview": "Needs review",
|
||||
"statUnresolved": "Unresolved",
|
||||
"statErrors": "Errors",
|
||||
"reviewSection": "Filename matches to review ({count})",
|
||||
"columnRecipe": "Recipe",
|
||||
"columnEntry": "Entry",
|
||||
"columnFile": "Matched file",
|
||||
"columnUndo": "Undo",
|
||||
"copyReport": "Copy Report",
|
||||
"close": "Close",
|
||||
"scope_global": "All recipes",
|
||||
"scope_bulk": "Selected recipes",
|
||||
"scope_single": "Single recipe"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Local Example Images",
|
||||
"message": "No local example images found for this model. View options:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"pathPlaceholder": "Type folder path or select from tree below...",
|
||||
"root": "Root"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Link to HuggingFace",
|
||||
"infoText": "Paste the HuggingFace repository URL to associate this model with its source. This enables AI-powered metadata enrichment.",
|
||||
"urlLabel": "HuggingFace Repository URL:",
|
||||
"linkModelSource": {
|
||||
"title": "Link to Model Source",
|
||||
"infoText": "Paste the model page URL to associate this model with its source. Linking enables AI-powered metadata enrichment for Hugging Face and ModelScope models.",
|
||||
"urlLabel": "Model Page URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Enter the full URL of the HuggingFace repository.",
|
||||
"helpText": "Enter the full URL of the model page. Supported sites:",
|
||||
"enrichNote": "AI enrichment needs a readable model card. Sites that don't expose one (currently TensorArt) can only be linked.",
|
||||
"urlRequired": "Please enter a model page URL.",
|
||||
"invalidUrl": "Unsupported URL. Supported sites: Hugging Face, ModelScope, TensorArt.",
|
||||
"linking": "Linking model source...",
|
||||
"confirmAction": "Save & Link"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,7 @@
|
||||
"empty": "No version history available for this model yet.",
|
||||
"error": "Failed to load versions.",
|
||||
"missingModelId": "This model is missing a CivitAI model id.",
|
||||
"hfGroupInfo": "This is a HuggingFace model group. Open the library to see all versions in the grid.",
|
||||
"sourceGroupInfo": "This is a {source} model group. Open the library to see all versions in the grid.",
|
||||
"confirm": {
|
||||
"delete": "Delete this version from your library?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "Initializing Embedding Manager",
|
||||
"message": "Scanning and building embedding cache. This may take a few minutes..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Initializing Other Models Manager",
|
||||
"message": "Scanning and building model cache. This may take a few minutes..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Initializing Recipe Manager",
|
||||
"message": "Loading and processing recipes. This may take a few minutes..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "Missing required data to create recipe",
|
||||
"created": "Recipe created successfully",
|
||||
"noMissingLoras": "No missing LoRAs to download",
|
||||
"unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
|
||||
"noPreviousRecipe": "No previous recipe available",
|
||||
"noNextRecipe": "No next recipe available",
|
||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "No recipes selected",
|
||||
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
|
||||
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
||||
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
|
||||
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
|
||||
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
||||
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
||||
"reimporting": "Re-importing recipe from source...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
||||
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
||||
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
|
||||
"otherRootsFailed": "Failed to load other model roots: {message}",
|
||||
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
||||
"mappingsCleared": "Base model path mappings cleared",
|
||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||
@@ -2403,12 +2528,14 @@
|
||||
"contentRatingFailed": "Failed to set content rating: {message}",
|
||||
"relinkSuccess": "Model successfully re-linked to CivitAI",
|
||||
"relinkFailed": "Error: {message}",
|
||||
"linkHfSuccess": "Model successfully linked to HuggingFace",
|
||||
"linkHfSuccess": "Model successfully linked to its model source",
|
||||
"linkHfFailed": "Error: {message}",
|
||||
"linkCivArchSuccess": "Model successfully re-linked via CivitArchive",
|
||||
"fetchMetadataFirst": "Please fetch metadata from CivitAI first",
|
||||
"noCivitaiInfo": "No CivitAI information available",
|
||||
"missingHash": "Model hash not available"
|
||||
"missingHash": "Model hash not available",
|
||||
"enrichNeedsSource": "Link this model to a model source first (Link Model → Link to Model Source)",
|
||||
"enrichUnsupportedSource": "AI enrichment is not available for {source} models"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "Example images path updated successfully",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "Rebuilding cache...",
|
||||
"rebuildFailed": "Failed to rebuild cache: {error}",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "Other Models Management is available",
|
||||
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
|
||||
"enable": "Enable Other Models",
|
||||
"openSettings": "Open Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"reorder": {
|
||||
"dragHandle": "Arrastra para reordenar"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "Ver en CivitAI",
|
||||
"notAvailableFromCivitai": "No disponible en CivitAI",
|
||||
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||
"viewOnSource": "Ver en {source}",
|
||||
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
|
||||
"copyLoRASyntax": "Copiar sintaxis de LoRA",
|
||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
||||
"modelNameCopied": "Nombre del modelo copiado",
|
||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "Reasociar recetas con modelos locales",
|
||||
"loading": "Reasociando recetas con modelos locales...",
|
||||
"success": "{entries} entradas asociadas en {recipes} recetas",
|
||||
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
||||
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
|
||||
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
||||
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
|
||||
"error": "Falló la reasociación de recetas: {message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "Recetas",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Otros",
|
||||
"statistics": "Estadísticas"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||
"defaultEmbeddingRoot": "Raíz de embedding",
|
||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||
"defaultVaeRoot": "Raíz de VAE",
|
||||
"defaultVaeRootHelp": "Establecer el directorio raíz predeterminado de VAE para descargas, importaciones y movimientos",
|
||||
"defaultUpscalerRoot": "Raíz de Upscaler",
|
||||
"defaultUpscalerRootHelp": "Establecer el directorio raíz predeterminado de Upscaler para descargas, importaciones y movimientos",
|
||||
"defaultTextEncoderRoot": "Raíz de Text Encoder",
|
||||
"defaultTextEncoderRootHelp": "Establecer el directorio raíz predeterminado de Text Encoder para descargas, importaciones y movimientos",
|
||||
"defaultClipVisionRoot": "Raíz de CLIP Vision",
|
||||
"defaultClipVisionRootHelp": "Establecer el directorio raíz predeterminado de CLIP Vision para descargas, importaciones y movimientos",
|
||||
"defaultControlnetRoot": "Raíz de ControlNet",
|
||||
"defaultControlnetRootHelp": "Establecer el directorio raíz predeterminado de ControlNet para descargas, importaciones y movimientos",
|
||||
"enableOtherModels": "Gestión de otros modelos",
|
||||
"enableOtherModelsHelp": "Cuando está desactivado, las carpetas VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet no se escanean, la página Otros modelos permanece desactivada y estos tipos de modelos no se pueden descargar.",
|
||||
"otherSubTypes": "Tipos de modelos gestionados",
|
||||
"otherSubTypesHelp": "Elige qué categorías de otros modelos se escanean y se muestran en la página Otros modelos.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Ruta de almacenamiento de recetas",
|
||||
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "Auto-organización completada",
|
||||
"error": "Error: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
|
||||
"enrichHfAgent": "Enriquecer metadatos con IA"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualizar datos de CivitAI",
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
"linkModel": "Vincular modelo",
|
||||
"linkCivitai": "Re-vincular a CivitAI",
|
||||
"linkHuggingFace": "Vincular a HuggingFace",
|
||||
"linkModelSource": "Vincular a una fuente de modelo",
|
||||
"copySyntax": "Copiar sintaxis de LoRA",
|
||||
"copyFilename": "Copiar nombre de archivo del modelo",
|
||||
"copyRecipeSyntax": "Copiar sintaxis de receta",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "Ver todos los LoRAs",
|
||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||
"deleteRecipe": "Eliminar receta",
|
||||
"enrichHfAgent": "Enriquecer metadatos HF (IA)"
|
||||
"enrichHfAgent": "Enriquecer metadatos con IA"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "Modelo base",
|
||||
"unknown": "Desconocido"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "Abrir ubicación del archivo",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Modelos embedding"
|
||||
},
|
||||
"other": {
|
||||
"title": "Otros modelos",
|
||||
"disabled": {
|
||||
"title": "La gestión de otros modelos está desactivada",
|
||||
"description": "Actívala para escanear y gestionar archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y para descargarlos desde CivitAI.",
|
||||
"enableButton": "Activar otros modelos",
|
||||
"hint": "Puedes cambiar los tipos de modelos gestionados más adelante en Configuración > Biblioteca.",
|
||||
"enableFailed": "No se pudieron activar los otros modelos",
|
||||
"downloadBlocked": "La gestión de otros modelos está desactivada para este tipo de modelo. Actívala en Configuración > Biblioteca para descargar este archivo.",
|
||||
"enableAction": "Activar otros modelos"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "No se encontraron carpetas de otros modelos",
|
||||
"descriptionStandalone": "La gestión de otros modelos está activada, pero no se encontraron carpetas de otros modelos. Añade las claves de carpeta que necesites a la sección folder_paths de tu settings.json y reinicia LoRA Manager.",
|
||||
"hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.",
|
||||
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
|
||||
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
|
||||
"openSettings": "Abrir configuración",
|
||||
"openSettingsFolder": "Abrir carpeta de ajustes"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
"collapseAllDisabled": "No disponible en la vista de lista",
|
||||
"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",
|
||||
"viewOptions": "Opciones de vista",
|
||||
"treeView": "Vista de árbol",
|
||||
"listView": "Vista de lista",
|
||||
"recursiveOn": "Incluir subcarpetas",
|
||||
"recursiveOff": "Solo carpeta actual",
|
||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista",
|
||||
"createFolder": "Nueva carpeta",
|
||||
"newSubfolder": "Nueva subcarpeta",
|
||||
"showEmptyFolders": "Mostrar carpetas vacías",
|
||||
"createFolderResult": {
|
||||
"success": "Carpeta \"{name}\" creada",
|
||||
"failed": "Error al crear la carpeta: {message}",
|
||||
"unsupported": "La creación de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
},
|
||||
"deleteFolder": "Eliminar carpeta",
|
||||
"deleteFolderModal": {
|
||||
"title": "¿Eliminar carpeta?",
|
||||
"message": "La carpeta y todo su contenido se eliminarán permanentemente del disco.",
|
||||
"folderLabel": "Carpeta",
|
||||
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
|
||||
"notEmptyTitle": "La carpeta no está vacía",
|
||||
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"confirm": "Eliminar carpeta"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Carpeta \"{name}\" eliminada",
|
||||
"successWithFiles": "Carpeta \"{name}\" eliminada junto con {count} elemento(s) más",
|
||||
"restored": "Carpeta restaurada",
|
||||
"failed": "Error al eliminar la carpeta: {message}",
|
||||
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
||||
"unsupported": "La eliminación de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
},
|
||||
"renameFolder": "Cambiar nombre de la carpeta",
|
||||
"renameFolderResult": {
|
||||
"success": "Carpeta renombrada a \"{name}\"",
|
||||
"failed": "Error al cambiar el nombre de la carpeta: {message}",
|
||||
"targetExists": "Ya existe una carpeta con ese nombre aquí",
|
||||
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
|
||||
"unsupported": "El cambio de nombre de carpetas no es compatible con esta página",
|
||||
"noRoot": "No hay ninguna raíz de modelo configurada"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||
"moveUnsupported": "El movimiento no es compatible con este elemento.",
|
||||
"createFolderHint": "Suelta para crear una nueva carpeta",
|
||||
"newFolderName": "Nombre de la nueva carpeta",
|
||||
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
|
||||
"emptyFolderName": "Por favor, introduce un nombre de carpeta",
|
||||
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
|
||||
"noDragState": "No se encontró ninguna operación de arrastre pendiente"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "No se encontraron carpetas",
|
||||
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
||||
"createHint": "Haz clic en el botón Nueva carpeta de arriba para crear carpetas"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Buscar actualizaciones en esta carpeta",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "Descargar modelo desde URL",
|
||||
"titleWithType": "Descargar {type} desde URL",
|
||||
"civitaiUrl": "URL de CivitAI:",
|
||||
"civitaiUrl": "URL del modelo:",
|
||||
"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.",
|
||||
"urlHint": "Ingrese una URL de CivitAI, CivArchive, Hugging Face o ModelScope 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...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "En la biblioteca"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Formato de URL de CivitAI inválido",
|
||||
"invalidUrl": "Formato de URL de modelo inválido",
|
||||
"noVersions": "No hay versiones disponibles para este modelo",
|
||||
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face en el mismo lote.",
|
||||
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face / ModelScope en el mismo lote.",
|
||||
"noModelFiles": "No se encontraron archivos de modelo en este repositorio."
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "Archivo actual:",
|
||||
"downloading": "Descargando: {name}",
|
||||
"metadata": "Metadatos: {name}",
|
||||
"indexingFile": "Leyendo el archivo de modelo...",
|
||||
"fetchingSourceMetadata": "Obteniendo metadatos de {source}...",
|
||||
"fetchingMetadata": "Obteniendo metadatos...",
|
||||
"transferred": "Descargado: {downloaded} / {total}",
|
||||
"transferredSimple": "Descargado: {downloaded}",
|
||||
"transferredUnknown": "Descargado: --",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
|
||||
"downloadButton": "Descargar {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Reasociar recetas",
|
||||
"messageGlobal": "Se escanearán todas las recetas contra tu biblioteca local de modelos.",
|
||||
"messageSingle": "Se escaneará esta receta contra tu biblioteca local de modelos.",
|
||||
"messageBulk": "Se escanearán {count} receta(s) seleccionada(s) contra tu biblioteca local de modelos.",
|
||||
"relaxedLabel": "Reconectar también los modelos faltantes por nombre de archivo",
|
||||
"relaxedDescription": "Estos modelos también se pueden corregir descargándolos; la descarga es más precisa. Las coincidencias pueden enlazar una versión diferente; se listarán para su revisión y se pueden deshacer.",
|
||||
"confirmButton": "Reasociar"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Deshacer",
|
||||
"undone": "Deshecho",
|
||||
"undoFailed": "No se pudo deshacer la reasociación: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Resumen de la reasociación",
|
||||
"successMessage": "{entries} entradas asociadas",
|
||||
"failed": "Falló la reasociación",
|
||||
"completedWithWarnings": "Reasociación completada — se recomienda revisar",
|
||||
"cancelledNote": "Ejecución cancelada antes de completarse — los recuentos son parciales.",
|
||||
"statMatched": "Entradas asociadas",
|
||||
"statReview": "Por revisar",
|
||||
"statUnresolved": "Sin coincidencia",
|
||||
"statErrors": "Errores",
|
||||
"reviewSection": "Coincidencias por nombre de archivo para revisar ({count})",
|
||||
"columnRecipe": "Receta",
|
||||
"columnEntry": "Entrada",
|
||||
"columnFile": "Archivo coincidente",
|
||||
"columnUndo": "Deshacer",
|
||||
"copyReport": "Copiar informe",
|
||||
"close": "Cerrar",
|
||||
"scope_global": "Todas las recetas",
|
||||
"scope_bulk": "Recetas seleccionadas",
|
||||
"scope_single": "Receta individual"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Imágenes de ejemplo locales",
|
||||
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"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:",
|
||||
"linkModelSource": {
|
||||
"title": "Vincular a una fuente de modelo",
|
||||
"infoText": "Pegue la URL de la página del modelo para asociar este modelo con su fuente. La vinculación permite el enriquecimiento de metadatos con IA para modelos de Hugging Face y ModelScope.",
|
||||
"urlLabel": "URL de la página del modelo:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Ingrese la URL completa del repositorio de HuggingFace.",
|
||||
"helpText": "Ingrese la URL completa de la página del modelo. Sitios soportados:",
|
||||
"enrichNote": "El enriquecimiento con IA necesita una ficha de modelo legible. Los sitios que no la exponen (actualmente TensorArt) solo se pueden vincular.",
|
||||
"urlRequired": "Ingrese la URL de la página del modelo.",
|
||||
"invalidUrl": "URL no soportada. Sitios soportados: Hugging Face, ModelScope, TensorArt.",
|
||||
"linking": "Vinculando la fuente del modelo...",
|
||||
"confirmAction": "Guardar y vincular"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,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.",
|
||||
"sourceGroupInfo": "Este es un grupo de modelos de {source}. Abra la biblioteca para ver todas las versiones en la cuadrícula.",
|
||||
"confirm": {
|
||||
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "Inicializando gestor de embedding",
|
||||
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Inicializando el gestor de otros modelos",
|
||||
"message": "Escaneando y construyendo la caché de modelos. Esto puede tomar unos minutos..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Inicializando gestor de recetas",
|
||||
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "Faltan datos necesarios para crear la receta",
|
||||
"created": "Receta creada exitosamente",
|
||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||
"unresolvableMarkedForReconnect": "Se marcaron {count} entrada(s) no resoluble(s) — ahora se pueden reconectar a un LoRA local.",
|
||||
"noPreviousRecipe": "No hay receta anterior disponible",
|
||||
"noNextRecipe": "No hay siguiente receta disponible",
|
||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
||||
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
||||
"noRecipesSelected": "No se han seleccionado recetas",
|
||||
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
|
||||
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
||||
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
|
||||
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
||||
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
||||
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
||||
"reimporting": "Reimportando receta desde origen...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
||||
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
||||
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
||||
"otherRootsFailed": "Error al cargar raíces de otros modelos: {message}",
|
||||
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
||||
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "Modelo re-vinculado exitosamente mediante CivitArchive",
|
||||
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
|
||||
"noCivitaiInfo": "No hay información de CivitAI disponible",
|
||||
"missingHash": "Hash del modelo no disponible"
|
||||
"missingHash": "Hash del modelo no disponible",
|
||||
"enrichNeedsSource": "Vincule este modelo a una fuente de modelo primero (Vincular modelo → Vincular a una fuente de modelo)",
|
||||
"enrichUnsupportedSource": "El enriquecimiento con IA no está disponible para modelos de {source}"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "Ruta de imágenes de ejemplo actualizada exitosamente",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "Reconstruyendo caché...",
|
||||
"rebuildFailed": "Error al reconstruir la caché: {error}",
|
||||
"retry": "Reintentar"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "La gestión de otros modelos ya está disponible",
|
||||
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
|
||||
"enable": "Activar otros modelos",
|
||||
"openSettings": "Abrir configuración"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"reorder": {
|
||||
"dragHandle": "Glisser pour réordonner"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "Voir sur CivitAI",
|
||||
"notAvailableFromCivitai": "Non disponible sur CivitAI",
|
||||
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||
"viewOnSource": "Voir sur {source}",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
|
||||
"copyLoRASyntax": "Copier la syntaxe LoRA",
|
||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
||||
"modelNameCopied": "Nom du modèle copié",
|
||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "Réassocier les Recipes aux modèles locaux",
|
||||
"loading": "Réassociation des Recipes aux modèles locaux...",
|
||||
"success": "{entries} entrées associées dans {recipes} Recipes",
|
||||
"successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
||||
"allFailed": "Échec de la réassociation de {failures} Recipes sur {total}",
|
||||
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
|
||||
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
|
||||
"error": "Échec de la réassociation des Recipes : {message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "Recipes",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Autres",
|
||||
"statistics": "Statistiques"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultEmbeddingRoot": "Racine Embedding",
|
||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultVaeRoot": "Racine VAE",
|
||||
"defaultVaeRootHelp": "Définir le répertoire racine VAE par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultUpscalerRoot": "Racine Upscaler",
|
||||
"defaultUpscalerRootHelp": "Définir le répertoire racine Upscaler par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultTextEncoderRoot": "Racine Text Encoder",
|
||||
"defaultTextEncoderRootHelp": "Définir le répertoire racine Text Encoder par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultClipVisionRoot": "Racine CLIP Vision",
|
||||
"defaultClipVisionRootHelp": "Définir le répertoire racine CLIP Vision par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultControlnetRoot": "Racine ControlNet",
|
||||
"defaultControlnetRootHelp": "Définir le répertoire racine ControlNet par défaut pour les téléchargements, imports et déplacements",
|
||||
"enableOtherModels": "Gestion des autres modèles",
|
||||
"enableOtherModelsHelp": "Lorsque cette option est désactivée, les dossiers VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet ne sont pas analysés, la page Autres modèles reste désactivée et ces types de modèles ne peuvent pas être téléchargés.",
|
||||
"otherSubTypes": "Types de modèles gérés",
|
||||
"otherSubTypesHelp": "Choisissez les catégories d’autres modèles analysées et affichées sur la page Autres modèles.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Chemin de stockage des Recipes",
|
||||
"recipesPathHelp": "Dossier personnalisé facultatif pour les Recipes enregistrées. Laissez vide pour utiliser le dossier recipes de la première racine LoRA.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "Auto-organisation terminée",
|
||||
"error": "Erreur : {error}"
|
||||
},
|
||||
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
|
||||
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Actualiser les données CivitAI",
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"linkModel": "Lier le modèle",
|
||||
"linkCivitai": "Relier à nouveau à CivitAI",
|
||||
"linkHuggingFace": "Lier à HuggingFace",
|
||||
"linkModelSource": "Lier à une source de modèle",
|
||||
"copySyntax": "Copier la syntaxe LoRA",
|
||||
"copyFilename": "Copier le nom de fichier du modèle",
|
||||
"copyRecipeSyntax": "Copier la syntaxe de la recipe",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "Voir tous les LoRAs",
|
||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||
"deleteRecipe": "Supprimer la recipe",
|
||||
"enrichHfAgent": "Enrichir les métadonnées HF (IA)"
|
||||
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "Modèle de base",
|
||||
"unknown": "Inconnu"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "Ouvrir l’emplacement du fichier",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Modèles Embedding"
|
||||
},
|
||||
"other": {
|
||||
"title": "Autres modèles",
|
||||
"disabled": {
|
||||
"title": "La gestion des autres modèles est désactivée",
|
||||
"description": "Activez-la pour analyser et gérer les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et pour les télécharger depuis CivitAI.",
|
||||
"enableButton": "Activer les autres modèles",
|
||||
"hint": "Vous pourrez modifier les types de modèles gérés plus tard dans Paramètres > Bibliothèque.",
|
||||
"enableFailed": "Échec de l’activation des autres modèles",
|
||||
"downloadBlocked": "La gestion des autres modèles est désactivée pour ce type de modèle. Activez-la dans Paramètres > Bibliothèque pour télécharger ce fichier.",
|
||||
"enableAction": "Activer les autres modèles"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Aucun dossier d’autres modèles trouvé",
|
||||
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun dossier d’autres modèles n’a été trouvé. Ajoutez les clés de dossiers dont vous avez besoin à la section folder_paths de votre settings.json, puis redémarrez LoRA Manager.",
|
||||
"hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.",
|
||||
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
|
||||
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
|
||||
"openSettings": "Ouvrir les paramètres",
|
||||
"openSettingsFolder": "Ouvrir le dossier des paramètres"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"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",
|
||||
"viewOptions": "Options d’affichage",
|
||||
"treeView": "Vue arborescente",
|
||||
"listView": "Vue liste",
|
||||
"recursiveOn": "Inclure les sous-dossiers",
|
||||
"recursiveOff": "Dossier actuel uniquement",
|
||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"createFolder": "Nouveau dossier",
|
||||
"newSubfolder": "Nouveau sous-dossier",
|
||||
"showEmptyFolders": "Afficher les dossiers vides",
|
||||
"createFolderResult": {
|
||||
"success": "Dossier \"{name}\" créé",
|
||||
"failed": "Échec de la création du dossier : {message}",
|
||||
"unsupported": "La création de dossiers n’est pas prise en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
},
|
||||
"deleteFolder": "Supprimer le dossier",
|
||||
"deleteFolderModal": {
|
||||
"title": "Supprimer le dossier ?",
|
||||
"message": "Le dossier et tout son contenu seront définitivement supprimés du disque.",
|
||||
"folderLabel": "Dossier",
|
||||
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers qu’il contient seront également supprimés.",
|
||||
"notEmptyTitle": "Le dossier n’est pas vide",
|
||||
"notEmptyMessage": "Ce dossier contient encore des modèles. Supprimez-les ou déplacez-les d’abord — la suppression d’un dossier n’entraîne jamais celle des fichiers de modèles.",
|
||||
"confirm": "Supprimer le dossier"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Dossier \"{name}\" supprimé",
|
||||
"successWithFiles": "Dossier \"{name}\" supprimé, ainsi que {count} autre(s) élément(s)",
|
||||
"restored": "Dossier restauré",
|
||||
"failed": "Échec de la suppression du dossier : {message}",
|
||||
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
|
||||
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
||||
"unsupported": "La suppression de dossiers n’est pas prise en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
},
|
||||
"renameFolder": "Renommer le dossier",
|
||||
"renameFolderResult": {
|
||||
"success": "Dossier renommé en \"{name}\"",
|
||||
"failed": "Échec du renommage du dossier : {message}",
|
||||
"targetExists": "Un dossier portant ce nom existe déjà ici",
|
||||
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
|
||||
"unsupported": "Le renommage de dossiers n’est pas pris en charge sur cette page",
|
||||
"noRoot": "Aucune racine de modèle n’est configurée"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
||||
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
|
||||
"createFolderHint": "Relâcher pour créer un nouveau dossier",
|
||||
"newFolderName": "Nom du nouveau dossier",
|
||||
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
|
||||
"emptyFolderName": "Veuillez saisir un nom de dossier",
|
||||
"invalidFolderName": "Le nom du dossier contient des caractères invalides",
|
||||
"noDragState": "Aucune opération de glissement en attente trouvée"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
||||
"createHint": "Cliquez sur le bouton Nouveau dossier ci-dessus pour créer des dossiers"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Vérifier les mises à jour dans ce dossier",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "Télécharger un modèle depuis une URL",
|
||||
"titleWithType": "Télécharger {type} depuis une URL",
|
||||
"civitaiUrl": "URL CivitAI :",
|
||||
"civitaiUrl": "URL du modèle :",
|
||||
"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.",
|
||||
"urlHint": "Entrez une URL CivitAI, CivArchive, Hugging Face ou ModelScope 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...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "Dans la bibliothèque"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Format d'URL CivitAI invalide",
|
||||
"invalidUrl": "Format d'URL de modèle invalide",
|
||||
"noVersions": "Aucune version disponible pour ce modèle",
|
||||
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face dans le même lot.",
|
||||
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face / ModelScope dans le même lot.",
|
||||
"noModelFiles": "Aucun fichier de modèle trouvé dans ce dépôt."
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "Fichier actuel :",
|
||||
"downloading": "Téléchargement : {name}",
|
||||
"metadata": "Métadonnées : {name}",
|
||||
"indexingFile": "Lecture du fichier de modèle...",
|
||||
"fetchingSourceMetadata": "Récupération des métadonnées depuis {source}...",
|
||||
"fetchingMetadata": "Récupération des métadonnées...",
|
||||
"transferred": "Téléchargé : {downloaded} / {total}",
|
||||
"transferredSimple": "Téléchargé : {downloaded}",
|
||||
"transferredUnknown": "Téléchargé : --",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
|
||||
"downloadButton": "Télécharger {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Réassocier les Recipes",
|
||||
"messageGlobal": "Toutes les Recipes seront analysées par rapport à votre bibliothèque de modèles locale.",
|
||||
"messageSingle": "Cette Recipe sera analysée par rapport à votre bibliothèque de modèles locale.",
|
||||
"messageBulk": "{count} Recipes sélectionnées seront analysées par rapport à votre bibliothèque de modèles locale.",
|
||||
"relaxedLabel": "Reconnecter aussi les modèles manquants par nom de fichier",
|
||||
"relaxedDescription": "Ces modèles peuvent aussi être corrigés par téléchargement — le téléchargement est plus précis. Les correspondances peuvent associer une version différente ; elles seront listées pour vérification et peuvent être annulées.",
|
||||
"confirmButton": "Réassocier"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Annuler",
|
||||
"undone": "Annulé",
|
||||
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Résumé de la réassociation",
|
||||
"successMessage": "{entries} entrées associées",
|
||||
"failed": "Échec de la réassociation",
|
||||
"completedWithWarnings": "Réassociation terminée — vérification recommandée",
|
||||
"cancelledNote": "Exécution annulée avant la fin — les décomptes sont partiels.",
|
||||
"statMatched": "Entrées associées",
|
||||
"statReview": "À vérifier",
|
||||
"statUnresolved": "Sans correspondance",
|
||||
"statErrors": "Erreurs",
|
||||
"reviewSection": "Correspondances par nom de fichier à vérifier ({count})",
|
||||
"columnRecipe": "Recipe",
|
||||
"columnEntry": "Entrée",
|
||||
"columnFile": "Fichier correspondant",
|
||||
"columnUndo": "Annuler",
|
||||
"copyReport": "Copier le rapport",
|
||||
"close": "Fermer",
|
||||
"scope_global": "Toutes les Recipes",
|
||||
"scope_bulk": "Recipes sélectionnées",
|
||||
"scope_single": "Une seule Recipe"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Images d'exemple locales",
|
||||
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"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 :",
|
||||
"linkModelSource": {
|
||||
"title": "Lier à une source de modèle",
|
||||
"infoText": "Collez l'URL de la page du modèle pour associer ce modèle à sa source. La liaison permet l'enrichissement des métadonnées par IA pour les modèles Hugging Face et ModelScope.",
|
||||
"urlLabel": "URL de la page du modèle :",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Entrez l'URL complète du dépôt HuggingFace.",
|
||||
"helpText": "Entrez l'URL complète de la page du modèle. Sites pris en charge :",
|
||||
"enrichNote": "L'enrichissement par IA nécessite une fiche de modèle lisible. Les sites qui n'en exposent pas (actuellement TensorArt) ne peuvent être que liés.",
|
||||
"urlRequired": "Veuillez saisir l'URL de la page du modèle.",
|
||||
"invalidUrl": "URL non prise en charge. Sites pris en charge : Hugging Face, ModelScope, TensorArt.",
|
||||
"linking": "Liaison de la source du modèle...",
|
||||
"confirmAction": "Enregistrer & lier"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,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.",
|
||||
"sourceGroupInfo": "Ceci est un groupe de modèles {source}. Ouvrez la bibliothèque pour voir toutes les versions dans la grille.",
|
||||
"confirm": {
|
||||
"delete": "Supprimer cette version de votre bibliothèque ?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "Initialisation du gestionnaire Embedding",
|
||||
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Initialisation du gestionnaire Autres modèles",
|
||||
"message": "Analyse et construction du cache de modèles. Cela peut prendre quelques minutes..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Initialisation du gestionnaire de recipes",
|
||||
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
||||
"created": "Recipe créé avec succès",
|
||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||
"unresolvableMarkedForReconnect": "{count} entrées irrésolubles marquées — elles peuvent maintenant être reconnectées à un LoRA local.",
|
||||
"noPreviousRecipe": "Aucune Recipe précédente",
|
||||
"noNextRecipe": "Aucune Recipe suivante",
|
||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
||||
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
||||
"noRecipesSelected": "Aucune Recipe sélectionnée",
|
||||
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
|
||||
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
||||
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
|
||||
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
|
||||
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
|
||||
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
||||
"reimporting": "Ré-import de la Recipe depuis la source...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
||||
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
||||
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
||||
"otherRootsFailed": "Échec du chargement des racines des autres modèles : {message}",
|
||||
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
|
||||
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "Modèle relié via CivitArchive avec succès",
|
||||
"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"
|
||||
"missingHash": "Hash du modèle non disponible",
|
||||
"enrichNeedsSource": "Liez d'abord ce modèle à une source de modèle (Lier le modèle → Lier à une source de modèle)",
|
||||
"enrichUnsupportedSource": "L'enrichissement par IA n'est pas disponible pour les modèles {source}"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "Chemin des images d'exemple mis à jour avec succès",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "Reconstruction du cache...",
|
||||
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "La gestion des autres modèles est disponible",
|
||||
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
|
||||
"enable": "Activer les autres modèles",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "ביטול",
|
||||
"confirm": "אישור",
|
||||
"reorder": {
|
||||
"dragHandle": "גרור כדי לשנות סדר"
|
||||
},
|
||||
"actions": {
|
||||
"save": "שמירה",
|
||||
"cancel": "ביטול",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "הצג ב-CivitAI",
|
||||
"notAvailableFromCivitai": "לא זמין מ-CivitAI",
|
||||
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||
"viewOnSource": "צפייה ב-{source}",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
|
||||
"copyLoRASyntax": "העתק תחביר LoRA",
|
||||
"checkpointNameCopied": "שם Checkpoint הועתק",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "העתק שם Checkpoint",
|
||||
"copyEmbeddingName": "העתק שם Embedding",
|
||||
"embeddingNameCopied": "תחביר Embedding הועתק",
|
||||
"modelNameCopied": "שם המודל הועתק",
|
||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
||||
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
|
||||
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
||||
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "מתכונים",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "אחרים",
|
||||
"statistics": "סטטיסטיקה"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||
"defaultVaeRoot": "תיקיית שורש VAE",
|
||||
"defaultVaeRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של VAE להורדות, ייבוא והעברות",
|
||||
"defaultUpscalerRoot": "תיקיית שורש Upscaler",
|
||||
"defaultUpscalerRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Upscaler להורדות, ייבוא והעברות",
|
||||
"defaultTextEncoderRoot": "תיקיית שורש Text Encoder",
|
||||
"defaultTextEncoderRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Text Encoder להורדות, ייבוא והעברות",
|
||||
"defaultClipVisionRoot": "תיקיית שורש CLIP Vision",
|
||||
"defaultClipVisionRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של CLIP Vision להורדות, ייבוא והעברות",
|
||||
"defaultControlnetRoot": "תיקיית שורש ControlNet",
|
||||
"defaultControlnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של ControlNet להורדות, ייבוא והעברות",
|
||||
"enableOtherModels": "ניהול מודלים אחרים",
|
||||
"enableOtherModelsHelp": "כשהאפשרות כבויה, תיקיות VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet אינן נסרקות, עמוד המודלים האחרים נשאר מושבת ולא ניתן להוריד סוגי מודלים אלה.",
|
||||
"otherSubTypes": "סוגי מודלים מנוהלים",
|
||||
"otherSubTypesHelp": "בחר אילו קטגוריות של מודלים אחרים ייסרקו ויוצגו בעמוד המודלים האחרים.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "נתיב אחסון מתכונים",
|
||||
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "ארגון אוטומטי הושלם",
|
||||
"error": "שגיאה: {error}"
|
||||
},
|
||||
"enrichHfAgent": "העשרת HF מטא-נתונים (AI)"
|
||||
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "רענן נתוני CivitAI",
|
||||
"checkUpdates": "בדוק עדכונים",
|
||||
"linkModel": "קישור מודל",
|
||||
"linkCivitai": "קשר מחדש ל-CivitAI",
|
||||
"linkHuggingFace": "קישור ל-HuggingFace",
|
||||
"linkModelSource": "קישור למקור מודל",
|
||||
"copySyntax": "העתק תחביר LoRA",
|
||||
"copyFilename": "העתק שם קובץ מודל",
|
||||
"copyRecipeSyntax": "העתק תחביר מתכון",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||
"downloadMissingLoras": "הורד LoRAs חסרים",
|
||||
"deleteRecipe": "מחק מתכון",
|
||||
"enrichHfAgent": "העשרת HF מטא-נתונים (AI)"
|
||||
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "מודל בסיס",
|
||||
"unknown": "לא ידוע"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "מודלי Embedding"
|
||||
},
|
||||
"other": {
|
||||
"title": "מודלים אחרים",
|
||||
"disabled": {
|
||||
"title": "ניהול המודלים האחרים כבוי",
|
||||
"description": "הפעל כדי לסרוק ולנהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, ולהוריד אותם מ-CivitAI.",
|
||||
"enableButton": "הפעל מודלים אחרים",
|
||||
"hint": "ניתן לשנות את סוגי המודלים המנוהלים מאוחר יותר בהגדרות > ספרייה.",
|
||||
"enableFailed": "הפעלת המודלים האחרים נכשלה",
|
||||
"downloadBlocked": "ניהול המודלים האחרים מושבת עבור סוג מודל זה. הפעל אותו בהגדרות > ספרייה כדי להוריד קובץ זה.",
|
||||
"enableAction": "הפעל מודלים אחרים"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "לא נמצאו תיקיות של מודלים אחרים",
|
||||
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך לא נמצאו תיקיות של מודלים אחרים. הוסף את מפתחות התיקיות הדרושים למקטע folder_paths ב-settings.json והפעל מחדש את LoRA Manager.",
|
||||
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
|
||||
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
|
||||
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
|
||||
"openSettings": "פתח הגדרות",
|
||||
"openSettingsFolder": "פתח תיקיית הגדרות"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
||||
"showSidebar": "הצג סרגל צד",
|
||||
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
||||
"switchToListView": "עבור לתצוגת רשימה",
|
||||
"switchToTreeView": "תצוגת עץ",
|
||||
"viewOptions": "אפשרויות תצוגה",
|
||||
"treeView": "תצוגת עץ",
|
||||
"listView": "תצוגת רשימה",
|
||||
"recursiveOn": "כלול תיקיות משנה",
|
||||
"recursiveOff": "רק התיקייה הנוכחית",
|
||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"createFolder": "תיקייה חדשה",
|
||||
"newSubfolder": "תיקיית משנה חדשה",
|
||||
"showEmptyFolders": "הצג תיקיות ריקות",
|
||||
"createFolderResult": {
|
||||
"success": "התיקייה \"{name}\" נוצרה",
|
||||
"failed": "יצירת התיקייה נכשלה: {message}",
|
||||
"unsupported": "יצירת תיקיות אינה נתמכת בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
},
|
||||
"deleteFolder": "מחק תיקייה",
|
||||
"deleteFolderModal": {
|
||||
"title": "למחוק את התיקייה?",
|
||||
"message": "התיקייה וכל תוכנה יימחקו לצמיתות מהדיסק.",
|
||||
"folderLabel": "תיקייה",
|
||||
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
|
||||
"notEmptyTitle": "התיקייה אינה ריקה",
|
||||
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"confirm": "מחק תיקייה"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "התיקייה \"{name}\" נמחקה",
|
||||
"successWithFiles": "התיקייה \"{name}\" נמחקה יחד עם {count} פריטים נוספים",
|
||||
"restored": "התיקייה שוחזרה",
|
||||
"failed": "מחיקת התיקייה נכשלה: {message}",
|
||||
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
},
|
||||
"renameFolder": "שנה שם תיקייה",
|
||||
"renameFolderResult": {
|
||||
"success": "שם התיקייה שונה ל-\"{name}\"",
|
||||
"failed": "שינוי שם התיקייה נכשל: {message}",
|
||||
"targetExists": "תיקייה בשם זה כבר קיימת כאן",
|
||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||
"unsupported": "שינוי שם תיקיות אינו נתמך בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
|
||||
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
|
||||
"newFolderName": "שם תיקייה חדשה",
|
||||
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
|
||||
"emptyFolderName": "אנא הזן שם תיקייה",
|
||||
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
|
||||
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "לא נמצאו תיקיות",
|
||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
||||
"createHint": "לחץ על כפתור תיקייה חדשה למעלה כדי ליצור תיקיות"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "בדוק עדכונים בתיקייה זו",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "הורד מודל מכתובת URL",
|
||||
"titleWithType": "הורד {type} מכתובת URL",
|
||||
"civitaiUrl": "כתובת URL של CivitAI:",
|
||||
"civitaiUrl": "כתובת URL של מודל:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
|
||||
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive, Hugging Face או ModelScope בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
|
||||
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
|
||||
"selectAll": "בחר הכל",
|
||||
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "בספרייה"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "פורמט URL של CivitAI לא חוקי",
|
||||
"invalidUrl": "פורמט URL של מודל לא חוקי",
|
||||
"noVersions": "אין גרסאות זמינות למודל זה",
|
||||
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
|
||||
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face / ModelScope באותה קבוצה.",
|
||||
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "הקובץ הנוכחי:",
|
||||
"downloading": "מוריד: {name}",
|
||||
"metadata": "מטא-נתונים: {name}",
|
||||
"indexingFile": "קורא קובץ מודל...",
|
||||
"fetchingSourceMetadata": "מביא מטא-נתונים מ-{source}...",
|
||||
"fetchingMetadata": "מביא מטא-נתונים...",
|
||||
"transferred": "הורד: {downloaded} / {total}",
|
||||
"transferredSimple": "הורד: {downloaded}",
|
||||
"transferredUnknown": "הורד: --",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
||||
"downloadButton": "הורד {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "התאמה מחדש של מתכונים",
|
||||
"messageGlobal": "כל המתכונים ייסרקו מול ספריית המודלים המקומית שלך.",
|
||||
"messageSingle": "מתכון זה ייסרק מול ספריית המודלים המקומית שלך.",
|
||||
"messageBulk": "{count} מתכונים שנבחרו ייסרקו מול ספריית המודלים המקומית שלך.",
|
||||
"relaxedLabel": "חבר מחדש גם מודלים חסרים לפי שם קובץ",
|
||||
"relaxedDescription": "אפשר לתקן את המודלים האלה גם על ידי הורדה — ההורדה מדויקת יותר. ההתאמות עשויות לקשר לגרסה אחרת; הן יוצגו לסקירה וניתן לבטל אותן.",
|
||||
"confirmButton": "התאם מחדש"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "בטל",
|
||||
"undone": "בוטל",
|
||||
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "סיכום התאמה מחדש",
|
||||
"successMessage": "הותאמו {entries} פריטים",
|
||||
"failed": "ההתאמה מחדש נכשלה",
|
||||
"completedWithWarnings": "ההתאמה מחדש הושלמה — מומלץ לסקור",
|
||||
"cancelledNote": "ההתאמה בוטלה לפני שהסתיימה — המספרים חלקיים.",
|
||||
"statMatched": "פריטים שהותאמו",
|
||||
"statReview": "טעוני סקירה",
|
||||
"statUnresolved": "ללא התאמה",
|
||||
"statErrors": "שגיאות",
|
||||
"reviewSection": "התאמות לפי שם קובץ לסקירה ({count})",
|
||||
"columnRecipe": "מתכון",
|
||||
"columnEntry": "פריט",
|
||||
"columnFile": "הקובץ שהותאם",
|
||||
"columnUndo": "בטל",
|
||||
"copyReport": "העתק דוח",
|
||||
"close": "סגור",
|
||||
"scope_global": "כל המתכונים",
|
||||
"scope_bulk": "מתכונים שנבחרו",
|
||||
"scope_single": "מתכון בודד"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "תמונות דוגמה מקומיות",
|
||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"pathPlaceholder": "הקלד נתיב תיקייה או בחר מהעץ למטה...",
|
||||
"root": "שורש"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "קישור ל-HuggingFace",
|
||||
"infoText": "הדבק את כתובת ה-URL של מאגר HuggingFace כדי לשייך מודל זה למקורו. פעולה זו מאפשרת העשרת מטא-נתונים באמצעות AI.",
|
||||
"urlLabel": "כתובת URL של מאגר HuggingFace:",
|
||||
"linkModelSource": {
|
||||
"title": "קישור למקור מודל",
|
||||
"infoText": "הדבק את כתובת ה-URL של עמוד המודל כדי לשייך מודל זה למקורו. הקישור מאפשר העשרת מטא-נתונים באמצעות AI עבור מודלים של Hugging Face ו-ModelScope.",
|
||||
"urlLabel": "כתובת URL של עמוד המודל:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "הזן את כתובת ה-URL המלאה של מאגר HuggingFace.",
|
||||
"helpText": "הזן את כתובת ה-URL המלאה של עמוד המודל. אתרים נתמכים:",
|
||||
"enrichNote": "העשרת AI דורשת כרטיס מודל קריא. אתרים שאינם חושפים אותו (נכון להיום TensorArt) ניתנים לקישור בלבד.",
|
||||
"urlRequired": "הזן כתובת URL של עמוד המודל.",
|
||||
"invalidUrl": "כתובת URL לא נתמכת. אתרים נתמכים: Hugging Face, ModelScope, TensorArt.",
|
||||
"linking": "מקשר את מקור המודל...",
|
||||
"confirmAction": "שמור וקשר"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,7 @@
|
||||
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
|
||||
"error": "טעינת הגרסאות נכשלה.",
|
||||
"missingModelId": "למודל זה אין מזהה מודל של CivitAI.",
|
||||
"hfGroupInfo": "זוהי קבוצת מודלים של HuggingFace. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
|
||||
"sourceGroupInfo": "זוהי קבוצת מודלים של {source}. פתח את הספרייה כדי לראות את כל הגרסאות ברשת.",
|
||||
"confirm": {
|
||||
"delete": "למחוק גרסה זו מהספרייה שלך?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "מאתחל מנהל Embedding",
|
||||
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
|
||||
},
|
||||
"other": {
|
||||
"title": "מאתחל את מנהל המודלים האחרים",
|
||||
"message": "סורק ובונה מטמון מודלים. זה עשוי לקחת מספר דקות..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "מאתחל מנהל מתכונים",
|
||||
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||
"created": "המתכון נוצר בהצלחה",
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
|
||||
"noPreviousRecipe": "אין מתכון קודם זמין",
|
||||
"noNextRecipe": "אין מתכון נוסף זמין",
|
||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
||||
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
||||
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
||||
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
||||
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
||||
"otherRootsFailed": "טעינת שורשי המודלים האחרים נכשלה: {message}",
|
||||
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
|
||||
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "המודל קושר מחדש דרך CivitArchive בהצלחה",
|
||||
"fetchMetadataFirst": "אנא אחזר מטא-נתונים מ-CivitAI תחילה",
|
||||
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
|
||||
"missingHash": "ה-hash של המודל אינו זמין"
|
||||
"missingHash": "ה-hash של המודל אינו זמין",
|
||||
"enrichNeedsSource": "קשר מודל זה למקור מודל תחילה (קישור מודל → קישור למקור מודל)",
|
||||
"enrichUnsupportedSource": "העשרת AI אינה זמינה עבור מודלים של {source}"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "נתיב תמונות הדוגמה עודכן בהצלחה",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "בונה מחדש את המטמון...",
|
||||
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
|
||||
"retry": "נסה שוב"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "ניהול המודלים האחרים זמין",
|
||||
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
||||
"enable": "הפעל מודלים אחרים",
|
||||
"openSettings": "פתח הגדרות"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "確認",
|
||||
"reorder": {
|
||||
"dragHandle": "ドラッグして並べ替え"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "CivitAIで表示",
|
||||
"notAvailableFromCivitai": "CivitAIでは利用できません",
|
||||
"viewOnHuggingFace": "Hugging Face で見る",
|
||||
"viewOnSource": "{source} で見る",
|
||||
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
|
||||
"copyLoRASyntax": "LoRA構文をコピー",
|
||||
"checkpointNameCopied": "Checkpointの名前をコピーしました",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "Checkpoint名をコピー",
|
||||
"copyEmbeddingName": "embedding名をコピー",
|
||||
"embeddingNameCopied": "Embedding構文をコピーしました",
|
||||
"modelNameCopied": "モデル名をコピーしました",
|
||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "レシピをローカルモデルに再マッチング",
|
||||
"loading": "レシピをローカルモデルに再マッチングしています...",
|
||||
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
||||
"error": "レシピの再マッチングに失敗しました:{message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "レシピ",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"other": "その他",
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||
"defaultEmbeddingRoot": "Embeddingルート",
|
||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||
"defaultVaeRoot": "VAEルート",
|
||||
"defaultVaeRootHelp": "ダウンロード、インポート、移動用のデフォルトVAEルートディレクトリを設定",
|
||||
"defaultUpscalerRoot": "Upscalerルート",
|
||||
"defaultUpscalerRootHelp": "ダウンロード、インポート、移動用のデフォルトUpscalerルートディレクトリを設定",
|
||||
"defaultTextEncoderRoot": "Text Encoderルート",
|
||||
"defaultTextEncoderRootHelp": "ダウンロード、インポート、移動用のデフォルトText Encoderルートディレクトリを設定",
|
||||
"defaultClipVisionRoot": "CLIP Visionルート",
|
||||
"defaultClipVisionRootHelp": "ダウンロード、インポート、移動用のデフォルトCLIP Visionルートディレクトリを設定",
|
||||
"defaultControlnetRoot": "ControlNetルート",
|
||||
"defaultControlnetRootHelp": "ダウンロード、インポート、移動用のデフォルトControlNetルートディレクトリを設定",
|
||||
"enableOtherModels": "その他のモデル管理",
|
||||
"enableOtherModelsHelp": "オフにすると、VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet フォルダーはスキャンされず、その他のモデルページは無効のままになり、これらのモデルタイプはダウンロードできません。",
|
||||
"otherSubTypes": "管理するモデルタイプ",
|
||||
"otherSubTypesHelp": "その他のモデルページでスキャンおよび表示するカテゴリを選択します。",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "レシピ保存先",
|
||||
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "自動整理が完了しました",
|
||||
"error": "エラー:{error}"
|
||||
},
|
||||
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||
"enrichHfAgent": "メタデータをAIで補完"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "CivitAIデータを更新",
|
||||
"checkUpdates": "更新確認",
|
||||
"linkModel": "モデルをリンク",
|
||||
"linkCivitai": "CivitAI にリンク",
|
||||
"linkHuggingFace": "HuggingFace にリンク",
|
||||
"linkModelSource": "モデルソースにリンク",
|
||||
"copySyntax": "LoRA構文をコピー",
|
||||
"copyFilename": "モデルファイル名をコピー",
|
||||
"copyRecipeSyntax": "レシピ構文をコピー",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "すべてのLoRAを表示",
|
||||
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
||||
"deleteRecipe": "レシピを削除",
|
||||
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||
"enrichHfAgent": "メタデータをAIで補完"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "ベースモデル",
|
||||
"unknown": "不明"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Embeddingモデル"
|
||||
},
|
||||
"other": {
|
||||
"title": "その他のモデル",
|
||||
"disabled": {
|
||||
"title": "その他のモデル管理はオフです",
|
||||
"description": "有効にすると VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||
"enableButton": "その他のモデルを有効にする",
|
||||
"hint": "管理するモデルタイプは後で「設定 > ライブラリ」で変更できます。",
|
||||
"enableFailed": "その他のモデルの有効化に失敗しました",
|
||||
"downloadBlocked": "このモデルタイプではその他のモデル管理が無効です。このファイルをダウンロードするには「設定 > ライブラリ」で有効にしてください。",
|
||||
"enableAction": "その他のモデルを有効にする"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "その他のモデルのフォルダーが見つかりません",
|
||||
"descriptionStandalone": "その他のモデル管理はオンですが、その他のモデルのフォルダーが見つかりません。必要なフォルダーキーをsettings.jsonのfolder_pathsセクションに追加し、LoRA Managerを再起動してください。",
|
||||
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
|
||||
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
|
||||
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
|
||||
"openSettings": "設定を開く",
|
||||
"openSettingsFolder": "設定フォルダーを開く"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "ルート",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"collapseAllDisabled": "リスト表示では利用できません",
|
||||
"hideOnThisPage": "このページでサイドバーを非表示",
|
||||
"showSidebar": "サイドバーを表示",
|
||||
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"viewOptions": "表示オプション",
|
||||
"treeView": "ツリー表示",
|
||||
"listView": "リスト表示",
|
||||
"recursiveOn": "サブフォルダーを含める",
|
||||
"recursiveOff": "現在のフォルダーのみ",
|
||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"createFolder": "新規フォルダ",
|
||||
"newSubfolder": "新規サブフォルダ",
|
||||
"showEmptyFolders": "空のフォルダを表示",
|
||||
"createFolderResult": {
|
||||
"success": "フォルダ \"{name}\" を作成しました",
|
||||
"failed": "フォルダの作成に失敗しました: {message}",
|
||||
"unsupported": "このページではフォルダを作成できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
},
|
||||
"deleteFolder": "フォルダを削除",
|
||||
"deleteFolderModal": {
|
||||
"title": "フォルダを削除しますか?",
|
||||
"message": "フォルダとその内容はすべてディスクから完全に削除されます。",
|
||||
"folderLabel": "フォルダ",
|
||||
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
|
||||
"notEmptyTitle": "フォルダが空ではありません",
|
||||
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"confirm": "フォルダを削除"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "フォルダ \"{name}\" を削除しました",
|
||||
"successWithFiles": "フォルダ \"{name}\" を削除し、他に {count} 件の項目も削除しました",
|
||||
"restored": "フォルダを復元しました",
|
||||
"failed": "フォルダの削除に失敗しました: {message}",
|
||||
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||
"unsupported": "このページではフォルダを削除できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
},
|
||||
"renameFolder": "フォルダ名を変更",
|
||||
"renameFolderResult": {
|
||||
"success": "フォルダ名を \"{name}\" に変更しました",
|
||||
"failed": "フォルダ名の変更に失敗しました: {message}",
|
||||
"targetExists": "同じ名前のフォルダが既に存在します",
|
||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||
"unsupported": "このページではフォルダ名を変更できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||
"moveUnsupported": "この項目の移動はサポートされていません。",
|
||||
"createFolderHint": "放して新しいフォルダを作成",
|
||||
"newFolderName": "新しいフォルダ名",
|
||||
"folderNameHint": "Enterで確定、Escでキャンセル",
|
||||
"emptyFolderName": "フォルダ名を入力してください",
|
||||
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
|
||||
"noDragState": "保留中のドラッグ操作が見つかりません"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "フォルダが見つかりません",
|
||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
||||
"createHint": "上部の新規フォルダボタンからフォルダを作成できます"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "このフォルダのアップデートを確認",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "URLからモデルをダウンロード",
|
||||
"titleWithType": "URLから{type}をダウンロード",
|
||||
"civitaiUrl": "CivitAI URL:",
|
||||
"civitaiUrl": "モデル URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "1行に1つのCivitAI、CivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||
"urlHint": "1行に1つのCivitAI、CivArchive、Hugging Face、またはModelScope URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
|
||||
"selectAll": "すべて選択",
|
||||
"fetchingRepoFiles": "リポジトリのファイルを取得中...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "ライブラリ内"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "無効なCivitAI URL形式",
|
||||
"invalidUrl": "無効なモデル URL 形式",
|
||||
"noVersions": "このモデルの利用可能なバージョンがありません",
|
||||
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
|
||||
"mixedSources": "同じバッチ内でCivitAIとHugging Face / ModelScopeのURLを混在させることはできません。",
|
||||
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "現在のファイル:",
|
||||
"downloading": "ダウンロード中: {name}",
|
||||
"metadata": "メタデータ: {name}",
|
||||
"indexingFile": "モデルファイルを読み込み中...",
|
||||
"fetchingSourceMetadata": "{source} からメタデータを取得中...",
|
||||
"fetchingMetadata": "メタデータを取得中...",
|
||||
"transferred": "ダウンロード済み: {downloaded} / {total}",
|
||||
"transferredSimple": "ダウンロード済み: {downloaded}",
|
||||
"transferredUnknown": "ダウンロード済み: --",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
||||
"downloadButton": "{count} 個の LoRA をダウンロード"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "レシピの再マッチング",
|
||||
"messageGlobal": "すべてのレシピをローカルのモデルライブラリと照合します。",
|
||||
"messageSingle": "このレシピをローカルのモデルライブラリと照合します。",
|
||||
"messageBulk": "選択した {count} 件のレシピをローカルのモデルライブラリと照合します。",
|
||||
"relaxedLabel": "見つからないモデルもファイル名で再接続する",
|
||||
"relaxedDescription": "これらのモデルはダウンロードでも修正できます(ダウンロードの方が正確です)。マッチにより別バージョンが関連付けられる場合があります。マッチした項目は確認用に一覧表示され、元に戻すことができます。",
|
||||
"confirmButton": "再マッチング"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "元に戻す",
|
||||
"undone": "元に戻しました",
|
||||
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "再マッチングの概要",
|
||||
"successMessage": "{entries} エントリをマッチングしました",
|
||||
"failed": "再マッチングに失敗しました",
|
||||
"completedWithWarnings": "再マッチングは完了しましたが、要確認の項目があります",
|
||||
"cancelledNote": "完了前に実行がキャンセルされたため、件数は一部のみです。",
|
||||
"statMatched": "マッチしたエントリ",
|
||||
"statReview": "要確認",
|
||||
"statUnresolved": "マッチなし",
|
||||
"statErrors": "エラー",
|
||||
"reviewSection": "確認が必要なファイル名マッチ({count})",
|
||||
"columnRecipe": "レシピ",
|
||||
"columnEntry": "エントリ",
|
||||
"columnFile": "マッチしたファイル",
|
||||
"columnUndo": "元に戻す",
|
||||
"copyReport": "レポートをコピー",
|
||||
"close": "閉じる",
|
||||
"scope_global": "すべてのレシピ",
|
||||
"scope_bulk": "選択したレシピ",
|
||||
"scope_single": "単一のレシピ"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "ローカル例画像",
|
||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"pathPlaceholder": "フォルダパスを入力するか、下のツリーから選択...",
|
||||
"root": "ルート"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "HuggingFace にリンク",
|
||||
"infoText": "HuggingFace リポジトリの URL を貼り付けてモデルを関連付けます。AI によるメタデータ補完が有効になります。",
|
||||
"urlLabel": "HuggingFace リポジトリ URL:",
|
||||
"linkModelSource": {
|
||||
"title": "モデルソースにリンク",
|
||||
"infoText": "モデルページの URL を貼り付けて、このモデルをソースに関連付けます。リンクすると、Hugging Face と ModelScope のモデルで AI によるメタデータ補完が有効になります。",
|
||||
"urlLabel": "モデルページ URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "完全な HuggingFace リポジトリ URL を入力してください。",
|
||||
"helpText": "完全なモデルページ URL を入力してください。対応サイト:",
|
||||
"enrichNote": "AI 補完には読み取り可能なモデルカードが必要です。モデルカードを公開していないサイト(現在は TensorArt)はリンクのみ可能です。",
|
||||
"urlRequired": "モデルページの URL を入力してください。",
|
||||
"invalidUrl": "サポートされていない URL です。対応サイト:Hugging Face、ModelScope、TensorArt。",
|
||||
"linking": "モデルソースをリンクしています...",
|
||||
"confirmAction": "保存&リンク"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,7 @@
|
||||
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||
"error": "バージョンの読み込みに失敗しました。",
|
||||
"missingModelId": "このモデルにはCivitAIのモデルIDがありません。",
|
||||
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
|
||||
"sourceGroupInfo": "これは {source} モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
|
||||
"confirm": {
|
||||
"delete": "このバージョンをライブラリから削除しますか?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "Embedding Managerを初期化中",
|
||||
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
||||
},
|
||||
"other": {
|
||||
"title": "その他のモデルマネージャーを初期化中",
|
||||
"message": "モデルキャッシュをスキャンして構築中です。数分かかる場合があります..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "レシピマネージャーを初期化中",
|
||||
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||
"created": "レシピを作成しました",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
|
||||
"noPreviousRecipe": "前のレシピがありません",
|
||||
"noNextRecipe": "次のレシピがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||
"noRecipesSelected": "レシピが選択されていません",
|
||||
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||
"reimporting": "ソースからレシピを再インポート中...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
||||
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
||||
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
||||
"otherRootsFailed": "その他のモデルルートの読み込みに失敗しました:{message}",
|
||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
||||
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました",
|
||||
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
||||
"noCivitaiInfo": "CivitAI情報が利用できません",
|
||||
"missingHash": "モデルハッシュが利用できません"
|
||||
"missingHash": "モデルハッシュが利用できません",
|
||||
"enrichNeedsSource": "まずこのモデルをモデルソースにリンクしてください(モデルをリンク → モデルソースにリンク)",
|
||||
"enrichUnsupportedSource": "{source} モデルでは AI 補完を利用できません"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "例画像パスが正常に更新されました",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "キャッシュを再構築中...",
|
||||
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "その他のモデル管理が利用可能になりました",
|
||||
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||
"enable": "その他のモデルを有効にする",
|
||||
"openSettings": "設定を開く"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"reorder": {
|
||||
"dragHandle": "드래그하여 순서 변경"
|
||||
},
|
||||
"actions": {
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "CivitAI에서 보기",
|
||||
"notAvailableFromCivitai": "CivitAI에서 사용할 수 없음",
|
||||
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||
"viewOnSource": "{source}에서 보기",
|
||||
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
|
||||
"copyLoRASyntax": "LoRA 문법 복사",
|
||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||
"copyEmbeddingName": "Embedding 이름 복사",
|
||||
"embeddingNameCopied": "Embedding 구문 복사됨",
|
||||
"modelNameCopied": "모델 이름 복사됨",
|
||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "레시피를 로컬 모델에 다시 매칭",
|
||||
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
|
||||
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
||||
"error": "레시피 재매칭 실패: {message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "레시피",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"other": "기타",
|
||||
"statistics": "통계"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||
"defaultEmbeddingRoot": "Embedding 루트",
|
||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||
"defaultVaeRoot": "VAE 루트",
|
||||
"defaultVaeRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 VAE 루트 디렉토리를 설정합니다",
|
||||
"defaultUpscalerRoot": "Upscaler 루트",
|
||||
"defaultUpscalerRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Upscaler 루트 디렉토리를 설정합니다",
|
||||
"defaultTextEncoderRoot": "Text Encoder 루트",
|
||||
"defaultTextEncoderRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Text Encoder 루트 디렉토리를 설정합니다",
|
||||
"defaultClipVisionRoot": "CLIP Vision 루트",
|
||||
"defaultClipVisionRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 CLIP Vision 루트 디렉토리를 설정합니다",
|
||||
"defaultControlnetRoot": "ControlNet 루트",
|
||||
"defaultControlnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 ControlNet 루트 디렉토리를 설정합니다",
|
||||
"enableOtherModels": "기타 모델 관리",
|
||||
"enableOtherModelsHelp": "끄면 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 폴더를 스캔하지 않으며, 기타 모델 페이지가 비활성화된 상태로 유지되고 이러한 모델 유형은 다운로드할 수 없습니다.",
|
||||
"otherSubTypes": "관리할 모델 유형",
|
||||
"otherSubTypesHelp": "기타 모델 페이지에서 스캔하고 표시할 카테고리를 선택합니다.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "레시피 저장 경로",
|
||||
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "자동 정리 완료",
|
||||
"error": "오류: {error}"
|
||||
},
|
||||
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||
"enrichHfAgent": "AI로 메타데이터 보강"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "CivitAI 데이터 새로고침",
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"linkModel": "모델 연결",
|
||||
"linkCivitai": "CivitAI에 연결",
|
||||
"linkHuggingFace": "HuggingFace에 연결",
|
||||
"linkModelSource": "모델 소스에 연결",
|
||||
"copySyntax": "LoRA 문법 복사",
|
||||
"copyFilename": "모델 파일명 복사",
|
||||
"copyRecipeSyntax": "레시피 문법 복사",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "모든 LoRA 보기",
|
||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||
"deleteRecipe": "레시피 삭제",
|
||||
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||
"enrichHfAgent": "AI로 메타데이터 보강"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "베이스 모델",
|
||||
"unknown": "알 수 없음"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding 모델"
|
||||
},
|
||||
"other": {
|
||||
"title": "기타 모델",
|
||||
"disabled": {
|
||||
"title": "기타 모델 관리가 꺼져 있습니다",
|
||||
"description": "활성화하면 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔하고 관리하며 CivitAI에서 다운로드할 수 있습니다.",
|
||||
"enableButton": "기타 모델 활성화",
|
||||
"hint": "관리할 모델 유형은 나중에 설정 > 라이브러리에서 변경할 수 있습니다.",
|
||||
"enableFailed": "기타 모델 활성화 실패",
|
||||
"downloadBlocked": "이 모델 유형에 대해서는 기타 모델 관리가 비활성화되어 있습니다. 이 파일을 다운로드하려면 설정 > 라이브러리에서 활성화하세요.",
|
||||
"enableAction": "기타 모델 활성화"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "기타 모델 폴더를 찾을 수 없습니다",
|
||||
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 기타 모델 폴더를 찾을 수 없습니다. 필요한 폴더 키를 settings.json의 folder_paths 섹션에 추가한 뒤 LoRA Manager를 재시작하세요.",
|
||||
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
|
||||
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
|
||||
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
|
||||
"openSettings": "설정 열기",
|
||||
"openSettingsFolder": "설정 폴더 열기"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "루트",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
||||
"showSidebar": "사이드바 표시",
|
||||
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"viewOptions": "보기 옵션",
|
||||
"treeView": "트리 보기",
|
||||
"listView": "목록 보기",
|
||||
"recursiveOn": "하위 폴더 포함",
|
||||
"recursiveOff": "현재 폴더만",
|
||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"createFolder": "새 폴더",
|
||||
"newSubfolder": "새 하위 폴더",
|
||||
"showEmptyFolders": "빈 폴더 표시",
|
||||
"createFolderResult": {
|
||||
"success": "\"{name}\" 폴더를 생성했습니다",
|
||||
"failed": "폴더 생성 실패: {message}",
|
||||
"unsupported": "이 페이지에서는 폴더를 만들 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
},
|
||||
"deleteFolder": "폴더 삭제",
|
||||
"deleteFolderModal": {
|
||||
"title": "폴더를 삭제할까요?",
|
||||
"message": "폴더와 그 안의 모든 내용이 디스크에서 영구적으로 삭제됩니다.",
|
||||
"folderLabel": "폴더",
|
||||
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
|
||||
"notEmptyTitle": "폴더가 비어 있지 않습니다",
|
||||
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"confirm": "폴더 삭제"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "\"{name}\" 폴더를 삭제했습니다",
|
||||
"successWithFiles": "\"{name}\" 폴더와 {count}개 항목을 함께 삭제했습니다",
|
||||
"restored": "폴더를 복원했습니다",
|
||||
"failed": "폴더 삭제 실패: {message}",
|
||||
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
},
|
||||
"renameFolder": "폴더 이름 바꾸기",
|
||||
"renameFolderResult": {
|
||||
"success": "폴더 이름을 \"{name}\"(으)로 변경했습니다",
|
||||
"failed": "폴더 이름 바꾸기 실패: {message}",
|
||||
"targetExists": "같은 이름의 폴더가 이미 있습니다",
|
||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||
"unsupported": "이 페이지에서는 폴더 이름을 바꿀 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
|
||||
"createFolderHint": "놓아서 새 폴더 만들기",
|
||||
"newFolderName": "새 폴더 이름",
|
||||
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
|
||||
"emptyFolderName": "폴더 이름을 입력하세요",
|
||||
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
|
||||
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
||||
"createHint": "위의 새 폴더 버튼을 클릭하여 폴더를 만들 수 있습니다"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "이 폴더의 업데이트 확인",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "URL에서 모델 다운로드",
|
||||
"titleWithType": "URL에서 {type} 다운로드",
|
||||
"civitaiUrl": "CivitAI URL:",
|
||||
"civitaiUrl": "모델 URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||
"urlHint": "한 줄에 하나의 CivitAI, CivArchive, Hugging Face 또는 ModelScope URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
|
||||
"selectAll": "모두 선택",
|
||||
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "라이브러리에 있음"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "잘못된 CivitAI URL 형식",
|
||||
"invalidUrl": "잘못된 모델 URL 형식",
|
||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
|
||||
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
|
||||
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face / ModelScope URL을 혼합할 수 없습니다.",
|
||||
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "현재 파일:",
|
||||
"downloading": "다운로드 중: {name}",
|
||||
"metadata": "메타데이터: {name}",
|
||||
"indexingFile": "모델 파일 읽는 중...",
|
||||
"fetchingSourceMetadata": "{source}에서 메타데이터 가져오는 중...",
|
||||
"fetchingMetadata": "메타데이터 가져오는 중...",
|
||||
"transferred": "다운로드됨: {downloaded} / {total}",
|
||||
"transferredSimple": "다운로드됨: {downloaded}",
|
||||
"transferredUnknown": "다운로드됨: --",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
||||
"downloadButton": "{count}개 LoRA 다운로드"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "레시피 재매칭",
|
||||
"messageGlobal": "모든 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||
"messageSingle": "이 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||
"messageBulk": "선택한 레시피 {count}개를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||
"relaxedLabel": "누락된 모델도 파일 이름으로 다시 연결",
|
||||
"relaxedDescription": "이 모델들은 다운로드로도 해결할 수 있으며 다운로드가 더 정확합니다. 매칭 시 모델의 다른 버전이 연결될 수 있으며, 검토용으로 목록에 표시되고 실행 취소할 수 있습니다.",
|
||||
"confirmButton": "재매칭"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "실행 취소",
|
||||
"undone": "실행 취소됨",
|
||||
"undoFailed": "재매칭 실행 취소 실패: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "재매칭 요약",
|
||||
"successMessage": "{entries}개 항목이 매칭되었습니다",
|
||||
"failed": "재매칭 실패",
|
||||
"completedWithWarnings": "재매칭이 완료되었습니다 — 검토가 권장됩니다",
|
||||
"cancelledNote": "완료 전에 실행이 취소되었습니다 — 집계는 부분적입니다.",
|
||||
"statMatched": "매칭된 항목",
|
||||
"statReview": "검토 필요",
|
||||
"statUnresolved": "매칭 없음",
|
||||
"statErrors": "오류",
|
||||
"reviewSection": "검토할 파일 이름 매칭 ({count})",
|
||||
"columnRecipe": "레시피",
|
||||
"columnEntry": "항목",
|
||||
"columnFile": "매칭된 파일",
|
||||
"columnUndo": "실행 취소",
|
||||
"copyReport": "보고서 복사",
|
||||
"close": "닫기",
|
||||
"scope_global": "모든 레시피",
|
||||
"scope_bulk": "선택한 레시피",
|
||||
"scope_single": "단일 레시피"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "로컬 예시 이미지",
|
||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"pathPlaceholder": "폴더 경로를 입력하거나 아래 트리에서 선택하세요...",
|
||||
"root": "루트"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "HuggingFace에 연결",
|
||||
"infoText": "HuggingFace 저장소 URL을 붙여넣어 모델을 연결합니다. AI 메타데이터 보강 기능을 사용할 수 있습니다.",
|
||||
"urlLabel": "HuggingFace 저장소 URL:",
|
||||
"linkModelSource": {
|
||||
"title": "모델 소스에 연결",
|
||||
"infoText": "모델 페이지 URL을 붙여넣어 이 모델을 소스에 연결합니다. 연결하면 Hugging Face 및 ModelScope 모델에 AI 메타데이터 보강을 사용할 수 있습니다.",
|
||||
"urlLabel": "모델 페이지 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "전체 HuggingFace 저장소 URL을 입력하세요.",
|
||||
"helpText": "전체 모델 페이지 URL을 입력하세요. 지원 사이트:",
|
||||
"enrichNote": "AI 보강에는 읽을 수 있는 모델 카드가 필요합니다. 모델 카드를 제공하지 않는 사이트(현재 TensorArt)는 연결만 가능합니다.",
|
||||
"urlRequired": "모델 페이지 URL을 입력하세요.",
|
||||
"invalidUrl": "지원되지 않는 URL입니다. 지원 사이트: Hugging Face, ModelScope, TensorArt.",
|
||||
"linking": "모델 소스를 연결하는 중...",
|
||||
"confirmAction": "저장 및 연결"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,7 @@
|
||||
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||
"error": "버전을 불러오지 못했습니다.",
|
||||
"missingModelId": "이 모델에는 CivitAI 모델 ID가 없습니다.",
|
||||
"hfGroupInfo": "HuggingFace 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
|
||||
"sourceGroupInfo": "{source} 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
|
||||
"confirm": {
|
||||
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "Embedding Manager 초기화 중",
|
||||
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
},
|
||||
"other": {
|
||||
"title": "기타 모델 관리자 초기화 중",
|
||||
"message": "모델 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "레시피 매니저 초기화 중",
|
||||
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||
"created": "레시피가 생성되었습니다",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
|
||||
"noPreviousRecipe": "이전 레시피가 없습니다",
|
||||
"noNextRecipe": "다음 레시피가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
||||
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
||||
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
||||
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
||||
"otherRootsFailed": "기타 모델 루트 로딩 실패: {message}",
|
||||
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
||||
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "모델이 CivitArchive을 통해 성공적으로 다시 연결되었습니다",
|
||||
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
||||
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
||||
"missingHash": "모델 해시를 사용할 수 없습니다"
|
||||
"missingHash": "모델 해시를 사용할 수 없습니다",
|
||||
"enrichNeedsSource": "먼저 이 모델을 모델 소스에 연결하세요 (모델 연결 → 모델 소스에 연결)",
|
||||
"enrichUnsupportedSource": "{source} 모델에서는 AI 보강을 사용할 수 없습니다"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "예시 이미지 경로가 성공적으로 업데이트되었습니다",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "캐시 재구축 중...",
|
||||
"rebuildFailed": "캐시 재구축 실패: {error}",
|
||||
"retry": "다시 시도"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "기타 모델 관리를 사용할 수 있습니다",
|
||||
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
||||
"enable": "기타 모델 활성화",
|
||||
"openSettings": "설정 열기"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"reorder": {
|
||||
"dragHandle": "Перетащите, чтобы изменить порядок"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "Посмотреть на CivitAI",
|
||||
"notAvailableFromCivitai": "Недоступно на CivitAI",
|
||||
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||
"viewOnSource": "Открыть {source}",
|
||||
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
|
||||
"copyLoRASyntax": "Копировать синтаксис LoRA",
|
||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "Копировать имя checkpoint",
|
||||
"copyEmbeddingName": "Копировать имя embedding",
|
||||
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
||||
"modelNameCopied": "Имя модели скопировано",
|
||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "Повторное сопоставление рецептов с локальными моделями",
|
||||
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
||||
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
|
||||
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
||||
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "Рецепты",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Другое",
|
||||
"statistics": "Статистика"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultVaeRoot": "Корневая папка VAE",
|
||||
"defaultVaeRootHelp": "Установить корневую папку VAE по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultUpscalerRoot": "Корневая папка Upscaler",
|
||||
"defaultUpscalerRootHelp": "Установить корневую папку Upscaler по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultTextEncoderRoot": "Корневая папка Text Encoder",
|
||||
"defaultTextEncoderRootHelp": "Установить корневую папку Text Encoder по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultClipVisionRoot": "Корневая папка CLIP Vision",
|
||||
"defaultClipVisionRootHelp": "Установить корневую папку CLIP Vision по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultControlnetRoot": "Корневая папка ControlNet",
|
||||
"defaultControlnetRootHelp": "Установить корневую папку ControlNet по умолчанию для загрузок, импорта и перемещений",
|
||||
"enableOtherModels": "Управление другими моделями",
|
||||
"enableOtherModelsHelp": "Если выключено, папки VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet не сканируются, страница «Другие модели» остаётся отключённой, а эти типы моделей нельзя загрузить.",
|
||||
"otherSubTypes": "Управляемые типы моделей",
|
||||
"otherSubTypesHelp": "Выберите, какие категории других моделей сканируются и отображаются на странице «Другие модели».",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Путь хранения рецептов",
|
||||
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "Автоматическая организация завершена",
|
||||
"error": "Ошибка: {error}"
|
||||
},
|
||||
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
|
||||
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Обновить данные CivitAI",
|
||||
"checkUpdates": "Проверить обновления",
|
||||
"linkModel": "Связать модель",
|
||||
"linkCivitai": "Пересвязать с CivitAI",
|
||||
"linkHuggingFace": "Связать с HuggingFace",
|
||||
"linkModelSource": "Связать с источником модели",
|
||||
"copySyntax": "Копировать синтаксис LoRA",
|
||||
"copyFilename": "Копировать имя файла модели",
|
||||
"copyRecipeSyntax": "Копировать синтаксис рецепта",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "Посмотреть все LoRAs",
|
||||
"downloadMissingLoras": "Загрузить отсутствующие LoRAs",
|
||||
"deleteRecipe": "Удалить рецепт",
|
||||
"enrichHfAgent": "Обогатить HF метаданные (ИИ)"
|
||||
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "Базовая модель",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Модели Embedding"
|
||||
},
|
||||
"other": {
|
||||
"title": "Другие модели",
|
||||
"disabled": {
|
||||
"title": "Управление другими моделями отключено",
|
||||
"description": "Включите, чтобы сканировать и управлять файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загружать их с CivitAI.",
|
||||
"enableButton": "Включить другие модели",
|
||||
"hint": "Вы сможете изменить управляемые типы моделей позже в разделе «Настройки > Библиотека».",
|
||||
"enableFailed": "Не удалось включить другие модели",
|
||||
"downloadBlocked": "Управление другими моделями отключено для этого типа моделей. Включите его в разделе «Настройки > Библиотека», чтобы загрузить этот файл.",
|
||||
"enableAction": "Включить другие модели"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Папки других моделей не найдены",
|
||||
"descriptionStandalone": "Управление другими моделями включено, но папки других моделей не найдены. Добавьте нужные ключи папок в раздел folder_paths файла settings.json и перезапустите LoRA Manager.",
|
||||
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
|
||||
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
|
||||
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
|
||||
"openSettings": "Открыть настройки",
|
||||
"openSettingsFolder": "Открыть папку настроек"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
||||
"showSidebar": "Показать боковую панель",
|
||||
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"viewOptions": "Параметры отображения",
|
||||
"treeView": "Дерево",
|
||||
"listView": "Список",
|
||||
"recursiveOn": "Включать вложенные папки",
|
||||
"recursiveOff": "Только текущая папка",
|
||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"createFolder": "Новая папка",
|
||||
"newSubfolder": "Новая вложенная папка",
|
||||
"showEmptyFolders": "Показывать пустые папки",
|
||||
"createFolderResult": {
|
||||
"success": "Папка \"{name}\" создана",
|
||||
"failed": "Не удалось создать папку: {message}",
|
||||
"unsupported": "Создание папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
},
|
||||
"deleteFolder": "Удалить папку",
|
||||
"deleteFolderModal": {
|
||||
"title": "Удалить папку?",
|
||||
"message": "Папка и всё её содержимое будут безвозвратно удалены с диска.",
|
||||
"folderLabel": "Папка",
|
||||
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
|
||||
"notEmptyTitle": "Папка не пуста",
|
||||
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"confirm": "Удалить папку"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Папка \"{name}\" удалена",
|
||||
"successWithFiles": "Папка \"{name}\" удалена вместе с ещё {count} элемент(ами)",
|
||||
"restored": "Папка восстановлена",
|
||||
"failed": "Не удалось удалить папку: {message}",
|
||||
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
|
||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||
"unsupported": "Удаление папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
},
|
||||
"renameFolder": "Переименовать папку",
|
||||
"renameFolderResult": {
|
||||
"success": "Папка переименована в \"{name}\"",
|
||||
"failed": "Не удалось переименовать папку: {message}",
|
||||
"targetExists": "Папка с таким именем уже существует здесь",
|
||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||
"unsupported": "Переименование папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||
"moveUnsupported": "Перемещение этого элемента не поддерживается.",
|
||||
"createFolderHint": "Отпустите, чтобы создать новую папку",
|
||||
"newFolderName": "Имя новой папки",
|
||||
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
|
||||
"emptyFolderName": "Пожалуйста, введите имя папки",
|
||||
"invalidFolderName": "Имя папки содержит недопустимые символы",
|
||||
"noDragState": "Ожидающая операция перетаскивания не найдена"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "Папки не найдены",
|
||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
||||
"createHint": "Нажмите кнопку «Новая папка» вверху, чтобы создать папки"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "Проверить обновления в этой папке",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "Скачать модель по URL",
|
||||
"titleWithType": "Скачать {type} по URL",
|
||||
"civitaiUrl": "CivitAI URL:",
|
||||
"civitaiUrl": "URL модели:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
|
||||
"urlHint": "Введите один URL CivitAI, CivArchive, Hugging Face или ModelScope в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
|
||||
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
|
||||
"selectAll": "Выбрать все",
|
||||
"fetchingRepoFiles": "Получение файлов репозитория...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "В библиотеке"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Неверный формат URL CivitAI",
|
||||
"invalidUrl": "Неверный формат URL модели",
|
||||
"noVersions": "Нет доступных версий для этой модели",
|
||||
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
|
||||
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face / ModelScope в одном пакете.",
|
||||
"noModelFiles": "В этом репозитории не найдено файлов моделей."
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "Текущий файл:",
|
||||
"downloading": "Скачивается: {name}",
|
||||
"metadata": "Метаданные: {name}",
|
||||
"indexingFile": "Чтение файла модели...",
|
||||
"fetchingSourceMetadata": "Получение метаданных из {source}...",
|
||||
"fetchingMetadata": "Получение метаданных...",
|
||||
"transferred": "Скачано: {downloaded} / {total}",
|
||||
"transferredSimple": "Скачано: {downloaded}",
|
||||
"transferredUnknown": "Скачано: --",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
||||
"downloadButton": "Скачать {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Повторное сопоставление рецептов",
|
||||
"messageGlobal": "Все рецепты будут проверены по вашей локальной библиотеке моделей.",
|
||||
"messageSingle": "Этот рецепт будет проверен по вашей локальной библиотеке моделей.",
|
||||
"messageBulk": "Выбранные рецепты ({count}) будут проверены по вашей локальной библиотеке моделей.",
|
||||
"relaxedLabel": "Также переподключать отсутствующие модели по имени файла",
|
||||
"relaxedDescription": "Эти модели также можно исправить загрузкой — загрузка точнее. Совпадения могут привязать другую версию; они будут перечислены для проверки, и их можно будет отменить.",
|
||||
"confirmButton": "Сопоставить"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Отменить",
|
||||
"undone": "Отменено",
|
||||
"undoFailed": "Не удалось отменить сопоставление: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Сводка повторного сопоставления",
|
||||
"successMessage": "Сопоставлено записей: {entries}",
|
||||
"failed": "Не удалось выполнить сопоставление",
|
||||
"completedWithWarnings": "Сопоставление завершено — рекомендуется проверка",
|
||||
"cancelledNote": "Запуск отменён до завершения — подсчёты неполные.",
|
||||
"statMatched": "Сопоставленные записи",
|
||||
"statReview": "Требуют проверки",
|
||||
"statUnresolved": "Не сопоставлено",
|
||||
"statErrors": "Ошибки",
|
||||
"reviewSection": "Совпадения по имени файла для проверки ({count})",
|
||||
"columnRecipe": "Рецепт",
|
||||
"columnEntry": "Запись",
|
||||
"columnFile": "Совпавший файл",
|
||||
"columnUndo": "Отменить",
|
||||
"copyReport": "Скопировать отчёт",
|
||||
"close": "Закрыть",
|
||||
"scope_global": "Все рецепты",
|
||||
"scope_bulk": "Выбранные рецепты",
|
||||
"scope_single": "Один рецепт"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Локальные примеры изображений",
|
||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"pathPlaceholder": "Введите путь к папке или выберите из дерева ниже...",
|
||||
"root": "Корень"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "Связать с HuggingFace",
|
||||
"infoText": "Вставьте URL репозитория HuggingFace, чтобы связать эту модель с её источником. Это позволит обогащать метаданные с помощью ИИ.",
|
||||
"urlLabel": "URL репозитория HuggingFace:",
|
||||
"linkModelSource": {
|
||||
"title": "Связать с источником модели",
|
||||
"infoText": "Вставьте URL страницы модели, чтобы связать эту модель с её источником. Связывание включает обогащение метаданных с помощью ИИ для моделей Hugging Face и ModelScope.",
|
||||
"urlLabel": "URL страницы модели:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "Введите полный URL репозитория HuggingFace.",
|
||||
"helpText": "Введите полный URL страницы модели. Поддерживаемые сайты:",
|
||||
"enrichNote": "Для обогащения с помощью ИИ нужна читаемая карточка модели. Сайты, которые её не предоставляют (сейчас TensorArt), можно только связать.",
|
||||
"urlRequired": "Введите URL страницы модели.",
|
||||
"invalidUrl": "Неподдерживаемый URL. Поддерживаемые сайты: Hugging Face, ModelScope, TensorArt.",
|
||||
"linking": "Связывание с источником модели...",
|
||||
"confirmAction": "Сохранить и связать"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,7 @@
|
||||
"empty": "Для этой модели пока нет истории версий.",
|
||||
"error": "Не удалось загрузить версии.",
|
||||
"missingModelId": "У этой модели отсутствует идентификатор модели CivitAI.",
|
||||
"hfGroupInfo": "Это группа моделей HuggingFace. Откройте библиотеку, чтобы увидеть все версии в сетке.",
|
||||
"sourceGroupInfo": "Это группа моделей {source}. Откройте библиотеку, чтобы увидеть все версии в сетке.",
|
||||
"confirm": {
|
||||
"delete": "Удалить эту версию из библиотеки?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "Инициализация Embedding Manager",
|
||||
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Инициализация менеджера других моделей",
|
||||
"message": "Сканирование и построение кэша моделей. Это может занять несколько минут..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Инициализация менеджера рецептов",
|
||||
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||
"created": "Рецепт успешно создан",
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
|
||||
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
||||
"noNextRecipe": "Следующий рецепт отсутствует",
|
||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
||||
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
||||
"noRecipesSelected": "Рецепты не выбраны",
|
||||
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
||||
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||
"reimporting": "Переимпорт рецепта из источника...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
||||
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
||||
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
||||
"otherRootsFailed": "Не удалось загрузить корни других моделей: {message}",
|
||||
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
|
||||
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "Модель успешно пересвязана через CivitArchive",
|
||||
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
|
||||
"noCivitaiInfo": "Информация CivitAI недоступна",
|
||||
"missingHash": "Хеш модели недоступен"
|
||||
"missingHash": "Хеш модели недоступен",
|
||||
"enrichNeedsSource": "Сначала свяжите эту модель с источником модели (Связать модель → Связать с источником модели)",
|
||||
"enrichUnsupportedSource": "Обогащение с помощью ИИ недоступно для моделей {source}"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "Путь к примерам изображений успешно обновлен",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "Перестроение кэша...",
|
||||
"rebuildFailed": "Не удалось перестроить кэш: {error}",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "Управление другими моделями доступно",
|
||||
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
||||
"enable": "Включить другие модели",
|
||||
"openSettings": "Открыть настройки"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"reorder": {
|
||||
"dragHandle": "拖拽以调整顺序"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "在 CivitAI 查看",
|
||||
"notAvailableFromCivitai": "CivitAI 上不可用",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"viewOnSource": "在 {source} 查看",
|
||||
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
|
||||
"copyLoRASyntax": "复制 LoRA 语法",
|
||||
"checkpointNameCopied": "Checkpoint 名称已复制",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||
"copyEmbeddingName": "复制 Embedding 名称",
|
||||
"embeddingNameCopied": "已复制 Embedding 语法",
|
||||
"modelNameCopied": "模型名称已复制",
|
||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "将配方重新匹配到本地模型",
|
||||
"loading": "正在将配方重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||
"allFailed": "{failures}/{total} 个配方重新匹配失败",
|
||||
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
||||
"error": "配方重新匹配失败:{message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "配方",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"other": "其他",
|
||||
"statistics": "统计"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||
"defaultEmbeddingRoot": "Embedding 根目录",
|
||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||
"defaultVaeRoot": "VAE 根目录",
|
||||
"defaultVaeRootHelp": "设置下载、导入和移动时的默认 VAE 根目录",
|
||||
"defaultUpscalerRoot": "Upscaler 根目录",
|
||||
"defaultUpscalerRootHelp": "设置下载、导入和移动时的默认 Upscaler 根目录",
|
||||
"defaultTextEncoderRoot": "Text Encoder 根目录",
|
||||
"defaultTextEncoderRootHelp": "设置下载、导入和移动时的默认 Text Encoder 根目录",
|
||||
"defaultClipVisionRoot": "CLIP Vision 根目录",
|
||||
"defaultClipVisionRootHelp": "设置下载、导入和移动时的默认 CLIP Vision 根目录",
|
||||
"defaultControlnetRoot": "ControlNet 根目录",
|
||||
"defaultControlnetRootHelp": "设置下载、导入和移动时的默认 ControlNet 根目录",
|
||||
"enableOtherModels": "其他模型管理",
|
||||
"enableOtherModelsHelp": "关闭后,不会扫描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 文件夹,其他模型页面保持禁用,且无法下载这些模型类型。",
|
||||
"otherSubTypes": "管理的模型类型",
|
||||
"otherSubTypesHelp": "选择要在其他模型页面中扫描和显示的其他模型类别。",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "配方存储路径",
|
||||
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "自动整理已完成",
|
||||
"error": "错误:{error}"
|
||||
},
|
||||
"enrichHfAgent": "AI HF 元数据增强"
|
||||
"enrichHfAgent": "AI 元数据增强"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 CivitAI 数据",
|
||||
"checkUpdates": "检查更新",
|
||||
"linkModel": "链接模型",
|
||||
"linkCivitai": "链接到 CivitAI",
|
||||
"linkHuggingFace": "链接到 HuggingFace",
|
||||
"linkModelSource": "链接到模型来源",
|
||||
"copySyntax": "复制 LoRA 语法",
|
||||
"copyFilename": "复制模型文件名",
|
||||
"copyRecipeSyntax": "复制配方语法",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "查看所有 LoRA",
|
||||
"downloadMissingLoras": "下载缺失的 LoRA",
|
||||
"deleteRecipe": "删除配方",
|
||||
"enrichHfAgent": "AI HF 元数据增强"
|
||||
"enrichHfAgent": "AI 元数据增强"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "基础模型",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "打开文件位置",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding 模型"
|
||||
},
|
||||
"other": {
|
||||
"title": "其他模型",
|
||||
"disabled": {
|
||||
"title": "其他模型管理已关闭",
|
||||
"description": "启用后可扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||
"enableButton": "启用其他模型",
|
||||
"hint": "你可以稍后在“设置 > 库”中更改管理的模型类型。",
|
||||
"enableFailed": "启用其他模型失败",
|
||||
"downloadBlocked": "其他模型管理已对此模型类型禁用。请在“设置 > 库”中启用以下载此文件。",
|
||||
"enableAction": "启用其他模型"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "未找到其他模型文件夹",
|
||||
"descriptionStandalone": "其他模型管理已开启,但未找到其他模型文件夹。请将你需要的文件夹键添加到 settings.json 的 folder_paths 部分,然后重启 LoRA Manager。",
|
||||
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
|
||||
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
|
||||
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
|
||||
"openSettings": "打开设置",
|
||||
"openSettingsFolder": "打开设置文件夹"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "根目录",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"hideOnThisPage": "隐藏此页面侧边栏",
|
||||
"showSidebar": "显示侧边栏",
|
||||
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"viewOptions": "视图选项",
|
||||
"treeView": "树形视图",
|
||||
"listView": "列表视图",
|
||||
"recursiveOn": "包含子文件夹",
|
||||
"recursiveOff": "仅当前文件夹",
|
||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"createFolder": "新建文件夹",
|
||||
"newSubfolder": "新建子文件夹",
|
||||
"showEmptyFolders": "显示空文件夹",
|
||||
"createFolderResult": {
|
||||
"success": "已创建文件夹 \"{name}\"",
|
||||
"failed": "创建文件夹失败: {message}",
|
||||
"unsupported": "此页面不支持创建文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
},
|
||||
"deleteFolder": "删除文件夹",
|
||||
"deleteFolderModal": {
|
||||
"title": "删除文件夹?",
|
||||
"message": "该文件夹及其中所有内容都将从磁盘上永久删除。",
|
||||
"folderLabel": "文件夹",
|
||||
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
|
||||
"notEmptyTitle": "文件夹不为空",
|
||||
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"confirm": "删除文件夹"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "已删除文件夹 \"{name}\"",
|
||||
"successWithFiles": "已删除文件夹 \"{name}\",同时删除了另外 {count} 项内容",
|
||||
"restored": "文件夹已恢复",
|
||||
"failed": "删除文件夹失败: {message}",
|
||||
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
|
||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||
"unsupported": "此页面不支持删除文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
},
|
||||
"renameFolder": "重命名文件夹",
|
||||
"renameFolderResult": {
|
||||
"success": "文件夹已重命名为 \"{name}\"",
|
||||
"failed": "重命名文件夹失败: {message}",
|
||||
"targetExists": "此处已存在同名文件夹",
|
||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||
"unsupported": "此页面不支持重命名文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||
"moveUnsupported": "此条目不支持移动。",
|
||||
"createFolderHint": "释放以创建新文件夹",
|
||||
"newFolderName": "新文件夹名称",
|
||||
"folderNameHint": "按 Enter 确认,Escape 取消",
|
||||
"emptyFolderName": "请输入文件夹名称",
|
||||
"invalidFolderName": "文件夹名称包含无效字符",
|
||||
"noDragState": "未找到待处理的拖放操作"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "未找到文件夹",
|
||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||
"createHint": "点击上方的新建文件夹按钮即可创建文件夹"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "检查此文件夹的更新",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "从 URL 下载模型",
|
||||
"titleWithType": "从 URL 下载 {type}",
|
||||
"civitaiUrl": "CivitAI URL:",
|
||||
"civitaiUrl": "模型 URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行输入一个 CivitAI、CivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
|
||||
"urlHint": "每行输入一个 CivitAI、CivArchive、Hugging Face 或 ModelScope URL。支持批量下载多个 URL。",
|
||||
"selectHfFiles": "选择从此仓库下载的文件:",
|
||||
"selectAll": "全选",
|
||||
"fetchingRepoFiles": "正在获取仓库文件...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "已在库中"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "无效的 CivitAI URL 格式",
|
||||
"invalidUrl": "无效的模型 URL 格式",
|
||||
"noVersions": "此模型没有可用版本",
|
||||
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face / ModelScope URL。",
|
||||
"noModelFiles": "在此仓库中未找到模型文件。"
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "当前文件:",
|
||||
"downloading": "下载中:{name}",
|
||||
"metadata": "元数据:{name}",
|
||||
"indexingFile": "正在读取模型文件...",
|
||||
"fetchingSourceMetadata": "正在从 {source} 获取元数据...",
|
||||
"fetchingMetadata": "正在获取元数据...",
|
||||
"transferred": "已下载:{downloaded} / {total}",
|
||||
"transferredSimple": "已下载:{downloaded}",
|
||||
"transferredUnknown": "已下载:--",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
||||
"downloadButton": "下载 {count} 个 LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "重新匹配配方",
|
||||
"messageGlobal": "将对照你的本地模型库扫描所有配方。",
|
||||
"messageSingle": "将对照你的本地模型库扫描此配方。",
|
||||
"messageBulk": "将对照你的本地模型库扫描 {count} 个所选配方。",
|
||||
"relaxedLabel": "同时按文件名重新关联缺失的模型",
|
||||
"relaxedDescription": "这些模型也可以通过下载来修复——下载更为准确。匹配结果可能链接到模型的其他版本;它们会被列出供检查,且可以撤销。",
|
||||
"confirmButton": "重新匹配"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "撤销",
|
||||
"undone": "已撤销",
|
||||
"undoFailed": "撤销重新匹配失败:{message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "重新匹配摘要",
|
||||
"successMessage": "已匹配 {entries} 个条目",
|
||||
"failed": "重新匹配失败",
|
||||
"completedWithWarnings": "重新匹配已完成——建议检查",
|
||||
"cancelledNote": "运行在完成前已取消——统计不完整。",
|
||||
"statMatched": "已匹配条目",
|
||||
"statReview": "需要检查",
|
||||
"statUnresolved": "未匹配",
|
||||
"statErrors": "错误",
|
||||
"reviewSection": "需要检查的文件名匹配({count})",
|
||||
"columnRecipe": "配方",
|
||||
"columnEntry": "条目",
|
||||
"columnFile": "匹配到的文件",
|
||||
"columnUndo": "撤销",
|
||||
"copyReport": "复制报告",
|
||||
"close": "关闭",
|
||||
"scope_global": "所有配方",
|
||||
"scope_bulk": "所选配方",
|
||||
"scope_single": "单个配方"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "本地示例图片",
|
||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"pathPlaceholder": "输入文件夹路径或从下方树中选择...",
|
||||
"root": "根目录"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "链接到 HuggingFace",
|
||||
"infoText": "粘贴 HuggingFace 仓库 URL 以关联此模型。关联后可启用 AI 元数据增强功能。",
|
||||
"urlLabel": "HuggingFace 仓库 URL:",
|
||||
"linkModelSource": {
|
||||
"title": "链接到模型来源",
|
||||
"infoText": "粘贴模型页面 URL 以关联此模型与其来源。关联后可对 Hugging Face 和 ModelScope 模型启用 AI 元数据增强。",
|
||||
"urlLabel": "模型页面 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "请输入完整的 HuggingFace 仓库 URL。",
|
||||
"helpText": "请输入完整的模型页面 URL。支持的站点:",
|
||||
"enrichNote": "AI 增强需要可读取的模型卡。未提供模型卡的站点(目前为 TensorArt)只能建立链接。",
|
||||
"urlRequired": "请输入模型页面 URL。",
|
||||
"invalidUrl": "URL 不受支持。支持的站点:Hugging Face、ModelScope、TensorArt。",
|
||||
"linking": "正在链接模型来源...",
|
||||
"confirmAction": "保存并链接"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,7 @@
|
||||
"empty": "该模型还没有版本历史。",
|
||||
"error": "加载版本失败。",
|
||||
"missingModelId": "该模型缺少 CivitAI 模型 ID。",
|
||||
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
|
||||
"sourceGroupInfo": "这是一个 {source} 模型组。打开库页面即可在网格中查看所有版本。",
|
||||
"confirm": {
|
||||
"delete": "从库中删除此版本?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "初始化 Embedding 管理器",
|
||||
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
|
||||
},
|
||||
"other": {
|
||||
"title": "正在初始化其他模型管理器",
|
||||
"message": "正在扫描并构建模型缓存。这可能需要几分钟..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "初始化配方管理器",
|
||||
"message": "正在加载和处理配方。这可能需要几分钟..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "缺少创建配方所需的数据",
|
||||
"created": "配方创建成功",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
|
||||
"noPreviousRecipe": "没有上一个配方",
|
||||
"noNextRecipe": "没有下一个配方",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||
"noRecipesSelected": "未选择任何配方",
|
||||
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
|
||||
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
||||
"rematchFailed": "重新匹配所选配方失败:{message}",
|
||||
"reimporting": "正在从源重新导入配方...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
||||
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
||||
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
||||
"otherRootsFailed": "加载其他模型根目录失败:{message}",
|
||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
||||
"mappingsCleared": "基础模型路径映射已清除",
|
||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联",
|
||||
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
||||
"noCivitaiInfo": "无 CivitAI 信息",
|
||||
"missingHash": "模型哈希不可用"
|
||||
"missingHash": "模型哈希不可用",
|
||||
"enrichNeedsSource": "请先将此模型链接到模型来源(链接模型 → 链接到模型来源)",
|
||||
"enrichUnsupportedSource": "{source} 模型不支持 AI 增强"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "示例图片路径更新成功",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "正在重建缓存...",
|
||||
"rebuildFailed": "重建缓存失败:{error}",
|
||||
"retry": "重试"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "其他模型管理现已可用",
|
||||
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||
"enable": "启用其他模型",
|
||||
"openSettings": "打开设置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+163
-30
@@ -2,6 +2,9 @@
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
"confirm": "確認",
|
||||
"reorder": {
|
||||
"dragHandle": "拖曳以調整順序"
|
||||
},
|
||||
"actions": {
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
@@ -139,6 +142,7 @@
|
||||
"viewOnCivitai": "在 CivitAI 查看",
|
||||
"notAvailableFromCivitai": "CivitAI 不提供",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"viewOnSource": "在 {source} 查看",
|
||||
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
|
||||
"copyLoRASyntax": "複製 LoRA 語法",
|
||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||
@@ -149,6 +153,7 @@
|
||||
"copyCheckpointName": "複製 Checkpoint 名稱",
|
||||
"copyEmbeddingName": "複製嵌入名稱",
|
||||
"embeddingNameCopied": "已複製 Embedding 語法",
|
||||
"modelNameCopied": "模型名稱已複製",
|
||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||
},
|
||||
@@ -216,9 +221,6 @@
|
||||
"label": "將配方重新匹配到本地模型",
|
||||
"loading": "正在將配方重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
|
||||
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
||||
"error": "配方重新匹配失敗:{message}"
|
||||
},
|
||||
@@ -236,6 +238,7 @@
|
||||
"recipes": "配方",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"other": "其他",
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
@@ -536,6 +539,25 @@
|
||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||
"defaultEmbeddingRoot": "Embedding 根目錄",
|
||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||
"defaultVaeRoot": "VAE 根目錄",
|
||||
"defaultVaeRootHelp": "設定下載、匯入和移動時的預設 VAE 根目錄",
|
||||
"defaultUpscalerRoot": "Upscaler 根目錄",
|
||||
"defaultUpscalerRootHelp": "設定下載、匯入和移動時的預設 Upscaler 根目錄",
|
||||
"defaultTextEncoderRoot": "Text Encoder 根目錄",
|
||||
"defaultTextEncoderRootHelp": "設定下載、匯入和移動時的預設 Text Encoder 根目錄",
|
||||
"defaultClipVisionRoot": "CLIP Vision 根目錄",
|
||||
"defaultClipVisionRootHelp": "設定下載、匯入和移動時的預設 CLIP Vision 根目錄",
|
||||
"defaultControlnetRoot": "ControlNet 根目錄",
|
||||
"defaultControlnetRootHelp": "設定下載、匯入和移動時的預設 ControlNet 根目錄",
|
||||
"enableOtherModels": "其他模型管理",
|
||||
"enableOtherModelsHelp": "關閉後,不會掃描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 資料夾,其他模型頁面會保持停用,且無法下載這些模型類型。",
|
||||
"otherSubTypes": "管理的模型類型",
|
||||
"otherSubTypesHelp": "選擇要在其他模型頁面中掃描和顯示的其他模型類別。",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "配方儲存路徑",
|
||||
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -849,14 +871,14 @@
|
||||
"complete": "自動整理完成",
|
||||
"error": "錯誤:{error}"
|
||||
},
|
||||
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||
"enrichHfAgent": "AI 中繼資料增強"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 CivitAI 資料",
|
||||
"checkUpdates": "檢查更新",
|
||||
"linkModel": "連結模型",
|
||||
"linkCivitai": "連結到 CivitAI",
|
||||
"linkHuggingFace": "連結到 HuggingFace",
|
||||
"linkModelSource": "連結到模型來源",
|
||||
"copySyntax": "複製 LoRA 語法",
|
||||
"copyFilename": "複製模型檔名",
|
||||
"copyRecipeSyntax": "複製配方語法",
|
||||
@@ -878,7 +900,7 @@
|
||||
"viewAllLoras": "檢視全部 LoRA",
|
||||
"downloadMissingLoras": "下載缺少的 LoRA",
|
||||
"deleteRecipe": "刪除配方",
|
||||
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||
"enrichHfAgent": "AI 中繼資料增強"
|
||||
}
|
||||
},
|
||||
"recipes": {
|
||||
@@ -896,7 +918,9 @@
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"baseModel": "基礎模型",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
@@ -1204,31 +1228,87 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding 模型"
|
||||
},
|
||||
"other": {
|
||||
"title": "其他模型",
|
||||
"disabled": {
|
||||
"title": "其他模型管理已關閉",
|
||||
"description": "啟用後可掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||
"enableButton": "啟用其他模型",
|
||||
"hint": "您稍後可以在「設定 > 模型庫」中變更管理的模型類型。",
|
||||
"enableFailed": "啟用其他模型失敗",
|
||||
"downloadBlocked": "其他模型管理已對此模型類型停用。請在「設定 > 模型庫」中啟用以下載此檔案。",
|
||||
"enableAction": "啟用其他模型"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "找不到其他模型資料夾",
|
||||
"descriptionStandalone": "其他模型管理已開啟,但找不到其他模型資料夾。請將您需要的資料夾鍵加入 settings.json 的 folder_paths 區段,然後重新啟動 LoRA Manager。",
|
||||
"hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
|
||||
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
|
||||
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
|
||||
"openSettings": "開啟設定",
|
||||
"openSettingsFolder": "開啟設定資料夾"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "根目錄",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"collapseAllDisabled": "清單檢視下無法使用",
|
||||
"hideOnThisPage": "隱藏此頁面側邊欄",
|
||||
"showSidebar": "顯示側邊欄",
|
||||
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"viewOptions": "檢視選項",
|
||||
"treeView": "樹狀檢視",
|
||||
"listView": "清單檢視",
|
||||
"recursiveOn": "包含子資料夾",
|
||||
"recursiveOff": "僅目前資料夾",
|
||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"createFolder": "新增資料夾",
|
||||
"newSubfolder": "新增子資料夾",
|
||||
"showEmptyFolders": "顯示空資料夾",
|
||||
"createFolderResult": {
|
||||
"success": "已建立資料夾 \"{name}\"",
|
||||
"failed": "建立資料夾失敗: {message}",
|
||||
"unsupported": "此頁面不支援建立資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
},
|
||||
"deleteFolder": "刪除資料夾",
|
||||
"deleteFolderModal": {
|
||||
"title": "刪除資料夾?",
|
||||
"message": "該資料夾及其中的所有內容都將從磁碟上永久刪除。",
|
||||
"folderLabel": "資料夾",
|
||||
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
|
||||
"notEmptyTitle": "資料夾不是空的",
|
||||
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"confirm": "刪除資料夾"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "已刪除資料夾 \"{name}\"",
|
||||
"successWithFiles": "已刪除資料夾 \"{name}\",同時刪除了另外 {count} 項內容",
|
||||
"restored": "資料夾已還原",
|
||||
"failed": "刪除資料夾失敗: {message}",
|
||||
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
|
||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||
"unsupported": "此頁面不支援刪除資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
},
|
||||
"renameFolder": "重新命名資料夾",
|
||||
"renameFolderResult": {
|
||||
"success": "資料夾已重新命名為 \"{name}\"",
|
||||
"failed": "重新命名資料夾失敗: {message}",
|
||||
"targetExists": "此處已存在同名資料夾",
|
||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||
"unsupported": "此頁面不支援重新命名資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
},
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||
"moveUnsupported": "此項目不支援移動。",
|
||||
"createFolderHint": "放開以建立新資料夾",
|
||||
"newFolderName": "新資料夾名稱",
|
||||
"folderNameHint": "按 Enter 確認,Escape 取消",
|
||||
"emptyFolderName": "請輸入資料夾名稱",
|
||||
"invalidFolderName": "資料夾名稱包含無效字元",
|
||||
"noDragState": "未找到待處理的拖放操作"
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "未找到資料夾",
|
||||
"dragHint": "將項目拖到此處以建立資料夾"
|
||||
"createHint": "點擊上方的新增資料夾按鈕即可建立資料夾"
|
||||
},
|
||||
"folderUpdateCheck": {
|
||||
"label": "檢查此資料夾的更新",
|
||||
@@ -1356,9 +1436,9 @@
|
||||
"download": {
|
||||
"title": "從網址下載模型",
|
||||
"titleWithType": "從網址下載 {type}",
|
||||
"civitaiUrl": "CivitAI 網址:",
|
||||
"civitaiUrl": "模型網址:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行輸入一個 CivitAI、CivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
|
||||
"urlHint": "每行輸入一個 CivitAI、CivArchive、Hugging Face 或 ModelScope URL。支援批量下載多個 URL。",
|
||||
"selectHfFiles": "選擇從此倉庫下載的檔案:",
|
||||
"selectAll": "全選",
|
||||
"fetchingRepoFiles": "正在獲取倉庫檔案...",
|
||||
@@ -1391,9 +1471,9 @@
|
||||
"inLibrary": "已在庫中"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "CivitAI 網址格式無效",
|
||||
"invalidUrl": "模型網址格式無效",
|
||||
"noVersions": "此模型無可用版本",
|
||||
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face / ModelScope URL。",
|
||||
"noModelFiles": "在此倉庫中未找到模型檔案。"
|
||||
},
|
||||
"status": {
|
||||
@@ -1407,6 +1487,10 @@
|
||||
"progress": {
|
||||
"currentFile": "目前檔案:",
|
||||
"downloading": "下載中:{name}",
|
||||
"metadata": "中繼資料:{name}",
|
||||
"indexingFile": "正在讀取模型檔案...",
|
||||
"fetchingSourceMetadata": "正在從 {source} 取得中繼資料...",
|
||||
"fetchingMetadata": "正在取得中繼資料...",
|
||||
"transferred": "已下載:{downloaded} / {total}",
|
||||
"transferredSimple": "已下載:{downloaded}",
|
||||
"transferredUnknown": "已下載:--",
|
||||
@@ -1501,6 +1585,41 @@
|
||||
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||
"downloadButton": "下載 {count} 個 LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "重新匹配配方",
|
||||
"messageGlobal": "所有配方將對照您的本地模型庫進行掃描。",
|
||||
"messageSingle": "此配方將對照您的本地模型庫進行掃描。",
|
||||
"messageBulk": "將對照您的本地模型庫掃描 {count} 個所選配方。",
|
||||
"relaxedLabel": "同時依檔案名稱重新關聯缺少的模型",
|
||||
"relaxedDescription": "這些模型也可以透過下載修復——下載更為準確。比對可能會連結到模型的不同版本;比對結果將列出供您檢閱,且可以撤銷。",
|
||||
"confirmButton": "重新匹配"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "撤銷",
|
||||
"undone": "已撤銷",
|
||||
"undoFailed": "撤銷重新匹配失敗:{message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "重新匹配摘要",
|
||||
"successMessage": "已匹配 {entries} 個條目",
|
||||
"failed": "重新匹配失敗",
|
||||
"completedWithWarnings": "重新匹配已完成——建議檢查",
|
||||
"cancelledNote": "執行在完成前已取消——統計不完整。",
|
||||
"statMatched": "已匹配條目",
|
||||
"statReview": "需要檢查",
|
||||
"statUnresolved": "未匹配",
|
||||
"statErrors": "錯誤",
|
||||
"reviewSection": "需要檢查的檔案名稱匹配({count})",
|
||||
"columnRecipe": "配方",
|
||||
"columnEntry": "條目",
|
||||
"columnFile": "匹配到的檔案",
|
||||
"columnUndo": "撤銷",
|
||||
"copyReport": "複製報告",
|
||||
"close": "關閉",
|
||||
"scope_global": "所有配方",
|
||||
"scope_bulk": "所選配方",
|
||||
"scope_single": "單個配方"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "本機範例圖片",
|
||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||
@@ -1523,12 +1642,16 @@
|
||||
"pathPlaceholder": "輸入資料夾路徑或從下方樹狀結構選擇...",
|
||||
"root": "根目錄"
|
||||
},
|
||||
"linkHuggingFace": {
|
||||
"title": "連結到 HuggingFace",
|
||||
"infoText": "貼上 HuggingFace 倉庫 URL 以關聯此模型。關聯後可啟用 AI 中繼資料增強功能。",
|
||||
"urlLabel": "HuggingFace 倉庫 URL:",
|
||||
"linkModelSource": {
|
||||
"title": "連結到模型來源",
|
||||
"infoText": "貼上模型頁面 URL 以關聯此模型與其來源。關聯後可對 Hugging Face 和 ModelScope 模型啟用 AI 中繼資料增強。",
|
||||
"urlLabel": "模型頁面 URL:",
|
||||
"urlPlaceholder": "https://huggingface.co/user/repo",
|
||||
"helpText": "請輸入完整的 HuggingFace 倉庫 URL。",
|
||||
"helpText": "請輸入完整的模型頁面 URL。支援的站點:",
|
||||
"enrichNote": "AI 增強需要可讀取的模型卡。未提供模型卡的站點(目前為 TensorArt)只能建立連結。",
|
||||
"urlRequired": "請輸入模型頁面 URL。",
|
||||
"invalidUrl": "URL 不受支援。支援的站點:Hugging Face、ModelScope、TensorArt。",
|
||||
"linking": "正在連結模型來源...",
|
||||
"confirmAction": "儲存並連結"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
@@ -1774,7 +1897,7 @@
|
||||
"empty": "此模型尚無版本歷史。",
|
||||
"error": "載入版本失敗。",
|
||||
"missingModelId": "此模型缺少 CivitAI 模型 ID。",
|
||||
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
|
||||
"sourceGroupInfo": "這是一個 {source} 模型組。打開庫頁面即可在網格中查看所有版本。",
|
||||
"confirm": {
|
||||
"delete": "要從庫中刪除此版本嗎?"
|
||||
},
|
||||
@@ -1846,6 +1969,10 @@
|
||||
"title": "初始化 Embedding 管理器",
|
||||
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
|
||||
},
|
||||
"other": {
|
||||
"title": "正在初始化其他模型管理器",
|
||||
"message": "正在掃描並建立模型快取。這可能需要幾分鐘..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "初始化配方管理器",
|
||||
"message": "正在載入並處理配方,可能需要幾分鐘..."
|
||||
@@ -2168,6 +2295,7 @@
|
||||
"createMissingData": "缺少建立配方所需的資料",
|
||||
"created": "配方建立成功",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
|
||||
"noPreviousRecipe": "沒有上一個配方",
|
||||
"noNextRecipe": "沒有下一個配方",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
@@ -2222,10 +2350,6 @@
|
||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||
"noRecipesSelected": "未選取任何配方",
|
||||
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
|
||||
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
||||
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
||||
"reimporting": "正在從來源重新匯入配方...",
|
||||
@@ -2304,6 +2428,7 @@
|
||||
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
||||
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
||||
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
||||
"otherRootsFailed": "載入其他模型根目錄失敗:{message}",
|
||||
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
||||
"mappingsCleared": "基礎模型路徑對應已清除",
|
||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||
@@ -2408,7 +2533,9 @@
|
||||
"linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結",
|
||||
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
||||
"noCivitaiInfo": "無 CivitAI 資訊",
|
||||
"missingHash": "模型雜湊不可用"
|
||||
"missingHash": "模型雜湊不可用",
|
||||
"enrichNeedsSource": "請先將此模型連結到模型來源(連結模型 → 連結到模型來源)",
|
||||
"enrichUnsupportedSource": "{source} 模型不支援 AI 增強"
|
||||
},
|
||||
"exampleImages": {
|
||||
"pathUpdated": "範例圖片路徑已更新",
|
||||
@@ -2566,6 +2693,12 @@
|
||||
"rebuilding": "重建快取中...",
|
||||
"rebuildFailed": "重建快取失敗:{error}",
|
||||
"retry": "重試"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "其他模型管理現已可用",
|
||||
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||
"enable": "啟用其他模型",
|
||||
"openSettings": "開啟設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+276
-1
@@ -17,6 +17,9 @@ import types as _types
|
||||
import time
|
||||
|
||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
||||
from .utils.constants import (
|
||||
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||
)
|
||||
from .utils.settings_paths import (
|
||||
ensure_settings_file,
|
||||
get_settings_dir,
|
||||
@@ -172,6 +175,13 @@ class Config:
|
||||
self.embeddings_roots = None
|
||||
self.base_models_roots = self._init_checkpoint_paths()
|
||||
self.embeddings_roots = self._init_embedding_paths()
|
||||
# Other-model roots (VAE, upscalers, text encoders, ...): flat deduped
|
||||
# list plus a normalized root -> sub_type map and per-folder_paths-key
|
||||
# roots for settings persistence.
|
||||
self.other_roots: Optional[List[str]] = None
|
||||
self.other_root_subtypes: Dict[str, str] = {}
|
||||
self.other_folder_roots: Dict[str, List[str]] = {}
|
||||
self.other_roots = self._init_other_paths()
|
||||
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||
self.extra_loras_roots: List[str] = []
|
||||
self.extra_checkpoints_roots: List[str] = []
|
||||
@@ -336,6 +346,10 @@ class Config:
|
||||
"unet": list(self.unet_roots or []),
|
||||
"embeddings": list(self.embeddings_roots or []),
|
||||
}
|
||||
# Persist the other-model roots under their original folder_paths
|
||||
# keys so library switching round-trips them.
|
||||
for key, roots in (self.other_folder_roots or {}).items():
|
||||
target_folder_paths[key] = list(roots)
|
||||
|
||||
normalized_target_paths = _normalize_folder_paths_for_comparison(
|
||||
target_folder_paths
|
||||
@@ -522,6 +536,7 @@ class Config:
|
||||
roots.extend(self.loras_roots or [])
|
||||
roots.extend(self.base_models_roots or [])
|
||||
roots.extend(self.embeddings_roots or [])
|
||||
roots.extend(self.other_roots or [])
|
||||
# Include extra paths for scanning symlinks
|
||||
roots.extend(self.extra_loras_roots or [])
|
||||
roots.extend(self.extra_checkpoints_roots or [])
|
||||
@@ -862,6 +877,8 @@ class Config:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.embeddings_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.other_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
# Include extra paths for preview access
|
||||
for root in self.extra_loras_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
@@ -882,7 +899,7 @@ class Config:
|
||||
path for path in preview_roots if path.is_absolute()
|
||||
}
|
||||
logger.debug(
|
||||
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings",
|
||||
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d other roots, %d symlink mappings",
|
||||
len(self._preview_root_paths),
|
||||
len(self.loras_roots or []),
|
||||
len(self.extra_loras_roots or []),
|
||||
@@ -890,6 +907,7 @@ class Config:
|
||||
len(self.extra_checkpoints_roots or []),
|
||||
len(self.embeddings_roots or []),
|
||||
len(self.extra_embeddings_roots or []),
|
||||
len(self.other_roots or []),
|
||||
len(self._path_mappings),
|
||||
)
|
||||
|
||||
@@ -1128,6 +1146,155 @@ class Config:
|
||||
|
||||
return unique_paths
|
||||
|
||||
def _get_enabled_other_folder_keys(self) -> List[str]:
|
||||
"""Return the OTHER_MODEL_FOLDER_SUBTYPES keys that are enabled.
|
||||
|
||||
Other Models management is opt-in: while ``enable_other_models`` is
|
||||
off (the default) no other-model folder is scanned at all. When it is
|
||||
on, only the folder keys of the enabled sub_types are scanned
|
||||
(text_encoder merges ``text_encoders`` with the legacy ``clip`` key).
|
||||
"""
|
||||
try:
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
enabled_sub_types = get_settings_manager().get_enabled_other_sub_types()
|
||||
except Exception:
|
||||
enabled_sub_types = []
|
||||
if not enabled_sub_types:
|
||||
return []
|
||||
allowed = set(enabled_sub_types)
|
||||
return [
|
||||
key
|
||||
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
|
||||
if sub_type in allowed
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _collapse_legacy_folder_keys(keys: List[str]) -> List[str]:
|
||||
"""Drop folder keys the host already normalizes onto another queried key.
|
||||
|
||||
ComfyUI's ``folder_paths`` rewrites legacy names before every access
|
||||
(``clip`` -> ``text_encoders``, ``unet`` -> ``diffusion_models``), and
|
||||
registers both legacy directories under the canonical key, so
|
||||
``get_folder_paths("clip")`` returns exactly the same list as
|
||||
``get_folder_paths("text_encoders")``. Querying both therefore reports
|
||||
every text-encoder folder twice and trips the overlap guard with a
|
||||
conflict the user cannot fix.
|
||||
|
||||
When the host exposes ``map_legacy`` the alias is provably redundant and
|
||||
is skipped (an empty canonical list implies an empty alias list).
|
||||
Without it - the standalone mock, whose keys are independent
|
||||
``settings.json`` entries - every key is kept, because a ``clip``-only
|
||||
configuration is then genuinely distinct.
|
||||
"""
|
||||
map_legacy = getattr(folder_paths, "map_legacy", None)
|
||||
if not callable(map_legacy):
|
||||
return list(keys)
|
||||
|
||||
queried = set(keys)
|
||||
collapsed: List[str] = []
|
||||
for key in keys:
|
||||
try:
|
||||
canonical = map_legacy(key)
|
||||
except Exception:
|
||||
canonical = key
|
||||
if canonical != key and canonical in queried:
|
||||
logger.debug(
|
||||
"Skipping legacy folder key '%s'; the host resolves it to "
|
||||
"'%s', which is queried as well.",
|
||||
key,
|
||||
canonical,
|
||||
)
|
||||
continue
|
||||
collapsed.append(key)
|
||||
return collapsed
|
||||
|
||||
def _prepare_other_paths(
|
||||
self, folder_path_map: Mapping[str, Iterable[str]]
|
||||
) -> Tuple[List[str], Dict[str, str], Dict[str, List[str]]]:
|
||||
"""Prepare other-model paths from a folder_paths-key -> raw paths map.
|
||||
|
||||
Returns:
|
||||
Tuple of (all_unique_roots, business_root -> sub_type map,
|
||||
folder_paths key -> business roots). This method does NOT modify
|
||||
instance variables - callers must set them.
|
||||
"""
|
||||
unique_paths: List[str] = []
|
||||
sub_type_map: Dict[str, str] = {}
|
||||
per_key_roots: Dict[str, List[str]] = {}
|
||||
# real path -> (business path, sub_type) of the category that claimed it
|
||||
seen_real_paths: Dict[str, Tuple[str, str]] = {}
|
||||
|
||||
# Cross-scanner overlap detection: warn when an "other" root is
|
||||
# already covered by the checkpoints/unet or embeddings scanners.
|
||||
# Kept (not dropped) on purpose - duplicate cards across pages are
|
||||
# cosmetic, while dropping would silently unmanage the files.
|
||||
covered_real_paths = {
|
||||
os.path.normpath(os.path.realpath(path)).replace(os.sep, "/"): path
|
||||
for path in [
|
||||
*(self.base_models_roots or []),
|
||||
*(self.embeddings_roots or []),
|
||||
]
|
||||
if isinstance(path, str) and path.strip() and os.path.exists(path)
|
||||
}
|
||||
|
||||
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||
raw_paths = folder_path_map.get(key)
|
||||
if not raw_paths:
|
||||
continue
|
||||
path_map = self._dedupe_existing_paths(raw_paths)
|
||||
key_roots: List[str] = []
|
||||
for real_path, business_path in sorted(
|
||||
path_map.items(), key=lambda item: item[1].lower()
|
||||
):
|
||||
seen = seen_real_paths.get(real_path)
|
||||
if seen is not None:
|
||||
seen_business_path, seen_sub_type = seen
|
||||
if seen_sub_type == sub_type:
|
||||
# Same category reached through a second folder_paths
|
||||
# key (legacy alias, or a sub_type spanning two keys).
|
||||
# Expected, so never a "fix your configuration" warning.
|
||||
logger.debug(
|
||||
"Ignoring duplicate folder '%s' for category '%s' "
|
||||
"(already covered by '%s').",
|
||||
business_path,
|
||||
sub_type,
|
||||
seen_business_path,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Detected the same folder '%s' under multiple other-model "
|
||||
"categories ('%s' is already mapped as '%s'). Keeping the "
|
||||
"first category; please fix your path configuration.",
|
||||
business_path,
|
||||
seen_business_path,
|
||||
seen_sub_type,
|
||||
)
|
||||
continue
|
||||
seen_real_paths[real_path] = (business_path, sub_type)
|
||||
unique_paths.append(business_path)
|
||||
key_roots.append(business_path)
|
||||
sub_type_map[business_path] = sub_type
|
||||
|
||||
if real_path != business_path:
|
||||
self.add_path_mapping(business_path, real_path)
|
||||
|
||||
covered_by = covered_real_paths.get(real_path)
|
||||
if covered_by:
|
||||
logger.warning(
|
||||
"Detected an other-model root ('%s', category '%s') that "
|
||||
"overlaps an existing checkpoints/embeddings root ('%s'). "
|
||||
"The same files will appear on both pages; please review "
|
||||
"your path configuration.",
|
||||
business_path,
|
||||
key,
|
||||
covered_by,
|
||||
)
|
||||
if key_roots:
|
||||
per_key_roots[key] = key_roots
|
||||
|
||||
return unique_paths, sub_type_map, per_key_roots
|
||||
|
||||
def _apply_library_paths(
|
||||
self,
|
||||
folder_paths: Mapping[str, Any],
|
||||
@@ -1151,6 +1318,16 @@ class Config:
|
||||
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
||||
|
||||
other_path_map = {
|
||||
key: folder_paths.get(key, []) or []
|
||||
for key in self._get_enabled_other_folder_keys()
|
||||
}
|
||||
(
|
||||
self.other_roots,
|
||||
self.other_root_subtypes,
|
||||
self.other_folder_roots,
|
||||
) = self._prepare_other_paths(other_path_map)
|
||||
|
||||
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||
extra_paths = extra_folder_paths or {}
|
||||
extra_lora_paths = extra_paths.get("loras", []) or []
|
||||
@@ -1267,6 +1444,104 @@ class Config:
|
||||
logger.warning(f"Error initializing embedding paths: {e}")
|
||||
return []
|
||||
|
||||
def _init_other_paths(self) -> List[str]:
|
||||
"""Initialize and validate other-model paths from ComfyUI settings.
|
||||
|
||||
Iterates the enabled OTHER_MODEL_FOLDER_SUBTYPES keys and pulls each
|
||||
from ``folder_paths.get_folder_paths(key)`` (in standalone mode the
|
||||
mock serves arbitrary keys from ``settings.json.folder_paths``).
|
||||
Legacy aliases the host normalizes onto a canonical key (``clip`` ->
|
||||
``text_encoders``) are collapsed first so the same folders are not
|
||||
reported twice.
|
||||
"""
|
||||
try:
|
||||
folder_path_map: Dict[str, List[str]] = {}
|
||||
for key in self._collapse_legacy_folder_keys(
|
||||
self._get_enabled_other_folder_keys()
|
||||
):
|
||||
try:
|
||||
folder_path_map[key] = folder_paths.get_folder_paths(key)
|
||||
except Exception as exc:
|
||||
logger.debug("Error reading folder paths for '%s': %s", key, exc)
|
||||
|
||||
(
|
||||
unique_paths,
|
||||
self.other_root_subtypes,
|
||||
self.other_folder_roots,
|
||||
) = self._prepare_other_paths(folder_path_map)
|
||||
|
||||
logger.info(
|
||||
"Found other model roots:"
|
||||
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
|
||||
)
|
||||
|
||||
if not unique_paths:
|
||||
logger.info("No valid other-model folders found in configuration")
|
||||
return []
|
||||
|
||||
return unique_paths
|
||||
except Exception as e:
|
||||
logger.warning(f"Error initializing other model paths: {e}")
|
||||
return []
|
||||
|
||||
def refresh_other_roots(self) -> None:
|
||||
"""Rebuild other-model roots after the management toggles changed.
|
||||
|
||||
Called when ``enable_other_models`` / ``enabled_other_sub_types`` are
|
||||
updated so the scanner immediately reflects the new folder set without
|
||||
a full application restart.
|
||||
"""
|
||||
self.other_roots = self._init_other_paths()
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def get_other_models_availability(self) -> Dict[str, Any]:
|
||||
"""Report the other-model folders the host can actually expose.
|
||||
|
||||
Independent of the opt-in ``enable_other_models`` toggle: this answers
|
||||
"could Other Models management work here at all?". ComfyUI mode almost
|
||||
always has these folder keys registered, while standalone mode only
|
||||
knows the keys present in ``settings.json.folder_paths`` - so the UI
|
||||
uses this to decide whether announcing the feature would be actionable.
|
||||
|
||||
Returns:
|
||||
``{"available": bool, "sub_types": {sub_type: [existing roots]}}``.
|
||||
A folder only counts when it exists on disk; an empty folder still
|
||||
counts because CivitAI downloads can target it.
|
||||
"""
|
||||
sub_types: Dict[str, List[str]] = {}
|
||||
try:
|
||||
keys = self._collapse_legacy_folder_keys(
|
||||
list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
keys = list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
|
||||
|
||||
for key in keys:
|
||||
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
|
||||
if not sub_type:
|
||||
continue
|
||||
try:
|
||||
raw_paths = folder_paths.get_folder_paths(key)
|
||||
except Exception as exc:
|
||||
logger.debug("Error probing folder paths for '%s': %s", key, exc)
|
||||
continue
|
||||
|
||||
bucket = sub_types.setdefault(sub_type, [])
|
||||
for root in sorted(
|
||||
self._dedupe_existing_paths(raw_paths or []).values(),
|
||||
key=lambda path: path.lower(),
|
||||
):
|
||||
if root not in bucket:
|
||||
bucket.append(root)
|
||||
|
||||
available_sub_types = {
|
||||
sub_type: roots for sub_type, roots in sub_types.items() if roots
|
||||
}
|
||||
return {
|
||||
"available": bool(available_sub_types),
|
||||
"sub_types": available_sub_types,
|
||||
}
|
||||
|
||||
def get_preview_static_url(self, preview_path: str) -> str:
|
||||
if not preview_path:
|
||||
return ""
|
||||
|
||||
+7
-8
@@ -219,6 +219,7 @@ class LoraManager:
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
|
||||
# Initialize recipe scanner if needed
|
||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||
@@ -236,6 +237,10 @@ class LoraManager:
|
||||
embedding_scanner.initialize_in_background(),
|
||||
name="embedding_cache_init",
|
||||
),
|
||||
asyncio.create_task(
|
||||
other_scanner.initialize_in_background(),
|
||||
name="other_cache_init",
|
||||
),
|
||||
asyncio.create_task(
|
||||
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
|
||||
),
|
||||
@@ -328,6 +333,7 @@ class LoraManager:
|
||||
all_roots.update(config.loras_roots)
|
||||
all_roots.update(config.base_models_roots or [])
|
||||
all_roots.update(config.embeddings_roots or [])
|
||||
all_roots.update(config.other_roots or [])
|
||||
|
||||
total_deleted = 0
|
||||
total_size_freed = 0
|
||||
@@ -460,18 +466,11 @@ class LoraManager:
|
||||
# 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"):
|
||||
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner", "other_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)
|
||||
|
||||
@@ -36,6 +36,7 @@ SCANNER_TYPE_MAP: dict[str, str] = {
|
||||
"get_lora_scanner": "lora",
|
||||
"get_checkpoint_scanner": "checkpoint",
|
||||
"get_embedding_scanner": "embedding",
|
||||
"get_other_scanner": "other",
|
||||
}
|
||||
|
||||
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
||||
@@ -80,8 +81,8 @@ async def _find_scanner_for_model(
|
||||
|
||||
|
||||
async def identify_model_type(model_path: str) -> str:
|
||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
|
||||
``\"embedding\"``) for *model_path*.
|
||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``,
|
||||
``\"embedding\"``, or ``\"other\"``) for *model_path*.
|
||||
|
||||
Falls back to ``\"lora\"`` when unknown.
|
||||
"""
|
||||
|
||||
@@ -78,7 +78,7 @@ class CheckpointLoaderLM:
|
||||
|
||||
# Filter only checkpoint type (not diffusion_model) and format names
|
||||
names = []
|
||||
for item in cache.raw_data:
|
||||
for item in list(cache.raw_data):
|
||||
if item.get("sub_type") == "checkpoint":
|
||||
file_path = item.get("file_path", "")
|
||||
# Only offer models that still exist on disk so ComfyUI
|
||||
@@ -126,7 +126,7 @@ class CheckpointLoaderLM:
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
base_models = set()
|
||||
for item in cache.raw_data:
|
||||
for item in list(cache.raw_data):
|
||||
if item.get("sub_type") != "checkpoint":
|
||||
continue
|
||||
base_model = item.get("base_model")
|
||||
|
||||
@@ -601,7 +601,7 @@ class SaveImageLM:
|
||||
os.path.basename(name),
|
||||
os.path.splitext(os.path.basename(name))[0],
|
||||
]
|
||||
for model in getattr(cache, "raw_data", []):
|
||||
for model in list(getattr(cache, "raw_data", [])):
|
||||
file_name = model.get("file_name")
|
||||
if file_name in candidates:
|
||||
return model
|
||||
|
||||
@@ -93,7 +93,7 @@ class UNETLoaderLM:
|
||||
|
||||
# Filter only diffusion_model type and format names
|
||||
names = []
|
||||
for item in cache.raw_data:
|
||||
for item in list(cache.raw_data):
|
||||
if item.get("sub_type") == "diffusion_model":
|
||||
file_path = item.get("file_path", "")
|
||||
# Only offer models that still exist on disk so ComfyUI
|
||||
@@ -141,7 +141,7 @@ class UNETLoaderLM:
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
base_models = set()
|
||||
for item in cache.raw_data:
|
||||
for item in list(cache.raw_data):
|
||||
if item.get("sub_type") != "diffusion_model":
|
||||
continue
|
||||
base_model = item.get("base_model")
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ def _find_missing_loras(names: list[str]) -> list[str]:
|
||||
|
||||
lookup = {}
|
||||
basename_candidates = {}
|
||||
for item in cache.raw_data:
|
||||
for item in list(cache.raw_data):
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
@@ -149,6 +149,7 @@ class BaseModelRoutes(ABC):
|
||||
settings_service=self._settings,
|
||||
server_i18n=self._server_i18n,
|
||||
logger=logger,
|
||||
page_context_provider=self._get_page_context_provider(),
|
||||
)
|
||||
listing = ModelListingHandler(
|
||||
service=service,
|
||||
@@ -250,6 +251,10 @@ class BaseModelRoutes(ABC):
|
||||
"""Get expected model types string for error messages - to be overridden by subclasses."""
|
||||
return "any model type"
|
||||
|
||||
def _get_page_context_provider(self):
|
||||
"""Optional hook returning extra template context for the page view."""
|
||||
return None
|
||||
|
||||
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") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""HTTP handler for download target routing decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...services.download_routing import (
|
||||
is_diffusion_model_download,
|
||||
resolve_other_download_sub_type,
|
||||
)
|
||||
from ...utils.constants import VALID_OTHER_CIVITAI_TYPES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DownloadRoutingHandler:
|
||||
"""Expose the download-time checkpoint/diffusion-model routing decision.
|
||||
|
||||
The web UI calls this when the user reaches the download location step
|
||||
so the root dropdown offers the same root set (checkpoint vs unet) that
|
||||
the download manager would pick for ``use_default_paths``.
|
||||
"""
|
||||
|
||||
async def get_download_routing(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON payload"}, status=400
|
||||
)
|
||||
|
||||
model_type = payload.get("model_type", "")
|
||||
base_model = payload.get("base_model") or ""
|
||||
file_types = payload.get("file_types") or []
|
||||
selected_file_type = payload.get("selected_file_type")
|
||||
|
||||
if not isinstance(model_type, str) or not model_type:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "model_type is required"}, status=400
|
||||
)
|
||||
if not isinstance(base_model, str) or not isinstance(file_types, list):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "base_model must be a string and file_types a list",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
if selected_file_type is not None and not isinstance(selected_file_type, str):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "selected_file_type must be a string"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
|
||||
settings = get_settings_manager()
|
||||
if not settings.is_other_models_enabled():
|
||||
# Opt-in feature is off: never auto-route, the UI falls back to
|
||||
# manual folder selection and the download manager rejects it.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"root_kind": "other",
|
||||
"sub_type": None,
|
||||
"disabled": True,
|
||||
"reason": "other_models_disabled",
|
||||
}
|
||||
)
|
||||
|
||||
sub_type = resolve_other_download_sub_type(
|
||||
model_type,
|
||||
file_types=(str(t) for t in file_types),
|
||||
selected_file_type=selected_file_type,
|
||||
)
|
||||
if sub_type and not settings.is_other_sub_type_enabled(sub_type):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"root_kind": "other",
|
||||
"sub_type": None,
|
||||
"disabled": True,
|
||||
"reason": "other_sub_type_disabled",
|
||||
"requested_sub_type": sub_type,
|
||||
}
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"root_kind": "other",
|
||||
"sub_type": sub_type,
|
||||
}
|
||||
)
|
||||
|
||||
is_diffusion = is_diffusion_model_download(
|
||||
model_type,
|
||||
file_types=(str(t) for t in file_types),
|
||||
base_model=base_model,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"is_diffusion_model": is_diffusion,
|
||||
"root_kind": "unet" if is_diffusion else model_type,
|
||||
}
|
||||
)
|
||||
@@ -1,508 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@@ -53,9 +53,11 @@ from ...utils.constants import (
|
||||
PREVIEW_EXTENSIONS,
|
||||
SUPPORTED_MEDIA_EXTENSIONS,
|
||||
VALID_LORA_TYPES,
|
||||
VALID_OTHER_CIVITAI_TYPES,
|
||||
)
|
||||
from .hf_handlers import HfHandler
|
||||
from .model_source_handlers import ModelSourceHandler
|
||||
from .agent_handlers import AgentHandler
|
||||
from .download_routing_handlers import DownloadRoutingHandler
|
||||
from .model_handlers import ModelCivitaiHandler
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import (
|
||||
@@ -419,6 +421,11 @@ def _wsl_to_windows_path(wsl_path: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _has_gui_display() -> bool:
|
||||
"""Check whether a GUI session is reachable for xdg-open."""
|
||||
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||
|
||||
|
||||
class PromptServerProtocol(Protocol):
|
||||
"""Subset of PromptServer used by the handlers."""
|
||||
|
||||
@@ -657,9 +664,21 @@ class HealthCheckHandler:
|
||||
"lora": ServiceRegistry.get_lora_scanner,
|
||||
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
||||
"embedding": ServiceRegistry.get_embedding_scanner,
|
||||
"other": ServiceRegistry.get_other_scanner,
|
||||
"recipe": ServiceRegistry.get_recipe_scanner,
|
||||
}
|
||||
|
||||
def _active_scanner_getters(
|
||||
self,
|
||||
) -> Mapping[str, Callable[[], Awaitable[Any]]]:
|
||||
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||
getters = self._scanner_getters
|
||||
if "other" not in getters:
|
||||
return getters
|
||||
if get_settings_manager().is_other_models_enabled():
|
||||
return getters
|
||||
return {name: getter for name, getter in getters.items() if name != "other"}
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
@@ -671,7 +690,7 @@ class HealthCheckHandler:
|
||||
page accepts the update and only reloads once all scanners are done.
|
||||
"""
|
||||
pending: list[str] = []
|
||||
for name, getter in self._scanner_getters.items():
|
||||
for name, getter in self._active_scanner_getters().items():
|
||||
try:
|
||||
scanner = await getter()
|
||||
except Exception:
|
||||
@@ -756,10 +775,19 @@ class DoctorHandler:
|
||||
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
||||
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
||||
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
||||
("other", "Other Models", ServiceRegistry.get_other_scanner),
|
||||
)
|
||||
)
|
||||
self._app_version_getter = app_version_getter
|
||||
|
||||
def _active_scanner_factories(
|
||||
self,
|
||||
) -> Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]]:
|
||||
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||
if self._settings.is_other_models_enabled():
|
||||
return self._scanner_factories
|
||||
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
|
||||
|
||||
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
client_version = (request.query.get("clientVersion") or "").strip()
|
||||
@@ -807,7 +835,7 @@ class DoctorHandler:
|
||||
repaired: list[dict[str, Any]] = []
|
||||
failures: list[dict[str, str]] = []
|
||||
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
for model_type, label, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
|
||||
@@ -839,7 +867,7 @@ class DoctorHandler:
|
||||
renamed: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
for model_type, label, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
hash_index = getattr(scanner, "_hash_index", None)
|
||||
@@ -1071,7 +1099,7 @@ class DoctorHandler:
|
||||
overall_status = "ok"
|
||||
summary = "All model caches look healthy."
|
||||
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
for model_type, label, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
persisted = None
|
||||
@@ -1156,7 +1184,7 @@ class DoctorHandler:
|
||||
total_conflict_groups = 0
|
||||
total_conflict_files = 0
|
||||
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
for model_type, label, factory in self._active_scanner_factories():
|
||||
# Duplicate filename detection targets LoRAs which use basename-only
|
||||
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
||||
# models via relative paths with extensions, so conflicts there would
|
||||
@@ -1536,6 +1564,22 @@ class SettingsHandler:
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
raw_llm_key = self._settings.get("llm_api_key")
|
||||
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
||||
# Derived capability flag (not persisted): whether the host exposes
|
||||
# any other-model folder at all. Standalone installs only know the
|
||||
# folder_paths keys present in settings.json, so the announcement
|
||||
# banner uses this to avoid promising a page that cannot list
|
||||
# anything.
|
||||
try:
|
||||
availability = config.get_other_models_availability()
|
||||
response_data["other_models_paths_available"] = bool(
|
||||
availability.get("available")
|
||||
)
|
||||
except Exception as availability_error: # pragma: no cover - defensive
|
||||
logger.debug(
|
||||
"Could not resolve Other Models availability: %s",
|
||||
availability_error,
|
||||
)
|
||||
response_data["other_models_paths_available"] = None
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
@@ -2065,6 +2109,7 @@ class ServiceRegistryAdapter:
|
||||
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
||||
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
||||
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
||||
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
|
||||
|
||||
|
||||
class ModelLibraryHandler:
|
||||
@@ -2089,6 +2134,8 @@ class ModelLibraryHandler:
|
||||
return "checkpoint"
|
||||
if normalized in {"embedding", "textualinversion"}:
|
||||
return "embedding"
|
||||
if normalized in VALID_OTHER_CIVITAI_TYPES:
|
||||
return "other"
|
||||
return None
|
||||
|
||||
async def _get_scanner_for_type(self, model_type: str | None):
|
||||
@@ -2099,6 +2146,13 @@ class ModelLibraryHandler:
|
||||
return normalized_type, await self._service_registry.get_checkpoint_scanner()
|
||||
if normalized_type == "embedding":
|
||||
return normalized_type, await self._service_registry.get_embedding_scanner()
|
||||
if normalized_type == "other":
|
||||
# Opt-in feature: the other scanner only resolves while the master
|
||||
# switch is on, so callers keep returning the legacy "required"
|
||||
# error (400) when it is off.
|
||||
if not get_settings_manager().is_other_models_enabled():
|
||||
return None, None
|
||||
return normalized_type, await self._service_registry.get_other_scanner()
|
||||
return None, None
|
||||
|
||||
async def _get_download_history_service(self):
|
||||
@@ -2190,6 +2244,11 @@ class ModelLibraryHandler:
|
||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||
# Opt-in: probe the other scanner only while Other Models is enabled,
|
||||
# so the disabled behaviour stays byte-identical to the legacy one.
|
||||
other_scanner = None
|
||||
if get_settings_manager().is_other_models_enabled():
|
||||
other_scanner = await self._service_registry.get_other_scanner()
|
||||
|
||||
if model_version_id_str:
|
||||
try:
|
||||
@@ -2228,6 +2287,13 @@ class ModelLibraryHandler:
|
||||
exists = True
|
||||
model_type = "embedding"
|
||||
matched_scanner = embedding_scanner
|
||||
elif (
|
||||
other_scanner
|
||||
and await other_scanner.check_model_version_exists(model_version_id)
|
||||
):
|
||||
exists = True
|
||||
model_type = "other"
|
||||
matched_scanner = other_scanner
|
||||
|
||||
if exists:
|
||||
return web.json_response(
|
||||
@@ -2245,7 +2311,7 @@ class ModelLibraryHandler:
|
||||
history_service = await self._get_download_history_service()
|
||||
has_been_downloaded = False
|
||||
history_type = None
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
|
||||
if await history_service.has_been_downloaded(
|
||||
candidate_type,
|
||||
model_version_id,
|
||||
@@ -2267,6 +2333,7 @@ class ModelLibraryHandler:
|
||||
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||
checkpoint_versions = []
|
||||
embedding_versions = []
|
||||
other_versions = []
|
||||
if not lora_versions and checkpoint_scanner:
|
||||
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
|
||||
model_id
|
||||
@@ -2275,6 +2342,13 @@ class ModelLibraryHandler:
|
||||
embedding_versions = await embedding_scanner.get_model_versions_by_id(
|
||||
model_id
|
||||
)
|
||||
if (
|
||||
not lora_versions
|
||||
and not checkpoint_versions
|
||||
and not embedding_versions
|
||||
and other_scanner
|
||||
):
|
||||
other_versions = await other_scanner.get_model_versions_by_id(model_id)
|
||||
|
||||
model_type = None
|
||||
versions = []
|
||||
@@ -2306,9 +2380,18 @@ class ModelLibraryHandler:
|
||||
"downloadedVersionIds": [],
|
||||
}
|
||||
)
|
||||
if other_versions:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"modelType": "other",
|
||||
"versions": self._with_downloaded_flag(other_versions),
|
||||
"downloadedVersionIds": [],
|
||||
}
|
||||
)
|
||||
|
||||
history_service = await self._get_download_history_service()
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
|
||||
candidate_downloaded_version_ids = (
|
||||
await history_service.get_downloaded_version_ids(
|
||||
candidate_type,
|
||||
@@ -2363,6 +2446,11 @@ class ModelLibraryHandler:
|
||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||
# Opt-in: keep the other probe last so model cards for lora /
|
||||
# checkpoint / embedding ids are unaffected by the extra scanner.
|
||||
other_scanner = None
|
||||
if get_settings_manager().is_other_models_enabled():
|
||||
other_scanner = await self._service_registry.get_other_scanner()
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for model_id in model_ids:
|
||||
@@ -2398,6 +2486,17 @@ class ModelLibraryHandler:
|
||||
})
|
||||
continue
|
||||
|
||||
if other_scanner:
|
||||
other_versions = await other_scanner.get_model_versions_by_id(model_id)
|
||||
if other_versions:
|
||||
results.append({
|
||||
"modelId": model_id,
|
||||
"modelType": "other",
|
||||
"versions": self._with_downloaded_flag(other_versions),
|
||||
"downloadedVersionIds": [],
|
||||
})
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"modelId": model_id,
|
||||
"modelType": None,
|
||||
@@ -2665,12 +2764,40 @@ class ModelLibraryHandler:
|
||||
|
||||
normalized_type, scanner = await self._get_scanner_for_type(model_type)
|
||||
if not normalized_type:
|
||||
# The lookup cannot be served as a fully interactive list. Two
|
||||
# cases share this branch: a CivitAI type with no scanner at all
|
||||
# (Wildcards, Workflows, Hypernetwork, Poses, AestheticGradient)
|
||||
# and an Other-model type while the opt-in master switch is off.
|
||||
# Answer 200 with the CivitAI list marked read-only plus a
|
||||
# machine-readable reason, so clients can still show the
|
||||
# versions and explain why the actions are missing. Legacy
|
||||
# clients keep working: they only read `success`/`versions`.
|
||||
reason = (
|
||||
"other_models_disabled"
|
||||
if self._normalize_model_type(model_type) == "other"
|
||||
else "model_type_unsupported"
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f'Model type "{model_type}" is not supported',
|
||||
},
|
||||
status=400,
|
||||
"success": True,
|
||||
"modelId": model_id,
|
||||
"modelName": model_name,
|
||||
"modelType": model_type,
|
||||
"supported": False,
|
||||
"reason": reason,
|
||||
"versions": [
|
||||
{
|
||||
"id": version.get("id"),
|
||||
"name": version.get("name", ""),
|
||||
"thumbnailUrl": version.get("images")[0]["url"]
|
||||
if version.get("images")
|
||||
else None,
|
||||
"inLibrary": False,
|
||||
"hasBeenDownloaded": False,
|
||||
}
|
||||
for version in versions
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if not scanner:
|
||||
@@ -2712,6 +2839,7 @@ class ModelLibraryHandler:
|
||||
"modelId": model_id,
|
||||
"modelName": model_name,
|
||||
"modelType": model_type,
|
||||
"supported": True,
|
||||
"versions": enriched_versions,
|
||||
}
|
||||
)
|
||||
@@ -2786,12 +2914,32 @@ class ModelLibraryHandler:
|
||||
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
||||
}
|
||||
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
|
||||
other_type_aliases = {
|
||||
model_type.lower() for model_type in VALID_OTHER_CIVITAI_TYPES
|
||||
}
|
||||
|
||||
# Acquire the other scanner lazily so adapters without it only
|
||||
# fail when the payload actually contains other-type models.
|
||||
# While the opt-in feature is off the scanner still exists (its
|
||||
# cache is empty), so other types simply report inLibrary=False.
|
||||
needs_other_scanner = any(
|
||||
isinstance(model, dict)
|
||||
and str(model.get("type", "")).lower() in other_type_aliases
|
||||
for model in models
|
||||
)
|
||||
other_scanner = None
|
||||
if needs_other_scanner:
|
||||
other_scanner = await self._service_registry.get_other_scanner()
|
||||
|
||||
type_scanner_map: Dict[str, Any] = {
|
||||
**{alias: lora_scanner for alias in lora_type_aliases},
|
||||
"checkpoint": checkpoint_scanner,
|
||||
"textualinversion": embedding_scanner,
|
||||
}
|
||||
if other_scanner is not None:
|
||||
type_scanner_map.update(
|
||||
{alias: other_scanner for alias in other_type_aliases}
|
||||
)
|
||||
|
||||
versions: list[dict[str, Any]] = []
|
||||
history_service = await self._get_download_history_service()
|
||||
@@ -2815,12 +2963,17 @@ class ModelLibraryHandler:
|
||||
"embedding",
|
||||
model_ids,
|
||||
)
|
||||
other_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||
"other",
|
||||
model_ids,
|
||||
)
|
||||
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
||||
"lora": lora_downloaded,
|
||||
"locon": lora_downloaded,
|
||||
"dora": lora_downloaded,
|
||||
"checkpoint": checkpoint_downloaded,
|
||||
"textualinversion": embedding_downloaded,
|
||||
**{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES},
|
||||
}
|
||||
for model in models:
|
||||
if not isinstance(model, dict):
|
||||
@@ -3274,6 +3427,18 @@ class FileSystemHandler:
|
||||
subprocess.Popen(["open", "-R", settings_file])
|
||||
else:
|
||||
folder = os.path.dirname(settings_file)
|
||||
if not _has_gui_display():
|
||||
# Headless/SSH session: xdg-open cannot open a file
|
||||
# manager, so hand the path to the browser for copying
|
||||
# instead of reporting a success that never happened.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Headless session: path available for copying",
|
||||
"path": settings_file,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
|
||||
return web.json_response(
|
||||
@@ -3882,8 +4047,9 @@ class MiscHandlerSet:
|
||||
doctor: DoctorHandler,
|
||||
example_workflows: ExampleWorkflowsHandler,
|
||||
base_model: BaseModelHandlerSet,
|
||||
hf_handler: Any = None,
|
||||
model_source_handler: Any = None,
|
||||
agent_handler: Any = None,
|
||||
download_routing: Any = None,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -3902,8 +4068,9 @@ class MiscHandlerSet:
|
||||
self.doctor = doctor
|
||||
self.example_workflows = example_workflows
|
||||
self.base_model = base_model
|
||||
self.hf_handler = hf_handler
|
||||
self.model_source_handler = model_source_handler
|
||||
self.agent_handler = agent_handler
|
||||
self.download_routing = download_routing
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -3955,13 +4122,19 @@ class MiscHandlerSet:
|
||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
||||
# Hugging Face handlers
|
||||
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
|
||||
"download_hf_model": self.hf_handler.download_hf_model,
|
||||
"set_hf_url": self.hf_handler.set_hf_url,
|
||||
# External model sources (Hugging Face / ModelScope)
|
||||
"list_model_source_files": self.model_source_handler.list_model_source_files,
|
||||
"download_model_source": self.model_source_handler.download_model_source,
|
||||
"get_hf_repo_files": self.model_source_handler.list_model_source_files,
|
||||
"download_hf_model": self.model_source_handler.download_model_source,
|
||||
"set_hf_url": self.model_source_handler.set_hf_url,
|
||||
"get_model_sources": self.model_source_handler.get_model_sources,
|
||||
# Agent skill handlers
|
||||
"get_agent_skills": self.agent_handler.get_agent_skills,
|
||||
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
||||
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
||||
# Download routing handler
|
||||
"get_download_routing": self.download_routing.get_download_routing,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
@@ -3975,6 +4148,7 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
|
||||
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
||||
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
||||
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
||||
get_other_scanner=ServiceRegistry.get_other_scanner,
|
||||
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
||||
get_backup_service=ServiceRegistry.get_backup_service,
|
||||
)
|
||||
|
||||
@@ -90,6 +90,7 @@ class ModelPageView:
|
||||
settings_service: SettingsManager,
|
||||
server_i18n,
|
||||
logger: logging.Logger,
|
||||
page_context_provider: Callable[[web.Request], Dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self._template_env = template_env
|
||||
self._template_name = template_name
|
||||
@@ -97,6 +98,7 @@ class ModelPageView:
|
||||
self._settings = settings_service
|
||||
self._server_i18n = server_i18n
|
||||
self._logger = logger
|
||||
self._page_context_provider = page_context_provider
|
||||
|
||||
def _load_supporters(self) -> dict[str, Any]:
|
||||
"""Load supporters data from JSON file."""
|
||||
@@ -210,6 +212,16 @@ class ModelPageView:
|
||||
self._logger.error("Error loading cache data: %s", cache_error)
|
||||
template_context["is_initializing"] = True
|
||||
|
||||
if self._page_context_provider is not None:
|
||||
try:
|
||||
extra_context = self._page_context_provider(request)
|
||||
if isinstance(extra_context, dict):
|
||||
template_context.update(extra_context)
|
||||
except Exception as context_error: # pragma: no cover - logging path
|
||||
self._logger.error(
|
||||
"Error building page context: %s", context_error
|
||||
)
|
||||
|
||||
rendered = self._template_env.get_template(self._template_name).render(
|
||||
**template_context
|
||||
)
|
||||
@@ -1898,6 +1910,11 @@ class ModelDownloadHandler:
|
||||
response_payload["status"] = status
|
||||
if "message" in progress_data:
|
||||
response_payload["message"] = progress_data["message"]
|
||||
# Post-transfer stage (indexing / source metadata); polling
|
||||
# consumers need it to tell "working" from "stuck".
|
||||
for field in ("stage", "platform"):
|
||||
if field in progress_data:
|
||||
response_payload[field] = progress_data[field]
|
||||
elif status is None and "message" in progress_data:
|
||||
response_payload["message"] = progress_data["message"]
|
||||
|
||||
@@ -2467,6 +2484,90 @@ class ModelMoveHandler:
|
||||
self._move_service = move_service
|
||||
self._logger = logger
|
||||
|
||||
async def create_folder(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
try:
|
||||
folder_path = data.get("folder_path")
|
||||
if not folder_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Folder path is required"}, status=400
|
||||
)
|
||||
result = await self._move_service.create_folder(folder_path)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error creating folder: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def delete_folder(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
try:
|
||||
folder_path = data.get("folder_path")
|
||||
if not folder_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Folder path is required"}, status=400
|
||||
)
|
||||
dry_run = bool(data.get("dry_run"))
|
||||
result = await self._move_service.delete_folder(
|
||||
folder_path, dry_run=dry_run
|
||||
)
|
||||
if result.get("success"):
|
||||
if not dry_run:
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result, status=200)
|
||||
|
||||
# "not_empty" / "busy" are conflicts between the tree the client
|
||||
# rendered and the on-disk truth; everything else is a bad request.
|
||||
code = result.get("code")
|
||||
status = 409 if code in ("not_empty", "busy") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error deleting folder: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def rename_folder(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
try:
|
||||
folder_path = data.get("folder_path")
|
||||
new_name = data.get("new_name")
|
||||
if not folder_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Folder path is required"}, status=400
|
||||
)
|
||||
if not new_name:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "New folder name is required"}, status=400
|
||||
)
|
||||
result = await self._move_service.rename_folder(folder_path, new_name)
|
||||
if result.get("success"):
|
||||
if result.get("renamed"):
|
||||
_broadcast_models_changed()
|
||||
return web.json_response(result, status=200)
|
||||
|
||||
# A name collision or a staged delete inside the subtree is a
|
||||
# conflict with the state the client rendered, not a bad request.
|
||||
code = result.get("code")
|
||||
status = 409 if code in ("target_exists", "busy") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error renaming folder: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_model(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -3417,6 +3518,9 @@ class ModelHandlerSet:
|
||||
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
|
||||
"move_model": self.move.move_model,
|
||||
"move_models_bulk": self.move.move_models_bulk,
|
||||
"create_folder": self.move.create_folder,
|
||||
"delete_folder": self.move.delete_folder,
|
||||
"rename_folder": self.move.rename_folder,
|
||||
"auto_organize_models": self.auto_organize.auto_organize_models,
|
||||
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
||||
"get_model_notes": self.query.get_model_notes,
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
"""Handlers for external model sources: linking, file listing and downloads.
|
||||
|
||||
Covers every site registered in :mod:`py.services.model_sources`. The module
|
||||
was Hugging Face only (``hf_handlers.py`` / ``HfHandler``) until ModelScope
|
||||
downloads were added; the per-site differences now live in the providers, so
|
||||
this file has no platform branches beyond the capability lookups.
|
||||
|
||||
The historical route paths (``/api/lm/set-hf-url``, ``/api/lm/hf-repo-files``,
|
||||
``/api/lm/download-hf-model``) are still registered as aliases of the generic
|
||||
handlers, so existing callers keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...config import config
|
||||
from ...services.downloader import (
|
||||
DownloadProgress,
|
||||
get_downloader,
|
||||
)
|
||||
from ...services.aria2_downloader import Aria2Downloader
|
||||
from ...services.model_sources import (
|
||||
ModelSourceError,
|
||||
SourceRef,
|
||||
detect_source,
|
||||
get_download_source,
|
||||
hydrate_from_source,
|
||||
is_valid_source_id,
|
||||
list_sources,
|
||||
normalize_metadata_source,
|
||||
)
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
from ...services.websocket_manager import ws_manager
|
||||
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"
|
||||
|
||||
|
||||
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 _report_phase(
|
||||
download_id: str | None, stage: str, platform: str = ""
|
||||
) -> None:
|
||||
"""Tell the progress UI which post-transfer stage is running.
|
||||
|
||||
A download's byte counter stops the moment the last byte lands, but the
|
||||
backend still has to index the file and read the model site's API. Without
|
||||
this the bar sits at 100% reporting "0 B/s" and the download looks stuck for
|
||||
several seconds. *stage* is machine-readable — the UI localises it — and
|
||||
*platform* lets it name the site the metadata comes from.
|
||||
"""
|
||||
|
||||
if not download_id:
|
||||
return
|
||||
try:
|
||||
await ws_manager.broadcast_download_progress(
|
||||
download_id,
|
||||
{
|
||||
"status": "metadata",
|
||||
"stage": stage,
|
||||
"platform": platform,
|
||||
"progress": 100,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - progress must never be fatal
|
||||
logger.debug("Failed to report the '%s' phase: %s", stage, exc)
|
||||
|
||||
|
||||
async def _save_source_metadata(
|
||||
dest_path: str, ref: SourceRef, model_root: str, *, download_id: str | None = None
|
||||
) -> None:
|
||||
"""Create a proper .metadata.json and add the model to the scanner cache.
|
||||
|
||||
The metadata is created through the owning scanner rather than
|
||||
``MetadataManager.create_default_metadata()``, because that is the only
|
||||
factory that knows when hashing must be deferred: ``CheckpointScanner`` and
|
||||
``OtherScanner`` deliberately record ``hash_status="pending"`` with an empty
|
||||
``sha256`` for their multi-GB files, and the generic helper would read a
|
||||
10 GB checkpoint end to end *inside the download request*. Scanners for the
|
||||
small types delegate straight back to it, so nothing changes for them.
|
||||
|
||||
The external-source fields are then overlaid and the model is registered in
|
||||
the in-memory scanner cache so it appears immediately without a full
|
||||
filesystem walk.
|
||||
|
||||
Finally the site's own published metadata is applied (see
|
||||
:func:`~py.services.model_sources.hydration.hydrate_from_source`), so a
|
||||
ModelScope or Hugging Face download lands with the same populated model
|
||||
card a CivitAI download produces instead of a bare filename and hash.
|
||||
|
||||
Both post-transfer stages are reported through *download_id* when the UI is
|
||||
watching one, because neither advances the byte counter.
|
||||
"""
|
||||
try:
|
||||
model_class, scanner_getter_name = _infer_model_type(model_root)
|
||||
|
||||
scanner = None
|
||||
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||
if scanner_getter is not None:
|
||||
scanner = await scanner_getter()
|
||||
|
||||
# 1. Create proper metadata (reads safetensors headers; hashes only for
|
||||
# the model types whose scanner does not defer it)
|
||||
await _report_phase(download_id, "indexing", ref.platform)
|
||||
create_metadata = getattr(scanner, "_create_default_metadata", None)
|
||||
if create_metadata is not None:
|
||||
metadata = await create_metadata(dest_path)
|
||||
else:
|
||||
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 the external-source fields (`hf_url` is written by
|
||||
# normalisation for Hugging Face only)
|
||||
fields = metadata._unknown_fields
|
||||
fields["source_url"] = ref.url
|
||||
fields["source_platform"] = ref.platform
|
||||
if ref.platform == "huggingface":
|
||||
fields["hf_url"] = ref.url
|
||||
metadata.from_civitai = False # externally-sourced models are not from CivitAI
|
||||
|
||||
# 3. Save metadata atomically
|
||||
await MetadataManager.save_metadata(dest_path, metadata)
|
||||
logger.info(
|
||||
"Saved %s metadata (source=%s, hash_status=%s) for %s",
|
||||
ref.platform, ref.url, getattr(metadata, "hash_status", "?"), 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)
|
||||
if scanner is not None:
|
||||
metadata_dict = normalize_metadata_source(metadata.to_dict())
|
||||
await scanner.add_model_to_cache(metadata_dict, folder)
|
||||
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
||||
|
||||
# 6. Top up from the site's public API. Runs last so the scanner-cache
|
||||
# refresh it performs lands on the entry created above. It never
|
||||
# raises and never fails the download.
|
||||
await _report_phase(download_id, "source", ref.platform)
|
||||
await hydrate_from_source(dest_path, ref=ref)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save source 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)
|
||||
|
||||
|
||||
def _unsupported_platform_error(platform: str) -> web.Response:
|
||||
supported = ", ".join(source.label for source in list_sources() if source.supports_download)
|
||||
return web.json_response(
|
||||
{"error": f"'{platform}' does not support downloads. Supported: {supported}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
class ModelSourceHandler:
|
||||
"""Handle external model browsing, linking and downloads."""
|
||||
|
||||
async def get_model_sources(self, request: web.Request) -> web.Response:
|
||||
"""List the external model sites the UI can link a model to.
|
||||
|
||||
Used by the "Link Model" dialog to validate URLs client-side, to
|
||||
explain which sites support AI metadata enrichment, and to pick the
|
||||
right download endpoint/revision.
|
||||
"""
|
||||
|
||||
return web.json_response([
|
||||
{
|
||||
"platform": source.platform,
|
||||
"label": source.label,
|
||||
"supports_enrichment": source.supports_enrichment,
|
||||
"supports_download": source.supports_download,
|
||||
"default_revision": source.default_revision,
|
||||
"example_url": source.canonical_url(
|
||||
"user/repo" if source.platform != "tensorart" else "827823520299086029"
|
||||
),
|
||||
}
|
||||
for source in list_sources()
|
||||
])
|
||||
|
||||
async def set_hf_url(self, request: web.Request) -> web.Response:
|
||||
"""Link a model file to its page on an external model site.
|
||||
|
||||
Accepts ``source_url`` (preferred) or the legacy ``hf_url`` / ``url``
|
||||
payload key. Every registered site is recognised and the platform is
|
||||
stored alongside the canonical URL. TensorArt models can be linked and
|
||||
browsed, but not AI-enriched.
|
||||
|
||||
The route path keeps its historical ``set-hf-url`` name.
|
||||
"""
|
||||
|
||||
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()
|
||||
raw_url = (
|
||||
payload.get("source_url")
|
||||
or payload.get("hf_url")
|
||||
or payload.get("url")
|
||||
or ""
|
||||
)
|
||||
source_url = raw_url.strip() if isinstance(raw_url, str) else ""
|
||||
|
||||
if not file_path or not source_url:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing required fields: 'file_path' and 'source_url'",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
ref = detect_source(source_url, strict=True)
|
||||
if ref is None:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"Unsupported model URL. Supported formats: "
|
||||
+ ", ".join(
|
||||
f"{s.label} ({s.canonical_url('user/repo')})"
|
||||
if s.platform != "tensorart"
|
||||
else f"{s.label} (https://tensor.art/models/<id>)"
|
||||
for s in list_sources()
|
||||
)
|
||||
),
|
||||
},
|
||||
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 a model source.",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
existing = await MetadataManager.load_metadata_payload(file_path)
|
||||
|
||||
already_linked = (
|
||||
(existing.get("source_url") or "").strip() == ref.url
|
||||
and (existing.get("source_platform") or "").strip().lower()
|
||||
== ref.platform
|
||||
) or (
|
||||
not existing.get("source_url")
|
||||
and ref.platform == "huggingface"
|
||||
and (existing.get("hf_url") or "").strip() == ref.url
|
||||
)
|
||||
if already_linked:
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": "source_url already set",
|
||||
"source_url": ref.url,
|
||||
"source_platform": ref.platform,
|
||||
"hf_url": ref.url if ref.platform == "huggingface" else "",
|
||||
})
|
||||
|
||||
existing["source_url"] = ref.url
|
||||
existing["source_platform"] = ref.platform
|
||||
if ref.platform == "huggingface":
|
||||
existing["hf_url"] = ref.url
|
||||
else:
|
||||
existing.pop("hf_url", None)
|
||||
normalize_metadata_source(existing)
|
||||
|
||||
# NOTE: deliberately do NOT touch `from_civitai` here. It records
|
||||
# where the metadata came from, and the UI must show the CivitAI
|
||||
# link whenever CivitAI data is present — linking an external
|
||||
# source must not hide it (#1094). Source provenance is tracked
|
||||
# via `source_platform` / `source_url`.
|
||||
await MetadataManager.save_metadata(file_path, existing)
|
||||
|
||||
await _add_to_scanner_cache(file_path, existing)
|
||||
|
||||
logger.info(
|
||||
"Linked %s to %s source (%s)", file_path, ref.platform, ref.url
|
||||
)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Linked to {ref.url}",
|
||||
"source_url": ref.url,
|
||||
"source_platform": ref.platform,
|
||||
"hf_url": existing.get("hf_url", ""),
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.error("Failed to link %s to a model source: %s", file_path, exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def list_model_source_files(self, request: web.Request) -> web.Response:
|
||||
"""List the downloadable weight files of an external repository.
|
||||
|
||||
Query params: ``platform``, ``repo`` (``owner/name``), ``revision``
|
||||
(optional; each site has its own default branch).
|
||||
|
||||
Returns a JSON array of ``{"filename", "size"}``, largest first —
|
||||
the same shape the Hugging Face endpoint has always returned.
|
||||
"""
|
||||
|
||||
platform = (request.query.get("platform") or "").strip()
|
||||
repo = (request.query.get("repo") or "").strip()
|
||||
revision = (request.query.get("revision") or "").strip()
|
||||
|
||||
source = get_download_source(platform)
|
||||
if source is None:
|
||||
return _unsupported_platform_error(platform)
|
||||
if not is_valid_source_id(repo):
|
||||
return web.json_response(
|
||||
{"error": "Missing or invalid 'repo' parameter (expected owner/name)"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
files = await source.list_files(repo, revision)
|
||||
except ModelSourceError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=exc.status)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to list %s files in %s: %s", platform, repo, exc)
|
||||
return web.json_response({"error": str(exc)}, status=502)
|
||||
|
||||
return web.json_response(files)
|
||||
|
||||
async def download_model_source(self, request: web.Request) -> web.Response:
|
||||
"""Download a single file from an external repository.
|
||||
|
||||
POST JSON body::
|
||||
|
||||
{
|
||||
"platform": "modelscope",
|
||||
"repo": "owner/name",
|
||||
"filename": "subdir/model.safetensors",
|
||||
"revision": "master",
|
||||
"model_root": "loras",
|
||||
"relative_path": "",
|
||||
"use_default_paths": false,
|
||||
"download_id": "optional-batch-id"
|
||||
}
|
||||
|
||||
``platform`` defaults to ``huggingface`` when omitted, which keeps the
|
||||
legacy ``/api/lm/download-hf-model`` payload working unchanged.
|
||||
|
||||
If ``download_id`` is provided, real-time progress (bytes, speed,
|
||||
percentage) is broadcast via the WebSocket progress system.
|
||||
|
||||
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)
|
||||
|
||||
platform = (payload.get("platform") or "huggingface").strip()
|
||||
repo = (payload.get("repo") or "").strip()
|
||||
filename = (payload.get("filename") or "").strip()
|
||||
revision = (payload.get("revision") or "").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_model_source: platform=%s repo=%s file=%s root=%s download_id=%s",
|
||||
platform, repo, filename, model_root, download_id,
|
||||
)
|
||||
|
||||
source = get_download_source(platform)
|
||||
if source is None:
|
||||
return _unsupported_platform_error(platform)
|
||||
|
||||
if not repo or not filename:
|
||||
return web.json_response(
|
||||
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
|
||||
)
|
||||
|
||||
# `owner/name` only; the components become path segments below.
|
||||
if not is_valid_source_id(repo):
|
||||
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
|
||||
# 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_source_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, source.default_subdir, owner, repo_name)
|
||||
elif relative_path:
|
||||
target_dir = os.path.join(base_dir, relative_path)
|
||||
else:
|
||||
target_dir = base_dir
|
||||
|
||||
# Strip the repository sub-directory — "diffusion_models/xxx.safetensors"
|
||||
# is a repository 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)
|
||||
|
||||
# Built per request: sites that redirect to a CDN hand out a
|
||||
# time-limited token in the redirect, so the URL must never be cached.
|
||||
resolve_url = source.file_download_url(repo, filename, revision)
|
||||
ref = SourceRef(
|
||||
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
|
||||
)
|
||||
|
||||
# Check if already exists (simple skip)
|
||||
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
|
||||
logger.info("download_model_source: file already exists, skipping — %s", dest_path)
|
||||
# The sidecar may predate the source metadata being fetched, or may
|
||||
# have been deleted, so top it up instead of skipping past it.
|
||||
# Hydration no-ops when there is no sidecar to update.
|
||||
await _report_phase(download_id, "source", source.platform)
|
||||
await hydrate_from_source(dest_path, ref=ref)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"File already exists: {dest_path}",
|
||||
"path": dest_path,
|
||||
})
|
||||
|
||||
# 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"{source.platform}_{repo}_{filename}"
|
||||
try:
|
||||
ok, result = await aria2.download_file(
|
||||
url=resolve_url,
|
||||
save_path=dest_path,
|
||||
download_id=aid,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
if ok:
|
||||
await _save_source_metadata(
|
||||
dest_path, ref, model_root, download_id=download_id
|
||||
)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Downloaded to {dest_path}",
|
||||
"path": dest_path,
|
||||
})
|
||||
return web.json_response(
|
||||
{"success": False, "error": result or "aria2 download failed"},
|
||||
status=500,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("%s download (aria2) failed: %s", platform, 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_source_metadata(
|
||||
dest_path, ref, model_root, download_id=download_id
|
||||
)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Downloaded to {result}",
|
||||
"path": result,
|
||||
})
|
||||
return web.json_response(
|
||||
{"success": False, "error": result or "Download failed"},
|
||||
status=500,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("%s download failed: %s", platform, exc)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
@@ -35,6 +35,7 @@ _MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
|
||||
"loras": "get_lora_scanner",
|
||||
"checkpoints": "get_checkpoint_scanner",
|
||||
"embeddings": "get_embedding_scanner",
|
||||
"other": "get_other_scanner",
|
||||
}
|
||||
|
||||
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
||||
|
||||
@@ -74,6 +74,26 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
|
||||
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
|
||||
|
||||
|
||||
async def _parse_relaxed_flag(request: web.Request) -> bool:
|
||||
"""Read the relaxed-rematch flag from the JSON body or query string.
|
||||
|
||||
The flag defaults to False (strict candidacy). A JSON body value wins;
|
||||
``?relaxed=true`` is honored as a fallback so GET-only clients can opt
|
||||
in. Body parse failures (empty/invalid JSON) are treated as "no flag".
|
||||
"""
|
||||
relaxed = False
|
||||
if request.can_read_body:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception: # noqa: BLE001 - any parse failure means no flag
|
||||
data = None
|
||||
if isinstance(data, dict):
|
||||
relaxed = bool(data.get("relaxed"))
|
||||
if not relaxed:
|
||||
relaxed = request.query.get("relaxed", "").lower() == "true"
|
||||
return relaxed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecipeHandlerSet:
|
||||
"""Group of handlers providing recipe route implementations."""
|
||||
@@ -812,6 +832,8 @@ class RecipeManagementHandler:
|
||||
|
||||
recipe_scanner.reset_cancellation()
|
||||
|
||||
relaxed = await _parse_relaxed_flag(request)
|
||||
|
||||
async def progress_callback(data):
|
||||
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
||||
|
||||
@@ -819,7 +841,8 @@ class RecipeManagementHandler:
|
||||
async def run_rematch():
|
||||
try:
|
||||
await recipe_scanner.rematch_all_recipes(
|
||||
progress_callback=progress_callback
|
||||
progress_callback=progress_callback,
|
||||
relaxed=relaxed,
|
||||
)
|
||||
except Exception as e:
|
||||
self._logger.error(
|
||||
@@ -892,7 +915,13 @@ class RecipeManagementHandler:
|
||||
status=400,
|
||||
)
|
||||
|
||||
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
|
||||
relaxed = bool(data.get("relaxed")) or (
|
||||
request.query.get("relaxed", "").lower() == "true"
|
||||
)
|
||||
|
||||
result = await recipe_scanner.rematch_recipes_bulk(
|
||||
recipe_ids, relaxed=relaxed
|
||||
)
|
||||
return web.json_response(result)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
@@ -921,7 +950,10 @@ class RecipeManagementHandler:
|
||||
)
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
|
||||
relaxed = await _parse_relaxed_flag(request)
|
||||
result = await recipe_scanner.rematch_recipe_by_id(
|
||||
recipe_id, relaxed=relaxed
|
||||
)
|
||||
return web.json_response(result)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
@@ -3092,6 +3124,12 @@ class RecipeWorkflowHandler:
|
||||
class BatchImportHandler:
|
||||
"""Handle batch import operations for recipes."""
|
||||
|
||||
# Virtual path token for the Windows drive list. Browsing up from a drive
|
||||
# root (e.g. C:\) lands here so users can switch drives without typing a
|
||||
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
||||
# path handling and fails the existence check.
|
||||
WINDOWS_DRIVES_TOKEN = "__drives__"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -3265,31 +3303,27 @@ class BatchImportHandler:
|
||||
data = await request.json()
|
||||
directory_path = data.get("path", "")
|
||||
|
||||
if not directory_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Directory path is required"},
|
||||
status=400,
|
||||
)
|
||||
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
|
||||
return self._windows_drives_response()
|
||||
|
||||
# Normalize the path
|
||||
# Default to the user's home directory. The frontend previously
|
||||
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
||||
# resolves to the current drive root and then fails the access
|
||||
# check below.
|
||||
if not directory_path:
|
||||
path = Path.home()
|
||||
else:
|
||||
path = Path(directory_path).expanduser().resolve()
|
||||
|
||||
# Security check: ensure path is within allowed directories
|
||||
# Allow common image/model directories
|
||||
allowed_roots = [
|
||||
Path.home(),
|
||||
Path("/"), # Allow browsing from root for flexibility
|
||||
]
|
||||
|
||||
# Check if path is within any allowed root
|
||||
is_allowed = False
|
||||
for root in allowed_roots:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
is_allowed = True
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
# Access check: browsing intentionally covers the whole server
|
||||
# filesystem (the server operator browses their own machine). On
|
||||
# POSIX every absolute path is under "/", but Path("/") has no
|
||||
# drive letter on Windows and can never anchor a drive-qualified
|
||||
# path in relative_to(), so test for a drive there instead.
|
||||
if os.name == "nt":
|
||||
is_allowed = bool(path.drive)
|
||||
else:
|
||||
is_allowed = path.is_absolute()
|
||||
|
||||
if not is_allowed:
|
||||
return web.json_response(
|
||||
@@ -3356,15 +3390,24 @@ class BatchImportHandler:
|
||||
directories.sort(key=lambda x: x["name"].lower())
|
||||
image_files.sort(key=lambda x: x["name"].lower())
|
||||
|
||||
# Add parent directory if not at root
|
||||
parent_path = path.parent
|
||||
show_parent = str(path) != str(path.root)
|
||||
# Parent directory. A filesystem root is its own parent
|
||||
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||
# drive root (C:\) links up to the virtual drive list so users
|
||||
# can switch drives. The previous str(path) != str(path.root)
|
||||
# check misfired on Windows, where a drive root's parent is
|
||||
# itself, producing an infinite self-loop.
|
||||
if path.parent == path:
|
||||
parent_path = (
|
||||
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||
)
|
||||
else:
|
||||
parent_path = str(path.parent)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"current_path": str(path),
|
||||
"parent_path": str(parent_path) if show_parent else None,
|
||||
"parent_path": parent_path,
|
||||
"directories": directories,
|
||||
"image_files": image_files,
|
||||
"image_count": len(image_files),
|
||||
@@ -3391,3 +3434,30 @@ class BatchImportHandler:
|
||||
except Exception as exc:
|
||||
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
def _windows_drives_response(self) -> web.Response:
|
||||
"""List available drive letters as a virtual directory (Windows only)."""
|
||||
try:
|
||||
drives = os.listdrives()
|
||||
except AttributeError: # Python < 3.12
|
||||
drives = [
|
||||
f"{letter}:\\"
|
||||
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
if os.path.exists(f"{letter}:\\")
|
||||
]
|
||||
directories = [
|
||||
{"name": drive, "path": drive, "is_parent": False} for drive in drives
|
||||
]
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
# Empty current_path marks the virtual level; the frontend
|
||||
# disables folder selection there.
|
||||
"current_path": "",
|
||||
"parent_path": None,
|
||||
"directories": directories,
|
||||
"image_files": [],
|
||||
"image_count": 0,
|
||||
"directory_count": len(directories),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -99,16 +99,31 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||
),
|
||||
# Hugging Face model endpoints
|
||||
# External model source endpoints (Hugging Face / ModelScope).
|
||||
# The hf-* paths are the historical names, kept as aliases.
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/model-source-files", "list_model_source_files"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||
),
|
||||
# Download target routing decision (checkpoint vs diffusion model roots)
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download/routing", "get_download_routing"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-model-source", "download_model_source"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/set-hf-url", "set_hf_url"
|
||||
),
|
||||
# Supported external model sites (Hugging Face / ModelScope / TensorArt)
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/model-sources", "get_model_sources"
|
||||
),
|
||||
# Agent skill endpoints
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/agent/skills", "get_agent_skills"
|
||||
|
||||
@@ -39,8 +39,9 @@ from .handlers.misc_handlers import (
|
||||
build_service_registry_adapter,
|
||||
)
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
from .handlers.hf_handlers import HfHandler
|
||||
from .handlers.model_source_handlers import ModelSourceHandler
|
||||
from .handlers.agent_handlers import AgentHandler
|
||||
from .handlers.download_routing_handlers import DownloadRoutingHandler
|
||||
from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -138,8 +139,9 @@ class MiscRoutes:
|
||||
doctor = DoctorHandler(settings_service=self._settings)
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
base_model = BaseModelHandlerSet()
|
||||
hf_handler = HfHandler()
|
||||
model_source_handler = ModelSourceHandler()
|
||||
agent_handler = AgentHandler()
|
||||
download_routing = DownloadRoutingHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -159,8 +161,9 @@ class MiscRoutes:
|
||||
doctor=doctor,
|
||||
example_workflows=example_workflows,
|
||||
base_model=base_model,
|
||||
hf_handler=hf_handler,
|
||||
model_source_handler=model_source_handler,
|
||||
agent_handler=agent_handler,
|
||||
download_routing=download_routing,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/verify-duplicates", "verify_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/move_model", "move_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/create-folder", "create_folder"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/delete-folder", "delete_folder"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/rename-folder", "rename_folder"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
|
||||
RouteDefinition(
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
from aiohttp import web
|
||||
|
||||
from .base_model_routes import BaseModelRoutes
|
||||
from .model_route_registrar import ModelRouteRegistrar
|
||||
from ..config import config
|
||||
from ..services.other_model_service import OtherModelService
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.constants import (
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||
VALID_OTHER_CIVITAI_TYPES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OtherRoutes(BaseModelRoutes):
|
||||
"""Other-model-specific route controller (VAE, upscaler, text encoder, ...)"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Other-model routes with OtherModel service"""
|
||||
super().__init__()
|
||||
self.template_name = "other.html"
|
||||
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = OtherModelService(other_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
def setup_routes(self, app: web.Application, prefix: str = "other"):
|
||||
"""Setup Other-model routes"""
|
||||
# Schedule service initialization on app startup
|
||||
app.on_startup.append(lambda _: self.initialize_services())
|
||||
|
||||
# Setup common routes with 'other' prefix (includes page route)
|
||||
super().setup_routes(app, prefix)
|
||||
|
||||
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||
"""Setup Other-model-specific routes"""
|
||||
# Other-model info by name
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_other_model_info)
|
||||
# Other-model roots grouped by sub_type (text_encoders + legacy clip
|
||||
# are aggregated under text_encoder)
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/roots_by_subtype', prefix, self.get_roots_by_subtype)
|
||||
|
||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||
"""Validate CivitAI model type for other models.
|
||||
|
||||
Accepts retired CivitAI types (CLIP, CLIPVision) as well — grandfathered
|
||||
models on CivitAI still carry them. Types whose sub_type is currently
|
||||
disabled (or every type while the opt-in feature is off) are rejected.
|
||||
"""
|
||||
normalized = (model_type or "").strip().lower()
|
||||
if normalized not in VALID_OTHER_CIVITAI_TYPES:
|
||||
return False
|
||||
if not self._settings.is_other_models_enabled():
|
||||
return False
|
||||
|
||||
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized)
|
||||
if sub_type is None:
|
||||
# CivitAI "Other" has no sub_type of its own; it is only usable
|
||||
# while at least one sub_type is enabled.
|
||||
return bool(self._settings.get_enabled_other_sub_types())
|
||||
return self._settings.is_other_sub_type_enabled(sub_type)
|
||||
|
||||
def _get_page_context_provider(self):
|
||||
"""Expose the opt-in feature state to the Other Models page template."""
|
||||
return self._page_context_for_other
|
||||
|
||||
def _page_context_for_other(self, request: web.Request) -> Dict[str, Any]:
|
||||
if not self._settings.is_other_models_enabled():
|
||||
return {"other_disabled": True, "other_no_paths": False}
|
||||
|
||||
# Enabled but nothing to scan: folder paths for the managed sub_types
|
||||
# resolved to no existing folder. Render an actionable empty state
|
||||
# instead of an apparently broken empty grid.
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||
context = {
|
||||
"other_disabled": False,
|
||||
"other_no_paths": not bool(config.other_roots),
|
||||
"standalone_mode": standalone_mode,
|
||||
}
|
||||
if standalone_mode:
|
||||
# The settings UI cannot edit primary folder_paths, so the empty
|
||||
# state must point at the actual file the user has to edit.
|
||||
context["settings_file"] = getattr(self._settings, "settings_file", "") or ""
|
||||
return context
|
||||
|
||||
def _get_expected_model_types(self) -> str:
|
||||
"""Get expected model types string for error messages"""
|
||||
return "VAE, Upscaler, TextEncoder, CLIPVision, Controlnet, or Other"
|
||||
|
||||
def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Parse other-model-specific parameters (none in Phase 1)."""
|
||||
return {}
|
||||
|
||||
async def get_roots_by_subtype(self, request: web.Request) -> web.Response:
|
||||
"""Return other-model roots grouped by sub_type.
|
||||
|
||||
Aggregates the per-folder_paths-key roots from config
|
||||
(``text_encoders`` and the legacy ``clip`` key both land under
|
||||
``text_encoder``).
|
||||
"""
|
||||
try:
|
||||
roots_by_subtype: Dict[str, List[str]] = {}
|
||||
for key, roots in (config.other_folder_roots or {}).items():
|
||||
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
|
||||
if not sub_type:
|
||||
continue
|
||||
bucket = roots_by_subtype.setdefault(sub_type, [])
|
||||
for root in roots:
|
||||
if root and root not in bucket:
|
||||
bucket.append(root)
|
||||
return web.json_response(
|
||||
{"success": True, "roots_by_subtype": roots_by_subtype}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting other roots by sub_type: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e)}, status=500
|
||||
)
|
||||
|
||||
async def get_other_model_info(self, request: web.Request) -> web.Response:
|
||||
"""Get detailed information for a specific other model by name"""
|
||||
try:
|
||||
name = request.match_info.get('name', '')
|
||||
model_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
if model_info:
|
||||
return web.json_response(model_info)
|
||||
else:
|
||||
return web.json_response({"error": "Model not found"}, status=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_other_model_info: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -21,7 +21,20 @@ NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||
# 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')
|
||||
# ``cache`` covers the resolved cache tree (cache/model, cache/recipe,
|
||||
# cache/fts, ...); the legacy ``recipe_cache`` / ``model_cache`` directories
|
||||
# are listed too because a portable install can predate the cache/ move.
|
||||
_PRESERVE_DIRS = (
|
||||
'settings.json',
|
||||
'civitai',
|
||||
'wildcards',
|
||||
'backups',
|
||||
'stats',
|
||||
'logs',
|
||||
'cache',
|
||||
'model_cache',
|
||||
'recipe_cache',
|
||||
)
|
||||
|
||||
|
||||
def _clean_excludes() -> List[str]:
|
||||
|
||||
@@ -19,16 +19,21 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
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 ..model_sources import (
|
||||
ModelCardContext,
|
||||
ModelSourceCache,
|
||||
get_source,
|
||||
resolve_source_ref,
|
||||
source_label,
|
||||
)
|
||||
from ..model_sources.hydration import load_model_card, resolve_site_base_model
|
||||
from ..websocket_manager import ws_manager
|
||||
from .post_processor import PostProcessor
|
||||
from .skill_registry import SkillRegistry
|
||||
@@ -255,6 +260,11 @@ class AgentService:
|
||||
llm = await self._ensure_llm()
|
||||
llm_configured = llm.is_configured() if skill.llm_required else True
|
||||
|
||||
# A collection repository holds many model files under one source id;
|
||||
# this memo keeps the README and the repository metadata from being
|
||||
# re-fetched once per file. It lives for this run only.
|
||||
source_cache = ModelSourceCache()
|
||||
|
||||
for model_path in model_paths:
|
||||
model_filename = os.path.basename(model_path)
|
||||
logger.info(
|
||||
@@ -267,24 +277,50 @@ class AgentService:
|
||||
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", ""):
|
||||
# Fast-fail: enrich_hf_metadata needs an external model source
|
||||
# that exposes an accessible model card.
|
||||
if skill_name == "enrich_hf_metadata":
|
||||
skip_reason = self._enrichment_skip_reason(metadata)
|
||||
if skip_reason:
|
||||
logger.info(
|
||||
"[%s] SKIP %s — no hf_url in metadata",
|
||||
skill_name, model_filename,
|
||||
"[%s] SKIP %s — %s",
|
||||
skill_name, model_filename, skip_reason,
|
||||
)
|
||||
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,
|
||||
# The site's own data is deterministic and must land whether
|
||||
# or not an LLM is available: a user without a key still gets
|
||||
# the author summary, the example images and the tags.
|
||||
source_vars, source_context = await self._load_source_card(
|
||||
model_path, metadata, cache=source_cache,
|
||||
)
|
||||
resolved_base_model = ""
|
||||
if skill_name == "enrich_hf_metadata" and not (
|
||||
metadata.get("base_model") or ""
|
||||
).strip():
|
||||
resolved_base_model = await self._resolve_site_base_model(
|
||||
source_context,
|
||||
)
|
||||
|
||||
llm_response: Optional[Dict[str, Any]] = None
|
||||
if skill.llm_required and llm_configured:
|
||||
if skill.llm_required and not llm_configured:
|
||||
# Without a provider the deterministic model-source data
|
||||
# still lands; the LLM-only fields simply stay untouched.
|
||||
logger.info(
|
||||
"[%s] No LLM configured for %s — applying %s data only",
|
||||
skill_name, model_filename,
|
||||
"model-source"
|
||||
if not source_context.is_empty()
|
||||
else "README",
|
||||
)
|
||||
elif skill.llm_required:
|
||||
prompt_vars = await self._build_prompt_context(
|
||||
skill_name, model_path, metadata, registry, llm,
|
||||
source_vars=source_vars,
|
||||
source_context=source_context,
|
||||
)
|
||||
prompt_template = registry.load_prompt(skill_name)
|
||||
rendered = _render_prompt(prompt_template, prompt_vars)
|
||||
llm_response = await llm.chat_completion_json(
|
||||
@@ -307,7 +343,9 @@ class AgentService:
|
||||
model_path=model_path,
|
||||
llm_output=llm_response or {},
|
||||
metadata=metadata,
|
||||
readme_content=prompt_vars.get("readme_content_full", ""),
|
||||
readme_content=source_vars.get("readme_content_full", ""),
|
||||
source_context=source_context,
|
||||
resolved_base_model=resolved_base_model,
|
||||
)
|
||||
|
||||
if model_result.get("success", True):
|
||||
@@ -358,6 +396,28 @@ class AgentService:
|
||||
# Base model grouping (keeps the prompt compact)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _enrichment_skip_reason(metadata: Dict[str, Any]) -> str:
|
||||
"""Return why ``enrich_hf_metadata`` cannot run, or ``""`` if it can.
|
||||
|
||||
Distinguishes the three cases the user can act on: no source linked,
|
||||
a source we don't know, and a known source whose model card is not
|
||||
reachable from the backend (TensorArt).
|
||||
"""
|
||||
|
||||
ref = resolve_source_ref(metadata)
|
||||
if ref is None:
|
||||
return "no model source linked (source_url missing)"
|
||||
source = get_source(ref.platform)
|
||||
if source is None:
|
||||
return f"unsupported model source platform '{ref.platform}'"
|
||||
if not source.supports_enrichment:
|
||||
return (
|
||||
f"{source.label} does not expose a model card to the backend; "
|
||||
"AI metadata enrichment is not available for this source"
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_base_models(models: List[str]) -> str:
|
||||
"""Format the base model list as a flat, one-per-line list.
|
||||
@@ -368,6 +428,82 @@ class AgentService:
|
||||
"""
|
||||
return "\n".join(f"- {m}" for m in models)
|
||||
|
||||
async def _load_source_card(
|
||||
self,
|
||||
model_path: str,
|
||||
metadata: Dict[str, Any],
|
||||
*,
|
||||
cache: Optional[ModelSourceCache] = None,
|
||||
) -> tuple[Dict[str, Any], ModelCardContext]:
|
||||
"""Fetch the model card and site-published extras for one model.
|
||||
|
||||
Runs for every source-backed enrichment regardless of LLM
|
||||
availability, because everything it returns is deterministic data that
|
||||
should be applied even without a configured provider.
|
||||
|
||||
*cache* is the per-run memo created by :meth:`execute_skill`. The
|
||||
README is repository-wide, so it is fetched once per source id; only
|
||||
successful reads are memoised, leaving a transient failure to be
|
||||
retried for the next file.
|
||||
"""
|
||||
|
||||
variables: Dict[str, Any] = {
|
||||
"asset_base_url": "",
|
||||
"source_description": "",
|
||||
"source_base_model": "",
|
||||
"source_official_tags": "",
|
||||
"source_example_images": "",
|
||||
"source_trigger_words": "",
|
||||
"readme_content": "(README not available)",
|
||||
"readme_content_full": "",
|
||||
}
|
||||
|
||||
ref = resolve_source_ref(metadata)
|
||||
source = get_source(ref.platform) if ref is not None else None
|
||||
if ref is None or source is None or not source.supports_enrichment:
|
||||
return variables, ModelCardContext()
|
||||
|
||||
raw_basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
variables["asset_base_url"] = source.asset_base_url(ref.source_id)
|
||||
|
||||
readme = await load_model_card(source, ref.source_id, cache)
|
||||
|
||||
# Sites such as ModelScope keep part of the model card outside the
|
||||
# README (author summary, curated tags, per-file example images). The
|
||||
# recorded hash identifies the file even after the user renames it.
|
||||
card_context = await source.fetch_model_card_context(
|
||||
ref.source_id,
|
||||
os.path.basename(model_path),
|
||||
sha256=(metadata.get("sha256") or "").strip(),
|
||||
cache=cache,
|
||||
)
|
||||
variables["source_description"] = card_context.description
|
||||
variables["source_base_model"] = card_context.base_model
|
||||
variables["source_official_tags"] = "\n".join(
|
||||
f"- {tag}" for tag in card_context.official_tags
|
||||
)
|
||||
variables["source_example_images"] = "\n".join(
|
||||
f"- {url}" for url in card_context.example_images
|
||||
)
|
||||
variables["source_trigger_words"] = ", ".join(card_context.trigger_words)
|
||||
|
||||
# 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 ""
|
||||
variables["readme_content"] = cleaned if cleaned else "(README not available)"
|
||||
variables["readme_content_full"] = readme or ""
|
||||
|
||||
return variables, card_context
|
||||
|
||||
async def _resolve_site_base_model(self, source_context: ModelCardContext) -> str:
|
||||
"""Resolve the site's base-model hints to a canonical name, or ``""``."""
|
||||
|
||||
return await resolve_site_base_model(source_context)
|
||||
|
||||
async def _build_prompt_context(
|
||||
self,
|
||||
skill_name: str,
|
||||
@@ -375,19 +511,45 @@ class AgentService:
|
||||
metadata: Dict[str, Any],
|
||||
registry: SkillRegistry,
|
||||
llm: Any,
|
||||
*,
|
||||
source_vars: Optional[Dict[str, Any]] = None,
|
||||
source_context: Optional[ModelCardContext] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gather variables for the skill's prompt template.
|
||||
|
||||
Reads metadata, fetches the HF README (if applicable), lists available
|
||||
Reads metadata, fetches the model card (unless a pre-fetched
|
||||
*source_vars* / *source_context* pair is supplied), 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
|
||||
|
||||
if source_vars is None or source_context is None:
|
||||
source_vars, source_context = await self._load_source_card(
|
||||
model_path, metadata,
|
||||
)
|
||||
|
||||
context: Dict[str, Any] = {
|
||||
"model_path": model_path,
|
||||
"model_basename": "",
|
||||
# Canonical external-source variables
|
||||
"source_url": "",
|
||||
"source_id": "",
|
||||
"source_platform": "",
|
||||
"source_label": "",
|
||||
"asset_base_url": "",
|
||||
# Site-provided card extras (see ModelSource.fetch_model_card_context)
|
||||
"source_description": "",
|
||||
"source_base_model": "",
|
||||
"source_official_tags": "",
|
||||
"source_example_images": "",
|
||||
"source_trigger_words": "",
|
||||
# Carrier for the structured context handed to the post-processor;
|
||||
# never rendered into the prompt.
|
||||
"source_context": ModelCardContext(),
|
||||
# Legacy Hugging Face aliases (kept so older prompt templates and
|
||||
# third-party skills keep rendering)
|
||||
"hf_url": "",
|
||||
"repo": "",
|
||||
"readme_content": "",
|
||||
@@ -407,26 +569,33 @@ class AgentService:
|
||||
"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 ""
|
||||
ref = resolve_source_ref(metadata)
|
||||
if ref is not None:
|
||||
context["source_url"] = ref.url
|
||||
context["source_id"] = ref.source_id
|
||||
context["source_platform"] = ref.platform
|
||||
context["source_label"] = source_label(ref.platform, ref.platform)
|
||||
if ref.platform == "huggingface":
|
||||
context["hf_url"] = ref.url
|
||||
context["repo"] = ref.source_id
|
||||
|
||||
source = get_source(ref.platform) if ref is not None else None
|
||||
if ref is not None and source is not None and source.supports_enrichment:
|
||||
# Values fetched once by _load_source_card and shared with the
|
||||
# post-processor, so the network is not hit twice per model.
|
||||
context["asset_base_url"] = source_vars["asset_base_url"]
|
||||
context["source_context"] = source_context
|
||||
context["source_description"] = source_vars["source_description"]
|
||||
context["source_base_model"] = source_vars["source_base_model"]
|
||||
context["source_official_tags"] = source_vars["source_official_tags"]
|
||||
context["source_example_images"] = source_vars["source_example_images"]
|
||||
context["source_trigger_words"] = source_vars["source_trigger_words"]
|
||||
context["readme_content"] = source_vars["readme_content"]
|
||||
context["readme_content_full"] = source_vars["readme_content_full"]
|
||||
|
||||
try:
|
||||
raw_models = await list_base_models()
|
||||
@@ -459,20 +628,14 @@ class AgentService:
|
||||
|
||||
@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 ""
|
||||
"""Fetch a Hugging Face README (tries ``main``, then ``master``).
|
||||
|
||||
Kept for backward compatibility; new code should go through the
|
||||
model-source registry so every supported site works.
|
||||
"""
|
||||
from ..model_sources import HuggingFaceSource
|
||||
|
||||
return await HuggingFaceSource().fetch_model_card(repo)
|
||||
|
||||
async def _emit_progress(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Map a site-reported base model onto this system's canonical vocabulary.
|
||||
|
||||
Model sites name base models in their own terms: ModelScope publishes
|
||||
``krea/Krea-2-Turbo`` and ``KREA_2_TURBO`` where this system expects the
|
||||
canonical ``Krea 2``. Turning one into the other is normally the LLM's job;
|
||||
this module resolves the cases that can be decided safely so the canonical
|
||||
field is still populated when the LLM returns nothing usable for it.
|
||||
|
||||
The resolver is deliberately strict, because a wrong base model written with
|
||||
apparent authority is worse than no value at all:
|
||||
|
||||
* it only ever returns a name that is already present in *known_names*;
|
||||
* matching is on the normalised form (lowercased, non-alphanumerics removed),
|
||||
so separators and casing are ignored but nothing is inferred;
|
||||
* a bounded set of published variant suffixes may be stripped, and only when
|
||||
the remainder still matches a known name exactly.
|
||||
|
||||
Anything it cannot decide returns ``""``, and the caller falls back to the LLM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
#: Variant suffixes sites append to a base-model *family* name. Stripping one
|
||||
#: is only attempted when the remainder matches a known name exactly, so an
|
||||
#: unrecognised suffix can never produce a bogus match.
|
||||
_VARIANT_SUFFIXES: tuple[str, ...] = (
|
||||
"turbo",
|
||||
"schnell",
|
||||
"lightning",
|
||||
"dev",
|
||||
"beta",
|
||||
"alpha",
|
||||
)
|
||||
|
||||
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def _normalize(value: str) -> str:
|
||||
"""Return the comparison form of *value*.
|
||||
|
||||
Lowercases and drops every non-alphanumeric character, so ``KREA_2``,
|
||||
``Krea 2``, ``krea-2`` and ``krea.2`` all collapse to ``krea2``.
|
||||
"""
|
||||
|
||||
return _NON_ALNUM.sub("", (value or "").lower())
|
||||
|
||||
|
||||
def resolve_base_model(
|
||||
hints: Iterable[str], known_names: Sequence[str]
|
||||
) -> str:
|
||||
"""Return the canonical base model that *hints* refers to, or ``""``.
|
||||
|
||||
Args:
|
||||
hints: Site-reported names, best first (e.g. an architecture enum
|
||||
before a link-style repository id).
|
||||
known_names: The canonical vocabulary; only these are ever returned.
|
||||
|
||||
Returns:
|
||||
One of *known_names*, or ``""`` when nothing matches exactly.
|
||||
"""
|
||||
|
||||
normalized: dict[str, str] = {}
|
||||
for name in known_names:
|
||||
key = _normalize(name)
|
||||
if key and key not in normalized:
|
||||
normalized[key] = name
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
ordered = [hint for hint in hints if hint]
|
||||
|
||||
# 1. Exact normalised match — the unambiguous case.
|
||||
for hint in ordered:
|
||||
candidate = _normalize(hint)
|
||||
if candidate in normalized:
|
||||
return normalized[candidate]
|
||||
|
||||
# 2. Drop one published variant suffix and retry exactly.
|
||||
for hint in ordered:
|
||||
candidate = _normalize(hint)
|
||||
for suffix in _VARIANT_SUFFIXES:
|
||||
if not candidate.endswith(suffix) or candidate == suffix:
|
||||
continue
|
||||
stem = candidate[: -len(suffix)]
|
||||
if stem in normalized:
|
||||
return normalized[stem]
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
__all__ = ["resolve_base_model"]
|
||||
@@ -10,12 +10,16 @@ refresh cache). All actual I/O is delegated to :mod:`~py.metadata_ops`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from ..model_sources import ModelCardContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,6 +46,9 @@ class PostProcessor:
|
||||
llm_output: Dict[str, Any],
|
||||
metadata: Dict[str, Any],
|
||||
readme_content: str = "",
|
||||
source_context: Optional["ModelCardContext"] = None,
|
||||
resolved_base_model: str = "",
|
||||
metadata_source: str = "agent:enrich_hf_metadata",
|
||||
) -> Dict[str, Any]:
|
||||
"""Route *llm_output* to the correct skill post-processor.
|
||||
|
||||
@@ -49,12 +56,26 @@ class PostProcessor:
|
||||
that is converted to HTML and stored as ``modelDescription`` for
|
||||
the description tab.
|
||||
|
||||
*source_context* carries the extras the model site publishes outside
|
||||
the README (author description, per-file example images, trigger
|
||||
words). It is ``None`` for callers that have none.
|
||||
|
||||
*resolved_base_model* is the canonical base-model name the site's own
|
||||
hints resolve to, used when the LLM did not supply one (which is the
|
||||
normal case when the LLM was skipped).
|
||||
|
||||
*metadata_source* records who produced the metadata. The AI skill
|
||||
keeps its historical value; the deterministic download-time hydration
|
||||
passes its own so the two remain distinguishable. ``llm_enriched_at``
|
||||
is only stamped when *llm_output* actually carries a provider answer.
|
||||
|
||||
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,
|
||||
model_path, llm_output, metadata, readme_content, source_context,
|
||||
resolved_base_model, metadata_source,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
@@ -72,12 +93,16 @@ class PostProcessor:
|
||||
llm_output: Dict[str, Any],
|
||||
metadata: Dict[str, Any],
|
||||
readme_content: str = "",
|
||||
source_context: Optional["ModelCardContext"] = None,
|
||||
resolved_base_model: str = "",
|
||||
metadata_source: str = "agent:enrich_hf_metadata",
|
||||
) -> Dict[str, Any]:
|
||||
from ...metadata_ops import (
|
||||
apply_metadata_updates,
|
||||
download_preview,
|
||||
refresh_cache,
|
||||
)
|
||||
from ..model_sources import get_source, has_external_source, resolve_source_ref
|
||||
from .skills.enrich_hf_metadata.readme_processor import (
|
||||
convert_readme_to_html,
|
||||
extract_gallery_images,
|
||||
@@ -85,24 +110,49 @@ class PostProcessor:
|
||||
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)
|
||||
# -- Determine whether this is an externally-sourced model ---------
|
||||
# Key off the source fields directly: `from_civitai` records provenance
|
||||
# and can be true for a model that is also linked to an external site
|
||||
# (both sources coexist, see #1094), so it must not gate enrichment.
|
||||
is_source_model = has_external_source(metadata)
|
||||
|
||||
source_ref = resolve_source_ref(metadata)
|
||||
source = get_source(source_ref.platform) if source_ref else None
|
||||
source_id = source_ref.source_id if source_ref else ""
|
||||
asset_base_url = (
|
||||
source.asset_base_url(source_id)
|
||||
if source is not None and source_id
|
||||
else None
|
||||
)
|
||||
|
||||
# -- Collect updates -----------------------------------------------
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
# base_model
|
||||
# base_model — the LLM's mapping wins; when it returned nothing usable,
|
||||
# fall back to the canonical name the site's own hints resolve to.
|
||||
new_base = (llm_output.get("base_model") or "").strip()
|
||||
if not new_base:
|
||||
new_base = (resolved_base_model or "").strip()
|
||||
current_base = metadata.get("base_model", "") or ""
|
||||
if new_base and self._should_overwrite(current_base, is_hf_model):
|
||||
if new_base and self._should_overwrite(current_base, is_source_model):
|
||||
updates["base_model"] = new_base
|
||||
|
||||
# model_name — the site's own display name, so a source download never
|
||||
# shows up under its local filename. Written only while the name is
|
||||
# still the untouched file stem: once a user renames a model that
|
||||
# choice is theirs to keep.
|
||||
site_name = ((source_context.model_name if source_context else "") or "").strip()
|
||||
if is_source_model and site_name:
|
||||
current_name = (metadata.get("model_name") or "").strip()
|
||||
file_stem = (metadata.get("file_name") or "").strip()
|
||||
if not current_name or current_name == file_stem:
|
||||
updates["model_name"] = site_name
|
||||
|
||||
# trigger words → civitai.trainedWords
|
||||
new_triggers = llm_output.get("trigger_words", [])
|
||||
trigger_words_empty = True
|
||||
@@ -110,45 +160,71 @@ class PostProcessor:
|
||||
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
|
||||
current_triggers = (metadata.get("civitai") or {}).get("trainedWords") or []
|
||||
if self._should_overwrite_list(current_triggers, is_source_model):
|
||||
self._merge_civitai(updates, metadata, trainedWords=cleaned)
|
||||
|
||||
# modelDescription — from raw README content (converted to HTML)
|
||||
if readme_content and is_hf_model:
|
||||
# modelDescription — the author's own summary (when the site keeps one
|
||||
# outside the README, e.g. ModelScope's ``Description``) followed by the
|
||||
# README converted to HTML.
|
||||
site_description = (
|
||||
(source_context.description if source_context else "") or ""
|
||||
).strip()
|
||||
if is_source_model and (site_description or readme_content):
|
||||
parts: List[str] = []
|
||||
if site_description:
|
||||
parts.append(f"<p>{html.escape(site_description)}</p>")
|
||||
if readme_content:
|
||||
converted = convert_readme_to_html(readme_content)
|
||||
if converted:
|
||||
updates["modelDescription"] = converted
|
||||
parts.append(converted)
|
||||
if parts:
|
||||
updates["modelDescription"] = "\n".join(parts)
|
||||
|
||||
# short_description → civitai.description (for "About this version")
|
||||
# short_description → civitai.description (for "About this version").
|
||||
# Falls back to the site's author summary, which for ModelScope AIGC
|
||||
# models is frequently the only human-written text available.
|
||||
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
|
||||
if not short_desc:
|
||||
short_desc = site_description
|
||||
if short_desc and is_source_model:
|
||||
self._merge_civitai(updates, metadata, description=short_desc)
|
||||
|
||||
# The version label completes the card the way a CivitAI download does:
|
||||
# the UI renders `civitai.name` as the version chip. It is per file,
|
||||
# so a collection repository shows that checkpoint's own label.
|
||||
site_version = (
|
||||
(source_context.version_name if source_context else "") or ""
|
||||
).strip()
|
||||
if is_source_model and site_version:
|
||||
self._merge_civitai(updates, metadata, name=site_version)
|
||||
|
||||
# gallery images → civitai.images (site example images, YAML frontmatter
|
||||
# widget entries, and Sample Gallery markdown tables in the README body)
|
||||
rec_width = llm_output.get("recommended_width") or 0
|
||||
rec_height = llm_output.get("recommended_height") or 0
|
||||
|
||||
# Example images the site publishes for *this* file. They are matched
|
||||
# by filename, so they are the most precise preview source available
|
||||
# and the only one for repositories whose README carries no images.
|
||||
site_images: List[Dict[str, Any]] = []
|
||||
if is_source_model and source_context is not None:
|
||||
site_images = [
|
||||
_example_image(url, rec_width, rec_height)
|
||||
for url in source_context.example_images
|
||||
if url
|
||||
]
|
||||
|
||||
# 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
|
||||
|
||||
if (readme_content or site_images) and is_source_model:
|
||||
repo = source_id
|
||||
readme_images: List[Dict[str, Any]] = []
|
||||
if readme_content and repo:
|
||||
# 1. Widget images (YAML frontmatter)
|
||||
gallery = extract_gallery_images(
|
||||
readme_content, repo,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
default_width=rec_width, default_height=rec_height,
|
||||
base_url=asset_base_url,
|
||||
)
|
||||
|
||||
# 2. Sample Gallery table images (markdown body), deduplicated
|
||||
@@ -156,7 +232,8 @@ class PostProcessor:
|
||||
table_images = extract_gallery_table_images(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
default_width=rec_width, default_height=rec_height,
|
||||
base_url=asset_base_url,
|
||||
)
|
||||
existing_urls.update(img["url"] for img in table_images if img.get("url"))
|
||||
|
||||
@@ -164,7 +241,8 @@ class PostProcessor:
|
||||
simple_images = extract_simple_markdown_images(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
default_width=rec_width, default_height=rec_height,
|
||||
base_url=asset_base_url,
|
||||
)
|
||||
existing_urls.update(img["url"] for img in simple_images if img.get("url"))
|
||||
|
||||
@@ -172,54 +250,71 @@ class PostProcessor:
|
||||
html_images = extract_html_img_tags(
|
||||
readme_content, repo,
|
||||
existing_urls=existing_urls,
|
||||
default_width=rec_w, default_height=rec_h,
|
||||
default_width=rec_width, default_height=rec_height,
|
||||
base_url=asset_base_url,
|
||||
)
|
||||
|
||||
all_images = gallery + table_images + simple_images + html_images
|
||||
readme_images = gallery + table_images + simple_images + html_images
|
||||
|
||||
# Site images come first so the preview fallback below prefers an
|
||||
# image that is known to belong to this exact file.
|
||||
all_images = _dedupe_images(site_images + readme_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
|
||||
self._merge_civitai(updates, metadata, images=all_images)
|
||||
|
||||
# tags
|
||||
# tags — the site's curated tags are authoritative content vocabulary, so
|
||||
# they are kept alongside whatever the LLM proposed (the LLM is skipped
|
||||
# entirely when the site data is complete, which is why this cannot rely
|
||||
# on ``llm_output`` alone).
|
||||
new_tags = llm_output.get("tags", [])
|
||||
if isinstance(new_tags, list) and new_tags:
|
||||
candidate_tags: List[str] = []
|
||||
if is_source_model and source_context is not None:
|
||||
candidate_tags.extend(source_context.official_tags)
|
||||
if isinstance(new_tags, list):
|
||||
candidate_tags.extend(
|
||||
tag for tag in new_tags if tag not in candidate_tags
|
||||
)
|
||||
if candidate_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:
|
||||
merged = self._merge_tags(existing_tags, candidate_tags)
|
||||
if len(merged) > len(existing_tags) or is_source_model:
|
||||
updates["tags"] = merged
|
||||
|
||||
# metadata_source & llm_enriched_at (always set)
|
||||
updates["metadata_source"] = "agent:enrich_hf_metadata"
|
||||
# metadata_source is recorded for provenance; llm_enriched_at only means
|
||||
# something when a provider actually answered, so the deterministic
|
||||
# download-time hydration does not claim an enrichment that never ran.
|
||||
updates["metadata_source"] = metadata_source
|
||||
if llm_output:
|
||||
updates["llm_enriched_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Store LLM confidence in metadata so it's accessible for evaluation
|
||||
# LLM confidence, stored for the enrichment evaluation harness. The key
|
||||
# must NOT start with an underscore: `BaseModelMetadata.from_dict()`
|
||||
# deliberately drops underscore-prefixed keys so they never round-trip,
|
||||
# which silently erased this field on the next metadata write.
|
||||
raw_confidence = (llm_output.get("confidence") or "").strip()
|
||||
if raw_confidence:
|
||||
updates["_llm_confidence"] = 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.
|
||||
# Fallback: use the trigger words the site records for this exact file,
|
||||
# then the README's YAML `instance_prompt`, when the LLM returned none.
|
||||
if trigger_words_empty:
|
||||
site_triggers = (
|
||||
list(source_context.trigger_words) if source_context else []
|
||||
)
|
||||
if not site_triggers:
|
||||
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
|
||||
site_triggers = [instance_prompt]
|
||||
if site_triggers:
|
||||
self._merge_civitai(updates, metadata, trainedWords=site_triggers)
|
||||
|
||||
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:
|
||||
if not preview_remote_url and readme_content and is_source_model:
|
||||
model_basename = os.path.splitext(os.path.basename(model_path))[0]
|
||||
relevant_section = extract_relevant_section(
|
||||
readme_content, model_basename,
|
||||
@@ -245,8 +340,12 @@ class PostProcessor:
|
||||
if new_notes:
|
||||
updates["notes"] = new_notes
|
||||
|
||||
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4})
|
||||
# usage_tips — JSON string (e.g. {"strength_min":0.85,"strength_max":1.4}).
|
||||
# When the LLM returned nothing, recover an explicitly stated strength
|
||||
# range from the author summary so the value is not lost.
|
||||
raw_tips = (llm_output.get("usage_tips") or "").strip()
|
||||
if not raw_tips or raw_tips == "{}":
|
||||
raw_tips = _extract_usage_tips(site_description)
|
||||
if raw_tips and raw_tips != "{}":
|
||||
try:
|
||||
json.loads(raw_tips)
|
||||
@@ -276,16 +375,35 @@ class PostProcessor:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _should_overwrite(current_value: str, is_hf_model: bool) -> bool:
|
||||
def _should_overwrite(current_value: str, is_source_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 (
|
||||
return is_source_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:
|
||||
def _merge_civitai(
|
||||
updates: Dict[str, Any], metadata: Dict[str, Any], **fields: Any
|
||||
) -> None:
|
||||
"""Layer *fields* onto the ``civitai`` block being assembled.
|
||||
|
||||
Description, version label, trigger words and gallery images all live
|
||||
in the same dict and are contributed by separate branches, so each one
|
||||
starts from what is already on disk and then applies whatever an
|
||||
earlier branch queued in *updates*.
|
||||
"""
|
||||
|
||||
merged = dict(metadata.get("civitai") or {})
|
||||
queued = updates.get("civitai")
|
||||
if isinstance(queued, dict):
|
||||
merged.update(queued)
|
||||
merged.update(fields)
|
||||
updates["civitai"] = merged
|
||||
|
||||
@staticmethod
|
||||
def _should_overwrite_list(current_list: List[str], is_source_model: bool) -> bool:
|
||||
"""Return ``True`` when a list field should be overwritten."""
|
||||
return is_hf_model or not current_list
|
||||
return is_source_model or not current_list
|
||||
|
||||
@staticmethod
|
||||
def _merge_tags(existing: List[str], new: List[str]) -> List[str]:
|
||||
@@ -309,6 +427,129 @@ class PostProcessor:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
#: Separator between a label and its value. Published model cards routinely
|
||||
#: wrap the numbers in markdown emphasis or quotes (``strength: **0.85 - 1.4**``,
|
||||
#: ``CLIP 强度「0.5」``), so those are absorbed rather than treated as a break.
|
||||
_EMPHASIS = "[\"'\u201c\u201d\u300c\u300d*_`\\s]*"
|
||||
|
||||
#: An explicitly stated strength/weight range, e.g. ``权重0.5-1.2``,
|
||||
#: ``强度 0.8 ~ 1.2``, ``strength: **0.85 - 1.4**``.
|
||||
_RANGE_DASH = "(?:-|\u2010|\u2011|\u2012|\u2013|\u2014|\uff0d|~|\uff5e|\u81f3|\u5230|to)"
|
||||
|
||||
_STRENGTH_RANGE_RE = re.compile(
|
||||
"(?:\u6743\u91cd|\u5f3a\u5ea6|strength|weight)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
|
||||
+ r"(\d+(?:\.\d+)?)" + _EMPHASIS + _RANGE_DASH + _EMPHASIS
|
||||
+ r"(\d+(?:\.\d+)?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: A single strength/weight value, e.g. ``strength: 0.6``, ``权重 0.8``.
|
||||
_STRENGTH_VALUE_RE = re.compile(
|
||||
"(?:\u6743\u91cd|\u5f3a\u5ea6|strength|weight)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
|
||||
+ r"(\d+(?:\.\d+)?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: ``clip strength: 0.5`` / ``CLIP 强度 0.5``.
|
||||
_CLIP_STRENGTH_RE = re.compile(
|
||||
"clip" + _EMPHASIS + "(?:\u5f3a\u5ea6|strength)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
|
||||
+ r"(\d+(?:\.\d+)?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
#: ``clip skip: 2`` / ``CLIP 跳过 2``.
|
||||
_CLIP_SKIP_RE = re.compile(
|
||||
"clip" + _EMPHASIS + "(?:skip|\u8df3\u8fc7)" + _EMPHASIS + "[:\uff1a]?" + _EMPHASIS
|
||||
+ r"(\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_usage_tips(text: str) -> str:
|
||||
"""Extract stated strength/CLIP recommendations from prose.
|
||||
|
||||
This is the deterministic counterpart to the LLM's ``usage_tips`` output,
|
||||
used when the LLM was skipped. It only recognises explicitly written
|
||||
values — it never infers a range — and returns ``""`` when it finds none.
|
||||
|
||||
Returns:
|
||||
A JSON string matching the skill's ``usage_tips`` schema, or ``""``.
|
||||
"""
|
||||
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
tips: Dict[str, Any] = {}
|
||||
|
||||
# CLIP strength is resolved first and then blanked out, so the generic
|
||||
# strength patterns cannot mistake `CLIP 强度 0.5` for the LoRA strength.
|
||||
text_for_strength = text
|
||||
clip_strength = _CLIP_STRENGTH_RE.search(text_for_strength)
|
||||
if clip_strength:
|
||||
tips["clip_strength"] = float(clip_strength.group(1))
|
||||
text_for_strength = (
|
||||
text_for_strength[: clip_strength.start()]
|
||||
+ " "
|
||||
+ text_for_strength[clip_strength.end() :]
|
||||
)
|
||||
|
||||
range_match = _STRENGTH_RANGE_RE.search(text_for_strength)
|
||||
if range_match:
|
||||
low = float(range_match.group(1))
|
||||
high = float(range_match.group(2))
|
||||
if low > high:
|
||||
low, high = high, low
|
||||
tips["strength_min"] = low
|
||||
tips["strength_max"] = high
|
||||
tips["strength_range"] = f"{low:g}-{high:g}"
|
||||
else:
|
||||
value_match = _STRENGTH_VALUE_RE.search(text_for_strength)
|
||||
if value_match:
|
||||
tips["strength"] = float(value_match.group(1))
|
||||
|
||||
clip_skip = _CLIP_SKIP_RE.search(text)
|
||||
if clip_skip:
|
||||
tips["clip_skip"] = int(clip_skip.group(1))
|
||||
|
||||
if not tips:
|
||||
return ""
|
||||
return json.dumps(tips, ensure_ascii=False)
|
||||
|
||||
|
||||
def _example_image(url: str, width: int, height: int) -> Dict[str, Any]:
|
||||
"""Build a ``civitai.images`` entry for a site-provided example image.
|
||||
|
||||
The site publishes no prompt alongside these images, so the entry carries
|
||||
empty prompt metadata and the LLM's recommended dimensions when it found
|
||||
any (falling back to the same 512px placeholder the README extractors use).
|
||||
"""
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"type": "image",
|
||||
"nsfwLevel": 0,
|
||||
"width": width or 512,
|
||||
"height": height or 512,
|
||||
"meta": {"prompt": "", "negativePrompt": ""},
|
||||
"hasMeta": False,
|
||||
"hasPositivePrompt": False,
|
||||
}
|
||||
|
||||
|
||||
def _dedupe_images(images: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Drop later entries that repeat an earlier image URL, keeping order."""
|
||||
|
||||
seen: set[str] = set()
|
||||
unique: List[Dict[str, Any]] = []
|
||||
for image in images:
|
||||
url = image.get("url") or ""
|
||||
if not url or url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
unique.append(image)
|
||||
return unique
|
||||
|
||||
|
||||
def _extract_yaml_instance_prompt(readme_content: str) -> str:
|
||||
"""Extract ``instance_prompt`` from the YAML frontmatter of a HF README.
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
---
|
||||
name: enrich_hf_metadata
|
||||
title: "Enrich Metadata from HuggingFace"
|
||||
title: "Enrich Metadata from Model Card"
|
||||
description: >
|
||||
Parse the HuggingFace model card via LLM to extract description, trigger
|
||||
words, base model, tags, and preview image URL.
|
||||
Parse the model card (README) from HuggingFace, ModelScope, or any other
|
||||
supported model site 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).
|
||||
You are an expert assistant for AI image generation models. Your task is to extract structured metadata from a model card (README).
|
||||
|
||||
## Model Information
|
||||
|
||||
- **Repository**: {{hf_url}}
|
||||
- **Source site**: {{source_label}} ({{source_platform}})
|
||||
- **Model page**: {{source_url}}
|
||||
- **Model file path**: {{model_path}}
|
||||
- **Model filename**: {{model_basename}}
|
||||
- **Repository ID**: {{repo}}
|
||||
- **Repository ID**: {{source_id}}
|
||||
- **Repository raw-file base URL**: {{asset_base_url}}
|
||||
|
||||
## Current Metadata (may be incomplete)
|
||||
|
||||
@@ -22,6 +25,34 @@ You are an expert assistant for AI image generation models. Your task is to extr
|
||||
{{current_metadata}}
|
||||
```
|
||||
|
||||
## Site-Provided Metadata (any field may be empty)
|
||||
|
||||
The model site publishes the following **alongside** the README. It is
|
||||
first-hand information recorded by the site itself, so it outranks anything
|
||||
you would otherwise guess:
|
||||
|
||||
- **Author description**: {{source_description}}
|
||||
- **Base model reported by the site**: {{source_base_model}}
|
||||
- **Trigger words recorded for this file**: {{source_trigger_words}}
|
||||
- **Site-curated tags**:
|
||||
{{source_official_tags}}
|
||||
- **Example image URLs for this file**:
|
||||
{{source_example_images}}
|
||||
|
||||
Use it as follows:
|
||||
|
||||
- A weight or strength range stated in the **author description** belongs in
|
||||
``usage_tips`` (and in ``notes``); do not leave ``usage_tips`` empty when the
|
||||
description states one.
|
||||
- When the author description exists, base ``short_description`` on it rather
|
||||
than on the README, which on some sites is auto-generated boilerplate.
|
||||
- Treat the **site-curated tags** as strong signals for ``tags``: they are
|
||||
already a curated content vocabulary, so prefer them over invented words.
|
||||
- Treat the **base model reported by the site** as a strong hint for
|
||||
``base_model``, but still map it to the EXACT canonical name from the
|
||||
available base-model list.
|
||||
- Use the **example image URLs** when the README contains no usable image.
|
||||
|
||||
## User Priority Tags Reference
|
||||
|
||||
The user has configured the following list of **meaningful tag categories** for this model type (`{{model_type}}`):
|
||||
@@ -39,7 +70,7 @@ name listed — do not invent aliases or modify variant suffixes.
|
||||
|
||||
{{base_models}}
|
||||
|
||||
## HuggingFace README Content
|
||||
## Model Card Content
|
||||
|
||||
```
|
||||
{{readme_content}}
|
||||
@@ -52,10 +83,11 @@ 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
|
||||
Check the **base model reported by the site** (above) and the YAML frontmatter ``base_model:`` first. If neither yields a match, 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:
|
||||
- The **trigger words recorded for this file** in the site-provided metadata (most authoritative)
|
||||
- `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)
|
||||
@@ -63,12 +95,13 @@ The trigger words or activation prompts needed to use this LoRA. Look for:
|
||||
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.
|
||||
A concise 1-2 sentence summary of what this model does. For collection repos, focus on the **specific model version** matching `{{model_basename}}`, not the repo as a whole. Prefer the **author description** from the site-provided metadata when it is present; otherwise extract from the "Model description" section or the first paragraph. Return empty string if the available content is too minimal.
|
||||
|
||||
### tags
|
||||
3-8 relevant tags for categorizing this model. **Quality over quantity.**
|
||||
|
||||
Sources to consider:
|
||||
- The **site-curated tags** from the site-provided metadata (these are already filtered content tags — prefer them)
|
||||
- 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")
|
||||
@@ -79,7 +112,9 @@ Sources to consider:
|
||||
|
||||
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"`).
|
||||
3. **All lowercase, and keep each tag's own wording.** Prefer the spelling already used by the site, the frontmatter, or the author — including hyphenated and multi-word tags such as `"sci-fi"`, `"semi-realistic"`, `"character-enhancement"` or `"art style"`. Do **not** strip separators or invent a single-word variant of a tag you are already including (e.g. do not emit both `"character-enhancement"` and `"character"`). When a tag is written in another script (e.g. Chinese), likewise keep it verbatim instead of translating it.
|
||||
|
||||
4. **Never invent a tag** that neither the site-provided metadata, the YAML frontmatter, nor the README text supports.
|
||||
|
||||
Return empty array if no meaningful content tags remain after filtering.
|
||||
|
||||
@@ -92,13 +127,13 @@ The URL of the most suitable preview image from the README. Look for:
|
||||
- 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.
|
||||
Choose the first image that appears to be a generation example (not a logo or diagram). Construct the absolute URL from the repository raw-file base URL (`{{asset_base_url}}`) plus the relative path. If the README has no suitable image, fall back to the site-provided **example image URLs** for this file. If nothing is available, 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.
|
||||
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}}`. Include the **author description** from the site-provided metadata when it is present. Return empty string if there is 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):
|
||||
A JSON string with structured usage recommendations. Extract from the **author description** (site-provided metadata) and the README any explicit ranges or recommended values (e.g. "Set LoRA strength: **0.85 - 1.4**", "CLIP strength: 0.5", "权重0.5-1.2"). Possible fields (include only those you can determine):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -121,7 +156,7 @@ Your confidence level in the extracted data:
|
||||
|
||||
## Important: Handling Collection Repos (multiple model files)
|
||||
|
||||
Many HuggingFace repos contain **multiple model files** in a single repository
|
||||
Many model repositories 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}}`**
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""HF README processing for the ``enrich_hf_metadata`` skill.
|
||||
"""Model card (README) processing for the ``enrich_hf_metadata`` skill.
|
||||
|
||||
Provides README cleaning for LLM injection, gallery/image extraction from
|
||||
multiple formats (YAML widget, markdown, HTML ``<img>``, gallery tables),
|
||||
and section-based README trimming for collection repos.
|
||||
|
||||
The extractors default to Hugging Face asset URLs, but every one of them
|
||||
accepts an explicit ``base_url`` so the same parsing works for any model
|
||||
source (ModelScope, ...). See :mod:`py.services.model_sources`.
|
||||
|
||||
This module deliberately has no package-relative imports: it is also loaded
|
||||
standalone by the README-processing test harness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,12 +22,25 @@ from typing import Any, List, Tuple
|
||||
_REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)")
|
||||
|
||||
|
||||
def resolve_asset_base_url(repo: str, base_url: str | None = None) -> str:
|
||||
"""Return the base URL used to resolve repository-relative assets.
|
||||
|
||||
Falls back to the historical Hugging Face layout when *base_url* is not
|
||||
supplied, so existing callers keep their behaviour.
|
||||
"""
|
||||
|
||||
if base_url:
|
||||
return base_url.rstrip("/")
|
||||
return f"https://huggingface.co/{repo}/resolve/main"
|
||||
|
||||
|
||||
def extract_simple_markdown_images(
|
||||
markdown_text: str,
|
||||
repo: str,
|
||||
existing_urls: set[str] | None = None,
|
||||
default_width: int = 512,
|
||||
default_height: int = 512,
|
||||
base_url: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Extract standalone markdown images from the README body.
|
||||
|
||||
@@ -32,10 +52,10 @@ def extract_simple_markdown_images(
|
||||
Returns a list of dicts in the same ``civitai.images`` format as
|
||||
:func:`extract_gallery_images`.
|
||||
"""
|
||||
if not markdown_text or not repo:
|
||||
if not markdown_text or not (repo or base_url):
|
||||
return []
|
||||
|
||||
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
||||
base_url = resolve_asset_base_url(repo, base_url)
|
||||
images: list[dict[str, Any]] = []
|
||||
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
|
||||
|
||||
@@ -89,20 +109,21 @@ def extract_html_img_tags(
|
||||
existing_urls: set[str] | None = None,
|
||||
default_width: int = 512,
|
||||
default_height: int = 512,
|
||||
base_url: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Extract image URLs from HTML ``<img src=\"...\">`` tags in the README.
|
||||
|
||||
Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML
|
||||
``<img>`` tags exclusively for their sample images, with no markdown
|
||||
``![]()`` equivalents. This function finds those tags and constructs
|
||||
resolvable HF URLs.
|
||||
resolvable URLs.
|
||||
|
||||
Returns a list of dicts in the ``civitai.images`` format.
|
||||
"""
|
||||
if not markdown_text or not repo:
|
||||
if not markdown_text or not (repo or base_url):
|
||||
return []
|
||||
|
||||
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
||||
base_url = resolve_asset_base_url(repo, base_url)
|
||||
images: list[dict[str, Any]] = []
|
||||
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
|
||||
|
||||
@@ -166,7 +187,7 @@ def extract_html_img_tags(
|
||||
|
||||
def extract_repo_from_hf_url(hf_url: str) -> str:
|
||||
"""Extract ``user/repo`` from a HuggingFace URL."""
|
||||
m = _REPO_URL_PATTERN.match(hf_url)
|
||||
m = _REPO_URL_PATTERN.match(hf_url or "")
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
@@ -175,21 +196,23 @@ def extract_gallery_images(
|
||||
repo: str,
|
||||
default_width: int = 512,
|
||||
default_height: int = 512,
|
||||
base_url: str | None = None,
|
||||
) -> List[dict[str, Any]]:
|
||||
"""Extract widget/gallery images from the YAML frontmatter of a HF README.
|
||||
"""Extract widget/gallery images from the YAML frontmatter of a README.
|
||||
|
||||
Args:
|
||||
markdown_text: Raw README content.
|
||||
repo: HF repo identifier (``user/repo``).
|
||||
repo: Repository identifier (``user/repo``).
|
||||
default_width: Fallback width when the README provides no dimension.
|
||||
default_height: Fallback height when the README provides no dimension.
|
||||
base_url: Overrides the asset base URL (defaults to Hugging Face).
|
||||
|
||||
Returns a list of dicts compatible with the ``civitai.images`` metadata
|
||||
format, each containing ``url`` (absolute HF URL), ``meta.prompt``,
|
||||
format, each containing ``url`` (absolute), ``meta.prompt``,
|
||||
``width``, ``height``, and ``type``. Returns an empty list when no
|
||||
widget entries are found or when *repo* is empty.
|
||||
"""
|
||||
if not markdown_text or not repo:
|
||||
if not markdown_text or not (repo or base_url):
|
||||
return []
|
||||
|
||||
frontmatter = _extract_frontmatter(markdown_text)
|
||||
@@ -197,7 +220,7 @@ def extract_gallery_images(
|
||||
return []
|
||||
|
||||
images: List[dict[str, Any]] = []
|
||||
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
||||
base_url = resolve_asset_base_url(repo, base_url)
|
||||
w = default_width or 512
|
||||
h = default_height or 512
|
||||
|
||||
@@ -279,10 +302,11 @@ def extract_gallery_table_images(
|
||||
existing_urls: set[str] | None = None,
|
||||
default_width: int = 512,
|
||||
default_height: int = 512,
|
||||
base_url: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Extract images from ``| Preview | Prompt |`` markdown gallery tables.
|
||||
|
||||
Many HF READMEs include a sample-gallery table in the body (outside
|
||||
Many READMEs include a sample-gallery table in the body (outside
|
||||
the YAML frontmatter) that shows generation examples with their
|
||||
prompts. This function parses those tables and merges results with
|
||||
the widget-sourced images from :func:`extract_gallery_images`.
|
||||
@@ -291,10 +315,10 @@ def extract_gallery_table_images(
|
||||
:func:`extract_gallery_images`. Already-seen URLs (from *existing_urls*)
|
||||
are skipped.
|
||||
"""
|
||||
if not markdown_text or not repo:
|
||||
if not markdown_text or not (repo or base_url):
|
||||
return []
|
||||
|
||||
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
||||
base_url = resolve_asset_base_url(repo, base_url)
|
||||
images: list[dict[str, Any]] = []
|
||||
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
|
||||
lines = markdown_text.split("\n")
|
||||
@@ -368,12 +392,18 @@ def _extract_frontmatter(text: str) -> str:
|
||||
|
||||
|
||||
def convert_readme_to_html(markdown_text: str | None) -> str:
|
||||
"""Convert HF README markdown to sanitised HTML."""
|
||||
"""Convert HF README markdown to sanitised HTML.
|
||||
|
||||
Site-generated placeholder notices are dropped here too, so a repository
|
||||
whose author wrote nothing does not store the download instructions as its
|
||||
model description; the result is an empty string in that case.
|
||||
"""
|
||||
if not markdown_text:
|
||||
return ""
|
||||
|
||||
text = markdown_text
|
||||
text = _strip_frontmatter(text)
|
||||
text = _strip_generated_card_boilerplate(text)
|
||||
text = _strip_gallery(text)
|
||||
text = _strip_badge_images(text)
|
||||
text = _strip_html_comments(text)
|
||||
@@ -420,6 +450,59 @@ _MASSIVE_LIST_LINE_MIN_LEN = 150
|
||||
#: Minimum consecutive enumeration lines to trigger massive-list stripping.
|
||||
_MASSIVE_LIST_THRESHOLD = 8
|
||||
|
||||
#: Substrings identifying text a *site* generated to fill a model card whose
|
||||
#: author wrote nothing, as opposed to the author's own content. ModelScope
|
||||
#: renders such a card as a placeholder notice, a block of SDK/git download
|
||||
#: instructions, and a closing invitation to improve the card.
|
||||
#:
|
||||
#: Matched as substrings rather than whole headings because the notices are
|
||||
#: prose, and because non-Latin scripts are not space-delimited — the notice
|
||||
#: continues with a full-width period, so the ``title == kw`` style matching
|
||||
#: used for :data:`_BOILERPLATE_HEADERS` would never fire.
|
||||
_GENERATED_CARD_MARKERS: tuple[str, ...] = (
|
||||
"当前模型的贡献者未提供更加详细的模型介绍",
|
||||
"您可以通过如下",
|
||||
"如果您是本模型的贡献者",
|
||||
)
|
||||
|
||||
|
||||
def _strip_generated_card_boilerplate(text: str) -> str:
|
||||
"""Remove the notices a site generates to fill an empty model card.
|
||||
|
||||
A repository whose uploader wrote no README still gets a card: ModelScope
|
||||
answers with "the contributor provided no further description", the SDK
|
||||
and git download commands, and an invitation to complete the card. None
|
||||
of it describes the model, yet it was landing in both the LLM prompt and
|
||||
the stored description.
|
||||
|
||||
A notice that is a heading takes its whole section with it, so the
|
||||
download block goes too; a stand-alone notice line is dropped on its own.
|
||||
Content the author added later — under a heading of equal or higher
|
||||
level — is kept, so an improved card is not thrown away.
|
||||
"""
|
||||
|
||||
lines = text.split("\n")
|
||||
out: list[str] = []
|
||||
skip_until_level: int | None = None
|
||||
|
||||
for line in lines:
|
||||
level = _heading_level(line)
|
||||
|
||||
if any(marker in line for marker in _GENERATED_CARD_MARKERS):
|
||||
if level > 0:
|
||||
skip_until_level = level
|
||||
continue
|
||||
|
||||
if skip_until_level is not None:
|
||||
if level > 0 and level <= skip_until_level:
|
||||
skip_until_level = None
|
||||
else:
|
||||
continue
|
||||
|
||||
out.append(line)
|
||||
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> str:
|
||||
"""Clean a HF README for injection into an LLM metadata-extraction prompt.
|
||||
@@ -429,6 +512,8 @@ def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> s
|
||||
|
||||
* ``widget:`` YAML block (example prompts + output URLs)
|
||||
* ``<Gallery />`` tags and wrappers
|
||||
* Site-generated placeholder notices for a card the author never wrote
|
||||
(see :func:`_strip_generated_card_boilerplate`)
|
||||
* Fenced code blocks (Python / bash / bibtex / yaml)
|
||||
* Standalone ```` image lines and ``<img>`` tags
|
||||
* Training-parameter tables
|
||||
@@ -454,6 +539,7 @@ def clean_readme_for_llm(markdown_text: str | None, max_length: int = 6000) -> s
|
||||
# Order matters — broader strips first, then finer ones.
|
||||
text = _strip_gallery(text)
|
||||
text = _strip_widget_section(text)
|
||||
text = _strip_generated_card_boilerplate(text)
|
||||
text = _strip_fenced_code_blocks(text)
|
||||
text = _strip_standalone_images(text)
|
||||
text = _strip_training_tables(text)
|
||||
|
||||
@@ -161,6 +161,11 @@ class Aria2Downloader:
|
||||
(typically an expired CivitAI signed URL): a fresh URL is resolved
|
||||
and the partial download continues. Recovery is bounded by
|
||||
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
||||
|
||||
Cancellation never leaks daemon transfers: the gid is tracked in
|
||||
``_transfers`` before any post-``addUri`` await, and a gid accepted
|
||||
by the daemon while the caller is being cancelled is removed again
|
||||
before the ``CancelledError`` propagates.
|
||||
"""
|
||||
|
||||
await self._ensure_process()
|
||||
@@ -251,7 +256,11 @@ class Aria2Downloader:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
finally:
|
||||
current = self._transfers.get(download_id)
|
||||
if current is not None and current.gid == transfer.gid:
|
||||
if (
|
||||
transfer is not None
|
||||
and current is not None
|
||||
and current.gid == transfer.gid
|
||||
):
|
||||
self._transfers.pop(download_id, None)
|
||||
|
||||
async def _get_status_with_retry(
|
||||
@@ -339,21 +348,43 @@ class Aria2Downloader:
|
||||
resolved_url != url,
|
||||
)
|
||||
|
||||
# Shield the addUri RPC from cancellation: the daemon may accept the
|
||||
# download even when the caller is cancelled while the request is in
|
||||
# flight. On cancellation, wait for the RPC result so the freshly
|
||||
# created gid can be removed instead of leaking an untracked
|
||||
# download that keeps running in the daemon.
|
||||
add_task = asyncio.ensure_future(
|
||||
self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||
)
|
||||
try:
|
||||
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||
gid = await asyncio.shield(add_task)
|
||||
except asyncio.CancelledError:
|
||||
leaked_gid: Any = None
|
||||
try:
|
||||
leaked_gid = await add_task
|
||||
except Exception:
|
||||
leaked_gid = None
|
||||
if isinstance(leaked_gid, str) and leaked_gid:
|
||||
logger.info(
|
||||
"Removing aria2 gid %s accepted while download %s was "
|
||||
"being cancelled",
|
||||
leaked_gid,
|
||||
download_id,
|
||||
)
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [leaked_gid])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to remove leaked aria2 gid %s for download %s: %s",
|
||||
leaked_gid,
|
||||
download_id,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
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 _register_transfer(
|
||||
@@ -372,7 +403,46 @@ class Aria2Downloader:
|
||||
headers=headers,
|
||||
)
|
||||
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
|
||||
# Register the transfer before any further await: once the daemon
|
||||
# holds the gid, cancel_download() must be able to find it. An await
|
||||
# in between would open a window where a concurrent cancel reports
|
||||
# "Download task not found" and the daemon keeps downloading
|
||||
# untracked.
|
||||
self._transfers[download_id] = transfer
|
||||
try:
|
||||
await self._state_store.upsert(
|
||||
download_id,
|
||||
{
|
||||
"gid": gid,
|
||||
"save_path": transfer.save_path,
|
||||
"status": "downloading",
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# The task was cancelled while persisting state and the
|
||||
# coordinator's cancel ran before the transfer was registered
|
||||
# above. Remove the daemon transfer unless it was deliberately
|
||||
# paused (skip_download preserves paused transfers for resume).
|
||||
status = None
|
||||
try:
|
||||
status = await self.get_status(download_id)
|
||||
except Exception:
|
||||
status = None
|
||||
if status is not None and status.get("status") != "paused":
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [gid])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to remove aria2 gid %s for cancelled download %s: %s",
|
||||
gid,
|
||||
download_id,
|
||||
exc,
|
||||
)
|
||||
current = self._transfers.get(download_id)
|
||||
if current is not None and current.gid == gid:
|
||||
self._transfers.pop(download_id, None)
|
||||
raise
|
||||
return transfer
|
||||
|
||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES, VALID_OTHER_SUB_TYPES
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.usage_stats import UsageStats
|
||||
@@ -21,6 +21,7 @@ from .model_query import (
|
||||
resolve_sub_type,
|
||||
)
|
||||
from .settings_manager import get_settings_manager
|
||||
from .model_sources import source_group_key
|
||||
from ..utils.civitai_utils import build_civitai_model_page_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -742,29 +743,32 @@ class BaseModelService(ABC):
|
||||
@staticmethod
|
||||
def _extract_hf_group_key(item: Dict[str, Any]) -> 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)}"
|
||||
key = BaseModelService._extract_source_group_key(item)
|
||||
return key if key and key.startswith("hf:") else None
|
||||
|
||||
@staticmethod
|
||||
def _extract_source_group_key(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return the external-source group key for *item*, or None.
|
||||
|
||||
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
|
||||
platforms use their own short prefix (``ms:`` / ``ta:``).
|
||||
"""
|
||||
return source_group_key(item)
|
||||
|
||||
@staticmethod
|
||||
def _extract_group_key(item: Dict[str, Any]) -> Union[int, str, None]:
|
||||
"""Return the group identity key: CivitAI modelId (int) or HF repo (str).
|
||||
"""Return the group identity key.
|
||||
|
||||
Preference order:
|
||||
1. CivitAI ``modelId`` (int)
|
||||
2. HF repo identity ``hf:{owner}/{repo}`` (str)
|
||||
2. External model source identity, e.g. ``hf:{owner}/{repo}``,
|
||||
``ms:{owner}/{repo}``, ``ta:{model_id}`` (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)
|
||||
return BaseModelService._extract_source_group_key(item)
|
||||
|
||||
@staticmethod
|
||||
def _extract_model_id(item: Dict[str, Any]) -> Optional[int]:
|
||||
@@ -904,6 +908,11 @@ class BaseModelService(ABC):
|
||||
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
if (
|
||||
self.model_type == "other"
|
||||
and normalized_type not in VALID_OTHER_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
|
||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||
|
||||
|
||||
@@ -410,6 +410,10 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
return None
|
||||
|
||||
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve sub_type from the configured root that contains the file."""
|
||||
return self._resolve_sub_type(self._find_root_for_file(file_path))
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
"""Adjust metadata during scanning to set sub_type."""
|
||||
sub_type = self._resolve_sub_type(root_path)
|
||||
@@ -419,9 +423,7 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
|
||||
sub_type = self._resolve_sub_type(
|
||||
self._find_root_for_file(entry.get("file_path"))
|
||||
)
|
||||
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
|
||||
if sub_type:
|
||||
entry["sub_type"] = sub_type
|
||||
return entry
|
||||
|
||||
@@ -67,6 +67,8 @@ class CheckpointService(BaseModelService):
|
||||
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
+120
-24
@@ -17,13 +17,18 @@ from dataclasses import dataclass, field
|
||||
import uuid
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
||||
from urllib.parse import urlparse
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ..utils.models import (
|
||||
LoraMetadata,
|
||||
CheckpointMetadata,
|
||||
EmbeddingMetadata,
|
||||
OtherModelMetadata,
|
||||
)
|
||||
from ..utils.constants import (
|
||||
CARD_PREVIEW_WIDTH,
|
||||
DIFFUSION_MODEL_BASE_MODELS,
|
||||
MODEL_WEIGHT_FILE_TYPES,
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_LORA_TYPES,
|
||||
VALID_OTHER_CIVITAI_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||
@@ -32,6 +37,7 @@ from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
@@ -228,12 +234,21 @@ class DownloadManager:
|
||||
return False
|
||||
|
||||
async def _get_scanner_for_model_type(self, model_type: str):
|
||||
"""Return the scanner responsible for the given model type."""
|
||||
"""Return the scanner responsible for the given model type.
|
||||
|
||||
Every supported type resolves explicitly — an unknown type must never
|
||||
fall through to the lora scanner (an "other" download would silently
|
||||
dedupe against the lora library).
|
||||
"""
|
||||
if model_type == "checkpoint":
|
||||
return await self._get_checkpoint_scanner()
|
||||
if model_type == "embedding":
|
||||
return await ServiceRegistry.get_embedding_scanner()
|
||||
if model_type == "other":
|
||||
return await ServiceRegistry.get_other_scanner()
|
||||
if model_type == "lora":
|
||||
return await self._get_lora_scanner()
|
||||
raise ValueError(f'Unknown model type "{model_type}"')
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target_file(
|
||||
@@ -978,6 +993,8 @@ class DownloadManager:
|
||||
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
if model_type == "embedding":
|
||||
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
if model_type == "other":
|
||||
return OtherModelMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
|
||||
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
|
||||
@@ -1438,6 +1455,7 @@ class DownloadManager:
|
||||
lora_scanner = await self._get_lora_scanner()
|
||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
|
||||
# Check lora scanner first
|
||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
||||
@@ -1462,6 +1480,13 @@ class DownloadManager:
|
||||
"error": "Model version already exists in embedding library",
|
||||
}
|
||||
|
||||
# Check other scanner
|
||||
if await other_scanner.check_model_version_exists(model_version_id):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Model version already exists in other library",
|
||||
}
|
||||
|
||||
# Use CivArchive provider directly when source is 'civarchive'
|
||||
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
||||
if source == "civarchive":
|
||||
@@ -1500,6 +1525,20 @@ class DownloadManager:
|
||||
model_type = "lora"
|
||||
elif model_type_from_info == "textualinversion":
|
||||
model_type = "embedding"
|
||||
elif model_type_from_info in VALID_OTHER_CIVITAI_TYPES:
|
||||
if not get_settings_manager().is_other_models_enabled():
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Other Models management is disabled. Enable it in "
|
||||
"Settings > Library before downloading VAE, upscaler, "
|
||||
"text encoder or CLIP files."
|
||||
),
|
||||
# Machine-readable failure code consumed by the companion
|
||||
# browser extension (docs/other-models-support.md C4).
|
||||
"reason": "other_models_disabled",
|
||||
}
|
||||
model_type = "other"
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -1621,26 +1660,12 @@ class DownloadManager:
|
||||
}
|
||||
|
||||
# Check if this checkpoint should be treated as a diffusion model
|
||||
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
||||
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
||||
is_diffusion_model = False
|
||||
if model_type == "checkpoint":
|
||||
# Check file types first (more direct signal from CivitAI)
|
||||
version_files = version_info.get("files", [])
|
||||
for f in version_files:
|
||||
f_type = f.get("type", "")
|
||||
if f_type in ("UNet", "Diffusion Model"):
|
||||
is_diffusion_model = True
|
||||
logger.info(
|
||||
f"File type '{f_type}' detected, routing checkpoint to unet folder"
|
||||
)
|
||||
break
|
||||
|
||||
# Fallback to baseModel name check
|
||||
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||
is_diffusion_model = True
|
||||
logger.info(
|
||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||
# (shared with the download routing endpoint so the UI location
|
||||
# step and the actual download agree on the target roots).
|
||||
is_diffusion_model = is_diffusion_model_download(
|
||||
model_type,
|
||||
file_types=(f.get("type", "") for f in version_info.get("files", [])),
|
||||
base_model=base_model_value,
|
||||
)
|
||||
|
||||
# Existence check after the metadata fetch (#1058):
|
||||
@@ -1700,6 +1725,13 @@ class DownloadManager:
|
||||
"success": False,
|
||||
"error": "Model version already exists in embedding library",
|
||||
}
|
||||
elif model_type == "other":
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
if await other_scanner.check_model_version_exists(version_id):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Model version already exists in other library",
|
||||
}
|
||||
|
||||
# Handle use_default_paths
|
||||
if use_default_paths:
|
||||
@@ -1739,6 +1771,60 @@ class DownloadManager:
|
||||
"error": "Default embedding root path not set in settings",
|
||||
}
|
||||
save_dir = default_path
|
||||
elif model_type == "other":
|
||||
other_sub_type = resolve_other_download_sub_type(
|
||||
model_type_from_info,
|
||||
file_types=(
|
||||
f.get("type", "")
|
||||
for f in version_info.get("files", [])
|
||||
if isinstance(f, dict)
|
||||
),
|
||||
selected_file_type=(
|
||||
target_file.get("type") if explicit_file else None
|
||||
),
|
||||
)
|
||||
default_other_roots = (
|
||||
settings_manager.get("default_other_roots") or {}
|
||||
)
|
||||
if other_sub_type and not settings_manager.is_other_sub_type_enabled(
|
||||
other_sub_type
|
||||
):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Other-model sub-type '{other_sub_type}' is "
|
||||
f"disabled in settings. Please pick a destination "
|
||||
f"folder explicitly instead of using default paths."
|
||||
),
|
||||
"reason": "other_sub_type_disabled",
|
||||
}
|
||||
default_path = (
|
||||
default_other_roots.get(other_sub_type)
|
||||
if other_sub_type
|
||||
else None
|
||||
)
|
||||
if not isinstance(default_path, str) or not default_path:
|
||||
if other_sub_type:
|
||||
detail = (
|
||||
f"No default root configured for other-model "
|
||||
f"sub-type '{other_sub_type}'"
|
||||
)
|
||||
reason = "other_no_default_root"
|
||||
else:
|
||||
detail = (
|
||||
"Could not determine the other-model sub-type "
|
||||
"from the model metadata"
|
||||
)
|
||||
reason = "other_sub_type_undecidable"
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"{detail}. Please pick a destination folder "
|
||||
f"explicitly instead of using default paths."
|
||||
),
|
||||
"reason": reason,
|
||||
}
|
||||
save_dir = default_path
|
||||
|
||||
# Calculate relative path using template
|
||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||
@@ -1935,6 +2021,11 @@ class DownloadManager:
|
||||
version_info, file_info, save_path
|
||||
)
|
||||
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
||||
elif model_type == "other":
|
||||
metadata = OtherModelMetadata.from_civitai_info(
|
||||
version_info, file_info, save_path
|
||||
)
|
||||
logger.info(f"Creating OtherModelMetadata for {file_name}")
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -2147,6 +2238,8 @@ class DownloadManager:
|
||||
scanner = await self._get_checkpoint_scanner()
|
||||
elif model_type == "embedding":
|
||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
elif model_type == "other":
|
||||
scanner = await ServiceRegistry.get_other_scanner()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
||||
|
||||
@@ -2643,6 +2736,9 @@ class DownloadManager:
|
||||
elif model_type == "embedding":
|
||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
|
||||
elif model_type == "other":
|
||||
scanner = await ServiceRegistry.get_other_scanner()
|
||||
logger.info(f"Updating other-model cache for {actual_file_paths[0]}")
|
||||
|
||||
adjust_cached_entry = (
|
||||
getattr(scanner, "adjust_cached_entry", None)
|
||||
@@ -2732,7 +2828,7 @@ class DownloadManager:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||
if model_type == "checkpoint":
|
||||
if model_type in ("checkpoint", "other"):
|
||||
return {
|
||||
".ckpt",
|
||||
".pt",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Shared download routing logic.
|
||||
|
||||
Decides whether a download initiated from the checkpoint library should be
|
||||
routed to the unet/diffusion-model roots instead of the checkpoint roots.
|
||||
Used by both the download manager (at download time) and the download
|
||||
routing HTTP endpoint (when the user picks a location in the UI), so the
|
||||
two can never disagree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from ..utils.constants import (
|
||||
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||
DIFFUSION_MODEL_BASE_MODELS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File types reported by the CivitAI API that indicate a raw diffusion
|
||||
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
|
||||
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
|
||||
|
||||
|
||||
def is_diffusion_model_download(
|
||||
model_type: str,
|
||||
file_types: Iterable[str] = (),
|
||||
base_model: str = "",
|
||||
) -> bool:
|
||||
"""Return True when a download should be routed to the unet roots.
|
||||
|
||||
Only applies to downloads initiated from the checkpoint library.
|
||||
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
|
||||
direct signal from CivitAI), (2) baseModel is a known diffusion model.
|
||||
"""
|
||||
if model_type != "checkpoint":
|
||||
return False
|
||||
|
||||
for file_type in file_types:
|
||||
if file_type in DIFFUSION_FILE_TYPES:
|
||||
logger.info(
|
||||
"File type '%s' detected, routing checkpoint to unet folder",
|
||||
file_type,
|
||||
)
|
||||
return True
|
||||
|
||||
if base_model in DIFFUSION_MODEL_BASE_MODELS:
|
||||
logger.info(
|
||||
"baseModel '%s' is a known diffusion model, routing to unet folder",
|
||||
base_model,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def resolve_other_download_sub_type(
|
||||
civitai_model_type: str,
|
||||
file_types: Iterable[str] = (),
|
||||
selected_file_type: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the "other"-page sub_type for a download.
|
||||
|
||||
Fixed priority (locked design, docs/plans/other-models-page.md §9.2):
|
||||
|
||||
1. Explicit user file pick — when the picked file's type maps, it wins
|
||||
even when model.type maps to something else.
|
||||
2. model.type via CIVITAI_TYPE_TO_OTHER_SUB_TYPE.
|
||||
3. file.type fallback — only when model.type maps to nothing. Must NOT
|
||||
override a mapped model.type: checkpoint models routinely bundle
|
||||
VAE/Text Encoder component files.
|
||||
4. Still undecidable -> None (caller must ask the user for a folder).
|
||||
"""
|
||||
if selected_file_type:
|
||||
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(selected_file_type)
|
||||
if mapped:
|
||||
logger.info(
|
||||
"Explicit file pick type '%s' routes other download to '%s'",
|
||||
selected_file_type,
|
||||
mapped,
|
||||
)
|
||||
return mapped
|
||||
|
||||
normalized_model_type = (civitai_model_type or "").strip().lower()
|
||||
mapped = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized_model_type)
|
||||
if mapped:
|
||||
return mapped
|
||||
|
||||
for file_type in file_types:
|
||||
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(file_type)
|
||||
if mapped:
|
||||
logger.info(
|
||||
"model.type '%s' unmapped; file type '%s' routes other download to '%s'",
|
||||
civitai_model_type,
|
||||
file_type,
|
||||
mapped,
|
||||
)
|
||||
return mapped
|
||||
|
||||
return None
|
||||
@@ -67,6 +67,8 @@ class EmbeddingService(BaseModelService):
|
||||
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
+95
-28
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
|
||||
# ``{provider_id: {model_id: max_output_tokens}}``.
|
||||
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
# Monotonic timestamp of the last failed catalog fetch (None = no failure
|
||||
# yet). Failed fetches are negatively cached: further calls return the
|
||||
# empty fallback without hitting the network until the cooldown elapses,
|
||||
# so users on broken networks don't stall on every settings-modal open.
|
||||
_catalog_last_failure: Optional[float] = None
|
||||
_CATALOG_FAILURE_COOLDOWN = 600.0 # seconds
|
||||
|
||||
# Serializes catalog fetches so concurrent callers don't duplicate requests.
|
||||
_catalog_lock = asyncio.Lock()
|
||||
|
||||
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
# Cloudflare serves brotli when the client advertises it, and brotli is a
|
||||
# required dependency here — a corrupted br stream can crash the native
|
||||
# decoder with a Windows access violation (issue #1099). Request gzip
|
||||
# instead; zlib decompression is not affected and corrupt gzip data only
|
||||
# raises ContentEncodingError (an aiohttp.ClientError subclass), which the
|
||||
# exception handlers below already catch.
|
||||
_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"}
|
||||
|
||||
|
||||
async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||
"""Fetch and parse the model catalog.
|
||||
@@ -46,25 +65,49 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||
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.
|
||||
|
||||
Failed fetches are negatively cached: further calls return an empty
|
||||
dict without hitting the network until ``_CATALOG_FAILURE_COOLDOWN``
|
||||
has elapsed, so a broken network does not stall every settings-modal
|
||||
open. Concurrent callers are serialized behind :data:`_catalog_lock`
|
||||
so only one request is ever in flight.
|
||||
"""
|
||||
global _catalog_cache, _model_output_limits
|
||||
global _catalog_cache, _model_output_limits, _catalog_last_failure
|
||||
if _catalog_cache is not None:
|
||||
return _catalog_cache
|
||||
|
||||
async with _catalog_lock:
|
||||
# Re-check under the lock: another caller may have fetched (or
|
||||
# failed) while we were waiting.
|
||||
if _catalog_cache is not None:
|
||||
return _catalog_cache
|
||||
if (
|
||||
_catalog_last_failure is not None
|
||||
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
|
||||
):
|
||||
logger.debug(
|
||||
"Skipping model catalog fetch: last attempt failed %.0fs ago",
|
||||
time.monotonic() - _catalog_last_failure,
|
||||
)
|
||||
return {}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||
async with session.get(_MODEL_CATALOG_URL) as resp:
|
||||
async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||
return _catalog_cache or {}
|
||||
_catalog_last_failure = time.monotonic()
|
||||
return {}
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||
return _catalog_cache or {}
|
||||
_catalog_last_failure = time.monotonic()
|
||||
return {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||
return _catalog_cache or {}
|
||||
_catalog_last_failure = time.monotonic()
|
||||
return {}
|
||||
|
||||
result: Dict[str, List[str]] = {}
|
||||
output_limits: Dict[str, Dict[str, int]] = {}
|
||||
@@ -126,7 +169,7 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
|
||||
url = f"{api_base.rstrip('/')}/models"
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
||||
async with session.get(url) as resp:
|
||||
async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp:
|
||||
if resp.status != 200:
|
||||
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
||||
return []
|
||||
@@ -224,6 +267,16 @@ _PROVIDER_DEFAULTS: Dict[str, str] = {
|
||||
# Request timeout for LLM calls (seconds)
|
||||
_LLM_TIMEOUT = aiohttp.ClientTimeout(total=120)
|
||||
|
||||
# Providers that do NOT implement ``response_format: {"type": "json_schema"}``
|
||||
# and reject it with HTTP 400. For these the weaker, widely supported
|
||||
# ``json_object`` mode is used instead (the prompt already specifies the
|
||||
# expected JSON shape, and ``_try_salvage_json`` repairs imperfect output).
|
||||
# DeepSeek answers a json_schema request with
|
||||
# ``{"error":{"message":"This response_format type is unavailable now"}}``.
|
||||
# LM Studio and some other local OpenAI-compatible servers reject
|
||||
# ``json_object`` but accept ``json_schema``, so they are not listed here.
|
||||
_JSON_OBJECT_ONLY_PROVIDERS = frozenset({"deepseek"})
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""Centralized LLM API client.
|
||||
@@ -571,47 +624,61 @@ class LLMService:
|
||||
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 = {
|
||||
# Structured-output format. ``json_schema`` is preferred because LM
|
||||
# Studio and other local OpenAI-compatible servers reject
|
||||
# ``json_object`` but accept ``json_schema``; ``{"type": "object"}``
|
||||
# accepts any JSON object without constraining specific fields, so the
|
||||
# two modes are functionally equivalent here. Providers known to
|
||||
# reject json_schema (see _JSON_OBJECT_ONLY_PROVIDERS) get
|
||||
# ``json_object`` instead.
|
||||
schema_format: Dict[str, Any] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "metadata",
|
||||
"schema": {"type": "object"},
|
||||
},
|
||||
}
|
||||
json_object_format: Dict[str, Any] = {"type": "json_object"}
|
||||
|
||||
if self._get_config()["provider"] in _JSON_OBJECT_ONLY_PROVIDERS:
|
||||
format_chain: List[Optional[Dict[str, Any]]] = [
|
||||
json_object_format,
|
||||
None,
|
||||
]
|
||||
else:
|
||||
format_chain = [schema_format, json_object_format, None]
|
||||
|
||||
result: Optional[Dict[str, Any]] = None
|
||||
for index, fmt in enumerate(format_chain):
|
||||
try:
|
||||
result = await self.chat_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
response_format=response_format,
|
||||
response_format=fmt,
|
||||
max_tokens=effective_max,
|
||||
)
|
||||
break
|
||||
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():
|
||||
message = str(e).lower()
|
||||
if index + 1 >= len(format_chain):
|
||||
raise
|
||||
# Only downgrade when the failure is about ``response_format``.
|
||||
# Everything else (auth, unknown model, rate limits) must
|
||||
# surface unchanged. Matching on the bare parameter name also
|
||||
# covers variants such as DeepSeek's "This response_format
|
||||
# type is unavailable now" without swallowing unrelated 400s.
|
||||
if "response_format" not in message:
|
||||
raise
|
||||
logger.info(
|
||||
"Provider rejected response_format, retrying without it. "
|
||||
"Falling back to prompt-only JSON mode. Error: %s",
|
||||
"Provider rejected response_format=%s, retrying with %s. "
|
||||
"Error: %s",
|
||||
(fmt or {}).get("type", "none"),
|
||||
(format_chain[index + 1] or {}).get("type", "none"),
|
||||
e,
|
||||
)
|
||||
result = await self.chat_completion(
|
||||
messages=messages,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
response_format=None,
|
||||
max_tokens=effective_max,
|
||||
)
|
||||
|
||||
assert result is not None # non-empty chain always sets or raises
|
||||
|
||||
content = result.get("content", "") or ""
|
||||
if not content:
|
||||
|
||||
@@ -79,6 +79,8 @@ class LoraService(BaseModelService):
|
||||
),
|
||||
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,33 @@ from ..utils.model_utils import determine_base_model
|
||||
from ..utils.models import autov3_from_civitai_files
|
||||
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
|
||||
from .errors import RateLimitError
|
||||
from .model_sources import has_external_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]:
|
||||
"""Concatenate two word lists, dropping duplicates without reordering.
|
||||
|
||||
Trigger word order is meaningful: the sequence stored in
|
||||
``civitai.trainedWords`` is the order used when building prompts, and users
|
||||
can reorder it in the UI. A plain ``set`` union used to shuffle that order on
|
||||
every metadata refresh, so existing words are kept first (in their saved
|
||||
order) and newly discovered ones are appended.
|
||||
"""
|
||||
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for word in list(existing) + list(new):
|
||||
if word in seen:
|
||||
continue
|
||||
seen.add(word)
|
||||
merged.append(word)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
class MetadataProviderProtocol(Protocol):
|
||||
"""Subset of metadata provider interface consumed by the sync service."""
|
||||
|
||||
@@ -114,9 +137,10 @@ class MetadataSyncService:
|
||||
)
|
||||
|
||||
if "trainedWords" in existing_civitai:
|
||||
existing_trained = existing_civitai.get("trainedWords", [])
|
||||
new_trained = civitai_metadata.get("trainedWords", [])
|
||||
merged_trained = list(set(existing_trained + new_trained))
|
||||
existing_trained = existing_civitai.get("trainedWords", []) or []
|
||||
new_trained = civitai_metadata.get("trainedWords", []) or []
|
||||
# Order preserving merge: the saved order drives prompt order.
|
||||
merged_trained = _merge_ordered_unique(existing_trained, new_trained)
|
||||
merged_civitai["trainedWords"] = merged_trained
|
||||
|
||||
local_metadata["civitai"] = merged_civitai
|
||||
@@ -222,9 +246,10 @@ class MetadataSyncService:
|
||||
error_msg = "CivitAI model is deleted and no archive provider is available"
|
||||
return False, error_msg
|
||||
else:
|
||||
is_hf_source = bool(model_data.get("hf_url"))
|
||||
is_hf_source = has_external_source(model_data)
|
||||
if is_hf_source:
|
||||
# HF-sourced model: only check CivitAI API directly.
|
||||
# External-source model (Hugging Face / ModelScope /
|
||||
# TensorArt): 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
|
||||
|
||||
@@ -33,6 +33,11 @@ class ModelCache:
|
||||
|
||||
raw_data: List[Dict[str, Any]]
|
||||
folders: List[str]
|
||||
# Every directory under the model roots (including empty ones), as
|
||||
# recorded by the last scan/hydration. ``None`` means "never recorded"
|
||||
# (e.g. a persisted snapshot predating this field) and triggers a
|
||||
# background filesystem backfill in the scanner.
|
||||
all_folders: Optional[List[str]] = None
|
||||
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
# Multi-valued companion to version_index: every local file entry of a
|
||||
|
||||
@@ -2,13 +2,15 @@ import asyncio
|
||||
import fnmatch
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set
|
||||
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 ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -484,6 +486,357 @@ class ModelMoveService:
|
||||
self.scanner = scanner
|
||||
self.model_type = model_type
|
||||
|
||||
async def create_folder(self, folder_path: str) -> Dict[str, Any]:
|
||||
"""Create a directory inside the model library roots.
|
||||
|
||||
Args:
|
||||
folder_path: Absolute path of the directory to create (business
|
||||
path — symlinks are not resolved)
|
||||
|
||||
Returns:
|
||||
Dictionary with success flag, the created path and the
|
||||
library-relative folder name used by folder trees.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
return {"success": False, "error": "Folder path is required"}
|
||||
|
||||
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
|
||||
|
||||
absolute_path = os.path.abspath(folder_path)
|
||||
already_exists = os.path.isdir(absolute_path)
|
||||
os.makedirs(absolute_path, exist_ok=True)
|
||||
|
||||
relative_folder = self._calculate_relative_folder(absolute_path)
|
||||
if relative_folder:
|
||||
await self.scanner.add_known_folder(relative_folder)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||
"folder": relative_folder,
|
||||
"created": not already_exists,
|
||||
}
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.error(f"Error creating folder: {exc}", exc_info=True)
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
def _calculate_relative_folder(self, absolute_path: str) -> str:
|
||||
"""Return the library-relative folder for an absolute directory path."""
|
||||
normalized = os.path.abspath(absolute_path)
|
||||
for root in self.scanner.get_model_roots():
|
||||
abs_root = os.path.abspath(root)
|
||||
try:
|
||||
rel = os.path.relpath(normalized, abs_root)
|
||||
except ValueError:
|
||||
continue
|
||||
if rel == ".":
|
||||
return ""
|
||||
if not rel.startswith(".."):
|
||||
return rel.replace(os.sep, "/")
|
||||
return ""
|
||||
|
||||
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
|
||||
"""Delete a model-free directory inside the model library roots.
|
||||
|
||||
Only directories whose subtree holds no model weight files can be
|
||||
removed: a folder-level cascade would bypass the per-model lifecycle
|
||||
bookkeeping (metadata sidecars, previews, cache entries, pending-delete
|
||||
staging and recipe references), so it is deliberately refused. Leftover
|
||||
non-model files (stray previews, sidecars, ``.bak`` files) are reported
|
||||
in the manifest before they are removed.
|
||||
|
||||
Args:
|
||||
folder_path: Absolute path of the directory to remove (business
|
||||
path — symlinks are not resolved)
|
||||
dry_run: When true, only report what would be removed
|
||||
|
||||
Returns:
|
||||
Dictionary with the success flag plus a removal manifest
|
||||
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
|
||||
``total_bytes``/``restorable``) on success.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
return {"success": False, "error": "Folder path is required"}
|
||||
|
||||
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
|
||||
|
||||
absolute_path = os.path.abspath(folder_path)
|
||||
if os.path.islink(absolute_path):
|
||||
# shutil.rmtree refuses symlinked roots, and silently deleting
|
||||
# the link (leaving the real directory behind) is a separate
|
||||
# decision we do not make here.
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Symlinked folders cannot be deleted",
|
||||
}
|
||||
if not os.path.isdir(absolute_path):
|
||||
return {"success": False, "error": "Folder no longer exists"}
|
||||
|
||||
if self._is_model_root(absolute_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "The library root itself cannot be deleted",
|
||||
}
|
||||
|
||||
manifest = self._collect_folder_manifest(absolute_path)
|
||||
|
||||
if manifest["pending_delete_job"]:
|
||||
return {
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": (
|
||||
"A staged delete is still pending inside this folder; "
|
||||
"wait for the undo window to expire"
|
||||
),
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
if manifest["model_count"] > 0:
|
||||
return {
|
||||
"success": False,
|
||||
"code": "not_empty",
|
||||
"error": (
|
||||
f"Folder still contains {manifest['model_count']} model "
|
||||
"file(s); delete or move them first"
|
||||
),
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
relative_folder = self._calculate_relative_folder(absolute_path)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||
"folder": relative_folder,
|
||||
**manifest,
|
||||
}
|
||||
|
||||
shutil.rmtree(absolute_path)
|
||||
|
||||
await self._forget_folder(relative_folder)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": False,
|
||||
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||
"folder": relative_folder,
|
||||
**manifest,
|
||||
}
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.error(f"Error deleting folder: {exc}", exc_info=True)
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
def _is_model_root(self, absolute_path: str) -> bool:
|
||||
"""Return True when the path *is* one of the configured library roots."""
|
||||
normalized = os.path.normpath(absolute_path)
|
||||
for root in self.scanner.get_model_roots():
|
||||
if os.path.normpath(os.path.abspath(root)) == normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_model_file(file_name: str) -> bool:
|
||||
"""Return True when the file name carries a model weight extension."""
|
||||
return os.path.splitext(file_name)[1].lower() in MODEL_FILE_EXTENSIONS
|
||||
|
||||
def _collect_folder_manifest(self, absolute_path: str) -> Dict[str, Any]:
|
||||
"""Describe everything a recursive delete of *absolute_path* removes.
|
||||
|
||||
Walking is intentional: the scanner cache can be stale, and a model file
|
||||
that appeared on disk since the last scan must still block the delete.
|
||||
Symbolic links are never followed (``os.walk`` default) and are counted
|
||||
separately — ``shutil.rmtree`` unlinks them without touching their
|
||||
targets.
|
||||
"""
|
||||
model_count = 0
|
||||
file_count = 0
|
||||
dir_count = 0
|
||||
symlink_count = 0
|
||||
total_bytes = 0
|
||||
pending_delete_job = False
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(absolute_path):
|
||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||
pending_delete_job = True
|
||||
|
||||
for name in dirnames:
|
||||
if os.path.islink(os.path.join(dirpath, name)):
|
||||
symlink_count += 1
|
||||
else:
|
||||
dir_count += 1
|
||||
|
||||
for name in filenames:
|
||||
full_path = os.path.join(dirpath, name)
|
||||
if os.path.islink(full_path):
|
||||
symlink_count += 1
|
||||
continue
|
||||
if self._is_model_file(name):
|
||||
model_count += 1
|
||||
else:
|
||||
file_count += 1
|
||||
try:
|
||||
total_bytes += os.path.getsize(full_path)
|
||||
except OSError: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
return {
|
||||
"model_count": model_count,
|
||||
"file_count": file_count,
|
||||
"dir_count": dir_count,
|
||||
"symlink_count": symlink_count,
|
||||
"total_bytes": total_bytes,
|
||||
"pending_delete_job": pending_delete_job,
|
||||
# A truly empty directory is the only case an "undo" can restore by
|
||||
# simply recreating it; a folder holding stray files is gone for good.
|
||||
"restorable": (
|
||||
model_count == 0
|
||||
and file_count == 0
|
||||
and dir_count == 0
|
||||
and symlink_count == 0
|
||||
),
|
||||
}
|
||||
|
||||
async def _forget_folder(self, relative_folder: str) -> None:
|
||||
"""Drop a removed directory from the scanner's folder/cache records."""
|
||||
if not relative_folder:
|
||||
return
|
||||
remove_known_folder = getattr(self.scanner, "remove_known_folder", None)
|
||||
if callable(remove_known_folder):
|
||||
await remove_known_folder(relative_folder)
|
||||
|
||||
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
|
||||
"""Rename a directory inside the model library roots.
|
||||
|
||||
Unlike :meth:`delete_folder` this works on folders that hold models.
|
||||
A rename keeps every file, so no per-model lifecycle step is bypassed:
|
||||
the directory is renamed on disk and the affected folder, cache, hash
|
||||
index and metadata-sidecar records are re-keyed onto the new prefix by
|
||||
the scanner.
|
||||
|
||||
Args:
|
||||
folder_path: Absolute path of the directory to rename (business
|
||||
path — symlinks are not resolved)
|
||||
new_name: New leaf name; a single path segment, not a path
|
||||
|
||||
Returns:
|
||||
Dictionary with the success flag, the previous/next library-relative
|
||||
folder names and whether the directory actually moved.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
return {"success": False, "error": "Folder path is required"}
|
||||
|
||||
new_name = str(new_name or "").strip()
|
||||
if not new_name:
|
||||
return {"success": False, "error": "New folder name is required"}
|
||||
if new_name in (".", "..") or any(
|
||||
char in new_name for char in '/\\:*?"<>|'
|
||||
):
|
||||
return {"success": False, "error": "Invalid characters in folder name"}
|
||||
|
||||
_require_path_in_library_roots(folder_path, self.scanner, label="Folder path")
|
||||
|
||||
absolute_path = os.path.abspath(folder_path)
|
||||
if os.path.islink(absolute_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Symlinked folders cannot be renamed",
|
||||
}
|
||||
if not os.path.isdir(absolute_path):
|
||||
return {"success": False, "error": "Folder no longer exists"}
|
||||
|
||||
if self._is_model_root(absolute_path):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "The library root itself cannot be renamed",
|
||||
}
|
||||
|
||||
previous_relative = self._calculate_relative_folder(absolute_path)
|
||||
target = os.path.join(os.path.dirname(absolute_path), new_name)
|
||||
|
||||
if os.path.normpath(target) == os.path.normpath(absolute_path):
|
||||
return {
|
||||
"success": True,
|
||||
"renamed": False,
|
||||
"folder": previous_relative,
|
||||
"previous_folder": previous_relative,
|
||||
"folder_path": absolute_path.replace(os.sep, "/"),
|
||||
}
|
||||
|
||||
if os.path.exists(target):
|
||||
return {
|
||||
"success": False,
|
||||
"code": "target_exists",
|
||||
"error": f"A folder named \"{new_name}\" already exists here",
|
||||
}
|
||||
|
||||
# A staging manifest records absolute original/staged paths, so
|
||||
# moving a folder that holds one would break its undo and purge.
|
||||
if self._has_pending_delete_job(absolute_path):
|
||||
return {
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": (
|
||||
"A staged delete is still pending inside this folder; "
|
||||
"wait for the undo window to expire"
|
||||
),
|
||||
}
|
||||
|
||||
os.rename(absolute_path, target)
|
||||
|
||||
new_relative = self._calculate_relative_folder(target)
|
||||
await self._rename_folder_records(
|
||||
previous_relative, new_relative, absolute_path, target
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"renamed": True,
|
||||
"folder": new_relative,
|
||||
"previous_folder": previous_relative,
|
||||
"folder_path": target.replace(os.sep, "/"),
|
||||
}
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.error(f"Error renaming folder: {exc}", exc_info=True)
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
@staticmethod
|
||||
def _has_pending_delete_job(absolute_path: str) -> bool:
|
||||
"""Return True when a staged-delete batch lives inside the subtree."""
|
||||
for _dirpath, dirnames, _filenames in os.walk(absolute_path):
|
||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _rename_folder_records(
|
||||
self,
|
||||
previous_relative: str,
|
||||
new_relative: str,
|
||||
previous_path: str,
|
||||
new_path: str,
|
||||
) -> None:
|
||||
"""Hand the rename to the scanner so folder/cache records follow it."""
|
||||
if not previous_relative or not new_relative:
|
||||
return
|
||||
rename_known_folder = getattr(self.scanner, "rename_known_folder", None)
|
||||
if callable(rename_known_folder):
|
||||
await rename_known_folder(
|
||||
previous_relative,
|
||||
new_relative,
|
||||
previous_path=previous_path,
|
||||
new_path=new_path,
|
||||
)
|
||||
|
||||
async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
|
||||
"""Move a single model file
|
||||
|
||||
|
||||
+570
-55
@@ -15,6 +15,7 @@ from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
|
||||
from .model_sources import normalize_metadata_source
|
||||
from .service_registry import ServiceRegistry
|
||||
from .websocket_manager import ws_manager
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
@@ -62,9 +63,14 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
|
||||
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
||||
|
||||
|
||||
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
|
||||
# requests (modal open + autocomplete) do not re-walk the model roots.
|
||||
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
|
||||
def _file_name_stem(file_path: str) -> str:
|
||||
"""Return the extension-free file name of a normalized model path.
|
||||
|
||||
``file_name`` cache/sidecar fields are defined as the on-disk stem, so this
|
||||
is the authoritative value to compare stored names against (issue #1112).
|
||||
"""
|
||||
return os.path.splitext(os.path.basename(file_path))[0]
|
||||
|
||||
|
||||
# Maps a scanner model type to the manager page type used in progress
|
||||
# broadcasts (e.g. 'lora' -> 'loras').
|
||||
@@ -72,6 +78,7 @@ PAGE_TYPE_MAP = {
|
||||
'lora': 'loras',
|
||||
'checkpoint': 'checkpoints',
|
||||
'embedding': 'embeddings',
|
||||
'other': 'other',
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +96,10 @@ class CacheBuildResult:
|
||||
hash_index: ModelHashIndex
|
||||
tags_count: Dict[str, int]
|
||||
excluded_models: List[str]
|
||||
# Every directory under the model roots (including empty ones) discovered
|
||||
# during the scan, or None when the source has no folder information
|
||||
# (e.g. a persisted snapshot predating folder recording).
|
||||
all_folders: Optional[List[str]] = None
|
||||
|
||||
class ModelScanner:
|
||||
"""Base service for scanning and managing model files"""
|
||||
@@ -144,8 +155,9 @@ class ModelScanner:
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self._cancel_requested = False # Flag for cancellation
|
||||
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
|
||||
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
|
||||
# Guard against concurrent all-folders backfill walks (cold fallback
|
||||
# for persisted snapshots that predate folder recording).
|
||||
self._all_folders_backfill_running = False
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -208,8 +220,14 @@ class ModelScanner:
|
||||
"""
|
||||
self._cache_version += 1
|
||||
|
||||
def on_library_changed(self) -> None:
|
||||
"""Reset caches when the active library changes."""
|
||||
def on_library_changed(self, reconcile: bool = False) -> None:
|
||||
"""Reset caches when the active library changes.
|
||||
|
||||
When ``reconcile`` is True an incremental reconcile runs right after
|
||||
the cache is re-hydrated, so newly configured roots are scanned and
|
||||
entries for removed roots are purged. Used when scanner-affecting
|
||||
settings (e.g. the Other Models toggles) change.
|
||||
"""
|
||||
self._persistent_cache = get_persistent_cache()
|
||||
self._cache = None
|
||||
self._hash_index = ModelHashIndex()
|
||||
@@ -217,7 +235,6 @@ class ModelScanner:
|
||||
self._excluded_models = []
|
||||
self._is_initializing = False
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self.invalidate_all_folders_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
try:
|
||||
@@ -228,7 +245,7 @@ class ModelScanner:
|
||||
if loop and not loop.is_closed():
|
||||
self._loop = loop
|
||||
self.loop = loop
|
||||
loop.create_task(self.initialize_in_background())
|
||||
loop.create_task(self.initialize_in_background(reconcile=reconcile))
|
||||
|
||||
def _resolve_name_display_mode(self) -> str:
|
||||
"""Return the configured display mode for name sorting."""
|
||||
@@ -380,8 +397,14 @@ class ModelScanner:
|
||||
'civitai': civitai_slim,
|
||||
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||
# External model source (Hugging Face / ModelScope / TensorArt).
|
||||
# `source_url` + `source_platform` are canonical; `hf_url` stays in
|
||||
# sync as a legacy alias (normalised below).
|
||||
'source_platform': get_value('source_platform', '') or '',
|
||||
'source_url': get_value('source_url', '') or '',
|
||||
'hf_url': get_value('hf_url', '') or '',
|
||||
}
|
||||
normalize_metadata_source(entry)
|
||||
|
||||
license_source: Dict[str, Any] = {}
|
||||
if isinstance(civitai_full, Mapping):
|
||||
@@ -459,8 +482,14 @@ class ModelScanner:
|
||||
_, license_flags = resolve_license_info(license_source)
|
||||
entry['license_flags'] = license_flags
|
||||
|
||||
async def initialize_in_background(self) -> None:
|
||||
"""Initialize cache in background using thread pool"""
|
||||
async def initialize_in_background(self, reconcile: bool = False) -> None:
|
||||
"""Initialize cache in background using thread pool
|
||||
|
||||
Args:
|
||||
reconcile: When True and a persisted snapshot is hydrated, run an
|
||||
incremental reconcile afterwards so the cache matches the
|
||||
current root configuration.
|
||||
"""
|
||||
try:
|
||||
# Set initial empty cache to avoid None reference errors
|
||||
if self._cache is None:
|
||||
@@ -500,6 +529,11 @@ class ModelScanner:
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} cache hydrated from persisted snapshot with {len(self._cache.raw_data)} models"
|
||||
)
|
||||
if reconcile:
|
||||
# Root configuration changed (e.g. Other Models toggles):
|
||||
# pick up newly enabled folders and drop rows for folders
|
||||
# that are no longer managed.
|
||||
await self.get_cached_data(force_refresh=True)
|
||||
return
|
||||
|
||||
# Persistent load failed; fall back to a full scan
|
||||
@@ -662,21 +696,33 @@ class ModelScanner:
|
||||
if not persisted or not persisted.raw_data:
|
||||
return None
|
||||
|
||||
# Drop entries the scanner no longer manages (e.g. an other-model
|
||||
# sub_type the user just disabled) before rebuilding the indexes, so
|
||||
# hash/autov3 lookups cannot resolve to unmanaged files either.
|
||||
kept_items = [
|
||||
item
|
||||
for item in persisted.raw_data
|
||||
if self._should_keep_cached_entry(item)
|
||||
]
|
||||
kept_paths = {
|
||||
item.get("file_path") for item in kept_items if item.get("file_path")
|
||||
}
|
||||
|
||||
hash_index = ModelHashIndex()
|
||||
for sha_value, path in persisted.hash_rows:
|
||||
if sha_value and path:
|
||||
if sha_value and path and path in kept_paths:
|
||||
hash_index.add_entry(sha_value.lower(), path)
|
||||
|
||||
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
||||
# cover every known autov3 -> path mapping regardless of whether a
|
||||
# sha256 row also exists for the same file.
|
||||
for autov3_value, path in persisted.autov3_hash_rows:
|
||||
if autov3_value and path:
|
||||
if autov3_value and path and path in kept_paths:
|
||||
hash_index.add_autov3(autov3_value.lower(), path)
|
||||
|
||||
tags_count: Dict[str, int] = {}
|
||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
||||
for item in persisted.raw_data:
|
||||
for item in kept_items:
|
||||
# load_cache builds a fresh dict per row, and validate_batch below
|
||||
# works on its own per-entry copy when auto_repair=True, so no
|
||||
# additional dict copy is needed here.
|
||||
@@ -702,7 +748,8 @@ class ModelScanner:
|
||||
raw_data=valid_entries,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=list(persisted.excluded_models)
|
||||
excluded_models=list(persisted.excluded_models),
|
||||
all_folders=list(persisted.all_folders) if persisted.all_folders is not None else None,
|
||||
)
|
||||
return scan_result, invalid_entries
|
||||
|
||||
@@ -737,6 +784,7 @@ class ModelScanner:
|
||||
hash_snapshot,
|
||||
list(scan_result.excluded_models),
|
||||
autov3_snapshot,
|
||||
scan_result.all_folders,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
|
||||
@@ -784,7 +832,12 @@ class ModelScanner:
|
||||
raw_data=list(self._cache.raw_data),
|
||||
hash_index=self._hash_index,
|
||||
tags_count=dict(self._tags_count),
|
||||
excluded_models=list(self._excluded_models)
|
||||
excluded_models=list(self._excluded_models),
|
||||
all_folders=(
|
||||
list(self._cache.all_folders)
|
||||
if self._cache.all_folders is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
await self._save_persistent_cache(snapshot)
|
||||
await self._sync_download_history(snapshot.raw_data, source='scan')
|
||||
@@ -1005,20 +1058,56 @@ class ModelScanner:
|
||||
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
|
||||
|
||||
# Get current cached file paths
|
||||
cached_size_before = len(self._cache.raw_data)
|
||||
cached_paths = {item['file_path'] for item in self._cache.raw_data}
|
||||
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
|
||||
|
||||
# physical path -> cached business path, for the alias case where the
|
||||
# same file is reachable under a different path than the cached one
|
||||
# (overlapping roots / symlink layout changes): keep the existing
|
||||
# entry instead of delete + re-add (which would re-read metadata and
|
||||
# re-hash every file). Built lazily on the first miss, because a
|
||||
# realpath per cached entry is ~half the cost of a no-change
|
||||
# reconcile and the map is only ever consulted for misses.
|
||||
cached_real_paths: Optional[Dict[str, str]] = None
|
||||
|
||||
def lookup_cached_real_path(real_path: str) -> Optional[str]:
|
||||
nonlocal cached_real_paths
|
||||
if cached_real_paths is None:
|
||||
cached_real_paths = {}
|
||||
for cached_path in cached_paths:
|
||||
try:
|
||||
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
||||
except Exception:
|
||||
continue
|
||||
return cached_real_paths.get(real_path)
|
||||
|
||||
# Track found files and new files
|
||||
found_paths = set()
|
||||
new_files = []
|
||||
# Cached entries whose stored file_name no longer matches the file
|
||||
# on disk (e.g. dotted stems truncated by the legacy .civitai.info
|
||||
# migration, issue #1112). Repaired in place after the walk; the
|
||||
# list stays empty on a clean library, so a no-change reconcile
|
||||
# only pays one string compare per cached file.
|
||||
stale_paths: List[str] = []
|
||||
stale_seen: Set[str] = set()
|
||||
|
||||
def mark_stale_if_needed(cached_path: str) -> None:
|
||||
"""Queue a cached path for file_name repair when it drifted."""
|
||||
if cached_path in stale_seen:
|
||||
return
|
||||
item = path_to_item.get(cached_path)
|
||||
if item is None:
|
||||
return
|
||||
if item.get("file_name") == _file_name_stem(cached_path):
|
||||
return
|
||||
stale_seen.add(cached_path)
|
||||
stale_paths.append(cached_path)
|
||||
|
||||
visited_real_paths = set()
|
||||
discovered_real_files = set()
|
||||
discovered_folders: Set[str] = set()
|
||||
|
||||
# Scan all model roots
|
||||
for root_path in self.get_model_roots():
|
||||
@@ -1033,21 +1122,35 @@ class ModelScanner:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
# Record every visited directory (including empty ones) so
|
||||
# the folder tree stays accurate without a live walk.
|
||||
rel_dir = os.path.relpath(
|
||||
os.path.abspath(root), os.path.abspath(root_path)
|
||||
).replace(os.path.sep, "/")
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered_folders.add(rel_dir)
|
||||
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in self.file_extensions:
|
||||
# Construct paths exactly as they would be in cache
|
||||
file_path = os.path.join(root, file).replace(os.sep, '/')
|
||||
real_file_path = os.path.realpath(os.path.join(root, file))
|
||||
|
||||
# Check if this file is already in cache
|
||||
if file_path in cached_paths:
|
||||
found_paths.add(file_path)
|
||||
mark_stale_if_needed(file_path)
|
||||
continue
|
||||
|
||||
cached_real_match = cached_real_paths.get(real_file_path)
|
||||
# Only a cache miss needs the physical path, so the
|
||||
# realpath syscalls are paid per changed file rather
|
||||
# than per file in the library.
|
||||
real_file_path = os.path.realpath(os.path.join(root, file))
|
||||
|
||||
cached_real_match = lookup_cached_real_path(real_file_path)
|
||||
if cached_real_match:
|
||||
found_paths.add(cached_real_match)
|
||||
mark_stale_if_needed(cached_real_match)
|
||||
continue
|
||||
|
||||
if file_path in self._excluded_models:
|
||||
@@ -1060,6 +1163,7 @@ class ModelScanner:
|
||||
for cached_path in cached_paths:
|
||||
if cached_path.lower() == lower_path:
|
||||
found_paths.add(cached_path)
|
||||
mark_stale_if_needed(cached_path)
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
@@ -1090,6 +1194,9 @@ class ModelScanner:
|
||||
total_new = len(new_files)
|
||||
processed_new = 0
|
||||
last_progress_time = time.time()
|
||||
# Snapshot the roots once: this matches the walk above (which
|
||||
# also snapshots them) and avoids a config read per new file.
|
||||
model_roots = self.get_model_roots()
|
||||
for i in range(0, total_new, batch_size):
|
||||
batch = new_files[i:i+batch_size]
|
||||
for path in batch:
|
||||
@@ -1098,12 +1205,10 @@ class ModelScanner:
|
||||
try:
|
||||
# Find the appropriate root path for this file
|
||||
root_path = None
|
||||
model_roots = self.get_model_roots()
|
||||
normalized_path = os.path.normpath(path)
|
||||
for potential_root in model_roots:
|
||||
# Normalize both paths for comparison
|
||||
normalized_path = os.path.normpath(path)
|
||||
normalized_root = os.path.normpath(potential_root)
|
||||
if normalized_path.startswith(normalized_root):
|
||||
if normalized_path.startswith(os.path.normpath(potential_root)):
|
||||
root_path = potential_root
|
||||
break
|
||||
|
||||
@@ -1170,6 +1275,56 @@ class ModelScanner:
|
||||
)
|
||||
return
|
||||
|
||||
# Repair rows whose file_name drifted from the file on disk. Only
|
||||
# mismatching entries are re-read here, so a clean library never
|
||||
# touches metadata during a refresh. Each repair goes through the
|
||||
# single-row update path: load_metadata() normalizes the sidecar
|
||||
# (MetadataManager._normalize_metadata_paths) and
|
||||
# _sync_cache_from_metadata_impl() rewrites one targeted SQL delta
|
||||
# instead of a full cache save, and the mismatch is gone
|
||||
# afterwards, so the work never repeats (issue #1112).
|
||||
total_repaired = 0
|
||||
if stale_paths:
|
||||
logger.info(
|
||||
"%s Scanner: Repairing %d cached entries whose file_name no longer matches the file on disk",
|
||||
self.model_type.capitalize(),
|
||||
len(stale_paths),
|
||||
)
|
||||
for path in stale_paths:
|
||||
if self.is_cancelled():
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile repair cancelled")
|
||||
break
|
||||
try:
|
||||
metadata, _should_skip = await MetadataManager.load_metadata(
|
||||
path, self.model_class
|
||||
)
|
||||
if metadata is None:
|
||||
# Missing or corrupt sidecar: keep the existing row
|
||||
# so a full rebuild can recreate the metadata from
|
||||
# .civitai.info (or defaults) without losing cached
|
||||
# fields such as tags or civitai data.
|
||||
logger.debug(
|
||||
"%s Scanner: Leaving %s unchanged (no usable metadata to repair from)",
|
||||
self.model_type.capitalize(),
|
||||
path,
|
||||
)
|
||||
continue
|
||||
|
||||
payload = metadata.to_dict()
|
||||
unknown_fields = getattr(metadata, "_unknown_fields", None)
|
||||
if isinstance(unknown_fields, dict):
|
||||
payload.update(unknown_fields)
|
||||
|
||||
if await self._sync_cache_from_metadata_impl(path, payload):
|
||||
total_repaired += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"%s Scanner: Failed to repair file_name for %s: %s",
|
||||
self.model_type.capitalize(),
|
||||
path,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Find missing files (in cache but not in filesystem)
|
||||
missing_files = cached_paths - found_paths
|
||||
total_removed = 0
|
||||
@@ -1200,6 +1355,14 @@ class ModelScanner:
|
||||
# Update cache data
|
||||
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
||||
|
||||
# Defensive integrity pass: drop entries sharing a business path.
|
||||
# Duplicates can only be introduced by external code rewriting
|
||||
# raw_data directly or by this pass's own appends, so an unchanged
|
||||
# filesystem walk over a clean cache has nothing to clean. The size
|
||||
# mismatch is an O(1) tell that the snapshot already contained
|
||||
# duplicates; skipping the O(N) pass when it is provably clean is
|
||||
# what keeps a no-change Refresh cheap.
|
||||
if cached_size_before != len(cached_paths) or total_added > 0:
|
||||
dedup_removed = 0
|
||||
seen_paths: set[str] = set()
|
||||
deduped: list[Dict[str, Any]] = []
|
||||
@@ -1219,6 +1382,14 @@ class ModelScanner:
|
||||
self._cache.raw_data = list(reversed(deduped))
|
||||
total_removed += dedup_removed
|
||||
|
||||
# The walk above visited every directory, so refresh the recorded
|
||||
# folder list (including empty folders) even when no model files
|
||||
# changed — e.g. an empty folder was created or removed externally.
|
||||
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
|
||||
folders_changed = self._cache.all_folders != sorted_discovered
|
||||
if folders_changed:
|
||||
self._cache.all_folders = sorted_discovered
|
||||
|
||||
# Resort cache if changes were made
|
||||
if total_added > 0 or total_removed > 0:
|
||||
# Update folders list
|
||||
@@ -1231,8 +1402,14 @@ class ModelScanner:
|
||||
await self._cache.resort()
|
||||
|
||||
await self._persist_current_cache()
|
||||
elif folders_changed:
|
||||
await self._persist_current_cache()
|
||||
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in "
|
||||
f"{time.time() - start_time:.2f} seconds. Added {total_added}, "
|
||||
f"removed {total_removed}, repaired {total_repaired} models."
|
||||
)
|
||||
await self._broadcast_scan_progress(
|
||||
'completed', 'process_new', 100, False,
|
||||
added=total_added, removed=total_removed,
|
||||
@@ -1270,22 +1447,311 @@ class ModelScanner:
|
||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||
|
||||
async def get_all_folders(self) -> List[str]:
|
||||
"""Return every known directory under the model roots.
|
||||
|
||||
The directory list (including empty ones) is recorded during cache
|
||||
scans and hydrated from the persisted snapshot, so this is a pure
|
||||
in-memory read — no filesystem walk ever runs on the event loop
|
||||
(walking network roots synchronously used to freeze the whole
|
||||
server, see issue #1110). The result is unioned with the
|
||||
model-derived folders so it is always a superset of
|
||||
``cache.folders``.
|
||||
|
||||
Cold fallback: when the cache was hydrated from a persisted snapshot
|
||||
that predates folder recording (``all_folders is None``), a one-shot
|
||||
background walk is scheduled off the event loop to backfill and
|
||||
persist the list; until it lands, the models-only folders are
|
||||
returned.
|
||||
"""
|
||||
folders: Set[str] = set()
|
||||
cache = self._cache
|
||||
if cache is not None:
|
||||
folders |= {item.get('folder', '') for item in cache.raw_data}
|
||||
recorded = getattr(cache, 'all_folders', None)
|
||||
if recorded is None:
|
||||
self._schedule_all_folders_backfill()
|
||||
else:
|
||||
folders |= set(recorded)
|
||||
else:
|
||||
self._schedule_all_folders_backfill()
|
||||
|
||||
return sorted(folders, key=lambda x: x.lower())
|
||||
|
||||
async def add_known_folder(self, folder: str) -> None:
|
||||
"""Record a folder (and its parents) in the known folder list.
|
||||
|
||||
Called when a directory is created between scans (e.g. via the
|
||||
create-folder API) so folder trees reflect it immediately without
|
||||
waiting for the next reconciliation. When ``all_folders`` has not
|
||||
been recorded yet (legacy snapshot), this is a no-op — the scheduled
|
||||
backfill walk discovers the directory from disk instead.
|
||||
"""
|
||||
normalized = folder.replace("\\", "/").strip("/")
|
||||
parts = [part for part in normalized.split("/") if part]
|
||||
if not parts:
|
||||
return
|
||||
cache = self._cache
|
||||
if cache is None:
|
||||
return
|
||||
recorded = getattr(cache, "all_folders", None)
|
||||
if recorded is None:
|
||||
return
|
||||
known = set(recorded)
|
||||
for i in range(1, len(parts) + 1):
|
||||
known.add("/".join(parts[:i]))
|
||||
updated = sorted(known, key=lambda x: x.lower())
|
||||
if updated != list(recorded):
|
||||
cache.all_folders = updated
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
async def remove_known_folder(self, folder: str) -> None:
|
||||
"""Forget a folder (and its subtree) that no longer exists on disk.
|
||||
|
||||
Counterpart of :meth:`add_known_folder`, called after a directory is
|
||||
removed between scans (e.g. via the delete-folder API) so folder trees
|
||||
and the move/download destination pickers stop offering it without a
|
||||
full rescan. Ancestors are kept on purpose: every recorded ancestor
|
||||
exists on disk in its own right, so only the removed subtree is dropped.
|
||||
|
||||
Cache entries that referenced the now-missing directory are purged as
|
||||
well, which keeps a stale (phantom) model card from surviving the
|
||||
deletion. When ``all_folders`` has not been recorded yet (legacy
|
||||
snapshot) only the cache purge runs — the scheduled backfill walk
|
||||
rebuilds the folder list from disk.
|
||||
"""
|
||||
normalized = folder.replace("\\", "/").strip("/")
|
||||
if not normalized:
|
||||
return
|
||||
cache = self._cache
|
||||
if cache is None:
|
||||
return
|
||||
|
||||
prefix = f"{normalized}/"
|
||||
|
||||
folders_changed = False
|
||||
recorded = getattr(cache, "all_folders", None)
|
||||
if recorded is not None:
|
||||
updated = [
|
||||
entry
|
||||
for entry in recorded
|
||||
if entry != normalized and not entry.startswith(prefix)
|
||||
]
|
||||
if updated != list(recorded):
|
||||
cache.all_folders = updated
|
||||
folders_changed = True
|
||||
|
||||
stale_paths = [
|
||||
item.get("file_path")
|
||||
for item in (cache.raw_data or [])
|
||||
if self._folder_within(item.get("folder", ""), normalized)
|
||||
]
|
||||
if stale_paths:
|
||||
# The purge persists the cache — including the already updated
|
||||
# all_folders list — and bumps the version itself.
|
||||
await self._batch_update_cache_for_deleted_models(stale_paths)
|
||||
folders = set(item.get("folder", "") for item in cache.raw_data)
|
||||
cache.folders = sorted(folders, key=lambda x: x.lower())
|
||||
elif folders_changed:
|
||||
await self._persist_current_cache()
|
||||
|
||||
self.bump_cache_version()
|
||||
|
||||
@staticmethod
|
||||
def _folder_within(candidate: str, target: str) -> bool:
|
||||
"""Return True when *candidate* is *target* or lives below it."""
|
||||
return candidate == target or candidate.startswith(f"{target}/")
|
||||
|
||||
@staticmethod
|
||||
def _rekey_path(value: str, old_prefix: str, new_prefix: str) -> str:
|
||||
"""Move a stored path (or URL) from *old_prefix* onto *new_prefix*."""
|
||||
if not value:
|
||||
return value
|
||||
normalized = value.replace("\\", "/")
|
||||
if normalized.startswith(old_prefix):
|
||||
return new_prefix + normalized[len(old_prefix):]
|
||||
return value
|
||||
|
||||
async def rename_known_folder(
|
||||
self,
|
||||
previous_folder: str,
|
||||
new_folder: str,
|
||||
*,
|
||||
previous_path: str,
|
||||
new_path: str,
|
||||
) -> bool:
|
||||
"""Re-key folder, cache and metadata records after a directory rename.
|
||||
|
||||
Counterpart of :meth:`add_known_folder` / :meth:`remove_known_folder`.
|
||||
A rename keeps every file, so nothing may be dropped: the recorded
|
||||
folder list, the affected cache entries (``file_path``/``folder``/
|
||||
``preview_url``), the hash index and the on-disk metadata sidecars are
|
||||
all rewritten onto the new prefix. That is what lets a folder full of
|
||||
models be renamed without a rescan and without breaking per-model
|
||||
bookkeeping.
|
||||
|
||||
Args:
|
||||
previous_folder: Library-relative folder name before the rename
|
||||
new_folder: Library-relative folder name after the rename
|
||||
previous_path: Absolute directory path before the rename
|
||||
new_path: Absolute directory path after the rename
|
||||
|
||||
Returns:
|
||||
True when any recorded data was rewritten.
|
||||
"""
|
||||
previous = previous_folder.replace("\\", "/").strip("/")
|
||||
current = new_folder.replace("\\", "/").strip("/")
|
||||
if not previous or not current or previous == current:
|
||||
return False
|
||||
|
||||
old_rel_prefix = f"{previous}/"
|
||||
new_rel_prefix = f"{current}/"
|
||||
old_abs_prefix = f"{str(previous_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
new_abs_prefix = f"{str(new_path).replace(chr(92), '/').rstrip('/')}/"
|
||||
|
||||
cache = self._cache
|
||||
if cache is None:
|
||||
return False
|
||||
|
||||
changed = False
|
||||
|
||||
recorded = getattr(cache, "all_folders", None)
|
||||
if recorded is not None:
|
||||
rekeyed = sorted(
|
||||
(
|
||||
self._rekey_folder_name(entry, previous, old_rel_prefix, new_rel_prefix)
|
||||
for entry in recorded
|
||||
),
|
||||
key=lambda entry: entry.lower(),
|
||||
)
|
||||
if rekeyed != list(recorded):
|
||||
cache.all_folders = rekeyed
|
||||
changed = True
|
||||
|
||||
excluded = getattr(self, "_excluded_models", None)
|
||||
if excluded:
|
||||
rekeyed_excluded = [
|
||||
self._rekey_path(entry, old_abs_prefix, new_abs_prefix)
|
||||
for entry in excluded
|
||||
]
|
||||
if rekeyed_excluded != list(excluded):
|
||||
self._excluded_models = rekeyed_excluded
|
||||
changed = True
|
||||
|
||||
touched: List[Dict[str, Any]] = []
|
||||
for item in cache.raw_data or []:
|
||||
folder_value = item.get("folder", "") or self._calculate_folder(
|
||||
item.get("file_path", "")
|
||||
)
|
||||
if not self._folder_within(folder_value, previous):
|
||||
continue
|
||||
|
||||
old_file_path = item.get("file_path", "")
|
||||
if old_file_path:
|
||||
cache.remove_from_version_index(item)
|
||||
item["file_path"] = self._rekey_path(
|
||||
old_file_path, old_abs_prefix, new_abs_prefix
|
||||
)
|
||||
hash_value = (item.get("sha256") or "").lower()
|
||||
if hash_value:
|
||||
self._hash_index.remove_by_path(old_file_path, hash_value)
|
||||
self._hash_index.add_entry(
|
||||
hash_value, item["file_path"], item.get("autov3") or None
|
||||
)
|
||||
|
||||
item["folder"] = self._rekey_folder_name(
|
||||
folder_value, previous, old_rel_prefix, new_rel_prefix
|
||||
)
|
||||
if item.get("preview_url"):
|
||||
item["preview_url"] = self._rekey_path(
|
||||
item["preview_url"], old_abs_prefix, new_abs_prefix
|
||||
)
|
||||
touched.append(item)
|
||||
|
||||
if touched:
|
||||
changed = True
|
||||
await self._rewrite_sidecar_paths(touched)
|
||||
folders = set(item.get("folder", "") for item in cache.raw_data)
|
||||
cache.folders = sorted(folders, key=lambda x: x.lower())
|
||||
cache.rebuild_version_index()
|
||||
await cache.resort()
|
||||
|
||||
if changed:
|
||||
await self._persist_current_cache()
|
||||
|
||||
self.bump_cache_version()
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _rekey_folder_name(
|
||||
entry: str, previous: str, old_rel_prefix: str, new_rel_prefix: str
|
||||
) -> str:
|
||||
"""Move a library-relative folder name (and its subtree) under a new name."""
|
||||
if entry == previous:
|
||||
return new_rel_prefix.rstrip("/")
|
||||
if entry.startswith(old_rel_prefix):
|
||||
return new_rel_prefix + entry[len(old_rel_prefix):]
|
||||
return entry
|
||||
|
||||
async def _rewrite_sidecar_paths(self, entries: List[Dict[str, Any]]) -> None:
|
||||
"""Point each model's metadata sidecar at its new location.
|
||||
|
||||
Sidecars travel with the renamed directory, so only the recorded
|
||||
``file_path``/``preview_url`` inside them need rewriting. Failures are
|
||||
logged and skipped — a stale sidecar is repaired by the next metadata
|
||||
refresh, and must not abort the rename.
|
||||
"""
|
||||
for item in entries:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
if not os.path.exists(metadata_path):
|
||||
continue
|
||||
try:
|
||||
await self._update_metadata_paths(metadata_path, file_path)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(
|
||||
"Failed to rewrite metadata sidecar %s: %s", metadata_path, exc
|
||||
)
|
||||
|
||||
def _schedule_all_folders_backfill(self) -> None:
|
||||
"""Kick off a one-shot background folder walk if none is running."""
|
||||
if self._all_folders_backfill_running:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
self._all_folders_backfill_running = True
|
||||
loop.create_task(self._run_all_folders_backfill())
|
||||
|
||||
async def _run_all_folders_backfill(self) -> None:
|
||||
"""Walk the roots in a worker thread, then record and persist the result."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
folders = await loop.run_in_executor(None, self._walk_all_folders_sync)
|
||||
cache = self._cache
|
||||
# A scan may have recorded the list while the walk was in flight;
|
||||
# prefer the fresher scan data in that case.
|
||||
if cache is not None and cache.all_folders is None:
|
||||
cache.all_folders = folders
|
||||
await self._persist_current_cache()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"%s Scanner: all-folders backfill failed: %s",
|
||||
self.model_type.capitalize(),
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
self._all_folders_backfill_running = False
|
||||
|
||||
def _walk_all_folders_sync(self) -> List[str]:
|
||||
"""Enumerate every directory under the model roots, live from disk.
|
||||
|
||||
Unlike the models-only ``cache.folders``, this includes empty
|
||||
directories, so it stays accurate even when the in-memory cache was
|
||||
hydrated from a persisted snapshot without a filesystem walk. Hidden
|
||||
directories (any segment starting with '.') and the pending-delete
|
||||
staging dir are excluded. The result is unioned with the model-derived
|
||||
folders so it is always a superset of ``cache.folders``, and cached
|
||||
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
|
||||
Runs in a worker thread. Hidden directories (any segment starting
|
||||
with '.') and the pending-delete staging dir are excluded.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if self._all_folders_ttl_cache is not None:
|
||||
cached_at, cached_folders = self._all_folders_ttl_cache
|
||||
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
|
||||
return cached_folders
|
||||
|
||||
discovered: Set[str] = set()
|
||||
visited_real_paths: Set[str] = set()
|
||||
|
||||
@@ -1307,17 +1773,7 @@ class ModelScanner:
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered.add(rel_dir)
|
||||
|
||||
folders = set(discovered)
|
||||
if self._cache is not None:
|
||||
folders |= {item.get('folder', '') for item in self._cache.raw_data}
|
||||
|
||||
result = sorted(folders, key=lambda x: x.lower())
|
||||
self._all_folders_ttl_cache = (now, result)
|
||||
return result
|
||||
|
||||
def invalidate_all_folders_cache(self) -> None:
|
||||
"""Drop the cached get_all_folders() result (e.g. after a move)."""
|
||||
self._all_folders_ttl_cache = None
|
||||
return sorted(discovered, key=lambda x: x.lower())
|
||||
|
||||
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
|
||||
"""Get model file info and metadata (extensible for different model types)"""
|
||||
@@ -1339,6 +1795,23 @@ class ModelScanner:
|
||||
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
|
||||
return entry
|
||||
|
||||
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
|
||||
"""Hook for subclasses: decide whether a persisted entry is still managed.
|
||||
|
||||
Entries rejected here are dropped (with their hash/autov3 index rows)
|
||||
while hydrating the persisted cache, so a scanner whose configured
|
||||
roots shrank does not surface stale models before the next reconcile.
|
||||
"""
|
||||
return True
|
||||
|
||||
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Hook for subclasses: resolve the location-derived sub_type for a file.
|
||||
|
||||
Returns ``None`` when the model type has no location-derived sub-types
|
||||
(the default), in which case any stored value is left untouched.
|
||||
"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path_value(path: Optional[str]) -> str:
|
||||
if not path:
|
||||
@@ -1403,11 +1876,16 @@ class ModelScanner:
|
||||
|
||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
||||
if file_info:
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
file_info['name'] = file_name
|
||||
local_stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||
# from_civitai_info expects an API-shaped file entry and
|
||||
# strips one extension itself, so hand it the real
|
||||
# basename: passing the already extension-free stem made
|
||||
# it cut dotted names at their last dot ("lora-sd1.5-..."
|
||||
# became "lora-sd1", issue #1112).
|
||||
file_info['name'] = os.path.basename(file_path)
|
||||
|
||||
metadata = cast(Any, self.model_class).from_civitai_info(version_info, file_info, file_path)
|
||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||
metadata.preview_url = find_preview_file(local_stem, os.path.dirname(file_path))
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
|
||||
except Exception as e:
|
||||
@@ -1533,6 +2011,9 @@ class ModelScanner:
|
||||
else:
|
||||
self._cache.raw_data = list(scan_result.raw_data)
|
||||
|
||||
if scan_result.all_folders is not None:
|
||||
self._cache.all_folders = list(scan_result.all_folders)
|
||||
|
||||
# resort() rebuilds folders and the version index on every path, so a
|
||||
# separate rebuild_version_index() call here would be redundant.
|
||||
await self._cache.resort()
|
||||
@@ -1630,6 +2111,7 @@ class ModelScanner:
|
||||
processed_files = 0
|
||||
processed_real_files: Set[str] = set()
|
||||
visited_real_dirs: Set[str] = set()
|
||||
discovered_folders: Set[str] = set()
|
||||
|
||||
async def handle_progress(current_name: str = '') -> None:
|
||||
if progress_callback is None:
|
||||
@@ -1708,6 +2190,13 @@ class ModelScanner:
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
if _is_excluded_dir(entry.name):
|
||||
continue
|
||||
# Record every directory (including empty ones) so
|
||||
# the folder tree can be served without a live walk.
|
||||
rel_dir = os.path.relpath(
|
||||
os.path.abspath(entry.path), os.path.abspath(root_path)
|
||||
).replace(os.path.sep, "/")
|
||||
if not _is_hidden_relative_path(rel_dir):
|
||||
discovered_folders.add(rel_dir)
|
||||
await scan_recursive(entry.path, root_path, visited_paths)
|
||||
except Exception as entry_error:
|
||||
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
||||
@@ -1727,7 +2216,8 @@ class ModelScanner:
|
||||
raw_data=raw_data,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=excluded_models
|
||||
excluded_models=excluded_models,
|
||||
all_folders=sorted(discovered_folders, key=lambda x: x.lower()),
|
||||
)
|
||||
|
||||
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
|
||||
@@ -1869,6 +2359,20 @@ class ModelScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving metadata file: {e}")
|
||||
|
||||
if metadata is not None:
|
||||
# sub_type is derived from the model's location (e.g. a file
|
||||
# moved from a checkpoints root into a unet root becomes a
|
||||
# diffusion_model). Persist the recalculated value into the
|
||||
# moved metadata file so later metadata-driven cache syncs
|
||||
# do not revert the cache entry to the stale sub_type.
|
||||
new_sub_type = self.resolve_sub_type_for_path(target_file)
|
||||
if new_sub_type and metadata.get('sub_type') != new_sub_type:
|
||||
metadata['sub_type'] = new_sub_type
|
||||
try:
|
||||
await MetadataManager.save_metadata(moved_metadata_path, metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Error persisting sub_type for moved model: {e}")
|
||||
|
||||
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
|
||||
|
||||
return {
|
||||
@@ -1970,6 +2474,16 @@ class ModelScanner:
|
||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# The move target may live in directories the last scan never saw;
|
||||
# record the destination folder (and its parents) in the known
|
||||
# folder list so the folder tree reflects it without a rescan.
|
||||
if cache.all_folders is not None and folder_value:
|
||||
parts = folder_value.split("/")
|
||||
known = set(cache.all_folders)
|
||||
for i in range(1, len(parts) + 1):
|
||||
known.add("/".join(parts[:i]))
|
||||
cache.all_folders = sorted(known, key=lambda x: x.lower())
|
||||
|
||||
for tag in cache_entry.get('tags', []):
|
||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||
|
||||
@@ -1977,10 +2491,6 @@ class ModelScanner:
|
||||
|
||||
await cache.resort()
|
||||
|
||||
# A move may have created new directories; drop the cached live-walk
|
||||
# result so the next include_empty request sees them.
|
||||
self.invalidate_all_folders_cache()
|
||||
|
||||
if cache_modified:
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
@@ -2064,6 +2574,11 @@ class ModelScanner:
|
||||
file_path_override=file_path,
|
||||
)
|
||||
|
||||
# Location-derived fields (e.g. the checkpoint sub_type) must be
|
||||
# re-resolved from the file path rather than trusting the on-disk
|
||||
# metadata snapshot, which may predate a cross-root move.
|
||||
desired_entry = self.adjust_cached_entry(desired_entry)
|
||||
|
||||
# Ensure sha256 is populated (defensive — metadata should have it)
|
||||
if (
|
||||
not desired_entry.get("sha256")
|
||||
|
||||
@@ -118,13 +118,15 @@ class ModelServiceFactory:
|
||||
|
||||
|
||||
def register_default_model_types():
|
||||
"""Register the default model types (LoRA, Checkpoint, and Embedding)"""
|
||||
"""Register the default model types (LoRA, Checkpoint, Embedding, and Other)"""
|
||||
from ..services.lora_service import LoraService
|
||||
from ..services.checkpoint_service import CheckpointService
|
||||
from ..services.embedding_service import EmbeddingService
|
||||
from ..services.other_model_service import OtherModelService
|
||||
from ..routes.lora_routes import LoraRoutes
|
||||
from ..routes.checkpoint_routes import CheckpointRoutes
|
||||
from ..routes.embedding_routes import EmbeddingRoutes
|
||||
from ..routes.other_routes import OtherRoutes
|
||||
|
||||
# Register LoRA model type
|
||||
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
||||
@@ -134,3 +136,6 @@ def register_default_model_types():
|
||||
|
||||
# Register Embedding model type
|
||||
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
||||
|
||||
# Register Other model type (VAE, upscaler, text encoder, ...)
|
||||
ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""External model-source providers (Hugging Face, ModelScope, TensorArt).
|
||||
|
||||
This package is the single abstraction over "a site that hosts models and
|
||||
a model card". See :mod:`py.services.model_sources.base` for the provider
|
||||
protocol and :mod:`py.services.model_sources.registry` for the lookup and
|
||||
metadata-normalisation helpers used across the codebase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import (
|
||||
GROUP_PREFIXES,
|
||||
HTTP_TIMEOUT,
|
||||
ModelCardContext,
|
||||
ModelSource,
|
||||
ModelSourceCache,
|
||||
ModelSourceError,
|
||||
SourceRef,
|
||||
USER_AGENT,
|
||||
clean_source_url,
|
||||
fetch_json,
|
||||
fetch_text,
|
||||
filter_weight_files,
|
||||
is_valid_source_id,
|
||||
)
|
||||
from .huggingface import HuggingFaceSource
|
||||
from .hydration import (
|
||||
hydrate_from_source,
|
||||
load_model_card,
|
||||
resolve_site_base_model,
|
||||
)
|
||||
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
||||
from .registry import (
|
||||
LEGACY_HF_URL_FIELD,
|
||||
SOURCE_PLATFORM_FIELD,
|
||||
SOURCE_URL_FIELD,
|
||||
detect_source,
|
||||
downloadable_sources,
|
||||
get_download_source,
|
||||
get_source,
|
||||
get_source_platform,
|
||||
has_external_source,
|
||||
list_sources,
|
||||
normalize_metadata_source,
|
||||
resolve_source_ref,
|
||||
source_group_key,
|
||||
source_label,
|
||||
)
|
||||
from .tensorart import TensorArtSource
|
||||
|
||||
__all__ = [
|
||||
"GROUP_PREFIXES",
|
||||
"HTTP_TIMEOUT",
|
||||
"LEGACY_HF_URL_FIELD",
|
||||
"ModelCardContext",
|
||||
"ModelSource",
|
||||
"ModelSourceCache",
|
||||
"ModelSourceError",
|
||||
"HuggingFaceSource",
|
||||
"ModelScopeIntlSource",
|
||||
"ModelScopeSource",
|
||||
"SOURCE_PLATFORM_FIELD",
|
||||
"SOURCE_URL_FIELD",
|
||||
"SourceRef",
|
||||
"TensorArtSource",
|
||||
"USER_AGENT",
|
||||
"clean_source_url",
|
||||
"detect_source",
|
||||
"downloadable_sources",
|
||||
"fetch_json",
|
||||
"fetch_text",
|
||||
"filter_weight_files",
|
||||
"get_download_source",
|
||||
"get_source",
|
||||
"get_source_platform",
|
||||
"has_external_source",
|
||||
"hydrate_from_source",
|
||||
"is_valid_source_id",
|
||||
"list_sources",
|
||||
"load_model_card",
|
||||
"normalize_metadata_source",
|
||||
"resolve_site_base_model",
|
||||
"resolve_source_ref",
|
||||
"source_group_key",
|
||||
"source_label",
|
||||
]
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Base types for the external model-source provider abstraction.
|
||||
|
||||
A *model source* is a third-party site that hosts model files and a model
|
||||
card (README) describing them — Hugging Face, ModelScope, TensorArt, and
|
||||
whatever gets added later. Everything the rest of the codebase needs to
|
||||
know about such a site is expressed by :class:`ModelSource`:
|
||||
|
||||
* how to recognise one of its URLs (:meth:`ModelSource.parse`)
|
||||
* the canonical page URL for a source id (:meth:`ModelSource.canonical_url`)
|
||||
* how to fetch the model card (:meth:`ModelSource.fetch_model_card`)
|
||||
* how to fetch the extras that live *outside* the README
|
||||
(:meth:`ModelSource.fetch_model_card_context`)
|
||||
* how to turn repository-relative asset paths into absolute URLs
|
||||
(:meth:`ModelSource.asset_base_url`)
|
||||
* which capabilities the site actually supports
|
||||
(``supports_enrichment`` / ``supports_download``)
|
||||
|
||||
Keeping this in one place means the agent pipeline, the scanners, and the
|
||||
HTTP handlers never need site-specific branching.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ...utils.constants import MODEL_FILE_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Shared HTTP timeout for model-card fetches.
|
||||
HTTP_TIMEOUT = 30
|
||||
|
||||
#: User agent used for all model-source HTTP requests.
|
||||
USER_AGENT = "ComfyUI-LoRA-Manager/1.0"
|
||||
|
||||
#: Platform → short prefix used when building version-group keys.
|
||||
#: ``huggingface`` keeps the historical ``hf:`` prefix for backward
|
||||
#: compatibility with already-cached group keys.
|
||||
GROUP_PREFIXES: dict[str, str] = {
|
||||
"huggingface": "hf",
|
||||
"modelscope": "ms",
|
||||
"modelscope-ai": "msai",
|
||||
"tensorart": "ta",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceRef:
|
||||
"""A parsed reference to a model hosted on an external site."""
|
||||
|
||||
platform: str
|
||||
"""Canonical platform id, e.g. ``"huggingface"``."""
|
||||
|
||||
source_id: str
|
||||
"""Site-specific identity, e.g. ``"user/repo"`` or ``"827823520299086029"``."""
|
||||
|
||||
url: str
|
||||
"""Canonical URL of the model page."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelCardContext:
|
||||
"""Site-specific extras that accompany a model's README model card.
|
||||
|
||||
A model card is not always just ``README.md``. ModelScope, for example,
|
||||
keeps the author's summary, the site-curated tags, and the per-file
|
||||
example images in its model-detail API rather than in the repository.
|
||||
Sources with no such extras return an empty context (the default), so
|
||||
every field here must be treated as optional by callers.
|
||||
"""
|
||||
|
||||
description: str = ""
|
||||
"""Author-written summary shown on the model page, outside the README."""
|
||||
|
||||
model_name: str = ""
|
||||
"""Site-published display name for the repository.
|
||||
|
||||
Sites publish this next to the repository id (ModelScope's ``Name``).
|
||||
It is what a CivitAI download would store as the model's name, so the
|
||||
card never has to fall back to the local filename.
|
||||
"""
|
||||
|
||||
model_name_localized: str = ""
|
||||
"""Site-published localized name (ModelScope's ``ChineseName``)."""
|
||||
|
||||
version_name: str = ""
|
||||
"""Site-published label for the requested file's version.
|
||||
|
||||
Resolved per file, like :attr:`example_images`: a repository publishes
|
||||
one label per checkpoint (ModelScope's ``modelVersion.showName``).
|
||||
"""
|
||||
|
||||
license: str = ""
|
||||
"""License the site records for the repository."""
|
||||
|
||||
model_type: str = ""
|
||||
"""Site-reported model type, e.g. ModelScope's ``AigcType`` (``LoRA``)."""
|
||||
|
||||
base_model: str = ""
|
||||
"""Base model as reported by the site (possibly a site-local id)."""
|
||||
|
||||
base_model_aliases: list[str] = field(default_factory=list)
|
||||
"""Other names the site uses for the same base model.
|
||||
|
||||
Sites often publish both a link-style id (``krea/Krea-2-Turbo``) and an
|
||||
internal architecture enum (``KREA_2``). The enum usually normalises
|
||||
cleanly onto this system's canonical vocabulary, so it is the better
|
||||
resolution hint for :mod:`py.services.agent.base_model_resolver`.
|
||||
"""
|
||||
|
||||
official_tags: list[str] = field(default_factory=list)
|
||||
"""Content tags curated by the site itself."""
|
||||
|
||||
example_images: list[str] = field(default_factory=list)
|
||||
"""Absolute URLs of example images for the requested model file."""
|
||||
|
||||
trigger_words: list[str] = field(default_factory=list)
|
||||
"""Trigger words the site records for the requested model file."""
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Return ``True`` when the site contributed nothing extra."""
|
||||
|
||||
return not any(
|
||||
(
|
||||
self.description,
|
||||
self.model_name,
|
||||
self.model_name_localized,
|
||||
self.version_name,
|
||||
self.license,
|
||||
self.model_type,
|
||||
self.base_model,
|
||||
self.base_model_aliases,
|
||||
self.official_tags,
|
||||
self.example_images,
|
||||
self.trigger_words,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ModelSourceError(Exception):
|
||||
"""Raised when a model source cannot satisfy a request.
|
||||
|
||||
Carries the HTTP status the API handler should answer with, so the
|
||||
handlers stay free of per-site error mapping.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, status: int = 502) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class ModelSourceCache:
|
||||
"""Per-run memo shared between the agent pipeline and a model source.
|
||||
|
||||
A collection repository publishes many model files under a single source
|
||||
id, so enriching each file re-fetches the same README and the same
|
||||
repository metadata. One cache is created per enrichment run and thrown
|
||||
away afterwards: nothing is retained across runs (a model card can change
|
||||
at any time), and download URLs are never routed through it.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
#: Provider-agnostic: ``"<platform>:<source_id>"`` → raw README text.
|
||||
self.readmes: Dict[str, str] = {}
|
||||
#: Provider-owned scratch space. Keys must be namespaced by the
|
||||
#: provider (``(platform, kind, source_id)``) so two providers can
|
||||
#: never collide. Only successful results should be stored, so a
|
||||
#: transient failure is still retried for the next file.
|
||||
self.provider: Dict[Any, Any] = {}
|
||||
|
||||
|
||||
#: Repository ids are always exactly ``owner/name``. Components may contain
|
||||
#: dots (``black-forest-labs/FLUX.1-dev``) but must not be empty, ``.`` / ``..``,
|
||||
#: or start with a dot - the id is used as a path segment on disk.
|
||||
_SOURCE_ID_COMPONENT = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
||||
|
||||
|
||||
def is_valid_source_id(source_id: str) -> bool:
|
||||
"""Return ``True`` when *source_id* is a safe ``owner/name`` repository id."""
|
||||
|
||||
if not source_id or not isinstance(source_id, str) or source_id.count("/") != 1:
|
||||
return False
|
||||
owner, name = source_id.split("/", 1)
|
||||
return all(
|
||||
part and part not in (".", "..") and _SOURCE_ID_COMPONENT.match(part)
|
||||
for part in (owner, name)
|
||||
)
|
||||
|
||||
|
||||
async def fetch_text(url: str, *, timeout: int = HTTP_TIMEOUT) -> str:
|
||||
"""Fetch *url* and return its body as text, or ``""`` on any failure.
|
||||
|
||||
Network problems are expected (offline installs, rate limits, dead
|
||||
repos) and must never bubble up into the pipeline, so every error is
|
||||
logged at debug level and normalised to an empty string.
|
||||
"""
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.text()
|
||||
logger.debug("Fetch %s returned HTTP %s", url, resp.status)
|
||||
except Exception as exc: # pragma: no cover - network dependent
|
||||
logger.debug("Failed to fetch %s: %s", url, exc)
|
||||
return ""
|
||||
|
||||
|
||||
async def fetch_json(
|
||||
url: str, *, timeout: int = HTTP_TIMEOUT
|
||||
) -> tuple[int, Any]:
|
||||
"""Fetch *url* and return ``(status, parsed_body)``.
|
||||
|
||||
Unlike :func:`fetch_text` this reports the status, because callers such as
|
||||
the file-listing endpoints need to distinguish "repo not found" (404) from
|
||||
a transport failure. ``parsed_body`` is ``None`` when the response is not
|
||||
JSON or the request failed outright (status ``0``).
|
||||
"""
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
return resp.status, None
|
||||
try:
|
||||
return resp.status, await resp.json(content_type=None)
|
||||
except Exception:
|
||||
return resp.status, None
|
||||
except Exception as exc: # pragma: no cover - network dependent
|
||||
logger.debug("Failed to fetch %s: %s", url, exc)
|
||||
return 0, None
|
||||
|
||||
|
||||
class ModelSource:
|
||||
"""Description and I/O for one external model hosting site."""
|
||||
|
||||
#: Canonical platform id stored in metadata.
|
||||
platform: str = ""
|
||||
|
||||
#: Human-readable name used in UI copy and prompts.
|
||||
label: str = ""
|
||||
|
||||
#: Whether the agent skill can fetch a model card and run AI extraction.
|
||||
supports_enrichment: bool = False
|
||||
|
||||
#: Whether models can be downloaded directly from this site.
|
||||
supports_download: bool = False
|
||||
|
||||
#: Branch used when the caller does not pass an explicit revision.
|
||||
default_revision: str = ""
|
||||
|
||||
#: Sub-directory the "use default paths" template places downloads in.
|
||||
default_subdir: str = ""
|
||||
|
||||
#: Lenient pattern used to recognise URLs already stored in metadata.
|
||||
#: Captures the site-specific source id in group ``id``.
|
||||
url_pattern: re.Pattern[str] | None = None
|
||||
|
||||
#: Strict pattern used to validate user input. Must match the whole URL.
|
||||
strict_url_pattern: re.Pattern[str] | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def parse(self, url: str, *, strict: bool = False) -> Optional[str]:
|
||||
"""Return the source id contained in *url*, or ``None``.
|
||||
|
||||
With ``strict=True`` the URL must match this site's canonical shape
|
||||
exactly (used when validating what a user pasted); with
|
||||
``strict=False`` sub-paths such as ``/resolve/main/file.bin`` are
|
||||
tolerated (used when normalising already-stored values).
|
||||
"""
|
||||
|
||||
if not url or not isinstance(url, str):
|
||||
return None
|
||||
candidate = url.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
pattern = self.strict_url_pattern if strict else self.url_pattern
|
||||
if pattern is None:
|
||||
return None
|
||||
match = pattern.match(candidate)
|
||||
return match.group("id") if match else None
|
||||
|
||||
def ref(self, url: str, *, strict: bool = False) -> Optional[SourceRef]:
|
||||
"""Return a :class:`SourceRef` for *url*, or ``None`` if not ours."""
|
||||
|
||||
source_id = self.parse(url, strict=strict)
|
||||
if not source_id:
|
||||
return None
|
||||
return SourceRef(
|
||||
platform=self.platform,
|
||||
source_id=source_id,
|
||||
url=self.canonical_url(source_id),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# URLs and content
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
"""Return the canonical model-page URL for *source_id*."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
"""Base URL used to resolve repository-relative asset paths."""
|
||||
|
||||
return ""
|
||||
|
||||
def group_key(self, source_id: str) -> str:
|
||||
"""Return the version-group key for *source_id*."""
|
||||
|
||||
prefix = GROUP_PREFIXES.get(self.platform, self.platform)
|
||||
return f"{prefix}:{source_id}"
|
||||
|
||||
async def fetch_model_card(self, source_id: str) -> str:
|
||||
"""Fetch the raw model card (README) markdown for *source_id*."""
|
||||
|
||||
return ""
|
||||
|
||||
async def fetch_model_card_context(
|
||||
self,
|
||||
source_id: str,
|
||||
filename: str = "",
|
||||
*,
|
||||
sha256: str = "",
|
||||
cache: Optional["ModelSourceCache"] = None,
|
||||
) -> ModelCardContext:
|
||||
"""Return the card extras the site keeps outside the README.
|
||||
|
||||
*filename* is the model file's basename (no directory) and *sha256*
|
||||
its content hash; between them they select the right entry when a
|
||||
repository holds several models. A site that records per-file hashes
|
||||
should prefer *sha256*, because it is the only identifier that
|
||||
survives the user renaming the weights.
|
||||
|
||||
*cache* is an optional per-run memo (see :class:`ModelSourceCache`)
|
||||
that lets a provider avoid re-fetching repository-wide data for every
|
||||
file in a collection repository.
|
||||
|
||||
Sites whose model card is fully described by :meth:`fetch_model_card`
|
||||
need no override and inherit this empty context.
|
||||
|
||||
Implementations must never raise: enrichment treats a missing
|
||||
context as "the site had nothing extra to say".
|
||||
"""
|
||||
|
||||
return ModelCardContext()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Download support
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def list_files(
|
||||
self, source_id: str, revision: str = ""
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List downloadable weight files in *source_id*.
|
||||
|
||||
Returns ``[{"filename": <repo-relative path>, "size": <bytes>}]``,
|
||||
largest first, filtered to :data:`MODEL_FILE_EXTENSIONS`. Sites
|
||||
without download support return an empty list.
|
||||
|
||||
Raises :class:`ModelSourceError` when the repository cannot be read,
|
||||
so the handler can surface "not found" separately from a transport
|
||||
failure.
|
||||
"""
|
||||
|
||||
return []
|
||||
|
||||
def file_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
"""Return the direct (redirecting) download URL for one file."""
|
||||
|
||||
raise ModelSourceError(
|
||||
f"{self.label or self.platform} does not support downloads", status=400
|
||||
)
|
||||
|
||||
def resolve_revision(self, revision: str = "") -> str:
|
||||
"""Return *revision*, falling back to this site's default branch."""
|
||||
|
||||
return revision or self.default_revision
|
||||
|
||||
def page_url_for_file(self, source_id: str, filename: str) -> str:
|
||||
"""Return the human-facing page for *filename* inside *source_id*."""
|
||||
|
||||
return self.canonical_url(source_id)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"<ModelSource {self.platform}>"
|
||||
|
||||
|
||||
def clean_source_url(url: Any) -> str:
|
||||
"""Normalise a stored source URL value into a stripped string."""
|
||||
|
||||
if not isinstance(url, str):
|
||||
return ""
|
||||
return url.strip()
|
||||
|
||||
|
||||
def filter_weight_files(entries: Iterable[tuple[str, int]]) -> list[dict[str, Any]]:
|
||||
"""Keep model-weight files from ``(path, size)`` pairs, largest first.
|
||||
|
||||
Every site lists a lot more than weights (READMEs, configs, tokenizers,
|
||||
…); the download picker only ever wants the files ComfyUI can load, which
|
||||
is exactly :data:`MODEL_FILE_EXTENSIONS`.
|
||||
"""
|
||||
|
||||
files = [
|
||||
{"filename": path, "size": int(size or 0)}
|
||||
for path, size in entries
|
||||
if path and os.path.splitext(path)[1].lower() in MODEL_FILE_EXTENSIONS
|
||||
]
|
||||
files.sort(key=lambda entry: entry["size"], reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GROUP_PREFIXES",
|
||||
"HTTP_TIMEOUT",
|
||||
"ModelCardContext",
|
||||
"ModelSource",
|
||||
"ModelSourceCache",
|
||||
"ModelSourceError",
|
||||
"SourceRef",
|
||||
"USER_AGENT",
|
||||
"clean_source_url",
|
||||
"fetch_json",
|
||||
"fetch_text",
|
||||
"filter_weight_files",
|
||||
"is_valid_source_id",
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Hugging Face model source."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .base import (
|
||||
ModelSource,
|
||||
ModelSourceError,
|
||||
fetch_json,
|
||||
fetch_text,
|
||||
filter_weight_files,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Lenient — used to normalise URLs already stored in metadata; tolerates
|
||||
#: sub-paths such as ``/resolve/main/model.safetensors``.
|
||||
_URL_PATTERN = re.compile(
|
||||
r"https?://(?:www\.)?huggingface\.co/(?P<id>[^/?#\s]+/[^/?#\s]+)"
|
||||
)
|
||||
|
||||
#: Strict — validates what the user pasted into the "link model" dialog.
|
||||
_STRICT_URL_PATTERN = re.compile(
|
||||
r"https?://(?:www\.)?huggingface\.co/(?P<id>[^/?#\s]+/[^/?#\s]+)/?$"
|
||||
)
|
||||
|
||||
|
||||
class HuggingFaceSource(ModelSource):
|
||||
"""Hugging Face Hub (``huggingface.co``)."""
|
||||
|
||||
platform = "huggingface"
|
||||
label = "Hugging Face"
|
||||
supports_enrichment = True
|
||||
supports_download = True
|
||||
default_revision = "main"
|
||||
default_subdir = "huggingface"
|
||||
url_pattern = _URL_PATTERN
|
||||
strict_url_pattern = _STRICT_URL_PATTERN
|
||||
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"https://huggingface.co/{source_id}"
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
return f"https://huggingface.co/{source_id}/resolve/{self.resolve_revision(revision)}"
|
||||
|
||||
async def fetch_model_card(self, source_id: str) -> str:
|
||||
"""Fetch ``README.md`` from Hugging Face (tries ``main``, then ``master``)."""
|
||||
|
||||
for branch in ("main", "master"):
|
||||
text = await fetch_text(
|
||||
f"https://huggingface.co/{source_id}/raw/{branch}/README.md"
|
||||
)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
async def list_files(
|
||||
self, source_id: str, revision: str = ""
|
||||
) -> list[dict]:
|
||||
"""List weight files via the Hub tree API.
|
||||
|
||||
The tree endpoint (rather than the model-info endpoint) is used
|
||||
because it reports accurate sizes for LFS-tracked files.
|
||||
"""
|
||||
|
||||
revision = self.resolve_revision(revision)
|
||||
status, payload = await fetch_json(
|
||||
f"https://huggingface.co/api/models/{source_id}/tree/{revision}"
|
||||
)
|
||||
|
||||
if status == 404:
|
||||
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
|
||||
if status != 200 or not isinstance(payload, list):
|
||||
raise ModelSourceError(
|
||||
f"Hugging Face API error while listing '{source_id}' (HTTP {status})"
|
||||
)
|
||||
|
||||
entries = []
|
||||
for entry in payload:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
path = entry.get("path", "")
|
||||
size = entry.get("size", 0) or 0
|
||||
if not size and isinstance(entry.get("lfs"), dict):
|
||||
size = entry["lfs"].get("size", 0) or 0
|
||||
entries.append((path, size))
|
||||
|
||||
return filter_weight_files(entries)
|
||||
|
||||
def file_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
return (
|
||||
f"https://huggingface.co/{source_id}/resolve/"
|
||||
f"{self.resolve_revision(revision)}/{filename}"
|
||||
)
|
||||
|
||||
def page_url_for_file(self, source_id: str, filename: str) -> str:
|
||||
return (
|
||||
f"https://huggingface.co/{source_id}/blob/{self.default_revision}/{filename}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["HuggingFaceSource"]
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Deterministic metadata hydration for freshly downloaded source models.
|
||||
|
||||
A CivitAI download writes a fully-populated metadata sidecar as part of the
|
||||
download itself: the name, the description, the tags, the trigger words and
|
||||
the example images all arrive with the file. A download from an external
|
||||
model source (ModelScope, Hugging Face) has the same information behind a
|
||||
public API, but historically landed as a bare filename plus a source URL that
|
||||
the user had to enrich by hand ("Enrich Metadata with AI").
|
||||
|
||||
This module closes that gap without involving an LLM. It fetches the linked
|
||||
site's model card, hands it to the same :class:`~py.services.agent.post_processor.PostProcessor`
|
||||
the AI skill uses, and writes the result. Everything it applies is data the
|
||||
site published, so it is safe to run automatically on every download and to
|
||||
treat as a fallback for the gaps the LLM would otherwise fill.
|
||||
|
||||
Nothing here may break a download: every failure is logged and normalised to
|
||||
"the site had nothing to contribute".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from .base import ModelCardContext, ModelSourceCache
|
||||
from .registry import get_source, resolve_source_ref
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from .base import ModelSource, SourceRef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: How long a fetched repository payload stays usable. A download batch walks
|
||||
#: a repository's files one HTTP request at a time, and the README plus the
|
||||
#: detail payload describe the *repository*, not the file, so re-fetching them
|
||||
#: per file would be pure waste. They expire so an edited model card is still
|
||||
#: picked up by the next batch.
|
||||
SHARED_CACHE_TTL = 300.0
|
||||
|
||||
#: Upper bound on memoised repositories; a long-running server must not grow
|
||||
#: without limit.
|
||||
SHARED_CACHE_MAX_ENTRIES = 32
|
||||
|
||||
#: ``"<platform>:<source_id>"`` → ``(expiry, memo)``.
|
||||
_shared_caches: dict[str, tuple[float, ModelSourceCache]] = {}
|
||||
|
||||
|
||||
def shared_source_cache(platform: str, source_id: str) -> ModelSourceCache:
|
||||
"""Return a short-lived per-repository memo for download-time hydration."""
|
||||
|
||||
now = time.monotonic()
|
||||
key = f"{platform}:{source_id}"
|
||||
entry = _shared_caches.get(key)
|
||||
if entry is not None and entry[0] > now:
|
||||
return entry[1]
|
||||
|
||||
for expired in [k for k, (expiry, _) in _shared_caches.items() if expiry <= now]:
|
||||
_shared_caches.pop(expired, None)
|
||||
if len(_shared_caches) >= SHARED_CACHE_MAX_ENTRIES:
|
||||
oldest = min(_shared_caches, key=lambda k: _shared_caches[k][0])
|
||||
_shared_caches.pop(oldest, None)
|
||||
|
||||
cache = ModelSourceCache()
|
||||
_shared_caches[key] = (now + SHARED_CACHE_TTL, cache)
|
||||
return cache
|
||||
|
||||
|
||||
def reset_shared_caches() -> None:
|
||||
"""Drop every memoised repository — used by tests."""
|
||||
|
||||
_shared_caches.clear()
|
||||
|
||||
|
||||
async def load_model_card(
|
||||
source: "ModelSource",
|
||||
source_id: str,
|
||||
cache: Optional[ModelSourceCache] = None,
|
||||
) -> str:
|
||||
"""Return *source_id*'s README, reusing *cache* when one is supplied.
|
||||
|
||||
Only successful reads are memoised, leaving a transient failure to be
|
||||
retried for the next file of the same repository.
|
||||
"""
|
||||
|
||||
key = f"{source.platform}:{source_id}"
|
||||
if cache is not None:
|
||||
cached = cache.readmes.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
readme = await source.fetch_model_card(source_id)
|
||||
if cache is not None and readme:
|
||||
cache.readmes[key] = readme
|
||||
return readme or ""
|
||||
|
||||
|
||||
async def resolve_site_base_model(context: ModelCardContext) -> str:
|
||||
"""Resolve the site's base-model hints to a canonical name, or ``""``.
|
||||
|
||||
Sites name base models in their own vocabulary (ModelScope publishes both
|
||||
``krea/Krea-2-Turbo`` and the ``KREA_2_TURBO`` enum). The resolver is
|
||||
strict and only ever returns a name the canonical vocabulary already
|
||||
contains, so an uncertain hint yields ``""`` rather than a plausible-looking
|
||||
wrong value.
|
||||
"""
|
||||
|
||||
hints = [*context.base_model_aliases, context.base_model]
|
||||
if not any(hints):
|
||||
return ""
|
||||
|
||||
# Imported lazily: pulling in the agent package at module scope would make
|
||||
# the model-source package import itself while it is still initialising.
|
||||
try:
|
||||
from ...metadata_ops import list_base_models
|
||||
from ..agent.base_model_resolver import resolve_base_model
|
||||
|
||||
known_names = await list_base_models()
|
||||
except Exception as exc:
|
||||
logger.warning("Could not resolve a site base model: %s", exc)
|
||||
return ""
|
||||
return resolve_base_model(hints, known_names)
|
||||
|
||||
|
||||
async def hydrate_from_source(
|
||||
file_path: str,
|
||||
*,
|
||||
ref: "SourceRef",
|
||||
cache: Optional[ModelSourceCache] = None,
|
||||
) -> list[str]:
|
||||
"""Apply the linked site's published metadata to a downloaded model.
|
||||
|
||||
This is the deterministic counterpart of the ``enrich_hf_metadata`` skill:
|
||||
it produces the same populated model card a CivitAI download produces,
|
||||
without an LLM and without user action.
|
||||
|
||||
Args:
|
||||
file_path: The just-downloaded model file, whose sidecar already
|
||||
carries the SHA256 used to match the right file in a collection
|
||||
repository.
|
||||
ref: The source the file came from.
|
||||
cache: Optional per-call memo; defaults to a short-lived shared one so
|
||||
a batch over one repository fetches its card only once.
|
||||
|
||||
Returns:
|
||||
The names of the metadata fields that changed. Never raises — a site
|
||||
that is down, or an API that changed shape, must not fail a download.
|
||||
"""
|
||||
|
||||
try:
|
||||
source = get_source(ref.platform)
|
||||
if source is None or not source.supports_enrichment:
|
||||
return []
|
||||
|
||||
from ...metadata_ops import read_metadata
|
||||
|
||||
metadata = await read_metadata(file_path)
|
||||
if not metadata:
|
||||
logger.debug("No metadata to hydrate for %s", file_path)
|
||||
return []
|
||||
|
||||
# Only a model that is actually linked to this repository may be
|
||||
# updated. The download path writes those fields just before calling
|
||||
# us; a file that merely shares a name with the requested one must not
|
||||
# be given another model's card.
|
||||
linked = resolve_source_ref(metadata)
|
||||
if linked is None or (linked.platform, linked.source_id) != (
|
||||
ref.platform,
|
||||
ref.source_id,
|
||||
):
|
||||
logger.debug(
|
||||
"Not hydrating %s: linked to %s, not %s",
|
||||
file_path, linked.url if linked else "no model source", ref.url,
|
||||
)
|
||||
return []
|
||||
|
||||
memo = cache if cache is not None else shared_source_cache(
|
||||
ref.platform, ref.source_id
|
||||
)
|
||||
readme = await load_model_card(source, ref.source_id, memo)
|
||||
context = await source.fetch_model_card_context(
|
||||
ref.source_id,
|
||||
os.path.basename(file_path),
|
||||
sha256=(metadata.get("sha256") or "").strip(),
|
||||
cache=memo,
|
||||
)
|
||||
if context.is_empty() and not readme:
|
||||
logger.debug(
|
||||
"No published metadata for %s on %s", ref.source_id, ref.platform
|
||||
)
|
||||
return []
|
||||
|
||||
resolved_base_model = await resolve_site_base_model(context)
|
||||
|
||||
from ..agent.post_processor import PostProcessor
|
||||
|
||||
result = await PostProcessor().process(
|
||||
skill_name="enrich_hf_metadata",
|
||||
model_path=file_path,
|
||||
llm_output={},
|
||||
metadata=metadata,
|
||||
readme_content=readme,
|
||||
source_context=context,
|
||||
resolved_base_model=resolved_base_model,
|
||||
metadata_source=f"source:{ref.platform}",
|
||||
)
|
||||
if not result.get("success", True):
|
||||
logger.debug(
|
||||
"Hydration reported failure for %s: %s",
|
||||
file_path, result.get("errors"),
|
||||
)
|
||||
return []
|
||||
|
||||
updated = list(result.get("updated_fields") or [])
|
||||
logger.info(
|
||||
"Hydrated %s from %s (%s): %s",
|
||||
file_path, source.label or ref.platform, ref.source_id,
|
||||
", ".join(updated) or "nothing to change",
|
||||
)
|
||||
return updated
|
||||
except Exception as exc: # pragma: no cover - defensive by design
|
||||
logger.warning("Source hydration failed for %s: %s", file_path, exc)
|
||||
return []
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SHARED_CACHE_MAX_ENTRIES",
|
||||
"SHARED_CACHE_TTL",
|
||||
"hydrate_from_source",
|
||||
"load_model_card",
|
||||
"reset_shared_caches",
|
||||
"resolve_site_base_model",
|
||||
"shared_source_cache",
|
||||
]
|
||||
@@ -0,0 +1,644 @@
|
||||
"""ModelScope (魔搭社区) model sources.
|
||||
|
||||
ModelScope exposes the same "model card as README.md" convention as
|
||||
Hugging Face, including a YAML frontmatter block that often carries
|
||||
``base_model:`` and ``trigger_words:``. Four public endpoints are used,
|
||||
none of which requires an API key for public models:
|
||||
|
||||
* ``/models/{owner}/{name}/resolve/{revision}/README.md`` — raw model card
|
||||
* ``/api/v1/models/{owner}/{name}/repo?Revision=..&FilePath=README.md`` —
|
||||
the same content through the API, used as a fallback when the resolve
|
||||
URL is unavailable.
|
||||
* ``/api/v1/models/{owner}/{name}`` — the model-detail payload behind the
|
||||
model page. It carries the repository's display name (``Name`` /
|
||||
``ChineseName``), the author's summary (``Description``), the license, the
|
||||
AIGC type, the site tags (``OfficialTags``, falling back to ``Tags``), and,
|
||||
per published version, the model filenames
|
||||
(``MuseInfo.versions[].stats.fileList``) together with that version's label
|
||||
(``modelVersion.showName``), example images (``coverImages``) and trigger
|
||||
words. See :meth:`ModelScopeSource.fetch_model_card_context`.
|
||||
* ``/api/v1/models/{owner}/{name}/repo/files?Revision=..`` — the file
|
||||
listing backing the download picker. It reports real sizes for LFS
|
||||
files (not the pointer size), so no extra HEAD request is needed.
|
||||
|
||||
Downloads go through ``/models/{owner}/{name}/resolve/{revision}/{path}``,
|
||||
which redirects to a CDN URL carrying a time-limited ``auth_key``.
|
||||
Requesting the resolve URL fresh on every attempt (which the shared
|
||||
downloader does, including for resumable Range requests) keeps that key
|
||||
valid; the CDN URL must never be cached.
|
||||
|
||||
The README and the detail payload both describe the whole repository rather
|
||||
than one file, so a per-run ``ModelSourceCache`` keeps them from being read
|
||||
again for every checkpoint of a collection repository.
|
||||
|
||||
Two deployments are served by this module. ``modelscope.cn`` (with
|
||||
``modelscope.com`` as a redirect alias) and ``modelscope.ai`` are *separate
|
||||
catalogues*, not mirrors, so they are registered as distinct sources:
|
||||
:class:`ModelScopeSource` and :class:`ModelScopeIntlSource`. Every URL either
|
||||
class builds is derived from its ``base_url``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Iterable, Optional
|
||||
|
||||
from .base import (
|
||||
ModelCardContext,
|
||||
ModelSource,
|
||||
ModelSourceError,
|
||||
fetch_json,
|
||||
fetch_text,
|
||||
filter_weight_files,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from .base import ModelSourceCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: ModelScope runs two independent catalogues. ``modelscope.com`` is a
|
||||
#: redirect alias of the mainland site, but ``modelscope.ai`` is the
|
||||
#: *international* deployment with its own repository catalogue — a repository
|
||||
#: published on one is routinely absent from the other (``referall13/EM1``
|
||||
#: exists only on ``.ai``, ``jj3550945163/Krea-2-LORA`` only on ``.cn``). The
|
||||
#: host therefore decides which site, API and CDN a model belongs to, and the
|
||||
#: two deployments are registered as separate sources rather than folded into
|
||||
#: one id.
|
||||
_MAINLAND_HOSTS = r"modelscope\.(?:cn|com)"
|
||||
_INTERNATIONAL_HOSTS = r"modelscope\.ai"
|
||||
|
||||
#: Trailing view segments the site appends to a model URL; accepted verbatim
|
||||
#: when the user pastes a browser tab URL.
|
||||
_VIEW_SEGMENTS = r"(?:summary|files|model-file|readme|community|evaluation)?"
|
||||
|
||||
|
||||
def _url_patterns(hosts: str) -> tuple[re.Pattern[str], re.Pattern[str]]:
|
||||
"""Build the lenient and strict model-URL patterns for *hosts*."""
|
||||
|
||||
body = rf"https?://(?:www\.)?(?:{hosts})/models/(?P<id>[^/?#\s]+/[^/?#\s]+)"
|
||||
return re.compile(body), re.compile(rf"{body}/?{_VIEW_SEGMENTS}/?$")
|
||||
|
||||
|
||||
#: ``master`` is ModelScope's default branch; ``main`` is tried as a fallback
|
||||
#: for repos imported from Hugging Face.
|
||||
_REVISIONS = ("master", "main")
|
||||
|
||||
|
||||
class ModelScopeSource(ModelSource):
|
||||
"""ModelScope's mainland site (``modelscope.cn``).
|
||||
|
||||
``modelscope.com`` is accepted as an alias of it. The international
|
||||
deployment is :class:`ModelScopeIntlSource`; everything below is written in
|
||||
terms of ``base_url`` so both share one implementation.
|
||||
"""
|
||||
|
||||
platform = "modelscope"
|
||||
label = "ModelScope"
|
||||
supports_enrichment = True
|
||||
supports_download = True
|
||||
default_revision = "master"
|
||||
default_subdir = "modelscope"
|
||||
|
||||
#: Origin every outgoing URL is built from.
|
||||
base_url = "https://modelscope.cn"
|
||||
|
||||
url_pattern, strict_url_pattern = _url_patterns(_MAINLAND_HOSTS)
|
||||
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"{self.base_url}/models/{source_id}"
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
return (
|
||||
f"{self.base_url}/models/{source_id}/resolve/"
|
||||
f"{self.resolve_revision(revision)}"
|
||||
)
|
||||
|
||||
async def fetch_model_card(self, source_id: str) -> str:
|
||||
"""Fetch the model card, preferring the raw resolve URL."""
|
||||
|
||||
for revision in _REVISIONS:
|
||||
text = await fetch_text(
|
||||
f"{self.base_url}/models/{source_id}/resolve/{revision}/README.md"
|
||||
)
|
||||
if text:
|
||||
return text
|
||||
|
||||
# Fallback: the repo API proxies the same file and is reachable in
|
||||
# environments where the CDN resolve host is blocked.
|
||||
for revision in _REVISIONS:
|
||||
text = await fetch_text(
|
||||
f"{self.base_url}/api/v1/models/"
|
||||
f"{source_id}/repo?Revision={revision}&FilePath=README.md"
|
||||
)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
async def fetch_model_card_context(
|
||||
self,
|
||||
source_id: str,
|
||||
filename: str = "",
|
||||
*,
|
||||
sha256: str = "",
|
||||
cache: Optional["ModelSourceCache"] = None,
|
||||
) -> ModelCardContext:
|
||||
"""Read the model-detail API that backs the ModelScope model page.
|
||||
|
||||
ModelScope splits a model card in two: ``README.md`` holds the
|
||||
long-form content, while the author's summary, the site-curated tags,
|
||||
and the per-file example images live only here. AIGC repositories
|
||||
frequently ship an auto-generated README ("the contributor provided
|
||||
no further description") and put everything useful in ``Description``,
|
||||
so enrichment that reads only the README comes back nearly empty.
|
||||
|
||||
The wanted file is identified by its sha256 when the caller knows it
|
||||
and by *filename* otherwise; see :func:`_matching_versions`. The
|
||||
images and trigger words returned belong to that exact
|
||||
``.safetensors`` — essential for collection repositories, where every
|
||||
checkpoint has its own sample image.
|
||||
|
||||
The detail payload describes the whole repository and is therefore
|
||||
shared across every file in it, so it is read through *cache* when the
|
||||
caller supplies one; only the per-file selection is redone.
|
||||
"""
|
||||
|
||||
data = await self._fetch_detail(source_id, cache=cache)
|
||||
if data is None:
|
||||
return ModelCardContext()
|
||||
return _build_card_context(data, filename, sha256)
|
||||
|
||||
async def _fetch_detail(
|
||||
self,
|
||||
source_id: str,
|
||||
*,
|
||||
cache: Optional["ModelSourceCache"] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Fetch (or reuse) the model-detail payload for *source_id*."""
|
||||
|
||||
cache_key = (self.platform, "detail", source_id)
|
||||
if cache is not None and cache_key in cache.provider:
|
||||
return cache.provider[cache_key]
|
||||
|
||||
status, payload = await fetch_json(
|
||||
f"{self.base_url}/api/v1/models/{source_id}"
|
||||
)
|
||||
if status != 200 or not isinstance(payload, dict):
|
||||
logger.debug(
|
||||
"ModelScope detail API returned HTTP %s for %s", status, source_id
|
||||
)
|
||||
return None
|
||||
data = payload.get("Data")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
if cache is not None:
|
||||
cache.provider[cache_key] = data
|
||||
return data
|
||||
|
||||
async def list_files(
|
||||
self, source_id: str, revision: str = ""
|
||||
) -> list[dict]:
|
||||
"""List weight files via the repo files API.
|
||||
|
||||
``master`` is the only branch name the API accepts — even repos
|
||||
imported from Hugging Face are addressed as ``master`` (``main``
|
||||
returns 404) — so no fallback probing is done here.
|
||||
"""
|
||||
|
||||
revision = self.resolve_revision(revision)
|
||||
status, payload = await fetch_json(
|
||||
f"{self.base_url}/api/v1/models/"
|
||||
f"{source_id}/repo/files?Revision={revision}"
|
||||
)
|
||||
|
||||
if status == 404:
|
||||
raise ModelSourceError(f"Repository '{source_id}' not found", status=404)
|
||||
if status != 200 or not isinstance(payload, dict):
|
||||
raise ModelSourceError(
|
||||
f"ModelScope API error while listing '{source_id}' (HTTP {status})"
|
||||
)
|
||||
|
||||
entries = []
|
||||
for entry in (payload.get("Data") or {}).get("Files") or []:
|
||||
if not isinstance(entry, dict) or entry.get("Type") != "blob":
|
||||
continue
|
||||
entries.append((entry.get("Path", ""), entry.get("Size", 0) or 0))
|
||||
|
||||
return filter_weight_files(entries)
|
||||
|
||||
def file_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
return (
|
||||
f"{self.base_url}/models/{source_id}/resolve/"
|
||||
f"{self.resolve_revision(revision)}/{filename}"
|
||||
)
|
||||
|
||||
def page_url_for_file(self, source_id: str, filename: str) -> str:
|
||||
return (
|
||||
f"{self.base_url}/models/{source_id}/file/view/"
|
||||
f"{self.default_revision}/{filename}"
|
||||
)
|
||||
|
||||
|
||||
class ModelScopeIntlSource(ModelScopeSource):
|
||||
"""ModelScope's international site (``modelscope.ai``).
|
||||
|
||||
A separate catalogue rather than a mirror, so it is registered under its
|
||||
own platform id: the two deployments must not share a version group, a
|
||||
"use default paths" directory, or a stored ``source_url``. The detail API,
|
||||
the file listing, the resolve URLs and the CDN redirect all behave exactly
|
||||
like the mainland site, which is why every URL here is derived from
|
||||
:attr:`base_url` instead of being duplicated.
|
||||
"""
|
||||
|
||||
platform = "modelscope-ai"
|
||||
label = "ModelScope (International)"
|
||||
default_subdir = "modelscope-ai"
|
||||
base_url = "https://www.modelscope.ai"
|
||||
|
||||
url_pattern, strict_url_pattern = _url_patterns(_INTERNATIONAL_HOSTS)
|
||||
|
||||
|
||||
__all__ = ["ModelScopeIntlSource", "ModelScopeSource"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model-detail API parsing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Trigger-word values that mean "the author left this blank".
|
||||
_EMPTY_TRIGGER_VALUES = frozenset({"none", "null", "n/a"})
|
||||
|
||||
#: Repository tags that only restate what the model *is* (its library, task or
|
||||
#: framework) rather than what it depicts. ModelScope mixes both into the
|
||||
#: plain ``Tags`` list, and a card tagged "lora" or "text-to-image" is noise.
|
||||
_GENERIC_TAGS = frozenset(
|
||||
{
|
||||
"any-to-any",
|
||||
"checkpoint",
|
||||
"controlnet",
|
||||
"diffusers",
|
||||
"embedding",
|
||||
"image-text-to-text",
|
||||
"image-to-image",
|
||||
"image-to-video",
|
||||
"lora",
|
||||
"lycoris",
|
||||
"onnx",
|
||||
"pytorch",
|
||||
"safetensors",
|
||||
"tensorflow",
|
||||
"text-to-image",
|
||||
"text-to-speech",
|
||||
"text-to-video",
|
||||
"textual-inversion",
|
||||
"vae",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _clean_text(value: Any) -> str:
|
||||
"""Return a stripped string for *value*, or ``""`` for anything else."""
|
||||
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _first_string(value: Any) -> str:
|
||||
"""Return the first non-empty string in a list, or ``""``."""
|
||||
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
text = _clean_text(item)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _build_card_context(
|
||||
data: dict[str, Any], filename: str, sha256: str = ""
|
||||
) -> ModelCardContext:
|
||||
"""Turn a model-detail payload into a :class:`ModelCardContext`.
|
||||
|
||||
Separated from the HTTP fetch so the repository-wide payload can be cached
|
||||
across the files of a collection repository while the per-file selection
|
||||
is still redone for each one.
|
||||
"""
|
||||
|
||||
context = ModelCardContext(
|
||||
description=_clean_text(data.get("Description")),
|
||||
model_name=_clean_text(data.get("Name")),
|
||||
model_name_localized=_clean_text(data.get("ChineseName")),
|
||||
license=_clean_text(data.get("License")),
|
||||
model_type=_clean_text(data.get("AigcType")),
|
||||
base_model=_first_string(data.get("BaseModel")),
|
||||
base_model_aliases=_base_model_aliases(data),
|
||||
official_tags=_official_tags(data),
|
||||
)
|
||||
|
||||
versions = _matching_versions(
|
||||
data.get("MuseInfo"),
|
||||
filename,
|
||||
digests=_file_digests(data),
|
||||
sha256=sha256,
|
||||
)
|
||||
if versions:
|
||||
context.version_name = _version_label(versions)
|
||||
context.example_images = _cover_image_urls(versions)
|
||||
context.trigger_words = _version_trigger_words(versions)
|
||||
return context
|
||||
|
||||
|
||||
def _base_model_aliases(data: dict[str, Any]) -> list[str]:
|
||||
"""Return the site's own names for the base model.
|
||||
|
||||
ModelScope publishes a link-style id (``krea/Krea-2-Turbo``) plus its
|
||||
internal architecture enums (``VisionFoundation: KREA_2``,
|
||||
``SubVisionFoundation: KREA_2_TURBO``). The enums are the better
|
||||
resolution hint because they normalise onto this system's canonical
|
||||
vocabulary, so they come first; the owner prefix is also stripped from
|
||||
the link-style ids.
|
||||
"""
|
||||
|
||||
aliases: list[str] = []
|
||||
for key in ("VisionFoundation", "SubVisionFoundation"):
|
||||
value = _clean_text(data.get(key))
|
||||
if value and value not in aliases:
|
||||
aliases.append(value)
|
||||
|
||||
base_models = data.get("BaseModel")
|
||||
if isinstance(base_models, list):
|
||||
for item in base_models:
|
||||
text = _clean_text(item)
|
||||
leaf = text.rsplit("/", 1)[-1] if text else ""
|
||||
if leaf and leaf not in aliases:
|
||||
aliases.append(leaf)
|
||||
return aliases
|
||||
|
||||
|
||||
def _official_tags(data: dict[str, Any]) -> list[str]:
|
||||
"""Return the content tags the site publishes for the repository.
|
||||
|
||||
``OfficialTags`` is ModelScope's curated content vocabulary and is
|
||||
preferred whenever it is populated. Plenty of AIGC repositories leave it
|
||||
empty and carry only the plain ``Tags`` list, which mixes content tags with
|
||||
framework and task categories; those categories are dropped so a card is
|
||||
not handed "lora" and "text-to-image" as if they described the model.
|
||||
"""
|
||||
|
||||
curated = _dedupe(_tag_values(data.get("OfficialTags")))
|
||||
if curated:
|
||||
return curated
|
||||
|
||||
generic = set(_GENERIC_TAGS)
|
||||
for value in (
|
||||
data.get("AigcType"),
|
||||
data.get("Libraries"),
|
||||
data.get("Frameworks"),
|
||||
):
|
||||
for item in value if isinstance(value, list) else [value]:
|
||||
text = _clean_text(item).lower()
|
||||
if text:
|
||||
generic.add(text)
|
||||
|
||||
return _dedupe(
|
||||
tag for tag in _tag_values(data.get("Tags")) if tag.lower() not in generic
|
||||
)
|
||||
|
||||
|
||||
def _tag_values(value: Any) -> list[str]:
|
||||
"""Return the tag strings from either shape ModelScope publishes.
|
||||
|
||||
``OfficialTags`` is a list of ``{"Tag": ..., "ChineseName": ...}`` dicts
|
||||
carrying an English value; the plain ``Tags`` list is already strings.
|
||||
"""
|
||||
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
tags: list[str] = []
|
||||
for entry in value:
|
||||
tag = _clean_text(entry.get("Tag") if isinstance(entry, dict) else entry)
|
||||
if tag:
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
def _dedupe(values: Iterable[str]) -> list[str]:
|
||||
"""Drop empties and repeats, keeping the first spelling seen."""
|
||||
|
||||
unique: list[str] = []
|
||||
for value in values:
|
||||
if value and value not in unique:
|
||||
unique.append(value)
|
||||
return unique
|
||||
|
||||
|
||||
def _version_files(version: dict[str, Any]) -> list[str]:
|
||||
"""Return the model filenames covered by one ``MuseInfo.versions`` entry.
|
||||
|
||||
The listing normally sits in ``stats.fileList``; some payloads only
|
||||
carry the same field as a JSON-encoded string under
|
||||
``modelVersion.stats``, so both shapes are accepted.
|
||||
"""
|
||||
|
||||
stats = version.get("stats")
|
||||
files = stats.get("fileList") if isinstance(stats, dict) else None
|
||||
|
||||
if not isinstance(files, list):
|
||||
model_version = version.get("modelVersion")
|
||||
raw = model_version.get("stats") if isinstance(model_version, dict) else None
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
decoded = None
|
||||
if isinstance(decoded, dict):
|
||||
files = decoded.get("fileList")
|
||||
|
||||
if not isinstance(files, list):
|
||||
return []
|
||||
return [item for item in files if isinstance(item, str) and item]
|
||||
|
||||
|
||||
def _version_show_name(version: dict[str, Any]) -> str:
|
||||
"""Return the human-facing version label (e.g. ``c1-st1000``)."""
|
||||
|
||||
model_version = version.get("modelVersion")
|
||||
if not isinstance(model_version, dict):
|
||||
return ""
|
||||
return _clean_text(model_version.get("showName")).lower()
|
||||
|
||||
|
||||
def _version_label(versions: list[dict[str, Any]]) -> str:
|
||||
"""Return the first published version label, preserving its spelling.
|
||||
|
||||
Unlike :func:`_version_show_name` this is for display, so the label is
|
||||
not lowercased.
|
||||
"""
|
||||
|
||||
for version in versions:
|
||||
model_version = version.get("modelVersion")
|
||||
if not isinstance(model_version, dict):
|
||||
continue
|
||||
label = _clean_text(model_version.get("showName"))
|
||||
if label:
|
||||
return label
|
||||
return ""
|
||||
|
||||
|
||||
def _file_digests(data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Return ``basename -> sha256`` for every published weight file.
|
||||
|
||||
``ModelInfos`` groups the repository's files by kind (``safetensor``,
|
||||
…) and records a real sha256 for each, which is what makes it possible to
|
||||
recognise a file the user has renamed.
|
||||
"""
|
||||
|
||||
digests: dict[str, str] = {}
|
||||
model_infos = data.get("ModelInfos")
|
||||
if not isinstance(model_infos, dict):
|
||||
return digests
|
||||
for info in model_infos.values():
|
||||
files = info.get("files") if isinstance(info, dict) else None
|
||||
if not isinstance(files, list):
|
||||
continue
|
||||
for entry in files:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = _clean_text(entry.get("name"))
|
||||
digest = _clean_text(entry.get("sha256"))
|
||||
if name and digest:
|
||||
digests.setdefault(os.path.basename(name).lower(), digest.lower())
|
||||
return digests
|
||||
|
||||
|
||||
def _matching_versions(
|
||||
muse_info: Any,
|
||||
filename: str,
|
||||
*,
|
||||
digests: dict[str, str] | None = None,
|
||||
sha256: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the ``versions`` entries that publish the wanted model file.
|
||||
|
||||
Strategies, in order:
|
||||
|
||||
1. **sha256** — the file's content hash, looked up through
|
||||
:func:`_file_digests`. This is the only strategy that survives the
|
||||
user renaming the weights, which is common once a model is filed away.
|
||||
2. **Exact basename** against each version's ``stats.fileList``.
|
||||
3. **``showName`` inside the file stem**, which absorbs the naming drift
|
||||
ModelScope sometimes applies to uploaded weights.
|
||||
|
||||
A known-but-unmatched hash falls through to the filename strategies
|
||||
rather than giving up, in case the local file was re-encoded. All matches
|
||||
are returned so a file re-published across several versions contributes
|
||||
all of its example images. With no *filename* and no *sha256*, only an
|
||||
unambiguous single-version repository is used, because a per-file image
|
||||
must never be attributed to the wrong file.
|
||||
"""
|
||||
|
||||
if not isinstance(muse_info, dict):
|
||||
return []
|
||||
versions = muse_info.get("versions")
|
||||
if not isinstance(versions, list):
|
||||
return []
|
||||
entries = [entry for entry in versions if isinstance(entry, dict)]
|
||||
if not entries:
|
||||
return []
|
||||
|
||||
target_hash = (sha256 or "").strip().lower()
|
||||
if target_hash:
|
||||
known = digests or {}
|
||||
by_hash: list[dict[str, Any]] = []
|
||||
for version in entries:
|
||||
for path in _version_files(version):
|
||||
if known.get(os.path.basename(path).lower()) == target_hash:
|
||||
by_hash.append(version)
|
||||
break
|
||||
if by_hash:
|
||||
return by_hash
|
||||
|
||||
if not filename:
|
||||
return entries if len(entries) == 1 else []
|
||||
|
||||
target = os.path.basename(filename).strip().lower()
|
||||
if not target:
|
||||
return []
|
||||
stem = os.path.splitext(target)[0]
|
||||
|
||||
exact: list[dict[str, Any]] = []
|
||||
fuzzy: list[dict[str, Any]] = []
|
||||
for version in entries:
|
||||
files = {os.path.basename(path).lower() for path in _version_files(version)}
|
||||
if target in files:
|
||||
exact.append(version)
|
||||
continue
|
||||
show_name = _version_show_name(version)
|
||||
if show_name and show_name in stem:
|
||||
fuzzy.append(version)
|
||||
|
||||
return exact or fuzzy
|
||||
|
||||
|
||||
def _cover_image_urls(versions: list[dict[str, Any]]) -> list[str]:
|
||||
"""Collect the example-image URLs published by the given versions."""
|
||||
|
||||
urls: list[str] = []
|
||||
for version in versions:
|
||||
covers = version.get("coverImages")
|
||||
if not isinstance(covers, list):
|
||||
continue
|
||||
for cover in covers:
|
||||
if not isinstance(cover, dict):
|
||||
continue
|
||||
url = _clean_text(cover.get("url"))
|
||||
if url and url not in urls:
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
|
||||
def _version_trigger_words(versions: list[dict[str, Any]]) -> list[str]:
|
||||
"""Return the first non-empty trigger-word list across *versions*."""
|
||||
|
||||
for version in versions:
|
||||
model_version = version.get("modelVersion")
|
||||
raw = (
|
||||
model_version.get("triggerWords")
|
||||
if isinstance(model_version, dict)
|
||||
else None
|
||||
)
|
||||
words = _parse_trigger_words(raw)
|
||||
if words:
|
||||
return words
|
||||
return []
|
||||
|
||||
|
||||
def _parse_trigger_words(raw: Any) -> list[str]:
|
||||
"""Decode ModelScope's JSON-encoded trigger-word string list."""
|
||||
|
||||
if isinstance(raw, list):
|
||||
candidates = raw
|
||||
elif isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
if not isinstance(decoded, list):
|
||||
return []
|
||||
candidates = decoded
|
||||
else:
|
||||
return []
|
||||
|
||||
words: list[str] = []
|
||||
for item in candidates:
|
||||
word = _clean_text(item)
|
||||
if not word or word.lower() in _EMPTY_TRIGGER_VALUES:
|
||||
continue
|
||||
if word not in words:
|
||||
words.append(word)
|
||||
return words
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Registry and metadata helpers for external model sources.
|
||||
|
||||
The registry is the single place the rest of the codebase asks "which site
|
||||
is this URL from?", "what is this model's source?", and "can we enrich it?".
|
||||
Import from :mod:`py.services.model_sources` rather than this module
|
||||
directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
|
||||
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
|
||||
from .huggingface import HuggingFaceSource
|
||||
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
||||
from .tensorart import TensorArtSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Order matters only for disambiguation; the URL patterns are disjoint.
|
||||
#: ``modelscope.ai`` is a separate catalogue from ``modelscope.cn`` rather than
|
||||
#: an alias, which is why it gets its own entry (see ``modelscope.py``).
|
||||
_SOURCES: tuple[ModelSource, ...] = (
|
||||
HuggingFaceSource(),
|
||||
ModelScopeSource(),
|
||||
ModelScopeIntlSource(),
|
||||
TensorArtSource(),
|
||||
)
|
||||
|
||||
_BY_PLATFORM: Dict[str, ModelSource] = {s.platform: s for s in _SOURCES}
|
||||
|
||||
#: Metadata keys that carry the canonical external-source identity.
|
||||
SOURCE_PLATFORM_FIELD = "source_platform"
|
||||
SOURCE_URL_FIELD = "source_url"
|
||||
#: Legacy field kept as a read/write alias for Hugging Face models so that
|
||||
#: older sidecars, cached rows, and third-party consumers keep working.
|
||||
LEGACY_HF_URL_FIELD = "hf_url"
|
||||
|
||||
|
||||
def list_sources() -> list[ModelSource]:
|
||||
"""Return every known model source."""
|
||||
|
||||
return list(_SOURCES)
|
||||
|
||||
|
||||
def get_source(platform: Optional[str]) -> Optional[ModelSource]:
|
||||
"""Return the source registered for *platform*, or ``None``."""
|
||||
|
||||
if not platform or not isinstance(platform, str):
|
||||
return None
|
||||
return _BY_PLATFORM.get(platform.strip().lower())
|
||||
|
||||
|
||||
def source_label(platform: Optional[str], default: str = "") -> str:
|
||||
"""Return the human-readable label for *platform*."""
|
||||
|
||||
source = get_source(platform)
|
||||
return source.label if source else default
|
||||
|
||||
|
||||
def downloadable_sources() -> list[ModelSource]:
|
||||
"""Return the sources whose repositories can be downloaded directly."""
|
||||
|
||||
return [source for source in _SOURCES if source.supports_download]
|
||||
|
||||
|
||||
def get_download_source(platform: Optional[str]) -> Optional[ModelSource]:
|
||||
"""Return the source for *platform*, but only when it supports downloads."""
|
||||
|
||||
source = get_source(platform)
|
||||
if source is None or not source.supports_download:
|
||||
return None
|
||||
return source
|
||||
|
||||
|
||||
def detect_source(url: Optional[str], *, strict: bool = False) -> Optional[SourceRef]:
|
||||
"""Return the :class:`SourceRef` for *url*, or ``None`` if unsupported."""
|
||||
|
||||
if not url or not isinstance(url, str):
|
||||
return None
|
||||
for source in _SOURCES:
|
||||
ref = source.ref(url, strict=strict)
|
||||
if ref is not None:
|
||||
return ref
|
||||
return None
|
||||
|
||||
|
||||
def resolve_source_ref(metadata: Mapping[str, Any]) -> Optional[SourceRef]:
|
||||
"""Return the source reference described by a model's metadata.
|
||||
|
||||
Handles all three storage states found in the wild:
|
||||
|
||||
1. ``source_url`` + ``source_platform`` (current format)
|
||||
2. ``hf_url`` only (legacy Hugging Face storage)
|
||||
3. ``hf_url`` plus a newer ``source_url`` (both written by older builds)
|
||||
"""
|
||||
|
||||
if not isinstance(metadata, Mapping):
|
||||
return None
|
||||
|
||||
platform = clean_source_url(metadata.get(SOURCE_PLATFORM_FIELD)).lower()
|
||||
url = clean_source_url(metadata.get(SOURCE_URL_FIELD))
|
||||
legacy = clean_source_url(metadata.get(LEGACY_HF_URL_FIELD))
|
||||
|
||||
source = get_source(platform)
|
||||
if url:
|
||||
if source is not None:
|
||||
ref = source.ref(url)
|
||||
if ref is not None:
|
||||
return ref
|
||||
ref = detect_source(url)
|
||||
if ref is not None:
|
||||
return ref
|
||||
# Unknown platform but a URL is present: keep it addressable.
|
||||
return SourceRef(platform=platform or "unknown", source_id="", url=url)
|
||||
|
||||
if legacy:
|
||||
return detect_source(legacy)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_metadata_source(metadata: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Normalise the external-source fields on *metadata* in place.
|
||||
|
||||
Guarantees that ``source_url``/``source_platform`` are present and
|
||||
consistent, and that ``hf_url`` mirrors ``source_url`` for Hugging Face
|
||||
models (never for other platforms, so a stale alias can't make a
|
||||
ModelScope model look like a Hugging Face one).
|
||||
|
||||
Returns the same dict for convenient chaining.
|
||||
"""
|
||||
|
||||
if not isinstance(metadata, dict):
|
||||
return metadata
|
||||
|
||||
platform = clean_source_url(metadata.get(SOURCE_PLATFORM_FIELD)).lower()
|
||||
url = clean_source_url(metadata.get(SOURCE_URL_FIELD))
|
||||
legacy = clean_source_url(metadata.get(LEGACY_HF_URL_FIELD))
|
||||
|
||||
source = get_source(platform)
|
||||
ref: Optional[SourceRef] = None
|
||||
|
||||
if url:
|
||||
ref = source.ref(url) if source is not None else None
|
||||
if ref is None:
|
||||
ref = detect_source(url)
|
||||
elif legacy:
|
||||
ref = detect_source(legacy)
|
||||
|
||||
if ref is not None and ref.source_id:
|
||||
platform = ref.platform
|
||||
url = ref.url or url
|
||||
|
||||
if platform:
|
||||
metadata[SOURCE_PLATFORM_FIELD] = platform
|
||||
else:
|
||||
metadata.setdefault(SOURCE_PLATFORM_FIELD, "")
|
||||
|
||||
metadata[SOURCE_URL_FIELD] = url
|
||||
|
||||
# Keep the legacy alias in sync, but only for Hugging Face.
|
||||
if url and platform == "huggingface":
|
||||
metadata[LEGACY_HF_URL_FIELD] = url
|
||||
elif LEGACY_HF_URL_FIELD in metadata and platform and platform != "huggingface":
|
||||
metadata[LEGACY_HF_URL_FIELD] = ""
|
||||
elif legacy and not url:
|
||||
metadata[LEGACY_HF_URL_FIELD] = legacy
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def has_external_source(item: Mapping[str, Any]) -> bool:
|
||||
"""Return ``True`` when *item* is linked to any external model site."""
|
||||
|
||||
if not isinstance(item, Mapping):
|
||||
return False
|
||||
return bool(
|
||||
clean_source_url(item.get(SOURCE_URL_FIELD))
|
||||
or clean_source_url(item.get(LEGACY_HF_URL_FIELD))
|
||||
)
|
||||
|
||||
|
||||
def get_source_platform(item: Mapping[str, Any]) -> str:
|
||||
"""Return the platform id stored on *item* (may be empty)."""
|
||||
|
||||
if not isinstance(item, Mapping):
|
||||
return ""
|
||||
platform = clean_source_url(item.get(SOURCE_PLATFORM_FIELD)).lower()
|
||||
if platform:
|
||||
return platform
|
||||
ref = resolve_source_ref(item)
|
||||
return ref.platform if ref else ""
|
||||
|
||||
|
||||
def source_group_key(item: Mapping[str, Any]) -> Optional[str]:
|
||||
"""Return the version-group key for *item*, or ``None``.
|
||||
|
||||
Hugging Face keeps the historical ``hf:{owner}/{repo}`` shape; other
|
||||
platforms use their own short prefix (see :data:`GROUP_PREFIXES`).
|
||||
"""
|
||||
|
||||
ref = resolve_source_ref(item)
|
||||
if ref is None or not ref.source_id:
|
||||
return None
|
||||
source = get_source(ref.platform)
|
||||
if source is None:
|
||||
return None
|
||||
return source.group_key(ref.source_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GROUP_PREFIXES",
|
||||
"LEGACY_HF_URL_FIELD",
|
||||
"SOURCE_PLATFORM_FIELD",
|
||||
"SOURCE_URL_FIELD",
|
||||
"detect_source",
|
||||
"downloadable_sources",
|
||||
"get_download_source",
|
||||
"get_source",
|
||||
"get_source_platform",
|
||||
"has_external_source",
|
||||
"list_sources",
|
||||
"normalize_metadata_source",
|
||||
"resolve_source_ref",
|
||||
"source_group_key",
|
||||
"source_label",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""TensorArt model source (link / provenance only).
|
||||
|
||||
TensorArt support is intentionally limited to *linking* a model to its
|
||||
TensorArt page. Automatic metadata extraction is not possible without a
|
||||
user session:
|
||||
|
||||
* ``tensor.art`` sits behind a Cloudflare managed challenge, so plain
|
||||
HTTP clients (aiohttp, requests, curl) receive ``403 "Just a moment..."``.
|
||||
* Its internal API (``ap-east-1.tensorart.cloud`` / ``cn.tensorart.net``)
|
||||
answers every ``/v1/model/*`` route with
|
||||
``{"code":100002,"message":"invalid authorization header"}``.
|
||||
* The official TAMS API requires an AccessKey/SecretKey pair and request
|
||||
signatures, which is a poor fit for a "paste a URL" workflow.
|
||||
|
||||
``supports_enrichment`` is therefore ``False``: the agent pipeline skips
|
||||
these models with an explicit reason instead of failing silently, and the
|
||||
UI keeps showing the "View on TensorArt" link. ``tusi.cn`` is TensorArt's
|
||||
Chinese mirror and is accepted as the same platform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .base import ModelSource
|
||||
|
||||
_DOMAINS = r"(?:tensor\.art|tusi\.cn)"
|
||||
|
||||
_URL_PATTERN = re.compile(
|
||||
rf"https?://(?:www\.)?{_DOMAINS}/models/(?P<id>\d+)"
|
||||
)
|
||||
|
||||
_STRICT_URL_PATTERN = re.compile(
|
||||
rf"https?://(?:www\.)?{_DOMAINS}/models/(?P<id>\d+)(?:/[^/?#\s]+)?/?$"
|
||||
)
|
||||
|
||||
|
||||
class TensorArtSource(ModelSource):
|
||||
"""TensorArt (``tensor.art``)."""
|
||||
|
||||
platform = "tensorart"
|
||||
label = "TensorArt"
|
||||
supports_enrichment = False
|
||||
supports_download = False
|
||||
url_pattern = _URL_PATTERN
|
||||
strict_url_pattern = _STRICT_URL_PATTERN
|
||||
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"https://tensor.art/models/{source_id}"
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
# Unreachable today: enrichment is disabled for this platform.
|
||||
return f"https://tensor.art/models/{source_id}"
|
||||
|
||||
|
||||
__all__ = ["TensorArtSource"]
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
from ..utils.models import OtherModelMetadata
|
||||
from ..config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OtherModelService(BaseModelService):
|
||||
"""Other-model-specific service implementation (VAE, upscaler, text encoder, ...)"""
|
||||
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize Other-model service
|
||||
|
||||
Args:
|
||||
scanner: Other-model scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("other", scanner, OtherModelMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Format other-model 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 = model_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted other-model entry (missing file_path): %s",
|
||||
model_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = model_data.get("sub_type", "vae")
|
||||
|
||||
file_name = model_data.get("file_name") or ""
|
||||
model_name = model_data.get("model_name") or file_name
|
||||
folder = model_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
|
||||
"base_model": model_data.get("base_model", ""),
|
||||
"folder": folder,
|
||||
"sha256": model_data.get("sha256", ""),
|
||||
"autov3": model_data.get("autov3"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": model_data.get("size", 0),
|
||||
"modified": model_data.get("modified", ""),
|
||||
"tags": model_data.get("tags", []),
|
||||
"from_civitai": model_data.get("from_civitai", True),
|
||||
"notes": model_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": model_data.get("favorite", False),
|
||||
"exclude": bool(model_data.get("exclude", False)),
|
||||
"update_available": bool(model_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||
"version_count": model_data.get("version_count"),
|
||||
"source_platform": model_data.get("source_platform", ""),
|
||||
"source_url": model_data.get("source_url", ""),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict[str, Any]:
|
||||
"""Find other models with duplicate SHA256 hashes"""
|
||||
return self.scanner._hash_index.get_duplicate_hashes()
|
||||
|
||||
def find_duplicate_filenames(self) -> Dict[str, Any]:
|
||||
"""Find other models with conflicting filenames"""
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
@@ -0,0 +1,478 @@
|
||||
# pyright: reportImportCycles=false
|
||||
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||
# import cycles. Breaking them would require an architectural refactor.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.models import OtherModelMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OtherScanner(ModelScanner):
|
||||
"""Service for scanning and managing "other" model files.
|
||||
|
||||
Aggregates every enabled folder_paths category from
|
||||
OTHER_MODEL_FOLDER_SUBTYPES (VAE, upscalers, text encoders, CLIP vision,
|
||||
opt-in ControlNet) into one scanner; sub_type is derived from the root
|
||||
containing the file (mirrors CheckpointScanner's checkpoints/unet split).
|
||||
|
||||
Hashing is lazy (checkpoint-style): text encoders can be ~10 GB, so the
|
||||
initial scan records hash_status="pending" and the SHA256 is computed
|
||||
on-demand via calculate_hash_for_model (e.g. when fetching CivitAI
|
||||
metadata).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Same extension set as CheckpointScanner (ComfyUI's
|
||||
# supported_pt_extensions plus ".gguf").
|
||||
file_extensions = {
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".safetensors",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
super().__init__(
|
||||
model_type="other",
|
||||
model_class=OtherModelMetadata,
|
||||
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
|
||||
) -> Optional[OtherModelMetadata]:
|
||||
"""Create default metadata without calculating hash (lazy hash).
|
||||
|
||||
Other models include multi-GB text encoders, so hash calculation is
|
||||
deferred until on-demand (e.g. CivitAI metadata fetch).
|
||||
"""
|
||||
try:
|
||||
real_path = os.path.realpath(file_path)
|
||||
if not os.path.exists(real_path):
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
|
||||
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||
# large files; record the checked state at creation time ("" =
|
||||
# checked but unavailable).
|
||||
autov3 = calculate_autov3(real_path)
|
||||
|
||||
# Create metadata WITHOUT calculating hash
|
||||
metadata = OtherModelMetadata(
|
||||
file_name=base_name,
|
||||
model_name=base_name,
|
||||
file_path=normalize_path(file_path),
|
||||
size=os.path.getsize(real_path),
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256="", # Empty hash - will be calculated on-demand
|
||||
base_model="Unknown",
|
||||
preview_url=normalize_path(preview_url),
|
||||
tags=[],
|
||||
modelDescription="",
|
||||
sub_type=self.resolve_sub_type_for_path(file_path) or "vae",
|
||||
from_civitai=False, # Mark as local model since no hash yet
|
||||
hash_status="pending", # Mark hash as pending
|
||||
autov3=autov3 or "",
|
||||
)
|
||||
|
||||
# Save the created metadata
|
||||
logger.info(f"Creating other-model metadata (hash pending) for {file_path}")
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error creating default other-model metadata for {file_path}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
|
||||
"""Calculate hash for a model on-demand with per-file singleflight.
|
||||
|
||||
Args:
|
||||
file_path: Path to the model file
|
||||
|
||||
Returns:
|
||||
SHA256 hash string, or None if calculation failed
|
||||
"""
|
||||
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,
|
||||
getattr(metadata, "autov3", None) or None,
|
||||
)
|
||||
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,
|
||||
getattr(metadata, "autov3", None) or None,
|
||||
)
|
||||
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 model 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
|
||||
)
|
||||
if metadata is None:
|
||||
if should_skip:
|
||||
logger.error(f"Invalid metadata found for {file_path}")
|
||||
return None
|
||||
created_metadata = await self._create_default_metadata(file_path)
|
||||
if created_metadata is None:
|
||||
logger.error(f"No metadata found for {file_path}")
|
||||
return None
|
||||
metadata = created_metadata
|
||||
|
||||
# Check if hash is already calculated
|
||||
if metadata.hash_status == "completed" and metadata.sha256:
|
||||
# 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,
|
||||
getattr(metadata, "autov3", None) or None,
|
||||
)
|
||||
return metadata.sha256
|
||||
|
||||
# Update status to calculating
|
||||
metadata.hash_status = "calculating"
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
|
||||
# Calculate hash
|
||||
logger.info(f"Calculating hash for other model: {file_path}")
|
||||
sha256 = await calculate_sha256(real_path)
|
||||
|
||||
# Update metadata with hash
|
||||
metadata.sha256 = sha256
|
||||
metadata.hash_status = "completed"
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
|
||||
# Update hash index
|
||||
self._hash_index.add_entry(
|
||||
sha256.lower(),
|
||||
file_path,
|
||||
getattr(metadata, "autov3", None) or None,
|
||||
)
|
||||
|
||||
# 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"
|
||||
self.bump_cache_version()
|
||||
break
|
||||
|
||||
logger.info(f"Hash calculated for other model: {file_path}")
|
||||
return sha256
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||
# Update status to failed
|
||||
try:
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if metadata:
|
||||
metadata.hash_status = "failed"
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def calculate_all_pending_hashes(
|
||||
self, progress_callback=None
|
||||
) -> Dict[str, int]:
|
||||
"""Calculate hashes for all other models with pending hash status.
|
||||
|
||||
If cache is not initialized, scans filesystem directly for metadata files
|
||||
with hash_status != 'completed'.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback(progress, total, current_file)
|
||||
|
||||
Returns:
|
||||
Dict with 'completed', 'failed', 'total' counts
|
||||
"""
|
||||
# Try to get from cache first
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
if cache and cache.raw_data:
|
||||
# Use cache if available
|
||||
pending_models = [
|
||||
item
|
||||
for item in cache.raw_data
|
||||
if item.get("hash_status") != "completed" or not item.get("sha256")
|
||||
]
|
||||
else:
|
||||
# Cache not initialized, scan filesystem directly
|
||||
pending_models = await self._find_pending_models_from_filesystem()
|
||||
|
||||
if not pending_models:
|
||||
return {"completed": 0, "failed": 0, "total": 0}
|
||||
|
||||
total = len(pending_models)
|
||||
completed = 0
|
||||
failed = 0
|
||||
|
||||
for i, model_data in enumerate(pending_models):
|
||||
file_path = model_data.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
try:
|
||||
sha256 = await self.calculate_hash_for_model(file_path)
|
||||
if sha256:
|
||||
completed += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||
failed += 1
|
||||
|
||||
if progress_callback:
|
||||
try:
|
||||
await progress_callback(i + 1, total, file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"completed": completed, "failed": failed, "total": total}
|
||||
|
||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||
"""Scan filesystem for other-model metadata files with pending hash status."""
|
||||
pending_models = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".metadata.json"):
|
||||
continue
|
||||
|
||||
metadata_path = os.path.join(dirpath, filename)
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check if hash is pending
|
||||
hash_status = data.get("hash_status", "completed")
|
||||
sha256 = data.get("sha256", "")
|
||||
|
||||
if hash_status != "completed" or not sha256:
|
||||
# Find corresponding model file
|
||||
model_name = filename.replace(".metadata.json", "")
|
||||
model_path = None
|
||||
|
||||
# Look for model file with matching name
|
||||
for ext in self.file_extensions:
|
||||
potential_path = os.path.join(dirpath, model_name + ext)
|
||||
if os.path.exists(potential_path):
|
||||
model_path = potential_path
|
||||
break
|
||||
|
||||
if model_path:
|
||||
pending_models.append(
|
||||
{
|
||||
"file_path": model_path.replace(os.sep, "/"),
|
||||
"hash_status": hash_status,
|
||||
"sha256": sha256,
|
||||
**{
|
||||
k: v
|
||||
for k, v in data.items()
|
||||
if k
|
||||
not in [
|
||||
"file_path",
|
||||
"hash_status",
|
||||
"sha256",
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.debug(
|
||||
f"Error reading metadata file {metadata_path}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return pending_models
|
||||
|
||||
def _root_sub_type_map(self) -> Dict[str, str]:
|
||||
"""Return the configured business root -> sub_type map."""
|
||||
root_map = getattr(config, "other_root_subtypes", None)
|
||||
return root_map if isinstance(root_map, dict) else {}
|
||||
|
||||
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the sub_type for a configured root path."""
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
normalized_root = self._normalize_path_value(root_path)
|
||||
for root, sub_type in self._root_sub_type_map().items():
|
||||
if self._normalize_path_value(root) == normalized_root:
|
||||
return sub_type
|
||||
|
||||
return None
|
||||
|
||||
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve sub_type from the configured root that contains the file.
|
||||
|
||||
Uses the longest-prefix match so nested roots (e.g. a controlnet root
|
||||
inside a vae root) resolve to the most specific category.
|
||||
"""
|
||||
normalized_path = self._normalize_path_value(file_path)
|
||||
if not normalized_path:
|
||||
return None
|
||||
|
||||
best_length = 0
|
||||
best_sub_type: Optional[str] = None
|
||||
for root, sub_type in self._root_sub_type_map().items():
|
||||
normalized_root = self._normalize_path_value(root)
|
||||
if not normalized_root:
|
||||
continue
|
||||
if (
|
||||
normalized_path == normalized_root
|
||||
or normalized_path.startswith(f"{normalized_root}/")
|
||||
) and len(normalized_root) > best_length:
|
||||
best_length = len(normalized_root)
|
||||
best_sub_type = sub_type
|
||||
|
||||
return best_sub_type
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
"""Adjust metadata during scanning to set sub_type."""
|
||||
sub_type = self._resolve_sub_type(root_path) or self.resolve_sub_type_for_path(
|
||||
file_path
|
||||
)
|
||||
if sub_type:
|
||||
metadata.sub_type = sub_type
|
||||
return metadata
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Adjust entries loaded from the persisted cache to ensure sub_type is set.
|
||||
|
||||
sub_type is location-derived: it is re-derived on cache load, never
|
||||
trusted from the persisted snapshot.
|
||||
"""
|
||||
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
|
||||
if sub_type:
|
||||
entry["sub_type"] = sub_type
|
||||
return entry
|
||||
|
||||
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
|
||||
"""Drop persisted entries whose folder is no longer a managed root.
|
||||
|
||||
sub_type is location-derived and config only maps enabled roots, so a
|
||||
file under a disabled sub_type - or under any other root while the
|
||||
feature is off - resolves to None here and is filtered out while the
|
||||
persisted cache is hydrated.
|
||||
"""
|
||||
return self.resolve_sub_type_for_path(entry.get("file_path")) is not None
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get other-model root directories"""
|
||||
roots: List[str] = []
|
||||
roots.extend(config.other_roots or [])
|
||||
# Remove duplicates while preserving order
|
||||
seen: set[str] = set()
|
||||
unique_roots: List[str] = []
|
||||
for root in roots:
|
||||
if root and root not in seen:
|
||||
seen.add(root)
|
||||
unique_roots.append(root)
|
||||
return unique_roots
|
||||
@@ -59,6 +59,7 @@ _MODEL_TYPE_PAGE_MAP = {
|
||||
"lora": "loras",
|
||||
"checkpoint": "checkpoints",
|
||||
"embedding": "embeddings",
|
||||
"other": "other",
|
||||
}
|
||||
|
||||
# Module-level alias so tests can spy on timer task creation without patching
|
||||
@@ -983,6 +984,7 @@ class PendingDeleteService:
|
||||
"get_lora_scanner",
|
||||
"get_checkpoint_scanner",
|
||||
"get_embedding_scanner",
|
||||
"get_other_scanner",
|
||||
):
|
||||
getter = getattr(ServiceRegistry, getter_name, None)
|
||||
if not callable(getter):
|
||||
|
||||
@@ -6,7 +6,10 @@ import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.file_lock import exclusive_lock
|
||||
from .model_sources import normalize_metadata_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,6 +22,9 @@ class PersistedCacheData:
|
||||
hash_rows: List[Tuple[str, str]]
|
||||
excluded_models: List[str]
|
||||
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
|
||||
# Every directory under the model roots (including empty ones), or None
|
||||
# when the snapshot predates folder recording.
|
||||
all_folders: Optional[List[str]] = None
|
||||
|
||||
|
||||
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
|
||||
@@ -59,6 +65,8 @@ class PersistentModelCache:
|
||||
"db_checked",
|
||||
"last_checked_at",
|
||||
"hash_status",
|
||||
"source_platform",
|
||||
"source_url",
|
||||
"hf_url",
|
||||
)
|
||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||
@@ -128,6 +136,14 @@ class PersistentModelCache:
|
||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
folder_rows = conn.execute(
|
||||
"SELECT path FROM folders WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
folders_recorded = conn.execute(
|
||||
"SELECT value FROM cache_meta WHERE key = ?",
|
||||
(f"folders_recorded:{model_type}",),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
@@ -195,8 +211,13 @@ class PersistentModelCache:
|
||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||
"license_flags": int(license_value),
|
||||
"hash_status": row["hash_status"] or "completed",
|
||||
"source_platform": row["source_platform"] or "",
|
||||
"source_url": row["source_url"] or "",
|
||||
"hf_url": row["hf_url"] or "",
|
||||
}
|
||||
# Legacy rows only carry `hf_url`; derive the canonical pair so
|
||||
# every consumer sees the same shape.
|
||||
normalize_metadata_source(item)
|
||||
if row["autov3"] is not None:
|
||||
item["autov3"] = (row["autov3"] or "").lower()
|
||||
raw_data.append(item)
|
||||
@@ -216,14 +237,20 @@ class PersistentModelCache:
|
||||
]
|
||||
|
||||
excluded_paths = [row["file_path"] for row in excluded]
|
||||
all_folders: Optional[List[str]] = None
|
||||
if folders_recorded is not None:
|
||||
all_folders = sorted(
|
||||
(row["path"] for row in folder_rows), key=lambda x: x.lower()
|
||||
)
|
||||
return PersistedCacheData(
|
||||
raw_data=raw_data,
|
||||
hash_rows=hash_pairs,
|
||||
excluded_models=excluded_paths,
|
||||
autov3_hash_rows=autov3_pairs,
|
||||
all_folders=all_folders,
|
||||
)
|
||||
|
||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None) -> None:
|
||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
if not self._schema_initialized:
|
||||
@@ -232,6 +259,10 @@ class PersistentModelCache:
|
||||
return
|
||||
try:
|
||||
with self._db_lock:
|
||||
# Cross-process serialization: another LoRA Manager instance may
|
||||
# share this settings directory, and the read-merge-write below
|
||||
# spans several statements.
|
||||
with exclusive_lock(self._db_path):
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
@@ -469,6 +500,27 @@ class PersistentModelCache:
|
||||
excluded_inserts,
|
||||
)
|
||||
|
||||
if all_folders is not None:
|
||||
conn.execute(
|
||||
"DELETE FROM folders WHERE model_type = ?",
|
||||
(model_type,),
|
||||
)
|
||||
folder_inserts = [
|
||||
(model_type, path) for path in all_folders if path
|
||||
]
|
||||
if folder_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
|
||||
folder_inserts,
|
||||
)
|
||||
# Mark the snapshot as having folder data even when the
|
||||
# library has no subfolders, so an empty list is not
|
||||
# mistaken for "never recorded" on load.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
|
||||
(f"folders_recorded:{model_type}", "1"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -524,6 +576,8 @@ class PersistentModelCache:
|
||||
db_checked INTEGER,
|
||||
last_checked_at REAL,
|
||||
hash_status TEXT,
|
||||
source_platform TEXT DEFAULT '',
|
||||
source_url TEXT DEFAULT '',
|
||||
hf_url TEXT DEFAULT '',
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
@@ -554,6 +608,17 @@ class PersistentModelCache:
|
||||
file_path TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
model_type TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._ensure_additional_model_columns(conn)
|
||||
@@ -580,6 +645,8 @@ class PersistentModelCache:
|
||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||
"hash_status": "TEXT DEFAULT 'completed'",
|
||||
"source_platform": "TEXT DEFAULT ''",
|
||||
"source_url": "TEXT DEFAULT ''",
|
||||
"hf_url": "TEXT DEFAULT ''",
|
||||
"autov3": "TEXT",
|
||||
}
|
||||
@@ -589,18 +656,19 @@ class PersistentModelCache:
|
||||
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _prepare_model_row(self, model_type: str, item: Dict[str, Any]) -> Tuple[Any, ...]:
|
||||
# Keep `source_*` and the legacy `hf_url` alias consistent no matter
|
||||
# which caller populated the item.
|
||||
normalize_metadata_source(item)
|
||||
civitai = item.get("civitai") or {}
|
||||
trained_words = civitai.get("trainedWords")
|
||||
if isinstance(trained_words, str):
|
||||
@@ -664,6 +732,8 @@ class PersistentModelCache:
|
||||
1 if item.get("db_checked") else 0,
|
||||
float(item.get("last_checked_at") or 0.0),
|
||||
item.get("hash_status", "completed"),
|
||||
item.get("source_platform") or "",
|
||||
item.get("source_url") or "",
|
||||
item.get("hf_url") or "",
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.file_lock import exclusive_lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -170,28 +172,59 @@ class PersistentRecipeCache:
|
||||
recipes: List[Dict[str, Any]],
|
||||
json_paths: Optional[Dict[str, str]] = None,
|
||||
image_id_map: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
skip_if_empty: bool = False,
|
||||
) -> bool:
|
||||
"""Save all recipes to SQLite cache.
|
||||
|
||||
Args:
|
||||
recipes: List of recipe dictionaries to persist.
|
||||
json_paths: Optional mapping of recipe_id -> json_path for file stats.
|
||||
image_id_map: Optional precomputed civitai image_id → recipe_id mapping.
|
||||
skip_if_empty: When True, refuse to replace a non-empty cache with an
|
||||
empty one. This is the storage-level backstop against a scan that
|
||||
silently loses every recipe (unavailable drive / mis-resolved
|
||||
recipes directory): overwriting both deletes the user's data and
|
||||
destroys their only record of it. Intentional full clears (manual
|
||||
rebuild) must pass ``skip_if_empty=False``.
|
||||
|
||||
Returns:
|
||||
``True`` when the write happened, ``False`` when it was skipped.
|
||||
"""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
return False
|
||||
if not self._schema_initialized:
|
||||
self._initialize_schema()
|
||||
if not self._schema_initialized:
|
||||
return
|
||||
return False
|
||||
|
||||
try:
|
||||
with self._db_lock:
|
||||
# Cross-process serialization: another LoRA Manager instance may
|
||||
# share this settings directory, and a full-table replace is a
|
||||
# read-modify-write that SQLite alone cannot make atomic.
|
||||
with exclusive_lock(self._db_path):
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN")
|
||||
|
||||
if skip_if_empty and not recipes:
|
||||
existing = conn.execute(
|
||||
"SELECT COUNT(*) FROM recipes"
|
||||
).fetchone()
|
||||
if existing and existing[0]:
|
||||
conn.rollback()
|
||||
logger.warning(
|
||||
"Refusing to persist an empty recipe cache: the "
|
||||
"stored cache still holds %d recipe(s). The scan "
|
||||
"found nothing, which usually means the recipes "
|
||||
"path was unavailable or resolved elsewhere; "
|
||||
"keeping the stored cache so the data stays "
|
||||
"recoverable.",
|
||||
existing[0],
|
||||
)
|
||||
return False
|
||||
|
||||
# Clear existing data
|
||||
conn.execute("DELETE FROM recipes")
|
||||
|
||||
@@ -225,10 +258,12 @@ class PersistentRecipeCache:
|
||||
|
||||
conn.commit()
|
||||
logger.debug("Persisted %d recipes to cache", len(recipe_rows))
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist recipe cache: %s", exc)
|
||||
return False
|
||||
|
||||
def get_file_stats(self) -> Dict[str, Tuple[float, int]]:
|
||||
"""Return stored file stats for all cached recipes.
|
||||
@@ -486,16 +521,14 @@ class PersistentRecipeCache:
|
||||
logger.warning("Failed to initialize persistent recipe cache schema: %s", exc)
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _prepare_recipe_row(self, recipe: Dict[str, Any], json_path: str) -> Tuple[Any, ...]:
|
||||
"""Convert a recipe dict to a row tuple for SQLite insertion."""
|
||||
|
||||
@@ -16,6 +16,7 @@ import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -633,16 +634,13 @@ class RecipeFTSIndex:
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Create a database connection."""
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
|
||||
"""Remove a recipe entry. Caller must hold the lock."""
|
||||
|
||||
+279
-43
@@ -116,6 +116,12 @@ class RecipeScanner:
|
||||
self._persistent_cache: Optional[PersistentRecipeCache] = None
|
||||
self._civitai_client: Any = None # Lazily initialized from registry
|
||||
self._json_path_map: Dict[str, str] = {} # recipe_id -> json_path
|
||||
# True when the last scan refused to prune the stored cache because
|
||||
# every recorded recipe file was missing (see
|
||||
# :meth:`_initialize_recipe_cache_sync`). Keeps dependent background
|
||||
# work (FTS index) aligned with the stored rows instead of the
|
||||
# intentionally out-of-sync in-memory view.
|
||||
self._prune_skipped: bool = False
|
||||
if lora_scanner:
|
||||
self._lora_scanner = lora_scanner
|
||||
if checkpoint_scanner:
|
||||
@@ -483,32 +489,43 @@ class RecipeScanner:
|
||||
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
|
||||
return suggestions[:limit]
|
||||
|
||||
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
|
||||
def _is_rematch_candidate(
|
||||
self, entry: dict[str, Any], relaxed: bool = False
|
||||
) -> bool:
|
||||
"""Return True when a recipe entry is eligible for local re-matching.
|
||||
|
||||
An entry counts as unresolved when its identity is known to be
|
||||
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
|
||||
identity fields (``hash``/``file_name``). A healthy entry whose
|
||||
hash is simply not present in the local library is NOT a candidate:
|
||||
it may be a recipe imported without downloading the model yet, and
|
||||
its CivitAI-valid hash must never be overwritten by the imprecise
|
||||
filename fallback.
|
||||
hash is simply not present in the local library is NOT a candidate
|
||||
in the default strict mode: it may be a recipe imported without
|
||||
downloading the model yet, and its CivitAI-valid hash must never be
|
||||
overwritten by the imprecise filename fallback.
|
||||
|
||||
With ``relaxed=True`` any entry carrying an identifier is a
|
||||
candidate, including healthy ones — the caller opted into trying to
|
||||
reconnect "Not in Library" entries by file name. Entries without
|
||||
any identifier are never candidates in either mode.
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
unresolved = (
|
||||
entry.get("isDeleted")
|
||||
or entry.get("hashInvalid")
|
||||
or not entry.get("hash")
|
||||
or not entry.get("file_name")
|
||||
)
|
||||
has_identifier = (
|
||||
entry.get("hash")
|
||||
or entry.get("modelVersionId")
|
||||
or entry.get("id")
|
||||
or entry.get("file_name")
|
||||
)
|
||||
return bool(unresolved and has_identifier)
|
||||
if not has_identifier:
|
||||
return False
|
||||
if relaxed:
|
||||
return True
|
||||
unresolved = (
|
||||
entry.get("isDeleted")
|
||||
or entry.get("hashInvalid")
|
||||
or not entry.get("hash")
|
||||
or not entry.get("file_name")
|
||||
)
|
||||
return bool(unresolved)
|
||||
|
||||
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
|
||||
"""Build a version-cached map of computed AutoV3 hashes to local items.
|
||||
@@ -809,7 +826,9 @@ class RecipeScanner:
|
||||
"""Check if cancellation has been requested."""
|
||||
return self._cancel_requested
|
||||
|
||||
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
async def rematch_recipe_by_id(
|
||||
self, recipe_id: str, *, relaxed: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Logs one INFO summary line for this run and delegates the per-recipe
|
||||
@@ -817,12 +836,14 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
|
||||
Raises RecipeNotFoundError when the recipe is missing.
|
||||
"""
|
||||
result = await self._rematch_recipe_by_id(recipe_id)
|
||||
result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed)
|
||||
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
|
||||
logger.info(
|
||||
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
|
||||
@@ -835,7 +856,9 @@ class RecipeScanner:
|
||||
)
|
||||
return result
|
||||
|
||||
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
async def _rematch_recipe_by_id(
|
||||
self, recipe_id: str, *, relaxed: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||
@@ -846,12 +869,16 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch result with unified counters
|
||||
(matched_recipes, matched_entries, unresolved_recipes,
|
||||
unresolved_entries plus the legacy rematched/skipped/errors
|
||||
fields) and a per-entry ``details`` report. The legacy ``skipped``
|
||||
fields) and a per-entry ``details`` report plus a flattened
|
||||
``l4_matches`` list (filename-level matches for review/undo,
|
||||
consistent with the bulk/global paths). The legacy ``skipped``
|
||||
field means "recipe not updated" and overlaps
|
||||
``unresolved_recipes`` (a recipe with unmatched candidates counts
|
||||
as both). Raises RecipeNotFoundError when the recipe is missing.
|
||||
@@ -872,7 +899,8 @@ class RecipeScanner:
|
||||
|
||||
try:
|
||||
rematched, _errors, details = await self._rematch_single_recipe(
|
||||
recipe, local_cache, autov3_cache, filename_cache
|
||||
recipe, local_cache, autov3_cache, filename_cache,
|
||||
relaxed=relaxed,
|
||||
)
|
||||
except RecipePersistenceError as exc:
|
||||
logger.error(
|
||||
@@ -891,12 +919,16 @@ class RecipeScanner:
|
||||
"unresolved_recipes": 0,
|
||||
"unresolved_entries": 0,
|
||||
"details": {"matched": [], "unresolved": []},
|
||||
"l4_matches": [],
|
||||
"recipe": recipe,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
unresolved_entries = len(details["unresolved"])
|
||||
unresolved_recipes = 1 if unresolved_entries > 0 else 0
|
||||
# Flattened L4 matches for the results modal, consistent with
|
||||
# the bulk/global paths.
|
||||
l4_matches = self._collect_l4_matches(recipe_id, details)
|
||||
|
||||
if rematched == 0:
|
||||
return {
|
||||
@@ -908,6 +940,7 @@ class RecipeScanner:
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"details": details,
|
||||
"l4_matches": l4_matches,
|
||||
"recipe": recipe,
|
||||
}
|
||||
|
||||
@@ -921,6 +954,7 @@ class RecipeScanner:
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"details": details,
|
||||
"l4_matches": l4_matches,
|
||||
"recipe": await self.get_recipe_by_id(recipe_id),
|
||||
}
|
||||
|
||||
@@ -930,6 +964,8 @@ class RecipeScanner:
|
||||
local_cache: dict[str, dict[str, Any]],
|
||||
autov3_cache: dict[str, dict[str, Any]],
|
||||
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
||||
*,
|
||||
relaxed: bool = False,
|
||||
) -> Tuple[int, int, Dict[str, Any]]:
|
||||
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
||||
|
||||
@@ -945,16 +981,24 @@ class RecipeScanner:
|
||||
autov3_cache: L3 computed-autov3 cache snapshot
|
||||
filename_cache: L4 filename cache snapshot, or None to disable
|
||||
the filename fallback
|
||||
relaxed: When True, healthy entries ("Not in Library") are also
|
||||
rematch candidates. Anti-churn rule: an entry that is a
|
||||
candidate ONLY because of relaxed mode is skipped when its
|
||||
hash already resolves in the L1 ``local_cache`` — it is
|
||||
already correctly linked and rematching would only add noise
|
||||
and a pointless snapshot.
|
||||
|
||||
Returns:
|
||||
Tuple of (rematched_entries, errors, details). The errors element
|
||||
is always 0 on a normal return — a persistence failure RAISES
|
||||
``RecipePersistenceError`` so callers can count it. ``details``
|
||||
carries the per-entry outcome:
|
||||
``{"matched": [{type, entry, file_name, match_level}],
|
||||
``{"matched": [{type, entry, file_name, match_level, lora_index?}],
|
||||
"unresolved": [{type, entry}]}`` where an unresolved entry is a
|
||||
rematch candidate that found no local match — an expected outcome
|
||||
(the model may simply not exist locally), not an error.
|
||||
``lora_index`` is only present for lora entries (the checkpoint
|
||||
restore endpoint needs no index).
|
||||
|
||||
Raises:
|
||||
RecipePersistenceError: when the recipe changed but
|
||||
@@ -963,11 +1007,23 @@ class RecipeScanner:
|
||||
rematched = 0
|
||||
details: Dict[str, Any] = {"matched": [], "unresolved": []}
|
||||
|
||||
def is_actionable_candidate(entry: Dict[str, Any]) -> bool:
|
||||
"""Apply candidacy plus the relaxed-mode anti-churn rule."""
|
||||
if self._is_rematch_candidate(entry):
|
||||
return True
|
||||
if not relaxed or not self._is_rematch_candidate(entry, relaxed=True):
|
||||
return False
|
||||
# Relaxed-only candidate: skip when the stored hash already
|
||||
# resolves in the L1 local cache — the entry is already correctly
|
||||
# linked and rematching would just add noise and a snapshot.
|
||||
entry_hash = (entry.get("hash") or "").lower()
|
||||
return local_cache.get(entry_hash) is None
|
||||
|
||||
# Lora entries
|
||||
loras = recipe.get("loras", [])
|
||||
if isinstance(loras, list):
|
||||
for entry in loras:
|
||||
if not self._is_rematch_candidate(entry):
|
||||
for lora_index, entry in enumerate(loras):
|
||||
if not is_actionable_candidate(entry):
|
||||
continue
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
entry,
|
||||
@@ -991,6 +1047,7 @@ class RecipeScanner:
|
||||
"entry": self._entry_identifier(entry),
|
||||
"file_name": item.get("file_name") or "",
|
||||
"match_level": level,
|
||||
"lora_index": lora_index,
|
||||
}
|
||||
)
|
||||
self._write_rematch_lora_entry(entry, item)
|
||||
@@ -1000,7 +1057,7 @@ class RecipeScanner:
|
||||
# silently since ``entry.get`` on a str would raise AttributeError).
|
||||
checkpoint = recipe.get("checkpoint")
|
||||
if isinstance(checkpoint, dict):
|
||||
if self._is_rematch_candidate(checkpoint):
|
||||
if is_actionable_candidate(checkpoint):
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
checkpoint,
|
||||
local_cache,
|
||||
@@ -1065,8 +1122,36 @@ class RecipeScanner:
|
||||
self._update_fts_index_for_recipe(recipe, "update")
|
||||
return (rematched, 0, details)
|
||||
|
||||
@staticmethod
|
||||
def _collect_l4_matches(
|
||||
recipe_id: Any, details: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Flatten a recipe's L4 (filename-level) matches for review.
|
||||
|
||||
Returns ``[{recipe_id, type, entry, file_name, lora_index?}]`` rows —
|
||||
one per matched detail at level L4. ``lora_index`` is only present
|
||||
for lora entries (checkpoint restore needs no index).
|
||||
"""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for match in details.get("matched", []):
|
||||
if match.get("match_level") != "L4":
|
||||
continue
|
||||
row: Dict[str, Any] = {
|
||||
"recipe_id": recipe_id,
|
||||
"type": match.get("type"),
|
||||
"entry": match.get("entry"),
|
||||
"file_name": match.get("file_name"),
|
||||
}
|
||||
if "lora_index" in match:
|
||||
row["lora_index"] = match["lora_index"]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
async def rematch_all_recipes(
|
||||
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
||||
self,
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
*,
|
||||
relaxed: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
@@ -1080,14 +1165,19 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for progress updates
|
||||
(started/processing/cancelled/completed events).
|
||||
(started/processing/cancelled/completed events). The
|
||||
completed/cancelled payloads carry ``l4_matches``, a
|
||||
flattened list of filename-level matches for review/undo.
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch run with unified counters
|
||||
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
|
||||
entries plus the legacy success/status/rematched/skipped/errors/
|
||||
total fields). ``rematched`` (legacy) counts updated recipes —
|
||||
use ``matched_entries`` for the entry-level total.
|
||||
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
|
||||
updated recipes — use ``matched_entries`` for the entry-level
|
||||
total.
|
||||
"""
|
||||
start_time = time.perf_counter()
|
||||
|
||||
@@ -1109,6 +1199,7 @@ class RecipeScanner:
|
||||
unresolved_entries = 0
|
||||
skipped_count = 0
|
||||
errors_count = 0
|
||||
l4_matches: List[Dict[str, Any]] = []
|
||||
|
||||
for i, recipe in enumerate(all_recipes):
|
||||
if self.is_cancelled():
|
||||
@@ -1137,6 +1228,7 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -1150,6 +1242,7 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1165,11 +1258,15 @@ class RecipeScanner:
|
||||
)
|
||||
|
||||
rematched, _errors, details = await self._rematch_single_recipe(
|
||||
recipe, local_cache, autov3_cache, filename_cache
|
||||
recipe, local_cache, autov3_cache, filename_cache,
|
||||
relaxed=relaxed,
|
||||
)
|
||||
if rematched > 0:
|
||||
matched_recipes += 1
|
||||
matched_entries += rematched
|
||||
l4_matches.extend(
|
||||
self._collect_l4_matches(recipe.get("id"), details)
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
@@ -1215,6 +1312,7 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1228,9 +1326,12 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
|
||||
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
|
||||
async def rematch_recipes_bulk(
|
||||
self, recipe_ids: List[str], *, relaxed: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Rematch a set of recipes by their IDs.
|
||||
|
||||
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
|
||||
@@ -1241,14 +1342,18 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_ids: List of recipe ids to rematch.
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the bulk run with unified counters
|
||||
(matched_recipes, matched_entries, unresolved_recipes,
|
||||
unresolved_entries plus the legacy total/rematched/skipped/errors
|
||||
fields) and a per-recipe ``details`` list. The legacy ``rematched``
|
||||
field is the total entry count (same as ``matched_entries``) —
|
||||
unlike ``rematch_all_recipes`` where it counts updated recipes.
|
||||
fields), a per-recipe ``details`` list, and ``l4_matches`` — a
|
||||
flattened list of filename-level matches for review/undo. The
|
||||
legacy ``rematched`` field is the total entry count (same as
|
||||
``matched_entries``) — unlike ``rematch_all_recipes`` where it
|
||||
counts updated recipes.
|
||||
"""
|
||||
total = len(recipe_ids)
|
||||
matched_recipes = 0
|
||||
@@ -1259,10 +1364,13 @@ class RecipeScanner:
|
||||
errors = 0
|
||||
recipes: List[Dict[str, Any]] = []
|
||||
details_list: List[Dict[str, Any]] = []
|
||||
l4_matches: List[Dict[str, Any]] = []
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
try:
|
||||
result = await self._rematch_recipe_by_id(recipe_id)
|
||||
result = await self._rematch_recipe_by_id(
|
||||
recipe_id, relaxed=relaxed
|
||||
)
|
||||
if result.get("success"):
|
||||
matched_recipes += result.get("matched_recipes", 0)
|
||||
matched_entries += result.get("matched_entries", 0)
|
||||
@@ -1275,6 +1383,9 @@ class RecipeScanner:
|
||||
details_list.append(
|
||||
{"recipe_id": recipe_id, **result["details"]}
|
||||
)
|
||||
l4_matches.extend(
|
||||
self._collect_l4_matches(recipe_id, result["details"])
|
||||
)
|
||||
else:
|
||||
errors += result.get("errors", 0)
|
||||
except RecipeNotFoundError:
|
||||
@@ -1309,12 +1420,22 @@ class RecipeScanner:
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"recipes": recipes,
|
||||
"details": details_list,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
|
||||
def _write_rematch_lora_entry(
|
||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Write back a matched local model to a lora recipe entry."""
|
||||
# Snapshot the pre-rematch state so the association can be restored
|
||||
# later (undo), mirroring the manual reconnect flow in
|
||||
# ``update_lora_entry``. Never nest snapshots.
|
||||
snapshot = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in entry.items()
|
||||
if key != "reconnectSnapshot"
|
||||
}
|
||||
|
||||
entry["isDeleted"] = False
|
||||
entry["hashInvalid"] = False
|
||||
|
||||
@@ -1338,6 +1459,8 @@ class RecipeScanner:
|
||||
if civitai.get("name"):
|
||||
entry["modelVersionName"] = civitai["name"]
|
||||
|
||||
entry["reconnectSnapshot"] = snapshot
|
||||
|
||||
def _write_rematch_checkpoint_entry(
|
||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||
) -> None:
|
||||
@@ -1349,6 +1472,15 @@ class RecipeScanner:
|
||||
when they already exist on the entry (or written fresh for the
|
||||
identifier key when neither identifier form exists).
|
||||
"""
|
||||
# Snapshot the pre-rematch state so the association can be restored
|
||||
# later (undo), mirroring the manual reconnect flow. Never nest
|
||||
# snapshots.
|
||||
snapshot = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in entry.items()
|
||||
if key != "reconnectSnapshot"
|
||||
}
|
||||
|
||||
entry["isDeleted"] = False
|
||||
entry["hashInvalid"] = False
|
||||
|
||||
@@ -1389,6 +1521,8 @@ class RecipeScanner:
|
||||
else:
|
||||
entry["modelVersionId"] = civ_id
|
||||
|
||||
entry["reconnectSnapshot"] = snapshot
|
||||
|
||||
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
||||
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
||||
recipe_id = recipe.get("id")
|
||||
@@ -1523,7 +1657,11 @@ class RecipeScanner:
|
||||
'pageType': 'recipes',
|
||||
})
|
||||
self._schedule_post_scan_enrichment()
|
||||
# Schedule FTS index build in background (non-blocking)
|
||||
# Schedule FTS index build in background (non-blocking). When the
|
||||
# prune was skipped the in-memory cache is intentionally out of sync
|
||||
# with the stored rows, so leave the existing index alone instead of
|
||||
# rebuilding it from the empty view.
|
||||
if not self._prune_skipped:
|
||||
self._schedule_fts_index_build()
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
|
||||
@@ -1595,6 +1733,7 @@ class RecipeScanner:
|
||||
"""
|
||||
loop = None
|
||||
scan_start_time: Optional[float] = None
|
||||
self._prune_skipped = False
|
||||
try:
|
||||
# Ensure cache exists to avoid None reference errors
|
||||
if self._cache is None:
|
||||
@@ -1621,14 +1760,38 @@ class RecipeScanner:
|
||||
logger.warning(f"Recipes directory not found: {recipes_dir}")
|
||||
return self._cache
|
||||
|
||||
# Record which directory the scan actually used. When the Recipes
|
||||
# Storage Path is empty this falls back to the first LoRA root, and
|
||||
# a support reader needs that path to tell a real wipe apart from a
|
||||
# scan that looked somewhere else (see the prune guard below).
|
||||
logger.info(f"Recipe scan directory: {recipes_dir}")
|
||||
|
||||
# Try to load from persistent cache first
|
||||
persisted = self._persistent_cache.load_cache()
|
||||
if persisted:
|
||||
recipes, changed, json_paths = self._reconcile_recipe_cache(
|
||||
persisted, recipes_dir
|
||||
)
|
||||
(
|
||||
recipes,
|
||||
changed,
|
||||
json_paths,
|
||||
skipped_prune_reason,
|
||||
) = self._reconcile_recipe_cache(persisted, recipes_dir)
|
||||
self._json_path_map = json_paths
|
||||
|
||||
if skipped_prune_reason:
|
||||
# Every persisted recipe file vanished at once. That is not a
|
||||
# reliable deletion signal: a drive that did not mount, a
|
||||
# recipes_path that silently fell back to another root, or a
|
||||
# shared cache touched by a second instance all look exactly
|
||||
# like this. Keep the stored cache and skip the prune, so the
|
||||
# only copy of the user's recipes is not destroyed.
|
||||
logger.warning(
|
||||
f"Recipe cache prune skipped: {skipped_prune_reason}. "
|
||||
f"Keeping {len(persisted.raw_data)} stored recipe(s); this "
|
||||
"session reports no recipes until the files are found again."
|
||||
)
|
||||
self._prune_skipped = True
|
||||
return self._cache
|
||||
|
||||
if not changed:
|
||||
# Fast path: use cached data directly
|
||||
logger.info(
|
||||
@@ -1642,7 +1805,10 @@ class RecipeScanner:
|
||||
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
recipes,
|
||||
json_paths,
|
||||
self._cache.image_id_map,
|
||||
skip_if_empty=True,
|
||||
)
|
||||
else:
|
||||
# Use persisted map, or rebuild if empty (e.g. first startup
|
||||
@@ -1670,7 +1836,10 @@ class RecipeScanner:
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
# Persist updated cache
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
recipes,
|
||||
json_paths,
|
||||
self._cache.image_id_map,
|
||||
skip_if_empty=True,
|
||||
)
|
||||
return self._cache
|
||||
|
||||
@@ -1697,7 +1866,10 @@ class RecipeScanner:
|
||||
|
||||
# Persist for next startup
|
||||
self._persistent_cache.save_cache(
|
||||
recipes, json_paths, self._cache.image_id_map
|
||||
recipes,
|
||||
json_paths,
|
||||
self._cache.image_id_map,
|
||||
skip_if_empty=True,
|
||||
)
|
||||
|
||||
if report_progress:
|
||||
@@ -1734,7 +1906,7 @@ class RecipeScanner:
|
||||
self,
|
||||
persisted: PersistedRecipeData,
|
||||
recipes_dir: str,
|
||||
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str]]:
|
||||
) -> Tuple[List[Dict[str, Any]], bool, Dict[str, str], Optional[str]]:
|
||||
"""Reconcile persisted cache with current filesystem state.
|
||||
|
||||
Args:
|
||||
@@ -1742,7 +1914,11 @@ class RecipeScanner:
|
||||
recipes_dir: Path to the recipes directory.
|
||||
|
||||
Returns:
|
||||
Tuple of (recipes list, changed flag, json_paths dict).
|
||||
Tuple of (recipes list, changed flag, json_paths dict,
|
||||
skipped_prune_reason). The last element is ``None`` on a normal
|
||||
reconcile. When it is a string, the scan saw every persisted recipe
|
||||
file disappear at once; the caller must then keep the persisted
|
||||
cache instead of overwriting it. The reason text is user-facing.
|
||||
"""
|
||||
recipes: List[Dict[str, Any]] = []
|
||||
json_paths: Dict[str, str] = {}
|
||||
@@ -1823,12 +1999,67 @@ class RecipeScanner:
|
||||
time.sleep(0)
|
||||
|
||||
# Check for deleted files
|
||||
for json_path in persisted.file_stats.keys():
|
||||
if json_path not in current_files:
|
||||
orphaned_stats = [
|
||||
json_path
|
||||
for json_path in persisted.file_stats.keys()
|
||||
if json_path not in current_files
|
||||
]
|
||||
if orphaned_stats:
|
||||
changed = True
|
||||
# This single line plus the resolved scan directory logged by the
|
||||
# caller are the evidence a support reader gets for a recipes path
|
||||
# that moved; the per-file lines stay at debug to avoid flooding.
|
||||
if len(orphaned_stats) > 10:
|
||||
logger.info(
|
||||
f"Recipe reconcile: {len(orphaned_stats)} of "
|
||||
f"{len(persisted.file_stats)} cached recipe file(s) are not in "
|
||||
f"{recipes_dir} (first: {orphaned_stats[0]}, "
|
||||
f"last: {orphaned_stats[-1]})"
|
||||
)
|
||||
else:
|
||||
for json_path in orphaned_stats:
|
||||
logger.debug("Recipe file deleted: %s", json_path)
|
||||
|
||||
return recipes, changed, json_paths
|
||||
skipped_prune_reason: Optional[str] = None
|
||||
if not current_files and persisted.file_stats:
|
||||
metadata_is_coherent = self._persisted_metadata_is_coherent(persisted)
|
||||
if metadata_is_coherent:
|
||||
skipped_prune_reason = (
|
||||
f"every recipe file recorded in the cache "
|
||||
f"({len(persisted.file_stats)}) is missing from {recipes_dir}"
|
||||
)
|
||||
else:
|
||||
# The stored row set and its recorded file stats disagree, so
|
||||
# this cache is stale rather than a faithful record of recipes
|
||||
# that have just gone missing. Pruning it is safe.
|
||||
logger.info(
|
||||
f"Recipe reconcile: stored cache is inconsistent "
|
||||
f"({len(persisted.raw_data)} row(s) vs "
|
||||
f"{len(persisted.file_stats)} file record(s)); falling back "
|
||||
"to a normal prune."
|
||||
)
|
||||
|
||||
return recipes, changed, json_paths, skipped_prune_reason
|
||||
|
||||
@staticmethod
|
||||
def _persisted_metadata_is_coherent(persisted: PersistedRecipeData) -> bool:
|
||||
"""Return True when the stored rows and their file stats describe one set.
|
||||
|
||||
The prune guard treats "no recipe files found" as a signal that the
|
||||
directory moved out from under us, which is only meaningful when the
|
||||
stored cache is a faithful record of recipes that exist on disk. A cache
|
||||
whose row set and file-stat set have diverged (left behind by an older
|
||||
reconcile) carries recipes that were already orphaned, so it is not
|
||||
evidence of a fresh disappearance.
|
||||
"""
|
||||
stats_ids = {
|
||||
os.path.basename(json_path)[: -len(".recipe.json")]
|
||||
for json_path in persisted.file_stats
|
||||
if os.path.basename(json_path).lower().endswith(".recipe.json")
|
||||
}
|
||||
rows_ids = {str(recipe.get("id", "")) for recipe in persisted.raw_data}
|
||||
rows_ids.discard("")
|
||||
return bool(rows_ids) and rows_ids == stats_ids
|
||||
|
||||
# Metadata key recording that the one-shot source_path backfill has run.
|
||||
_SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled"
|
||||
@@ -2498,6 +2729,10 @@ class RecipeScanner:
|
||||
try:
|
||||
# Invalidate persistent cache so the sync path does a
|
||||
# full directory scan instead of reconciling stale data.
|
||||
# This is the deliberate escape hatch from the
|
||||
# all-missing prune guard: an explicit user rebuild is
|
||||
# allowed to clear the stored cache, while an implicit
|
||||
# startup scan is not.
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.save_cache([], {})
|
||||
self._json_path_map = {}
|
||||
@@ -2528,6 +2763,7 @@ class RecipeScanner:
|
||||
|
||||
# Schedule non-blocking background work
|
||||
self._schedule_post_scan_enrichment()
|
||||
if not self._prune_skipped:
|
||||
self._schedule_fts_index_build()
|
||||
|
||||
return cast(RecipeCache, self._cache)
|
||||
|
||||
@@ -314,6 +314,27 @@ class ServiceRegistry:
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return scanner
|
||||
|
||||
@classmethod
|
||||
async def get_other_scanner(cls):
|
||||
"""Get or create Other-model scanner instance"""
|
||||
service_name = "other_scanner"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
# Double-check after acquiring lock
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from .other_scanner import OtherScanner
|
||||
|
||||
scanner = await OtherScanner.get_instance()
|
||||
cls._services[service_name] = scanner
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return scanner
|
||||
|
||||
@classmethod
|
||||
def clear_services(cls):
|
||||
"""Clear all registered services - mainly for testing"""
|
||||
|
||||
+189
-19
@@ -25,13 +25,19 @@ from typing import (
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from ..utils.constants import (
|
||||
DEFAULT_DOWNLOAD_PATH_TEMPLATES,
|
||||
DEFAULT_ENABLED_OTHER_SUB_TYPES,
|
||||
DEFAULT_HASH_CHUNK_SIZE_MB,
|
||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||
OTHER_SUB_TYPE_FOLDER_KEYS,
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_OTHER_SUB_TYPES,
|
||||
normalize_other_sub_types,
|
||||
)
|
||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||
from ..utils.settings_paths import (
|
||||
APP_NAME,
|
||||
_portable_env_override,
|
||||
ensure_settings_file,
|
||||
get_legacy_settings_path,
|
||||
get_settings_dir_override,
|
||||
@@ -83,6 +89,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"default_checkpoint_root": "",
|
||||
"default_unet_root": "",
|
||||
"default_embedding_root": "",
|
||||
"default_other_roots": {},
|
||||
# Other Models management is opt-in: nothing is scanned, shown or offered
|
||||
# for download until the user turns the feature on.
|
||||
"enable_other_models": False,
|
||||
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
|
||||
"recipes_path": "",
|
||||
"base_model_path_mappings": {},
|
||||
"download_path_templates": {},
|
||||
@@ -162,13 +173,23 @@ class SettingsManager:
|
||||
self._check_environment_variables()
|
||||
self._collect_configuration_warnings()
|
||||
|
||||
if (
|
||||
os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1"
|
||||
and not is_settings_dir_pinned()
|
||||
):
|
||||
portable_override = _portable_env_override()
|
||||
if portable_override is True and not is_settings_dir_pinned():
|
||||
if not self.settings.get("use_portable_settings"):
|
||||
self.settings["use_portable_settings"] = True
|
||||
self._save_settings()
|
||||
elif portable_override is False and self.settings.get(
|
||||
"use_portable_settings"
|
||||
):
|
||||
# Explicit opt-out from a persisted portable mode: clear the flag so
|
||||
# later runs go back to the shared settings directory instead of
|
||||
# requiring a manual edit of settings.json.
|
||||
logger.info(
|
||||
"Clearing the persisted portable-mode flag because %s=0",
|
||||
"LORA_MANAGER_PORTABLE",
|
||||
)
|
||||
self.settings["use_portable_settings"] = False
|
||||
self._save_settings()
|
||||
|
||||
if self._needs_initial_save:
|
||||
self._save_settings()
|
||||
@@ -309,6 +330,7 @@ class SettingsManager:
|
||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||
default_unet_root=merged.get("default_unet_root"),
|
||||
default_embedding_root=merged.get("default_embedding_root"),
|
||||
default_other_roots=merged.get("default_other_roots"),
|
||||
recipes_path=merged.get("recipes_path"),
|
||||
)
|
||||
}
|
||||
@@ -443,6 +465,7 @@ class SettingsManager:
|
||||
),
|
||||
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||
default_other_roots=self.settings.get("default_other_roots"),
|
||||
recipes_path=self.settings.get("recipes_path", ""),
|
||||
)
|
||||
libraries = {library_name: library_payload}
|
||||
@@ -494,6 +517,7 @@ class SettingsManager:
|
||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||
default_unet_root=data.get("default_unet_root"),
|
||||
default_embedding_root=data.get("default_embedding_root"),
|
||||
default_other_roots=data.get("default_other_roots"),
|
||||
recipes_path=data.get("recipes_path"),
|
||||
metadata=data.get("metadata"),
|
||||
base=data,
|
||||
@@ -541,6 +565,9 @@ class SettingsManager:
|
||||
self.settings["default_embedding_root"] = active_library.get(
|
||||
"default_embedding_root", ""
|
||||
)
|
||||
self.settings["default_other_roots"] = self._normalize_default_other_roots(
|
||||
active_library.get("default_other_roots", {})
|
||||
)
|
||||
self.settings["recipes_path"] = active_library.get("recipes_path", "")
|
||||
|
||||
if save:
|
||||
@@ -558,6 +585,7 @@ class SettingsManager:
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_unet_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: Optional[str] = None,
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
base: Optional[Mapping[str, Any]] = None,
|
||||
@@ -597,6 +625,15 @@ class SettingsManager:
|
||||
else:
|
||||
payload.setdefault("default_embedding_root", "")
|
||||
|
||||
if default_other_roots is not None:
|
||||
payload["default_other_roots"] = self._normalize_default_other_roots(
|
||||
default_other_roots
|
||||
)
|
||||
else:
|
||||
payload["default_other_roots"] = self._normalize_default_other_roots(
|
||||
payload.get("default_other_roots", {})
|
||||
)
|
||||
|
||||
if recipes_path is not None:
|
||||
payload["recipes_path"] = recipes_path
|
||||
else:
|
||||
@@ -632,6 +669,71 @@ class SettingsManager:
|
||||
normalized[key] = cleaned
|
||||
return normalized
|
||||
|
||||
def _normalize_default_other_roots(
|
||||
self, value: Any, *, strict: bool = False
|
||||
) -> Dict[str, str]:
|
||||
"""Normalize a ``default_other_roots`` mapping ({sub_type: root path}).
|
||||
|
||||
Unknown sub_type keys and non-string/empty paths are dropped; with
|
||||
``strict=True`` unknown sub_type keys raise instead (used by ``set()``
|
||||
so typos in API payloads surface as errors).
|
||||
"""
|
||||
if not isinstance(value, Mapping):
|
||||
if strict and value is not None:
|
||||
raise ValueError("default_other_roots must be a mapping")
|
||||
return {}
|
||||
normalized: Dict[str, str] = {}
|
||||
for sub_type, path in value.items():
|
||||
if sub_type not in VALID_OTHER_SUB_TYPES:
|
||||
if strict:
|
||||
raise ValueError(
|
||||
f"Unknown other-model sub-type '{sub_type}'; "
|
||||
f"expected one of {sorted(VALID_OTHER_SUB_TYPES)}"
|
||||
)
|
||||
continue
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
stripped = path.strip()
|
||||
if stripped:
|
||||
normalized[sub_type] = stripped
|
||||
return normalized
|
||||
|
||||
def is_other_models_enabled(self) -> bool:
|
||||
"""Return True when the opt-in Other Models management is enabled."""
|
||||
return bool(self.settings.get("enable_other_models", False))
|
||||
|
||||
def get_enabled_other_sub_types(self) -> List[str]:
|
||||
"""Return the enabled other-model sub_types (empty when the feature is off)."""
|
||||
if not self.is_other_models_enabled():
|
||||
return []
|
||||
return normalize_other_sub_types(self.settings.get("enabled_other_sub_types"))
|
||||
|
||||
def is_other_sub_type_enabled(self, sub_type: Optional[str]) -> bool:
|
||||
"""Return True when ``sub_type`` is currently managed."""
|
||||
if not sub_type:
|
||||
return False
|
||||
return sub_type in self.get_enabled_other_sub_types()
|
||||
|
||||
def _apply_other_model_settings_change(self) -> None:
|
||||
"""Rebuild other-model roots and refresh the other scanner after a toggle."""
|
||||
try:
|
||||
from ..config import config # Local import to avoid circular dependency
|
||||
|
||||
config.refresh_other_roots()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug("Failed to refresh other-model roots: %s", exc)
|
||||
|
||||
try:
|
||||
from .service_registry import ServiceRegistry # pyright: ignore[reportImportCycles]
|
||||
|
||||
scanner = ServiceRegistry.get_service_sync("other_scanner")
|
||||
if scanner is not None and hasattr(scanner, "on_library_changed"):
|
||||
# reconcile=True lets the scanner pick up newly enabled roots and
|
||||
# purge rows for folders that are no longer managed.
|
||||
scanner.on_library_changed(reconcile=True)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug("Failed to refresh other scanner after settings change: %s", exc)
|
||||
|
||||
def _has_configured_paths(self, folder_paths: Any) -> bool:
|
||||
if not isinstance(folder_paths, Mapping):
|
||||
return False
|
||||
@@ -744,6 +846,7 @@ class SettingsManager:
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_unet_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: Optional[str] = None,
|
||||
) -> bool:
|
||||
libraries = self.settings.get("libraries", {})
|
||||
@@ -794,6 +897,14 @@ class SettingsManager:
|
||||
library["default_embedding_root"] = default_embedding_root
|
||||
changed = True
|
||||
|
||||
if default_other_roots is not None:
|
||||
normalized_other_roots = self._normalize_default_other_roots(
|
||||
default_other_roots
|
||||
)
|
||||
if library.get("default_other_roots") != normalized_other_roots:
|
||||
library["default_other_roots"] = normalized_other_roots
|
||||
changed = True
|
||||
|
||||
if recipes_path is not None and library.get("recipes_path") != recipes_path:
|
||||
library["recipes_path"] = recipes_path
|
||||
changed = True
|
||||
@@ -894,12 +1005,53 @@ class SettingsManager:
|
||||
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
||||
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
|
||||
|
||||
# Other-model default roots: one entry per enabled sub_type; candidates
|
||||
# are the union of that sub_type's folder_paths keys (text_encoder
|
||||
# merges the legacy 'clip' key with 'text_encoders'). When the opt-in
|
||||
# feature is off the existing mapping is left untouched.
|
||||
other_roots = self._normalize_default_other_roots(
|
||||
self.settings.get("default_other_roots")
|
||||
)
|
||||
if self.is_other_models_enabled():
|
||||
for sub_type in self.get_enabled_other_sub_types():
|
||||
candidates: List[str] = []
|
||||
candidate_identities: set[str] = set()
|
||||
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
|
||||
for candidate in self._get_valid_root_candidates(folder_key):
|
||||
identity = _normalize_root_identity(candidate)
|
||||
if identity in candidate_identities:
|
||||
continue
|
||||
candidate_identities.add(identity)
|
||||
candidates.append(candidate)
|
||||
if not candidates:
|
||||
continue
|
||||
current = other_roots.get(sub_type, "")
|
||||
if current and _normalize_root_identity(current) in candidate_identities:
|
||||
continue
|
||||
other_roots[sub_type] = candidates[0]
|
||||
if current:
|
||||
logger.info(
|
||||
"Repaired stale default_other_roots[%s] from '%s' to '%s' because it is not present in primary or extra roots",
|
||||
sub_type,
|
||||
current,
|
||||
candidates[0],
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Auto-set default_other_roots[%s] to '%s'",
|
||||
sub_type,
|
||||
candidates[0],
|
||||
)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
self.settings["default_other_roots"] = other_roots
|
||||
self._update_active_library_entry(
|
||||
default_lora_root=self.settings.get("default_lora_root"),
|
||||
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
||||
default_unet_root=self.settings.get("default_unet_root"),
|
||||
default_embedding_root=self.settings.get("default_embedding_root"),
|
||||
default_other_roots=other_roots,
|
||||
)
|
||||
if self._bootstrap_reason == "missing":
|
||||
self._needs_initial_save = True
|
||||
@@ -1599,6 +1751,12 @@ class SettingsManager:
|
||||
value = self.normalize_download_skip_base_models(value)
|
||||
elif key == "mature_blur_level":
|
||||
value = self.normalize_mature_blur_level(value)
|
||||
elif key == "default_other_roots":
|
||||
value = self._normalize_default_other_roots(value, strict=True)
|
||||
elif key == "enabled_other_sub_types":
|
||||
value = normalize_other_sub_types(value)
|
||||
elif key == "enable_other_models":
|
||||
value = bool(value)
|
||||
elif key == "recipes_path":
|
||||
current_recipes_dir = self._get_effective_recipes_dir()
|
||||
value = self._normalize_recipes_path_value(value)
|
||||
@@ -1626,6 +1784,8 @@ class SettingsManager:
|
||||
self._update_active_library_entry(default_unet_root=str(value))
|
||||
elif key == "default_embedding_root":
|
||||
self._update_active_library_entry(default_embedding_root=str(value))
|
||||
elif key == "default_other_roots":
|
||||
self._update_active_library_entry(default_other_roots=value)
|
||||
elif key == "recipes_path":
|
||||
self._update_active_library_entry(recipes_path=str(value))
|
||||
elif key == "model_name_display":
|
||||
@@ -1633,6 +1793,8 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
if key == "recipes_path":
|
||||
self._notify_library_change(self.get_active_library_name())
|
||||
if key in ("enable_other_models", "enabled_other_sub_types"):
|
||||
self._apply_other_model_settings_change()
|
||||
if portable_switch_pending:
|
||||
self._finalize_portable_switch()
|
||||
|
||||
@@ -1796,6 +1958,7 @@ class SettingsManager:
|
||||
"lora_scanner",
|
||||
"checkpoint_scanner",
|
||||
"embedding_scanner",
|
||||
"other_scanner",
|
||||
"recipe_scanner",
|
||||
):
|
||||
service = ServiceRegistry.get_service_sync(service_name)
|
||||
@@ -1960,6 +2123,7 @@ class SettingsManager:
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_unet_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: Optional[str] = None,
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
activate: bool = False,
|
||||
@@ -2004,6 +2168,11 @@ class SettingsManager:
|
||||
if default_embedding_root is not None
|
||||
else existing.get("default_embedding_root")
|
||||
),
|
||||
default_other_roots=(
|
||||
default_other_roots
|
||||
if default_other_roots is not None
|
||||
else existing.get("default_other_roots")
|
||||
),
|
||||
recipes_path=(
|
||||
recipes_path
|
||||
if recipes_path is not None
|
||||
@@ -2036,6 +2205,7 @@ class SettingsManager:
|
||||
default_checkpoint_root: str = "",
|
||||
default_unet_root: str = "",
|
||||
default_embedding_root: str = "",
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: str = "",
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
activate: bool = False,
|
||||
@@ -2054,6 +2224,7 @@ class SettingsManager:
|
||||
default_checkpoint_root=default_checkpoint_root,
|
||||
default_unet_root=default_unet_root,
|
||||
default_embedding_root=default_embedding_root,
|
||||
default_other_roots=default_other_roots,
|
||||
recipes_path=recipes_path,
|
||||
metadata=metadata,
|
||||
activate=activate,
|
||||
@@ -2114,6 +2285,7 @@ class SettingsManager:
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_unet_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update folder paths for the active library."""
|
||||
@@ -2127,6 +2299,7 @@ class SettingsManager:
|
||||
default_checkpoint_root=default_checkpoint_root,
|
||||
default_unet_root=default_unet_root,
|
||||
default_embedding_root=default_embedding_root,
|
||||
default_other_roots=default_other_roots,
|
||||
recipes_path=recipes_path,
|
||||
activate=True,
|
||||
)
|
||||
@@ -2151,6 +2324,7 @@ class SettingsManager:
|
||||
"lora_scanner",
|
||||
"checkpoint_scanner",
|
||||
"embedding_scanner",
|
||||
"other_scanner",
|
||||
"recipe_scanner",
|
||||
"model_update_service",
|
||||
):
|
||||
@@ -2173,10 +2347,14 @@ class SettingsManager:
|
||||
"""Get download path template for specific model type
|
||||
|
||||
Args:
|
||||
model_type: The type of model ('lora', 'checkpoint', 'embedding')
|
||||
model_type: The type of model ('lora', 'checkpoint', 'embedding',
|
||||
'other')
|
||||
|
||||
Returns:
|
||||
Template string for the model type, defaults to '{base_model}/{first_tag}'
|
||||
Template string for the model type. Falls back to the per-type
|
||||
default in ``DEFAULT_DOWNLOAD_PATH_TEMPLATES``; unknown model types
|
||||
resolve to an empty string (flat layout) rather than silently
|
||||
nesting downloads under an unconfigured subfolder.
|
||||
"""
|
||||
templates = self.settings.get("download_path_templates", {})
|
||||
|
||||
@@ -2200,27 +2378,19 @@ class SettingsManager:
|
||||
logger.warning(
|
||||
f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
|
||||
)
|
||||
default_template = "{base_model}/{first_tag}"
|
||||
templates = {
|
||||
"lora": default_template,
|
||||
"checkpoint": default_template,
|
||||
"embedding": default_template,
|
||||
}
|
||||
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
|
||||
self.settings["download_path_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
# Ensure templates is a dictionary
|
||||
if not isinstance(templates, dict):
|
||||
default_template = "{base_model}/{first_tag}"
|
||||
templates = {
|
||||
"lora": default_template,
|
||||
"checkpoint": default_template,
|
||||
"embedding": default_template,
|
||||
}
|
||||
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
|
||||
self.settings["download_path_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
return templates.get(model_type, "{base_model}/{first_tag}")
|
||||
return templates.get(
|
||||
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
||||
)
|
||||
|
||||
|
||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||
|
||||
@@ -20,6 +20,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from ..utils.cache_db import connect_cache_db
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -677,16 +678,13 @@ class TagFTSIndex:
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Create a database connection."""
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
if readonly and not os.path.exists(self._db_path):
|
||||
raise FileNotFoundError(self._db_path)
|
||||
return connect_cache_db(
|
||||
self._db_path,
|
||||
readonly=readonly,
|
||||
row_factory=sqlite3.Row,
|
||||
)
|
||||
|
||||
def _build_fts_query(self, query: str) -> str:
|
||||
"""Build an FTS5 query string with prefix matching.
|
||||
|
||||
@@ -7,6 +7,7 @@ import time
|
||||
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
||||
|
||||
from ..metadata_sync_service import MetadataSyncService
|
||||
from ..model_sources import has_external_source
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
|
||||
|
||||
@@ -51,10 +52,11 @@ class BulkMetadataRefreshUseCase:
|
||||
if not model.get("skip_metadata_refresh", False)
|
||||
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
|
||||
and (not model.get("civitai") or not model["civitai"].get("id"))
|
||||
# Skip models downloaded from Hugging Face — they are not on
|
||||
# CivitAI / CivArchive. Users can still refresh them individually
|
||||
# via the right-click context menu.
|
||||
and not model.get("hf_url", "")
|
||||
# Skip models linked to an external model site (Hugging Face /
|
||||
# ModelScope / TensorArt) — they are not on CivitAI / CivArchive.
|
||||
# Users can still refresh them individually via the right-click
|
||||
# context menu.
|
||||
and not has_external_source(model)
|
||||
and not (
|
||||
# Skip models confirmed not on CivitAI when no need to retry
|
||||
model.get("from_civitai") is False
|
||||
|
||||
@@ -170,6 +170,13 @@ class WebSocketManager:
|
||||
progress_entry['status'] = data['status']
|
||||
if 'message' in data:
|
||||
progress_entry['message'] = data['message']
|
||||
# Post-transfer stage reporting (see `model_source_handlers._report_phase`):
|
||||
# the byte counter has stopped by then, so the stage is the only thing
|
||||
# that still says the download is working.
|
||||
if 'stage' in data:
|
||||
progress_entry['stage'] = data['stage']
|
||||
if 'platform' in data:
|
||||
progress_entry['platform'] = data['platform']
|
||||
|
||||
self._download_progress[download_id] = progress_entry
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared SQLite connection setup for LoRA Manager cache databases.
|
||||
|
||||
Cache databases live under the settings directory (``cache/model/<library>.sqlite``,
|
||||
``cache/recipe/<library>.sqlite``, ``cache/fts/*.sqlite``). With portable mode or a
|
||||
pinned ``LORA_MANAGER_SETTINGS_DIR`` off, that directory is shared by every ComfyUI
|
||||
instance on the machine, so two processes can open the same cache file at once.
|
||||
|
||||
SQLite serializes writers, but the default ``timeout`` is 5 seconds: a second
|
||||
instance that writes while the first is mid-transaction fails with "database is
|
||||
locked". These settings make concurrent access wait instead of failing, and keep
|
||||
the write path in WAL so readers are never blocked by a writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
# How long a connection waits for a competing writer before raising.
|
||||
CONCURRENT_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# PRAGMAs applied to every cache connection.
|
||||
#
|
||||
# ``busy_timeout`` mirrors the connection timeout so a busy database is retried
|
||||
# inside SQLite rather than surfacing as an immediate error. ``synchronous=NORMAL``
|
||||
# is the documented companion of WAL: still crash-safe, far fewer fsyncs.
|
||||
_TUNING_PRAGMAS = (
|
||||
"PRAGMA busy_timeout = 30000",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
)
|
||||
|
||||
|
||||
def connect_cache_db(
|
||||
path: str,
|
||||
*,
|
||||
readonly: bool = False,
|
||||
uri: bool = False,
|
||||
detect_types: int = 0,
|
||||
row_factory: Any = None,
|
||||
) -> sqlite3.Connection:
|
||||
"""Open a cache database with multi-instance-friendly settings.
|
||||
|
||||
Args:
|
||||
path: Database path, or a ``file:`` URI when *uri* is True.
|
||||
readonly: Open through a read-only URI. Callers still pass the
|
||||
plain path; the ``mode=ro`` suffix is added here. The
|
||||
write-oriented tuning pragmas are skipped in that case so a
|
||||
read-only connection never attempts to change the file.
|
||||
uri: Treat *path* as a SQLite URI.
|
||||
detect_types: Forwarded to :func:`sqlite3.connect`.
|
||||
row_factory: Optional ``row_factory`` for the connection.
|
||||
|
||||
Returns:
|
||||
A configured :class:`sqlite3.Connection`.
|
||||
"""
|
||||
if readonly:
|
||||
if not uri and not path.startswith("file:"):
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
|
||||
conn = sqlite3.connect(
|
||||
path,
|
||||
check_same_thread=False,
|
||||
uri=uri,
|
||||
detect_types=detect_types,
|
||||
timeout=CONCURRENT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if row_factory is not None:
|
||||
conn.row_factory = row_factory
|
||||
|
||||
try:
|
||||
for pragma in _TUNING_PRAGMAS:
|
||||
# A read-only connection may reject write PRAGMAs; they are not
|
||||
# needed there anyway.
|
||||
conn.execute(pragma)
|
||||
except sqlite3.Error:
|
||||
# Tuning is best-effort: a connection that cannot set pragmas still
|
||||
# works, just without the concurrency headroom.
|
||||
pass
|
||||
|
||||
return conn
|
||||
+112
-1
@@ -1,4 +1,4 @@
|
||||
from typing import Any
|
||||
from typing import Any, Dict, List
|
||||
|
||||
NSFW_LEVELS = {
|
||||
"PG": 1,
|
||||
@@ -83,6 +83,103 @@ VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
|
||||
|
||||
# folder_paths key -> sub_type; single source of truth for extensibility.
|
||||
# Adding support for a new ComfyUI folder category is a one-line change here.
|
||||
OTHER_MODEL_FOLDER_SUBTYPES = {
|
||||
"vae": "vae",
|
||||
"upscale_models": "upscaler",
|
||||
"text_encoders": "text_encoder",
|
||||
"clip": "text_encoder", # legacy ComfyUI key
|
||||
"clip_vision": "clip_vision",
|
||||
"controlnet": "controlnet",
|
||||
}
|
||||
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
|
||||
# Sub-types managed when the (opt-in) Other Models feature is switched on.
|
||||
# The feature itself defaults to off (``enable_other_models`` = False), so
|
||||
# nothing here is scanned until the user enables it.
|
||||
#
|
||||
# The default set is deliberately limited to the dependency-style assets every
|
||||
# pipeline needs and where "which one am I actually using" is the real problem:
|
||||
# VAE, upscalers and text encoders. ``clip_vision`` and ``controlnet`` are
|
||||
# workflow-driven instead (IPAdapter/SVD, per-workflow ControlNet variants) and
|
||||
# ControlNet libraries routinely run to dozens of files, so both stay opt-in
|
||||
# and are treated symmetrically.
|
||||
DEFAULT_ENABLED_OTHER_SUB_TYPES: List[str] = [
|
||||
"vae",
|
||||
"upscaler",
|
||||
"text_encoder",
|
||||
]
|
||||
|
||||
|
||||
def other_sub_type_folder_keys() -> Dict[str, List[str]]:
|
||||
"""Invert OTHER_MODEL_FOLDER_SUBTYPES into sub_type -> folder_paths keys.
|
||||
|
||||
``text_encoder`` maps to two folder keys (``text_encoders`` and the legacy
|
||||
``clip``), so every consumer that resolves a sub_type back to folders must
|
||||
merge both.
|
||||
"""
|
||||
mapping: Dict[str, List[str]] = {}
|
||||
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||
mapping.setdefault(sub_type, []).append(folder_key)
|
||||
return mapping
|
||||
|
||||
|
||||
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
|
||||
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
|
||||
|
||||
|
||||
def normalize_other_sub_types(value: Any) -> List[str]:
|
||||
"""Normalize a stored/requested enabled-sub_type list.
|
||||
|
||||
Unknown values and duplicates are dropped; the result follows the
|
||||
canonical VALID_OTHER_SUB_TYPES order so the stored setting and the UI
|
||||
stay stable. Non-list input falls back to the defaults.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
candidates: Any = [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
candidates = value
|
||||
else:
|
||||
return list(DEFAULT_ENABLED_OTHER_SUB_TYPES)
|
||||
|
||||
allowed = {item for item in candidates if isinstance(item, str)}
|
||||
return [sub_type for sub_type in VALID_OTHER_SUB_TYPES if sub_type in allowed]
|
||||
# CivitAI model.type values accepted by the "other" page's fetch-metadata
|
||||
# validation (lowercased). CLIP/CLIPVision are retired upstream but still
|
||||
# appear on grandfathered models.
|
||||
VALID_OTHER_CIVITAI_TYPES = {
|
||||
"vae",
|
||||
"upscaler",
|
||||
"textencoder",
|
||||
"clip",
|
||||
"clipvision",
|
||||
"controlnet",
|
||||
"other",
|
||||
}
|
||||
# CivitAI model.type -> internal sub_type for the "other" model page.
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE = {
|
||||
"vae": "vae",
|
||||
"upscaler": "upscaler",
|
||||
"textencoder": "text_encoder",
|
||||
"clip": "text_encoder",
|
||||
"clipvision": "clip_vision",
|
||||
"controlnet": "controlnet",
|
||||
}
|
||||
|
||||
# CivitAI ModelFile.type values -> internal sub_type for the "other" model
|
||||
# page. Used for download routing only, and strictly as an explicit user file
|
||||
# pick or a fallback when model.type maps to nothing — checkpoint models
|
||||
# routinely bundle VAE/Text Encoder component files, so file types must never
|
||||
# override a mapped model.type.
|
||||
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
|
||||
"VAE": "vae",
|
||||
"Upscaler": "upscaler",
|
||||
"Text Encoder": "text_encoder",
|
||||
"Vision Encoder": "clip_vision",
|
||||
"CLIPVision": "clip_vision",
|
||||
"ControlNet": "controlnet",
|
||||
}
|
||||
|
||||
# Backward compatibility alias
|
||||
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
||||
|
||||
@@ -91,6 +188,7 @@ CIVITAI_USER_MODEL_TYPES = [
|
||||
*VALID_LORA_TYPES,
|
||||
"textualinversion",
|
||||
"checkpoint",
|
||||
*sorted(VALID_OTHER_CIVITAI_TYPES),
|
||||
]
|
||||
|
||||
# Default chunk size in megabytes used for hashing large files.
|
||||
@@ -159,6 +257,19 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
||||
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
|
||||
}
|
||||
|
||||
# Default download path template for each model type. "other" defaults to a
|
||||
# flat layout (empty template) on purpose: other-model downloads are already
|
||||
# separated by sub_type roots (default_other_roots), and priority_tags has no
|
||||
# "other" entry, so {first_tag} would resolve to an arbitrary CivitAI tag and
|
||||
# scatter files into unstable folders. Users can still opt in to a template by
|
||||
# writing "other" into download_path_templates in settings.json.
|
||||
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
|
||||
"lora": "{base_model}/{first_tag}",
|
||||
"checkpoint": "{base_model}/{first_tag}",
|
||||
"embedding": "{base_model}/{first_tag}",
|
||||
"other": "",
|
||||
}
|
||||
|
||||
# baseModel values from CivitAI that should be treated as diffusion models (unet)
|
||||
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
|
||||
@@ -420,6 +420,10 @@ class DownloadManager:
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
scanners.append(("embedding", embedding_scanner))
|
||||
|
||||
if "other" in model_types:
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
scanners.append(("other", other_scanner))
|
||||
|
||||
# Load progress file to check processed models (async to avoid blocking)
|
||||
settings_manager = get_settings_manager()
|
||||
active_library = settings_manager.get_active_library_name()
|
||||
@@ -600,6 +604,10 @@ class DownloadManager:
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
scanners.append(("embedding", embedding_scanner))
|
||||
|
||||
if "other" in model_types:
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
scanners.append(("other", other_scanner))
|
||||
|
||||
# Get all models
|
||||
all_models = []
|
||||
for scanner_type, scanner in scanners:
|
||||
@@ -1098,6 +1106,10 @@ class DownloadManager:
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
scanners.append(("embedding", embedding_scanner))
|
||||
|
||||
if "other" in model_types:
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
scanners.append(("other", other_scanner))
|
||||
|
||||
# Find the specified models
|
||||
models_to_process = []
|
||||
for scanner_type, scanner in scanners:
|
||||
|
||||
@@ -2,7 +2,7 @@ import inspect
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
from typing import TYPE_CHECKING, Any, Dict, Mapping, MutableMapping, Optional
|
||||
|
||||
from ..recipes.constants import GEN_PARAM_KEYS
|
||||
from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||
@@ -13,9 +13,20 @@ from ..services.downloader import get_downloader
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.video_metadata import get_video_dimensions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Placeholder dimensions written when the real ones cannot be determined.
|
||||
# Kept for backwards compatibility with pre-existing metadata entries.
|
||||
_DEFAULT_MEDIA_WIDTH = 720
|
||||
_DEFAULT_MEDIA_HEIGHT = 1280
|
||||
|
||||
# Example metadata entries carry a marker: ``customImages`` use their ``id``
|
||||
# while ``images`` use the positional index. Either way the marker must be a
|
||||
# plain filename-safe token, never a path fragment.
|
||||
_ENTRY_MARKER_PATTERN = re.compile(r"^(?:custom_|image_)?([^./\\]+)$")
|
||||
|
||||
_preview_service = PreviewAssetService(
|
||||
metadata_manager=MetadataManager,
|
||||
downloader_factory=get_downloader,
|
||||
@@ -66,6 +77,141 @@ def _build_metadata_sync_service(settings_manager: "SettingsManager") -> Metadat
|
||||
)
|
||||
|
||||
|
||||
def _read_media_dimensions(path: str, is_video: bool) -> tuple[int, int]:
|
||||
"""Return ``(width, height)`` for an example image or video file.
|
||||
|
||||
Videos are read from their container headers (PIL cannot open them) so the
|
||||
showcase viewer sizes the gallery to the real aspect ratio. Falls back to
|
||||
the legacy ``720x1280`` placeholder when the dimensions cannot be
|
||||
determined — e.g. an unreadable file or an exotic codec — which only
|
||||
affects the displayed aspect ratio, never the file itself.
|
||||
"""
|
||||
|
||||
dimensions = None
|
||||
|
||||
if is_video:
|
||||
dimensions = get_video_dimensions(path)
|
||||
else:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
if os.path.exists(path):
|
||||
with Image.open(path) as img:
|
||||
dimensions = img.size
|
||||
except Exception:
|
||||
dimensions = None
|
||||
|
||||
if dimensions:
|
||||
width, height = dimensions
|
||||
if width > 0 and height > 0:
|
||||
return int(width), int(height)
|
||||
|
||||
return _DEFAULT_MEDIA_WIDTH, _DEFAULT_MEDIA_HEIGHT
|
||||
|
||||
|
||||
def _is_video_entry(file_path: Optional[str], entry: Mapping[str, Any]) -> bool:
|
||||
"""Return True when an example entry points at a video file.
|
||||
|
||||
The local file extension wins over the recorded ``type`` because files in
|
||||
the wild are frequently mislabelled (animated WebP saved as ``.mp4``);
|
||||
``_read_media_dimensions`` handles that correctly either way.
|
||||
"""
|
||||
|
||||
if file_path:
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in SUPPORTED_MEDIA_EXTENSIONS["videos"]:
|
||||
return True
|
||||
if ext in SUPPORTED_MEDIA_EXTENSIONS["images"]:
|
||||
return False
|
||||
return str(entry.get("type", "")).lower() == "video"
|
||||
|
||||
|
||||
def _resolve_local_file(
|
||||
entry: Mapping[str, Any],
|
||||
index: int,
|
||||
local_files: Mapping[str, str],
|
||||
) -> Optional[str]:
|
||||
"""Map a metadata entry onto its example file inside the model folder.
|
||||
|
||||
Reads the entry's own marker (``id`` for ``customImages``, positional
|
||||
``index`` for ``images``) with an anchored regex, so the identifier can
|
||||
never bleed into a neighbouring filename the way a prefix comparison can.
|
||||
"""
|
||||
|
||||
marker = entry.get("id")
|
||||
if not isinstance(marker, str) or not marker:
|
||||
marker = str(index)
|
||||
|
||||
match = _ENTRY_MARKER_PATTERN.fullmatch(marker)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
return local_files.get(match.group(1))
|
||||
|
||||
|
||||
def repair_local_video_dimensions(
|
||||
metadata: MutableMapping[str, Any],
|
||||
local_files: Mapping[str, str],
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> int:
|
||||
"""Backfill real video dimensions for an entry that has local files.
|
||||
|
||||
Only entries with an empty ``url`` are considered: those have no remote
|
||||
source, so the local file is the single source of truth for their size and
|
||||
rewriting them cannot discard API-supplied data. Entries whose dimensions
|
||||
already match the file are left byte-identical.
|
||||
|
||||
Args:
|
||||
metadata: Raw metadata payload (mutated in place unless ``dry_run``).
|
||||
local_files: ``{identifier: path}`` for files present in the model's
|
||||
example folder, where the identifier is the entry's ``id`` (for
|
||||
``customImages``) or its positional index (for ``images``).
|
||||
dry_run: Count the fixes without mutating ``metadata``.
|
||||
|
||||
Returns:
|
||||
The number of entries that were (or would be) repaired.
|
||||
"""
|
||||
|
||||
civitai = metadata.get("civitai")
|
||||
if not isinstance(civitai, dict):
|
||||
return 0
|
||||
|
||||
repaired = 0
|
||||
|
||||
for key in ("customImages", "images"):
|
||||
entries = civitai.get(key)
|
||||
if not isinstance(entries, list) or not entries:
|
||||
continue
|
||||
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("url", "") != "":
|
||||
# Remote-backed entry: never rebuilt from local state.
|
||||
continue
|
||||
|
||||
file_path = _resolve_local_file(entry, index, local_files)
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
dimensions = _read_media_dimensions(
|
||||
file_path, _is_video_entry(file_path, entry)
|
||||
)
|
||||
width, height = dimensions
|
||||
if width <= 0 or height <= 0:
|
||||
continue
|
||||
if entry.get("width") == width and entry.get("height") == height:
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
entry["width"] = width
|
||||
entry["height"] = height
|
||||
repaired += 1
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
def _get_metadata_sync_service() -> MetadataSyncService:
|
||||
"""Return the shared metadata sync service, initialising it lazily."""
|
||||
|
||||
@@ -231,28 +377,20 @@ class MetadataUpdater:
|
||||
file_ext = os.path.splitext(path)[1].lower()
|
||||
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
|
||||
width, height = _read_media_dimensions(path, is_video)
|
||||
|
||||
# Create image metadata entry
|
||||
image_entry = {
|
||||
"url": "", # Empty URL as required
|
||||
"nsfwLevel": 0,
|
||||
"width": 720, # Default dimensions
|
||||
"height": 1280,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"type": "video" if is_video else "image",
|
||||
"meta": None,
|
||||
"hasMeta": False,
|
||||
"hasPositivePrompt": False
|
||||
}
|
||||
|
||||
# If it's an image, try to get actual dimensions (optional enhancement)
|
||||
try:
|
||||
from PIL import Image
|
||||
if not is_video and os.path.exists(path):
|
||||
with Image.open(path) as img:
|
||||
image_entry["width"], image_entry["height"] = img.size
|
||||
except:
|
||||
# If PIL fails or is unavailable, use default dimensions
|
||||
pass
|
||||
|
||||
images.append(image_entry)
|
||||
|
||||
# Update the model's civitai.images field
|
||||
@@ -322,13 +460,15 @@ class MetadataUpdater:
|
||||
file_ext = os.path.splitext(path)[1].lower()
|
||||
is_video = file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
|
||||
width, height = _read_media_dimensions(path, is_video)
|
||||
|
||||
# Create image metadata entry
|
||||
image_entry = {
|
||||
"url": "", # Empty URL as requested
|
||||
"id": short_id,
|
||||
"nsfwLevel": 0,
|
||||
"width": 720, # Default dimensions
|
||||
"height": 1280,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"type": "video" if is_video else "image",
|
||||
"meta": None,
|
||||
"hasMeta": False,
|
||||
@@ -353,16 +493,6 @@ class MetadataUpdater:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract metadata from {os.path.basename(path)}: {e}")
|
||||
|
||||
# If it's an image, try to get actual dimensions
|
||||
try:
|
||||
from PIL import Image
|
||||
if not is_video and os.path.exists(path):
|
||||
with Image.open(path) as img:
|
||||
image_entry["width"], image_entry["height"] = img.size
|
||||
except:
|
||||
# If PIL fails or is unavailable, use default dimensions
|
||||
pass
|
||||
|
||||
# Append to existing customImages array
|
||||
custom_images.append(image_entry)
|
||||
|
||||
|
||||
@@ -15,12 +15,20 @@ from ..utils.example_images_paths import (
|
||||
)
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||
from ..utils.example_images_metadata import update_cache_from_metadata
|
||||
from ..utils.example_images_metadata import (
|
||||
repair_local_video_dimensions,
|
||||
update_cache_from_metadata,
|
||||
)
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change
|
||||
CURRENT_NAMING_VERSION = 3 # Increment this when naming conventions change
|
||||
|
||||
# Example files worth inspecting during the dimension repair.
|
||||
_REPAIRABLE_EXTENSIONS = frozenset(
|
||||
SUPPORTED_MEDIA_EXTENSIONS["images"] + SUPPORTED_MEDIA_EXTENSIONS["videos"]
|
||||
)
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
@@ -185,6 +193,9 @@ class ExampleImagesMigration:
|
||||
if from_version < 2 and to_version >= 2:
|
||||
await ExampleImagesMigration._migrate_to_v2(model_folders)
|
||||
|
||||
if from_version < 3 and to_version >= 3:
|
||||
await ExampleImagesMigration._migrate_to_v3(example_images_path, model_folders)
|
||||
|
||||
# Update version in progress file
|
||||
progress_file = os.path.join(example_images_path, '.download_progress.json')
|
||||
try:
|
||||
@@ -438,3 +449,136 @@ class ExampleImagesMigration:
|
||||
migration_errors += 1
|
||||
|
||||
logger.info(f"Migration to v2 complete: migrated {count} custom examples across {updated_models} models with {migration_errors} errors")
|
||||
|
||||
@staticmethod
|
||||
def _build_local_file_map(folder):
|
||||
"""Map entry markers to their files inside a model's example folder.
|
||||
|
||||
Keys are the marker alone (``custom_<id>`` → ``<id>``,
|
||||
``image_<index>`` → ``<index>``) so they line up with the metadata
|
||||
entries' ``id``/positional index without any prefix ambiguity.
|
||||
"""
|
||||
|
||||
local_files = {}
|
||||
try:
|
||||
entries = os.listdir(folder)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not list example folder %s: %s", folder, exc)
|
||||
return local_files
|
||||
|
||||
for name in entries:
|
||||
stem, ext = os.path.splitext(name)
|
||||
if ext.lower() not in _REPAIRABLE_EXTENSIONS:
|
||||
continue
|
||||
if stem.startswith("custom_"):
|
||||
local_files[stem[len("custom_"):]] = os.path.join(folder, name)
|
||||
elif stem.startswith("image_"):
|
||||
local_files[stem[len("image_"):]] = os.path.join(folder, name)
|
||||
|
||||
return local_files
|
||||
|
||||
@staticmethod
|
||||
async def _find_scanner_for_hash(model_hash):
|
||||
"""Return the scanner owning ``model_hash``, or ``None``."""
|
||||
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
for scanner in (lora_scanner, checkpoint_scanner, embedding_scanner):
|
||||
if scanner is None:
|
||||
continue
|
||||
try:
|
||||
if scanner.has_hash(model_hash):
|
||||
return scanner
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("has_hash check failed for %s: %s", type(scanner).__name__, exc)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _migrate_to_v3(example_images_path, model_folders):
|
||||
"""Backfill real dimensions for locally imported example videos.
|
||||
|
||||
Imported videos were stored with a hardcoded ``720x1280`` placeholder
|
||||
(issue #1115), so landscape clips were rendered inside a portrait
|
||||
container. Only entries with an empty ``url`` are touched — those have
|
||||
no remote source, which makes the local file authoritative and the
|
||||
rewrite lossless. Entries already carrying the right size are left
|
||||
untouched, so re-running this migration is a no-op.
|
||||
|
||||
This runs once per library via the ``naming_version`` gate in
|
||||
``run_migrations``; it is deliberately not wired into any request path.
|
||||
"""
|
||||
|
||||
repaired_entries = 0
|
||||
updated_models = 0
|
||||
migration_errors = 0
|
||||
|
||||
logger.info(
|
||||
"Starting v3 migration (local example video dimensions) for %d model folders",
|
||||
len(model_folders),
|
||||
)
|
||||
|
||||
for folder in model_folders:
|
||||
try:
|
||||
model_hash = os.path.basename(folder)
|
||||
if not model_hash or len(model_hash) != 64:
|
||||
continue
|
||||
|
||||
local_files = ExampleImagesMigration._build_local_file_map(folder)
|
||||
if not local_files:
|
||||
continue
|
||||
|
||||
scanner = await ExampleImagesMigration._find_scanner_for_hash(model_hash)
|
||||
if scanner is None:
|
||||
logger.debug(
|
||||
"Model %s not found in any scanner cache, skipping dimension repair",
|
||||
model_hash,
|
||||
)
|
||||
continue
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
model_data = None
|
||||
for item in cache.raw_data:
|
||||
if item.get("sha256") == model_hash:
|
||||
model_data = item
|
||||
break
|
||||
|
||||
if not model_data:
|
||||
continue
|
||||
|
||||
file_path = model_data.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
|
||||
repaired = repair_local_video_dimensions(payload, local_files)
|
||||
if repaired <= 0:
|
||||
continue
|
||||
|
||||
# The model cache shape differs from the on-disk payload, so
|
||||
# persist the file first and let the cache sync re-read it.
|
||||
await MetadataManager.save_metadata(file_path, payload)
|
||||
await update_cache_from_metadata(scanner, file_path, payload)
|
||||
|
||||
repaired_entries += repaired
|
||||
updated_models += 1
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to repair example video dimensions for %s: %s",
|
||||
folder,
|
||||
exc,
|
||||
)
|
||||
migration_errors += 1
|
||||
|
||||
logger.info(
|
||||
"Migration to v3 complete: repaired %d example entr(ies) across %d model(s) "
|
||||
"with %d error(s)",
|
||||
repaired_entries,
|
||||
updated_models,
|
||||
migration_errors,
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Cross-process advisory locking for shared LoRA Manager state.
|
||||
|
||||
Two LoRA Manager processes (the ComfyUI plugin and a standalone server, or two
|
||||
ComfyUI installs pointed at the same settings directory) can open the same cache
|
||||
database. SQLite serializes individual statements, but it cannot make a
|
||||
read-modify-write *sequence* atomic across processes: two full-table cache
|
||||
replacements can interleave so that one process's snapshot overwrites the
|
||||
other's.
|
||||
|
||||
This module provides a small advisory file lock for those sequences. It is
|
||||
deliberately non-fatal: if locking is unavailable or the wait times out, callers
|
||||
keep working with SQLite's own ``busy_timeout`` as the fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long to wait for another process to release the lock before giving up.
|
||||
DEFAULT_LOCK_TIMEOUT_SECONDS = 30.0
|
||||
_POLL_INTERVAL_SECONDS = 0.05
|
||||
|
||||
# Windows byte-range locks; fcntl.flock on POSIX.
|
||||
try: # pragma: no cover - platform dependent
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - Windows
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
try: # pragma: no cover - Windows only
|
||||
import msvcrt
|
||||
except ImportError: # pragma: no cover - POSIX
|
||||
msvcrt = None # type: ignore[assignment]
|
||||
|
||||
|
||||
class FileLockUnavailable(RuntimeError):
|
||||
"""Raised when the lock could not be acquired within the timeout."""
|
||||
|
||||
|
||||
def lock_path_for(db_path: str) -> str:
|
||||
"""Return the sibling lock file path used for *db_path*."""
|
||||
absolute = os.path.abspath(db_path)
|
||||
directory = os.path.dirname(absolute)
|
||||
if not directory:
|
||||
raise ValueError(f"Cannot derive a lock directory from {db_path!r}")
|
||||
return os.path.join(directory, f".{os.path.basename(absolute)}.lock")
|
||||
|
||||
|
||||
class CrossProcessLock:
|
||||
"""A best-effort advisory lock backed by a lock file.
|
||||
|
||||
The lock file is a sibling of the guarded resource and is never deleted:
|
||||
unlinking it would let a second process create a fresh inode and lock that
|
||||
instead, defeating mutual exclusion.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
|
||||
self.path = path
|
||||
self.timeout = timeout
|
||||
self._handle = None
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""Try to take the lock, waiting up to ``timeout`` seconds.
|
||||
|
||||
Returns:
|
||||
True when the lock is held (including when another lock is already
|
||||
held by *this* process — the calls are not reentrant, so callers must
|
||||
not nest them). False when locking is unsupported or timed out; the
|
||||
caller should proceed and rely on the SQLite busy timeout instead.
|
||||
"""
|
||||
if fcntl is None and msvcrt is None: # pragma: no cover - exotic platform
|
||||
return False
|
||||
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
try:
|
||||
handle = open(self.path, "a+b")
|
||||
except OSError as exc:
|
||||
logger.debug("Could not open lock file %s: %s", self.path, exc)
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + max(0.0, self.timeout)
|
||||
while True:
|
||||
if self._try_lock(handle):
|
||||
self._handle = handle
|
||||
return True
|
||||
if time.monotonic() >= deadline:
|
||||
handle.close()
|
||||
return False
|
||||
time.sleep(_POLL_INTERVAL_SECONDS)
|
||||
|
||||
def release(self) -> None:
|
||||
"""Release the lock if held. Safe to call more than once."""
|
||||
handle = self._handle
|
||||
if handle is None:
|
||||
return
|
||||
self._handle = None
|
||||
try:
|
||||
self._unlock(handle)
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.debug("Failed to release lock %s: %s", self.path, exc)
|
||||
finally:
|
||||
try:
|
||||
handle.close()
|
||||
except OSError: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "CrossProcessLock":
|
||||
self.acquire()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc_info: object) -> None:
|
||||
self.release()
|
||||
|
||||
# -- platform primitives -------------------------------------------------
|
||||
|
||||
def _try_lock(self, handle) -> bool:
|
||||
if fcntl is not None:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
if msvcrt is not None: # pragma: no cover - Windows
|
||||
try:
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _unlock(self, handle) -> None:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
return
|
||||
if msvcrt is not None: # pragma: no cover - Windows
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
|
||||
def exclusive_lock(db_path: str, timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS):
|
||||
"""Return a :class:`CrossProcessLock` for the database at *db_path*."""
|
||||
return CrossProcessLock(lock_path_for(db_path), timeout=timeout)
|
||||
+88
-13
@@ -2,7 +2,11 @@ from dataclasses import dataclass, asdict, field
|
||||
from typing import Callable, Dict, Optional, List, Any
|
||||
from datetime import datetime
|
||||
import os
|
||||
from .constants import INVALID_AUTOV3_EMPTY_HASH
|
||||
from .constants import (
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||
INVALID_AUTOV3_EMPTY_HASH,
|
||||
MODEL_FILE_EXTENSIONS,
|
||||
)
|
||||
from .model_utils import determine_base_model
|
||||
|
||||
|
||||
@@ -46,6 +50,24 @@ def autov3_from_civitai_files(civitai_data: Optional[Dict[str, Any]], sha256: st
|
||||
return None
|
||||
|
||||
|
||||
def strip_model_extension(file_name: str) -> str:
|
||||
"""Strip a recognized model file extension, leaving dotted stems intact.
|
||||
|
||||
``os.path.splitext`` treats everything after the last dot as an extension,
|
||||
so applying it to an already extension-free name truncates dotted stems:
|
||||
``lora-sd1.5-backlight_slider_v10`` becomes ``lora-sd1``. API filenames keep
|
||||
their extension and need one strip, while migration paths (``.civitai.info``)
|
||||
pass the local stem as-is, so only remove a suffix that is a known model
|
||||
extension and both inputs resolve to the same stem (issue #1112).
|
||||
"""
|
||||
if not file_name:
|
||||
return file_name
|
||||
stem, extension = os.path.splitext(file_name)
|
||||
if extension.lower() in MODEL_FILE_EXTENSIONS:
|
||||
return stem
|
||||
return file_name
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseModelMetadata:
|
||||
"""Base class for all model metadata structures"""
|
||||
@@ -77,9 +99,6 @@ class BaseModelMetadata:
|
||||
last_checked_at: float = 0 # Last checked timestamp
|
||||
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
||||
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
|
||||
trainedWords: List[str] = field(
|
||||
default_factory=list
|
||||
) # Trigger words / activation prompts (source-agnostic)
|
||||
_unknown_fields: Dict[str, Any] = field(
|
||||
default_factory=dict, repr=False, compare=False
|
||||
) # Store unknown fields
|
||||
@@ -92,9 +111,6 @@ class BaseModelMetadata:
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
if self.trainedWords is None:
|
||||
self.trainedWords = []
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
||||
"""Create instance from dictionary"""
|
||||
@@ -247,6 +263,7 @@ class LoraMetadata(BaseModelMetadata):
|
||||
) -> "LoraMetadata":
|
||||
"""Create LoraMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get("name", "")
|
||||
base_name = strip_model_extension(file_name)
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
|
||||
# Extract tags and description if available
|
||||
@@ -261,8 +278,8 @@ class LoraMetadata(BaseModelMetadata):
|
||||
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_name=base_name,
|
||||
model_name=model_data.get("name", base_name),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
@@ -291,6 +308,7 @@ class CheckpointMetadata(BaseModelMetadata):
|
||||
) -> "CheckpointMetadata":
|
||||
"""Create CheckpointMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get("name", "")
|
||||
base_name = strip_model_extension(file_name)
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
|
||||
sub_type = version_info.get("type", "checkpoint")
|
||||
@@ -305,8 +323,64 @@ class CheckpointMetadata(BaseModelMetadata):
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_name=base_name,
|
||||
model_name=model_data.get("name", base_name),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=sha256_value,
|
||||
base_model=base_model,
|
||||
preview_url="", # Will be updated after preview download
|
||||
preview_nsfw_level=0,
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
sub_type=sub_type,
|
||||
tags=tags,
|
||||
modelDescription=description,
|
||||
# Direct read: the downloaded file IS file_info, no SHA256 matching.
|
||||
autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OtherModelMetadata(BaseModelMetadata):
|
||||
"""Represents the metadata structure for an "other" model (VAE, upscaler,
|
||||
text encoder, CLIP vision, ControlNet, ...).
|
||||
|
||||
The sub_type is location-derived: the OtherScanner sets it from the
|
||||
folder_paths category whose root contains the file. The dataclass default
|
||||
is only a placeholder.
|
||||
"""
|
||||
|
||||
sub_type: str = "vae" # Placeholder; overridden by the scanner hooks
|
||||
|
||||
@classmethod
|
||||
def from_civitai_info(
|
||||
cls, version_info: Dict[str, Any], file_info: Dict[str, Any], save_path: str
|
||||
) -> "OtherModelMetadata":
|
||||
"""Create OtherModelMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get("name", "")
|
||||
base_name = strip_model_extension(file_name)
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
|
||||
# Map the CivitAI model type onto our sub_types; unknown types keep the
|
||||
# placeholder until the scanner re-derives sub_type from the location.
|
||||
# The type lives at version["model"]["type"], not version["type"].
|
||||
civitai_type = str((version_info.get("model") or {}).get("type", "") or "").lower()
|
||||
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(civitai_type, "vae")
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get("model") or {}
|
||||
if "tags" in model_data:
|
||||
tags = model_data["tags"]
|
||||
if "description" in model_data:
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_name=base_name,
|
||||
model_name=model_data.get("name", base_name),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
@@ -336,6 +410,7 @@ class EmbeddingMetadata(BaseModelMetadata):
|
||||
) -> "EmbeddingMetadata":
|
||||
"""Create EmbeddingMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get("name", "")
|
||||
base_name = strip_model_extension(file_name)
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
|
||||
sub_type = version_info.get("type", "embedding")
|
||||
@@ -350,8 +425,8 @@ class EmbeddingMetadata(BaseModelMetadata):
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_name=base_name,
|
||||
model_name=model_data.get("name", base_name),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
|
||||
@@ -174,12 +174,42 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
return target_path
|
||||
|
||||
|
||||
def _portable_env_override() -> Optional[bool]:
|
||||
"""Return the portable mode forced by ``LORA_MANAGER_PORTABLE``, if any.
|
||||
|
||||
Returns:
|
||||
``True`` when the variable enables portable mode, ``False`` when it is
|
||||
explicitly set to ``"0"``, and ``None`` when it is unset or holds some
|
||||
other value (in which case the persisted settings flag decides).
|
||||
"""
|
||||
|
||||
raw = os.environ.get(_LM_PORTABLE_ENV)
|
||||
if raw is None:
|
||||
return None
|
||||
if raw == "1":
|
||||
return True
|
||||
if raw == "0":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
|
||||
"""Return ``True`` when the env var forces it or the settings file enables it."""
|
||||
|
||||
if os.environ.get(_LM_PORTABLE_ENV, "0") == "1":
|
||||
override = _portable_env_override()
|
||||
if override is True:
|
||||
logger.debug("Portable mode enabled via %s", _LM_PORTABLE_ENV)
|
||||
return True
|
||||
if override is False:
|
||||
# Explicit opt-out. Without this, a single `LORA_MANAGER_PORTABLE=1`
|
||||
# run would pin the shared plugin settings.json to portable mode
|
||||
# forever, with no way back except editing that file by hand.
|
||||
logger.info(
|
||||
"Portable mode disabled via %s=%s",
|
||||
_LM_PORTABLE_ENV,
|
||||
os.environ.get(_LM_PORTABLE_ENV, ""),
|
||||
)
|
||||
return False
|
||||
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
"""Read intrinsic dimensions from video containers without external tooling.
|
||||
|
||||
PIL cannot open ``.mp4``/``.webm`` files, so example videos imported through
|
||||
the "Add examples" flow used to fall back to a hardcoded ``720x1280`` (portrait)
|
||||
entry, which forced the showcase viewer to letterbox landscape videos.
|
||||
|
||||
This module reads the dimensions out of the container headers themselves:
|
||||
|
||||
* ISO base media files (``.mp4``/``.mov``/``.m4v``) — ``moov/trak/tkhd``,
|
||||
falling back to the sample description of the video track.
|
||||
* WebM/Matroska (``.webm``/``.mkv``) — ``Segment/Tracks/TrackEntry/Video``
|
||||
``PixelWidth``/``PixelHeight``.
|
||||
* Animated WebP (``RIFF``/``WEBP``) — handled because users routinely save
|
||||
animated examples with a video extension.
|
||||
|
||||
The container signature decides which reader runs, so a mislabelled file
|
||||
(a ``.mp4`` that is really WebM) still reports the right dimensions.
|
||||
|
||||
Both readers stream over the file: only container headers are read, so a
|
||||
multi-gigabyte ``mdat`` is never pulled into memory (it is seeked past).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
from typing import BinaryIO, Iterator, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ISO_MEDIA_EXTENSIONS = frozenset({".mp4", ".m4v", ".mov"})
|
||||
EBML_MEDIA_EXTENSIONS = frozenset({".webm", ".mkv"})
|
||||
|
||||
_EBML_MAGIC = b"\x1a\x45\xdf\xa3"
|
||||
|
||||
# Cap recursion into nesting containers so a crafted/corrupt file cannot blow
|
||||
# the Python stack.
|
||||
_MAX_BOX_DEPTH = 12
|
||||
_MAX_EBML_DEPTH = 12
|
||||
|
||||
# Header structs (``tkhd``, sample entries) are tiny; guard against a bogus
|
||||
# size claiming the whole file.
|
||||
_MAX_HEADER_PAYLOAD = 1024 * 1024
|
||||
|
||||
_WIDTH_HEIGHT_UNSET = (0, 0)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def _get_video_dimensions_cached(
|
||||
path: str, _mtime_ns: int, _size: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return ``(width, height)`` for ``path``, or ``None`` on any failure.
|
||||
|
||||
``_mtime_ns`` and ``_size`` participate in the cache key only so a replaced
|
||||
file is re-probed; they are never read by the parser.
|
||||
"""
|
||||
try:
|
||||
return _read_video_dimensions(path)
|
||||
except Exception:
|
||||
logger.debug("Failed to read video dimensions for %s", path, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _read_video_dimensions(path: str) -> Optional[Tuple[int, int]]:
|
||||
"""Dispatch to the ISO or EBML reader based on the container's magic bytes.
|
||||
|
||||
Real libraries contain files whose extension lies about their container
|
||||
(a ``.mp4`` that is really WebM, typically), so the sniffed signature wins
|
||||
and the extension is only a fallback.
|
||||
"""
|
||||
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
file_size = os.path.getsize(path)
|
||||
|
||||
with open(path, "rb") as stream:
|
||||
magic = stream.read(12)
|
||||
|
||||
if _looks_like_iso_media(magic):
|
||||
return _read_iso_media_dimensions(stream, file_size)
|
||||
if magic[:4] == _EBML_MAGIC:
|
||||
return _read_ebml_dimensions(stream, file_size)
|
||||
if magic[:4] == b"RIFF" and magic[8:12] == b"WEBP":
|
||||
return _read_riff_webp_dimensions(stream, file_size)
|
||||
|
||||
# Signature is inconclusive (truncated or unusual file): fall back to
|
||||
# the extension.
|
||||
if ext in EBML_MEDIA_EXTENSIONS:
|
||||
return _read_ebml_dimensions(stream, file_size)
|
||||
if ext in ISO_MEDIA_EXTENSIONS:
|
||||
return _read_iso_media_dimensions(stream, file_size)
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_iso_media(magic: bytes) -> bool:
|
||||
"""Return True when the leading bytes are an ISO base media box header."""
|
||||
|
||||
return len(magic) >= 8 and magic[4:8] in {
|
||||
b"ftyp",
|
||||
b"moov",
|
||||
b"mdat",
|
||||
b"free",
|
||||
b"skip",
|
||||
b"wide",
|
||||
}
|
||||
|
||||
|
||||
def get_video_dimensions(path: str) -> Optional[Tuple[int, int]]:
|
||||
"""Return the intrinsic ``(width, height)`` of a local video file.
|
||||
|
||||
Returns ``None`` when the extension is unsupported, the file is missing or
|
||||
corrupt, or the dimensions cannot be determined. Never raises.
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
return _get_video_dimensions_cached(path, stat.st_mtime_ns, stat.st_size)
|
||||
|
||||
|
||||
def _clear_video_dimensions_cache() -> None:
|
||||
"""Drop the dimension cache (used by tests)."""
|
||||
|
||||
_get_video_dimensions_cached.cache_clear()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ISO base media (MP4 / MOV)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _iter_boxes(
|
||||
stream: BinaryIO, end: int, depth: int = 0
|
||||
) -> Iterator[Tuple[bytes, int, int]]:
|
||||
"""Yield ``(type, payload_start, box_end)`` for boxes in ``[tell, end)``.
|
||||
|
||||
The stream is left at the next box boundary after each yielded box.
|
||||
"""
|
||||
if depth > _MAX_BOX_DEPTH:
|
||||
return
|
||||
|
||||
while True:
|
||||
start = stream.tell()
|
||||
if start + 8 > end:
|
||||
return
|
||||
|
||||
header = stream.read(8)
|
||||
if len(header) < 8:
|
||||
return
|
||||
|
||||
size, box_type = struct.unpack(">I4s", header)
|
||||
header_size = 8
|
||||
|
||||
if size == 1:
|
||||
# 64-bit ``largesize`` follows the type.
|
||||
extended = stream.read(8)
|
||||
if len(extended) < 8:
|
||||
return
|
||||
size = struct.unpack(">Q", extended)[0]
|
||||
header_size = 16
|
||||
elif size == 0:
|
||||
# Box extends to the end of the enclosing container.
|
||||
size = end - start
|
||||
|
||||
if size < header_size or start + size > end:
|
||||
return
|
||||
|
||||
yield box_type, start + header_size, start + size
|
||||
stream.seek(start + size)
|
||||
|
||||
|
||||
def _read_iso_media_dimensions(
|
||||
stream: BinaryIO, file_size: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Walk ``moov`` looking for the video track's dimensions."""
|
||||
|
||||
stream.seek(0)
|
||||
moov: Optional[Tuple[int, int]] = None
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, file_size):
|
||||
if box_type == b"moov":
|
||||
moov = (payload_start, box_end)
|
||||
break
|
||||
|
||||
if moov is None:
|
||||
return None
|
||||
|
||||
stream.seek(moov[0])
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, moov[1], depth=1):
|
||||
if box_type != b"trak":
|
||||
continue
|
||||
dimensions = _read_trak_dimensions(stream, payload_start, box_end)
|
||||
if dimensions is not None:
|
||||
return dimensions
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_trak_dimensions(
|
||||
stream: BinaryIO, trak_start: int, trak_end: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return the dimensions of a ``trak`` when it describes a video track."""
|
||||
|
||||
stream.seek(trak_start)
|
||||
|
||||
is_video = False
|
||||
tkhd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||
stsd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, trak_end, depth=2):
|
||||
if box_type == b"tkhd":
|
||||
tkhd_dimensions = _parse_tkhd(stream, payload_start, box_end)
|
||||
elif box_type == b"mdia":
|
||||
stream.seek(payload_start)
|
||||
media = _read_mdia_dimensions(stream, payload_start, box_end)
|
||||
if media is not None:
|
||||
is_video, stsd_dimensions = media
|
||||
|
||||
if not is_video:
|
||||
return None
|
||||
|
||||
# ``tkhd`` is preferred: it is display space, and its 16.16 fixed point
|
||||
# encoding keeps non-integer dimensions (odd crops produce those).
|
||||
for width, height in (tkhd_dimensions, stsd_dimensions):
|
||||
if width > 0 and height > 0:
|
||||
return int(round(width)), int(round(height))
|
||||
return None
|
||||
|
||||
|
||||
def _read_mdia_dimensions(
|
||||
stream: BinaryIO, mdia_start: int, mdia_end: int
|
||||
) -> Optional[Tuple[bool, Tuple[float, float]]]:
|
||||
"""Return ``(is_video, dimensions)`` for a ``mdia`` box."""
|
||||
|
||||
handler_type = b""
|
||||
stsd_dimensions = _WIDTH_HEIGHT_UNSET
|
||||
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, mdia_end, depth=3):
|
||||
if box_type == b"hdlr":
|
||||
handler_type = _parse_handler_type(stream, payload_start, box_end)
|
||||
elif box_type == b"minf":
|
||||
stream.seek(payload_start)
|
||||
stsd_dimensions = _read_minf_dimensions(stream, payload_start, box_end)
|
||||
|
||||
return handler_type == b"vide", stsd_dimensions
|
||||
|
||||
|
||||
def _read_minf_dimensions(
|
||||
stream: BinaryIO, minf_start: int, minf_end: int
|
||||
) -> Tuple[float, float]:
|
||||
"""Return the sample-entry dimensions declared under ``minf/stbl/stsd``."""
|
||||
|
||||
for box_type, payload_start, box_end in _iter_boxes(stream, minf_end, depth=4):
|
||||
if box_type != b"stbl":
|
||||
continue
|
||||
stream.seek(payload_start)
|
||||
for inner_type, inner_start, inner_end in _iter_boxes(
|
||||
stream, box_end, depth=5
|
||||
):
|
||||
if inner_type == b"stsd":
|
||||
return _parse_stsd(stream, inner_start, inner_end)
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
|
||||
def _parse_tkhd(
|
||||
stream: BinaryIO, payload_start: int, box_end: int
|
||||
) -> Tuple[float, float]:
|
||||
"""Parse the 16.16 fixed point width/height trailer of a ``tkhd`` box."""
|
||||
|
||||
size = box_end - payload_start
|
||||
if size < 8 or size > _MAX_HEADER_PAYLOAD:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
stream.seek(box_end - 8)
|
||||
trailer = stream.read(8)
|
||||
if len(trailer) < 8:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
width, height = struct.unpack(">II", trailer)
|
||||
return width / 65536.0, height / 65536.0
|
||||
|
||||
|
||||
def _parse_handler_type(
|
||||
stream: BinaryIO, payload_start: int, box_end: int
|
||||
) -> bytes:
|
||||
"""Parse the handler type from an ``hdlr`` box.
|
||||
|
||||
Layout: version/flags (4) + pre_defined (4) + handler_type (4).
|
||||
"""
|
||||
|
||||
if box_end - payload_start < 12:
|
||||
return b""
|
||||
stream.seek(payload_start)
|
||||
data = stream.read(12)
|
||||
if len(data) < 12:
|
||||
return b""
|
||||
return data[8:12]
|
||||
|
||||
|
||||
def _parse_stsd(
|
||||
stream: BinaryIO, payload_start: int, box_end: int
|
||||
) -> Tuple[float, float]:
|
||||
"""Parse the visual sample entry dimensions from an ``stsd`` box.
|
||||
|
||||
Only the first entry is inspected: video tracks are single-entry in every
|
||||
container we import from.
|
||||
"""
|
||||
|
||||
if box_end - payload_start < 16:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
stream.seek(payload_start)
|
||||
header = stream.read(8) # version/flags + entry_count
|
||||
if len(header) < 8:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
entry_start = payload_start + 8
|
||||
if entry_start + 8 > box_end:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
stream.seek(entry_start)
|
||||
entry_header = stream.read(8)
|
||||
if len(entry_header) < 8:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
entry_size = struct.unpack(">I", entry_header[:4])[0]
|
||||
header_size = 8
|
||||
|
||||
if entry_size == 1:
|
||||
extended = stream.read(8)
|
||||
if len(extended) < 8:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
entry_size = struct.unpack(">Q", extended)[0]
|
||||
header_size = 16
|
||||
elif entry_size == 0:
|
||||
entry_size = box_end - entry_start
|
||||
|
||||
if entry_size < header_size + 8 or entry_start + entry_size > box_end:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
# Visual sample entries: 6 bytes reserved + 2 bytes data_reference_index,
|
||||
# then width (2) and height (2).
|
||||
stream.seek(entry_start + header_size + 6 + 2)
|
||||
dimensions = stream.read(4)
|
||||
if len(dimensions) < 4:
|
||||
return _WIDTH_HEIGHT_UNSET
|
||||
|
||||
width, height = struct.unpack(">HH", dimensions)
|
||||
return float(width), float(height)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# WebM / Matroska (EBML)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# EBML element IDs (stored with their length marker, as they appear on disk).
|
||||
_ID_SEGMENT = 0x18538067
|
||||
_ID_TRACKS = 0x1654AE6B
|
||||
_ID_TRACK_ENTRY = 0xAE
|
||||
_ID_TRACK_TYPE = 0x83
|
||||
_ID_VIDEO = 0xE0
|
||||
_ID_PIXEL_WIDTH = 0xB0
|
||||
_ID_PIXEL_HEIGHT = 0xBA
|
||||
|
||||
# Nested containers we descend into while hunting for video dimensions.
|
||||
_EBML_CONTAINER_IDS = frozenset({_ID_SEGMENT, _ID_TRACKS, _ID_TRACK_ENTRY})
|
||||
|
||||
|
||||
def _read_ebml_vint(stream: BinaryIO, *, keep_marker: bool) -> Optional[Tuple[int, int]]:
|
||||
"""Read an EBML variable-length integer.
|
||||
|
||||
Returns ``(value, byte_length)``. For element IDs the marker bit is kept
|
||||
(``keep_marker=True``) because IDs are compared in their on-disk form; for
|
||||
sizes the marker is stripped to yield the actual payload length.
|
||||
"""
|
||||
|
||||
first = stream.read(1)
|
||||
if not first:
|
||||
return None
|
||||
|
||||
first_byte = first[0]
|
||||
if first_byte == 0:
|
||||
return None
|
||||
|
||||
length = 1
|
||||
mask = 0x80
|
||||
while not first_byte & mask:
|
||||
mask >>= 1
|
||||
length += 1
|
||||
if length > 8:
|
||||
return None
|
||||
|
||||
value = first_byte if keep_marker else first_byte & (mask - 1)
|
||||
remaining = length - 1
|
||||
|
||||
if remaining:
|
||||
extra = stream.read(remaining)
|
||||
if len(extra) < remaining:
|
||||
return None
|
||||
for byte in extra:
|
||||
value = (value << 8) | byte
|
||||
|
||||
return value, length
|
||||
|
||||
|
||||
def _read_ebml_dimensions(
|
||||
stream: BinaryIO, file_size: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Parse ``Segment/Tracks`` for the first video ``TrackEntry``."""
|
||||
|
||||
stream.seek(0)
|
||||
header = stream.read(4)
|
||||
if header != _EBML_MAGIC:
|
||||
return None
|
||||
|
||||
return _walk_ebml(stream, 0, file_size, depth=0)
|
||||
|
||||
|
||||
def _walk_ebml(
|
||||
stream: BinaryIO, start: int, end: int, *, depth: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Recursively scan EBML elements in ``[start, end)`` for video dimensions."""
|
||||
|
||||
if depth > _MAX_EBML_DEPTH:
|
||||
return None
|
||||
|
||||
stream.seek(start)
|
||||
|
||||
while stream.tell() < end:
|
||||
element_start = stream.tell()
|
||||
|
||||
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||
if element_id is None:
|
||||
return None
|
||||
element_id_value = element_id[0]
|
||||
|
||||
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||
if size_field is None:
|
||||
return None
|
||||
payload_size, size_length = size_field
|
||||
|
||||
payload_start = element_start + element_id[1] + size_length
|
||||
|
||||
# A size field of all-ones marks an unknown-size element, which is
|
||||
# legal for Segment/Tracks; treat it as "until the parent ends".
|
||||
unknown_size = payload_size == (1 << (7 * size_length)) - 1
|
||||
payload_end = end if unknown_size else payload_start + payload_size
|
||||
|
||||
if payload_end > end:
|
||||
return None
|
||||
|
||||
if element_id_value == _ID_VIDEO:
|
||||
dimensions = _read_ebml_video(stream, payload_start, min(payload_end, end))
|
||||
if dimensions is not None:
|
||||
return dimensions
|
||||
elif element_id_value == _ID_TRACK_ENTRY:
|
||||
track = _read_ebml_track_entry(
|
||||
stream, payload_start, min(payload_end, end)
|
||||
)
|
||||
if track is not None:
|
||||
return track
|
||||
elif element_id_value in _EBML_CONTAINER_IDS:
|
||||
found = _walk_ebml(
|
||||
stream, payload_start, min(payload_end, end), depth=depth + 1
|
||||
)
|
||||
if found is not None:
|
||||
return found
|
||||
|
||||
if unknown_size:
|
||||
# Cannot resume after an unknown-size element; its siblings cannot
|
||||
# be located reliably, so stop scanning this level.
|
||||
return None
|
||||
|
||||
stream.seek(payload_end)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_ebml_track_entry(
|
||||
stream: BinaryIO, start: int, end: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return dimensions when a ``TrackEntry`` is a video track."""
|
||||
|
||||
track_type: Optional[int] = None
|
||||
dimensions: Optional[Tuple[int, int]] = None
|
||||
|
||||
stream.seek(start)
|
||||
while stream.tell() < end:
|
||||
element_start = stream.tell()
|
||||
|
||||
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||
if element_id is None:
|
||||
return None
|
||||
|
||||
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||
if size_field is None:
|
||||
return None
|
||||
payload_size, size_length = size_field
|
||||
|
||||
payload_start = element_start + element_id[1] + size_length
|
||||
payload_end = min(payload_start + payload_size, end)
|
||||
|
||||
if element_id[0] == _ID_TRACK_TYPE:
|
||||
track_type = _read_ebml_uint(stream, payload_start, payload_end)
|
||||
elif element_id[0] == _ID_VIDEO:
|
||||
dimensions = _read_ebml_video(stream, payload_start, payload_end)
|
||||
|
||||
stream.seek(payload_end)
|
||||
|
||||
# Track type 1 is video.
|
||||
if track_type == 1 and dimensions is not None:
|
||||
return dimensions
|
||||
return None
|
||||
|
||||
|
||||
def _read_ebml_video(
|
||||
stream: BinaryIO, start: int, end: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return ``PixelWidth``/``PixelHeight`` from a ``Video`` element."""
|
||||
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
|
||||
stream.seek(start)
|
||||
while stream.tell() < end:
|
||||
element_start = stream.tell()
|
||||
|
||||
element_id = _read_ebml_vint(stream, keep_marker=True)
|
||||
if element_id is None:
|
||||
return None
|
||||
|
||||
size_field = _read_ebml_vint(stream, keep_marker=False)
|
||||
if size_field is None:
|
||||
return None
|
||||
payload_size, size_length = size_field
|
||||
|
||||
payload_start = element_start + element_id[1] + size_length
|
||||
payload_end = min(payload_start + payload_size, end)
|
||||
|
||||
if element_id[0] == _ID_PIXEL_WIDTH:
|
||||
width = _read_ebml_uint(stream, payload_start, payload_end)
|
||||
elif element_id[0] == _ID_PIXEL_HEIGHT:
|
||||
height = _read_ebml_uint(stream, payload_start, payload_end)
|
||||
|
||||
stream.seek(payload_end)
|
||||
|
||||
if width and height and width > 0 and height > 0:
|
||||
return width, height
|
||||
return None
|
||||
|
||||
|
||||
def _read_ebml_uint(stream: BinaryIO, start: int, end: int) -> Optional[int]:
|
||||
"""Read an unsigned big-endian integer element payload."""
|
||||
|
||||
length = end - start
|
||||
if length <= 0 or length > 8:
|
||||
return None
|
||||
|
||||
stream.seek(start)
|
||||
raw = stream.read(length)
|
||||
if len(raw) < length:
|
||||
return None
|
||||
|
||||
value = 0
|
||||
for byte in raw:
|
||||
value = (value << 8) | byte
|
||||
return value
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# RIFF / WebP (animated examples are often renamed to ``.mp4``)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _read_riff_webp_dimensions(
|
||||
stream: BinaryIO, file_size: int
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""Return dimensions from a WebP file's first dimension-bearing chunk."""
|
||||
|
||||
stream.seek(12)
|
||||
|
||||
while stream.tell() + 8 <= file_size:
|
||||
header = stream.read(8)
|
||||
if len(header) < 8:
|
||||
return None
|
||||
|
||||
fourcc, chunk_size = struct.unpack("<4sI", header)
|
||||
payload_start = stream.tell()
|
||||
|
||||
if fourcc == b"VP8X":
|
||||
payload = stream.read(10)
|
||||
if len(payload) < 10:
|
||||
return None
|
||||
# Canvas size is stored minus one, as 24-bit little endian values.
|
||||
width = int.from_bytes(payload[4:7], "little") + 1
|
||||
height = int.from_bytes(payload[7:10], "little") + 1
|
||||
return width, height
|
||||
|
||||
if fourcc == b"VP8 ":
|
||||
# Frame tag (3 bytes, bit 0 = key frame) then the key frame start
|
||||
# code 0x9d 0x01 0x2a and the 16-bit dimensions.
|
||||
payload = stream.read(10)
|
||||
if len(payload) < 10:
|
||||
return None
|
||||
start = payload.find(b"\x9d\x01\x2a")
|
||||
if start < 0 or start + 7 > len(payload):
|
||||
return None
|
||||
width, height = struct.unpack("<HH", payload[start + 3 : start + 7])
|
||||
return width & 0x3FFF, height & 0x3FFF
|
||||
|
||||
if fourcc == b"VP8L":
|
||||
payload = stream.read(5)
|
||||
if len(payload) < 5 or payload[0] != 0x2F:
|
||||
return None
|
||||
bits = int.from_bytes(payload[1:5], "little")
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
|
||||
# Skip this chunk (payloads are padded to an even byte boundary).
|
||||
stream.seek(payload_start + chunk_size + (chunk_size & 1))
|
||||
|
||||
return None
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -225,10 +225,9 @@ def main() -> int:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get project root (parent of .agents directory)
|
||||
# Get project root: this script lives in <project_root>/scripts/e2e/.
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
skill_dir = os.path.dirname(script_dir)
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
|
||||
project_root = os.path.dirname(os.path.dirname(script_dir))
|
||||
|
||||
managed_pids = read_managed_pids(args.port)
|
||||
|
||||
|
||||
@@ -18,6 +18,5 @@
|
||||
"C:/path/to/your/embeddings_folder",
|
||||
"C:/path/to/another/embeddings_folder"
|
||||
]
|
||||
},
|
||||
"auto_organize_exclusions": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
/* Textarea Styling */
|
||||
#batchUrlInput {
|
||||
width: 100%;
|
||||
/* Content-box sizing made the border box wider than the modal's content
|
||||
box, so the right border/halo fell outside the clipped area and was cut
|
||||
off. Include padding and border in the declared width. */
|
||||
box-sizing: border-box;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1400px;
|
||||
max-width: none;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
@@ -38,19 +38,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Responsive header container for larger screens */
|
||||
@media (min-width: 2150px) {
|
||||
.header-container {
|
||||
max-width: 1800px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.header-container {
|
||||
max-width: 2400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo and title styling */
|
||||
.header-branding {
|
||||
display: flex;
|
||||
@@ -96,6 +83,12 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Opt-in pages (e.g. Other Models) hide their nav entry until enabled.
|
||||
A class is used instead of [hidden] because .nav-item sets display: flex. */
|
||||
.nav-item--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item:focus-visible {
|
||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
@@ -120,6 +113,9 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 600px;
|
||||
/* No hard floor: the field shrinks with the available space instead of parking
|
||||
at a fixed width and crowding its own placeholder (see the 1366px query). */
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@@ -128,6 +124,7 @@
|
||||
.header-search .search-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -149,7 +146,12 @@
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-left: 2.25rem !important;
|
||||
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
||||
/* Reserve exactly the inline chrome so typed text never runs under it:
|
||||
cue(58) + clear(28) + toggles(28 + 28 + 4 gap) + edges(8 + 8) = 126px.
|
||||
Below 1366px the cue is hidden and the reservation drops to 68px.
|
||||
!important is required: search-filter.css loads later and sets its own
|
||||
right padding at equal specificity (.search-container input). */
|
||||
padding-right: 7.875rem !important;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
@@ -697,6 +699,20 @@
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Responsive: the Ctrl+F cue is pure decoration and, above 950px, the widest
|
||||
thing inside the field. Below 1366px the header (branding + full nav) leaves
|
||||
too little room for it, so it steps aside and the field reclaims its 58px.
|
||||
The shortcut itself keeps working - only the visual hint is dropped. */
|
||||
@media (max-width: 1366px) {
|
||||
.header-search .search-shortcut-cue {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-search input {
|
||||
padding-right: 4.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
||||
@media (max-width: 1200px) {
|
||||
.header-container {
|
||||
@@ -716,11 +732,6 @@
|
||||
.header-controls {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-controls > div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive: Hide nav icons at 1100px to save space */
|
||||
@@ -797,13 +808,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* For very small screens - switch nav to icons only */
|
||||
@media (max-width: 600px) {
|
||||
.header-container {
|
||||
padding: 0 8px;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* For narrower screens - switch nav to icons only.
|
||||
A labelled nav needs ~383px and a readable search field needs ~300px, so the
|
||||
two cannot coexist below ~700px: at 601-700px the search input was previously
|
||||
squeezed to 200px, leaving only ~96px of text room and overlapping the
|
||||
placeholder with the inline toggles. Labels therefore collapse here. */
|
||||
@media (max-width: 700px) {
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
@@ -811,8 +821,7 @@
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
@@ -821,6 +830,22 @@
|
||||
|
||||
.nav-item i {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* For very small screens - tighten container spacing */
|
||||
@media (max-width: 600px) {
|
||||
.header-container {
|
||||
padding: 0 8px;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,32 @@
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
/* The transfer is done but the backend is still indexing the file and reading
|
||||
the model site's API. A sheen over the full bar reads as "busy" where a
|
||||
motionless 100% bar reads as "stuck". */
|
||||
.current-item-bar.is-indeterminate {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.current-item-bar.is-indeterminate::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.5) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: progress-sheen 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-sheen {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.current-item-percent {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-secondary, var(--text-color));
|
||||
@@ -131,4 +157,8 @@
|
||||
.current-item-bar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.current-item-bar.is-indeterminate::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,20 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Destructive entries. The token used to be the nonexistent `--danger-color`,
|
||||
which made the declaration invalid at computed-value time: the colour then
|
||||
fell back to the menu's inherited text colour, so every "Delete …" entry in
|
||||
the folder and model-card context menus rendered plain. */
|
||||
.context-menu-item.delete-item {
|
||||
color: var(--danger-color);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* The shared .context-menu-item:hover paints the accent background, which the
|
||||
red label does not read against — destructive entries get their own wash. */
|
||||
.context-menu-item.delete-item:hover,
|
||||
.context-menu-item.delete-item:focus-visible {
|
||||
background-color: var(--lora-error-bg);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.context-menu-item i {
|
||||
@@ -55,6 +67,21 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Muted counter shown next to a menu label (e.g. how many empty folders the
|
||||
"Show empty folders" toggle would reveal) */
|
||||
.context-menu-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* The count keeps the label/tally muted even while the row is hovered, since
|
||||
the accent background would otherwise wash the muted colour out. */
|
||||
.context-menu-item:hover .context-menu-count,
|
||||
.context-menu-item:focus-visible .context-menu-count {
|
||||
color: var(--lora-text);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.context-menu-section-header {
|
||||
padding: 6px 12px 2px;
|
||||
|
||||
@@ -592,3 +592,145 @@ button:disabled,
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Recipe Rematch Options Modal */
|
||||
#rematchOptionsModal .modal-body {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Selectable option card — click anywhere toggles the checkbox (label wrap).
|
||||
Checkmark follows the batch-import modal's custom checkbox pattern. */
|
||||
#rematchOptionsModal .rematch-option-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card:hover {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(from var(--lora-accent) l c h / 0.08);
|
||||
}
|
||||
|
||||
/* Visually hidden but keyboard-focusable (focus ring lands on the card). */
|
||||
#rematchOptionsModal .rematch-option-card input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) {
|
||||
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-checkmark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-base);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark {
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after {
|
||||
content: '\f00c';
|
||||
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||
font-weight: 900;
|
||||
color: var(--lora-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-color);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-caveat {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-caveat i {
|
||||
color: var(--lora-accent);
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js;
|
||||
stat cards / failure table / summary header come from
|
||||
metadata-refresh-result.css and download-batch-summary.css). */
|
||||
.rematch-summary-modal {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.rematch-cancelled-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.rematch-cancelled-note i {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Review section heading uses the accent (review, not failure) instead of
|
||||
the failure-section error color. */
|
||||
.rematch-review-section h4 {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#rematchSummaryModal .rematch-undo-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
.input-group input,
|
||||
.input-group select {
|
||||
width: 100%;
|
||||
/* Include padding/border in the declared width so full-width fields do not
|
||||
spill past the modal's content box, where their right border gets
|
||||
clipped by the step's overflow-x: hidden. */
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
@@ -720,6 +724,9 @@
|
||||
/* Textarea for multi-URL input */
|
||||
#modelUrl {
|
||||
width: 100%;
|
||||
/* Content-box sizing pushed the border box 2px past the step's content
|
||||
edge, clipping the right border. Include padding/border in the width. */
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
@@ -750,6 +757,42 @@
|
||||
#downloadModal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* The active step scrolls instead of the whole modal */
|
||||
}
|
||||
|
||||
/* Sticky footer layout (mirrors the import modal fix): fixed header,
|
||||
scrollable step content, pinned action buttons. Ensures Back/Download
|
||||
buttons stay visible on short viewports (e.g. 1080p or 150% zoom). */
|
||||
#downloadModal .modal-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#downloadModal .download-step {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; /* Allow the step to shrink and scroll within the flex container */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Fields sit flush against the scrollable step's content edge; the global
|
||||
focus outline (offset: 2px) has its left/right edges clipped by the step's
|
||||
overflow-x. Draw the ring inset so the full outline stays visible.
|
||||
(Same fix as #importModal in import-modal.css.) */
|
||||
#downloadModal input:focus-visible,
|
||||
#downloadModal select:focus-visible,
|
||||
#downloadModal textarea:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
#downloadModal .download-step .modal-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background: var(--lora-surface);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
padding-top: var(--space-2);
|
||||
padding-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
#batchPreviewStep {
|
||||
|
||||
@@ -747,13 +747,13 @@
|
||||
}
|
||||
|
||||
.priority-tags-input.settings-input-error {
|
||||
border-color: var(--danger-color, #dc2626);
|
||||
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12);
|
||||
border-color: var(--lora-error);
|
||||
box-shadow: 0 0 0 2px rgba(from var(--lora-error) r g b / 0.12);
|
||||
}
|
||||
|
||||
.settings-input-error-message {
|
||||
font-size: 0.8em;
|
||||
color: var(--danger-color, #dc2626);
|
||||
color: var(--lora-error);
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1744,3 +1744,39 @@ input:checked + .toggle-slider:before {
|
||||
font-style: italic;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Other Models opt-in: sub_type checkbox row */
|
||||
.other-subtype-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px 14px;
|
||||
}
|
||||
|
||||
.other-subtype-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.other-subtype-checkbox input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.other-subtype-toggles.is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.other-subtype-toggles.is-disabled .other-subtype-checkbox {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Disabled default-root selects for switched-off sub_types / feature */
|
||||
.select-control select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,38 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Tags row: the base model badge shares one line with the compact tags. */
|
||||
.recipe-tags-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recipe-tags-row #recipeTagsContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Header base model badge: reuses the card .base-model-label pill shape but
|
||||
swaps the on-image overlay styling (text shadow, backdrop blur) for the
|
||||
accent-tinted chip look used by resource rows in this modal. */
|
||||
.recipe-base-model-badge {
|
||||
flex-shrink: 0;
|
||||
max-width: 160px;
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
color: var(--lora-accent);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.recipe-base-model-badge.is-unknown {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.recipe-modal-header h2 {
|
||||
margin: 0 0 var(--space-1);
|
||||
padding: var(--space-1);
|
||||
|
||||
@@ -92,38 +92,104 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 8px;
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.metadata-item:active {
|
||||
/* --- Shared chip reordering (tags + trigger words) ------------------------ */
|
||||
/* Chips in a list that is actually sortable advertise the grab gesture only
|
||||
then, so lists that cannot be reordered never lie about it. */
|
||||
|
||||
.metadata-items.pointer-sort-enabled .metadata-item {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.metadata-items.pointer-sort-enabled .metadata-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.metadata-item-dragging {
|
||||
/* Grip handle: always in the DOM, revealed when the list is sortable */
|
||||
.reorder-handle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
margin-left: -2px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
opacity: 0.4;
|
||||
font-size: 0.8em;
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
/* Keep a touch drag on the handle from scrolling the surrounding panel */
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition: opacity 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.has-sortable-words .reorder-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Tag chips have no flex gap (unlike trigger word tags), so the grip needs its
|
||||
own spacing before the tag text */
|
||||
.metadata-item .reorder-handle {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.reorder-handle:hover {
|
||||
opacity: 0.9;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.reorder-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Hint shown in the edit controls row while reordering is available */
|
||||
.reorder-hint {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: auto;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.has-sortable-words .reorder-hint {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Snapped-to-grid transition for the remaining chips while dragging */
|
||||
.reorder-sorting > * {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
/* The lifted chip that follows the pointer */
|
||||
.reorder-dragging {
|
||||
box-shadow: var(--shadow-dialog);
|
||||
cursor: grabbing;
|
||||
opacity: 0.95;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.metadata-item-placeholder {
|
||||
/* Drop target left behind by the lifted chip */
|
||||
.reorder-placeholder {
|
||||
border: 1px dashed var(--lora-accent);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metadata-items-sorting .metadata-item {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
body.metadata-drag-active {
|
||||
body.reorder-drag-active {
|
||||
user-select: none;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
body.metadata-drag-active * {
|
||||
body.reorder-drag-active * {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -228,6 +228,18 @@
|
||||
border-left-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Empty folders (no models) shown when the empty-folders toggle is on */
|
||||
.sidebar-tree-node-content.empty .sidebar-tree-folder-name,
|
||||
.sidebar-node-content.empty .sidebar-folder-name {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content.empty .sidebar-tree-folder-icon,
|
||||
.sidebar-node-content.empty .sidebar-folder-icon {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content.drop-target .sidebar-tree-folder-icon,
|
||||
.sidebar-node-content.drop-target .sidebar-folder-icon {
|
||||
color: var(--lora-accent);
|
||||
@@ -627,88 +639,40 @@
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Create folder drop zone */
|
||||
.sidebar-create-folder-zone {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
padding: 16px;
|
||||
border: 2px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
|
||||
/* Create folder inline row: rendered inside the tree at the creation
|
||||
location, styled like a regular node row with a full-width input */
|
||||
.sidebar-create-folder-row {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidebar-tree-node-content.sidebar-create-folder-row:hover,
|
||||
.sidebar-node-content.sidebar-create-folder-row:hover {
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-spacer {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: var(--transition-base);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-zone.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.sidebar-create-folder-row .sidebar-tree-folder-icon,
|
||||
.sidebar-create-folder-row .sidebar-folder-icon {
|
||||
color: var(--lora-accent);
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-content i {
|
||||
font-size: 1.5em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Create folder input container */
|
||||
.sidebar-create-folder-input-container {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 20;
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input-wrapper > i {
|
||||
color: var(--lora-accent);
|
||||
font-size: 1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
min-width: 0; /* allow the input to shrink below its intrinsic width */
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
font-size: 1em;
|
||||
outline: none;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
@@ -718,49 +682,6 @@
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-create-folder-btn:hover,
|
||||
.sidebar-create-folder-btn:focus-visible {
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-confirm:hover,
|
||||
.sidebar-create-folder-confirm:focus-visible {
|
||||
background: oklch(from var(--success-color) l c h / 0.15);
|
||||
color: var(--success-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-cancel:hover,
|
||||
.sidebar-create-folder-cancel:focus-visible {
|
||||
background: oklch(from var(--error-color) l c h / 0.15);
|
||||
color: var(--error-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-create-folder-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Dragging state for sidebar */
|
||||
.folder-sidebar.dragging-active {
|
||||
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
|
||||
@@ -772,11 +693,6 @@
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
|
||||
}
|
||||
|
||||
/* Tree container positioning for create folder elements */
|
||||
.sidebar-tree-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Folder context menu - positioned relative to sidebar */
|
||||
#sidebarFolderContextMenu {
|
||||
z-index: var(--z-modal, 1002);
|
||||
|
||||
+33
-27
@@ -59,7 +59,10 @@ body.sticky-controls .sticky-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto; /* Push to the right */
|
||||
/* Push to the right of the row. Because it is also the flex item that is
|
||||
allowed to drop to a second row, an auto margin keeps it right-aligned on
|
||||
either row — no width: 100% / viewport breakpoint needed. */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -67,7 +70,11 @@ body.sticky-controls .sticky-topbar {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: nowrap;
|
||||
/* Wrap only when the controls genuinely cannot fit, instead of at a fixed
|
||||
viewport width. Viewport-based wrapping wasted space on high-DPI displays
|
||||
(e.g. a 2560px monitor at 200% scaling reports a ~1280px CSS viewport even
|
||||
when the window is maximized). */
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -75,7 +82,11 @@ body.sticky-controls .sticky-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: nowrap;
|
||||
/* Let the group shrink rather than overflow so .controls-right only wraps
|
||||
when it really has to. */
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Action button styling */
|
||||
@@ -84,7 +95,9 @@ body.sticky-controls .sticky-topbar {
|
||||
}
|
||||
|
||||
.control-group button {
|
||||
min-width: 100px;
|
||||
/* Keeps the toolbar visually even without forcing the row to overflow (the
|
||||
old 100px floor pushed the total past the container on wide screens). */
|
||||
min-width: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -627,49 +640,42 @@ body.sticky-controls .sticky-topbar {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
|
||||
/* Intermediate breakpoint: tighten the controls so the whole bar still fits on
|
||||
one row at common laptop/high-DPI widths. The buttons are allowed to shrink to
|
||||
their content (min-width: 0) here, which is what reclaims the space the old
|
||||
100px floor plus a forced wrap used to waste. .controls-right is deliberately
|
||||
NOT forced onto its own row: it stays inline while it fits and only drops to a
|
||||
second row (staying right-aligned through its auto margin) when it does not. */
|
||||
@media (max-width: 1500px) {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
padding-left: 0;
|
||||
.control-group button {
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* Reduce button sizes to fit better */
|
||||
.control-group button {
|
||||
min-width: 80px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
.control-group select {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Narrow screens: let the right-hand group wrap below the buttons, still
|
||||
right-aligned. */
|
||||
.controls-right {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.control-group button:hover {
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Keep the fixed-position sidebar anchored when highlighted, otherwise
|
||||
.onboarding-target-highlight's position: relative would pull it into
|
||||
normal flow and it would move away from the spotlight cutout */
|
||||
.folder-sidebar.onboarding-target-highlight {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.onboarding-popup {
|
||||
position: absolute;
|
||||
background: var(--lora-surface);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user