mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-04 17:15:33 -03:00
Compare commits
57
Commits
v1.1.4
..
8b344ea39f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b344ea39f | ||
|
|
8348a0cef8 | ||
|
|
7cf785b72f | ||
|
|
e8913f4481 | ||
|
|
f9c3d8dc97 | ||
|
|
09ca91fc0e | ||
|
|
16f5222efd | ||
|
|
28e7c04b37 | ||
|
|
28f99c46d3 | ||
|
|
205194f4e6 | ||
|
|
402d8b07cf | ||
|
|
3e303ab316 | ||
|
|
e9e8c31ad1 | ||
|
|
703a6a4ea0 | ||
|
|
283730cf38 | ||
|
|
20417797e8 | ||
|
|
004c69b9ef | ||
|
|
47fe2d3783 | ||
|
|
36ef840a22 | ||
|
|
09c2445ac9 | ||
|
|
8a6d23f9c7 | ||
|
|
3d207b6744 | ||
|
|
b3edda62ad | ||
|
|
a429e6b1c3 | ||
|
|
c1bf9c6221 | ||
|
|
75fffc1e25 | ||
|
|
f264bab65c | ||
|
|
154fcd803b | ||
|
|
4ef32d3a96 | ||
|
|
d2d109a69c | ||
|
|
3a2941d751 | ||
|
|
0ac10dfd42 | ||
|
|
9c95856b2f | ||
|
|
5ce4667d32 | ||
|
|
be53fda6df | ||
|
|
f48de05102 | ||
|
|
93ad81ed87 | ||
|
|
ea14d211be | ||
|
|
8052cefd46 | ||
|
|
845815b9b7 | ||
|
|
609dc5d783 | ||
|
|
7a71b34b54 | ||
|
|
71a459422f | ||
|
|
cd2628a0ee | ||
|
|
85da7175bc | ||
|
|
d3bf0a164b | ||
|
|
afb6ca1b8d | ||
|
|
94f43426d7 | ||
|
|
2b361f4f5d | ||
|
|
7438072f8c | ||
|
|
26c54fd358 | ||
|
|
7cb6b04c63 | ||
|
|
fc29cde82a | ||
|
|
559ca946dc | ||
|
|
2b8e7c7504 | ||
|
|
6816d75933 | ||
|
|
b58abbad7c |
@@ -7,6 +7,10 @@ py/run_test.py
|
||||
.vscode/
|
||||
cache/
|
||||
civitai/
|
||||
stats/
|
||||
wildcards/
|
||||
backups/
|
||||
logs/
|
||||
node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
@@ -19,6 +23,7 @@ model_cache/
|
||||
.codex
|
||||
.omo
|
||||
reasonix.toml
|
||||
.reasonix/
|
||||
.codegraph/
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
|
||||
+269
-256
@@ -20,33 +20,53 @@
|
||||
"Carl G.",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"JongWon Han",
|
||||
"FreelancerZ",
|
||||
"Polymorphic Indeterminate",
|
||||
"Skalabananen",
|
||||
"Marc Whiffen",
|
||||
"Birdy",
|
||||
"itismyelement",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Kiba",
|
||||
"Reno Lam",
|
||||
"onesecondinosaur",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Mark Corneglio",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"James Todd",
|
||||
"Steven Pfeiffer",
|
||||
"VantAI",
|
||||
"レプサイ",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"runte3221",
|
||||
"Illrigger",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
@@ -54,176 +74,161 @@
|
||||
"Edgar Tejeda",
|
||||
"Fraser Cross",
|
||||
"Liam MacDougal",
|
||||
"Polymorphic Indeterminate",
|
||||
"Sterilized",
|
||||
"BadassArabianMofo",
|
||||
"JORGE+LUIZ+HUSSNI+MESSIAS",
|
||||
"Marc Whiffen",
|
||||
"Skalabananen",
|
||||
"Birdy",
|
||||
"quarz",
|
||||
"Reno Lam",
|
||||
"Greg",
|
||||
"jean jahren",
|
||||
"JSST",
|
||||
"sig",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"lmsupporter",
|
||||
"Takkan",
|
||||
"wfpearl",
|
||||
"Matt+J",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Jack B Nimble",
|
||||
"Nazono_hito",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"bh",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"Anthony Rizzo",
|
||||
"iamresist",
|
||||
"M Postkasse",
|
||||
"RedrockVP",
|
||||
"Wolffen",
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Steven Pfeiffer",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"nwalker94",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"aai",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"Tori",
|
||||
"wildnut",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"lmsupporter",
|
||||
"Karl P.",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"Eldithor",
|
||||
"Jack B Nimble",
|
||||
"fancypants",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"contrite831",
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"Bro Xie",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"太郎 ゲーム",
|
||||
"Some Guy Named Barry",
|
||||
"M Postkasse",
|
||||
"AELOX",
|
||||
"Gooohokrbe",
|
||||
"Nicfit23",
|
||||
"wamekukyouzin",
|
||||
"OldBones",
|
||||
"Jacob Hoehler",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Christopher Michel",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Zach Gonser",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"Philip Hempel",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"nahinahi9",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"aai",
|
||||
"Mouthlessman",
|
||||
"otaku fra",
|
||||
"jean jahren",
|
||||
"MiraiKuriyamaSy",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"Penfore",
|
||||
"Karl P.",
|
||||
"Gordon Cole",
|
||||
"Adam Taylor",
|
||||
"AbstractAss",
|
||||
"Weird_With_A_Beard",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"Jackthemind",
|
||||
"fancypants",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"Neco28",
|
||||
"Cosmosis",
|
||||
"David Ortega",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"FloPro4Sho",
|
||||
"Cristian Vazquez",
|
||||
"drum matthieu",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Christopher Michel",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"Bruce",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"nahinahi9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Blackfish95",
|
||||
"Paul Kroll",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
"yuxz69",
|
||||
"esthe",
|
||||
"decoy",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
"CloudValley",
|
||||
"linnfrey",
|
||||
"Jackthemind",
|
||||
"griffin+dahlberg",
|
||||
"Joboshy",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"yer fey",
|
||||
"Error_Rule34_Not_found",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"Neco28",
|
||||
"Cosmosis",
|
||||
"David Ortega",
|
||||
"Dankin",
|
||||
"FloPro4Sho",
|
||||
"Cristian Vazquez",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"DougPeterson",
|
||||
"ApathyJones",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"Steven Owens",
|
||||
"Kevin John Duck",
|
||||
"Kevin Christopher",
|
||||
"Blackfish95",
|
||||
"dd",
|
||||
"Paul Kroll",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
"yuxz69",
|
||||
"esthe",
|
||||
"AlexDuKaNa",
|
||||
"decoy",
|
||||
"thesoftwaredruid",
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
@@ -231,53 +236,11 @@
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"地獄の禄",
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"linnfrey",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"IamAyam",
|
||||
"skaterb949",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"confiscated Zyra",
|
||||
"Error_Rule34_Not_found",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"jcay015",
|
||||
"Gerald Welly",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Tee Gee",
|
||||
"Geolog",
|
||||
"tarek helmi",
|
||||
"Eris3D",
|
||||
"Max Marklund",
|
||||
"Pronredn",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"lh qwe",
|
||||
"James Coleman",
|
||||
"conner",
|
||||
"Kevin Christopher",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"Princess Bright Eyes",
|
||||
"Dušan Ryban",
|
||||
"Felipe dos Santos",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Douglas Gaspar",
|
||||
"Metryman55",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"dw",
|
||||
"地獄の禄",
|
||||
"Gamalonia",
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"Mirko Katzula",
|
||||
@@ -285,6 +248,53 @@
|
||||
"Piccio08",
|
||||
"kumakichi",
|
||||
"cppbel",
|
||||
"IamAyam",
|
||||
"jeaness",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"confiscated Zyra",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Gerald Welly",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Tee Gee",
|
||||
"Geolog",
|
||||
"tarek helmi",
|
||||
"Eris3D",
|
||||
"Max Marklund",
|
||||
"m",
|
||||
"Pierce McBride",
|
||||
"Pronredn",
|
||||
"Mikko Hemilä",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"lh qwe",
|
||||
"James Coleman",
|
||||
"Martial",
|
||||
"conner",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"Princess Bright Eyes",
|
||||
"Yuji Kaneko",
|
||||
"Dušan Ryban",
|
||||
"Felipe dos Santos",
|
||||
"Rops Alot",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Ace Ventura",
|
||||
"Douglas Gaspar",
|
||||
"Metryman55",
|
||||
"George",
|
||||
"dw",
|
||||
"Gamalonia",
|
||||
"WRL_SPR",
|
||||
"momokai",
|
||||
"Moon Knight",
|
||||
"몽타주",
|
||||
"Kland",
|
||||
@@ -294,56 +304,9 @@
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"준희 김",
|
||||
"Brian Buie",
|
||||
"Thought2Form",
|
||||
"Kevin Picco",
|
||||
"Sadlip",
|
||||
"Joey Callahan",
|
||||
"Tomohiro Baba",
|
||||
"m",
|
||||
"Noora",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
"Mattssn",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Martial",
|
||||
"Michael Anthony Scott",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Davaitamin",
|
||||
"Rops Alot",
|
||||
"tedcor",
|
||||
"Fotek Design",
|
||||
"Ace Ventura",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"LarsesFPC",
|
||||
"MadSpin",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"momokai",
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
@@ -355,6 +318,51 @@
|
||||
"JohnDoe42054",
|
||||
"BillyHill",
|
||||
"emyth",
|
||||
"gzmzmvp",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Gregory Kozhemiak",
|
||||
"준희 김",
|
||||
"Brian Buie",
|
||||
"Sadlip",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Tomohiro Baba",
|
||||
"Mike Simone",
|
||||
"Noora",
|
||||
"Joshua Gray",
|
||||
"Mattssn",
|
||||
"Morgandel",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Michael Anthony Scott",
|
||||
"Emil Andersson",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Decx _",
|
||||
"Davaitamin",
|
||||
"tedcor",
|
||||
"Fotek Design",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"LarsesFPC",
|
||||
"MadSpin",
|
||||
"FrxzenSnxw",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"Crocket",
|
||||
"chriphost",
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
@@ -374,56 +382,64 @@
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"gzmzmvp",
|
||||
"Gregory Kozhemiak",
|
||||
"moonpetal",
|
||||
"g9p0o",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"Wolfe7D1",
|
||||
"Draven T",
|
||||
"mrjuan",
|
||||
"Eric Whitney",
|
||||
"elleshar666",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Aquatic Coffee",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"Kauffy",
|
||||
"John J Linehan",
|
||||
"ethanfel",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"Focuschannel",
|
||||
"Edward Kennedy",
|
||||
"Noah",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"hexxish",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"notedfakes",
|
||||
"Nathan",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Michael Scott",
|
||||
"Pat Hen",
|
||||
"Xeeosat",
|
||||
"Saya",
|
||||
"Ed Wang",
|
||||
"Jordan Shaw",
|
||||
"Wes Sims",
|
||||
"g unit",
|
||||
"Srdb",
|
||||
"Filippo Ferrari",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"FrxzenSnxw",
|
||||
"zenobeus",
|
||||
"ryoma",
|
||||
"Whitepinetrader",
|
||||
"Stryker",
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"Menard",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"Pkrsky",
|
||||
"TheHolySheep",
|
||||
"nanana",
|
||||
"raf8osz",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
@@ -437,38 +453,34 @@
|
||||
"cocona",
|
||||
"ElitaSSJ4",
|
||||
"David Schenck",
|
||||
"Wolfe7D1",
|
||||
"blikkies",
|
||||
"Chris",
|
||||
"Time Valentine",
|
||||
"elleshar666",
|
||||
"Shock Shockor",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Goldwaters",
|
||||
"Kauffy",
|
||||
"Zude",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Kyler",
|
||||
"Edward Kennedy",
|
||||
"Bryan Rutkowski",
|
||||
"Justin Blaylock",
|
||||
"aRtFuL_DodGeR",
|
||||
"Nick Kage",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"Xenon Xue",
|
||||
"notedfakes",
|
||||
"Edward Ten Eyck",
|
||||
"Billy Gladky",
|
||||
"Michael Scott",
|
||||
"Probis",
|
||||
"Solixer",
|
||||
"Wes Sims",
|
||||
"ItsGeneralButtNaked",
|
||||
"Donor4115",
|
||||
"jinksta187",
|
||||
"Distortik",
|
||||
"Filippo Ferrari",
|
||||
"Manu Thetug",
|
||||
"Karlanx",
|
||||
"operationancut",
|
||||
"Youguang",
|
||||
"andrewzpong",
|
||||
"BossGame",
|
||||
@@ -478,11 +490,19 @@
|
||||
"AIVORY3D",
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"Whitepinetrader",
|
||||
"POPPIN",
|
||||
"nanana",
|
||||
"YassineKhaled",
|
||||
"Y",
|
||||
"MatteKey",
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Inkognito",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"Bob+Barker",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
@@ -495,40 +515,36 @@
|
||||
"g",
|
||||
"J",
|
||||
"Alan+Cano",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"BillyBoy84",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
"John Martin",
|
||||
"Ink Temptation",
|
||||
"JBsuede",
|
||||
"moranqianlong",
|
||||
"Kalli Core",
|
||||
"Ronan Delevacq",
|
||||
"Christian Schäfer",
|
||||
"りん あめ",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"Frogmilk",
|
||||
"Sean voets",
|
||||
"Jarrid Lee",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Kyron Mahan",
|
||||
"Bryan Rutkowski",
|
||||
"Boba Smith",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"JackJohnnyJim",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Khánh Đặng",
|
||||
"Edward Ten Eyck",
|
||||
"Michael Docherty",
|
||||
"Jimmy Borup",
|
||||
"Paul Hartsuyker",
|
||||
@@ -536,16 +552,12 @@
|
||||
"Pete Pain",
|
||||
"Jacob Winter",
|
||||
"Ryan Presley Ng",
|
||||
"jinksta187",
|
||||
"RHopkirk",
|
||||
"Andrew Wilkinson",
|
||||
"Manu Thetug",
|
||||
"Karlanx",
|
||||
"Lyavph",
|
||||
"Maxim",
|
||||
"David",
|
||||
"Meilo",
|
||||
"operationancut",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
"MaartenAlbers",
|
||||
@@ -564,6 +576,17 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"Akkas+Haque",
|
||||
"Kachac",
|
||||
"SAVEagleBasement",
|
||||
"Kevin+Isom",
|
||||
"Rune+Osnes",
|
||||
"you+halo9",
|
||||
"cloudghost",
|
||||
"Yongkwan+Lee",
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
@@ -584,57 +607,51 @@
|
||||
"Lev+Lanevskiy",
|
||||
"low9",
|
||||
"Winged",
|
||||
"YassineKhaled",
|
||||
"Y",
|
||||
"MatteKey",
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Inkognito",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"Jacky+Ho",
|
||||
"generic404",
|
||||
"abattoirblues",
|
||||
"zounik",
|
||||
"4IXplr0r3r",
|
||||
"hayden",
|
||||
"Obsidian.Studios",
|
||||
"ahoystan",
|
||||
"Bob Barker",
|
||||
"Zomba Mann",
|
||||
"edk",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Neko Desco",
|
||||
"Ben D",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"Vinarus",
|
||||
"ja s",
|
||||
"Leslie Andrew Ridings",
|
||||
"Doug Mason",
|
||||
"Jeremy Townsend",
|
||||
"Dave Abraham",
|
||||
"scoreswazey",
|
||||
"Owen Gwosdz",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Spire",
|
||||
"Mythspire",
|
||||
"AZ Party Oasis",
|
||||
"Boba Smith",
|
||||
"Devil Lude",
|
||||
"David Murcko",
|
||||
"TheFusion",
|
||||
"MR.Bear",
|
||||
"Jack Dole",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"ivistorm",
|
||||
"max blo",
|
||||
"Sauv",
|
||||
"Ivan Imes",
|
||||
"CptNeo",
|
||||
"Jack Lawfield",
|
||||
"Borte",
|
||||
"Maso",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"Eric Ketchum",
|
||||
"Kevin Wallace",
|
||||
"David Spearing",
|
||||
"Zeeble",
|
||||
"ChicRic",
|
||||
"Tigon",
|
||||
"BastardSama",
|
||||
@@ -642,6 +659,7 @@
|
||||
"SkibidiRizzler",
|
||||
"Tania Nayelli Fernandez",
|
||||
"Draconach",
|
||||
"Kalle Björk",
|
||||
"Yavizu3d",
|
||||
"Yves Poezevara",
|
||||
"Teriak47",
|
||||
@@ -694,6 +712,13 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"Somebody",
|
||||
"Jasper",
|
||||
"megameganck",
|
||||
"thomasand01",
|
||||
"Shiba+Sama",
|
||||
"miduzza",
|
||||
"KB",
|
||||
"shw",
|
||||
"Celestial+Kitten",
|
||||
"bakeliteboy",
|
||||
@@ -714,23 +739,12 @@
|
||||
"matter",
|
||||
"SRCRCOSS",
|
||||
"imer",
|
||||
"Akkas+Haque",
|
||||
"Kachac",
|
||||
"tyrant2811",
|
||||
"Kevin",
|
||||
"Rune+Osnes",
|
||||
"jcx29",
|
||||
"cloudghost",
|
||||
"Yongkwan+Lee",
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Drizzly",
|
||||
"Nebuleux",
|
||||
"Join+Chun",
|
||||
"GDS+DEV",
|
||||
"4rt+r3d",
|
||||
"you+halo9",
|
||||
"Somebody",
|
||||
"Somebody",
|
||||
"Crescent~San",
|
||||
@@ -743,35 +757,36 @@
|
||||
"Bula",
|
||||
"KUJYAKU",
|
||||
"Coeur+de+cochon",
|
||||
"Obsidian.Studios",
|
||||
"han b",
|
||||
"Zomba Mann",
|
||||
"Aquaneo",
|
||||
"Nico",
|
||||
"Maximilian Krischan",
|
||||
"Banana Joe",
|
||||
"proto merp",
|
||||
"_ G3n",
|
||||
"Brandon Thomas",
|
||||
"Donovan Jenkins",
|
||||
"Hans Meier",
|
||||
"sicarius",
|
||||
"Michael Eid",
|
||||
"Wolf and Fox Legends",
|
||||
"beersandbacon",
|
||||
"Neko Desco",
|
||||
"Bob barker",
|
||||
"Ninja Tom",
|
||||
"karim ben brik",
|
||||
"Vinarus",
|
||||
"Elemnt",
|
||||
"Josh Snyder",
|
||||
"Michael Zhu",
|
||||
"Nemisu",
|
||||
"Seraphy",
|
||||
"雨の心 落",
|
||||
"AllTimeNoobie",
|
||||
"swra",
|
||||
"JollRodrigo",
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"Oliverfish",
|
||||
"yfx507",
|
||||
"Room Light",
|
||||
"Jairus Knudsen",
|
||||
@@ -783,22 +798,20 @@
|
||||
"Forbidden Atelier",
|
||||
"Thomas Sankowski",
|
||||
"DrB",
|
||||
"Nimhloth",
|
||||
"Adictedtohumping",
|
||||
"Snorklebort",
|
||||
"vinter",
|
||||
"Towelie",
|
||||
"TheFusion",
|
||||
"Jean-françois SEMA",
|
||||
"3zS4QNQ4",
|
||||
"Kurt",
|
||||
"Andrew Ly",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Slacks",
|
||||
"Bouya shaka",
|
||||
"john Greene",
|
||||
"Faburizu",
|
||||
"Jack Lawfield",
|
||||
"jimyjomson",
|
||||
"JaeHyun Jang",
|
||||
"Homero Banda",
|
||||
@@ -807,7 +820,7 @@
|
||||
"yyuvuvu",
|
||||
"Inyoshu",
|
||||
"Chad Barnes",
|
||||
"Person Y",
|
||||
"Adam Gardner",
|
||||
"Nomki",
|
||||
"inusanorthcape",
|
||||
"James Ming",
|
||||
@@ -829,5 +842,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 826
|
||||
"totalCount": 839
|
||||
}
|
||||
+2140
-2121
File diff suppressed because it is too large
Load Diff
+37
-18
@@ -105,6 +105,7 @@
|
||||
"removeFromFavorites": "Remove from favorites",
|
||||
"viewOnCivitai": "View on Civitai",
|
||||
"notAvailableFromCivitai": "Not available from Civitai",
|
||||
"viewOnHuggingFace": "View on Hugging Face",
|
||||
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
|
||||
"copyLoRASyntax": "Copy LoRA Syntax",
|
||||
"checkpointNameCopied": "Checkpoint name copied",
|
||||
@@ -145,6 +146,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Times used"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versions",
|
||||
"viewAllVersions": "View all local versions"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +188,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Manage Excluded Models"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Group by Model"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +203,7 @@
|
||||
"statistics": "Stats"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search...",
|
||||
"placeholders": {
|
||||
"loras": "Search LoRAs...",
|
||||
"recipes": "Search recipes...",
|
||||
"checkpoints": "Search checkpoints...",
|
||||
"embeddings": "Search embeddings..."
|
||||
},
|
||||
"placeholder": "Search",
|
||||
"options": "Search Options",
|
||||
"searchIn": "Search In:",
|
||||
"notAvailable": "Search not available on statistics page",
|
||||
@@ -325,7 +327,7 @@
|
||||
"extraFolderPaths": "Extra Folder Paths",
|
||||
"downloadPathTemplates": "Download Path Templates",
|
||||
"priorityTags": "Priority Tags",
|
||||
"updateFlags": "Update Flags",
|
||||
"versionScope": "Version Scope",
|
||||
"exampleImages": "Example Images",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"metadata": "Metadata",
|
||||
@@ -430,6 +432,8 @@
|
||||
"help": "When enabled, versions downloaded before will be skipped."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Group by Model",
|
||||
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
"default": "Default",
|
||||
@@ -586,12 +590,12 @@
|
||||
"download": "Download",
|
||||
"restartRequired": "Requires restart"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "Update Flag Strategy",
|
||||
"help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.",
|
||||
"versionGrouping": {
|
||||
"label": "Version Grouping",
|
||||
"help": "Decide how versions are grouped for display: by base model or all together. Also controls update badge logic and the VLM version list filtering.",
|
||||
"options": {
|
||||
"sameBase": "Match updates by base model",
|
||||
"any": "Flag any available update"
|
||||
"sameBase": "Group by base model (same_base)",
|
||||
"any": "Show all versions (any)"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
@@ -670,7 +674,11 @@
|
||||
"sizeAsc": "Smallest",
|
||||
"usage": "Use Count",
|
||||
"usageDesc": "Most",
|
||||
"usageAsc": "Least"
|
||||
"usageAsc": "Least",
|
||||
"versionsCount": "Local Versions",
|
||||
"versionsCountDesc": "Most versions first",
|
||||
"versionsCountAsc": "Fewest versions first",
|
||||
"versionIdDesc": "Newest version first"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh model list",
|
||||
@@ -1127,7 +1135,10 @@
|
||||
"titleWithType": "Download {type} from URL",
|
||||
"civitaiUrl": "Civitai URL(s):",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
|
||||
"urlHint": "Enter one CivitAI, CivArchive, or Hugging Face 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...",
|
||||
"locationPreview": "Download Location Preview",
|
||||
"useDefaultPath": "Use Default Path",
|
||||
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
||||
@@ -1156,7 +1167,9 @@
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Invalid Civitai URL format",
|
||||
"noVersions": "No versions available for this model"
|
||||
"noVersions": "No versions available for this model",
|
||||
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
|
||||
"noModelFiles": "No model files found in this repository."
|
||||
},
|
||||
"status": {
|
||||
"preparing": "Preparing download...",
|
||||
@@ -1307,6 +1320,8 @@
|
||||
"editVersionName": "Edit version name",
|
||||
"viewOnCivitai": "View on Civitai",
|
||||
"viewOnCivitaiText": "View on Civitai",
|
||||
"viewOnHuggingFace": "View on Hugging Face",
|
||||
"viewOnHuggingFaceText": "View on Hugging Face",
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
"openFileLocation": "Open File Location",
|
||||
"sendToWorkflow": "Send to ComfyUI",
|
||||
@@ -1463,7 +1478,7 @@
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
"viewLocalVersions": "View all local versions",
|
||||
"viewLocalTooltip": "Coming soon"
|
||||
"viewLocalTooltip": "Show all local versions of this model on the main page"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Base filter",
|
||||
@@ -1608,12 +1623,15 @@
|
||||
"modelUpdated": "Model updated in workflow",
|
||||
"modelFailed": "Failed to update model node",
|
||||
"embeddingAdded": "Embedding added to workflow",
|
||||
"embeddingFailed": "Failed to add embedding"
|
||||
"embeddingFailed": "Failed to add embedding",
|
||||
"promptSent": "Prompt sent to workflow",
|
||||
"promptFailed": "Failed to send prompt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Replace",
|
||||
"append": "Append",
|
||||
"selectTargetNode": "Select target node",
|
||||
@@ -1800,6 +1818,7 @@
|
||||
"enterLoraName": "Please enter a LoRA name or syntax",
|
||||
"reconnectedSuccessfully": "LoRA reconnected successfully",
|
||||
"reconnectFailed": "Error reconnecting LoRA: {message}",
|
||||
"noPromptToSend": "No prompt to send",
|
||||
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
||||
"sendFailed": "Failed to send recipe to workflow",
|
||||
"sendError": "Error sending recipe to workflow",
|
||||
|
||||
+2140
-2121
File diff suppressed because it is too large
Load Diff
+2140
-2121
File diff suppressed because it is too large
Load Diff
+2140
-2121
File diff suppressed because it is too large
Load Diff
+2140
-2121
File diff suppressed because it is too large
Load Diff
+2140
-2121
File diff suppressed because it is too large
Load Diff
+2140
-2121
File diff suppressed because it is too large
Load Diff
+2140
-2121
File diff suppressed because it is too large
Load Diff
+2140
-2121
File diff suppressed because it is too large
Load Diff
@@ -608,7 +608,7 @@ class SaveImageLM:
|
||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||
|
||||
# Generate filename with counter if needed
|
||||
base_filename = filename
|
||||
base_filename = filename.replace("%batch_num%", str(i))
|
||||
if add_counter_to_filename:
|
||||
# Use counter + i to ensure unique filenames for all images in batch
|
||||
current_counter = counter + i
|
||||
|
||||
@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
if model_hash_from_hashes:
|
||||
metadata["model_hash"] = model_hash_from_hashes
|
||||
|
||||
# Extract Lora hashes in alternative format
|
||||
# Extract Lora hashes in alternative format.
|
||||
# Run unconditionally (not just as fallback) so that
|
||||
# non-empty hashes from Lora hashes fill in the gaps left
|
||||
# by empty values in the Hashes JSON dict. Some WebUI
|
||||
# builds write real hash values only to Lora hashes and
|
||||
# leave the Hashes JSON values empty.
|
||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||
if not hashes_match and lora_hashes_match:
|
||||
if lora_hashes_match:
|
||||
try:
|
||||
lora_hashes_str = lora_hashes_match.group(1)
|
||||
lora_hash_entries = lora_hashes_str.split(', ')
|
||||
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
|
||||
|
||||
# Parse each lora hash entry (format: "name: hash")
|
||||
for entry in lora_hash_entries:
|
||||
if ': ' in entry:
|
||||
lora_name, lora_hash = entry.split(': ', 1)
|
||||
# Add as lora type in the same format as regular hashes
|
||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
||||
|
||||
lora_hash = lora_hash.strip()
|
||||
if not lora_hash:
|
||||
# Skip entries without a hash value
|
||||
continue
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
# Add as lora type in the same format as
|
||||
# regular hashes. Only override an
|
||||
# existing entry if its value is empty
|
||||
# (Lora hashes is the more reliable
|
||||
# source when Hashes JSON has blanks).
|
||||
key = f"lora:{lora_name}"
|
||||
existing = metadata["hashes"].get(key, "")
|
||||
if not existing:
|
||||
metadata["hashes"][key] = lora_hash
|
||||
|
||||
# Remove lora hashes from params section
|
||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||
except Exception as e:
|
||||
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Only process lora or hypernet types
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
continue
|
||||
|
||||
# Skip entries without a hash value — they can't be
|
||||
# resolved via CivitAI and would only produce a
|
||||
# useless "Deleted" entry in the recipe.
|
||||
if not lora_hash:
|
||||
continue
|
||||
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
|
||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Try to get info from Civitai
|
||||
if metadata_provider:
|
||||
try:
|
||||
if lora_hash:
|
||||
# If we have hash, use it for lookup
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
else:
|
||||
civitai_info = None
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
|
||||
@@ -514,11 +514,21 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Process modelVersionIds from Civitai image API
|
||||
# These are model version IDs returned at root level when meta doesn't contain resources
|
||||
if "modelVersionIds" in metadata and isinstance(
|
||||
metadata["modelVersionIds"], list
|
||||
# Process modelVersionIds from Civitai image API.
|
||||
# These are version IDs returned at root level of the API response.
|
||||
# When resources or civitaiResources are already present in metadata
|
||||
# (which they are when ?withMeta=true is passed), those sections have
|
||||
# complete hash/type information — modelVersionIds is a fallback for
|
||||
# when meta is null and only the flat ID list is available. Skipping
|
||||
# it here avoids duplicates: the same file hash often resolves to
|
||||
# different version IDs via hash lookup (resources) vs the original
|
||||
# version ID in modelVersionIds, and both paths would create entries.
|
||||
if (
|
||||
"modelVersionIds" in metadata
|
||||
and isinstance(metadata["modelVersionIds"], list)
|
||||
and not result.get("loras")
|
||||
):
|
||||
|
||||
for version_id in metadata["modelVersionIds"]:
|
||||
version_id_str = str(version_id)
|
||||
|
||||
@@ -526,6 +536,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
if version_id_str in added_loras:
|
||||
continue
|
||||
|
||||
# Skip if this version ID is already the recipe's checkpoint
|
||||
# (resolved earlier from embedded resources/Model hash,
|
||||
# avoiding a duplicate CivitAI API call).
|
||||
existing_model = result.get("model")
|
||||
if existing_model and str(existing_model.get("id")) == version_id_str:
|
||||
continue
|
||||
|
||||
# Initialize lora entry with version ID
|
||||
lora_entry = {
|
||||
"id": version_id,
|
||||
@@ -559,9 +576,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
# Not a LoRA — try as checkpoint (only if we
|
||||
# don't already have one). Reuses the same
|
||||
# civitai_info from the API call above so no
|
||||
# extra query is made.
|
||||
if result["model"] is None:
|
||||
checkpoint_entry = {
|
||||
"id": version_id,
|
||||
"modelId": 0,
|
||||
"name": "Unknown Model",
|
||||
"version": "",
|
||||
"type": "checkpoint",
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": "",
|
||||
"hash": "",
|
||||
"thumbnailUrl": (
|
||||
"/loras_static/images/no-preview.png"
|
||||
),
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
cp_populated = await (
|
||||
self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry, civitai_info
|
||||
)
|
||||
)
|
||||
if cp_populated.get("modelId"):
|
||||
result["model"] = cp_populated
|
||||
continue # Not a LoRA, don't add to loras
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching Civitai info for model version {version_id}: {e}"
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
|
||||
# 3. Save metadata atomically
|
||||
await MetadataManager.save_metadata(dest_path, metadata)
|
||||
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)
|
||||
|
||||
|
||||
class HfHandler:
|
||||
"""Handle Hugging Face model browsing and download."""
|
||||
|
||||
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 separators or ..
|
||||
if "/" in filename or "\\" in filename or ".." 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)
|
||||
|
||||
# Validate model_root — must not contain path traversal
|
||||
if not os.path.isabs(model_root):
|
||||
# For relative model_root, check it doesn't escape
|
||||
resolved_model_root = os.path.realpath(
|
||||
os.path.join(os.getcwd(), "models", model_root)
|
||||
)
|
||||
else:
|
||||
resolved_model_root = os.path.realpath(model_root)
|
||||
|
||||
# Verify model_root is within a configured scanner root
|
||||
allowed_roots = set()
|
||||
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 [],
|
||||
):
|
||||
for r in root_list:
|
||||
allowed_roots.add(os.path.realpath(r))
|
||||
|
||||
if not any(resolved_model_root == root or resolved_model_root.startswith(root + os.sep) for root in allowed_roots):
|
||||
logger.warning("Invalid model_root rejected: %s", model_root)
|
||||
return web.json_response({"error": f"Invalid model_root: {model_root}"}, status=400)
|
||||
|
||||
base_dir = resolved_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
|
||||
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
dest_path = os.path.join(target_dir, filename)
|
||||
|
||||
# Resolve symlinks and check for path traversal escape
|
||||
real_dest = os.path.realpath(dest_path)
|
||||
real_base = os.path.realpath(target_dir)
|
||||
if not real_dest.startswith(real_base + os.sep):
|
||||
logger.warning("Path traversal blocked: %s -> %s", dest_path, real_dest)
|
||||
return web.json_response({"error": "Path traversal detected"}, status=400)
|
||||
|
||||
# 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
|
||||
)
|
||||
@@ -48,6 +48,7 @@ from ...utils.constants import (
|
||||
SUPPORTED_MEDIA_EXTENSIONS,
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from .hf_handlers import HfHandler
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import (
|
||||
find_non_compliant_items_in_example_images_root,
|
||||
@@ -414,9 +415,10 @@ class PromptServerProtocol(Protocol):
|
||||
"""Subset of PromptServer used by the handlers."""
|
||||
|
||||
instance: "PromptServerProtocol"
|
||||
sockets: dict # maps clientId (sid) → WebSocketResponse
|
||||
|
||||
def send_sync(
|
||||
self, event: str, payload: dict
|
||||
self, event: str, payload: dict | None = None, sid: str | None = None
|
||||
) -> None: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
@@ -471,89 +473,154 @@ class BackupServiceProtocol(Protocol):
|
||||
|
||||
|
||||
class NodeRegistry:
|
||||
"""Thread-safe registry for tracking LoRA nodes in active workflows."""
|
||||
"""Thread-safe registry for tracking LoRA nodes across ComfyUI tabs.
|
||||
|
||||
Each connected ComfyUI browser tab (identified by its ``sid`` / ``clientId``)
|
||||
registers its own set of workflow nodes. Queries merge all known tabs into
|
||||
a single result so that the calling LM panel always sees *every* available
|
||||
target node, regardless of which tab responded fastest.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self._nodes: Dict[str, dict] = {}
|
||||
self._registry_updated = asyncio.Event()
|
||||
# sid → {unique_id → node_info}
|
||||
self._tab_nodes: Dict[str, Dict[str, dict]] = {}
|
||||
self._ready = asyncio.Event()
|
||||
self._waiting_clients: set[str] = set()
|
||||
|
||||
@property
|
||||
def pending_client_count(self) -> int:
|
||||
"""Number of clients that have not yet responded in the current refresh cycle."""
|
||||
return len(self._waiting_clients)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers to build one node dict (extracted so it's reused for each tab)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _build_node_dict(node: dict) -> dict:
|
||||
node_id = node["node_id"]
|
||||
graph_id = str(node["graph_id"])
|
||||
unique_id = f"{graph_id}:{node_id}"
|
||||
node_type = node.get("type", "")
|
||||
type_id = NODE_TYPES.get(node_type, 0)
|
||||
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
||||
|
||||
raw_capabilities = node.get("capabilities")
|
||||
capabilities: dict = {}
|
||||
if isinstance(raw_capabilities, dict):
|
||||
capabilities = dict(raw_capabilities)
|
||||
|
||||
raw_widget_names: list | None = node.get("widget_names")
|
||||
if not isinstance(raw_widget_names, list):
|
||||
capability_widget_names = capabilities.get("widget_names")
|
||||
raw_widget_names = (
|
||||
capability_widget_names
|
||||
if isinstance(capability_widget_names, list)
|
||||
else None
|
||||
)
|
||||
|
||||
widget_names: list[str] = []
|
||||
if isinstance(raw_widget_names, list):
|
||||
widget_names = [
|
||||
str(widget_name)
|
||||
for widget_name in raw_widget_names
|
||||
if isinstance(widget_name, str) and widget_name
|
||||
]
|
||||
|
||||
if widget_names:
|
||||
capabilities["widget_names"] = widget_names
|
||||
else:
|
||||
capabilities.pop("widget_names", None)
|
||||
|
||||
if "supports_lora" in capabilities:
|
||||
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
||||
|
||||
comfy_class = node.get("comfy_class")
|
||||
if not isinstance(comfy_class, str) or not comfy_class:
|
||||
comfy_class = node_type if isinstance(node_type, str) else None
|
||||
|
||||
return {
|
||||
"id": node_id,
|
||||
"graph_id": graph_id,
|
||||
"graph_name": node.get("graph_name"),
|
||||
"unique_id": unique_id,
|
||||
"bgcolor": bgcolor,
|
||||
"title": node.get("title"),
|
||||
"type": type_id,
|
||||
"type_name": node_type,
|
||||
"comfy_class": comfy_class,
|
||||
"capabilities": capabilities,
|
||||
"widget_names": widget_names,
|
||||
"mode": node.get("mode"),
|
||||
"marker_role": node.get("marker_role"),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
async def register_nodes(self, sid: str, nodes: list[dict]) -> None:
|
||||
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
|
||||
tab_nodes: dict[str, dict] = {}
|
||||
for node in nodes:
|
||||
nd = self._build_node_dict(node)
|
||||
tab_nodes[nd["unique_id"]] = nd
|
||||
|
||||
async def register_nodes(self, nodes: list[dict]) -> None:
|
||||
async with self._lock:
|
||||
self._nodes.clear()
|
||||
for node in nodes:
|
||||
node_id = node["node_id"]
|
||||
graph_id = str(node["graph_id"])
|
||||
unique_id = f"{graph_id}:{node_id}"
|
||||
node_type = node.get("type", "")
|
||||
type_id = NODE_TYPES.get(node_type, 0)
|
||||
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
||||
raw_capabilities = node.get("capabilities")
|
||||
capabilities: dict = {}
|
||||
if isinstance(raw_capabilities, dict):
|
||||
capabilities = dict(raw_capabilities)
|
||||
self._tab_nodes[sid] = tab_nodes
|
||||
self._waiting_clients.discard(sid)
|
||||
if not self._waiting_clients:
|
||||
self._ready.set()
|
||||
|
||||
raw_widget_names: list | None = node.get("widget_names")
|
||||
if not isinstance(raw_widget_names, list):
|
||||
capability_widget_names = capabilities.get("widget_names")
|
||||
raw_widget_names = (
|
||||
capability_widget_names
|
||||
if isinstance(capability_widget_names, list)
|
||||
else None
|
||||
)
|
||||
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
|
||||
|
||||
widget_names: list[str] = []
|
||||
if isinstance(raw_widget_names, list):
|
||||
widget_names = [
|
||||
str(widget_name)
|
||||
for widget_name in raw_widget_names
|
||||
if isinstance(widget_name, str) and widget_name
|
||||
]
|
||||
def prepare_for_refresh(self, active_sids: list[str]) -> None:
|
||||
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
|
||||
self._ready.clear()
|
||||
self._waiting_clients = set(active_sids)
|
||||
|
||||
if widget_names:
|
||||
capabilities["widget_names"] = widget_names
|
||||
else:
|
||||
capabilities.pop("widget_names", None)
|
||||
|
||||
if "supports_lora" in capabilities:
|
||||
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
||||
|
||||
comfy_class = node.get("comfy_class")
|
||||
if not isinstance(comfy_class, str) or not comfy_class:
|
||||
comfy_class = node_type if isinstance(node_type, str) else None
|
||||
|
||||
self._nodes[unique_id] = {
|
||||
"id": node_id,
|
||||
"graph_id": graph_id,
|
||||
"graph_name": node.get("graph_name"),
|
||||
"unique_id": unique_id,
|
||||
"bgcolor": bgcolor,
|
||||
"title": node.get("title"),
|
||||
"type": type_id,
|
||||
"type_name": node_type,
|
||||
"comfy_class": comfy_class,
|
||||
"capabilities": capabilities,
|
||||
"widget_names": widget_names,
|
||||
"mode": node.get("mode"),
|
||||
}
|
||||
logger.debug("Registered %s nodes in registry", len(nodes))
|
||||
self._registry_updated.set()
|
||||
|
||||
async def get_registry(self) -> dict:
|
||||
async with self._lock:
|
||||
return {
|
||||
"nodes": dict(self._nodes),
|
||||
"node_count": len(self._nodes),
|
||||
}
|
||||
|
||||
async def wait_for_update(self, timeout: float = 1.0) -> bool:
|
||||
self._registry_updated.clear()
|
||||
async def wait_for_all(self, timeout: float = 2.0) -> bool:
|
||||
"""Block until every client in the current waiting set has responded
|
||||
(or *timeout* seconds elapse). Returns ``True`` if all responded."""
|
||||
if not self._waiting_clients:
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(self._registry_updated.wait(), timeout=timeout)
|
||||
await asyncio.wait_for(self._ready.wait(), timeout=timeout)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict:
|
||||
"""Return the union of all known tab nodes, pruning any tab that is no
|
||||
longer connected."""
|
||||
async with self._lock:
|
||||
# Garbage-collect stale entries (disconnected tabs)
|
||||
if active_sids is not None:
|
||||
for sid in list(self._tab_nodes):
|
||||
if sid not in active_sids:
|
||||
del self._tab_nodes[sid]
|
||||
|
||||
merged: dict[str, dict] = {}
|
||||
tab_info: dict[str, dict] = {}
|
||||
for sid, nodes in self._tab_nodes.items():
|
||||
tab_info[sid] = {
|
||||
"node_count": len(nodes),
|
||||
"graph_names": list(
|
||||
{
|
||||
n.get("graph_name")
|
||||
for n in nodes.values()
|
||||
if n.get("graph_name")
|
||||
}
|
||||
),
|
||||
}
|
||||
merged.update(nodes)
|
||||
|
||||
return {
|
||||
"nodes": merged,
|
||||
"node_count": len(merged),
|
||||
"tab_count": len(self._tab_nodes),
|
||||
"tabs": tab_info,
|
||||
}
|
||||
|
||||
|
||||
class HealthCheckHandler:
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
@@ -2994,10 +3061,21 @@ class NodeRegistryHandler:
|
||||
try:
|
||||
data = await request.json()
|
||||
nodes = data.get("nodes", [])
|
||||
client_id = data.get("client_id")
|
||||
if not isinstance(nodes, list):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "nodes must be a list"}, status=400
|
||||
)
|
||||
|
||||
if not isinstance(client_id, str) or not client_id:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing client_id parameter",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
for index, node in enumerate(nodes):
|
||||
if not isinstance(node, dict):
|
||||
return web.json_response(
|
||||
@@ -3041,7 +3119,7 @@ class NodeRegistryHandler:
|
||||
else:
|
||||
node["graph_name"] = str(graph_name)
|
||||
|
||||
await self._node_registry.register_nodes(nodes)
|
||||
await self._node_registry.register_nodes(client_id, nodes)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
@@ -3065,9 +3143,15 @@ class NodeRegistryHandler:
|
||||
status=503,
|
||||
)
|
||||
|
||||
# Snapshot of currently-connected ComfyUI tabs
|
||||
active_sids = list(self._prompt_server.instance.sockets.keys())
|
||||
self._node_registry.prepare_for_refresh(active_sids)
|
||||
|
||||
try:
|
||||
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
||||
logger.debug("Sent registry refresh request to frontend")
|
||||
logger.debug(
|
||||
"Sent registry refresh request (expecting %s clients)", len(active_sids)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send registry refresh message: %s", exc)
|
||||
return web.json_response(
|
||||
@@ -3079,19 +3163,31 @@ class NodeRegistryHandler:
|
||||
status=500,
|
||||
)
|
||||
|
||||
registry_updated = await self._node_registry.wait_for_update(timeout=1.0)
|
||||
if not registry_updated:
|
||||
logger.warning("Registry refresh timeout after 1 second")
|
||||
if not await self._node_registry.wait_for_all(timeout=2.0):
|
||||
logger.warning(
|
||||
"Registry refresh timeout after 2s (%s/%s clients responded)",
|
||||
len(active_sids) - self._node_registry.pending_client_count,
|
||||
len(active_sids),
|
||||
)
|
||||
|
||||
# Re-read current sockets after the wait: a tab may have connected
|
||||
# while we were waiting, and we don't want to garbage-collect it.
|
||||
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||
registry_info = await self._node_registry.get_merged_registry(
|
||||
active_sids=current_sids
|
||||
)
|
||||
|
||||
if registry_info["node_count"] == 0:
|
||||
logger.warning("No nodes registered after refresh")
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Timeout Error",
|
||||
"message": "Registry refresh timeout - ComfyUI frontend may not be responsive",
|
||||
"error": "Empty Registry",
|
||||
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
|
||||
},
|
||||
status=408,
|
||||
)
|
||||
|
||||
registry_info = await self._node_registry.get_registry()
|
||||
return web.json_response({"success": True, "data": registry_info})
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to get registry: %s", exc, exc_info=True)
|
||||
@@ -3104,13 +3200,17 @@ class NodeRegistryHandler:
|
||||
try:
|
||||
data = await request.json()
|
||||
widget_name = data.get("widget_name")
|
||||
action = data.get("action")
|
||||
value = data.get("value")
|
||||
mode = data.get("mode", "replace")
|
||||
node_ids = data.get("node_ids")
|
||||
|
||||
if not isinstance(widget_name, str) or not widget_name:
|
||||
if not action and (not isinstance(widget_name, str) or not widget_name):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing widget_name parameter"},
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing parameter: provide either 'action' or 'widget_name'",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -3149,12 +3249,15 @@ class NodeRegistryHandler:
|
||||
except (TypeError, ValueError):
|
||||
parsed_node_id = node_identifier
|
||||
|
||||
payload = {
|
||||
payload: dict = {
|
||||
"id": parsed_node_id,
|
||||
"widget_name": widget_name,
|
||||
"value": value,
|
||||
"mode": mode,
|
||||
}
|
||||
if action:
|
||||
payload["action"] = action
|
||||
if widget_name:
|
||||
payload["widget_name"] = widget_name
|
||||
|
||||
if graph_identifier is not None:
|
||||
payload["graph_id"] = str(graph_identifier)
|
||||
@@ -3213,6 +3316,7 @@ class MiscHandlerSet:
|
||||
doctor: DoctorHandler,
|
||||
example_workflows: ExampleWorkflowsHandler,
|
||||
base_model: BaseModelHandlerSet,
|
||||
hf_handler: HfHandler | None = None,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -3231,6 +3335,7 @@ class MiscHandlerSet:
|
||||
self.doctor = doctor
|
||||
self.example_workflows = example_workflows
|
||||
self.base_model = base_model
|
||||
self.hf_handler = hf_handler
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -3276,6 +3381,9 @@ class MiscHandlerSet:
|
||||
"get_supporters": self.supporters.get_supporters,
|
||||
"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,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
|
||||
@@ -203,11 +203,17 @@ class ModelListingHandler:
|
||||
result = await self._service.get_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
formatted_raw = [
|
||||
await self._service.format_response(entry)
|
||||
for entry in result["items"]
|
||||
]
|
||||
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||
# Note: "total" intentionally remains the pre-filter count to reflect
|
||||
# the true number of models in the cache; corrupted entries are rare
|
||||
# and adjusting total would cause pagination drift on every page.
|
||||
formatted_items = [item for item in formatted_raw if item is not None]
|
||||
formatted_result = {
|
||||
"items": [
|
||||
await self._service.format_response(item)
|
||||
for item in result["items"]
|
||||
],
|
||||
"items": formatted_items,
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
@@ -233,14 +239,20 @@ class ModelListingHandler:
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
params = self._parse_common_params(request)
|
||||
# group_by_model is meaningless for excluded view; strip it
|
||||
params.pop("group_by_model", None)
|
||||
result = await self._service.get_excluded_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
formatted_raw = [
|
||||
await self._service.format_response(entry)
|
||||
for entry in result["items"]
|
||||
]
|
||||
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||
# "total" stays at the pre-filter count; see get_models for rationale.
|
||||
formatted_items = [item for item in formatted_raw if item is not None]
|
||||
formatted_result = {
|
||||
"items": [
|
||||
await self._service.format_response(item)
|
||||
for item in result["items"]
|
||||
],
|
||||
"items": formatted_items,
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
@@ -366,6 +378,19 @@ class ModelListingHandler:
|
||||
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
|
||||
group_by_model = (
|
||||
request.query.get("group_by_model", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# View-local-versions filter: show all local versions of a specific model
|
||||
civitai_model_id = request.query.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
try:
|
||||
civitai_model_id = int(civitai_model_id)
|
||||
except (TypeError, ValueError):
|
||||
civitai_model_id = None
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -389,6 +414,8 @@ class ModelListingHandler:
|
||||
"name_pattern_include": name_pattern_include,
|
||||
"name_pattern_exclude": name_pattern_exclude,
|
||||
"name_pattern_use_regex": name_pattern_use_regex,
|
||||
"group_by_model": group_by_model,
|
||||
"civitai_model_id": civitai_model_id,
|
||||
**self._parse_specific_params(request),
|
||||
}
|
||||
|
||||
@@ -516,8 +543,13 @@ class ModelManagementHandler:
|
||||
if not success:
|
||||
return web.json_response({"success": False, "error": error})
|
||||
|
||||
formatted_metadata = await self._service.format_response(model_data)
|
||||
return web.json_response({"success": True, "metadata": formatted_metadata})
|
||||
formatted = await self._service.format_response(model_data)
|
||||
if formatted is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model entry is corrupted (missing file_path)"},
|
||||
status=500,
|
||||
)
|
||||
return web.json_response({"success": True, "metadata": formatted})
|
||||
except Exception as exc:
|
||||
if is_expected_offline_error(str(exc)):
|
||||
return web.json_response(
|
||||
@@ -1074,10 +1106,12 @@ class ModelQueryHandler:
|
||||
# Sort: originals first, copies last
|
||||
sorted_models = self._sort_duplicate_group(filtered)
|
||||
|
||||
# Format response
|
||||
# Format response, filtering out corrupted entries (issue #730)
|
||||
group = {"hash": sha256, "models": []}
|
||||
for model in sorted_models:
|
||||
group["models"].append(await self._service.format_response(model))
|
||||
formatted = await self._service.format_response(model)
|
||||
if formatted is not None:
|
||||
group["models"].append(formatted)
|
||||
|
||||
# Only include groups with 2+ models after filtering
|
||||
if len(group["models"]) > 1:
|
||||
@@ -1194,9 +1228,9 @@ class ModelQueryHandler:
|
||||
(m for m in cache.raw_data if m["file_path"] == path), None
|
||||
)
|
||||
if model:
|
||||
group["models"].append(
|
||||
await self._service.format_response(model)
|
||||
)
|
||||
formatted = await self._service.format_response(model)
|
||||
if formatted is not None:
|
||||
group["models"].append(formatted)
|
||||
hash_val = self._service.scanner.get_hash_by_filename(filename)
|
||||
if hash_val:
|
||||
main_path = self._service.get_path_by_hash(hash_val)
|
||||
@@ -1206,9 +1240,9 @@ class ModelQueryHandler:
|
||||
None,
|
||||
)
|
||||
if main_model:
|
||||
group["models"].insert(
|
||||
0, await self._service.format_response(main_model)
|
||||
)
|
||||
formatted = await self._service.format_response(main_model)
|
||||
if formatted is not None:
|
||||
group["models"].insert(0, formatted)
|
||||
if group["models"]:
|
||||
result.append(group)
|
||||
return web.json_response(
|
||||
|
||||
@@ -32,6 +32,7 @@ from ...utils.civitai_utils import (
|
||||
extract_civitai_image_id_from_cdn_url,
|
||||
rewrite_preview_url,
|
||||
)
|
||||
from ...utils.constants import NSFW_LEVELS
|
||||
from ...utils.exif_utils import ExifUtils
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
@@ -1120,6 +1121,13 @@ class RecipeManagementHandler:
|
||||
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||
metadata["base_model"] = parsed_embedded["base_model"]
|
||||
|
||||
# Extract preview_nsfw_level from the CivitAI API response
|
||||
# (injected into civitai_meta_raw by _download_remote_media).
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
bl = civitai_meta_raw.get("browsingLevel")
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
metadata["preview_nsfw_level"] = bl
|
||||
|
||||
civitai_client = self._civitai_client_getter()
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=metadata,
|
||||
@@ -1515,8 +1523,31 @@ class RecipeManagementHandler:
|
||||
# CivitAI API returns modelVersionIds at the root level of
|
||||
# the image response, NOT inside the meta object.
|
||||
mvids = image_info.get("modelVersionIds")
|
||||
if mvids and isinstance(civitai_meta_raw, dict):
|
||||
civitai_meta_raw["modelVersionIds"] = mvids
|
||||
if mvids:
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
civitai_meta_raw["modelVersionIds"] = mvids
|
||||
else:
|
||||
# meta is null but modelVersionIds exists — create a
|
||||
# minimal dict so downstream parsers can discover
|
||||
# LoRAs and checkpoints from the API response.
|
||||
civitai_meta_raw = {"modelVersionIds": mvids}
|
||||
|
||||
# Inject browsingLevel (canonical integer) so the recipe's
|
||||
# preview_nsfw_level can be set, enabling proper NSFW blur
|
||||
# of the preview image. Fall back to nsfwLevel (string)
|
||||
# when browsingLevel is absent.
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
browsing_level = image_info.get("browsingLevel")
|
||||
nsfw_level_str = image_info.get("nsfwLevel")
|
||||
if isinstance(browsing_level, int) and browsing_level > 0:
|
||||
civitai_meta_raw["browsingLevel"] = browsing_level
|
||||
elif (
|
||||
isinstance(nsfw_level_str, str)
|
||||
and nsfw_level_str in NSFW_LEVELS
|
||||
):
|
||||
civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[
|
||||
nsfw_level_str
|
||||
]
|
||||
|
||||
original_url = (
|
||||
image_info.get("url") if civitai_image_id and image_info else None
|
||||
@@ -1796,6 +1827,13 @@ class RecipeManagementHandler:
|
||||
"source_path": image_url,
|
||||
}
|
||||
|
||||
# Extract preview_nsfw_level from the CivitAI API response
|
||||
# (injected into civitai_meta_raw by _download_remote_media).
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
bl = civitai_meta_raw.get("browsingLevel")
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
metadata["preview_nsfw_level"] = bl
|
||||
|
||||
if civitai_parsed:
|
||||
civitai_loras = civitai_parsed.get("loras", [])
|
||||
if civitai_loras and not metadata.get("loras"):
|
||||
|
||||
@@ -94,6 +94,13 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||
),
|
||||
# Hugging Face model endpoints
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ from .handlers.misc_handlers import (
|
||||
build_service_registry_adapter,
|
||||
)
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
from .handlers.hf_handlers import HfHandler
|
||||
from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -136,6 +137,7 @@ class MiscRoutes:
|
||||
doctor = DoctorHandler(settings_service=self._settings)
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
base_model = BaseModelHandlerSet()
|
||||
hf_handler = HfHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -155,6 +157,7 @@ class MiscRoutes:
|
||||
doctor=doctor,
|
||||
example_workflows=example_workflows,
|
||||
base_model=base_model,
|
||||
hf_handler=hf_handler,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,27 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||
|
||||
# User-managed directories that live inside the plugin folder (portable
|
||||
# mode) and must survive a Git-based update. ``git clean -fd`` would
|
||||
# otherwise delete them because they are untracked and, in released tags,
|
||||
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||
# regardless of whether it is ignored.
|
||||
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
||||
|
||||
|
||||
def _clean_excludes() -> List[str]:
|
||||
"""Build the ``-e`` arguments for ``git clean`` from :data:`_PRESERVE_DIRS`."""
|
||||
excludes: List[str] = []
|
||||
for name in _PRESERVE_DIRS:
|
||||
excludes.append('-e')
|
||||
excludes.append(name)
|
||||
# For directories, also exclude nested matches explicitly
|
||||
# (``-e dir`` alone matches the dir entry; ``-e dir/**`` guards
|
||||
# contents under all git versions as defense-in-depth).
|
||||
excludes.append('-e')
|
||||
excludes.append(f'{name}/**')
|
||||
return excludes
|
||||
|
||||
|
||||
class UpdateRoutes:
|
||||
"""Routes for handling plugin update checks"""
|
||||
@@ -365,6 +386,8 @@ class UpdateRoutes:
|
||||
)
|
||||
return False, ""
|
||||
|
||||
clean_excludes = _clean_excludes()
|
||||
|
||||
try:
|
||||
# Open the Git repository
|
||||
repo = git.Repo(plugin_root)
|
||||
@@ -376,8 +399,9 @@ class UpdateRoutes:
|
||||
if nightly:
|
||||
# Reset to discard any local changes
|
||||
repo.git.reset('--hard')
|
||||
# Clean untracked files
|
||||
repo.git.clean('-fd')
|
||||
# Clean untracked files, but preserve user-managed directories
|
||||
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||
repo.git.clean('-fd', *clean_excludes)
|
||||
|
||||
# Switch to main branch and pull latest
|
||||
main_branch = 'main'
|
||||
@@ -394,8 +418,9 @@ class UpdateRoutes:
|
||||
else:
|
||||
# Reset to discard any local changes
|
||||
repo.git.reset('--hard')
|
||||
# Clean untracked files
|
||||
repo.git.clean('-fd')
|
||||
# Clean untracked files, but preserve user-managed directories
|
||||
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||
repo.git.clean('-fd', *clean_excludes)
|
||||
|
||||
# Get latest release tag
|
||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
||||
|
||||
@@ -84,6 +84,7 @@ class Aria2Downloader:
|
||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||
self._poll_interval = 0.5
|
||||
self._state_store = Aria2TransferStateStore()
|
||||
self._stderr_reader_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -115,7 +116,7 @@ class Aria2Downloader:
|
||||
|
||||
try:
|
||||
while True:
|
||||
status = await self.get_status(download_id)
|
||||
status = await self._get_status_with_retry(download_id)
|
||||
if status is None:
|
||||
return False, "aria2 download not found"
|
||||
|
||||
@@ -136,6 +137,35 @@ class Aria2Downloader:
|
||||
finally:
|
||||
self._transfers.pop(download_id, None)
|
||||
|
||||
async def _get_status_with_retry(
|
||||
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Call get_status with retry for transient RPC failures.
|
||||
|
||||
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
|
||||
``None`` immediately when the download_id is not tracked (a missing
|
||||
transfer is not a transient condition, so retrying is pointless).
|
||||
|
||||
A single failed RPC call should not immediately fail the download,
|
||||
because aria2 may be temporarily busy (e.g. finalizing multiple
|
||||
concurrent downloads) and a retry will often succeed.
|
||||
"""
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await self.get_status(download_id)
|
||||
except Aria2Error as exc:
|
||||
last_exc = exc
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
|
||||
attempt + 1, max_retries, download_id, exc,
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
raise Aria2Error(
|
||||
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
async def _schedule_download(
|
||||
self,
|
||||
url: str,
|
||||
@@ -312,6 +342,16 @@ class Aria2Downloader:
|
||||
async def close(self) -> None:
|
||||
"""Shut down the RPC process and session."""
|
||||
|
||||
# Cancel the background stderr reader first so it stops reading
|
||||
# from the pipe before the subprocess is terminated.
|
||||
if self._stderr_reader_task is not None:
|
||||
self._stderr_reader_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
self._stderr_reader_task = None
|
||||
|
||||
if self._rpc_session is not None:
|
||||
await self._rpc_session.close()
|
||||
self._rpc_session = None
|
||||
@@ -331,6 +371,23 @@ class Aria2Downloader:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
async def _drain_stderr(self) -> None:
|
||||
"""Continuously drain aria2's stderr pipe so it never blocks.
|
||||
|
||||
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
|
||||
blocks, which freezes the entire ``aria2c`` process — including its
|
||||
RPC handler. This background task reads lines from stderr as they
|
||||
arrive and forwards them to Python's logger.
|
||||
"""
|
||||
try:
|
||||
assert self._process is not None and self._process.stderr is not None
|
||||
async for line in self._process.stderr:
|
||||
text = line.decode("utf-8", errors="replace").rstrip()
|
||||
if text:
|
||||
logger.debug("aria2 stderr: %s", text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
||||
try:
|
||||
result = callback(snapshot, snapshot)
|
||||
@@ -465,6 +522,17 @@ class Aria2Downloader:
|
||||
|
||||
await self._wait_until_ready()
|
||||
|
||||
# Drain aria2's stderr in a background task so the pipe buffer
|
||||
# never fills up. If the pipe blocks, aria2 itself freezes and
|
||||
# cannot respond to RPC — this was the root cause of the
|
||||
# "Failed to query aria2 download status" timeout bug.
|
||||
# Must start AFTER _wait_until_ready to avoid a race where the
|
||||
# drain task consumes aria2's early-exit error message before
|
||||
# _wait_until_ready can read it.
|
||||
self._stderr_reader_task = asyncio.create_task(
|
||||
self._drain_stderr()
|
||||
)
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
settings = get_settings_manager()
|
||||
configured_path = (settings.get("aria2c_path") or "").strip()
|
||||
@@ -584,7 +652,9 @@ class Aria2Downloader:
|
||||
if self._rpc_session is None or self._rpc_session.closed:
|
||||
async with self._rpc_session_lock:
|
||||
if self._rpc_session is None or self._rpc_session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=None, sock_connect=10, sock_read=60
|
||||
)
|
||||
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
||||
return self._rpc_session
|
||||
|
||||
|
||||
@@ -104,6 +104,100 @@ class BaseModelService(ABC):
|
||||
fetch_duration = time.perf_counter() - t0
|
||||
initial_count = len(sorted_data)
|
||||
|
||||
# Optionally filter by civitai model ID (shows all local versions of a specific model)
|
||||
civitai_model_id = kwargs.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
sorted_data = [
|
||||
item for item in sorted_data
|
||||
if self._extract_model_id(item) == civitai_model_id
|
||||
]
|
||||
# VLM mode: always sort by version ID descending (newest version first),
|
||||
# regardless of the current sort_by preference.
|
||||
sorted_data.sort(
|
||||
key=lambda x: self._extract_version_id(x) or 0,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Optionally group by civitai modelId, showing only the latest version per model
|
||||
dedup_lost = 0
|
||||
if kwargs.get("group_by_model") and civitai_model_id is None:
|
||||
# Determine whether to further sub-group by base model
|
||||
# When version_grouping is "same_base", versions with different
|
||||
# base models are effectively different groups — the dedup key
|
||||
# needs to include base_model so the version count and VLM flow
|
||||
# stay consistent (card shows correct count for its base model).
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
|
||||
version_counter = {} # same-key -> count
|
||||
standalone = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_model_id(item)
|
||||
if mid is None:
|
||||
standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
# Count all versions per key
|
||||
version_counter[key] = version_counter.get(key, 0) + 1
|
||||
vid = self._extract_version_id(item) or 0
|
||||
if key not in dedup_map or vid > dedup_map[key][1]:
|
||||
dedup_map[key] = (item, vid)
|
||||
# Attach version_count to each surviving grouped item (shallow copy
|
||||
# to avoid mutating cached dicts — the cache is shared across requests)
|
||||
for key, (item, vid) in dedup_map.items():
|
||||
item = dict(item)
|
||||
item["version_count"] = version_counter[key]
|
||||
dedup_map[key] = (item, vid)
|
||||
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
|
||||
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
|
||||
|
||||
# Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand)
|
||||
if sort_params.key == "versions_count" and civitai_model_id is None:
|
||||
reverse = sort_params.order == "desc"
|
||||
if kwargs.get("group_by_model"):
|
||||
# Grouped mode: items are already dedup'd with version_count attached
|
||||
sorted_data.sort(
|
||||
key=lambda x: (
|
||||
x.get("version_count", 0),
|
||||
(x.get("model_name") or x.get("file_name") or "").lower(),
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
reverse=reverse,
|
||||
)
|
||||
else:
|
||||
# Non-grouped mode: group internally, sort groups by count, expand
|
||||
# Respect the version_grouping setting (same logic as grouped dedup)
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
model_groups: Dict[Any, List[Dict]] = {}
|
||||
ungrouped_standalone: List[Dict] = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_model_id(item)
|
||||
if mid is None:
|
||||
ungrouped_standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
model_groups.setdefault(key, []).append(item)
|
||||
# Sort versions within each group by version id descending
|
||||
for items in model_groups.values():
|
||||
items.sort(
|
||||
key=lambda x: self._extract_version_id(x) or 0,
|
||||
reverse=True,
|
||||
)
|
||||
# Sort groups by version count
|
||||
sorted_groups = sorted(
|
||||
model_groups.values(),
|
||||
key=lambda items: len(items),
|
||||
reverse=reverse,
|
||||
)
|
||||
# Flatten: grouped items first, standalone items last
|
||||
sorted_data = []
|
||||
for items in sorted_groups:
|
||||
sorted_data.extend(items)
|
||||
sorted_data.extend(ungrouped_standalone)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
if hash_filters:
|
||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||
@@ -172,7 +266,7 @@ class BaseModelService(ABC):
|
||||
overall_duration = time.perf_counter() - overall_start
|
||||
logger.debug(
|
||||
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
||||
"Counts: initial=%d, post_filter=%d, final=%d",
|
||||
"Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
|
||||
self.__class__.__name__,
|
||||
overall_duration,
|
||||
fetch_duration,
|
||||
@@ -181,6 +275,7 @@ class BaseModelService(ABC):
|
||||
pagination_duration,
|
||||
annotate_duration,
|
||||
initial_count,
|
||||
dedup_lost,
|
||||
post_filter_count,
|
||||
final_count,
|
||||
)
|
||||
@@ -495,7 +590,7 @@ class BaseModelService(ABC):
|
||||
if not ordered_ids:
|
||||
return annotated
|
||||
|
||||
strategy_value = self.settings.get("update_flag_strategy")
|
||||
strategy_value = self.settings.get("version_grouping")
|
||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||
strategy = strategy_value.strip().lower()
|
||||
else:
|
||||
@@ -696,8 +791,12 @@ class BaseModelService(ABC):
|
||||
}
|
||||
|
||||
@abstractmethod
|
||||
async def format_response(self, model_data: Dict) -> Dict:
|
||||
"""Format model data for API response - must be implemented by subclasses"""
|
||||
async def format_response(self, model_data: Dict) -> Optional[Dict]:
|
||||
"""Format model data for API response - must be implemented by subclasses.
|
||||
|
||||
Subclasses should return None for corrupted entries so the handler
|
||||
layer can filter them out. See issue #730.
|
||||
"""
|
||||
pass
|
||||
|
||||
# Common service methods that delegate to scanner
|
||||
|
||||
@@ -523,6 +523,10 @@ class BatchImportService:
|
||||
if payload.get("checkpoint"):
|
||||
metadata["checkpoint"] = payload["checkpoint"]
|
||||
|
||||
nsfw = payload.get("preview_nsfw_level")
|
||||
if isinstance(nsfw, int) and nsfw > 0:
|
||||
metadata["preview_nsfw_level"] = nsfw
|
||||
|
||||
image_bytes = None
|
||||
image_base64 = payload.get("image_base64")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
@@ -21,20 +21,37 @@ class CheckpointService(BaseModelService):
|
||||
"""
|
||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
||||
"""Format Checkpoint data for API response"""
|
||||
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
|
||||
"""Format Checkpoint data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = checkpoint_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted checkpoint entry (missing file_path): %s",
|
||||
checkpoint_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
||||
|
||||
|
||||
file_name = checkpoint_data.get("file_name") or ""
|
||||
model_name = checkpoint_data.get("model_name") or file_name
|
||||
folder = checkpoint_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": checkpoint_data["model_name"],
|
||||
"file_name": checkpoint_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
||||
"base_model": checkpoint_data.get("base_model", ""),
|
||||
"folder": checkpoint_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": checkpoint_data.get("sha256", ""),
|
||||
"file_path": checkpoint_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": checkpoint_data.get("size", 0),
|
||||
"modified": checkpoint_data.get("modified", ""),
|
||||
"tags": checkpoint_data.get("tags", []),
|
||||
@@ -48,6 +65,8 @@ class CheckpointService(BaseModelService):
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||
"version_count": checkpoint_data.get("version_count"),
|
||||
"hf_url": checkpoint_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -327,7 +327,7 @@ class CivArchiveClient:
|
||||
if resolved:
|
||||
return resolved, None
|
||||
|
||||
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||
logger.debug("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||
return None, "No version data found"
|
||||
|
||||
except RateLimitError:
|
||||
@@ -417,7 +417,7 @@ class CivArchiveClient:
|
||||
|
||||
if version_id is not None:
|
||||
raw_id = version_data.get("id")
|
||||
if raw_id != version_id:
|
||||
if raw_id is not None and str(raw_id) != str(version_id):
|
||||
logger.warning(
|
||||
"Requested version %s doesn't match default version %s for model %s",
|
||||
version_id,
|
||||
|
||||
@@ -196,6 +196,7 @@ class CivitaiBaseModelService:
|
||||
"ernie": "ERNI",
|
||||
"ernie turbo": "ETRB",
|
||||
"nucleus": "NUCL",
|
||||
"krea 2": "KR2",
|
||||
"svd": "SVD",
|
||||
"ltxv": "LTXV",
|
||||
"ltxv2": "LTV2",
|
||||
@@ -424,6 +425,7 @@ class CivitaiBaseModelService:
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"Nucleus",
|
||||
"Krea 2",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class CivitaiClient:
|
||||
self._MAX_CACHE_ENTRIES = 500
|
||||
|
||||
def _build_image_info_url(self, image_id: str) -> str:
|
||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
|
||||
@@ -1288,10 +1288,24 @@ class DownloadManager:
|
||||
"download_id": download_id,
|
||||
}
|
||||
|
||||
# Check if this checkpoint should be treated as a diffusion model based on baseModel
|
||||
# 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":
|
||||
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||
# 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"
|
||||
@@ -1420,7 +1434,7 @@ class DownloadManager:
|
||||
f
|
||||
for f in files
|
||||
if f.get("primary")
|
||||
and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -1451,7 +1465,7 @@ class DownloadManager:
|
||||
(
|
||||
f
|
||||
for f in files
|
||||
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -2029,7 +2043,21 @@ class DownloadManager:
|
||||
break
|
||||
|
||||
last_error = result
|
||||
if os.path.exists(save_path):
|
||||
# For aria2: if the .aria2 control file is missing, aria2 considers
|
||||
# the download complete. A transient RPC failure may have made us
|
||||
# think the download failed even though the file is fully on disk.
|
||||
# Keep the file so a retry can find it already complete.
|
||||
if (
|
||||
transfer_backend == "aria2"
|
||||
and os.path.exists(save_path)
|
||||
and not os.path.exists(f"{save_path}.aria2")
|
||||
):
|
||||
logger.warning(
|
||||
"aria2 download reported failure but .aria2 file is absent "
|
||||
"for %s — the file is likely complete. Preserving it for retry.",
|
||||
save_path,
|
||||
)
|
||||
elif os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
@@ -21,20 +21,37 @@ class EmbeddingService(BaseModelService):
|
||||
"""
|
||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
||||
"""Format Embedding data for API response"""
|
||||
async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
|
||||
"""Format Embedding data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = embedding_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted embedding entry (missing file_path): %s",
|
||||
embedding_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = embedding_data.get("sub_type", "embedding")
|
||||
|
||||
|
||||
file_name = embedding_data.get("file_name") or ""
|
||||
model_name = embedding_data.get("model_name") or file_name
|
||||
folder = embedding_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": embedding_data["model_name"],
|
||||
"file_name": embedding_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
||||
"base_model": embedding_data.get("base_model", ""),
|
||||
"folder": embedding_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": embedding_data.get("sha256", ""),
|
||||
"file_path": embedding_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": embedding_data.get("size", 0),
|
||||
"modified": embedding_data.get("modified", ""),
|
||||
"tags": embedding_data.get("tags", []),
|
||||
@@ -48,6 +65,8 @@ class EmbeddingService(BaseModelService):
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||
"version_count": embedding_data.get("version_count"),
|
||||
"hf_url": embedding_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -24,23 +24,41 @@ class LoraService(BaseModelService):
|
||||
"""
|
||||
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, lora_data: Dict) -> Dict:
|
||||
"""Format LoRA data for API response"""
|
||||
async def format_response(self, lora_data: Dict) -> Optional[Dict]:
|
||||
"""Format LoRA data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out instead of crashing the
|
||||
whole listing request. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = lora_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted LoRA entry (missing file_path): %s",
|
||||
lora_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||
# Normalize to lowercase for consistent API responses
|
||||
sub_type = resolve_sub_type(lora_data).lower()
|
||||
|
||||
file_name = lora_data.get("file_name") or ""
|
||||
model_name = lora_data.get("model_name") or file_name
|
||||
folder = lora_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": lora_data["model_name"],
|
||||
"file_name": lora_data["file_name"],
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(
|
||||
lora_data.get("preview_url", "")
|
||||
),
|
||||
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
||||
"base_model": lora_data.get("base_model", ""),
|
||||
"folder": lora_data["folder"],
|
||||
"folder": folder,
|
||||
"sha256": lora_data.get("sha256", ""),
|
||||
"file_path": lora_data["file_path"].replace(os.sep, "/"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": lora_data.get("size", 0),
|
||||
"modified": lora_data.get("modified", ""),
|
||||
"tags": lora_data.get("tags", []),
|
||||
@@ -59,6 +77,8 @@ class LoraService(BaseModelService):
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
),
|
||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||
"version_count": lora_data.get("version_count"),
|
||||
"hf_url": lora_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
|
||||
@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
|
||||
('size', 'desc'),
|
||||
('usage', 'asc'),
|
||||
('usage', 'desc'),
|
||||
('versions_count', 'asc'),
|
||||
('versions_count', 'desc'),
|
||||
]
|
||||
# Is this in use?
|
||||
|
||||
@@ -263,6 +265,17 @@ class ModelCache:
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'versions_count':
|
||||
# Pre-dedup sort: fall back to name sort.
|
||||
# Actual re-sort by version_count happens in get_paginated_data after dedup.
|
||||
result = natsorted(
|
||||
data,
|
||||
key=lambda x: (
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
else:
|
||||
# Fallback: no sort
|
||||
result = list(data)
|
||||
|
||||
@@ -248,6 +248,7 @@ class ModelScanner:
|
||||
'civitai': civitai_slim,
|
||||
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||
'hf_url': get_value('hf_url', '') or '',
|
||||
}
|
||||
|
||||
license_source: Dict[str, Any] = {}
|
||||
@@ -476,11 +477,20 @@ class ModelScanner:
|
||||
for tag in adjusted_item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
|
||||
# Validate cache entries and check health
|
||||
# Validate cache entries and check health.
|
||||
# Always use the validated/repaired entries — even when there are no
|
||||
# invalid entries, auto_repair may have filled in missing optional
|
||||
# fields (model_name, file_name, folder) with safe defaults on a copied
|
||||
# working_entry. Without this unconditional replacement the repaired
|
||||
# copies are discarded and None values propagate to format_response.
|
||||
# See issue #730.
|
||||
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||
adjusted_raw_data, auto_repair=True
|
||||
)
|
||||
|
||||
# Always use the validated entries (repaired copies)
|
||||
adjusted_raw_data = valid_entries
|
||||
|
||||
if invalid_entries:
|
||||
monitor = CacheHealthMonitor()
|
||||
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
||||
|
||||
@@ -724,6 +724,16 @@ class ModelUpdateService:
|
||||
"Refreshing update metadata for %d %s models", total_models, model_type
|
||||
)
|
||||
|
||||
# When filtering by folder, also collect the cross-folder version set
|
||||
# so that versions already present in other folders are not reported
|
||||
# as available updates. See issue #997.
|
||||
all_local_versions: Optional[Dict[int, List[int]]] = None
|
||||
if folder_path is not None:
|
||||
all_local_versions = await self._collect_local_versions(
|
||||
scanner,
|
||||
target_model_ids=target_filter,
|
||||
)
|
||||
|
||||
results: Dict[int, ModelUpdateRecord] = {}
|
||||
prefetched: Dict[int, Mapping] = {}
|
||||
|
||||
@@ -762,6 +772,12 @@ class ModelUpdateService:
|
||||
for index, (model_id, version_ids) in enumerate(
|
||||
local_versions.items(), start=1
|
||||
):
|
||||
# Use cross-folder version IDs for is_in_library if available
|
||||
all_vids: Sequence[int] = (
|
||||
all_local_versions.get(model_id, [])
|
||||
if all_local_versions is not None
|
||||
else version_ids
|
||||
)
|
||||
record = await self._refresh_single_model(
|
||||
model_type,
|
||||
model_id,
|
||||
@@ -769,6 +785,7 @@ class ModelUpdateService:
|
||||
metadata_provider,
|
||||
force_refresh=force_refresh,
|
||||
prefetched_response=prefetched.get(model_id),
|
||||
all_local_version_ids=all_vids,
|
||||
)
|
||||
if scanner.is_cancelled():
|
||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||
@@ -964,8 +981,16 @@ class ModelUpdateService:
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
prefetched_response: Optional[Mapping] = None,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
) -> Optional[ModelUpdateRecord]:
|
||||
normalized_local = self._normalize_sequence(local_versions)
|
||||
# When folder-filtering, this carries the cross-folder version set
|
||||
# for is_in_library; otherwise it falls back to normalized_local.
|
||||
normalized_all = (
|
||||
self._normalize_sequence(all_local_version_ids)
|
||||
if all_local_version_ids is not None
|
||||
else normalized_local
|
||||
)
|
||||
now = time.time()
|
||||
async with self._lock:
|
||||
existing = self._get_record(model_type, model_id)
|
||||
@@ -973,6 +998,7 @@ class ModelUpdateService:
|
||||
record = self._merge_with_local_versions(
|
||||
existing,
|
||||
normalized_local,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
self._upsert_record(record)
|
||||
return record
|
||||
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
|
||||
record = self._merge_with_local_versions(
|
||||
existing,
|
||||
normalized_local,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
self._upsert_record(record)
|
||||
return record
|
||||
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
|
||||
model_type=model_type,
|
||||
model_id=model_id,
|
||||
last_checked_at=now,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
record = replace(record, should_ignore_model=True)
|
||||
self._upsert_record(record)
|
||||
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
|
||||
fetched_versions,
|
||||
existing,
|
||||
now,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
else:
|
||||
record = self._merge_with_local_versions(
|
||||
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
|
||||
model_type=model_type,
|
||||
model_id=model_id,
|
||||
last_checked_at=existing.last_checked_at if existing else None,
|
||||
all_local_version_ids=normalized_all,
|
||||
)
|
||||
self._upsert_record(record)
|
||||
return record
|
||||
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
|
||||
existing: Optional[ModelUpdateRecord],
|
||||
normalized_local: Sequence[int],
|
||||
*,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
model_type: Optional[str] = None,
|
||||
model_id: Optional[int] = None,
|
||||
last_checked_at: Optional[float] = None,
|
||||
version_info: Optional[Mapping] = None,
|
||||
) -> ModelUpdateRecord:
|
||||
local_set = set(normalized_local)
|
||||
# When folder-filtering, also consider versions in other folders
|
||||
# as in-library so they are not reported as available updates.
|
||||
effective_local_set: set[int] = (
|
||||
local_set | set(all_local_version_ids)
|
||||
if all_local_version_ids is not None
|
||||
else local_set
|
||||
)
|
||||
versions: List[ModelVersionRecord] = []
|
||||
ignore_map: Dict[int, bool] = {}
|
||||
if existing:
|
||||
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
|
||||
versions.append(
|
||||
replace(
|
||||
version,
|
||||
is_in_library=version.version_id in local_set,
|
||||
is_in_library=version.version_id in effective_local_set,
|
||||
)
|
||||
)
|
||||
elif model_type is None or model_id is None:
|
||||
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
|
||||
remote_versions: Sequence[ModelVersionRecord],
|
||||
existing: Optional[ModelUpdateRecord],
|
||||
timestamp: float,
|
||||
*,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
) -> ModelUpdateRecord:
|
||||
local_set = set(local_versions)
|
||||
# When folder-filtering, also consider versions in other folders
|
||||
# as in-library so they are not reported as available updates.
|
||||
effective_local_set: set[int] = (
|
||||
local_set | set(all_local_version_ids)
|
||||
if all_local_version_ids is not None
|
||||
else local_set
|
||||
)
|
||||
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
||||
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
||||
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
||||
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
|
||||
released_at=remote_version.released_at,
|
||||
size_bytes=remote_version.size_bytes,
|
||||
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
||||
is_in_library=version_id in local_set,
|
||||
is_in_library=version_id in effective_local_set,
|
||||
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
||||
sort_index=sort_map.get(version_id, index),
|
||||
early_access_ends_at=remote_version.early_access_ends_at,
|
||||
|
||||
@@ -57,6 +57,7 @@ class PersistentModelCache:
|
||||
"db_checked",
|
||||
"last_checked_at",
|
||||
"hash_status",
|
||||
"hf_url",
|
||||
)
|
||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||
@@ -165,8 +166,8 @@ class PersistentModelCache:
|
||||
|
||||
item = {
|
||||
"file_path": file_path,
|
||||
"file_name": row["file_name"],
|
||||
"model_name": row["model_name"],
|
||||
"file_name": row["file_name"] or "",
|
||||
"model_name": row["model_name"] or "",
|
||||
"folder": row["folder"] or "",
|
||||
"size": row["size"] or 0,
|
||||
"modified": row["modified"] or 0.0,
|
||||
@@ -188,6 +189,7 @@ class PersistentModelCache:
|
||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||
"license_flags": int(license_value),
|
||||
"hash_status": row["hash_status"] or "completed",
|
||||
"hf_url": row["hf_url"] or "",
|
||||
}
|
||||
raw_data.append(item)
|
||||
|
||||
@@ -452,6 +454,7 @@ class PersistentModelCache:
|
||||
db_checked INTEGER,
|
||||
last_checked_at REAL,
|
||||
hash_status TEXT,
|
||||
hf_url TEXT DEFAULT '',
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
@@ -500,6 +503,7 @@ 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'",
|
||||
"hf_url": "TEXT DEFAULT ''",
|
||||
}
|
||||
|
||||
for column, definition in required_columns.items():
|
||||
@@ -548,19 +552,19 @@ class PersistentModelCache:
|
||||
return (
|
||||
model_type,
|
||||
item.get("file_path"),
|
||||
item.get("file_name"),
|
||||
item.get("model_name"),
|
||||
item.get("folder"),
|
||||
item.get("file_name") or "",
|
||||
item.get("model_name") or "",
|
||||
item.get("folder") or "",
|
||||
int(item.get("size") or 0),
|
||||
float(item.get("modified") or 0.0),
|
||||
(item.get("sha256") or "").lower() or None,
|
||||
item.get("base_model"),
|
||||
item.get("preview_url"),
|
||||
item.get("base_model") or "",
|
||||
item.get("preview_url") or "",
|
||||
int(item.get("preview_nsfw_level") or 0),
|
||||
1 if item.get("from_civitai", True) else 0,
|
||||
1 if item.get("favorite") else 0,
|
||||
item.get("notes"),
|
||||
item.get("usage_tips"),
|
||||
item.get("notes") or "",
|
||||
item.get("usage_tips") or "",
|
||||
metadata_source,
|
||||
civitai.get("id"),
|
||||
civitai.get("modelId"),
|
||||
@@ -575,6 +579,7 @@ 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("hf_url") or "",
|
||||
)
|
||||
|
||||
def _insert_model_sql(self) -> str:
|
||||
|
||||
@@ -146,11 +146,38 @@ class RecipeAnalysisService:
|
||||
):
|
||||
metadata = metadata["meta"]
|
||||
|
||||
# Include modelVersionIds from root level if available
|
||||
# Civitai API returns modelVersionIds at root level, not in meta
|
||||
# Include modelVersionIds from root level if available.
|
||||
# CivitAI API returns modelVersionIds at root level, not in meta.
|
||||
# When meta is null (None), create a minimal dict so downstream
|
||||
# parsers can still discover LoRAs and checkpoints.
|
||||
model_version_ids = image_info.get("modelVersionIds")
|
||||
if model_version_ids and isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
if model_version_ids:
|
||||
if isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
else:
|
||||
metadata = {"modelVersionIds": model_version_ids}
|
||||
|
||||
# Inject browsingLevel (canonical integer) so the recipe's
|
||||
# preview_nsfw_level can be set, enabling proper NSFW blur
|
||||
# of the preview image. Fall back to nsfwLevel (string)
|
||||
# when browsingLevel is absent.
|
||||
if isinstance(metadata, dict):
|
||||
browsing_level = image_info.get("browsingLevel")
|
||||
nsfw_level_str = image_info.get("nsfwLevel")
|
||||
if isinstance(browsing_level, int) and browsing_level > 0:
|
||||
metadata["browsingLevel"] = browsing_level
|
||||
elif (
|
||||
isinstance(nsfw_level_str, str)
|
||||
and nsfw_level_str
|
||||
in (
|
||||
"PG", "PG13", "R", "X", "XXX", "Blocked",
|
||||
)
|
||||
):
|
||||
from ...utils.constants import NSFW_LEVELS
|
||||
|
||||
metadata["browsingLevel"] = NSFW_LEVELS.get(
|
||||
nsfw_level_str, 0
|
||||
)
|
||||
|
||||
# Validate that metadata contains meaningful recipe fields
|
||||
# If not, treat as None to trigger EXIF extraction from downloaded image
|
||||
@@ -171,12 +198,19 @@ class RecipeAnalysisService:
|
||||
temp_path = self._create_temp_path(suffix=extension)
|
||||
await self._download_image(url, temp_path)
|
||||
|
||||
if metadata is None and not is_video:
|
||||
metadata = await asyncio.to_thread(
|
||||
# Always extract EXIF from the downloaded image for generation
|
||||
# params (prompt, negative prompt, sampler, steps, etc.).
|
||||
# Previously this was gated on ``metadata is None``, but that
|
||||
# skipped EXIF entirely when API metadata (modelVersionIds,
|
||||
# browsingLevel) is present, losing all generation parameters.
|
||||
exif_metadata = None
|
||||
if not is_video:
|
||||
exif_metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata, temp_path
|
||||
)
|
||||
|
||||
if not metadata and civitai_image_id and image_info:
|
||||
# Fallback: try the original (non-optimized) image for EXIF data
|
||||
if not exif_metadata and civitai_image_id and image_info:
|
||||
original_url = image_info.get("url")
|
||||
if original_url:
|
||||
self._logger.debug(
|
||||
@@ -187,15 +221,38 @@ class RecipeAnalysisService:
|
||||
orig_temp_path = self._create_temp_path(suffix=".png")
|
||||
try:
|
||||
await self._download_image(original_url, orig_temp_path)
|
||||
metadata = await asyncio.to_thread(
|
||||
exif_metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata,
|
||||
orig_temp_path,
|
||||
)
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
# Parse EXIF data (typically a string like parameters/prompt/workflow)
|
||||
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
|
||||
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
|
||||
# This mirrors the two-pass approach in _do_import_from_url.
|
||||
exif_parsed_result = None
|
||||
if isinstance(exif_metadata, str):
|
||||
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
|
||||
if exif_parser:
|
||||
exif_data = await exif_parser.parse_metadata(
|
||||
exif_metadata, recipe_scanner=recipe_scanner,
|
||||
)
|
||||
if exif_data and not exif_data.get("error"):
|
||||
exif_parsed_result = exif_data
|
||||
|
||||
# Merge API metadata (dict) with EXIF data (if dict) for the
|
||||
# CivitaiApiMetadataParser. If EXIF data is a string it was
|
||||
# parsed above — don't try to merge a string into a dict.
|
||||
merged = {}
|
||||
if isinstance(exif_metadata, dict):
|
||||
merged.update(exif_metadata)
|
||||
if isinstance(metadata, dict):
|
||||
merged.update(metadata)
|
||||
|
||||
result = await self._parse_metadata(
|
||||
metadata or {},
|
||||
merged,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=temp_path,
|
||||
include_image_base64=True,
|
||||
@@ -203,13 +260,23 @@ class RecipeAnalysisService:
|
||||
extension=extension,
|
||||
)
|
||||
|
||||
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||
mvid = image_info.get("modelVersionId")
|
||||
if not mvid:
|
||||
mvids = image_info.get("modelVersionIds")
|
||||
if isinstance(mvids, list) and mvids:
|
||||
mvid = mvids[0]
|
||||
# Merge EXIF string-parsed gen_params into the API result.
|
||||
# API gen_params take priority (they come later via update).
|
||||
if exif_parsed_result and not result.payload.get("error"):
|
||||
exif_gp = exif_parsed_result.get("gen_params") or {}
|
||||
result_gp = result.payload.get("gen_params") or {}
|
||||
merged_gp = {**exif_gp, **result_gp}
|
||||
if merged_gp:
|
||||
result.payload["gen_params"] = merged_gp
|
||||
|
||||
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||
# Use the metadata dict we built (may contain modelVersionIds
|
||||
# and browsingLevel from the API root level). Do NOT pass
|
||||
# image_info.get("meta") — it is null for images whose meta
|
||||
# lives at the root level only. Also do NOT derive
|
||||
# model_version_id from modelVersionIds[0] — that array mixes
|
||||
# checkpoints, LoRAs, and other types without ordering
|
||||
# guarantees; the parser already resolved them correctly.
|
||||
recipe_for_enrich = {
|
||||
"gen_params": result.payload.get("gen_params", {}),
|
||||
"loras": result.payload.get("loras", []),
|
||||
@@ -222,8 +289,10 @@ class RecipeAnalysisService:
|
||||
recipe=recipe_for_enrich,
|
||||
civitai_client=civitai_client,
|
||||
request_params=None,
|
||||
prefetched_civitai_meta_raw=image_info.get("meta"),
|
||||
prefetched_model_version_id=mvid,
|
||||
prefetched_civitai_meta_raw=(
|
||||
metadata if isinstance(metadata, dict) else None
|
||||
),
|
||||
prefetched_model_version_id=None,
|
||||
)
|
||||
|
||||
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||
@@ -232,6 +301,12 @@ class RecipeAnalysisService:
|
||||
if recipe_for_enrich.get("base_model"):
|
||||
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||
|
||||
# Extract browsingLevel from our constructed metadata for NSFW blur
|
||||
if isinstance(metadata, dict):
|
||||
bl = metadata.get("browsingLevel")
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
result.payload["preview_nsfw_level"] = bl
|
||||
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -314,6 +389,10 @@ class RecipeAnalysisService:
|
||||
"prompt_type",
|
||||
"positive",
|
||||
"negative",
|
||||
# modelVersionIds is injected at the root level by CivitAI's image
|
||||
# API when meta is null. It carries the version IDs of ALL models
|
||||
# (checkpoint + LoRAs) used to generate the image.
|
||||
"modelVersionIds",
|
||||
}
|
||||
return any(field in metadata for field in recipe_fields)
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"lora_syntax_format": "legacy",
|
||||
"model_card_footer_action": "replace_preview",
|
||||
"show_version_on_card": True,
|
||||
"update_flag_strategy": "same_base",
|
||||
"version_grouping": "same_base",
|
||||
"auto_organize_exclusions": [],
|
||||
"metadata_refresh_skip_paths": [],
|
||||
"skip_previously_downloaded_model_versions": False,
|
||||
@@ -106,6 +106,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
"group_by_model": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -744,6 +745,7 @@ class SettingsManager:
|
||||
"includeTriggerWords": "include_trigger_words",
|
||||
"compactMode": "compact_mode",
|
||||
"modelCardFooterAction": "model_card_footer_action",
|
||||
"update_flag_strategy": "version_grouping",
|
||||
}
|
||||
|
||||
updated = False
|
||||
@@ -1566,7 +1568,7 @@ class SettingsManager:
|
||||
previous_dir = os.path.dirname(previous_path) or target_dir
|
||||
|
||||
if os.path.abspath(previous_path) != os.path.abspath(target_path):
|
||||
self._copy_model_cache_directory(previous_dir, target_dir)
|
||||
self._migrate_settings_directory_content(previous_dir, target_dir)
|
||||
logger.info("Switching settings file to: %s", target_path)
|
||||
|
||||
self._pending_portable_switch = {"other_path": other_path}
|
||||
@@ -1601,46 +1603,52 @@ class SettingsManager:
|
||||
finally:
|
||||
self._pending_portable_switch = None
|
||||
|
||||
def _copy_model_cache_directory(self, source_dir: str, target_dir: str) -> None:
|
||||
"""Copy model_cache artifacts when switching storage locations."""
|
||||
def _migrate_settings_directory_content(
|
||||
self, source_dir: str, target_dir: str
|
||||
) -> None:
|
||||
"""Migrate settings directory subdirectories when switching storage locations.
|
||||
|
||||
Copies the canonical subdirectories (cache, backups, logs, stats, wildcards)
|
||||
from the old settings directory to the new one. Legacy cache artifacts
|
||||
(model_cache, recipe_cache, etc.) are migrated lazily by
|
||||
``resolve_cache_path_with_migration`` on first access.
|
||||
|
||||
Args:
|
||||
source_dir: The previous settings directory path.
|
||||
target_dir: The new settings directory path.
|
||||
"""
|
||||
|
||||
if not source_dir or not target_dir:
|
||||
return
|
||||
|
||||
source_cache_dir = os.path.join(source_dir, "model_cache")
|
||||
target_cache_dir = os.path.join(target_dir, "model_cache")
|
||||
if os.path.isdir(source_cache_dir) and os.path.abspath(
|
||||
source_cache_dir
|
||||
) != os.path.abspath(target_cache_dir):
|
||||
try:
|
||||
shutil.copytree(
|
||||
source_cache_dir,
|
||||
target_cache_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to copy model_cache directory from %s to %s: %s",
|
||||
source_cache_dir,
|
||||
target_cache_dir,
|
||||
exc,
|
||||
)
|
||||
def _copy_dir(name: str) -> None:
|
||||
source = os.path.join(source_dir, name)
|
||||
target = os.path.join(target_dir, name)
|
||||
if os.path.isdir(source) and os.path.abspath(source) != os.path.abspath(
|
||||
target
|
||||
):
|
||||
try:
|
||||
shutil.copytree(
|
||||
source,
|
||||
target,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to copy directory %s from %s to %s: %s",
|
||||
name,
|
||||
source,
|
||||
target,
|
||||
exc,
|
||||
)
|
||||
|
||||
source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
|
||||
target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
|
||||
if os.path.isfile(source_cache_file) and os.path.abspath(
|
||||
source_cache_file
|
||||
) != os.path.abspath(target_cache_file):
|
||||
try:
|
||||
shutil.copy2(source_cache_file, target_cache_file)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to copy model_cache.sqlite from %s to %s: %s",
|
||||
source_cache_file,
|
||||
target_cache_file,
|
||||
exc,
|
||||
)
|
||||
# Managed subdirectories under settings_dir
|
||||
_copy_dir("cache")
|
||||
_copy_dir("backups")
|
||||
_copy_dir("logs")
|
||||
_copy_dir("stats")
|
||||
_copy_dir("wildcards")
|
||||
|
||||
def _get_user_config_directory(self) -> str:
|
||||
"""Return the user configuration directory, falling back to ~/.config."""
|
||||
|
||||
@@ -47,6 +47,20 @@ SUPPORTED_MEDIA_EXTENSIONS = {
|
||||
"videos": [".mp4", ".webm"],
|
||||
}
|
||||
|
||||
# Model weight file extensions recognised by scanners.
|
||||
# This is the union of all scanner extensions (lora, checkpoint, embedding).
|
||||
MODEL_FILE_EXTENSIONS = {
|
||||
".safetensors",
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
|
||||
# Valid sub-types for each scanner type
|
||||
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||
@@ -147,6 +161,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
"Qwen",
|
||||
"ZImageBase",
|
||||
"ZImageTurbo",
|
||||
# Krea 2 — loaded via UNETLoader in ComfyUI
|
||||
"Krea 2",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -213,5 +229,6 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"Nucleus",
|
||||
"Krea 2",
|
||||
]
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.4"
|
||||
version = "1.1.6"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
{
|
||||
"id": 1746460,
|
||||
"name": "Mixplin Style [Illustrious]",
|
||||
"type": "LORA",
|
||||
"description": "description",
|
||||
"username": "Ty_Lee",
|
||||
"downloadCount": 4207,
|
||||
"favoriteCount": 0,
|
||||
"commentCount": 8,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"is_nsfw": true,
|
||||
"nsfw_level": 31,
|
||||
"createdAt": "2025-07-06T01:51:42.859Z",
|
||||
"updatedAt": "2025-10-10T23:15:26.714Z",
|
||||
"deletedAt": null,
|
||||
"tags": [
|
||||
"art",
|
||||
"style",
|
||||
"artist style",
|
||||
"styles",
|
||||
"mixplin",
|
||||
"artiststyle"
|
||||
],
|
||||
"creator_id": "Ty_Lee",
|
||||
"creator_username": "Ty_Lee",
|
||||
"creator_name": "Ty_Lee",
|
||||
"creator_url": "/users/Ty_Lee",
|
||||
"versions": [
|
||||
{
|
||||
"id": 2042594,
|
||||
"name": "v2.0",
|
||||
"href": "/models/1746460?modelVersionId=2042594"
|
||||
},
|
||||
{
|
||||
"id": 1976567,
|
||||
"name": "v1.0",
|
||||
"href": "/models/1746460?modelVersionId=1976567"
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"id": 1976567,
|
||||
"modelId": 1746460,
|
||||
"name": "v1.0",
|
||||
"baseModel": "Illustrious",
|
||||
"baseModelType": "Standard",
|
||||
"description": null,
|
||||
"downloadCount": 437,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"is_nsfw": true,
|
||||
"nsfw_level": 31,
|
||||
"createdAt": "2025-07-05T10:17:28.716Z",
|
||||
"updatedAt": "2025-10-10T23:15:26.756Z",
|
||||
"deletedAt": null,
|
||||
"files": [
|
||||
{
|
||||
"id": 1874043,
|
||||
"name": "mxpln-illustrious-ty_lee.safetensors",
|
||||
"type": "Model",
|
||||
"sizeKB": 223124.37109375,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1976567",
|
||||
"modelId": 1746460,
|
||||
"modelName": "Mixplin Style [Illustrious]",
|
||||
"modelVersionId": 1976567,
|
||||
"is_nsfw": true,
|
||||
"nsfw_level": 31,
|
||||
"sha256": "e2b7a280d6539556f23f380b3f71e4e22bc4524445c4c96526e117c6005c6ad3",
|
||||
"createdAt": "2025-07-05T10:17:28.716Z",
|
||||
"updatedAt": "2025-10-10T23:15:26.766Z",
|
||||
"is_primary": false,
|
||||
"mirrors": [
|
||||
{
|
||||
"filename": "mxpln-illustrious-ty_lee.safetensors",
|
||||
"url": "https://civitai.com/api/download/models/1976567",
|
||||
"source": "civitai",
|
||||
"model_id": 1746460,
|
||||
"model_version_id": 1976567,
|
||||
"deletedAt": null,
|
||||
"is_gated": false,
|
||||
"is_paid": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"id": 86403595,
|
||||
"url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
||||
"nsfwLevel": 1,
|
||||
"width": 1560,
|
||||
"height": 2280,
|
||||
"hash": "U7G8Zp0w02%IA6%N00-;D]-W~VNG0nMw-.IV",
|
||||
"type": "image",
|
||||
"minor": false,
|
||||
"poi": false,
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null,
|
||||
"image_url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
||||
"link": "https://genur.art/posts/86403595"
|
||||
}
|
||||
],
|
||||
"trigger": [
|
||||
"mxpln"
|
||||
],
|
||||
"allow_download": true,
|
||||
"download_url": "/api/download/models/1976567",
|
||||
"platform_url": "https://civitai.com/models/1746460?modelVersionId=1976567",
|
||||
"civitai_model_id": 1746460,
|
||||
"civitai_model_version_id": 1976567,
|
||||
"href": "/models/1746460?modelVersionId=1976567",
|
||||
"mirrors": [
|
||||
{
|
||||
"platform": "tensorart",
|
||||
"href": "/tensorart/models/904473536033245448/versions/904473536033245448",
|
||||
"platform_url": "https://tensor.art/models/904473536033245448",
|
||||
"name": "Mixplin Style MXP",
|
||||
"version_name": "Mixplin",
|
||||
"id": "904473536033245448",
|
||||
"version_id": "904473536033245448"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platform": "civitai",
|
||||
"platform_name": "CivitAI",
|
||||
"meta": {
|
||||
"title": "Mixplin Style [Illustrious] - v1.0 - CivitAI Archive",
|
||||
"description": "Mixplin Style [Illustrious] v1.0 is a Illustrious LORA AI model created by Ty_Lee for generating images of art, style, artist style, styles, mixplin, artiststyle",
|
||||
"image": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
||||
"canonical": "https://civarchive.com/models/1746460?modelVersionId=1976567"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
CREATE TABLE models (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
username TEXT,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE model_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
model_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
base_model TEXT NOT NULL,
|
||||
published_at INTEGER,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX model_versions_model_id_idx ON model_versions (model_id);
|
||||
CREATE TABLE model_files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
model_id INTEGER NOT NULL,
|
||||
version_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
sha256 TEXT,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX model_files_model_id_idx ON model_files (model_id);
|
||||
CREATE INDEX model_files_version_id_idx ON model_files (version_id);
|
||||
CREATE TABLE archived_model_files (
|
||||
file_id INTEGER PRIMARY KEY,
|
||||
model_id INTEGER NOT NULL,
|
||||
version_id INTEGER NOT NULL
|
||||
) STRICT;
|
||||
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"id": 1231067,
|
||||
"name": "Vivid Impressions Storybook Style",
|
||||
"description": "<h3 id=\"if-you'd-like-to-support-me-feel-free-to-visit-my-ko-fi-page.-please-share-your-images-using-the-"+add-post"-button-below.-it-supports-the-creators.-thanks!-nnfwkvfly\">If you'd like to support me, feel free to visit my <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/pixelpawsai\">Ko-Fi</a> page. ❤️<br /><br />Please share your images using the \"<span style=\"color:rgb(250, 82, 82)\">+add post</span>\" button below. It supports the creators. Thanks! 💕</h3><h3 id=\"if-you-like-my-lora-please-like-comment-or-donate-some-buzz.-much-appreciated!-vyeqok3go\">If you like my LoRA, please<span style=\"color:rgb(230, 73, 128)\"> </span><span style=\"color:rgb(250, 82, 82)\">like</span>, <span style=\"color:rgb(250, 82, 82)\">comment</span>, or <span style=\"color:#fa5252\">donate some Buzz</span>. Much appreciated! ❤️</h3><h3 id=\"-lo912t8rj\"></h3><h3 id=\"trigger-word:-ppstorybook-wlggllim2\"><strong><span style=\"color:rgb(253, 126, 20)\">Trigger word: </span></strong>ppstorybook</h3><h3 id=\"strength:-0.8-experiment-as-you-like-luvhks6za\"><strong><span style=\"color:rgb(253, 126, 20)\">Strength: </span></strong>0.8, experiment as you like</h3>",
|
||||
"allowNoCredit": true,
|
||||
"allowCommercialUse": [
|
||||
"Image",
|
||||
"RentCivit",
|
||||
"Rent",
|
||||
"Sell"
|
||||
],
|
||||
"allowDerivatives": true,
|
||||
"allowDifferentLicense": true,
|
||||
"type": "LORA",
|
||||
"minor": false,
|
||||
"sfwOnly": false,
|
||||
"poi": false,
|
||||
"nsfw": false,
|
||||
"nsfwLevel": 1,
|
||||
"availability": "Public",
|
||||
"cosmetic": null,
|
||||
"supportsGeneration": true,
|
||||
"stats": {
|
||||
"downloadCount": 2183,
|
||||
"favoriteCount": 0,
|
||||
"thumbsUpCount": 416,
|
||||
"thumbsDownCount": 0,
|
||||
"commentCount": 12,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"tippedAmountCount": 360
|
||||
},
|
||||
"creator": {
|
||||
"username": "PixelPawsAI",
|
||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
|
||||
},
|
||||
"tags": [
|
||||
"style",
|
||||
"illustration",
|
||||
"storybook"
|
||||
],
|
||||
"modelVersions": [
|
||||
{
|
||||
"id": 1387174,
|
||||
"index": 0,
|
||||
"name": "v1.0",
|
||||
"baseModel": "Flux.1 D",
|
||||
"baseModelType": "Standard",
|
||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
||||
"status": "Published",
|
||||
"availability": "Public",
|
||||
"nsfwLevel": 1,
|
||||
"trainedWords": [
|
||||
"ppstorybook"
|
||||
],
|
||||
"covered": true,
|
||||
"stats": {
|
||||
"downloadCount": 2183,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"thumbsUpCount": 416,
|
||||
"thumbsDownCount": 0
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 1289799,
|
||||
"sizeKB": 18829.1484375,
|
||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
||||
"type": "Model",
|
||||
"pickleScanResult": "Success",
|
||||
"pickleScanMessage": "No Pickle imports",
|
||||
"virusScanResult": "Success",
|
||||
"virusScanMessage": null,
|
||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
||||
"metadata": {
|
||||
"format": "SafeTensor"
|
||||
},
|
||||
"hashes": {
|
||||
"AutoV1": "F414C813",
|
||||
"AutoV2": "9753338AB6",
|
||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
||||
"CRC32": "A65AE7B3",
|
||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
||||
"AutoV3": "34A22376739D"
|
||||
},
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174",
|
||||
"primary": true
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/original=true/56547310.jpeg",
|
||||
"nsfwLevel": 1,
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
||||
"type": "image",
|
||||
"minor": false,
|
||||
"poi": false,
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null
|
||||
}
|
||||
],
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"id": 1387174,
|
||||
"modelId": 1231067,
|
||||
"name": "v1.0",
|
||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
||||
"updatedAt": "2025-02-08T11:29:04.526Z",
|
||||
"status": "Published",
|
||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
||||
"trainedWords": [
|
||||
"ppstorybook"
|
||||
],
|
||||
"trainingStatus": null,
|
||||
"trainingDetails": null,
|
||||
"baseModel": "Flux.1 D",
|
||||
"baseModelType": null,
|
||||
"earlyAccessEndsAt": null,
|
||||
"earlyAccessConfig": null,
|
||||
"description": null,
|
||||
"uploadType": "Created",
|
||||
"usageControl": "Download",
|
||||
"air": "urn:air:flux1:lora:civitai:1231067@1387174",
|
||||
"stats": {
|
||||
"downloadCount": 1436,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"thumbsUpCount": 316
|
||||
},
|
||||
"model": {
|
||||
"name": "Vivid Impressions Storybook Style",
|
||||
"type": "LORA",
|
||||
"nsfw": false,
|
||||
"poi": false
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 1289799,
|
||||
"sizeKB": 18829.1484375,
|
||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
||||
"type": "Model",
|
||||
"pickleScanResult": "Success",
|
||||
"pickleScanMessage": "No Pickle imports",
|
||||
"virusScanResult": "Success",
|
||||
"virusScanMessage": null,
|
||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
||||
"metadata": {
|
||||
"format": "SafeTensor",
|
||||
"size": null,
|
||||
"fp": null
|
||||
},
|
||||
"hashes": {
|
||||
"AutoV1": "F414C813",
|
||||
"AutoV2": "9753338AB6",
|
||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
||||
"CRC32": "A65AE7B3",
|
||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
||||
"AutoV3": "34A22376739D"
|
||||
},
|
||||
"primary": true,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg",
|
||||
"nsfwLevel": 1,
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
||||
"type": "image",
|
||||
"metadata": {
|
||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
||||
"size": 1361590,
|
||||
"width": 832,
|
||||
"height": 1216
|
||||
},
|
||||
"meta": {
|
||||
"Size": "832x1216",
|
||||
"seed": 1116375220995209,
|
||||
"Model": "flux_dev_fp8",
|
||||
"steps": 23,
|
||||
"hashes": {
|
||||
"model": ""
|
||||
},
|
||||
"prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.",
|
||||
"Version": "ComfyUI",
|
||||
"sampler": "DPM++ 2M",
|
||||
"cfgScale": 3.5,
|
||||
"clipSkip": 1,
|
||||
"resources": [],
|
||||
"Model hash": ""
|
||||
},
|
||||
"availability": "Public",
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null
|
||||
}
|
||||
],
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
{
|
||||
"resource-stack": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
|
||||
},
|
||||
"resource-stack-1": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
|
||||
"strength_model": 1,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack", 0],
|
||||
"clip": ["resource-stack", 1]
|
||||
}
|
||||
},
|
||||
"resource-stack-2": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
|
||||
"strength_model": 1,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack-1", 0],
|
||||
"clip": ["resource-stack-1", 1]
|
||||
}
|
||||
},
|
||||
"resource-stack-3": {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
|
||||
"strength_model": 1.55,
|
||||
"strength_clip": 1,
|
||||
"model": ["resource-stack-2", 0],
|
||||
"clip": ["resource-stack-2", 1]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
|
||||
"parser": "comfy",
|
||||
"text_g": "",
|
||||
"text_l": "",
|
||||
"ascore": 2.5,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"crop_w": 0,
|
||||
"crop_h": 0,
|
||||
"target_width": 0,
|
||||
"target_height": 0,
|
||||
"smZ_steps": 1,
|
||||
"mean_normalization": true,
|
||||
"multi_conditioning": true,
|
||||
"use_old_emphasis_implementation": false,
|
||||
"with_SDXL": false,
|
||||
"clip": ["resource-stack-3", 1]
|
||||
},
|
||||
"_meta": { "title": "Positive" }
|
||||
},
|
||||
"7": {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "bad quality,worst quality,worst detail,sketch,censor",
|
||||
"parser": "comfy",
|
||||
"text_g": "",
|
||||
"text_l": "",
|
||||
"ascore": 2.5,
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"crop_w": 0,
|
||||
"crop_h": 0,
|
||||
"target_width": 0,
|
||||
"target_height": 0,
|
||||
"smZ_steps": 1,
|
||||
"mean_normalization": true,
|
||||
"multi_conditioning": true,
|
||||
"use_old_emphasis_implementation": false,
|
||||
"with_SDXL": false,
|
||||
"clip": ["resource-stack-3", 1]
|
||||
},
|
||||
"_meta": { "title": "Negative" }
|
||||
},
|
||||
"20": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
|
||||
"_meta": { "title": "Load Upscale Model" }
|
||||
},
|
||||
"17": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
|
||||
"upload": "image"
|
||||
},
|
||||
"_meta": { "title": "Image Load" }
|
||||
},
|
||||
"19": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
|
||||
"_meta": { "title": "Upscale Image (using Model)" }
|
||||
},
|
||||
"23": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"upscale_method": "nearest-exact",
|
||||
"crop": "disabled",
|
||||
"width": 1280,
|
||||
"height": 1856,
|
||||
"image": ["19", 0]
|
||||
},
|
||||
"_meta": { "title": "Upscale Image" }
|
||||
},
|
||||
"21": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
|
||||
"_meta": { "title": "VAE Encode" }
|
||||
},
|
||||
"11": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"seed": 2088370631,
|
||||
"steps": 47,
|
||||
"cfg": 6.5,
|
||||
"denoise": 0.3,
|
||||
"model": ["resource-stack-3", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["21", 0]
|
||||
},
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"13": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"12": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
|
||||
"_meta": { "title": "Save Image" }
|
||||
},
|
||||
"extra": {
|
||||
"airs": [
|
||||
"urn:air:other:upscaler:civitai:147759@164821",
|
||||
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
|
||||
"urn:air:sdxl:lora:civitai:1107767@1253442",
|
||||
"urn:air:sdxl:lora:civitai:1342708@1516344",
|
||||
"urn:air:sdxl:lora:civitai:122359@135867"
|
||||
]
|
||||
},
|
||||
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting.
|
||||
Negative prompt:
|
||||
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
|
||||
|
||||
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
|
||||
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
|
||||
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
|
||||
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
|
||||
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
|
||||
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)
|
||||
|
||||
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1girl, solo, futuristic warrior, sleek exosuit with glowing energy cores, long braided hair flowing behind, gripping a high-tech bow with an energy arrow drawn, standing on a floating platform overlooking a massive space station, planets and nebulae in the distance, soft glow from distant stars, cinematic depth, foreshortening, dynamic pose, dramatic sci-fi lighting.
|
||||
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
|
||||
Steps: 20, Sampler: euler_ancestral_karras, CFG scale: 8.0, Seed: 691121152183439, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 1.0, Lora_0 Strength clip: 1.0, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
|
||||
|
||||
Immerse yourself in the enchanting journey, where harmonious transmutation of Bauhaus art unites photographic precision and contemporary illustration, capturing an enthralling blend between vivid abstract nature and urban landscapes. Let your eyes be captivated by a kaleidoscope of rich, deep reds and yellows, entwined with intriguing shades that beckon a somber atmosphere. As your spirit ventures along this haunting path, witness the mysterious, high-angle perspective dominated by scattered clouds – granting you a mesmerizing glimpse into the ever-transforming realm of metamorphosing environments. ,<lora:flux/fav/ck-charcoal-drawing-000014.safetensors:1.0:1.0>
|
||||
Negative prompt:
|
||||
Steps: 20, Sampler: Euler, CFG scale: 3.5, Seed: 885491426361006, Size: 832x1216, Model hash: 4610115bb0, Model: flux_dev, Hashes: {"LORA:flux/fav/ck-charcoal-drawing-000014.safetensors": "34d36c17c1", "model": "4610115bb0"}, Version: ComfyUI
|
||||
@@ -1,3 +0,0 @@
|
||||
In this ethereal masterpiece, metallic sculptures juxtapose effortlessly against a subtle backdrop of misty neutral hues. Exquisite curvatures and geometric shapes converge harmoniously, creating an illuminating realm of polished metallic surfaces. Shimmering copper, gleaming silver, and lustrous gold hues dance in perfect balance, highlighting the intricate play of light and shadow cast upon these celestial forms. A halo of diffused radiance envelops each piece, enhancing their textured depths and metallic brilliance while allowing delicate details to emerge from obscurity. The composition conveys a serene yet mesmerizing atmosphere, as if suspended in a dreamlike limbo between reality and fantasy. The tantalizing interplay of colors within this transcendent realm creates a profound sense of depth and grandeur that invites the viewer into an enchanting voyage through abstract metallic beauty. This captivating artwork evokes emotions of boundless curiosity and reverence reminiscent of the timeless works by artists such as Giorgio de Chirico or Paul Klee, while asserting a unique, modern artistic sensibility. With every observation, a new nuance unfolds, as if a never-ending story waiting to be discovered through the lens of metallic artistry.
|
||||
Negative prompt:
|
||||
Steps: 25, Sampler: dpmpp_2m_sgm_uniform, Seed: 471889513588087, Model: Fluxmania V5P.safetensors, Model hash: 8ae0583b06, VAE: ae.sft, VAE hash: afc8e28272, Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, Lora_0 Strength model: 0.65, Lora_0 Strength clip: 0.65, Lora_1 Model name: Kaoru Yamada.safetensors, Lora_1 Model hash: d4893f7202, Lora_1 Strength model: 0.75, Lora_1 Strength clip: 0.75, Hashes: {"model": "8ae0583b06", "vae": "afc8e28272", "lora:ArtVador I": "08f7133a58", "lora:Kaoru Yamada": "d4893f7202"}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
|
||||
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
|
||||
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
|
||||
"modified": 1754897325.0507245,
|
||||
"created_date": 1754897325.0507245,
|
||||
"base_model": "Illustrious",
|
||||
"loras": [
|
||||
{
|
||||
"file_name": "",
|
||||
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
|
||||
"strength": 1.0,
|
||||
"modelVersionId": 2007092,
|
||||
"modelName": "Pony: People's Works +",
|
||||
"modelVersionName": "v8_Illusv1.0",
|
||||
"isDeleted": false,
|
||||
"exclude": false
|
||||
}
|
||||
],
|
||||
"gen_params": {
|
||||
"prompt": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect eyes, realistic eyes,\n(flat colors:1.5), (anime:1.5), (lineart:1.5),\nclose-up, solo, tongue, 1girl, food, (saliva:0.1), open mouth, candy, simple background, blue background, large lollipop, tongue out, fade background, lips, hand up, holding, looking at viewer, licking, seductive, half-closed eyes,",
|
||||
"negative_prompt": "shiny skin,",
|
||||
"steps": 19,
|
||||
"sampler": "Euler a",
|
||||
"cfg_scale": 5,
|
||||
"seed": 1765271748,
|
||||
"size": "832x1216",
|
||||
"clip_skip": 2
|
||||
},
|
||||
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
|
||||
"source_path": "https://civitai.com/images/92427432",
|
||||
"folder": "test"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"id": 2269146,
|
||||
"modelId": 2004760,
|
||||
"name": "v1.0 Illustrious",
|
||||
"nsfwLevel": 1,
|
||||
"trainedWords": ["PencilSketchDaal"],
|
||||
"baseModel": "Illustrious",
|
||||
"description": "<p>Illustrious. Your pencil may vary with your checkpoint. </p>",
|
||||
"model": {
|
||||
"name": "Pencil Sketch Anime",
|
||||
"type": "LORA",
|
||||
"nsfw": false,
|
||||
"description": "description",
|
||||
"tags": ["style"],
|
||||
"allowNoCredit": true,
|
||||
"allowCommercialUse": ["Sell"],
|
||||
"allowDerivatives": true,
|
||||
"allowDifferentLicense": true
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 2161260,
|
||||
"sizeKB": 223106.37890625,
|
||||
"name": "Pencil-Sketch-Illustrious.safetensors",
|
||||
"type": "Model",
|
||||
"hashes": {
|
||||
"SHA256": "2C70479CD673B0FE056EAF4FD97C7F33A39F14853805431AC9AB84226ECE3B82"
|
||||
},
|
||||
"primary": true,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/2269146",
|
||||
"mirrors": {}
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{},
|
||||
{}
|
||||
],
|
||||
"creator": {
|
||||
"username": "Daalis",
|
||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/eb245b49-edc8-4ed6-ad7b-6d61eb8c51de/width=96/Daalis.jpeg"
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"id": 1255556,
|
||||
"modelId": 1117241,
|
||||
"name": "v1.0",
|
||||
"createdAt": "2025-01-08T06:13:08.839Z",
|
||||
"updatedAt": "2025-01-08T06:28:54.156Z",
|
||||
"status": "Published",
|
||||
"publishedAt": "2025-01-08T06:28:54.155Z",
|
||||
"trainedWords": ["in the style of ppWhimsy"],
|
||||
"trainingStatus": null,
|
||||
"trainingDetails": null,
|
||||
"baseModel": "Flux.1 D",
|
||||
"baseModelType": "Standard",
|
||||
"earlyAccessEndsAt": null,
|
||||
"earlyAccessConfig": null,
|
||||
"description": null,
|
||||
"uploadType": "Created",
|
||||
"usageControl": "Download",
|
||||
"air": "urn:air:flux1:lora:civitai:1117241@1255556",
|
||||
"stats": {
|
||||
"downloadCount": 210,
|
||||
"ratingCount": 0,
|
||||
"rating": 0,
|
||||
"thumbsUpCount": 26
|
||||
},
|
||||
"model": {
|
||||
"name": "Enchanted Whimsy style (Flux)",
|
||||
"type": "LORA",
|
||||
"nsfw": false,
|
||||
"poi": false
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": 1160774,
|
||||
"sizeKB": 38828.8125,
|
||||
"name": "pp-enchanted-whimsy.safetensors",
|
||||
"type": "Model",
|
||||
"pickleScanResult": "Success",
|
||||
"pickleScanMessage": "No Pickle imports",
|
||||
"virusScanResult": "Success",
|
||||
"virusScanMessage": null,
|
||||
"scannedAt": "2025-01-08T06:16:27.731Z",
|
||||
"metadata": {
|
||||
"format": "SafeTensor",
|
||||
"size": null,
|
||||
"fp": null
|
||||
},
|
||||
"hashes": {
|
||||
"AutoV1": "40CAF049",
|
||||
"AutoV2": "3202778C3E",
|
||||
"SHA256": "3202778C3EBE5CF7EBE5FC51561DEAE8611F4362036EB7C02EFA033C705E6240",
|
||||
"CRC32": "69DCD953",
|
||||
"BLAKE3": "ED04580DDB1AD36D8B87F4B0800F5930C7E5D4A7269BDC2BE26ED77EA1A34697",
|
||||
"AutoV3": "BF82986F8597"
|
||||
},
|
||||
"primary": true,
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1255556"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/707aef9b-36fb-46c2-ac41-adcab539d3a6/width=832/50270101.jpeg",
|
||||
"nsfwLevel": 1,
|
||||
"width": 832,
|
||||
"height": 1216,
|
||||
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
|
||||
"type": "image",
|
||||
"metadata": {
|
||||
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
|
||||
"size": 702313,
|
||||
"width": 832,
|
||||
"height": 1216
|
||||
},
|
||||
"minor": false,
|
||||
"poi": false,
|
||||
"meta": {
|
||||
"prompt": "in the style of ppWhimsy, a close-up of a boy with a crown of ferns and tiny horns, his eyes wide with wonder as a family of glowing hedgehogs nestle in his hands, their spines shimmering with soft pastel colors"
|
||||
},
|
||||
"availability": "Public",
|
||||
"hasMeta": true,
|
||||
"hasPositivePrompt": true,
|
||||
"onSite": false,
|
||||
"remixOfId": null
|
||||
}
|
||||
],
|
||||
"downloadUrl": "https://civitai.com/api/download/models/1255556",
|
||||
"creator": {
|
||||
"username": "PixelPawsAI",
|
||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Style for selected cards */
|
||||
.model-card.selected {
|
||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: -2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -509,6 +509,50 @@
|
||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||
}
|
||||
|
||||
/* Clickable version count link (shown in group-by-model mode) */
|
||||
.version-count-link {
|
||||
display: inline-block;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
border: 1px solid var(--color-accent-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 1px 6px;
|
||||
background: var(--color-accent-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.version-count-link:hover {
|
||||
background: var(--color-accent-border);
|
||||
border-color: var(--color-accent-transparent);
|
||||
}
|
||||
|
||||
/* Medium density adjustments for version count link */
|
||||
.medium-density .version-count-link {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.medium-density .badge-version-unit .version-count-link {
|
||||
max-width: 90px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Compact density adjustments for version count link */
|
||||
.compact-density .version-count-link {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.compact-density .badge-version-unit .version-count-link {
|
||||
max-width: 70px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Version row — flex container for badges + version names */
|
||||
.version-row {
|
||||
display: flex;
|
||||
@@ -690,6 +734,21 @@ body.hide-card-version .hl-badge {
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid-scoped loading overlay (replaces full-page overlay for VirtualScroller refreshes) */
|
||||
.grid-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--lora-bg-transparent, oklch(0% 0 0 / 0.3));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Add after the existing .model-card:hover styles */
|
||||
|
||||
@keyframes update-pulse {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-left: 2.25rem !important;
|
||||
padding-right: 5rem !important;
|
||||
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
@@ -190,6 +190,81 @@
|
||||
right: 2.25rem;
|
||||
}
|
||||
|
||||
/* Clear button: sit immediately left of the search-options toggle */
|
||||
.header-search .search-clear {
|
||||
position: absolute;
|
||||
right: 4.25rem; /* 2.25rem (options toggle) + 28px toggle width + 4px gap */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs, 4px);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
.header-search .search-clear.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-search .search-clear:hover {
|
||||
background: color-mix(in oklch, var(--text-muted) 15%, transparent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut cue: shown when search is empty, hidden when typing */
|
||||
.header-search .search-shortcut-cue {
|
||||
position: absolute;
|
||||
right: 4.25rem; /* same slot as clear button */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
pointer-events: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.header-search .search-shortcut-cue kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
/* Subtle tint derived from text color so it adapts to both light & dark themes */
|
||||
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
|
||||
border-radius: var(--border-radius-xs, 3px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-search .search-shortcut-cue.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-search.disabled .search-shortcut-cue {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-search .search-options-toggle:hover,
|
||||
.header-search .search-filter-toggle:hover,
|
||||
.header-search .search-filter-toggle:focus-visible {
|
||||
|
||||
@@ -229,6 +229,19 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Header row for params section */
|
||||
.metadata-row.params-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metadata-row.params-row .param-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Styling for parameters tags */
|
||||
.params-tags {
|
||||
display: flex;
|
||||
@@ -272,13 +285,25 @@
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.metadata-row.prompt-row .param-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metadata-row.prompt-row .param-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metadata-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
font-size: 0.85em;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metadata-prompt-wrapper {
|
||||
@@ -286,7 +311,7 @@
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 6px 30px 6px 8px;
|
||||
padding: 6px 8px;
|
||||
margin-top: 2px;
|
||||
max-height: 80px; /* Reduced from 120px */
|
||||
overflow-y: auto;
|
||||
@@ -302,22 +327,26 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.copy-prompt-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
.copy-prompt-btn,
|
||||
.send-prompt-btn,
|
||||
.send-params-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.copy-prompt-btn:hover {
|
||||
.copy-prompt-btn:hover,
|
||||
.send-prompt-btn:hover,
|
||||
.send-params-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--lora-accent);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
/* Scrollbar styling for metadata panel */
|
||||
|
||||
@@ -821,4 +821,66 @@
|
||||
|
||||
[data-theme="dark"] .batch-preview-item {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.hf-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: oklch(0.55 0.12 250 / 0.15);
|
||||
color: oklch(0.7 0.12 250);
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
|
||||
/* Checkbox inside HF batch preview items */
|
||||
.batch-preview-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--lora-accent);
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Select All toolbar in batch preview */
|
||||
.batch-preview-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--lora-surface);
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.batch-preview-select-all input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--lora-accent);
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.batch-preview-select-all label {
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .batch-preview-select-all {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
@@ -264,6 +264,174 @@
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
|
||||
}
|
||||
|
||||
/* Disabled sort dropdown — used when VLM custom filter is active */
|
||||
.control-group select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: var(--bg-color);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.control-group select:disabled:hover {
|
||||
border-color: var(--border-color);
|
||||
background-color: var(--bg-color);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* === Sort dropdown — decoupled trigger width ===========================
|
||||
The native <select> sizes its trigger to the widest <option>, wasting
|
||||
horizontal space when a short option is selected. This custom trigger
|
||||
sizes to the currently selected text only; the dropdown menu sizes to
|
||||
its content independently. The native <select> is kept in the DOM
|
||||
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
|
||||
and dynamically adds/removes <option>s keeps working. */
|
||||
|
||||
.sort-dropdown-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sort-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
max-width: 240px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.sort-trigger:hover,
|
||||
.sort-trigger:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sort-trigger:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.sort-trigger__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-trigger__caret {
|
||||
opacity: 0.8;
|
||||
transition: transform var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort-dropdown-group.active .sort-trigger__caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sort-dropdown-group.active .sort-trigger {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
|
||||
.sort-dropdown-group.is-disabled .sort-trigger {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
background: var(--bg-color);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Dropdown menu — sizes to its content, independent of trigger width.
|
||||
Inherits base .dropdown-menu styling; capped for very long i18n text. */
|
||||
.sort-dropdown-menu {
|
||||
min-width: max-content;
|
||||
max-width: 320px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
/* Optgroup label rendered as a section header */
|
||||
.sort-dropdown-group .sort-optgroup-label {
|
||||
padding: 8px 12px 4px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-optgroup-label:first-child {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Option items */
|
||||
.sort-dropdown-group .sort-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-option::before {
|
||||
content: '';
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-option:hover {
|
||||
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-option.is-selected {
|
||||
color: var(--lora-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sort-dropdown-group .sort-option.is-selected::before {
|
||||
content: '\2713';
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Visually hidden native <select> — kept in the DOM for programmatic access.
|
||||
High-specificity selector overrides .control-group select { min-width: 100px }. */
|
||||
.control-group .sort-select-native {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Ensure hidden class works properly */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
|
||||
@@ -59,3 +59,5 @@
|
||||
.initialization-notice .loading-spinner {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---------- reused from shared styles ---------- */
|
||||
|
||||
@@ -190,6 +190,12 @@ export const DOWNLOAD_ENDPOINTS = {
|
||||
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
|
||||
};
|
||||
|
||||
// Hugging Face API endpoints
|
||||
export const HF_ENDPOINTS = {
|
||||
repoFiles: '/api/lm/hf-repo-files',
|
||||
download: '/api/lm/download-hf-model',
|
||||
};
|
||||
|
||||
// WebSocket endpoints
|
||||
export const WS_ENDPOINTS = {
|
||||
fetchProgress: '/ws/fetch-progress'
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||
import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||
import {
|
||||
getCompleteApiConfig,
|
||||
getCurrentModelType,
|
||||
isValidModelType,
|
||||
DOWNLOAD_ENDPOINTS,
|
||||
HF_ENDPOINTS,
|
||||
WS_ENDPOINTS
|
||||
} from './apiConfig.js';
|
||||
import { resetAndReload } from './modelApiFactory.js';
|
||||
@@ -115,7 +116,10 @@ export class BaseModelApiClient {
|
||||
const pageState = this.getPageState();
|
||||
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`);
|
||||
// Use grid-scoped loading instead of full-page overlay
|
||||
if (state.virtualScroller?.showGridLoading) {
|
||||
state.virtualScroller.showGridLoading();
|
||||
}
|
||||
|
||||
pageState.isLoading = true;
|
||||
if (resetPage) {
|
||||
@@ -154,7 +158,14 @@ export class BaseModelApiClient {
|
||||
throw error;
|
||||
} finally {
|
||||
pageState.isLoading = false;
|
||||
state.loadingManager.hide();
|
||||
// Wait for the next rAF so refreshWithData's scheduleRender has
|
||||
// completed rendering new cards before hiding the grid loading overlay.
|
||||
// This eliminates the ~6.7ms blank-frame gap that caused the flicker.
|
||||
if (state.virtualScroller?.hideGridLoading) {
|
||||
requestAnimationFrame(() => {
|
||||
state.virtualScroller.hideGridLoading();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1233,6 +1244,48 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchHfRepoFiles(repo, revision = 'main') {
|
||||
try {
|
||||
const params = new URLSearchParams({ repo, revision });
|
||||
const response = await fetch(`${HF_ENDPOINTS.repoFiles}?${params}`);
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to fetch HF repo files');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching HF repo files:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
|
||||
try {
|
||||
const response = await fetch(HF_ENDPOINTS.download, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
repo,
|
||||
filename,
|
||||
revision: revision || 'main',
|
||||
model_root: modelRoot,
|
||||
relative_path: relativePath || '',
|
||||
use_default_paths: useDefaultPaths || false,
|
||||
...(download_id ? { download_id } : {}),
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error downloading HF model:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
_buildQueryParams(baseParams, pageState) {
|
||||
const params = new URLSearchParams(baseParams);
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
@@ -1271,6 +1324,12 @@ export class BaseModelApiClient {
|
||||
|
||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||
|
||||
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (state.global.settings.group_by_model && !vlmModelId) {
|
||||
params.append('group_by_model', 'true');
|
||||
}
|
||||
|
||||
if (!isExcludedView && pageState.filters) {
|
||||
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||
@@ -1352,6 +1411,24 @@ export class BaseModelApiClient {
|
||||
}
|
||||
|
||||
_addModelSpecificParams(params, pageState) {
|
||||
// Check for View Local Versions filter (takes priority over recipe filters)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
const vlmPageType = getSessionItem('vlm_page_type');
|
||||
if (vlmModelId && vlmPageType === this.modelType) {
|
||||
params.append('civitai_model_id', vlmModelId);
|
||||
const vlmBaseModel = getSessionItem('vlm_base_model');
|
||||
if (vlmBaseModel) {
|
||||
params.append('base_model', vlmBaseModel);
|
||||
}
|
||||
return;
|
||||
} else if (vlmModelId && vlmPageType !== this.modelType) {
|
||||
// Stale VLM data from a different page type — clean up
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
}
|
||||
|
||||
if (this.modelType === 'loras') {
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
|
||||
* Add LoRA-specific parameters to query
|
||||
*/
|
||||
_addModelSpecificParams(params, pageState) {
|
||||
// Let parent handle View Local Versions filter first
|
||||
super._addModelSpecificParams(params, pageState);
|
||||
// If VLM filter was applied, skip recipe-specific filters
|
||||
if (params.has('civitai_model_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
|
||||
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
|
||||
|
||||
// Update check indicator for group-by-model
|
||||
if (groupByModelCheck) {
|
||||
const isEnabled = !!state.global.settings.group_by_model;
|
||||
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (isRecipesPage) {
|
||||
modelUpdateItem?.classList.add('hidden');
|
||||
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
downloadExamplesItem?.classList.add('hidden');
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
groupByModelItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
downloadExamplesItem?.classList.remove('hidden');
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
groupByModelItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
case 'manage-excluded-models':
|
||||
this.manageExcludedModels();
|
||||
break;
|
||||
case 'toggle-group-by-model':
|
||||
this.toggleGroupByModel();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
@@ -86,6 +99,30 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
});
|
||||
}
|
||||
|
||||
toggleGroupByModel() {
|
||||
const sm = window.settingsManager;
|
||||
if (!sm) {
|
||||
console.error('settingsManager not available on window');
|
||||
return;
|
||||
}
|
||||
const newValue = !state.global.settings.group_by_model;
|
||||
state.global.settings.group_by_model = newValue;
|
||||
|
||||
// Save/restore sort preference when toggling group_by_model
|
||||
if (window.pageControls?.onGroupByModelToggled) {
|
||||
window.pageControls.onGroupByModelToggled(newValue);
|
||||
}
|
||||
|
||||
sm.saveSetting('group_by_model', newValue).catch((error) => {
|
||||
console.error('Failed to save group_by_model setting:', error);
|
||||
// Revert state on failure
|
||||
state.global.settings.group_by_model = !newValue;
|
||||
});
|
||||
|
||||
sm.applyFrontendSettings();
|
||||
sm.reloadContent();
|
||||
}
|
||||
|
||||
async downloadExampleImages(menuItem) {
|
||||
const downloadPath = state?.global?.settings?.example_images_path;
|
||||
if (!downloadPath) {
|
||||
|
||||
@@ -338,7 +338,6 @@ export class HeaderManager {
|
||||
const headerSearch = document.getElementById('headerSearch');
|
||||
const searchInput = headerSearch?.querySelector('#searchInput');
|
||||
const searchButtons = headerSearch?.querySelectorAll('button');
|
||||
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
|
||||
|
||||
if (this.currentPage === 'statistics' && headerSearch) {
|
||||
headerSearch.classList.add('disabled');
|
||||
@@ -353,7 +352,7 @@ export class HeaderManager {
|
||||
if (searchInput) {
|
||||
searchInput.disabled = false;
|
||||
// Use i18nHelpers to update placeholder
|
||||
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, '');
|
||||
updateElementAttribute(searchInput, 'placeholder', 'header.search.placeholder', {}, '');
|
||||
}
|
||||
searchButtons?.forEach(btn => btn.disabled = false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Recipe Modal Component
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
@@ -40,6 +40,16 @@ const GEN_PARAM_NORMALIZATION = {
|
||||
'Denoising strength': 'denoising_strength',
|
||||
};
|
||||
|
||||
const PARAM_DISPLAY_NAMES = {
|
||||
steps: 'Steps',
|
||||
sampler: 'Sampler',
|
||||
cfg_scale: 'CFG',
|
||||
seed: 'Seed',
|
||||
size: 'Size',
|
||||
clip_skip: 'Clip Skip',
|
||||
denoising_strength: 'Denoising Strength',
|
||||
};
|
||||
|
||||
class RecipeModal {
|
||||
constructor() {
|
||||
this.promptEditorState = {};
|
||||
@@ -588,10 +598,11 @@ class RecipeModal {
|
||||
|
||||
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
||||
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||
const displayName = PARAM_DISPLAY_NAMES[key] || key;
|
||||
const paramTag = document.createElement('div');
|
||||
paramTag.className = 'param-tag';
|
||||
paramTag.innerHTML = `
|
||||
<span class="param-name">${key}:</span>
|
||||
<span class="param-name">${displayName}:</span>
|
||||
<span class="param-value">${value}</span>
|
||||
`;
|
||||
otherParamsElement.appendChild(paramTag);
|
||||
@@ -1200,6 +1211,53 @@ class RecipeModal {
|
||||
this.sendRecipeToWorkflow();
|
||||
});
|
||||
}
|
||||
|
||||
// Send prompt to workflow buttons
|
||||
const sendPromptBtn = document.getElementById('sendPromptBtn');
|
||||
const sendNegativePromptBtn = document.getElementById('sendNegativePromptBtn');
|
||||
|
||||
if (sendPromptBtn) {
|
||||
sendPromptBtn.addEventListener('click', () => {
|
||||
let promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||
if (this.shouldStripLoraOnCopy()) {
|
||||
promptText = RecipeModal.stripLoraTags(promptText);
|
||||
}
|
||||
if (!promptText.trim()) {
|
||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
sendPromptToWorkflow(promptText);
|
||||
});
|
||||
}
|
||||
|
||||
if (sendNegativePromptBtn) {
|
||||
sendNegativePromptBtn.addEventListener('click', () => {
|
||||
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||
if (this.shouldStripLoraOnCopy()) {
|
||||
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
|
||||
}
|
||||
if (!negativePromptText.trim()) {
|
||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
sendPromptToWorkflow(negativePromptText, {
|
||||
actionTypeText: 'Negative Prompt',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Send params to workflow button
|
||||
const sendParamsBtn = document.getElementById('sendParamsBtn');
|
||||
if (sendParamsBtn) {
|
||||
sendParamsBtn.addEventListener('click', () => {
|
||||
const genParams = this.currentRecipe?.gen_params || {};
|
||||
if (!genParams || Object.keys(genParams).length === 0) {
|
||||
showToast('No generation parameters available', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
sendGenParamsToWorkflow(genParams);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1208,14 +1266,7 @@ class RecipeModal {
|
||||
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||
*/
|
||||
static stripLoraTags(text) {
|
||||
return text
|
||||
.replace(/<lora:[^>]*>/gi, '')
|
||||
.replace(/<lora:[^&]*>/gi, '')
|
||||
.replace(/,(\s*,)+/g, ',')
|
||||
.replace(/^,\s*/, '')
|
||||
.replace(/,\s*$/, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
return stripLoraTags(text);
|
||||
}
|
||||
|
||||
shouldStripLoraOnCopy() {
|
||||
|
||||
@@ -95,6 +95,23 @@ export class CheckpointsControls extends PageControls {
|
||||
* Clear checkpoint custom filter and reload
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
this._restoreSortAfterVlm();
|
||||
// Hide the indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
await resetAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
removeSessionItem('recipe_to_checkpoint_filterHash');
|
||||
removeSessionItem('recipe_to_checkpoint_filterHashes');
|
||||
removeSessionItem('filterCheckpointRecipeName');
|
||||
@@ -106,14 +123,4 @@ export class CheckpointsControls extends PageControls {
|
||||
|
||||
await resetAndReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to truncate text with ellipsis
|
||||
* @param {string} text
|
||||
* @param {number} maxLength
|
||||
* @returns {string}
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,22 @@ export class LorasControls extends PageControls {
|
||||
* Clear the custom filter and reload the page
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first (handles VLM and reloads)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
this._restoreSortAfterVlm();
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
await resetAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Clearing custom filter...");
|
||||
// Remove filter parameters from session storage
|
||||
removeSessionItem('recipe_to_lora_filterLoraHash');
|
||||
@@ -134,16 +150,6 @@ export class LorasControls extends PageControls {
|
||||
await resetAndReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to truncate text with ellipsis
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLength - Maximum length before truncating
|
||||
* @returns {string} - Truncated text
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the alphabet bar component
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { sidebarManager } from '../SidebarManager.js';
|
||||
import { initSortDropdown } from './SortDropdown.js';
|
||||
|
||||
/**
|
||||
* PageControls class - Unified control management for model pages
|
||||
@@ -106,6 +107,7 @@ export class PageControls {
|
||||
// Sort select handler
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
initSortDropdown(sortSelect);
|
||||
sortSelect.value = this.pageState.sortBy;
|
||||
sortSelect.addEventListener('change', async (e) => {
|
||||
this.pageState.sortBy = e.target.value;
|
||||
@@ -129,6 +131,9 @@ export class PageControls {
|
||||
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
|
||||
}
|
||||
|
||||
// Check for View Local Versions filter
|
||||
this.checkVlmFilter();
|
||||
|
||||
// Page-specific event listeners
|
||||
this.initPageSpecificListeners();
|
||||
}
|
||||
@@ -311,7 +316,12 @@ export class PageControls {
|
||||
* Load sort preference from storage
|
||||
*/
|
||||
loadSortPreference() {
|
||||
const savedSort = getStorageItem(`${this.pageType}_sort`);
|
||||
// Use separate keys for grouped vs non-grouped sort so each mode
|
||||
// remembers its own preference independently
|
||||
const key = state.global.settings.group_by_model
|
||||
? `${this.pageType}_sort_grouped`
|
||||
: `${this.pageType}_sort`;
|
||||
const savedSort = getStorageItem(key);
|
||||
if (savedSort) {
|
||||
// Handle legacy format conversion
|
||||
const convertedSort = this.convertLegacySortFormat(savedSort);
|
||||
@@ -355,7 +365,11 @@ export class PageControls {
|
||||
};
|
||||
return;
|
||||
}
|
||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
||||
// Separate storage for grouped vs non-grouped sort
|
||||
const key = state.global.settings.group_by_model
|
||||
? `${this.pageType}_sort_grouped`
|
||||
: `${this.pageType}_sort`;
|
||||
setStorageItem(key, sortValue);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,15 +473,211 @@ export class PageControls {
|
||||
this.api.toggleBulkMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom filter
|
||||
*/
|
||||
/**
|
||||
* Dynamically add the VLM sort option (version_id:desc) to the sort dropdown.
|
||||
* It is not a permanent option — only present while VLM is active.
|
||||
*/
|
||||
_addVlmSortOption() {
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (!sortSelect) return;
|
||||
// Only add if not already present
|
||||
if (sortSelect.querySelector('option[value="version_id:desc"]')) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'version_id:desc';
|
||||
opt.textContent = this._t('loras.controls.sort.versionIdDesc', 'Newest version first');
|
||||
sortSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the VLM sort option from the sort dropdown.
|
||||
*/
|
||||
_removeVlmSortOption() {
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (!sortSelect) return;
|
||||
const opt = sortSelect.querySelector('option[value="version_id:desc"]');
|
||||
if (opt) opt.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a translation key via the global i18n helper, falling back to
|
||||
* a plain-text default when the key is missing or i18n is unavailable.
|
||||
*/
|
||||
_t(key, fallback) {
|
||||
if (typeof window.i18n?.t === 'function') {
|
||||
return window.i18n.t(key, { defaultValue: fallback });
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the sort dropdown state after VLM is cleared.
|
||||
* Shared by PageControls.clearCustomFilter() and subclass overrides.
|
||||
*/
|
||||
_restoreSortAfterVlm() {
|
||||
const prevSort = getSessionItem('vlm_prev_sort');
|
||||
removeSessionItem('vlm_prev_sort');
|
||||
const restoredSort = prevSort || 'name:asc';
|
||||
this.pageState.sortBy = restoredSort;
|
||||
this.saveSortPreference(restoredSort);
|
||||
this._removeVlmSortOption();
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = restoredSort;
|
||||
sortSelect.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger View Local Versions without page reload
|
||||
* Sets sessionStorage and reloads data via the API.
|
||||
*/
|
||||
triggerVlmView(modelId, modelName, baseModel, pageType) {
|
||||
const targetPageType = pageType || this.pageType;
|
||||
setSessionItem('vlm_model_id', String(modelId));
|
||||
setSessionItem('vlm_model_name', modelName || String(modelId));
|
||||
setSessionItem('vlm_page_type', targetPageType);
|
||||
if (baseModel) {
|
||||
setSessionItem('vlm_base_model', baseModel);
|
||||
} else {
|
||||
removeSessionItem('vlm_base_model');
|
||||
}
|
||||
// Save current sort preference so it can be restored when VLM is cleared
|
||||
setSessionItem('vlm_prev_sort', this.pageState.sortBy);
|
||||
// Inject the temporary sort option and force version_id:desc
|
||||
this._addVlmSortOption();
|
||||
this.pageState.sortBy = 'version_id:desc';
|
||||
this.saveSortPreference('version_id:desc');
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = 'version_id:desc';
|
||||
sortSelect.disabled = true;
|
||||
}
|
||||
// Reload data via API (no page reload)
|
||||
this.resetAndReload(true).then(() => {
|
||||
// Show the VLM indicator after data loads
|
||||
this.checkVlmFilter();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when group_by_model is toggled.
|
||||
* Swaps between {pageType}_sort (non-group) and {pageType}_sort_grouped,
|
||||
* so each mode remembers its own sort preference independently.
|
||||
*/
|
||||
onGroupByModelToggled(isEnabled) {
|
||||
const groupedKey = `${this.pageType}_sort_grouped`;
|
||||
|
||||
if (isEnabled) {
|
||||
// Entering group mode: restore last-used grouped sort, if any
|
||||
const savedGroupedSort = getStorageItem(groupedKey);
|
||||
if (savedGroupedSort) {
|
||||
this.pageState.sortBy = savedGroupedSort;
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = savedGroupedSort;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Leaving group mode: persist current sort for next time, restore non-group sort
|
||||
setStorageItem(groupedKey, this.pageState.sortBy);
|
||||
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
|
||||
if (savedNormalSort) {
|
||||
this.pageState.sortBy = savedNormalSort;
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = savedNormalSort;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for View Local Versions filter in sessionStorage (page-type-scoped)
|
||||
*/
|
||||
checkVlmFilter() {
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
const vlmPageType = getSessionItem('vlm_page_type');
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
|
||||
// Only show VLM indicator when it belongs to the current page type
|
||||
if (vlmModelId && vlmPageType !== this.pageType) {
|
||||
// Stale VLM data from a different page — clean up
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
removeSessionItem('vlm_prev_sort');
|
||||
this._removeVlmSortOption();
|
||||
if (sortSelect) sortSelect.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const vlmModelName = getSessionItem('vlm_model_name');
|
||||
const vlmBaseModel = getSessionItem('vlm_base_model');
|
||||
|
||||
if (vlmModelId && vlmModelName) {
|
||||
// VLM is active — inject sort option, disable dropdown, show indicator
|
||||
this._addVlmSortOption();
|
||||
if (sortSelect) {
|
||||
sortSelect.value = 'version_id:desc';
|
||||
sortSelect.disabled = true;
|
||||
}
|
||||
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const filterText = indicator?.querySelector('.customFilterText');
|
||||
|
||||
if (indicator && filterText) {
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
const prefix = vlmBaseModel
|
||||
? 'Showing same-base versions from'
|
||||
: 'Showing all versions from';
|
||||
const displayText = `${prefix}: ${vlmModelName}`;
|
||||
|
||||
filterText.textContent = this._truncateText(displayText, 40);
|
||||
filterText.setAttribute('title', displayText);
|
||||
}
|
||||
} else {
|
||||
// No VLM — ensure sort option is removed and dropdown is enabled
|
||||
this._removeVlmSortOption();
|
||||
if (sortSelect) sortSelect.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom filter
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
|
||||
this._restoreSortAfterVlm();
|
||||
|
||||
// Hide the indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Reload data via API (no page reload)
|
||||
await this.resetAndReload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise delegate to subclass for recipe filters
|
||||
if (!this.api) {
|
||||
console.error('API methods not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await this.api.clearCustomFilter();
|
||||
} catch (error) {
|
||||
@@ -475,6 +685,14 @@ export class PageControls {
|
||||
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the favorites filter button state
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// SortDropdown.js — Decoupled sort trigger.
|
||||
//
|
||||
// The native <select> sizes its trigger to the widest <option>, so long
|
||||
// options (e.g. "Fewest versions first") or long i18n translations force the
|
||||
// control to be far wider than the selected text needs. This module wraps the
|
||||
// existing <select> with a custom trigger + menu that mirror its state, so the
|
||||
// trigger sizes to the selected text while the menu sizes to its content.
|
||||
//
|
||||
// The native <select> stays in the DOM (visually hidden) so existing code that
|
||||
// reads/writes `.value` / `.disabled` and dynamically adds/removes <option>s
|
||||
// (e.g. the VLM temporary option) keeps working unchanged. The `value` and
|
||||
// `disabled` setters are overridden on the instance to keep the trigger label
|
||||
// and disabled styling in sync with programmatic changes.
|
||||
//
|
||||
// Keyboard navigation (arrows, Home/End, type-to-select) mirrors native
|
||||
// <select> behavior so the control remains fully accessible.
|
||||
|
||||
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
|
||||
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
|
||||
|
||||
/**
|
||||
* Initialize a decoupled sort dropdown around a native <select>.
|
||||
* Idempotent: safe to call more than once on the same element.
|
||||
* @param {HTMLSelectElement|null} select
|
||||
* @returns {void}
|
||||
*/
|
||||
export function initSortDropdown(select) {
|
||||
if (!select) return;
|
||||
|
||||
const group = select.closest(SORT_GROUP_SELECTOR);
|
||||
if (!group || group.dataset.sortReady === '1') return;
|
||||
|
||||
const trigger = group.querySelector('.sort-trigger');
|
||||
const menu = group.querySelector('.sort-dropdown-menu');
|
||||
const label = group.querySelector('.sort-trigger__label');
|
||||
if (!trigger || !menu || !label) return;
|
||||
|
||||
const getOptions = () => menu.querySelectorAll('.sort-option');
|
||||
|
||||
const buildItem = (opt) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'sort-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.tabIndex = -1;
|
||||
item.dataset.value = opt.value;
|
||||
item.textContent = opt.textContent;
|
||||
item.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
choose(opt.value);
|
||||
close();
|
||||
});
|
||||
return item;
|
||||
};
|
||||
|
||||
const buildMenu = () => {
|
||||
menu.innerHTML = '';
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const child of Array.from(select.children)) {
|
||||
if (child.tagName === 'OPTGROUP') {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'sort-optgroup-label';
|
||||
header.textContent = child.label || '';
|
||||
fragment.appendChild(header);
|
||||
for (const opt of Array.from(child.children)) {
|
||||
fragment.appendChild(buildItem(opt));
|
||||
}
|
||||
} else if (child.tagName === 'OPTION') {
|
||||
fragment.appendChild(buildItem(child));
|
||||
}
|
||||
}
|
||||
menu.appendChild(fragment);
|
||||
syncSelected();
|
||||
};
|
||||
|
||||
const syncSelected = () => {
|
||||
const value = select.value;
|
||||
let labelText = '';
|
||||
let matched = false;
|
||||
getOptions().forEach((el) => {
|
||||
const selected = el.dataset.value === value;
|
||||
el.classList.toggle('is-selected', selected);
|
||||
el.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
if (selected) {
|
||||
labelText = el.textContent;
|
||||
matched = true;
|
||||
}
|
||||
});
|
||||
if (!matched) {
|
||||
const opt = select.querySelector(`option[value="${cssEscape(value)}"]`);
|
||||
labelText = opt
|
||||
? opt.textContent
|
||||
: (select.options[select.selectedIndex]?.textContent ?? '');
|
||||
}
|
||||
label.textContent = labelText;
|
||||
};
|
||||
|
||||
const choose = (value) => {
|
||||
if (select.value === value) return;
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => {
|
||||
if (g !== group) g.classList.remove('active');
|
||||
});
|
||||
group.classList.add('active');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
// Focus the currently selected option (or the first option) so
|
||||
// keyboard navigation starts from a sensible position.
|
||||
requestAnimationFrame(() => {
|
||||
const selected = menu.querySelector('.sort-option.is-selected');
|
||||
(selected || getOptions()[0])?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
group.classList.remove('active');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (group.classList.contains('active')) close();
|
||||
else open();
|
||||
};
|
||||
|
||||
// ---- keyboard navigation ----
|
||||
|
||||
// Type-to-select buffer: accumulate characters and reset after a pause.
|
||||
// Shared between trigger and menu keydown handlers.
|
||||
let typeBuffer = '';
|
||||
let typeTimer = null;
|
||||
|
||||
const focusOptionByText = (prefix) => {
|
||||
const options = getOptions();
|
||||
const lower = prefix.toLowerCase();
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
if (options[i].textContent.toLowerCase().startsWith(lower)) {
|
||||
options[i].focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const moveFocus = (options, direction) => {
|
||||
const focused = menu.querySelector('.sort-option:focus');
|
||||
let idx = focused ? Array.from(options).indexOf(focused) : -1;
|
||||
idx = Math.max(0, Math.min(options.length - 1, idx + direction));
|
||||
options[idx]?.focus();
|
||||
};
|
||||
|
||||
const handleTypeToSelect = (event) => {
|
||||
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
|
||||
event.preventDefault();
|
||||
clearTimeout(typeTimer);
|
||||
typeBuffer += event.key;
|
||||
focusOptionByText(typeBuffer);
|
||||
typeTimer = setTimeout(() => { typeBuffer = ''; }, 800);
|
||||
return true;
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
toggle();
|
||||
});
|
||||
|
||||
trigger.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
|
||||
event.preventDefault();
|
||||
if (!select.disabled) toggle();
|
||||
} else if (!group.classList.contains('active')) {
|
||||
// Type-to-select on closed dropdown: open and highlight match
|
||||
if (handleTypeToSelect(event)) {
|
||||
open();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
menu.addEventListener('keydown', (event) => {
|
||||
const options = getOptions();
|
||||
if (options.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
close();
|
||||
trigger.focus();
|
||||
return;
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
moveFocus(options, 1);
|
||||
return;
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
moveFocus(options, -1);
|
||||
return;
|
||||
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
options[0]?.focus();
|
||||
return;
|
||||
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
options[options.length - 1]?.focus();
|
||||
return;
|
||||
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
if (select.disabled) return;
|
||||
const focused = menu.querySelector('.sort-option:focus');
|
||||
if (focused) {
|
||||
choose(focused.dataset.value);
|
||||
close();
|
||||
trigger.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
handleTypeToSelect(event);
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!group.contains(event.target)) {
|
||||
const wasOpen = group.classList.contains('active');
|
||||
close();
|
||||
// Only return focus to the trigger when the dropdown was actually
|
||||
// open — avoids forcing scrollIntoView on every page click (which
|
||||
// causes the scroll container to jump when clicking a model card).
|
||||
if (wasOpen) trigger.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- property overrides ----
|
||||
|
||||
// Override `value` and `disabled` on this instance so programmatic
|
||||
// changes (loadSortPreference, VLM toggle, excluded-view sync, ...) keep
|
||||
// the trigger label and disabled styling in sync without touching callers.
|
||||
const proto = Object.getPrototypeOf(select);
|
||||
const valueDescriptor =
|
||||
Object.getOwnPropertyDescriptor(proto, 'value') ||
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
|
||||
const disabledDescriptor =
|
||||
Object.getOwnPropertyDescriptor(proto, 'disabled') ||
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'disabled');
|
||||
|
||||
if (valueDescriptor) {
|
||||
Object.defineProperty(select, 'value', {
|
||||
get() { return valueDescriptor.get.call(this); },
|
||||
set(v) {
|
||||
valueDescriptor.set.call(this, v);
|
||||
syncSelected();
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (disabledDescriptor) {
|
||||
Object.defineProperty(select, 'disabled', {
|
||||
get() { return disabledDescriptor.get.call(this); },
|
||||
set(v) {
|
||||
disabledDescriptor.set.call(this, v);
|
||||
group.classList.toggle('is-disabled', Boolean(v));
|
||||
trigger.disabled = Boolean(v);
|
||||
if (v) close();
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
|
||||
// option at runtime).
|
||||
const observer = new MutationObserver(() => buildMenu());
|
||||
observer.observe(select, { childList: true });
|
||||
|
||||
buildMenu();
|
||||
group.dataset.sortReady = '1';
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
// Fallback for environments without CSS.escape
|
||||
return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\]^`{|}~\\ -]/g, '\\$&');
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../../state/index.js';
|
||||
import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
@@ -66,6 +66,8 @@ function handleModelCardEvent_internal(event, modelType) {
|
||||
event.stopPropagation();
|
||||
if (card.dataset.from_civitai === 'true') {
|
||||
openCivitai(card.dataset.filepath);
|
||||
} else if (card.dataset.hf_url) {
|
||||
openHuggingFace(card.dataset.hf_url);
|
||||
}
|
||||
return true; // Stop propagation
|
||||
}
|
||||
@@ -100,6 +102,12 @@ function handleModelCardEvent_internal(event, modelType) {
|
||||
return true; // Stop propagation
|
||||
}
|
||||
|
||||
if (event.target.closest('.version-count-link')) {
|
||||
event.stopPropagation();
|
||||
handleViewLocalVersionsFromCard(card, modelType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||
handleCardClick(card, modelType);
|
||||
return false; // Continue with other handlers (e.g., bulk selection)
|
||||
@@ -265,6 +273,22 @@ async function handleExampleImagesAccess(card, modelType) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewLocalVersionsFromCard(card, modelType) {
|
||||
const modelId = card.dataset.modelId;
|
||||
const modelName = card.dataset.name;
|
||||
if (!modelId) return;
|
||||
// Respect version_grouping: only filter by base model when the strategy says so
|
||||
const strategy = state.global?.settings?.version_grouping;
|
||||
const shouldFilterByBase = strategy === 'same_base';
|
||||
const baseModel = shouldFilterByBase && card.dataset.base_model !== 'Unknown'
|
||||
? card.dataset.base_model
|
||||
: undefined;
|
||||
// Use the no-reload VLM flow via PageControls
|
||||
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
|
||||
window.pageControls.triggerVlmView(modelId, modelName, baseModel, modelType);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(card, modelType) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
@@ -291,6 +315,7 @@ async function showModelModalFromCard(card, modelType) {
|
||||
modified: card.dataset.modified,
|
||||
file_size: parseInt(card.dataset.file_size || '0'),
|
||||
from_civitai: card.dataset.from_civitai === 'true',
|
||||
hf_url: card.dataset.hf_url || '',
|
||||
base_model: card.dataset.base_model,
|
||||
notes: card.dataset.notes || '',
|
||||
favorite: card.dataset.favorite === 'true',
|
||||
@@ -379,6 +404,7 @@ function showExampleAccessModal(card, modelType) {
|
||||
modified: card.dataset.modified,
|
||||
file_size: card.dataset.file_size,
|
||||
from_civitai: card.dataset.from_civitai === 'true',
|
||||
hf_url: card.dataset.hf_url || '',
|
||||
base_model: card.dataset.base_model,
|
||||
notes: card.dataset.notes,
|
||||
favorite: card.dataset.favorite === 'true',
|
||||
@@ -445,9 +471,14 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.base_model = model.base_model || 'Unknown';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||||
card.dataset.hf_url = model.hf_url || '';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
// Store version_count for group-by-model display
|
||||
if (model.version_count !== undefined) {
|
||||
card.dataset.version_count = model.version_count;
|
||||
}
|
||||
|
||||
// To only show usage_count when sorting by usage.
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -552,7 +583,10 @@ export function createModelCard(model, modelType) {
|
||||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||
const globeTitle = model.from_civitai ?
|
||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||
model.hf_url ?
|
||||
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
|
||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||
const globeEnabled = model.from_civitai || !!model.hf_url;
|
||||
let sendTitle;
|
||||
let copyTitle;
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
@@ -577,7 +611,7 @@ export function createModelCard(model, modelType) {
|
||||
</i>
|
||||
<i class="fas fa-globe"
|
||||
title="${globeTitle}"
|
||||
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||
${!globeEnabled ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||
</i>
|
||||
<i class="fas fa-paper-plane"
|
||||
title="${sendTitle}">
|
||||
@@ -659,16 +693,28 @@ export function createModelCard(model, modelType) {
|
||||
const autoTags = model.auto_tags || [];
|
||||
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
||||
const hasVersionName = model.civitai?.name;
|
||||
if (!hlTags.length && !hasVersionName) return '';
|
||||
// When group_by_model is active and model has multiple versions,
|
||||
// show clickable version count instead of version name (and hide badges)
|
||||
const isGroupByModel = state.global.settings.group_by_model;
|
||||
const versionCount = model.version_count;
|
||||
const showVersionCount = isGroupByModel && versionCount > 1;
|
||||
if (!hlTags.length && !hasVersionName && !showVersionCount) return '';
|
||||
const density = state.global.settings.display_density || 'default';
|
||||
const shortLabels = density === 'medium' || density === 'compact';
|
||||
const badges = hlTags.map(t => {
|
||||
// Don't show HIGH/LOW badges when showing version count (confusing in grouped mode)
|
||||
const badges = !showVersionCount ? hlTags.map(t => {
|
||||
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
|
||||
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
||||
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
||||
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
||||
}).join('');
|
||||
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
|
||||
}).join('') : '';
|
||||
let versionHtml = '';
|
||||
if (showVersionCount) {
|
||||
const countLabel = translate('modelCard.footer.versionCount', { count: versionCount }, `${versionCount} versions`);
|
||||
versionHtml = `<span class="version-count-link" title="${translate('modelCard.footer.viewAllVersions', {}, 'View all local versions')}">${countLabel}</span>`;
|
||||
} else if (hasVersionName) {
|
||||
versionHtml = `<span class="version-name civitai-version">${model.civitai.name}</span>`;
|
||||
}
|
||||
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
|
||||
})()}
|
||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Handles model metadata editing functionality - General version
|
||||
*/
|
||||
|
||||
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
|
||||
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
|
||||
@@ -267,6 +267,7 @@ export function setupBaseModelEditing(filePath) {
|
||||
|
||||
// Add options from BASE_MODEL_CATEGORIES constants
|
||||
const baseModelCategories = BASE_MODEL_CATEGORIES;
|
||||
const categorizedModels = new Set();
|
||||
|
||||
// Create option groups for better organization
|
||||
Object.entries(baseModelCategories).forEach(([category, models]) => {
|
||||
@@ -277,13 +278,30 @@ export function setupBaseModelEditing(filePath) {
|
||||
const option = document.createElement('option');
|
||||
option.value = model;
|
||||
option.textContent = model;
|
||||
option.selected = model === currentValue;
|
||||
if (model === currentValue) option.selected = true;
|
||||
categorizedModels.add(model);
|
||||
group.appendChild(option);
|
||||
});
|
||||
|
||||
dropdown.appendChild(group);
|
||||
});
|
||||
|
||||
// Check for dynamic base models from API that aren't in any category
|
||||
const mergedModels = getMergedBaseModels();
|
||||
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
|
||||
if (uncategorizedModels.length > 0) {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = 'Other (API)';
|
||||
uncategorizedModels.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model;
|
||||
option.textContent = model;
|
||||
if (model === currentValue) option.selected = true;
|
||||
group.appendChild(option);
|
||||
});
|
||||
dropdown.appendChild(group);
|
||||
}
|
||||
|
||||
// Replace content with dropdown
|
||||
baseModelContent.style.display = 'none';
|
||||
baseModelDisplay.insertBefore(dropdown, editBtn);
|
||||
|
||||
@@ -360,6 +360,11 @@ export async function showModelModal(model, modelType) {
|
||||
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||
</div>`.trim() : '';
|
||||
const escapedHfUrl = modelWithFullData.hf_url ? escapeAttribute(modelWithFullData.hf_url) : '';
|
||||
const viewOnHuggingFaceAction = escapedHfUrl ? `
|
||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnHuggingFace', {}, 'View on Hugging Face')}" data-action="view-huggingface" data-hf-url="${escapedHfUrl}">
|
||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnHuggingFaceText', {}, 'View on Hugging Face')}
|
||||
</div>`.trim() : '';
|
||||
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
|
||||
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
||||
@@ -377,6 +382,9 @@ export async function showModelModal(model, modelType) {
|
||||
if (viewOnCivitaiAction) {
|
||||
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
|
||||
}
|
||||
if (viewOnHuggingFaceAction) {
|
||||
creatorActionItems.push(indentMarkup(viewOnHuggingFaceAction, 24));
|
||||
}
|
||||
if (creatorInfoAction) {
|
||||
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
|
||||
}
|
||||
@@ -752,6 +760,7 @@ export async function showModelModal(model, modelType) {
|
||||
modelId: civitaiModelId,
|
||||
currentVersionId: civitaiVersionId,
|
||||
currentBaseModel: modelWithFullData.base_model,
|
||||
modelName: model.model_name,
|
||||
onUpdateStatusChange: handleUpdateStatusChange,
|
||||
});
|
||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||
@@ -868,6 +877,11 @@ function setupEventHandlers(filePath, modelType) {
|
||||
case 'view-civitai':
|
||||
openCivitai(target.dataset.filepath);
|
||||
break;
|
||||
case 'view-huggingface':
|
||||
if (target.dataset.hfUrl) {
|
||||
window.open(target.dataset.hfUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
break;
|
||||
case 'view-creator':
|
||||
const username = target.dataset.username;
|
||||
if (username) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from './utils.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||
@@ -306,7 +307,7 @@ function getToggleTooltipText(mode) {
|
||||
}
|
||||
|
||||
function getDefaultDisplayMode() {
|
||||
const strategy = state?.global?.settings?.update_flag_strategy;
|
||||
const strategy = state?.global?.settings?.version_grouping;
|
||||
return strategy === DISPLAY_FILTER_MODES.SAME_BASE
|
||||
? DISPLAY_FILTER_MODES.SAME_BASE
|
||||
: DISPLAY_FILTER_MODES.ANY;
|
||||
@@ -338,7 +339,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const strategy = state?.global?.settings?.update_flag_strategy;
|
||||
const strategy = state?.global?.settings?.version_grouping;
|
||||
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
|
||||
|
||||
@@ -744,7 +745,7 @@ function renderToolbar(record, toolbarState = {}) {
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
|
||||
${escapeHtml(ignoreText)}
|
||||
</button>
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled>
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Show all local versions of this model on the main page'))}">
|
||||
${escapeHtml(viewLocalText)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -792,6 +793,7 @@ export function initVersionsTab({
|
||||
modelId,
|
||||
currentVersionId,
|
||||
currentBaseModel,
|
||||
modelName,
|
||||
onUpdateStatusChange,
|
||||
}) {
|
||||
const pane = document.querySelector(`#${modalId} #versions-tab`);
|
||||
@@ -1019,6 +1021,39 @@ export function initVersionsTab({
|
||||
render(controller.record);
|
||||
}
|
||||
|
||||
function handleViewLocalVersions() {
|
||||
if (!controller.record || !modelId) {
|
||||
return;
|
||||
}
|
||||
// Determine base model filter based on current display mode
|
||||
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
|
||||
const isFilteringActive =
|
||||
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
|
||||
Boolean(baseModelInfo.normalized);
|
||||
|
||||
// Write filter params to sessionStorage (page-scoped)
|
||||
setSessionItem('vlm_model_id', String(modelId));
|
||||
setSessionItem('vlm_model_name', modelName || String(modelId));
|
||||
setSessionItem('vlm_page_type', modelType);
|
||||
if (isFilteringActive) {
|
||||
// Use raw (non-normalized) base model for exact backend matching
|
||||
setSessionItem('vlm_base_model', baseModelInfo.raw);
|
||||
} else {
|
||||
removeSessionItem('vlm_base_model');
|
||||
}
|
||||
|
||||
// Close the modal and navigate via no-reload VLM flow
|
||||
modalManager.closeModal(modalId);
|
||||
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
|
||||
window.pageControls.triggerVlmView(
|
||||
modelId,
|
||||
modelName || String(modelId),
|
||||
isFilteringActive ? baseModelInfo.raw : undefined,
|
||||
modelType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleVersionIgnore(button, versionId) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
@@ -1348,6 +1383,10 @@ export function initVersionsTab({
|
||||
event.preventDefault();
|
||||
handleToggleVersionDisplayMode();
|
||||
break;
|
||||
case 'view-local':
|
||||
event.preventDefault();
|
||||
handleViewLocalVersions();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Media-specific utility functions for showcase components
|
||||
* (Moved from uiHelpers.js to better organize code)
|
||||
*/
|
||||
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, getNSFWLevelName, sendPromptToWorkflow, stripLoraTags, sendGenParamsToWorkflow } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
@@ -318,6 +318,74 @@ export function initMetadataPanelHandlers(container) {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle send prompt buttons
|
||||
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
|
||||
sendBtns.forEach(sendBtn => {
|
||||
const promptIndex = sendBtn.dataset.promptIndex;
|
||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
sendBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
let promptText = promptElement.textContent || '';
|
||||
if (!promptText.trim()) {
|
||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect strip <lora> setting from global state
|
||||
if (state.global.settings?.strip_lora_on_copy) {
|
||||
promptText = stripLoraTags(promptText);
|
||||
}
|
||||
|
||||
sendPromptToWorkflow(promptText);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle send params buttons
|
||||
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
|
||||
if (paramsBtn) {
|
||||
paramsBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Collect gen params from the param-tag elements
|
||||
const tagsContainer = wrapper.querySelector('.params-tags');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
const paramTags = tagsContainer.querySelectorAll('.param-tag');
|
||||
const genParams = {};
|
||||
|
||||
// Map display labels to genParams keys
|
||||
const labelToKey = {
|
||||
'Seed': 'seed',
|
||||
'Steps': 'steps',
|
||||
'Sampler': 'sampler',
|
||||
'CFG': 'cfg_scale',
|
||||
};
|
||||
|
||||
paramTags.forEach(tag => {
|
||||
const nameEl = tag.querySelector('.param-name');
|
||||
const valueEl = tag.querySelector('.param-value');
|
||||
if (!nameEl || !valueEl) return;
|
||||
|
||||
const label = nameEl.textContent.replace(':', '').trim();
|
||||
const key = labelToKey[label];
|
||||
if (key) {
|
||||
genParams[key] = valueEl.textContent.trim();
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(genParams).length === 0) {
|
||||
showToast('No sendable parameters found', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendGenParamsToWorkflow(genParams);
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent panel scroll from causing modal scroll
|
||||
metadataPanel.addEventListener('wheel', (e) => {
|
||||
const isAtTop = metadataPanel.scrollTop === 0;
|
||||
|
||||
@@ -28,14 +28,24 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
|
||||
if (hasParams) {
|
||||
content += `
|
||||
<div class="params-tags">
|
||||
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
|
||||
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
|
||||
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
|
||||
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
|
||||
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
|
||||
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
|
||||
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
|
||||
<div class="metadata-row params-row">
|
||||
<div class="param-header">
|
||||
<span class="metadata-label">Params:</span>
|
||||
<div class="param-actions">
|
||||
<button class="send-params-btn" title="Send Params to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="params-tags">
|
||||
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
|
||||
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
|
||||
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
|
||||
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
|
||||
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
|
||||
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
|
||||
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -53,12 +63,19 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
prompt = escapeHtml(prompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Prompt:</span>
|
||||
<div class="param-header">
|
||||
<span class="metadata-label">Prompt:</span>
|
||||
<div class="param-actions">
|
||||
<button class="send-prompt-btn" data-prompt-index="${promptIndex}" title="Send Prompt to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}" title="Copy Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-prompt-wrapper">
|
||||
<div class="metadata-prompt">${prompt}</div>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
|
||||
@@ -69,12 +86,19 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
negativePrompt = escapeHtml(negativePrompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Negative Prompt:</span>
|
||||
<div class="param-header">
|
||||
<span class="metadata-label">Negative Prompt:</span>
|
||||
<div class="param-actions">
|
||||
<button class="send-prompt-btn" data-prompt-index="${negPromptIndex}" title="Send Negative Prompt to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}" title="Copy Negative Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-prompt-wrapper">
|
||||
<div class="metadata-prompt">${negativePrompt}</div>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ export class BulkManager {
|
||||
this.isMarqueeActive = false;
|
||||
this.isDragging = false;
|
||||
this.marqueeStart = { x: 0, y: 0 };
|
||||
this.marqueeStartDoc = { x: 0, y: 0 }; // Marquee start in document coordinates
|
||||
this.marqueeElement = null;
|
||||
this.initialSelectedModels = new Set();
|
||||
|
||||
@@ -29,6 +30,11 @@ export class BulkManager {
|
||||
this.mouseDownTime = 0;
|
||||
this.mouseDownPosition = { x: 0, y: 0 };
|
||||
|
||||
// Auto-scroll properties for marquee
|
||||
this.lastClientX = 0;
|
||||
this.lastClientY = 0;
|
||||
this.autoScrollRaf = null;
|
||||
|
||||
// Model type specific action configurations
|
||||
this.actionConfig = {
|
||||
[MODEL_TYPES.LORA]: {
|
||||
@@ -168,7 +174,10 @@ export class BulkManager {
|
||||
|
||||
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
||||
if (this.isMarqueeActive) {
|
||||
this.lastClientX = e.clientX;
|
||||
this.lastClientY = e.clientY;
|
||||
this.updateMarqueeSelection(e);
|
||||
this.startAutoScroll();
|
||||
} else if (this.mouseDownTime && !this.isDragging) {
|
||||
// Check if we've moved enough to consider it a drag
|
||||
const dx = e.clientX - this.mouseDownPosition.x;
|
||||
@@ -237,6 +246,7 @@ export class BulkManager {
|
||||
* Clean up event handlers
|
||||
*/
|
||||
cleanup() {
|
||||
this.stopAutoScroll();
|
||||
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
|
||||
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
|
||||
eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
|
||||
@@ -1727,10 +1737,15 @@ export class BulkManager {
|
||||
* @param {boolean} isDragging - Whether this is triggered from a drag operation
|
||||
*/
|
||||
startMarqueeSelection(e, isDragging = false) {
|
||||
// Store initial mouse position
|
||||
// Store initial mouse position (viewport coordinates for visual element)
|
||||
this.marqueeStart.x = this.mouseDownPosition.x;
|
||||
this.marqueeStart.y = this.mouseDownPosition.y;
|
||||
|
||||
// Store initial mouse position in document coordinates (for logical selection)
|
||||
const container = document.querySelector('.page-content');
|
||||
this.marqueeStartDoc.x = this.mouseDownPosition.x + (container?.scrollLeft || 0);
|
||||
this.marqueeStartDoc.y = this.mouseDownPosition.y + (container?.scrollTop || 0);
|
||||
|
||||
// Store initial selection state
|
||||
this.initialSelectedModels = new Set(state.selectedModels);
|
||||
|
||||
@@ -1776,46 +1791,67 @@ export class BulkManager {
|
||||
*/
|
||||
updateMarqueeSelection(e) {
|
||||
if (!this.marqueeElement) return;
|
||||
|
||||
const currentX = e.clientX;
|
||||
const currentY = e.clientY;
|
||||
|
||||
// Calculate rectangle bounds
|
||||
const left = Math.min(this.marqueeStart.x, currentX);
|
||||
const top = Math.min(this.marqueeStart.y, currentY);
|
||||
const width = Math.abs(currentX - this.marqueeStart.x);
|
||||
const height = Math.abs(currentY - this.marqueeStart.y);
|
||||
|
||||
// Update marquee element position and size
|
||||
this.marqueeElement.style.left = left + 'px';
|
||||
this.marqueeElement.style.top = top + 'px';
|
||||
this.marqueeElement.style.width = width + 'px';
|
||||
this.marqueeElement.style.height = height + 'px';
|
||||
|
||||
// Check which cards intersect with marquee
|
||||
this.updateCardSelection(left, top, left + width, top + height);
|
||||
this.updateMarqueeSelectionFromPosition(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update card selection based on marquee bounds
|
||||
* Update marquee from raw client coordinates (used by both mousemove and auto-scroll loop)
|
||||
*/
|
||||
updateCardSelection(left, top, right, bottom) {
|
||||
const cards = document.querySelectorAll('.model-card');
|
||||
updateMarqueeSelectionFromPosition(clientX, clientY) {
|
||||
if (!this.marqueeElement) return;
|
||||
|
||||
const container = document.querySelector('.page-content');
|
||||
const scrollX = container?.scrollLeft || 0;
|
||||
const scrollY = container?.scrollTop || 0;
|
||||
|
||||
// Current position in document coordinates
|
||||
const currentDocX = clientX + scrollX;
|
||||
const currentDocY = clientY + scrollY;
|
||||
|
||||
// Calculate marquee rectangle in document coordinates
|
||||
const docLeft = Math.min(this.marqueeStartDoc.x, currentDocX);
|
||||
const docTop = Math.min(this.marqueeStartDoc.y, currentDocY);
|
||||
const docRight = Math.max(this.marqueeStartDoc.x, currentDocX);
|
||||
const docBottom = Math.max(this.marqueeStartDoc.y, currentDocY);
|
||||
|
||||
// Update visual marquee element (position: fixed, so subtract scroll offset)
|
||||
this.marqueeElement.style.left = (docLeft - scrollX) + 'px';
|
||||
this.marqueeElement.style.top = (docTop - scrollY) + 'px';
|
||||
this.marqueeElement.style.width = (docRight - docLeft) + 'px';
|
||||
this.marqueeElement.style.height = (docBottom - docTop) + 'px';
|
||||
|
||||
// Check which cards intersect with marquee
|
||||
this.updateCardSelection(docLeft, docTop, docRight, docBottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update card selection based on marquee bounds (document coordinates).
|
||||
* Uses dual detection: DOM cards for visible ones + VirtualScroller layout for off-screen cards.
|
||||
*/
|
||||
updateCardSelection(docLeft, docTop, docRight, docBottom) {
|
||||
const vs = state.virtualScroller;
|
||||
const container = document.querySelector('.page-content');
|
||||
const scrollX = container?.scrollLeft || 0;
|
||||
const scrollY = container?.scrollTop || 0;
|
||||
const newSelection = new Set(this.initialSelectedModels);
|
||||
const visibleFilepaths = new Set();
|
||||
|
||||
cards.forEach(card => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
|
||||
// Check if card intersects with marquee rectangle
|
||||
const intersects = !(rect.right < left ||
|
||||
rect.left > right ||
|
||||
rect.bottom < top ||
|
||||
rect.top > bottom);
|
||||
|
||||
// Step 1: Process visible DOM cards using getBoundingClientRect + scroll offset
|
||||
document.querySelectorAll('.model-card').forEach(card => {
|
||||
const filepath = card.dataset.filepath;
|
||||
if (!filepath) return;
|
||||
visibleFilepaths.add(filepath);
|
||||
|
||||
const rect = card.getBoundingClientRect();
|
||||
const cardLeft = rect.left + scrollX;
|
||||
const cardTop = rect.top + scrollY;
|
||||
const cardRight = rect.right + scrollX;
|
||||
const cardBottom = rect.bottom + scrollY;
|
||||
|
||||
const intersects = !(cardRight < docLeft || cardLeft > docRight ||
|
||||
cardBottom < docTop || cardTop > docBottom);
|
||||
|
||||
if (intersects) {
|
||||
// Add to selection if intersecting
|
||||
newSelection.add(filepath);
|
||||
card.classList.add('selected');
|
||||
|
||||
@@ -1825,12 +1861,43 @@ export class BulkManager {
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
} else if (!this.initialSelectedModels.has(filepath)) {
|
||||
// Remove from selection if not intersecting and wasn't initially selected
|
||||
newSelection.delete(filepath);
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
|
||||
// Step 2: Process off-screen cards via VirtualScroller layout calculation.
|
||||
// Since VirtualScroller removes off-screen DOM elements, we compute
|
||||
// each card's position from its index and the VS layout parameters.
|
||||
if (vs?.gridElement && vs.items && vs.columnsCount > 0) {
|
||||
const gridRect = vs.gridElement.getBoundingClientRect();
|
||||
// Grid origin in scroll-container content coordinates
|
||||
const originX = gridRect.left + scrollX;
|
||||
const originY = gridRect.top + scrollY;
|
||||
|
||||
for (let i = 0; i < vs.items.length; i++) {
|
||||
const filepath = vs.items[i]?.file_path;
|
||||
if (!filepath || visibleFilepaths.has(filepath)) continue;
|
||||
|
||||
const row = Math.floor(i / vs.columnsCount);
|
||||
const col = i % vs.columnsCount;
|
||||
|
||||
const cLeft = originX + col * (vs.itemWidth + vs.columnGap);
|
||||
const cTop = originY + (vs.containerPaddingTop || 0) + row * (vs.itemHeight + (vs.rowGap || 0));
|
||||
const cRight = cLeft + vs.itemWidth;
|
||||
const cBottom = cTop + vs.itemHeight;
|
||||
|
||||
const intersects = !(cRight < docLeft || cLeft > docRight ||
|
||||
cBottom < docTop || cTop > docBottom);
|
||||
|
||||
if (intersects) {
|
||||
newSelection.add(filepath);
|
||||
} else if (!this.initialSelectedModels.has(filepath)) {
|
||||
newSelection.delete(filepath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update global selection state
|
||||
state.selectedModels = newSelection;
|
||||
|
||||
@@ -1849,6 +1916,9 @@ export class BulkManager {
|
||||
this.isDragging = false;
|
||||
this.mouseDownTime = 0;
|
||||
|
||||
// Stop any active auto-scroll
|
||||
this.stopAutoScroll();
|
||||
|
||||
// Update event manager state
|
||||
eventManager.setState('marqueeActive', false);
|
||||
|
||||
@@ -1874,6 +1944,79 @@ export class BulkManager {
|
||||
// Clear initial selection state
|
||||
this.initialSelectedModels.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-scroll loop when mouse approaches viewport edge during marquee
|
||||
*/
|
||||
startAutoScroll() {
|
||||
if (this.autoScrollRaf) return;
|
||||
this.autoScrollLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-scroll loop
|
||||
*/
|
||||
stopAutoScroll() {
|
||||
if (this.autoScrollRaf) {
|
||||
cancelAnimationFrame(this.autoScrollRaf);
|
||||
this.autoScrollRaf = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-scroll loop: scrolls the page when mouse is near viewport edges
|
||||
* and re-evaluates marquee selection after each scroll.
|
||||
*/
|
||||
autoScrollLoop() {
|
||||
if (!this.isMarqueeActive) {
|
||||
this.autoScrollRaf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.querySelector('.page-content');
|
||||
if (!container) {
|
||||
this.autoScrollRaf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const MARGIN = 30; // Px from edge to trigger scroll
|
||||
const BASE_SPEED = 12; // Pixels per frame at edge boundary
|
||||
const MAX_SPEED = 40; // Maximum scroll speed
|
||||
const rect = container.getBoundingClientRect();
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
// Vertical auto-scroll - speed increases the further the cursor is past the edge
|
||||
if (this.lastClientY !== undefined) {
|
||||
if (this.lastClientY < rect.top + MARGIN) {
|
||||
const dist = Math.max(0, (rect.top + MARGIN) - this.lastClientY);
|
||||
dy = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
|
||||
} else if (this.lastClientY > rect.bottom - MARGIN) {
|
||||
const dist = Math.max(0, this.lastClientY - (rect.bottom - MARGIN));
|
||||
dy = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal auto-scroll
|
||||
if (this.lastClientX !== undefined) {
|
||||
if (this.lastClientX < rect.left + MARGIN) {
|
||||
const dist = Math.max(0, (rect.left + MARGIN) - this.lastClientX);
|
||||
dx = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
|
||||
} else if (this.lastClientX > rect.right - MARGIN) {
|
||||
const dist = Math.max(0, this.lastClientX - (rect.right - MARGIN));
|
||||
dx = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
|
||||
}
|
||||
}
|
||||
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
container.scrollBy(dx, dy);
|
||||
// Re-evaluate marquee selection with the new scroll position
|
||||
this.updateMarqueeSelectionFromPosition(this.lastClientX, this.lastClientY);
|
||||
this.autoScrollRaf = requestAnimationFrame(() => this.autoScrollLoop());
|
||||
} else {
|
||||
this.autoScrollRaf = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const bulkManager = new BulkManager();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from '../utils/formatters.js';
|
||||
|
||||
export class DownloadManager {
|
||||
constructor() {
|
||||
@@ -27,6 +28,10 @@ export class DownloadManager {
|
||||
this.isBatchMode = false;
|
||||
this.editingBatchIndex = -1;
|
||||
|
||||
// HF download state
|
||||
this.hfRepoId = null;
|
||||
this.hfSelectedFiles = [];
|
||||
|
||||
this.loadingManager = new LoadingManager();
|
||||
this.folderTreeManager = new FolderTreeManager();
|
||||
this.folderClickHandler = null;
|
||||
@@ -44,6 +49,8 @@ export class DownloadManager {
|
||||
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
||||
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
|
||||
this.handleNextFromBatch = this.nextFromBatch.bind(this);
|
||||
|
||||
|
||||
}
|
||||
|
||||
showDownloadModal() {
|
||||
@@ -99,6 +106,8 @@ export class DownloadManager {
|
||||
|
||||
// Default path toggle handler
|
||||
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
||||
|
||||
|
||||
}
|
||||
|
||||
updateModalLabels() {
|
||||
@@ -160,6 +169,10 @@ export class DownloadManager {
|
||||
|
||||
// Reset default path toggle
|
||||
this.loadDefaultPathSetting();
|
||||
|
||||
// Reset HF state
|
||||
this.hfRepoId = null;
|
||||
this.hfSelectedFiles = [];
|
||||
}
|
||||
|
||||
async retrieveVersionsForModel(modelId, source = null) {
|
||||
@@ -180,6 +193,29 @@ export class DownloadManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect URL types — all URLs must share the same source type
|
||||
const urlTypes = urls.map(u => DownloadManager.detectUrlType(u));
|
||||
const isHf = urlTypes.every(t => t && (t.type === 'hf-resolve' || t.type === 'hf-repo'));
|
||||
const isCivitai = urlTypes.every(t => t && t.type === 'civitai');
|
||||
|
||||
if (!isHf && !isCivitai) {
|
||||
const allValid = urlTypes.every(t => t !== null);
|
||||
if (!allValid) {
|
||||
errorElement.textContent = translate('modals.download.errors.invalidUrl');
|
||||
return;
|
||||
}
|
||||
// Mixed sources not supported in one batch
|
||||
if (urls.length > 1) {
|
||||
errorElement.textContent = translate('modals.download.errors.mixedSources');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isHf) {
|
||||
return this._validateAndFetchHf(urls, errorElement);
|
||||
}
|
||||
|
||||
// --- Original CivitAI flow below ---
|
||||
if (urls.length === 1) {
|
||||
this.isBatchMode = false;
|
||||
try {
|
||||
@@ -271,6 +307,112 @@ export class DownloadManager {
|
||||
this.showBatchPreviewStep();
|
||||
}
|
||||
|
||||
// ---- Hugging Face download flow ----
|
||||
|
||||
async _validateAndFetchHf(urls, errorElement) {
|
||||
if (urls.length === 1) {
|
||||
const info = DownloadManager.detectUrlType(urls[0]);
|
||||
// Direct file resolve URL → skip file selection, go to location
|
||||
if (info.type === 'hf-resolve') {
|
||||
this.isBatchMode = false;
|
||||
this.hfRepoId = info.repo;
|
||||
this.hfSelectedFiles = [info.filename];
|
||||
this.source = 'huggingface';
|
||||
this.proceedToLocation();
|
||||
return;
|
||||
}
|
||||
// Repo URL → fetch file list and convert to batch items
|
||||
try {
|
||||
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
|
||||
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error(translate('modals.download.errors.noModelFiles'));
|
||||
}
|
||||
this.isBatchMode = true;
|
||||
this.batchModels = [];
|
||||
this.source = 'huggingface';
|
||||
for (const file of files) {
|
||||
this.batchModels.push({
|
||||
url: urls[0],
|
||||
source: 'huggingface',
|
||||
repo: info.repo,
|
||||
filename: file.filename,
|
||||
revision: 'main',
|
||||
displayName: file.filename,
|
||||
fileSizeBytes: file.size,
|
||||
selectedVersion: true,
|
||||
versions: [],
|
||||
checked: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
this.showBatchPreviewStep();
|
||||
} catch (err) {
|
||||
errorElement.textContent = err.message;
|
||||
} finally {
|
||||
this.loadingManager.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple HF URLs → batch mode: flatten all files from all repos
|
||||
this.isBatchMode = true;
|
||||
this.batchModels = [];
|
||||
this.source = 'huggingface';
|
||||
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
|
||||
|
||||
for (const url of urls) {
|
||||
const info = DownloadManager.detectUrlType(url);
|
||||
if (!info) {
|
||||
this.batchModels.push({ url, error: 'Invalid URL', versions: [], selectedVersion: null });
|
||||
continue;
|
||||
}
|
||||
if (info.type === 'hf-resolve') {
|
||||
this.batchModels.push({
|
||||
url,
|
||||
source: 'huggingface',
|
||||
repo: info.repo,
|
||||
filename: info.filename,
|
||||
revision: info.revision || 'main',
|
||||
displayName: info.filename,
|
||||
selectedVersion: true,
|
||||
versions: [],
|
||||
checked: false,
|
||||
error: null,
|
||||
});
|
||||
} else if (info.type === 'hf-repo') {
|
||||
try {
|
||||
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
|
||||
if (!files || files.length === 0) {
|
||||
this.batchModels.push({ url, error: 'No model files found', versions: [], selectedVersion: null });
|
||||
continue;
|
||||
}
|
||||
// Flatten: create one batch item per file, all checked by default
|
||||
for (const file of files) {
|
||||
this.batchModels.push({
|
||||
url,
|
||||
source: 'huggingface',
|
||||
repo: info.repo,
|
||||
filename: file.filename,
|
||||
revision: 'main',
|
||||
displayName: file.filename,
|
||||
fileSizeBytes: file.size,
|
||||
selectedVersion: true,
|
||||
versions: [],
|
||||
checked: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.batchModels.push({ url, error: err.message, versions: [], selectedVersion: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.loadingManager.hide();
|
||||
this.showBatchPreviewStep();
|
||||
}
|
||||
|
||||
async fetchVersionsForCurrentModel() {
|
||||
const errorElement = document.getElementById('urlError');
|
||||
if (errorElement) {
|
||||
@@ -311,6 +453,60 @@ export class DownloadManager {
|
||||
return { modelId: null, modelVersionId: null, source: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the source type of a download URL.
|
||||
* @param {string} url
|
||||
* @returns {{ type: string, repo?: string, filename?: string, revision?: string } | null}
|
||||
* type: 'civitai' | 'civarchive' | 'hf-resolve' | 'hf-repo' | 'direct-http'
|
||||
*/
|
||||
static detectUrlType(url) {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// CivitAI
|
||||
if (/civitai\.com\/models\//i.test(trimmed) || /civitaiarchive|civarchive/i.test(trimmed)) {
|
||||
// Will be parsed by existing CivitAI logic
|
||||
return { type: 'civitai' };
|
||||
}
|
||||
|
||||
// Hugging Face resolve URL → direct file
|
||||
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
|
||||
if (hfResolveMatch) {
|
||||
return {
|
||||
type: 'hf-resolve',
|
||||
repo: hfResolveMatch[1],
|
||||
revision: hfResolveMatch[2],
|
||||
filename: hfResolveMatch[3],
|
||||
};
|
||||
}
|
||||
|
||||
// Hugging Face repo URL (huggingface.co/user/repo or bare user/repo path)
|
||||
// Require huggingface.co prefix for full URLs; bare user/repo only without ://
|
||||
const hfRepoMatch = trimmed.match(
|
||||
trimmed.includes('://')
|
||||
? /^https?:\/\/huggingface\.co\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\/?$|$)/
|
||||
: /^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)$/
|
||||
);
|
||||
if (hfRepoMatch) {
|
||||
// Reject path-traversal patterns like "../.." or "user/.."
|
||||
const parts = hfRepoMatch[1].split('/');
|
||||
if (parts.some(p => p === '.' || p === '..')) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'hf-repo',
|
||||
repo: hfRepoMatch[1],
|
||||
};
|
||||
}
|
||||
|
||||
// Direct HTTP(S) URL (non-HF)
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return { type: 'direct-http' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
extractModelId(url) {
|
||||
const result = DownloadManager.parseModelUrl(url);
|
||||
this.modelVersionId = result.modelVersionId;
|
||||
@@ -351,7 +547,7 @@ export class DownloadManager {
|
||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||
|
||||
// Count model-type files per version
|
||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
|
||||
const fileSize = version.modelSizeKB ?
|
||||
(version.modelSizeKB / 1024).toFixed(2) :
|
||||
@@ -478,7 +674,7 @@ export class DownloadManager {
|
||||
if (!version) return;
|
||||
|
||||
this.currentVersion = version;
|
||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||
|
||||
document.getElementById('versionStep').style.display = 'none';
|
||||
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||
@@ -534,7 +730,7 @@ export class DownloadManager {
|
||||
const version = this.currentVersion;
|
||||
if (!version) return;
|
||||
|
||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||
|
||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||
@@ -559,8 +755,8 @@ export class DownloadManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// In single-URL mode, validate version selection
|
||||
if (!this.isBatchMode) {
|
||||
// In single-URL mode, validate version selection (skip for HF)
|
||||
if (!this.isBatchMode && this.source !== 'huggingface') {
|
||||
if (!this.currentVersion) {
|
||||
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
||||
return;
|
||||
@@ -784,6 +980,77 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
this.loadingManager.restoreProgressBar();
|
||||
const totalFiles = this.hfSelectedFiles.length;
|
||||
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
||||
|
||||
try {
|
||||
let completedDownloads = 0;
|
||||
for (let i = 0; i < totalFiles; i++) {
|
||||
const filename = this.hfSelectedFiles[i];
|
||||
updateProgress(0, completedDownloads, filename);
|
||||
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
||||
|
||||
const downloadId = Date.now().toString() + '_' + i;
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = resolve;
|
||||
ws.onerror = reject;
|
||||
});
|
||||
|
||||
// Capture completed count at WS creation time so progress
|
||||
// updates arriving after completedDownloads increments still
|
||||
// show the correct "N / total" position.
|
||||
const snapshotCompleted = completedDownloads;
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'progress') {
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
totalBytes: data.total_bytes,
|
||||
bytesPerSecond: data.bytes_per_second,
|
||||
};
|
||||
updateProgress(data.progress, snapshotCompleted, filename, metrics);
|
||||
}
|
||||
};
|
||||
|
||||
const response = await this.apiClient.downloadHfModel({
|
||||
repo: this.hfRepoId,
|
||||
filename,
|
||||
revision: 'main',
|
||||
modelRoot,
|
||||
relativePath: targetFolder,
|
||||
useDefaultPaths,
|
||||
download_id: downloadId,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, filename);
|
||||
}
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
// Reload page data — model is already in scanner cache via backend
|
||||
await resetAndReload(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to download HF model:', error);
|
||||
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
this.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
updatePathSelectionUI() {
|
||||
const manualSelection = document.getElementById('manualPathSelection');
|
||||
|
||||
@@ -812,13 +1079,19 @@ export class DownloadManager {
|
||||
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
|
||||
document.getElementById('batchPreviewStep').style.display = 'block';
|
||||
|
||||
const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length;
|
||||
const validCount = this.batchModels.filter(m => {
|
||||
if (m.error) return false;
|
||||
if (m.source === 'huggingface') return m.checked !== false;
|
||||
return m.selectedVersion;
|
||||
}).length;
|
||||
document.getElementById('downloadModalTitle').textContent =
|
||||
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
||||
` (${validCount})`;
|
||||
|
||||
const list = document.getElementById('batchPreviewList');
|
||||
list.innerHTML = this.batchModels.map((item, index) => {
|
||||
const hasHfItems = this.batchModels.some(m => m.source === 'huggingface' && !m.error);
|
||||
|
||||
let itemsHtml = this.batchModels.map((item, index) => {
|
||||
if (item.error) {
|
||||
return `
|
||||
<div class="batch-preview-item batch-preview-error" data-index="${index}">
|
||||
@@ -837,6 +1110,30 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
const ver = item.selectedVersion;
|
||||
|
||||
// HF batch item rendering with checkbox
|
||||
if (item.source === 'huggingface') {
|
||||
const hfSize = item.fileSizeBytes
|
||||
? formatFileSize(item.fileSizeBytes)
|
||||
: '?';
|
||||
return `
|
||||
<div class="batch-preview-item" data-index="${index}">
|
||||
<input type="checkbox" class="batch-preview-checkbox"
|
||||
data-index="${index}" ${item.checked !== false ? 'checked' : ''} />
|
||||
<div class="batch-preview-info">
|
||||
<div class="batch-preview-name">${item.displayName || item.filename || `HF #${index}`} <span class="hf-badge">HF</span></div>
|
||||
<div class="batch-preview-meta">
|
||||
<span>${hfSize}</span>
|
||||
<span>${item.repo || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
|
||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||
const fileSize = ver?.modelSizeKB
|
||||
@@ -866,6 +1163,21 @@ export class DownloadManager {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Prepend select-all toolbar if there are HF items with checkboxes
|
||||
if (hasHfItems) {
|
||||
const allChecked = this.batchModels
|
||||
.filter(m => m.source === 'huggingface' && !m.error)
|
||||
.every(m => m.checked !== false);
|
||||
itemsHtml = `
|
||||
<div class="batch-preview-select-all">
|
||||
<input type="checkbox" id="batchSelectAll" ${allChecked ? 'checked' : ''} />
|
||||
<label for="batchSelectAll">${translate('modals.download.selectAll', {}, 'Select All')}</label>
|
||||
</div>
|
||||
` + itemsHtml;
|
||||
}
|
||||
|
||||
list.innerHTML = itemsHtml;
|
||||
|
||||
list.onclick = (e) => {
|
||||
const removeBtn = e.target.closest('.batch-preview-remove');
|
||||
if (removeBtn) {
|
||||
@@ -881,6 +1193,59 @@ export class DownloadManager {
|
||||
}
|
||||
};
|
||||
|
||||
// Checkbox handler for HF batch items
|
||||
const checkboxes = list.querySelectorAll('.batch-preview-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.addEventListener('change', (e) => {
|
||||
const idx = parseInt(e.target.dataset.index);
|
||||
if (this.batchModels[idx]) {
|
||||
this.batchModels[idx].checked = e.target.checked;
|
||||
}
|
||||
// Update valid count in title and Next button
|
||||
const checkedCount = this.batchModels.filter(
|
||||
m => !m.error && m.checked !== false
|
||||
).length;
|
||||
document.getElementById('downloadModalTitle').textContent =
|
||||
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
||||
` (${checkedCount})`;
|
||||
const nextBtn = document.getElementById('nextFromBatchBtn');
|
||||
nextBtn.disabled = checkedCount === 0;
|
||||
nextBtn.classList.toggle('disabled', checkedCount === 0);
|
||||
// Update select-all checkbox state
|
||||
const selectAll = document.getElementById('batchSelectAll');
|
||||
if (selectAll) {
|
||||
const hfItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error);
|
||||
selectAll.checked = hfItems.length > 0 && hfItems.every(m => m.checked !== false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Select-all handler
|
||||
const selectAll = document.getElementById('batchSelectAll');
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', (e) => {
|
||||
const checked = e.target.checked;
|
||||
const hfCheckboxes = list.querySelectorAll('.batch-preview-checkbox');
|
||||
hfCheckboxes.forEach(cb => {
|
||||
cb.checked = checked;
|
||||
const idx = parseInt(cb.dataset.index);
|
||||
if (this.batchModels[idx]) {
|
||||
this.batchModels[idx].checked = checked;
|
||||
}
|
||||
});
|
||||
// Update valid count in title and Next button
|
||||
const checkedCount = this.batchModels.filter(
|
||||
m => !m.error && m.checked !== false
|
||||
).length;
|
||||
document.getElementById('downloadModalTitle').textContent =
|
||||
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
||||
` (${checkedCount})`;
|
||||
const nextBtn = document.getElementById('nextFromBatchBtn');
|
||||
nextBtn.disabled = checkedCount === 0;
|
||||
nextBtn.classList.toggle('disabled', checkedCount === 0);
|
||||
});
|
||||
}
|
||||
|
||||
const nextBtn = document.getElementById('nextFromBatchBtn');
|
||||
nextBtn.disabled = validCount === 0;
|
||||
nextBtn.classList.toggle('disabled', validCount === 0);
|
||||
@@ -903,7 +1268,12 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
nextFromBatch() {
|
||||
const validModels = this.batchModels.filter(m => !m.error && m.selectedVersion);
|
||||
// For HF items, respect the checked flag; for CivitAI items, use selectedVersion
|
||||
const validModels = this.batchModels.filter(m => {
|
||||
if (m.error) return false;
|
||||
if (m.source === 'huggingface') return m.checked !== false;
|
||||
return m.selectedVersion;
|
||||
});
|
||||
if (validModels.length === 0) return;
|
||||
this.proceedToLocation();
|
||||
}
|
||||
@@ -953,8 +1323,17 @@ export class DownloadManager {
|
||||
targetFolder = this.folderTreeManager.getSelectedPath();
|
||||
}
|
||||
if (!this.isBatchMode) {
|
||||
// Single-item download
|
||||
if (this.source === 'huggingface') {
|
||||
return this._downloadHfSingle({
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
});
|
||||
}
|
||||
|
||||
const fileParams = this.selectedFile ? {
|
||||
type: 'Model',
|
||||
type: this.selectedFile.type || 'Model',
|
||||
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
||||
size: this.selectedFile.metadata?.size || 'full',
|
||||
fp: this.selectedFile.metadata?.fp,
|
||||
@@ -974,7 +1353,13 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
// Batch download mode
|
||||
const downloadItems = this.batchModels.filter(m => !m.error && m.selectedVersion && !m.selectedVersion.existsLocally);
|
||||
const downloadItems = this.batchModels.filter(m => {
|
||||
if (m.error) return false;
|
||||
if (!m.selectedVersion) return false;
|
||||
// HF items have selectedVersion as a boolean marker + checked flag
|
||||
if (m.source === 'huggingface') return m.checked !== false;
|
||||
return !m.selectedVersion.existsLocally;
|
||||
});
|
||||
if (downloadItems.length === 0) {
|
||||
showToast('toast.loras.downloadCompleted', {}, 'info');
|
||||
modalManager.closeModal('downloadModal');
|
||||
@@ -999,7 +1384,7 @@ export class DownloadManager {
|
||||
|
||||
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
|
||||
const current = downloadItems[completedDownloads + failedDownloads];
|
||||
const name = current?.selectedVersion?.name || `#${completedDownloads + failedDownloads + 1}`;
|
||||
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
totalBytes: data.total_bytes,
|
||||
@@ -1016,22 +1401,59 @@ export class DownloadManager {
|
||||
|
||||
for (let i = 0; i < downloadItems.length; i++) {
|
||||
const item = downloadItems[i];
|
||||
const ver = item.selectedVersion;
|
||||
const name = ver?.name || `Model #${item.modelId}`;
|
||||
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
|
||||
const isHf = item.source === 'huggingface';
|
||||
|
||||
updateProgress(0, completedDownloads, name);
|
||||
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
|
||||
|
||||
try {
|
||||
const response = await this.apiClient.downloadModel(
|
||||
item.modelId,
|
||||
ver.id,
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
batchDownloadId,
|
||||
item.source
|
||||
);
|
||||
let response;
|
||||
if (isHf) {
|
||||
// Per-file WebSocket for real-time progress
|
||||
const downloadId = Date.now().toString() + '_hf_' + i;
|
||||
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
wsHf.onopen = resolve;
|
||||
wsHf.onerror = reject;
|
||||
});
|
||||
const snapshotCompleted = completedDownloads;
|
||||
wsHf.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'progress') {
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
totalBytes: data.total_bytes,
|
||||
bytesPerSecond: data.bytes_per_second,
|
||||
};
|
||||
updateProgress(data.progress, snapshotCompleted, name, metrics);
|
||||
}
|
||||
};
|
||||
|
||||
response = await this.apiClient.downloadHfModel({
|
||||
repo: item.repo,
|
||||
filename: item.filename,
|
||||
revision: item.revision || 'main',
|
||||
modelRoot,
|
||||
relativePath: targetFolder,
|
||||
useDefaultPaths,
|
||||
download_id: downloadId,
|
||||
});
|
||||
} finally {
|
||||
wsHf.close();
|
||||
}
|
||||
} else {
|
||||
response = await this.apiClient.downloadModel(
|
||||
item.modelId,
|
||||
item.selectedVersion.id,
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
batchDownloadId,
|
||||
item.source
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
failedDownloads++;
|
||||
|
||||
@@ -27,6 +27,9 @@ export class SearchManager {
|
||||
// Create clear button for search input
|
||||
this.createClearButton();
|
||||
|
||||
// Keyboard shortcut cue element (static, exists in the HTML)
|
||||
this.searchShortcutCue = document.getElementById('searchShortcutCue');
|
||||
|
||||
this.initEventListeners();
|
||||
this.loadSearchPreferences();
|
||||
this.setupKeyboardShortcuts();
|
||||
@@ -163,8 +166,13 @@ export class SearchManager {
|
||||
}
|
||||
|
||||
updateClearButtonVisibility() {
|
||||
const hasText = this.searchInput.value.length > 0;
|
||||
if (this.clearButton) {
|
||||
this.clearButton.classList.toggle('visible', this.searchInput.value.length > 0);
|
||||
this.clearButton.classList.toggle('visible', hasText);
|
||||
}
|
||||
// Toggle the keyboard shortcut cue: visible only when search is empty
|
||||
if (this.searchShortcutCue) {
|
||||
this.searchShortcutCue.classList.toggle('hidden', hasText);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -905,15 +905,21 @@ export class SettingsManager {
|
||||
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
|
||||
}
|
||||
|
||||
// Set group by model
|
||||
const groupByModelCheckbox = document.getElementById('groupByModel');
|
||||
if (groupByModelCheckbox) {
|
||||
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
|
||||
}
|
||||
|
||||
// Set model name display setting
|
||||
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
||||
if (modelNameDisplaySelect) {
|
||||
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
|
||||
}
|
||||
|
||||
const updateFlagStrategySelect = document.getElementById('updateFlagStrategy');
|
||||
if (updateFlagStrategySelect) {
|
||||
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
|
||||
const versionGroupingSelect = document.getElementById('versionGrouping');
|
||||
if (versionGroupingSelect) {
|
||||
versionGroupingSelect.value = state.global.settings.version_grouping || 'same_base';
|
||||
}
|
||||
|
||||
// Set hide early access updates setting
|
||||
@@ -2011,7 +2017,11 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') {
|
||||
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content' || settingKey === 'group_by_model') {
|
||||
// Save/restore sort preference when toggling group_by_model
|
||||
if (settingKey === 'group_by_model' && window.pageControls?.onGroupByModelToggled) {
|
||||
window.pageControls.onGroupByModelToggled(value);
|
||||
}
|
||||
this.reloadContent();
|
||||
}
|
||||
|
||||
@@ -2060,7 +2070,7 @@ export class SettingsManager {
|
||||
if (
|
||||
settingKey === 'model_name_display'
|
||||
|| settingKey === 'model_card_footer_action'
|
||||
|| settingKey === 'update_flag_strategy'
|
||||
|| settingKey === 'version_grouping'
|
||||
|| settingKey === 'mature_blur_level'
|
||||
) {
|
||||
this.reloadContent();
|
||||
@@ -3046,6 +3056,10 @@ export class SettingsManager {
|
||||
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
|
||||
document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
|
||||
|
||||
// Apply group-by-model mode
|
||||
const groupByModel = !!state.global.settings.group_by_model;
|
||||
document.body.classList.toggle('group-by-model', groupByModel);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,9 +57,16 @@ export class DownloadManager {
|
||||
base_model: this.importManager.recipeData.base_model || "",
|
||||
loras: this.importManager.recipeData.loras || [],
|
||||
gen_params: this.importManager.recipeData.gen_params || {},
|
||||
raw_metadata: this.importManager.recipeData.raw_metadata || {}
|
||||
raw_metadata: this.importManager.recipeData.raw_metadata || {},
|
||||
};
|
||||
|
||||
// Preserve preview_nsfw_level from analysis so the saved
|
||||
// recipe applies the correct NSFW blur on the preview image.
|
||||
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
|
||||
if (nsfwLevel !== undefined && nsfwLevel !== null) {
|
||||
completeMetadata.preview_nsfw_level = nsfwLevel;
|
||||
}
|
||||
|
||||
const checkpointMetadata =
|
||||
this.importManager.recipeData.checkpoint ||
|
||||
this.importManager.recipeData.model ||
|
||||
|
||||
+17
-4
@@ -4,12 +4,13 @@ import { ImportManager } from './managers/ImportManager.js';
|
||||
import { BatchImportManager } from './managers/BatchImportManager.js';
|
||||
import { RecipeModal } from './components/RecipeModal.js';
|
||||
import { state, getCurrentPageState } from './state/index.js';
|
||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||
import { sidebarManager } from './components/SidebarManager.js';
|
||||
import { initSortDropdown } from './components/controls/SortDropdown.js';
|
||||
|
||||
class RecipePageControls {
|
||||
constructor() {
|
||||
@@ -149,9 +150,10 @@ class RecipeManager {
|
||||
|
||||
_showCustomFilterIndicator() {
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const textElement = document.getElementById('customFilterText');
|
||||
if (!indicator) return;
|
||||
const textElement = indicator.querySelector('.customFilterText');
|
||||
|
||||
if (!indicator || !textElement) return;
|
||||
if (!textElement) return;
|
||||
|
||||
// Update text based on filter type
|
||||
let filterText = '';
|
||||
@@ -235,12 +237,18 @@ class RecipeManager {
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// Sort select
|
||||
// Sort select — load saved preference, persist on change
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
const savedSort = getStorageItem('recipes_sort');
|
||||
if (savedSort) {
|
||||
this.pageState.sortBy = savedSort;
|
||||
}
|
||||
initSortDropdown(sortSelect);
|
||||
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
||||
sortSelect.addEventListener('change', () => {
|
||||
this.pageState.sortBy = sortSelect.value;
|
||||
setStorageItem('recipes_sort', sortSelect.value);
|
||||
refreshVirtualScroll();
|
||||
});
|
||||
}
|
||||
@@ -250,6 +258,11 @@ class RecipeManager {
|
||||
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
|
||||
}
|
||||
|
||||
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
|
||||
if (duplicatesButton) {
|
||||
duplicatesButton.addEventListener('click', () => this.findDuplicateRecipes());
|
||||
}
|
||||
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
if (favoriteFilterBtn) {
|
||||
favoriteFilterBtn.addEventListener('click', () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
include_trigger_words: false,
|
||||
compact_mode: false,
|
||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||
update_flag_strategy: 'same_base',
|
||||
version_grouping: 'same_base',
|
||||
hide_early_access_updates: false,
|
||||
auto_organize_exclusions: [],
|
||||
metadata_refresh_skip_paths: [],
|
||||
@@ -54,6 +54,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
backup_retention_count: 5,
|
||||
strip_lora_on_copy: false,
|
||||
use_new_license_icons: true,
|
||||
group_by_model: false,
|
||||
});
|
||||
|
||||
export function createDefaultSettings() {
|
||||
|
||||
@@ -657,6 +657,9 @@ export class VirtualScroller {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
// Remove any active grid loading overlay
|
||||
this.hideGridLoading();
|
||||
|
||||
// Remove rendered elements
|
||||
this.clearRenderedItems();
|
||||
|
||||
@@ -1130,4 +1133,30 @@ export class VirtualScroller {
|
||||
index: targetIndex
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a grid-scoped loading indicator (replaces full-page overlay)
|
||||
* Only covers the card grid area, leaving header/sidebar unaffected.
|
||||
*/
|
||||
showGridLoading() {
|
||||
// Remove any stale overlay from a prior deferred hide (e.g. from final rAF)
|
||||
this.hideGridLoading();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'grid-loading-overlay';
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'loading-spinner';
|
||||
overlay.appendChild(spinner);
|
||||
this.gridElement.appendChild(overlay);
|
||||
this.gridLoadingOverlay = overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the grid-scoped loading indicator.
|
||||
*/
|
||||
hideGridLoading() {
|
||||
if (this.gridLoadingOverlay) {
|
||||
this.gridLoadingOverlay.remove();
|
||||
this.gridLoadingOverlay = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const BASE_MODELS = {
|
||||
ERNIE_TURBO: "Ernie Turbo",
|
||||
NUCLEUS: "Nucleus",
|
||||
PONY_V7: "Pony V7",
|
||||
KREA_2: "Krea 2",
|
||||
// Default
|
||||
UNKNOWN: "Other"
|
||||
};
|
||||
@@ -197,6 +198,7 @@ export const BASE_MODEL_ABBREVIATIONS = {
|
||||
[BASE_MODELS.ERNIE]: 'ERNI',
|
||||
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
|
||||
[BASE_MODELS.NUCLEUS]: 'NUCL',
|
||||
[BASE_MODELS.KREA_2]: 'KR2',
|
||||
|
||||
// Default
|
||||
[BASE_MODELS.UNKNOWN]: 'OTH'
|
||||
@@ -401,6 +403,7 @@ export const BASE_MODEL_CATEGORIES = {
|
||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
||||
BASE_MODELS.ERNIE, BASE_MODELS.ERNIE_TURBO, BASE_MODELS.NUCLEUS,
|
||||
BASE_MODELS.KREA_2,
|
||||
BASE_MODELS.UNKNOWN
|
||||
]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* genParamsMapper.js
|
||||
* Maps display/recipe generation parameter values (sampler, scheduler) to
|
||||
* ComfyUI internal widget values, enabling "Send Gen Params to Workflow".
|
||||
*
|
||||
* Strategy (3 layers):
|
||||
* 1. Direct lookup via SAMPLER_DISPLAY_TO_INTERNAL
|
||||
* 2. Combined-name parsing (e.g. "Euler a Karras" → sampler + scheduler)
|
||||
* 3. Graceful skip for model-specific / unrecognized values
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sampler display name → internal name (ComfyUI KSampler.SAMPLERS / SAMPLER_NAMES)
|
||||
// ---------------------------------------------------------------------------
|
||||
const SAMPLER_DISPLAY_TO_INTERNAL = {
|
||||
// --- Euler family ---
|
||||
'Euler': 'euler',
|
||||
'euler': 'euler',
|
||||
'Euler a': 'euler_ancestral',
|
||||
'Euler A': 'euler_ancestral',
|
||||
'Euler ancestral': 'euler_ancestral',
|
||||
'Euler Ancestral': 'euler_ancestral',
|
||||
'euler_ancestral': 'euler_ancestral',
|
||||
|
||||
// --- Heun ---
|
||||
'Heun': 'heun',
|
||||
'heun': 'heun',
|
||||
'Heun++': 'heunpp2',
|
||||
'heunpp2': 'heunpp2',
|
||||
|
||||
// --- DPM2 ---
|
||||
'DPM2': 'dpm_2',
|
||||
'DPM 2': 'dpm_2',
|
||||
'dpm_2': 'dpm_2',
|
||||
'DPM2 a': 'dpm_2_ancestral',
|
||||
'DPM2 Ancestral': 'dpm_2_ancestral',
|
||||
'dpm_2_ancestral': 'dpm_2_ancestral',
|
||||
|
||||
// --- LMS ---
|
||||
'LMS': 'lms',
|
||||
'lms': 'lms',
|
||||
|
||||
// --- DPM fast / adaptive ---
|
||||
'DPM fast': 'dpm_fast',
|
||||
'DPM Fast': 'dpm_fast',
|
||||
'dpm_fast': 'dpm_fast',
|
||||
'DPM adaptive': 'dpm_adaptive',
|
||||
'DPM Adaptive': 'dpm_adaptive',
|
||||
'dpm_adaptive': 'dpm_adaptive',
|
||||
|
||||
// --- DPM++ 2S ancestral ---
|
||||
'DPM++ 2S a': 'dpmpp_2s_ancestral',
|
||||
'DPM++ 2S A': 'dpmpp_2s_ancestral',
|
||||
'DPM++ 2S Ancestral': 'dpmpp_2s_ancestral',
|
||||
'dpmpp_2s_ancestral': 'dpmpp_2s_ancestral',
|
||||
|
||||
// --- DPM++ SDE ---
|
||||
'DPM++ SDE': 'dpmpp_sde',
|
||||
'dpmpp_sde': 'dpmpp_sde',
|
||||
|
||||
// --- DPM++ 2M ---
|
||||
'DPM++ 2M': 'dpmpp_2m',
|
||||
'dpmpp_2m': 'dpmpp_2m',
|
||||
|
||||
// --- DPM++ 2M SDE ---
|
||||
'DPM++ 2M SDE': 'dpmpp_2m_sde',
|
||||
'dpmpp_2m_sde': 'dpmpp_2m_sde',
|
||||
|
||||
// --- DPM++ 3M SDE ---
|
||||
'DPM++ 3M SDE': 'dpmpp_3m_sde',
|
||||
'dpmpp_3m_sde': 'dpmpp_3m_sde',
|
||||
|
||||
// --- Others ---
|
||||
'DDIM': 'ddim',
|
||||
'ddim': 'ddim',
|
||||
'DDPM': 'ddpm',
|
||||
'ddpm': 'ddpm',
|
||||
'LCM': 'lcm',
|
||||
'lcm': 'lcm',
|
||||
'IPNDM': 'ipndm',
|
||||
'ipndm': 'ipndm',
|
||||
'DEIS': 'deis',
|
||||
'deis': 'deis',
|
||||
'UniPC': 'uni_pc',
|
||||
'unipc': 'uni_pc',
|
||||
'uni_pc': 'uni_pc',
|
||||
|
||||
// --- Restart / res_multistep ---
|
||||
'Restart': 'res_multistep',
|
||||
'res_multistep': 'res_multistep',
|
||||
|
||||
// --- ER SDE ---
|
||||
'ER SDE': 'er_sde',
|
||||
'E-R SDE': 'er_sde',
|
||||
'er_sde': 'er_sde',
|
||||
|
||||
// --- SA Solver ---
|
||||
'SA Solver': 'sa_solver',
|
||||
'SA solver': 'sa_solver',
|
||||
'sa_solver': 'sa_solver',
|
||||
|
||||
// --- Seeds ---
|
||||
'Seeds 2': 'seeds_2',
|
||||
'seeds_2': 'seeds_2',
|
||||
'Seeds 3': 'seeds_3',
|
||||
'seeds_3': 'seeds_3',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known scheduler suffixes (ComfyUI KSampler.SCHEDULERS)
|
||||
// Sorted by length (descending) for longest-match-first parsing.
|
||||
// ---------------------------------------------------------------------------
|
||||
const SCHEDULER_SUFFIXES = [
|
||||
'sgm_uniform',
|
||||
'ddim_uniform',
|
||||
'linear_quadratic',
|
||||
'kl_optimal',
|
||||
'exponential',
|
||||
'karras',
|
||||
'simple',
|
||||
'normal',
|
||||
'beta',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler-only values (values that are schedulers, not samplers)
|
||||
// ---------------------------------------------------------------------------
|
||||
const SCHEDULER_ONLY_VALUES = new Set([
|
||||
'simple', 'sgm_uniform', 'karras', 'exponential',
|
||||
'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Param key → widget name candidates (searched in order)
|
||||
// ---------------------------------------------------------------------------
|
||||
const PARAM_TO_WIDGET_CANDIDATES = {
|
||||
seed: ['seed', 'noise_seed'],
|
||||
steps: ['steps'],
|
||||
cfg: ['cfg'],
|
||||
sampler: ['sampler_name', 'sampler'],
|
||||
scheduler: ['scheduler'],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse a combined sampler+scheduler value (space-separated or underscore)
|
||||
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
|
||||
// Returns { sampler: internalName|null, scheduler: internalName|null } or null
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseCombinedSamplerName(rawValue) {
|
||||
if (!rawValue || typeof rawValue !== 'string') return null;
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Try space-separated first: split on last space
|
||||
const spaceIdx = trimmed.lastIndexOf(' ');
|
||||
if (spaceIdx > 0) {
|
||||
const candidateScheduler = trimmed.slice(spaceIdx + 1).trim().toLowerCase();
|
||||
if (SCHEDULER_SUFFIXES.includes(candidateScheduler)) {
|
||||
const samplerPart = trimmed.slice(0, spaceIdx).trim();
|
||||
const internalSampler = SAMPLER_DISPLAY_TO_INTERNAL[samplerPart];
|
||||
if (internalSampler) {
|
||||
return { sampler: internalSampler, scheduler: candidateScheduler };
|
||||
}
|
||||
// samplerPart might be a combined name itself (e.g., "DPM++ 2M SDE")
|
||||
// Try recursing (one level max) — already handled since we split at last space
|
||||
}
|
||||
}
|
||||
|
||||
// Try underscore-separated: e.g., "er_sde_beta"
|
||||
const underIdx = trimmed.lastIndexOf('_');
|
||||
if (underIdx > 0) {
|
||||
const candidateScheduler = trimmed.slice(underIdx + 1).trim().toLowerCase();
|
||||
if (SCHEDULER_SUFFIXES.includes(candidateScheduler)) {
|
||||
const samplerPart = trimmed.slice(0, underIdx).trim();
|
||||
const internalSampler = SAMPLER_DISPLAY_TO_INTERNAL[samplerPart] || SAMPLER_DISPLAY_TO_INTERNAL[samplerPart.toLowerCase()];
|
||||
if (internalSampler) {
|
||||
return { sampler: internalSampler, scheduler: candidateScheduler };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main resolver: takes a raw sampler value from recipe/showcase metadata
|
||||
// and returns { sampler: internalName|null, scheduler: internalName|null }
|
||||
// ---------------------------------------------------------------------------
|
||||
function resolveSamplerScheduler(rawValue) {
|
||||
if (!rawValue || typeof rawValue !== 'string') {
|
||||
return { sampler: null, scheduler: null };
|
||||
}
|
||||
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) return { sampler: null, scheduler: null };
|
||||
|
||||
// 1. Try direct lookup first
|
||||
const direct = SAMPLER_DISPLAY_TO_INTERNAL[trimmed];
|
||||
if (direct) return { sampler: direct, scheduler: null };
|
||||
|
||||
// 2. Try lowercase direct lookup
|
||||
const lowerDirect = SAMPLER_DISPLAY_TO_INTERNAL[trimmed.toLowerCase()];
|
||||
if (lowerDirect) return { sampler: lowerDirect, scheduler: null };
|
||||
|
||||
// 3. Scheduler-only value? (check BEFORE the "already internal name" regex,
|
||||
// because scheduler values like "karras", "simple" also match that pattern)
|
||||
if (SCHEDULER_ONLY_VALUES.has(trimmed.toLowerCase())) {
|
||||
return { sampler: null, scheduler: trimmed.toLowerCase() };
|
||||
}
|
||||
|
||||
// 4. Already an internal name? (lowercase, no spaces)
|
||||
if (/^[a-z][a-z0-9_]+$/.test(trimmed)) {
|
||||
return { sampler: trimmed, scheduler: null };
|
||||
}
|
||||
|
||||
// 5. Try combined name parsing (space-separated or underscore)
|
||||
const combined = parseCombinedSamplerName(trimmed);
|
||||
if (combined) return combined;
|
||||
|
||||
// 6. Custom format like "multistep/dpmpp_2m_simple" — try extracting the last segment
|
||||
if (trimmed.includes('/')) {
|
||||
const parts = trimmed.split('/');
|
||||
const last = parts[parts.length - 1];
|
||||
if (last) {
|
||||
const subResult = resolveSamplerScheduler(last);
|
||||
if (subResult.sampler || subResult.scheduler) return subResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Unrecognized — return null for both
|
||||
return { sampler: null, scheduler: null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find which gen params can be sent to a given node, matching by widget names
|
||||
// Returns array of { widgetName, value } objects
|
||||
// ---------------------------------------------------------------------------
|
||||
function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
||||
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
|
||||
const updates = [];
|
||||
|
||||
// Simple numeric/string params: seed, steps, cfg
|
||||
const simpleParams = [
|
||||
{ key: 'seed', value: resolvedParams.seed },
|
||||
{ key: 'steps', value: resolvedParams.steps },
|
||||
{ key: 'cfg', value: resolvedParams.cfg },
|
||||
];
|
||||
for (const { key, value } of simpleParams) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
|
||||
for (const candidate of candidates) {
|
||||
if (widgetSet.has(candidate.toLowerCase())) {
|
||||
updates.push({ widgetName: candidate, value: String(value) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sampler
|
||||
if (resolvedParams.sampler) {
|
||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler;
|
||||
for (const candidate of candidates) {
|
||||
if (widgetSet.has(candidate.toLowerCase())) {
|
||||
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduler
|
||||
if (resolvedParams.scheduler) {
|
||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler;
|
||||
for (const candidate of candidates) {
|
||||
if (widgetSet.has(candidate.toLowerCase())) {
|
||||
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
export {
|
||||
SAMPLER_DISPLAY_TO_INTERNAL,
|
||||
SCHEDULER_SUFFIXES,
|
||||
SCHEDULER_ONLY_VALUES,
|
||||
PARAM_TO_WIDGET_CANDIDATES,
|
||||
parseCombinedSamplerName,
|
||||
resolveSamplerScheduler,
|
||||
findMatchingWidgets,
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { eventManager } from './EventManager.js';
|
||||
import { bannerService } from '../managers/BannerService.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
|
||||
import { resolveSamplerScheduler, findMatchingWidgets } from './genParamsMapper.js';
|
||||
|
||||
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
|
||||
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
|
||||
@@ -318,6 +319,15 @@ export function openCivitai(filePath) {
|
||||
openCivitaiByMetadata(civitaiId, versionId, modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a Hugging Face model page in a new tab
|
||||
* @param {string} hfUrl - The Hugging Face URL
|
||||
*/
|
||||
export function openHuggingFace(hfUrl) {
|
||||
if (!hfUrl) return;
|
||||
window.open(hfUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically positions the search options panel and filter panel
|
||||
* based on the current layout and folder tags container height
|
||||
@@ -518,6 +528,22 @@ export function copyLoraSyntax(card) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
|
||||
* Handles both unescaped (<lora:...>) and HTML-escaped (<lora:...>) variants.
|
||||
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||
*/
|
||||
export function stripLoraTags(text) {
|
||||
return text
|
||||
.replace(/<lora:[^>]*>/gi, '')
|
||||
.replace(/<lora:[^&]*>/gi, '')
|
||||
.replace(/,(\s*,)+/g, ',')
|
||||
.replace(/^,\s*/, '')
|
||||
.replace(/,\s*$/, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function fetchWorkflowRegistry() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/get-registry');
|
||||
@@ -840,11 +866,12 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
||||
successMessage = 'Updated workflow node',
|
||||
failureMessage = 'Failed to update workflow node',
|
||||
missingTargetMessage = 'No target node selected',
|
||||
silent = false,
|
||||
} = messages;
|
||||
|
||||
const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
|
||||
if (targetIds.length === 0) {
|
||||
showToast(missingTargetMessage, {}, 'warning');
|
||||
if (!silent) showToast(missingTargetMessage, {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -853,7 +880,7 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
||||
.filter((reference) => reference && reference.node_id !== undefined);
|
||||
|
||||
if (references.length === 0) {
|
||||
showToast(missingTargetMessage, {}, 'warning');
|
||||
if (!silent) showToast(missingTargetMessage, {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -872,16 +899,16 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast(successMessage, {}, 'success');
|
||||
if (!silent) showToast(successMessage, {}, 'success');
|
||||
return true;
|
||||
}
|
||||
|
||||
const errorMessage = result?.error || failureMessage;
|
||||
showToast(errorMessage, {}, 'error');
|
||||
if (!silent) showToast(errorMessage, {}, 'error');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to send widget value to workflow:', error);
|
||||
showToast(failureMessage, {}, 'error');
|
||||
if (!silent) showToast(failureMessage, {}, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -915,7 +942,7 @@ async function sendTextToNodes(nodeIds, nodesMap, text, mode, messages = {}) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
widget_name: 'text',
|
||||
action: 'inject_text',
|
||||
value: text,
|
||||
mode: mode || 'append',
|
||||
node_ids: references,
|
||||
@@ -948,7 +975,10 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
|
||||
if (!isNodeEnabled(node)) {
|
||||
return false;
|
||||
}
|
||||
return node.capabilities?.has_text_widget === true;
|
||||
return (
|
||||
node.capabilities?.has_text_widget === true ||
|
||||
node.marker_role === "send_prompt_target"
|
||||
);
|
||||
});
|
||||
|
||||
const nodeKeys = Object.keys(textNodes);
|
||||
@@ -980,6 +1010,184 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt text to workflow text-capable nodes (replaces existing content).
|
||||
* Uses the same target node discovery as sendEmbeddingToWorkflow.
|
||||
* @param {string} promptText - The prompt/negative prompt text to send
|
||||
* @param {Object} [options] - Optional messages overrides
|
||||
* @param {string} [options.actionTypeText] - Label for the action type (default "Prompt")
|
||||
* @param {string} [options.successMessage] - Success toast message
|
||||
* @param {string} [options.failureMessage] - Failure toast message
|
||||
* @param {string} [options.missingNodesMessage] - No nodes warning message
|
||||
* @param {string} [options.missingTargetMessage] - No target selected warning message
|
||||
* @returns {Promise<boolean>} Whether the send succeeded
|
||||
*/
|
||||
export async function sendPromptToWorkflow(promptText, options = {}) {
|
||||
const registry = await fetchWorkflowRegistry();
|
||||
if (!registry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textNodes = filterRegistryNodes(registry.nodes, (node) => {
|
||||
if (!isNodeEnabled(node)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
node.capabilities?.has_text_widget === true ||
|
||||
node.marker_role === "send_prompt_target"
|
||||
);
|
||||
});
|
||||
|
||||
const nodeKeys = Object.keys(textNodes);
|
||||
if (nodeKeys.length === 0) {
|
||||
showToast(options.missingNodesMessage || 'uiHelpers.workflow.noMatchingNodes', {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
const messages = {
|
||||
successMessage: options.successMessage || translate('uiHelpers.workflow.promptSent', {}, 'Prompt sent to workflow'),
|
||||
failureMessage: options.failureMessage || translate('uiHelpers.workflow.promptFailed', {}, 'Failed to send prompt'),
|
||||
missingTargetMessage: options.missingTargetMessage || translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'),
|
||||
};
|
||||
|
||||
const handleSend = (selectedNodeIds) =>
|
||||
sendTextToNodes(selectedNodeIds, textNodes, promptText, 'replace', messages);
|
||||
|
||||
if (nodeKeys.length === 1) {
|
||||
return await handleSend([nodeKeys[0]]);
|
||||
}
|
||||
|
||||
const actionType = options.actionTypeText || translate('uiHelpers.nodeSelector.prompt', {}, 'Prompt');
|
||||
|
||||
showNodeSelector(textNodes, {
|
||||
actionType,
|
||||
actionMode: translate('uiHelpers.nodeSelector.replace', {}, 'Replace'),
|
||||
onSend: handleSend,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send generation parameters (seed, steps, cfg, sampler, scheduler) to
|
||||
* workflow nodes that have been marked with "Send Gen Params Target".
|
||||
*
|
||||
* @param {Object} genParams - Raw gen_params from recipe or showcase metadata
|
||||
* @returns {Promise<boolean>} Whether the send succeeded
|
||||
*/
|
||||
export async function sendGenParamsToWorkflow(genParams) {
|
||||
if (!genParams || typeof genParams !== 'object') {
|
||||
showToast('No generation parameters to send', {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Extract relevant params (skip prompt, negative_prompt, clip_skip, denoising_strength)
|
||||
const raw = {
|
||||
seed: genParams.seed,
|
||||
steps: genParams.steps,
|
||||
cfg: genParams.cfg_scale,
|
||||
};
|
||||
|
||||
// 2. Resolve sampler/scheduler
|
||||
const resolved = resolveSamplerScheduler(genParams.sampler);
|
||||
if (resolved) {
|
||||
if (resolved.sampler) raw.sampler = resolved.sampler;
|
||||
if (resolved.scheduler) raw.scheduler = resolved.scheduler;
|
||||
}
|
||||
|
||||
// Check if we have anything to send
|
||||
const hasAny = Object.values(raw).some(v => v !== undefined && v !== null && v !== '');
|
||||
if (!hasAny) {
|
||||
showToast('No sendable parameters found', {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Fetch workflow registry
|
||||
const registry = await fetchWorkflowRegistry();
|
||||
if (!registry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Filter nodes by marker_role === "send_gen_params"
|
||||
const targetNodes = filterRegistryNodes(registry.nodes, (node) => {
|
||||
return node.marker_role === 'send_gen_params' && isNodeEnabled(node);
|
||||
});
|
||||
|
||||
const nodeKeys = Object.keys(targetNodes);
|
||||
if (nodeKeys.length === 0) {
|
||||
showToast(
|
||||
'No node marked as Send Gen Params Target.\nRight-click a node in ComfyUI → Mark as → Send Gen Params Target',
|
||||
{},
|
||||
'warning'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. For each candidate node, find matching widgets
|
||||
// Also collect widget_names from registry for matching
|
||||
const sendToNode = async (nodeIds) => {
|
||||
const targetIds = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
|
||||
let allSuccess = true;
|
||||
let totalSent = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const nodeKey of targetIds) {
|
||||
const node = targetNodes[nodeKey];
|
||||
if (!node) continue;
|
||||
|
||||
const widgetNames = node.widget_names || [];
|
||||
const updates = findMatchingWidgets(widgetNames, raw);
|
||||
|
||||
if (updates.length === 0) {
|
||||
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
|
||||
allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send each widget value sequentially
|
||||
for (const update of updates) {
|
||||
const success = await sendWidgetValueToNodes(
|
||||
[nodeKey],
|
||||
targetNodes,
|
||||
update.widgetName,
|
||||
update.value,
|
||||
{
|
||||
silent: true,
|
||||
}
|
||||
);
|
||||
if (success) {
|
||||
totalSent++;
|
||||
} else {
|
||||
totalFailed++;
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show single summary toast
|
||||
if (totalSent > 0 && totalFailed === 0) {
|
||||
showToast(`Sent ${totalSent} parameter${totalSent > 1 ? 's' : ''} to workflow`, {}, 'success');
|
||||
} else if (totalFailed > 0 && totalSent > 0) {
|
||||
showToast(`Partially updated (${totalSent} ok, ${totalFailed} failed)`, {}, 'warning');
|
||||
} else if (totalFailed > 0) {
|
||||
showToast('Failed to update parameters', {}, 'error');
|
||||
}
|
||||
return allSuccess;
|
||||
};
|
||||
|
||||
// 6. If multiple nodes, show node selector; otherwise send directly
|
||||
if (nodeKeys.length === 1) {
|
||||
return await sendToNode([nodeKeys[0]]);
|
||||
}
|
||||
|
||||
showNodeSelector(targetNodes, {
|
||||
actionType: 'Gen Params',
|
||||
actionMode: 'Update',
|
||||
onSend: sendToNode,
|
||||
enableSendAll: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Global variable to track active node selector state
|
||||
let nodeSelectorState = {
|
||||
isActive: false,
|
||||
|
||||
@@ -158,6 +158,11 @@
|
||||
<div class="context-menu-item" data-action="manage-excluded-models">
|
||||
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="toggle-group-by-model">
|
||||
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
|
||||
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="repair-recipes">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div class="controls">
|
||||
{% if page_id != 'recipes' %}
|
||||
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||
<div class="excluded-view-banner__content">
|
||||
<div class="excluded-view-banner__title">
|
||||
@@ -11,42 +12,63 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
|
||||
<select id="sortSelect">
|
||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group sort-dropdown-group dropdown-group" data-sort-dropdown>
|
||||
<button type="button" class="sort-trigger" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="sort-trigger__label"></span>
|
||||
<i class="fas fa-caret-down sort-trigger__caret" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu sort-dropdown-menu" role="listbox"></div>
|
||||
<select id="sortSelect" class="sort-select-native" tabindex="-1" aria-hidden="true">
|
||||
<optgroup label="{{ t('loras.controls.sort.name') }}">
|
||||
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
||||
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('loras.controls.sort.date') }}">
|
||||
<optgroup label="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.date') }}{% else %}{{ t('loras.controls.sort.date') }}{% endif %}">
|
||||
<option value="date:desc">{{ t('loras.controls.sort.dateDesc') }}</option>
|
||||
<option value="date:asc">{{ t('loras.controls.sort.dateAsc') }}</option>
|
||||
</optgroup>
|
||||
{% if page_id != 'recipes' %}
|
||||
<optgroup label="{{ t('loras.controls.sort.size') }}">
|
||||
<option value="size:desc">{{ t('loras.controls.sort.sizeDesc') }}</option>
|
||||
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
|
||||
</optgroup>
|
||||
{% if page_id != 'embeddings' %}
|
||||
{% endif %}
|
||||
{% if page_id != 'embeddings' and page_id != 'recipes' %}
|
||||
<optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}">
|
||||
<option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option>
|
||||
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if page_id != 'recipes' %}
|
||||
<optgroup class="sort-option-versions-count" label="{{ t('loras.controls.sort.versionsCount', default='Local Versions') }}">
|
||||
<option value="versions_count:desc">{{ t('loras.controls.sort.versionsCountDesc', default='Most versions first') }}</option>
|
||||
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if page_id == 'recipes' %}
|
||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('loras.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}{% else %}{{ t('loras.controls.refresh.fullTooltip') }}{% endif %}">
|
||||
<i class="fas fa-tools"></i> <span>{% if page_id == 'recipes' %}{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}{% else %}{{ t('loras.controls.refresh.full') }}{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if page_id != 'recipes' %}
|
||||
<div class="control-group">
|
||||
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
|
||||
</div>
|
||||
@@ -55,6 +77,15 @@
|
||||
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if page_id == 'recipes' %}
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
|
||||
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{ t('recipes.batchImport.action') }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="control-group">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
|
||||
@@ -71,6 +102,7 @@
|
||||
<i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
{% if page_id != 'recipes' %}
|
||||
<div class="control-group dropdown-group update-filter-group">
|
||||
<button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}">
|
||||
<i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span>
|
||||
@@ -84,6 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
|
||||
|
||||
@@ -45,14 +45,14 @@
|
||||
|
||||
<!-- Center section: Search -->
|
||||
{% set search_disabled = current_page == 'statistics' %}
|
||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
||||
current_page %}
|
||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholder' %}
|
||||
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
||||
<div class="{{ header_search_class }}" id="headerSearch">
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||
disabled{% endif %} />
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<span class="search-shortcut-cue" id="searchShortcutCue"><kbd>Ctrl</kbd><kbd>F</kbd></span>
|
||||
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}" {% if
|
||||
search_disabled %} disabled aria-disabled="true" {% endif %}>
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="error-message" id="urlError"></div>
|
||||
<div class="input-hint">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>{{ t('modals.download.urlHint') }}</span>
|
||||
<span id="urlHint">{{ t('modals.download.urlHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
|
||||
@@ -536,6 +536,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by model toggle -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="groupByModel">
|
||||
{{ t('settings.layoutSettings.groupByModel') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.groupByModelHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="groupByModel"
|
||||
onchange="settingsManager.saveToggleSetting('groupByModel', 'group_by_model')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
@@ -1081,23 +1100,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Flags -->
|
||||
<!-- Version Scope -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
<h4>{{ t('settings.sections.updateFlags') }}</h4>
|
||||
<h4>{{ t('settings.sections.versionScope') }}</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="updateFlagStrategy">
|
||||
{{ t('settings.updateFlagStrategy.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.updateFlagStrategy.help') }}"></i>
|
||||
<label for="versionGrouping">
|
||||
{{ t('settings.versionGrouping.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.versionGrouping.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="updateFlagStrategy" onchange="settingsManager.saveSelectSetting('updateFlagStrategy', 'update_flag_strategy')">
|
||||
<option value="same_base">{{ t('settings.updateFlagStrategy.options.sameBase') }}</option>
|
||||
<option value="any">{{ t('settings.updateFlagStrategy.options.any') }}</option>
|
||||
<select id="versionGrouping" onchange="settingsManager.saveSelectSetting('versionGrouping', 'version_grouping')">
|
||||
<option value="same_base">{{ t('settings.versionGrouping.options.sameBase') }}</option>
|
||||
<option value="any">{{ t('settings.versionGrouping.options.any') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
<div class="param-header">
|
||||
<label>Prompt</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="sendPromptBtn" title="Send Prompt to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
@@ -62,6 +65,9 @@
|
||||
<div class="param-header">
|
||||
<label>Negative Prompt</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="sendNegativePromptBtn" title="Send Negative Prompt to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
@@ -84,7 +90,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Other Parameters -->
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Params</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="sendParamsBtn" title="Send Params to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-85
@@ -62,91 +62,9 @@
|
||||
|
||||
{% block content %}
|
||||
<!-- Recipe controls -->
|
||||
<div class="controls">
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div class="control-group">
|
||||
<select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
|
||||
<optgroup label="{{ t('recipes.controls.sort.name') }}">
|
||||
<option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
|
||||
<option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('recipes.controls.sort.date') }}">
|
||||
<option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
|
||||
<option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
|
||||
t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||
t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
|
||||
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
|
||||
t('recipes.batchImport.action') }}</button>
|
||||
</div>
|
||||
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
|
||||
<div class="shortcut-key">B</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add duplicate detection button -->
|
||||
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
|
||||
t('loras.controls.duplicates.action') }}</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
|
||||
title="{{ t('recipes.controls.favorites.title') }}">
|
||||
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Custom filter indicator button (hidden by default) -->
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
|
||||
}}</span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<div class="control-group doctor-control-group">
|
||||
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<span>{{ t('doctor.title', default='Doctor') }}</span>
|
||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
||||
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
||||
<!-- Breadcrumbs will be populated by JavaScript -->
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'components/controls.html' %}
|
||||
<!-- Breadcrumb Navigation -->
|
||||
{% include 'components/breadcrumb.html' %}
|
||||
|
||||
<!-- Duplicates banner (hidden by default) -->
|
||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||
|
||||
@@ -1117,9 +1117,9 @@ describe('Interaction-level regression coverage', () => {
|
||||
expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available');
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available');
|
||||
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
|
||||
expect(otherParamsText).toContain('sampler:');
|
||||
expect(otherParamsText).toContain('Sampler:');
|
||||
expect(otherParamsText).toContain('dpmpp_2m');
|
||||
expect(otherParamsText).not.toContain('cfg_scale');
|
||||
expect(otherParamsText).not.toContain('CFG');
|
||||
});
|
||||
|
||||
it('filters dirty generation params from recipe modal display', async () => {
|
||||
@@ -1168,8 +1168,8 @@ describe('Interaction-level regression coverage', () => {
|
||||
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
|
||||
expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt');
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative');
|
||||
expect(otherParamsText).toContain('sampler:');
|
||||
expect(otherParamsText).toContain('cfg_scale:');
|
||||
expect(otherParamsText).toContain('Sampler:');
|
||||
expect(otherParamsText).toContain('CFG:');
|
||||
expect(otherParamsText).not.toContain('Version');
|
||||
expect(otherParamsText).not.toContain('raw_metadata');
|
||||
expect(otherParamsText).not.toContain('RNG');
|
||||
@@ -1222,7 +1222,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
expect(document.getElementById('recipePrompt').textContent).not.toContain('stale prompt');
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative');
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).not.toContain('stale negative');
|
||||
expect(otherParamsText).toContain('cfg_scale:');
|
||||
expect(otherParamsText).toContain('CFG:');
|
||||
expect(otherParamsText).toContain('7');
|
||||
expect(otherParamsText).not.toContain('3');
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ const stateMock = {
|
||||
global: {
|
||||
settings: {
|
||||
autoplay_on_hover: false,
|
||||
update_flag_strategy: 'any',
|
||||
version_grouping: 'any',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -67,7 +67,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
</div>
|
||||
`;
|
||||
stateMock.global.settings.autoplay_on_hover = false;
|
||||
stateMock.global.settings.update_flag_strategy = 'any';
|
||||
stateMock.global.settings.version_grouping = 'any';
|
||||
({ getModelApiClient } = await import(API_FACTORY_MODULE));
|
||||
fetchModelUpdateVersions = vi.fn();
|
||||
getModelApiClient.mockReturnValue({
|
||||
@@ -157,7 +157,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('shows a stable label with a short state indicator', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'any';
|
||||
stateMock.global.settings.version_grouping = 'any';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -192,7 +192,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('filters versions to the current base model when strategy is same_base', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -235,7 +235,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('toggle button can switch to display all versions', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -286,7 +286,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('shows a newer version badge when viewing same-base results', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
|
||||
@@ -96,6 +96,7 @@ function renderControlsDom(pageKey) {
|
||||
<div class="search-container">
|
||||
<input id="searchInput" />
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<span class="search-shortcut-cue" id="searchShortcutCue"><kbd>Ctrl</kbd><kbd>F</kbd></span>
|
||||
<button id="searchOptionsToggle" class="search-options-toggle"></button>
|
||||
<button id="filterButton" class="search-filter-toggle">
|
||||
<span id="activeFiltersCount" class="filter-badge" style="display: none">0</span>
|
||||
@@ -215,6 +216,40 @@ describe('SearchManager filtering scenarios', () => {
|
||||
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledWith(true, false);
|
||||
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['loras'],
|
||||
['checkpoints'],
|
||||
])('toggles clear button and shortcut cue visibility for %s page', async (pageKey) => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderControlsDom(pageKey);
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState(pageKey);
|
||||
const { SearchManager } = await import('../../../static/js/managers/SearchManager.js');
|
||||
|
||||
new SearchManager({ page: pageKey, searchDelay: 0 });
|
||||
|
||||
const input = document.getElementById('searchInput');
|
||||
const cue = document.getElementById('searchShortcutCue');
|
||||
const clearBtn = document.querySelector('.search-clear');
|
||||
|
||||
// Initially empty: cue visible, clear hidden
|
||||
expect(cue.classList.contains('hidden')).toBe(false);
|
||||
expect(clearBtn.classList.contains('visible')).toBe(false);
|
||||
|
||||
// Type something: cue hidden, clear visible
|
||||
input.value = 'flux';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
expect(cue.classList.contains('hidden')).toBe(true);
|
||||
expect(clearBtn.classList.contains('visible')).toBe(true);
|
||||
|
||||
// Clear via click: cue visible, clear hidden
|
||||
clearBtn.click();
|
||||
expect(input.value).toBe('');
|
||||
expect(cue.classList.contains('hidden')).toBe(false);
|
||||
expect(clearBtn.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilterManager tag and base model filters', () => {
|
||||
|
||||
@@ -143,6 +143,19 @@ describe('RecipeManager', () => {
|
||||
|
||||
renderRecipesPage();
|
||||
|
||||
// Inject controls DOM that would normally come from components/controls.html
|
||||
// (raw template rendering doesn't process Jinja2 {% include %} tags)
|
||||
const customFilterIndicator = document.createElement('div');
|
||||
customFilterIndicator.id = 'customFilterIndicator';
|
||||
customFilterIndicator.className = 'control-group hidden';
|
||||
customFilterIndicator.innerHTML = `
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(customFilterIndicator);
|
||||
|
||||
({ RecipeManager } = await import('../../../static/js/recipes.js'));
|
||||
});
|
||||
|
||||
@@ -288,7 +301,7 @@ describe('RecipeManager', () => {
|
||||
});
|
||||
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const filterText = indicator.querySelector('#customFilterText');
|
||||
const filterText = indicator.querySelector('.customFilterText');
|
||||
|
||||
expect(filterText.innerHTML).toContain('Recipes using checkpoint:');
|
||||
expect(filterText.innerHTML).toContain('Flux Base');
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// genParamsMapper is pure logic with zero dependencies — safe to import directly
|
||||
import {
|
||||
SAMPLER_DISPLAY_TO_INTERNAL,
|
||||
SCHEDULER_SUFFIXES,
|
||||
SCHEDULER_ONLY_VALUES,
|
||||
PARAM_TO_WIDGET_CANDIDATES,
|
||||
parseCombinedSamplerName,
|
||||
resolveSamplerScheduler,
|
||||
findMatchingWidgets,
|
||||
} from '../../../static/js/utils/genParamsMapper.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants sanity
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('constants', () => {
|
||||
it('maps at least the common samplers', () => {
|
||||
expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler']).toBe('euler');
|
||||
expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler a']).toBe('euler_ancestral');
|
||||
expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M']).toBe('dpmpp_2m');
|
||||
expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M SDE']).toBe('dpmpp_2m_sde');
|
||||
expect(SAMPLER_DISPLAY_TO_INTERNAL['LCM']).toBe('lcm');
|
||||
expect(SAMPLER_DISPLAY_TO_INTERNAL['DDIM']).toBe('ddim');
|
||||
});
|
||||
|
||||
it('lists all 9 scheduler suffixes', () => {
|
||||
expect(SCHEDULER_SUFFIXES).toHaveLength(9);
|
||||
expect(SCHEDULER_SUFFIXES).toContain('karras');
|
||||
expect(SCHEDULER_SUFFIXES).toContain('simple');
|
||||
expect(SCHEDULER_SUFFIXES).toContain('exponential');
|
||||
});
|
||||
|
||||
it('marks scheduler-only values', () => {
|
||||
expect(SCHEDULER_ONLY_VALUES.has('karras')).toBe(true);
|
||||
expect(SCHEDULER_ONLY_VALUES.has('simple')).toBe(true);
|
||||
expect(SCHEDULER_ONLY_VALUES.has('euler')).toBe(false);
|
||||
});
|
||||
|
||||
it('has widget candidates for all param keys', () => {
|
||||
expect(PARAM_TO_WIDGET_CANDIDATES.seed).toContain('seed');
|
||||
expect(PARAM_TO_WIDGET_CANDIDATES.sampler).toContain('sampler_name');
|
||||
expect(PARAM_TO_WIDGET_CANDIDATES.scheduler).toContain('scheduler');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseCombinedSamplerName
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('parseCombinedSamplerName', () => {
|
||||
it('parses space-separated sampler + scheduler', () => {
|
||||
expect(parseCombinedSamplerName('Euler a Karras')).toEqual({
|
||||
sampler: 'euler_ancestral',
|
||||
scheduler: 'karras',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses DPM++ 2M Karras', () => {
|
||||
expect(parseCombinedSamplerName('DPM++ 2M Karras')).toEqual({
|
||||
sampler: 'dpmpp_2m',
|
||||
scheduler: 'karras',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses DPM++ 2M beta', () => {
|
||||
expect(parseCombinedSamplerName('DPM++ 2M beta')).toEqual({
|
||||
sampler: 'dpmpp_2m',
|
||||
scheduler: 'beta',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses DPM++ SDE Karras', () => {
|
||||
expect(parseCombinedSamplerName('DPM++ SDE Karras')).toEqual({
|
||||
sampler: 'dpmpp_sde',
|
||||
scheduler: 'karras',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses underscore-separated er_sde_beta', () => {
|
||||
expect(parseCombinedSamplerName('er_sde_beta')).toEqual({
|
||||
sampler: 'er_sde',
|
||||
scheduler: 'beta',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for sampler-only values', () => {
|
||||
expect(parseCombinedSamplerName('Euler a')).toBeNull();
|
||||
expect(parseCombinedSamplerName('LCM')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unrecognised suffix', () => {
|
||||
expect(parseCombinedSamplerName('Euler something_unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for null/empty', () => {
|
||||
expect(parseCombinedSamplerName(null)).toBeNull();
|
||||
expect(parseCombinedSamplerName('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveSamplerScheduler — the main resolver used by the send feature
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolveSamplerScheduler', () => {
|
||||
// --- Category 1: simple display names ---
|
||||
it('resolves Euler → euler', () => {
|
||||
expect(resolveSamplerScheduler('Euler')).toEqual({ sampler: 'euler', scheduler: null });
|
||||
});
|
||||
|
||||
it('resolves Euler a → euler_ancestral', () => {
|
||||
expect(resolveSamplerScheduler('Euler a')).toEqual({ sampler: 'euler_ancestral', scheduler: null });
|
||||
});
|
||||
|
||||
it('resolves DPM++ 2M → dpmpp_2m', () => {
|
||||
expect(resolveSamplerScheduler('DPM++ 2M')).toEqual({ sampler: 'dpmpp_2m', scheduler: null });
|
||||
});
|
||||
|
||||
it('resolves LCM → lcm', () => {
|
||||
expect(resolveSamplerScheduler('LCM')).toEqual({ sampler: 'lcm', scheduler: null });
|
||||
});
|
||||
|
||||
// --- Category 2: already-internal names ---
|
||||
it('passes through lowercase internal names', () => {
|
||||
expect(resolveSamplerScheduler('euler')).toEqual({ sampler: 'euler', scheduler: null });
|
||||
expect(resolveSamplerScheduler('heunpp2')).toEqual({ sampler: 'heunpp2', scheduler: null });
|
||||
expect(resolveSamplerScheduler('lcm')).toEqual({ sampler: 'lcm', scheduler: null });
|
||||
expect(resolveSamplerScheduler('er_sde')).toEqual({ sampler: 'er_sde', scheduler: null });
|
||||
});
|
||||
|
||||
// --- Category 3: combined names ---
|
||||
it('resolves Euler a Karras → euler_ancestral + karras', () => {
|
||||
expect(resolveSamplerScheduler('Euler a Karras')).toEqual({
|
||||
sampler: 'euler_ancestral',
|
||||
scheduler: 'karras',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves DPM++ 2M Karras → dpmpp_2m + karras', () => {
|
||||
expect(resolveSamplerScheduler('DPM++ 2M Karras')).toEqual({
|
||||
sampler: 'dpmpp_2m',
|
||||
scheduler: 'karras',
|
||||
});
|
||||
});
|
||||
|
||||
// --- Category 4: scheduler-only ---
|
||||
it('resolves scheduler-only values', () => {
|
||||
expect(resolveSamplerScheduler('karras')).toEqual({ sampler: null, scheduler: 'karras' });
|
||||
expect(resolveSamplerScheduler('simple')).toEqual({ sampler: null, scheduler: 'simple' });
|
||||
expect(resolveSamplerScheduler('sgm_uniform')).toEqual({ sampler: null, scheduler: 'sgm_uniform' });
|
||||
});
|
||||
|
||||
// --- Category 5: unrecognised / model-specific ---
|
||||
it('returns null+null for unrecognised values', () => {
|
||||
const result = resolveSamplerScheduler('AYS SDXL');
|
||||
expect(result.sampler).toBeNull();
|
||||
expect(result.scheduler).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null+null for Undefined', () => {
|
||||
const result = resolveSamplerScheduler('Undefined');
|
||||
expect(result.sampler).toBeNull();
|
||||
expect(result.scheduler).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null+null for model-specific values', () => {
|
||||
expect(resolveSamplerScheduler('Seedream-V45').sampler).toBeNull();
|
||||
expect(resolveSamplerScheduler('GPT-Image-2').sampler).toBeNull();
|
||||
});
|
||||
|
||||
// --- Category 6: edge cases ---
|
||||
it('returns null+null for null / empty / whitespace', () => {
|
||||
expect(resolveSamplerScheduler(null)).toEqual({ sampler: null, scheduler: null });
|
||||
expect(resolveSamplerScheduler('')).toEqual({ sampler: null, scheduler: null });
|
||||
expect(resolveSamplerScheduler(' ')).toEqual({ sampler: null, scheduler: null });
|
||||
});
|
||||
|
||||
it('handles slash-separated custom format (extracts last segment)', () => {
|
||||
// "multistep/dpmpp_2m_simple" — extracts last segment but the recursive
|
||||
// call hits the "already internal name" regex before combined-name parsing,
|
||||
// so it returns the raw segment as the sampler name.
|
||||
const result = resolveSamplerScheduler('multistep/dpmpp_2m_simple');
|
||||
expect(result.sampler).toBe('dpmpp_2m_simple');
|
||||
expect(result.scheduler).toBeNull();
|
||||
});
|
||||
|
||||
it('handles parse-error value (None', () => {
|
||||
const result = resolveSamplerScheduler('(None');
|
||||
expect(result.sampler).toBeNull();
|
||||
expect(result.scheduler).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findMatchingWidgets
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('findMatchingWidgets', () => {
|
||||
const resolved = {
|
||||
seed: 42,
|
||||
steps: 30,
|
||||
cfg: 7,
|
||||
sampler: 'euler_ancestral',
|
||||
scheduler: 'karras',
|
||||
};
|
||||
|
||||
it('matches seed to seed widget', () => {
|
||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||
expect(updates).toContainEqual({ widgetName: 'seed', value: '42' });
|
||||
expect(updates).toContainEqual({ widgetName: 'steps', value: '30' });
|
||||
expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' });
|
||||
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
|
||||
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
|
||||
});
|
||||
|
||||
it('skips undefined/null params', () => {
|
||||
const updates = findMatchingWidgets(['seed', 'steps'], { seed: 42, steps: null, cfg: undefined });
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].widgetName).toBe('seed');
|
||||
});
|
||||
|
||||
it('matches noise_seed when seed widget not present', () => {
|
||||
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
|
||||
expect(seedUpdate).toBeDefined();
|
||||
expect(seedUpdate.value).toBe('42');
|
||||
});
|
||||
|
||||
it('matches rgthree-style sampler widget name', () => {
|
||||
const updates = findMatchingWidgets(['sampler', 'scheduler'], { sampler: 'euler', scheduler: 'karras' });
|
||||
expect(updates).toContainEqual({ widgetName: 'sampler', value: 'euler' });
|
||||
});
|
||||
|
||||
it('returns empty array for empty widget list', () => {
|
||||
expect(findMatchingWidgets([], resolved)).toEqual([]);
|
||||
expect(findMatchingWidgets(null, resolved)).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles case-insensitive widget name matching', () => {
|
||||
const updates = findMatchingWidgets(['SEED', 'STEPS', 'CFG'], resolved);
|
||||
expect(updates).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns updates in param order (seed, steps, cfg, sampler, scheduler)', () => {
|
||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DownloadManager } from '../../../static/js/managers/DownloadManager.js';
|
||||
|
||||
describe('DownloadManager.detectUrlType — HF URL detection', () => {
|
||||
|
||||
it('detects HF resolve URL with file', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://huggingface.co/dx8152/Flux2-Klein-9B-Consistency/resolve/main/Flux2-Klein-9B-consistency-V2.safetensors'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'hf-resolve',
|
||||
repo: 'dx8152/Flux2-Klein-9B-Consistency',
|
||||
revision: 'main',
|
||||
filename: 'Flux2-Klein-9B-consistency-V2.safetensors',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects HF resolve URL with subdirectory file', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://huggingface.co/user/repo/resolve/main/subdir/model.safetensors'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'hf-resolve',
|
||||
repo: 'user/repo',
|
||||
revision: 'main',
|
||||
filename: 'subdir/model.safetensors',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects HF repo URL (full URL)', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://huggingface.co/dx8152/Flux2-Klein-9B-Consistency'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'hf-repo',
|
||||
repo: 'dx8152/Flux2-Klein-9B-Consistency',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects HF repo URL (bare user/repo)', () => {
|
||||
const result = DownloadManager.detectUrlType('dx8152/Flux2-Klein-9B-Consistency');
|
||||
expect(result).toEqual({
|
||||
type: 'hf-repo',
|
||||
repo: 'dx8152/Flux2-Klein-9B-Consistency',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects HF repo URL with trailing slash', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://huggingface.co/user/repo/'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'hf-repo',
|
||||
repo: 'user/repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects CivitAI URL', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://civitai.com/models/123/some-model'
|
||||
);
|
||||
expect(result).toEqual({ type: 'civitai' });
|
||||
});
|
||||
|
||||
it('detects CivArchive URL', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://civarchive.com/models/456'
|
||||
);
|
||||
expect(result).toEqual({ type: 'civitai' });
|
||||
});
|
||||
|
||||
it('detects direct HTTP URL', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://example.com/file.zip'
|
||||
);
|
||||
expect(result).toEqual({ type: 'direct-http' });
|
||||
});
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(DownloadManager.detectUrlType('')).toBeNull();
|
||||
expect(DownloadManager.detectUrlType(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unrecognized path', () => {
|
||||
expect(DownloadManager.detectUrlType('justrandomtext')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers HF resolve over repo when both match', () => {
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://huggingface.co/user/repo/resolve/main/file.safetensors'
|
||||
);
|
||||
expect(result?.type).toBe('hf-resolve');
|
||||
});
|
||||
|
||||
it('prefers CivitAI over HF when both match', () => {
|
||||
// CivitAI check comes first in detectUrlType
|
||||
// This URL should be detected as CivitAI, not HF
|
||||
const result = DownloadManager.detectUrlType(
|
||||
'https://civitai.com/models/123?huggingface.co/test/repo'
|
||||
);
|
||||
expect(result?.type).toBe('civitai');
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,9 @@ class FakePromptServer:
|
||||
sent = []
|
||||
|
||||
class Instance:
|
||||
def send_sync(self, event, payload):
|
||||
sockets: dict = {}
|
||||
|
||||
def send_sync(self, event, payload, sid=None):
|
||||
FakePromptServer.sent.append((event, payload))
|
||||
|
||||
instance = Instance()
|
||||
@@ -148,7 +150,8 @@ class TestNodeRegistryHandlerSnapshots:
|
||||
"type": "Lora Loader (LoraManager)",
|
||||
"title": "Test Loader",
|
||||
}
|
||||
]
|
||||
],
|
||||
"client_id": "test-client-1",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -167,7 +170,7 @@ class TestNodeRegistryHandlerSnapshots:
|
||||
standalone_mode=False,
|
||||
)
|
||||
|
||||
request = FakeRequest(json_data={"nodes": []})
|
||||
request = FakeRequest(json_data={"nodes": [], "client_id": "test-client-1"})
|
||||
response = await handler.register_nodes(request)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
|
||||
@@ -201,6 +201,45 @@ def test_list_models_returns_formatted_items(mock_service, mock_scanner):
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_list_models_filters_out_corrupted_entries(mock_service, mock_scanner):
|
||||
"""Corrupted cache entries (format_response returns None) must not appear
|
||||
in the response items nor cause a 500. See issue #730.
|
||||
"""
|
||||
mock_service.paginated_items = [
|
||||
{"file_path": "/tmp/good.safetensors", "name": "Good"},
|
||||
{"file_path": None, "name": "Corrupted"}, # triggers None from format_response
|
||||
{"file_path": "/tmp/also_good.safetensors", "name": "AlsoGood"},
|
||||
]
|
||||
|
||||
# Override format_response to return None for corrupted entries
|
||||
original_format = mock_service.format_response
|
||||
|
||||
async def conditional_format(item):
|
||||
if item.get("file_path") is None:
|
||||
return None
|
||||
return await original_format(item)
|
||||
|
||||
mock_service.format_response = conditional_format
|
||||
|
||||
async def scenario():
|
||||
client = await create_test_client(mock_service)
|
||||
try:
|
||||
response = await client.get("/api/lm/test-models/list")
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
# Only the 2 non-corrupted entries should appear
|
||||
assert len(payload["items"]) == 2
|
||||
assert payload["items"][0]["name"] == "Good"
|
||||
assert payload["items"][1]["name"] == "AlsoGood"
|
||||
# None should never appear in the items list
|
||||
assert None not in payload["items"]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_model_types_endpoint_returns_counts(mock_service, mock_scanner):
|
||||
mock_service.model_types = [
|
||||
{"type": "LoRa", "count": 3},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user