mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-27 05:11:15 -03:00
Compare commits
39 Commits
2b8e7c7504
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ model_cache/
|
|||||||
.codex
|
.codex
|
||||||
.omo
|
.omo
|
||||||
reasonix.toml
|
reasonix.toml
|
||||||
|
.reasonix/
|
||||||
.codegraph/
|
.codegraph/
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
"Insomnia Art Designs",
|
"Insomnia Art Designs",
|
||||||
"2018cfh",
|
"2018cfh",
|
||||||
"Arlecchino Shion",
|
"Arlecchino Shion",
|
||||||
"Charles Blakemore",
|
|
||||||
"Rob Williams",
|
"Rob Williams",
|
||||||
|
"Charles Blakemore",
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
"$MetaSamsara",
|
"$MetaSamsara",
|
||||||
"wackop",
|
"wackop",
|
||||||
@@ -20,23 +20,32 @@
|
|||||||
"Carl G.",
|
"Carl G.",
|
||||||
"stone9k",
|
"stone9k",
|
||||||
"Rosenthal",
|
"Rosenthal",
|
||||||
|
"Francisco Tatis",
|
||||||
|
"Polymorphic Indeterminate",
|
||||||
|
"Skalabananen",
|
||||||
|
"Marc Whiffen",
|
||||||
|
"Birdy",
|
||||||
"itismyelement",
|
"itismyelement",
|
||||||
"Mozzel",
|
"Mozzel",
|
||||||
"Gingko Biloba",
|
"Gingko Biloba",
|
||||||
"Kiba",
|
"Kiba",
|
||||||
|
"Reno Lam",
|
||||||
"onesecondinosaur",
|
"onesecondinosaur",
|
||||||
|
"sig",
|
||||||
"Christian Byrne",
|
"Christian Byrne",
|
||||||
"DM",
|
"DM",
|
||||||
"Sen314",
|
"Sen314",
|
||||||
"Estragon",
|
"Estragon",
|
||||||
|
"J\\B/ 8r0wns0n",
|
||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
"Francisco Tatis",
|
|
||||||
"Tobi_Swagg",
|
"Tobi_Swagg",
|
||||||
"SG",
|
"SG",
|
||||||
|
"zenbound",
|
||||||
"jmack",
|
"jmack",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
|
"James Todd",
|
||||||
"JongWon Han",
|
"JongWon Han",
|
||||||
"VantAI",
|
"VantAI",
|
||||||
"レプサイ",
|
"レプサイ",
|
||||||
@@ -47,6 +56,7 @@
|
|||||||
"JackieWang",
|
"JackieWang",
|
||||||
"FreelancerZ",
|
"FreelancerZ",
|
||||||
"fnkylove",
|
"fnkylove",
|
||||||
|
"Vik71it",
|
||||||
"Echo",
|
"Echo",
|
||||||
"Lilleman",
|
"Lilleman",
|
||||||
"Robert Stacey",
|
"Robert Stacey",
|
||||||
@@ -54,19 +64,14 @@
|
|||||||
"Edgar Tejeda",
|
"Edgar Tejeda",
|
||||||
"Fraser Cross",
|
"Fraser Cross",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
"Polymorphic Indeterminate",
|
|
||||||
"Sterilized",
|
"Sterilized",
|
||||||
"JORGE+LUIZ+HUSSNI+MESSIAS",
|
"JORGE+LUIZ+HUSSNI+MESSIAS",
|
||||||
"Marc Whiffen",
|
|
||||||
"Skalabananen",
|
|
||||||
"Birdy",
|
|
||||||
"quarz",
|
"quarz",
|
||||||
"Reno Lam",
|
"Greg",
|
||||||
"JSST",
|
"JSST",
|
||||||
"sig",
|
|
||||||
"J\\B/ 8r0wns0n",
|
|
||||||
"Snaggwort",
|
"Snaggwort",
|
||||||
"Takkan",
|
"Takkan",
|
||||||
|
"wfpearl",
|
||||||
"Matt+J",
|
"Matt+J",
|
||||||
"Baekdoosixt",
|
"Baekdoosixt",
|
||||||
"Jonathan Ross",
|
"Jonathan Ross",
|
||||||
@@ -84,9 +89,9 @@
|
|||||||
"carozzz",
|
"carozzz",
|
||||||
"Marlon Daniels",
|
"Marlon Daniels",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
"Aaron Bleuer",
|
"Aaron Bleuer",
|
||||||
|
"LacesOut!",
|
||||||
"Adam Shaw",
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
@@ -94,23 +99,24 @@
|
|||||||
"iamresist",
|
"iamresist",
|
||||||
"RedrockVP",
|
"RedrockVP",
|
||||||
"Wolffen",
|
"Wolffen",
|
||||||
"James Todd",
|
|
||||||
"Wicked Choices by ASLPro3D",
|
"Wicked Choices by ASLPro3D",
|
||||||
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
"FinalyFree",
|
||||||
"Weasyl",
|
"Weasyl",
|
||||||
"Steven Pfeiffer",
|
"Steven Pfeiffer",
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"Johnny",
|
"Johnny",
|
||||||
|
"Cory Paza",
|
||||||
"Tak",
|
"Tak",
|
||||||
"Lisster",
|
"Lisster",
|
||||||
"Big Red",
|
"Big Red",
|
||||||
"whudunit",
|
"whudunit",
|
||||||
"Luc Job",
|
"Luc Job",
|
||||||
"dl0901dm",
|
"dl0901dm",
|
||||||
|
"Philip Hempel",
|
||||||
"corde",
|
"corde",
|
||||||
"nwalker94",
|
"nwalker94",
|
||||||
"Yushio",
|
"Yushio",
|
||||||
"Vik71it",
|
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
"Todd Keck",
|
"Todd Keck",
|
||||||
"Briton Heilbrun",
|
"Briton Heilbrun",
|
||||||
@@ -120,16 +126,17 @@
|
|||||||
"AM Kuro",
|
"AM Kuro",
|
||||||
"BadassArabianMofo",
|
"BadassArabianMofo",
|
||||||
"Pascal Dahle",
|
"Pascal Dahle",
|
||||||
"Greg",
|
|
||||||
"Sangheili460",
|
"Sangheili460",
|
||||||
"MagnaInsomnia",
|
"MagnaInsomnia",
|
||||||
"Akira_HentAI",
|
"Akira_HentAI",
|
||||||
|
"Karl P.",
|
||||||
"lmsupporter",
|
"lmsupporter",
|
||||||
"andrew.tappan",
|
"andrew.tappan",
|
||||||
"N/A",
|
"N/A",
|
||||||
|
"graysock",
|
||||||
"Greenmoustache",
|
"Greenmoustache",
|
||||||
"zounic",
|
"zounic",
|
||||||
"wfpearl",
|
"fancypants",
|
||||||
"Eldithor",
|
"Eldithor",
|
||||||
"Jack B Nimble",
|
"Jack B Nimble",
|
||||||
"JaxMax",
|
"JaxMax",
|
||||||
@@ -137,23 +144,25 @@
|
|||||||
"Jwk0205",
|
"Jwk0205",
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
"Olive",
|
"Olive",
|
||||||
"LacesOut!",
|
|
||||||
"greebles",
|
"greebles",
|
||||||
"Some Guy Named Barry",
|
"Some Guy Named Barry",
|
||||||
"M Postkasse",
|
"M Postkasse",
|
||||||
|
"AELOX",
|
||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
|
"Nicfit23",
|
||||||
"wamekukyouzin",
|
"wamekukyouzin",
|
||||||
"OldBones",
|
"OldBones",
|
||||||
"Jacob Hoehler",
|
"drum matthieu",
|
||||||
"Dogmaster",
|
"Dogmaster",
|
||||||
"Matt Wenzel",
|
"Matt Wenzel",
|
||||||
"Lex Song",
|
"Lex Song",
|
||||||
"Cory Paza",
|
"Christopher Michel",
|
||||||
"Gonzalo Andre Allendes Lopez",
|
"Gonzalo Andre Allendes Lopez",
|
||||||
"Zach Gonser",
|
"Zach Gonser",
|
||||||
"Serge Bekenkamp",
|
"Serge Bekenkamp",
|
||||||
"Jimmy Ledbetter",
|
"Jimmy Ledbetter",
|
||||||
"Philip Hempel",
|
"LeoZero",
|
||||||
|
"Dustin Chen",
|
||||||
"dan",
|
"dan",
|
||||||
"aai",
|
"aai",
|
||||||
"Mouthlessman",
|
"Mouthlessman",
|
||||||
@@ -163,21 +172,22 @@
|
|||||||
"Ran C",
|
"Ran C",
|
||||||
"ViperC",
|
"ViperC",
|
||||||
"Penfore",
|
"Penfore",
|
||||||
"Karl P.",
|
|
||||||
"Gordon Cole",
|
"Gordon Cole",
|
||||||
"Adam Taylor",
|
"Adam Taylor",
|
||||||
"AbstractAss",
|
"AbstractAss",
|
||||||
"Weird_With_A_Beard",
|
"Weird_With_A_Beard",
|
||||||
"The Spawn",
|
"The Spawn",
|
||||||
"graysock",
|
|
||||||
"Pozadine1",
|
"Pozadine1",
|
||||||
"Qarob",
|
"Qarob",
|
||||||
"AIGooner",
|
"AIGooner",
|
||||||
"Luc",
|
"Luc",
|
||||||
"ProtonPrince",
|
"ProtonPrince",
|
||||||
"DiffDuck",
|
"DiffDuck",
|
||||||
|
"elu3199",
|
||||||
|
"Hasturkun",
|
||||||
|
"Jon Sandman",
|
||||||
|
"Ubivis",
|
||||||
"Jackthemind",
|
"Jackthemind",
|
||||||
"fancypants",
|
|
||||||
"Joboshy",
|
"Joboshy",
|
||||||
"Digital",
|
"Digital",
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
@@ -186,6 +196,7 @@
|
|||||||
"Bro Xie",
|
"Bro Xie",
|
||||||
"yer fey",
|
"yer fey",
|
||||||
"batblue",
|
"batblue",
|
||||||
|
"Error_Rule34_Not_found",
|
||||||
"carey6409",
|
"carey6409",
|
||||||
"太郎 ゲーム",
|
"太郎 ゲーム",
|
||||||
"Roslynd",
|
"Roslynd",
|
||||||
@@ -193,17 +204,12 @@
|
|||||||
"Neco28",
|
"Neco28",
|
||||||
"Cosmosis",
|
"Cosmosis",
|
||||||
"David Ortega",
|
"David Ortega",
|
||||||
"AELOX",
|
|
||||||
"Dankin",
|
"Dankin",
|
||||||
"Nicfit23",
|
|
||||||
"FloPro4Sho",
|
"FloPro4Sho",
|
||||||
"Cristian Vazquez",
|
"Cristian Vazquez",
|
||||||
"drum matthieu",
|
|
||||||
"Frank Nitty",
|
"Frank Nitty",
|
||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"Christopher Michel",
|
|
||||||
"DougPeterson",
|
"DougPeterson",
|
||||||
"LeoZero",
|
|
||||||
"Antonio Pontes",
|
"Antonio Pontes",
|
||||||
"ApathyJones",
|
"ApathyJones",
|
||||||
"Bruce",
|
"Bruce",
|
||||||
@@ -211,7 +217,7 @@
|
|||||||
"Steven Owens",
|
"Steven Owens",
|
||||||
"nahinahi9",
|
"nahinahi9",
|
||||||
"Kevin John Duck",
|
"Kevin John Duck",
|
||||||
"Dustin Chen",
|
"Kevin Christopher",
|
||||||
"Blackfish95",
|
"Blackfish95",
|
||||||
"Paul Kroll",
|
"Paul Kroll",
|
||||||
"Bas Imagineer",
|
"Bas Imagineer",
|
||||||
@@ -219,10 +225,6 @@
|
|||||||
"yuxz69",
|
"yuxz69",
|
||||||
"esthe",
|
"esthe",
|
||||||
"decoy",
|
"decoy",
|
||||||
"elu3199",
|
|
||||||
"Hasturkun",
|
|
||||||
"Jon Sandman",
|
|
||||||
"Ubivis",
|
|
||||||
"CloudValley",
|
"CloudValley",
|
||||||
"thesoftwaredruid",
|
"thesoftwaredruid",
|
||||||
"wundershark",
|
"wundershark",
|
||||||
@@ -236,6 +238,8 @@
|
|||||||
"linnfrey",
|
"linnfrey",
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
|
"capn",
|
||||||
|
"Joseph",
|
||||||
"IamAyam",
|
"IamAyam",
|
||||||
"skaterb949",
|
"skaterb949",
|
||||||
"Brian M",
|
"Brian M",
|
||||||
@@ -243,11 +247,12 @@
|
|||||||
"Nerezza",
|
"Nerezza",
|
||||||
"sanborondon",
|
"sanborondon",
|
||||||
"confiscated Zyra",
|
"confiscated Zyra",
|
||||||
"Error_Rule34_Not_found",
|
|
||||||
"Taylor Funk",
|
"Taylor Funk",
|
||||||
"aezin",
|
"aezin",
|
||||||
|
"Thought2Form",
|
||||||
"jcay015",
|
"jcay015",
|
||||||
"Gerald Welly",
|
"Gerald Welly",
|
||||||
|
"Kevin Picco",
|
||||||
"Erik Lopez",
|
"Erik Lopez",
|
||||||
"Mateo Curić",
|
"Mateo Curić",
|
||||||
"Tee Gee",
|
"Tee Gee",
|
||||||
@@ -255,6 +260,7 @@
|
|||||||
"tarek helmi",
|
"tarek helmi",
|
||||||
"Eris3D",
|
"Eris3D",
|
||||||
"Max Marklund",
|
"Max Marklund",
|
||||||
|
"m",
|
||||||
"Pronredn",
|
"Pronredn",
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
"a _",
|
"a _",
|
||||||
@@ -262,14 +268,16 @@
|
|||||||
"lh qwe",
|
"lh qwe",
|
||||||
"James Coleman",
|
"James Coleman",
|
||||||
"conner",
|
"conner",
|
||||||
"Kevin Christopher",
|
"Ouro Boros",
|
||||||
"Chad Idk",
|
"Chad Idk",
|
||||||
"dd",
|
"dd",
|
||||||
"Princess Bright Eyes",
|
"Princess Bright Eyes",
|
||||||
|
"Yuji Kaneko",
|
||||||
"Dušan Ryban",
|
"Dušan Ryban",
|
||||||
"Felipe dos Santos",
|
"Felipe dos Santos",
|
||||||
"Sam",
|
"Sam",
|
||||||
"sjon kreutz",
|
"sjon kreutz",
|
||||||
|
"Ace Ventura",
|
||||||
"Douglas Gaspar",
|
"Douglas Gaspar",
|
||||||
"Metryman55",
|
"Metryman55",
|
||||||
"AlexDuKaNa",
|
"AlexDuKaNa",
|
||||||
@@ -278,8 +286,7 @@
|
|||||||
"地獄の禄",
|
"地獄の禄",
|
||||||
"Gamalonia",
|
"Gamalonia",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"capn",
|
"momokai",
|
||||||
"Joseph",
|
|
||||||
"Mirko Katzula",
|
"Mirko Katzula",
|
||||||
"dan",
|
"dan",
|
||||||
"Piccio08",
|
"Piccio08",
|
||||||
@@ -294,8 +301,19 @@
|
|||||||
"ken",
|
"ken",
|
||||||
"epicgamer0020690",
|
"epicgamer0020690",
|
||||||
"Joshua Porrata",
|
"Joshua Porrata",
|
||||||
|
"keemun",
|
||||||
"SuBu",
|
"SuBu",
|
||||||
"RedPIXel",
|
"RedPIXel",
|
||||||
|
"Wind",
|
||||||
|
"Nexus",
|
||||||
|
"Ramneek“Guy”Ashok",
|
||||||
|
"squid_actually",
|
||||||
|
"Nat_20",
|
||||||
|
"Edward Weeks",
|
||||||
|
"kyoumei",
|
||||||
|
"RadStorm04",
|
||||||
|
"JohnDoe42054",
|
||||||
|
"gzmzmvp",
|
||||||
"Richard",
|
"Richard",
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"Andrew",
|
"Andrew",
|
||||||
@@ -303,12 +321,12 @@
|
|||||||
"Littlehuggy",
|
"Littlehuggy",
|
||||||
"준희 김",
|
"준희 김",
|
||||||
"Brian Buie",
|
"Brian Buie",
|
||||||
"Thought2Form",
|
|
||||||
"Kevin Picco",
|
|
||||||
"Sadlip",
|
"Sadlip",
|
||||||
|
"Eric Whitney",
|
||||||
"Joey Callahan",
|
"Joey Callahan",
|
||||||
|
"Ivan Tadic",
|
||||||
"Tomohiro Baba",
|
"Tomohiro Baba",
|
||||||
"m",
|
"Mike Simone",
|
||||||
"Noora",
|
"Noora",
|
||||||
"Pierce McBride",
|
"Pierce McBride",
|
||||||
"Joshua Gray",
|
"Joshua Gray",
|
||||||
@@ -322,37 +340,24 @@
|
|||||||
"Martial",
|
"Martial",
|
||||||
"Michael Anthony Scott",
|
"Michael Anthony Scott",
|
||||||
"Emil Andersson",
|
"Emil Andersson",
|
||||||
"Ouro Boros",
|
|
||||||
"Atilla Berke Pekduyar",
|
"Atilla Berke Pekduyar",
|
||||||
"Steam Steam",
|
"Steam Steam",
|
||||||
"CryptoTraderJK",
|
"CryptoTraderJK",
|
||||||
"Decx _",
|
"Decx _",
|
||||||
"Yuji Kaneko",
|
|
||||||
"Davaitamin",
|
"Davaitamin",
|
||||||
"Rops Alot",
|
"Rops Alot",
|
||||||
"tedcor",
|
"tedcor",
|
||||||
"Fotek Design",
|
"Fotek Design",
|
||||||
"Ace Ventura",
|
|
||||||
"四糸凜音",
|
"四糸凜音",
|
||||||
"Nihongasuki",
|
"Nihongasuki",
|
||||||
"LarsesFPC",
|
"LarsesFPC",
|
||||||
"MadSpin",
|
"MadSpin",
|
||||||
|
"FrxzenSnxw",
|
||||||
"inbijiburu",
|
"inbijiburu",
|
||||||
"Nick “Loadstone” D",
|
"Nick “Loadstone” D",
|
||||||
"momokai",
|
|
||||||
"starbugx",
|
"starbugx",
|
||||||
"dc7431",
|
"dc7431",
|
||||||
"Crocket",
|
"Crocket",
|
||||||
"keemun",
|
|
||||||
"Wind",
|
|
||||||
"Nexus",
|
|
||||||
"Ramneek“Guy”Ashok",
|
|
||||||
"squid_actually",
|
|
||||||
"Nat_20",
|
|
||||||
"Edward Weeks",
|
|
||||||
"kyoumei",
|
|
||||||
"RadStorm04",
|
|
||||||
"JohnDoe42054",
|
|
||||||
"BillyHill",
|
"BillyHill",
|
||||||
"emyth",
|
"emyth",
|
||||||
"chriphost",
|
"chriphost",
|
||||||
@@ -374,26 +379,30 @@
|
|||||||
"Adam Rinehart",
|
"Adam Rinehart",
|
||||||
"Pitpe11",
|
"Pitpe11",
|
||||||
"TheD1rtyD03",
|
"TheD1rtyD03",
|
||||||
"gzmzmvp",
|
"moonpetal",
|
||||||
|
"g9p0o",
|
||||||
|
"TheHolySheep",
|
||||||
|
"Monte Won",
|
||||||
"Gregory Kozhemiak",
|
"Gregory Kozhemiak",
|
||||||
"Draven T",
|
"Draven T",
|
||||||
"mrjuan",
|
"mrjuan",
|
||||||
"Eric Whitney",
|
"elleshar666",
|
||||||
"Aquatic Coffee",
|
"Aquatic Coffee",
|
||||||
"Ivan Tadic",
|
|
||||||
"Mike Simone",
|
|
||||||
"John J Linehan",
|
"John J Linehan",
|
||||||
"ethanfel",
|
"ethanfel",
|
||||||
"Elliot E",
|
"Elliot E",
|
||||||
"Morgandel",
|
"Morgandel",
|
||||||
"Theerat Jiramate",
|
"Theerat Jiramate",
|
||||||
"Focuschannel",
|
"Focuschannel",
|
||||||
|
"Edward Kennedy",
|
||||||
"Noah",
|
"Noah",
|
||||||
"X",
|
"X",
|
||||||
"Sloan Steddy",
|
"Sloan Steddy",
|
||||||
|
"Vane Holzer",
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"Anthony Faxlandez",
|
"Anthony Faxlandez",
|
||||||
"battu",
|
"battu",
|
||||||
|
"notedfakes",
|
||||||
"Nathan",
|
"Nathan",
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
@@ -406,24 +415,24 @@
|
|||||||
"JC",
|
"JC",
|
||||||
"Prompt Pirate",
|
"Prompt Pirate",
|
||||||
"uwutismxd",
|
"uwutismxd",
|
||||||
"FrxzenSnxw",
|
|
||||||
"zenobeus",
|
"zenobeus",
|
||||||
"ryoma",
|
"ryoma",
|
||||||
|
"Whitepinetrader",
|
||||||
"Stryker",
|
"Stryker",
|
||||||
"Ginnie",
|
"Ginnie",
|
||||||
"Raku",
|
"Raku",
|
||||||
"smart.edge5178",
|
"smart.edge5178",
|
||||||
"Menard",
|
"Menard",
|
||||||
"moonpetal",
|
|
||||||
"SomeDude",
|
"SomeDude",
|
||||||
"g9p0o",
|
|
||||||
"Pkrsky",
|
"Pkrsky",
|
||||||
"TheHolySheep",
|
"nanana",
|
||||||
"raf8osz",
|
"raf8osz",
|
||||||
"Monte Won",
|
|
||||||
"SpringBootisTrash",
|
"SpringBootisTrash",
|
||||||
"carsten",
|
"carsten",
|
||||||
"ikok",
|
"ikok",
|
||||||
|
"Doug+Rintoul",
|
||||||
|
"Noor",
|
||||||
|
"Yorunai",
|
||||||
"quantenmecha",
|
"quantenmecha",
|
||||||
"Jason+Nash",
|
"Jason+Nash",
|
||||||
"DarkRoast",
|
"DarkRoast",
|
||||||
@@ -441,7 +450,6 @@
|
|||||||
"blikkies",
|
"blikkies",
|
||||||
"Chris",
|
"Chris",
|
||||||
"Time Valentine",
|
"Time Valentine",
|
||||||
"elleshar666",
|
|
||||||
"Shock Shockor",
|
"Shock Shockor",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
"Михал Михалыч",
|
"Михал Михалыч",
|
||||||
@@ -451,15 +459,13 @@
|
|||||||
"Zude",
|
"Zude",
|
||||||
"SPJ",
|
"SPJ",
|
||||||
"Kyler",
|
"Kyler",
|
||||||
"Edward Kennedy",
|
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"aRtFuL_DodGeR",
|
"aRtFuL_DodGeR",
|
||||||
"Nick Kage",
|
"Nick Kage",
|
||||||
"Vane Holzer",
|
|
||||||
"psytrax",
|
"psytrax",
|
||||||
"Cyrus Fett",
|
"Cyrus Fett",
|
||||||
"Xenon Xue",
|
"Xenon Xue",
|
||||||
"notedfakes",
|
"Edward Ten Eyck",
|
||||||
"Billy Gladky",
|
"Billy Gladky",
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
"Probis",
|
"Probis",
|
||||||
@@ -468,6 +474,7 @@
|
|||||||
"ItsGeneralButtNaked",
|
"ItsGeneralButtNaked",
|
||||||
"Donor4115",
|
"Donor4115",
|
||||||
"Distortik",
|
"Distortik",
|
||||||
|
"Manu Thetug",
|
||||||
"Filippo Ferrari",
|
"Filippo Ferrari",
|
||||||
"Youguang",
|
"Youguang",
|
||||||
"andrewzpong",
|
"andrewzpong",
|
||||||
@@ -478,9 +485,10 @@
|
|||||||
"AIVORY3D",
|
"AIVORY3D",
|
||||||
"Kevinj",
|
"Kevinj",
|
||||||
"Mitchell Robson",
|
"Mitchell Robson",
|
||||||
"Whitepinetrader",
|
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
"nanana",
|
"G",
|
||||||
|
"Tan+Huynh",
|
||||||
|
"Bob+Barker",
|
||||||
"D",
|
"D",
|
||||||
"Dark_Pest",
|
"Dark_Pest",
|
||||||
"Alex",
|
"Alex",
|
||||||
@@ -497,9 +505,6 @@
|
|||||||
"Alan+Cano",
|
"Alan+Cano",
|
||||||
"FeralOpticsAI",
|
"FeralOpticsAI",
|
||||||
"Pavlaki",
|
"Pavlaki",
|
||||||
"Doug+Rintoul",
|
|
||||||
"Noor",
|
|
||||||
"Yorunai",
|
|
||||||
"BillyBoy84",
|
"BillyBoy84",
|
||||||
"Buecyb99",
|
"Buecyb99",
|
||||||
"Welkor",
|
"Welkor",
|
||||||
@@ -508,8 +513,10 @@
|
|||||||
"JBsuede",
|
"JBsuede",
|
||||||
"moranqianlong",
|
"moranqianlong",
|
||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
|
"Ronan Delevacq",
|
||||||
"Christian Schäfer",
|
"Christian Schäfer",
|
||||||
"りん あめ",
|
"りん あめ",
|
||||||
|
"Dave Abraham",
|
||||||
"Joaquin Hierrezuelo",
|
"Joaquin Hierrezuelo",
|
||||||
"Locrospiel",
|
"Locrospiel",
|
||||||
"Frogmilk",
|
"Frogmilk",
|
||||||
@@ -519,16 +526,17 @@
|
|||||||
"John Rednoulf",
|
"John Rednoulf",
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
"Bryan Rutkowski",
|
"Bryan Rutkowski",
|
||||||
|
"Boba Smith",
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
"SendingRavens",
|
"SendingRavens",
|
||||||
|
"Sauv",
|
||||||
"Steven",
|
"Steven",
|
||||||
"JackJohnnyJim",
|
"JackJohnnyJim",
|
||||||
"TenaciousD",
|
"TenaciousD",
|
||||||
"Dmitry Ryzhov",
|
"Dmitry Ryzhov",
|
||||||
"Khánh Đặng",
|
"Khánh Đặng",
|
||||||
"Edward Ten Eyck",
|
|
||||||
"Michael Docherty",
|
"Michael Docherty",
|
||||||
"Jimmy Borup",
|
"Jimmy Borup",
|
||||||
"Paul Hartsuyker",
|
"Paul Hartsuyker",
|
||||||
@@ -539,7 +547,6 @@
|
|||||||
"jinksta187",
|
"jinksta187",
|
||||||
"RHopkirk",
|
"RHopkirk",
|
||||||
"Andrew Wilkinson",
|
"Andrew Wilkinson",
|
||||||
"Manu Thetug",
|
|
||||||
"Karlanx",
|
"Karlanx",
|
||||||
"Lyavph",
|
"Lyavph",
|
||||||
"Maxim",
|
"Maxim",
|
||||||
@@ -564,6 +571,15 @@
|
|||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"D",
|
"D",
|
||||||
|
"SAVEagleBasement",
|
||||||
|
"Kevin+Isom",
|
||||||
|
"Rune+Osnes",
|
||||||
|
"you+halo9",
|
||||||
|
"cloudghost",
|
||||||
|
"Yongkwan+Lee",
|
||||||
|
"PoorStudent",
|
||||||
|
"lucites",
|
||||||
|
"Alex+Zaw",
|
||||||
"Mobius2020",
|
"Mobius2020",
|
||||||
"ExLightSaber",
|
"ExLightSaber",
|
||||||
"YaboiRay",
|
"YaboiRay",
|
||||||
@@ -590,33 +606,28 @@
|
|||||||
"Flob",
|
"Flob",
|
||||||
"ShiroSenpai",
|
"ShiroSenpai",
|
||||||
"Inkognito",
|
"Inkognito",
|
||||||
"G",
|
|
||||||
"Tan+Huynh",
|
|
||||||
"Jacky+Ho",
|
"Jacky+Ho",
|
||||||
"generic404",
|
"generic404",
|
||||||
"abattoirblues",
|
"abattoirblues",
|
||||||
"zounik",
|
"zounik",
|
||||||
"4IXplr0r3r",
|
"4IXplr0r3r",
|
||||||
"hayden",
|
"hayden",
|
||||||
|
"Obsidian.Studios",
|
||||||
"ahoystan",
|
"ahoystan",
|
||||||
"Bob Barker",
|
|
||||||
"edk",
|
"edk",
|
||||||
"Tú Nguyễn Lý Hoàng",
|
"Tú Nguyễn Lý Hoàng",
|
||||||
"shira1011",
|
"shira1011",
|
||||||
"Ben D",
|
"Ben D",
|
||||||
"G",
|
"G",
|
||||||
"Ronan Delevacq",
|
|
||||||
"ja s",
|
"ja s",
|
||||||
"Leslie Andrew Ridings",
|
"Leslie Andrew Ridings",
|
||||||
"Doug Mason",
|
"Doug Mason",
|
||||||
"Jeremy Townsend",
|
"scoreswazey",
|
||||||
"Dave Abraham",
|
|
||||||
"Owen Gwosdz",
|
"Owen Gwosdz",
|
||||||
"Jarrid Lee",
|
"Jarrid Lee",
|
||||||
"Poophead27 Blyat",
|
"Poophead27 Blyat",
|
||||||
"Spire",
|
"Mythspire",
|
||||||
"AZ Party Oasis",
|
"AZ Party Oasis",
|
||||||
"Boba Smith",
|
|
||||||
"Devil Lude",
|
"Devil Lude",
|
||||||
"David Murcko",
|
"David Murcko",
|
||||||
"MR.Bear",
|
"MR.Bear",
|
||||||
@@ -626,8 +637,8 @@
|
|||||||
"Terminuz",
|
"Terminuz",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
"max blo",
|
"max blo",
|
||||||
"Sauv",
|
|
||||||
"CptNeo",
|
"CptNeo",
|
||||||
|
"Jack Lawfield",
|
||||||
"Borte",
|
"Borte",
|
||||||
"Maso",
|
"Maso",
|
||||||
"Ted Cart",
|
"Ted Cart",
|
||||||
@@ -642,6 +653,7 @@
|
|||||||
"SkibidiRizzler",
|
"SkibidiRizzler",
|
||||||
"Tania Nayelli Fernandez",
|
"Tania Nayelli Fernandez",
|
||||||
"Draconach",
|
"Draconach",
|
||||||
|
"Kalle Björk",
|
||||||
"Yavizu3d",
|
"Yavizu3d",
|
||||||
"Yves Poezevara",
|
"Yves Poezevara",
|
||||||
"Teriak47",
|
"Teriak47",
|
||||||
@@ -694,6 +706,10 @@
|
|||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
|
"thomasand01",
|
||||||
|
"Shiba+Sama",
|
||||||
|
"miduzza",
|
||||||
|
"KB",
|
||||||
"shw",
|
"shw",
|
||||||
"Celestial+Kitten",
|
"Celestial+Kitten",
|
||||||
"bakeliteboy",
|
"bakeliteboy",
|
||||||
@@ -716,21 +732,12 @@
|
|||||||
"imer",
|
"imer",
|
||||||
"Akkas+Haque",
|
"Akkas+Haque",
|
||||||
"Kachac",
|
"Kachac",
|
||||||
"tyrant2811",
|
|
||||||
"Kevin",
|
|
||||||
"Rune+Osnes",
|
|
||||||
"jcx29",
|
"jcx29",
|
||||||
"cloudghost",
|
|
||||||
"Yongkwan+Lee",
|
|
||||||
"PoorStudent",
|
|
||||||
"lucites",
|
|
||||||
"Alex+Zaw",
|
|
||||||
"Drizzly",
|
"Drizzly",
|
||||||
"Nebuleux",
|
"Nebuleux",
|
||||||
"Join+Chun",
|
"Join+Chun",
|
||||||
"GDS+DEV",
|
"GDS+DEV",
|
||||||
"4rt+r3d",
|
"4rt+r3d",
|
||||||
"you+halo9",
|
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Crescent~San",
|
"Crescent~San",
|
||||||
@@ -743,7 +750,6 @@
|
|||||||
"Bula",
|
"Bula",
|
||||||
"KUJYAKU",
|
"KUJYAKU",
|
||||||
"Coeur+de+cochon",
|
"Coeur+de+cochon",
|
||||||
"Obsidian.Studios",
|
|
||||||
"han b",
|
"han b",
|
||||||
"Zomba Mann",
|
"Zomba Mann",
|
||||||
"Aquaneo",
|
"Aquaneo",
|
||||||
@@ -769,9 +775,12 @@
|
|||||||
"Seraphy",
|
"Seraphy",
|
||||||
"雨の心 落",
|
"雨の心 落",
|
||||||
"AllTimeNoobie",
|
"AllTimeNoobie",
|
||||||
|
"swra",
|
||||||
|
"JollRodrigo",
|
||||||
"jumpd",
|
"jumpd",
|
||||||
"John C",
|
"John C",
|
||||||
"Rim",
|
"Rim",
|
||||||
|
"Oliverfish",
|
||||||
"yfx507",
|
"yfx507",
|
||||||
"Room Light",
|
"Room Light",
|
||||||
"Jairus Knudsen",
|
"Jairus Knudsen",
|
||||||
@@ -798,7 +807,6 @@
|
|||||||
"Bouya shaka",
|
"Bouya shaka",
|
||||||
"john Greene",
|
"john Greene",
|
||||||
"Faburizu",
|
"Faburizu",
|
||||||
"Jack Lawfield",
|
|
||||||
"jimyjomson",
|
"jimyjomson",
|
||||||
"JaeHyun Jang",
|
"JaeHyun Jang",
|
||||||
"Homero Banda",
|
"Homero Banda",
|
||||||
@@ -807,7 +815,7 @@
|
|||||||
"yyuvuvu",
|
"yyuvuvu",
|
||||||
"Inyoshu",
|
"Inyoshu",
|
||||||
"Chad Barnes",
|
"Chad Barnes",
|
||||||
"Person Y",
|
"Adam Gardner",
|
||||||
"Nomki",
|
"Nomki",
|
||||||
"inusanorthcape",
|
"inusanorthcape",
|
||||||
"James Ming",
|
"James Ming",
|
||||||
@@ -829,5 +837,5 @@
|
|||||||
"Somebody",
|
"Somebody",
|
||||||
"CK"
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 826
|
"totalCount": 834
|
||||||
}
|
}
|
||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "Verwendungsanzahl"
|
"timesUsed": "Verwendungsanzahl"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} Versionen",
|
||||||
|
"viewAllVersions": "Alle lokalen Versionen anzeigen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Ausgeschlossene Modelle verwalten"
|
"label": "Ausgeschlossene Modelle verwalten"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "Nach Modell gruppieren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "Statistiken"
|
"statistics": "Statistiken"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Suchen...",
|
"placeholder": "Suchen",
|
||||||
"placeholders": {
|
|
||||||
"loras": "LoRAs suchen...",
|
|
||||||
"recipes": "Rezepte suchen...",
|
|
||||||
"checkpoints": "Checkpoints suchen...",
|
|
||||||
"embeddings": "Embeddings suchen..."
|
|
||||||
},
|
|
||||||
"options": "Suchoptionen",
|
"options": "Suchoptionen",
|
||||||
"searchIn": "Suchen in:",
|
"searchIn": "Suchen in:",
|
||||||
"notAvailable": "Suche auf Statistikseite nicht verfügbar",
|
"notAvailable": "Suche auf Statistikseite nicht verfügbar",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||||
"priorityTags": "Prioritäts-Tags",
|
"priorityTags": "Prioritäts-Tags",
|
||||||
"updateFlags": "Update-Markierungen",
|
"versionScope": "Update-Markierungen",
|
||||||
"exampleImages": "Beispielbilder",
|
"exampleImages": "Beispielbilder",
|
||||||
"autoOrganize": "Auto-Organisierung",
|
"autoOrganize": "Auto-Organisierung",
|
||||||
"metadata": "Metadaten",
|
"metadata": "Metadaten",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "Nach Modell gruppieren",
|
||||||
|
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
||||||
"displayDensity": "Anzeige-Dichte",
|
"displayDensity": "Anzeige-Dichte",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Standard",
|
"default": "Standard",
|
||||||
@@ -586,7 +589,7 @@
|
|||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"restartRequired": "Neustart erforderlich"
|
"restartRequired": "Neustart erforderlich"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "Strategie für Update-Markierungen",
|
"label": "Strategie für Update-Markierungen",
|
||||||
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
|
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "Kleinste",
|
"sizeAsc": "Kleinste",
|
||||||
"usage": "Anzahl Nutzung",
|
"usage": "Anzahl Nutzung",
|
||||||
"usageDesc": "Meiste",
|
"usageDesc": "Meiste",
|
||||||
"usageAsc": "Wenigste"
|
"usageAsc": "Wenigste",
|
||||||
|
"versionsCount": "Lokale Versionen",
|
||||||
|
"versionsCountDesc": "Meiste Versionen zuerst",
|
||||||
|
"versionsCountAsc": "Wenigste Versionen zuerst",
|
||||||
|
"versionIdDesc": "Neueste Version zuerst"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Modelliste aktualisieren",
|
"title": "Modelliste aktualisieren",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
||||||
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
|
"embeddingAdded": "Embedding zum Workflow hinzugefügt",
|
||||||
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings"
|
"embeddingFailed": "Fehler beim Hinzufügen des Embeddings",
|
||||||
|
"promptSent": "Prompt an Workflow gesendet",
|
||||||
|
"promptFailed": "Fehler beim Senden des Prompts"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Rezept",
|
"recipe": "Rezept",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "Prompt",
|
||||||
"replace": "Ersetzen",
|
"replace": "Ersetzen",
|
||||||
"append": "Anhängen",
|
"append": "Anhängen",
|
||||||
"selectTargetNode": "Zielknoten auswählen",
|
"selectTargetNode": "Zielknoten auswählen",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
|
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
|
||||||
"reconnectedSuccessfully": "LoRA erfolgreich neu verbunden",
|
"reconnectedSuccessfully": "LoRA erfolgreich neu verbunden",
|
||||||
"reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}",
|
"reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}",
|
||||||
|
"noPromptToSend": "Kein zu sendender Prompt",
|
||||||
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
|
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
|
||||||
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
|
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
|
||||||
"sendError": "Fehler beim Senden des Rezepts an Workflow",
|
"sendError": "Fehler beim Senden des Rezepts an Workflow",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "Times used"
|
"timesUsed": "Times used"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} versions",
|
||||||
|
"viewAllVersions": "View all local versions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Manage Excluded Models"
|
"label": "Manage Excluded Models"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "Group by Model"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "Stats"
|
"statistics": "Stats"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search...",
|
"placeholder": "Search",
|
||||||
"placeholders": {
|
|
||||||
"loras": "Search LoRAs...",
|
|
||||||
"recipes": "Search recipes...",
|
|
||||||
"checkpoints": "Search checkpoints...",
|
|
||||||
"embeddings": "Search embeddings..."
|
|
||||||
},
|
|
||||||
"options": "Search Options",
|
"options": "Search Options",
|
||||||
"searchIn": "Search In:",
|
"searchIn": "Search In:",
|
||||||
"notAvailable": "Search not available on statistics page",
|
"notAvailable": "Search not available on statistics page",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "Extra Folder Paths",
|
"extraFolderPaths": "Extra Folder Paths",
|
||||||
"downloadPathTemplates": "Download Path Templates",
|
"downloadPathTemplates": "Download Path Templates",
|
||||||
"priorityTags": "Priority Tags",
|
"priorityTags": "Priority Tags",
|
||||||
"updateFlags": "Update Flags",
|
"versionScope": "Version Scope",
|
||||||
"exampleImages": "Example Images",
|
"exampleImages": "Example Images",
|
||||||
"autoOrganize": "Auto-organize",
|
"autoOrganize": "Auto-organize",
|
||||||
"metadata": "Metadata",
|
"metadata": "Metadata",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "When enabled, versions downloaded before will be skipped."
|
"help": "When enabled, versions downloaded before will be skipped."
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"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",
|
"displayDensity": "Display Density",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
@@ -586,12 +589,12 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"restartRequired": "Requires restart"
|
"restartRequired": "Requires restart"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "Update Flag Strategy",
|
"label": "Version Grouping",
|
||||||
"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.",
|
"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": {
|
"options": {
|
||||||
"sameBase": "Match updates by base model",
|
"sameBase": "Group by base model (same_base)",
|
||||||
"any": "Flag any available update"
|
"any": "Show all versions (any)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hideEarlyAccessUpdates": {
|
"hideEarlyAccessUpdates": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "Smallest",
|
"sizeAsc": "Smallest",
|
||||||
"usage": "Use Count",
|
"usage": "Use Count",
|
||||||
"usageDesc": "Most",
|
"usageDesc": "Most",
|
||||||
"usageAsc": "Least"
|
"usageAsc": "Least",
|
||||||
|
"versionsCount": "Local Versions",
|
||||||
|
"versionsCountDesc": "Most versions first",
|
||||||
|
"versionsCountAsc": "Fewest versions first",
|
||||||
|
"versionIdDesc": "Newest version first"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh model list",
|
"title": "Refresh model list",
|
||||||
@@ -1463,7 +1470,7 @@
|
|||||||
"resumeModelUpdates": "Resume updates for this model",
|
"resumeModelUpdates": "Resume updates for this model",
|
||||||
"ignoreModelUpdates": "Ignore updates for this model",
|
"ignoreModelUpdates": "Ignore updates for this model",
|
||||||
"viewLocalVersions": "View all local versions",
|
"viewLocalVersions": "View all local versions",
|
||||||
"viewLocalTooltip": "Coming soon"
|
"viewLocalTooltip": "Show all local versions of this model on the main page"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"label": "Base filter",
|
"label": "Base filter",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "Model updated in workflow",
|
"modelUpdated": "Model updated in workflow",
|
||||||
"modelFailed": "Failed to update model node",
|
"modelFailed": "Failed to update model node",
|
||||||
"embeddingAdded": "Embedding added to workflow",
|
"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": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "Prompt",
|
||||||
"replace": "Replace",
|
"replace": "Replace",
|
||||||
"append": "Append",
|
"append": "Append",
|
||||||
"selectTargetNode": "Select target node",
|
"selectTargetNode": "Select target node",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "Please enter a LoRA name or syntax",
|
"enterLoraName": "Please enter a LoRA name or syntax",
|
||||||
"reconnectedSuccessfully": "LoRA reconnected successfully",
|
"reconnectedSuccessfully": "LoRA reconnected successfully",
|
||||||
"reconnectFailed": "Error reconnecting LoRA: {message}",
|
"reconnectFailed": "Error reconnecting LoRA: {message}",
|
||||||
|
"noPromptToSend": "No prompt to send",
|
||||||
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
||||||
"sendFailed": "Failed to send recipe to workflow",
|
"sendFailed": "Failed to send recipe to workflow",
|
||||||
"sendError": "Error sending recipe to workflow",
|
"sendError": "Error sending recipe to workflow",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "Veces usado"
|
"timesUsed": "Veces usado"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} versiones",
|
||||||
|
"viewAllVersions": "Ver todas las versiones locales"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Gestionar modelos excluidos"
|
"label": "Gestionar modelos excluidos"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "Agrupar por modelo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "Estadísticas"
|
"statistics": "Estadísticas"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Buscar...",
|
"placeholder": "Buscar",
|
||||||
"placeholders": {
|
|
||||||
"loras": "Buscar LoRAs...",
|
|
||||||
"recipes": "Buscar recetas...",
|
|
||||||
"checkpoints": "Buscar checkpoints...",
|
|
||||||
"embeddings": "Buscar embeddings..."
|
|
||||||
},
|
|
||||||
"options": "Opciones de búsqueda",
|
"options": "Opciones de búsqueda",
|
||||||
"searchIn": "Buscar en:",
|
"searchIn": "Buscar en:",
|
||||||
"notAvailable": "Búsqueda no disponible en la página de estadísticas",
|
"notAvailable": "Búsqueda no disponible en la página de estadísticas",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||||
"priorityTags": "Etiquetas prioritarias",
|
"priorityTags": "Etiquetas prioritarias",
|
||||||
"updateFlags": "Indicadores de actualización",
|
"versionScope": "Indicadores de actualización",
|
||||||
"exampleImages": "Imágenes de ejemplo",
|
"exampleImages": "Imágenes de ejemplo",
|
||||||
"autoOrganize": "Organización automática",
|
"autoOrganize": "Organización automática",
|
||||||
"metadata": "Metadatos",
|
"metadata": "Metadatos",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "Agrupar por modelo",
|
||||||
|
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
|
||||||
"displayDensity": "Densidad de visualización",
|
"displayDensity": "Densidad de visualización",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Predeterminado",
|
"default": "Predeterminado",
|
||||||
@@ -586,7 +589,7 @@
|
|||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"restartRequired": "Requiere reinicio"
|
"restartRequired": "Requiere reinicio"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "Estrategia de indicadores de actualización",
|
"label": "Estrategia de indicadores de actualización",
|
||||||
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
|
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "Menor",
|
"sizeAsc": "Menor",
|
||||||
"usage": "Número de usos",
|
"usage": "Número de usos",
|
||||||
"usageDesc": "Más",
|
"usageDesc": "Más",
|
||||||
"usageAsc": "Menos"
|
"usageAsc": "Menos",
|
||||||
|
"versionsCount": "Versiones locales",
|
||||||
|
"versionsCountDesc": "Más versiones primero",
|
||||||
|
"versionsCountAsc": "Menos versiones primero",
|
||||||
|
"versionIdDesc": "Versión más nueva primero"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de modelos",
|
"title": "Actualizar lista de modelos",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||||
"modelFailed": "Error al actualizar nodo de modelo",
|
"modelFailed": "Error al actualizar nodo de modelo",
|
||||||
"embeddingAdded": "Embedding añadido al flujo de trabajo",
|
"embeddingAdded": "Embedding añadido al flujo de trabajo",
|
||||||
"embeddingFailed": "Error al añadir el embedding"
|
"embeddingFailed": "Error al añadir el embedding",
|
||||||
|
"promptSent": "Prompt enviado al flujo de trabajo",
|
||||||
|
"promptFailed": "Error al enviar el prompt"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Receta",
|
"recipe": "Receta",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "Prompt",
|
||||||
"replace": "Reemplazar",
|
"replace": "Reemplazar",
|
||||||
"append": "Añadir",
|
"append": "Añadir",
|
||||||
"selectTargetNode": "Seleccionar nodo de destino",
|
"selectTargetNode": "Seleccionar nodo de destino",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
|
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
|
||||||
"reconnectedSuccessfully": "LoRA reconectado exitosamente",
|
"reconnectedSuccessfully": "LoRA reconectado exitosamente",
|
||||||
"reconnectFailed": "Error reconectando LoRA: {message}",
|
"reconnectFailed": "Error reconectando LoRA: {message}",
|
||||||
|
"noPromptToSend": "No hay prompt para enviar",
|
||||||
"cannotSend": "No se puede enviar receta: Falta ID de receta",
|
"cannotSend": "No se puede enviar receta: Falta ID de receta",
|
||||||
"sendFailed": "Error al enviar receta al flujo de trabajo",
|
"sendFailed": "Error al enviar receta al flujo de trabajo",
|
||||||
"sendError": "Error enviando receta al flujo de trabajo",
|
"sendError": "Error enviando receta al flujo de trabajo",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "Nombre d'utilisations"
|
"timesUsed": "Nombre d'utilisations"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} versions",
|
||||||
|
"viewAllVersions": "Voir toutes les versions locales"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Gérer les modèles exclus"
|
"label": "Gérer les modèles exclus"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "Grouper par modèle"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "Statistiques"
|
"statistics": "Statistiques"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Rechercher...",
|
"placeholder": "Rechercher",
|
||||||
"placeholders": {
|
|
||||||
"loras": "Rechercher des LoRAs...",
|
|
||||||
"recipes": "Rechercher des recipes...",
|
|
||||||
"checkpoints": "Rechercher des checkpoints...",
|
|
||||||
"embeddings": "Rechercher des embeddings..."
|
|
||||||
},
|
|
||||||
"options": "Options de recherche",
|
"options": "Options de recherche",
|
||||||
"searchIn": "Rechercher dans :",
|
"searchIn": "Rechercher dans :",
|
||||||
"notAvailable": "Recherche non disponible sur la page de statistiques",
|
"notAvailable": "Recherche non disponible sur la page de statistiques",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||||
"priorityTags": "Étiquettes prioritaires",
|
"priorityTags": "Étiquettes prioritaires",
|
||||||
"updateFlags": "Indicateurs de mise à jour",
|
"versionScope": "Indicateurs de mise à jour",
|
||||||
"exampleImages": "Images d'exemple",
|
"exampleImages": "Images d'exemple",
|
||||||
"autoOrganize": "Organisation automatique",
|
"autoOrganize": "Organisation automatique",
|
||||||
"metadata": "Métadonnées",
|
"metadata": "Métadonnées",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "Grouper par modèle",
|
||||||
|
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
|
||||||
"displayDensity": "Densité d'affichage",
|
"displayDensity": "Densité d'affichage",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Par défaut",
|
"default": "Par défaut",
|
||||||
@@ -586,7 +589,7 @@
|
|||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"restartRequired": "Redémarrage requis"
|
"restartRequired": "Redémarrage requis"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "Stratégie des indicateurs de mise à jour",
|
"label": "Stratégie des indicateurs de mise à jour",
|
||||||
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.",
|
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "Plus petit",
|
"sizeAsc": "Plus petit",
|
||||||
"usage": "Nombre d'utilisations",
|
"usage": "Nombre d'utilisations",
|
||||||
"usageDesc": "Plus",
|
"usageDesc": "Plus",
|
||||||
"usageAsc": "Moins"
|
"usageAsc": "Moins",
|
||||||
|
"versionsCount": "Versions locales",
|
||||||
|
"versionsCountDesc": "Plus de versions d'abord",
|
||||||
|
"versionsCountAsc": "Moins de versions d'abord",
|
||||||
|
"versionIdDesc": "Version la plus récente d'abord"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des modèles",
|
"title": "Actualiser la liste des modèles",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||||
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
||||||
"embeddingAdded": "Embedding ajouté au workflow",
|
"embeddingAdded": "Embedding ajouté au workflow",
|
||||||
"embeddingFailed": "Échec de l'ajout de l'embedding"
|
"embeddingFailed": "Échec de l'ajout de l'embedding",
|
||||||
|
"promptSent": "Prompt envoyé au workflow",
|
||||||
|
"promptFailed": "Échec de l'envoi du prompt"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Recipe",
|
"recipe": "Recipe",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "Prompt",
|
||||||
"replace": "Remplacer",
|
"replace": "Remplacer",
|
||||||
"append": "Ajouter",
|
"append": "Ajouter",
|
||||||
"selectTargetNode": "Sélectionner le nœud cible",
|
"selectTargetNode": "Sélectionner le nœud cible",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
|
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
|
||||||
"reconnectedSuccessfully": "LoRA reconnecté avec succès",
|
"reconnectedSuccessfully": "LoRA reconnecté avec succès",
|
||||||
"reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}",
|
"reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}",
|
||||||
|
"noPromptToSend": "Aucun prompt à envoyer",
|
||||||
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
|
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
|
||||||
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
|
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
|
||||||
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
|
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "מספר שימושים"
|
"timesUsed": "מספר שימושים"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} גרסאות",
|
||||||
|
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "ניהול מודלים מוחרגים"
|
"label": "ניהול מודלים מוחרגים"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "קיבוץ לפי דגם"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "סטטיסטיקה"
|
"statistics": "סטטיסטיקה"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "חפש...",
|
"placeholder": "חיפוש",
|
||||||
"placeholders": {
|
|
||||||
"loras": "חפש LoRAs...",
|
|
||||||
"recipes": "חפש מתכונים...",
|
|
||||||
"checkpoints": "חפש checkpoints...",
|
|
||||||
"embeddings": "חפש embeddings..."
|
|
||||||
},
|
|
||||||
"options": "אפשרויות חיפוש",
|
"options": "אפשרויות חיפוש",
|
||||||
"searchIn": "חפש ב:",
|
"searchIn": "חפש ב:",
|
||||||
"notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה",
|
"notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||||
"priorityTags": "תגיות עדיפות",
|
"priorityTags": "תגיות עדיפות",
|
||||||
"updateFlags": "תגי עדכון",
|
"versionScope": "תגי עדכון",
|
||||||
"exampleImages": "תמונות דוגמה",
|
"exampleImages": "תמונות דוגמה",
|
||||||
"autoOrganize": "ארגון אוטומטי",
|
"autoOrganize": "ארגון אוטומטי",
|
||||||
"metadata": "מטא-נתונים",
|
"metadata": "מטא-נתונים",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "קיבוץ לפי דגם",
|
||||||
|
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||||
"displayDensity": "צפיפות תצוגה",
|
"displayDensity": "צפיפות תצוגה",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "ברירת מחדל",
|
"default": "ברירת מחדל",
|
||||||
@@ -586,7 +589,7 @@
|
|||||||
"download": "הורד",
|
"download": "הורד",
|
||||||
"restartRequired": "דורש הפעלה מחדש"
|
"restartRequired": "דורש הפעלה מחדש"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "אסטרטגיית תגי עדכון",
|
"label": "אסטרטגיית תגי עדכון",
|
||||||
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
|
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "הקטן ביותר",
|
"sizeAsc": "הקטן ביותר",
|
||||||
"usage": "מספר שימושים",
|
"usage": "מספר שימושים",
|
||||||
"usageDesc": "הכי הרבה",
|
"usageDesc": "הכי הרבה",
|
||||||
"usageAsc": "הכי פחות"
|
"usageAsc": "הכי פחות",
|
||||||
|
"versionsCount": "גרסאות מקומיות",
|
||||||
|
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
|
||||||
|
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
|
||||||
|
"versionIdDesc": "גרסה חדשה ביותר ראשונה"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מודלים",
|
"title": "רענן רשימת מודלים",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "מודל עודכן ב-workflow",
|
"modelUpdated": "מודל עודכן ב-workflow",
|
||||||
"modelFailed": "עדכון צומת המודל נכשל",
|
"modelFailed": "עדכון צומת המודל נכשל",
|
||||||
"embeddingAdded": "Embedding נוסף ל-workflow",
|
"embeddingAdded": "Embedding נוסף ל-workflow",
|
||||||
"embeddingFailed": "הוספת Embedding נכשלה"
|
"embeddingFailed": "הוספת Embedding נכשלה",
|
||||||
|
"promptSent": "הנחיה נשלחה ל-workflow",
|
||||||
|
"promptFailed": "שליחת ההנחיה נכשלה"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "מתכון",
|
"recipe": "מתכון",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "הנחיה",
|
||||||
"replace": "החלף",
|
"replace": "החלף",
|
||||||
"append": "הוסף",
|
"append": "הוסף",
|
||||||
"selectTargetNode": "בחר צומת יעד",
|
"selectTargetNode": "בחר צומת יעד",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "אנא הזן שם LoRA או תחביר",
|
"enterLoraName": "אנא הזן שם LoRA או תחביר",
|
||||||
"reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה",
|
"reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה",
|
||||||
"reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}",
|
"reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}",
|
||||||
|
"noPromptToSend": "אין הנחיה לשליחה",
|
||||||
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
|
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
|
||||||
"sendFailed": "שליחת המתכון ל-workflow נכשלה",
|
"sendFailed": "שליחת המתכון ל-workflow נכשלה",
|
||||||
"sendError": "שגיאה בשליחת המתכון ל-workflow",
|
"sendError": "שגיאה בשליחת המתכון ל-workflow",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "使用回数"
|
"timesUsed": "使用回数"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} バージョン",
|
||||||
|
"viewAllVersions": "ローカルの全バージョンを表示"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "除外モデルを管理"
|
"label": "除外モデルを管理"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "モデルでグループ化"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "検索...",
|
"placeholder": "検索",
|
||||||
"placeholders": {
|
|
||||||
"loras": "LoRAを検索...",
|
|
||||||
"recipes": "レシピを検索...",
|
|
||||||
"checkpoints": "checkpointを検索...",
|
|
||||||
"embeddings": "embeddingを検索..."
|
|
||||||
},
|
|
||||||
"options": "検索オプション",
|
"options": "検索オプション",
|
||||||
"searchIn": "検索対象:",
|
"searchIn": "検索対象:",
|
||||||
"notAvailable": "統計ページでは検索は利用できません",
|
"notAvailable": "統計ページでは検索は利用できません",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "追加フォルダーパス",
|
"extraFolderPaths": "追加フォルダーパス",
|
||||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||||
"priorityTags": "優先タグ",
|
"priorityTags": "優先タグ",
|
||||||
"updateFlags": "アップデートフラグ",
|
"versionScope": "アップデートフラグ",
|
||||||
"exampleImages": "例画像",
|
"exampleImages": "例画像",
|
||||||
"autoOrganize": "自動整理",
|
"autoOrganize": "自動整理",
|
||||||
"metadata": "メタデータ",
|
"metadata": "メタデータ",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "モデルでグループ化",
|
||||||
|
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||||
"displayDensity": "表示密度",
|
"displayDensity": "表示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
@@ -586,7 +589,7 @@
|
|||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"restartRequired": "再起動が必要"
|
"restartRequired": "再起動が必要"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "アップデートフラグの表示戦略",
|
"label": "アップデートフラグの表示戦略",
|
||||||
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
|
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "小さい順",
|
"sizeAsc": "小さい順",
|
||||||
"usage": "使用回数",
|
"usage": "使用回数",
|
||||||
"usageDesc": "多い",
|
"usageDesc": "多い",
|
||||||
"usageAsc": "少ない"
|
"usageAsc": "少ない",
|
||||||
|
"versionsCount": "ローカルバージョン数",
|
||||||
|
"versionsCountDesc": "バージョン数の多い順",
|
||||||
|
"versionsCountAsc": "バージョン数の少ない順",
|
||||||
|
"versionIdDesc": "最新バージョン順"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "モデルリストを更新",
|
"title": "モデルリストを更新",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "モデルがワークフローで更新されました",
|
"modelUpdated": "モデルがワークフローで更新されました",
|
||||||
"modelFailed": "モデルノードの更新に失敗しました",
|
"modelFailed": "モデルノードの更新に失敗しました",
|
||||||
"embeddingAdded": "Embeddingをワークフローに追加しました",
|
"embeddingAdded": "Embeddingをワークフローに追加しました",
|
||||||
"embeddingFailed": "Embeddingの追加に失敗しました"
|
"embeddingFailed": "Embeddingの追加に失敗しました",
|
||||||
|
"promptSent": "プロンプトをワークフローに送信しました",
|
||||||
|
"promptFailed": "プロンプトの送信に失敗しました"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "レシピ",
|
"recipe": "レシピ",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "プロンプト",
|
||||||
"replace": "置換",
|
"replace": "置換",
|
||||||
"append": "追加",
|
"append": "追加",
|
||||||
"selectTargetNode": "ターゲットノードを選択",
|
"selectTargetNode": "ターゲットノードを選択",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "LoRA名または構文を入力してください",
|
"enterLoraName": "LoRA名または構文を入力してください",
|
||||||
"reconnectedSuccessfully": "LoRAが正常に再接続されました",
|
"reconnectedSuccessfully": "LoRAが正常に再接続されました",
|
||||||
"reconnectFailed": "LoRA再接続エラー:{message}",
|
"reconnectFailed": "LoRA再接続エラー:{message}",
|
||||||
|
"noPromptToSend": "送信するプロンプトがありません",
|
||||||
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
||||||
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
||||||
"sendError": "レシピのワークフロー送信エラー",
|
"sendError": "レシピのワークフロー送信エラー",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "사용 횟수"
|
"timesUsed": "사용 횟수"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count}개 버전",
|
||||||
|
"viewAllVersions": "모든 로컬 버전 보기"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "제외된 모델 관리"
|
"label": "제외된 모델 관리"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "모델별 그룹화"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "통계"
|
"statistics": "통계"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "검색...",
|
"placeholder": "검색",
|
||||||
"placeholders": {
|
|
||||||
"loras": "LoRA 검색...",
|
|
||||||
"recipes": "레시피 검색...",
|
|
||||||
"checkpoints": "Checkpoint 검색...",
|
|
||||||
"embeddings": "Embedding 검색..."
|
|
||||||
},
|
|
||||||
"options": "검색 옵션",
|
"options": "검색 옵션",
|
||||||
"searchIn": "검색 범위:",
|
"searchIn": "검색 범위:",
|
||||||
"notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다",
|
"notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "추가 폴다 경로",
|
"extraFolderPaths": "추가 폴다 경로",
|
||||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||||
"priorityTags": "우선순위 태그",
|
"priorityTags": "우선순위 태그",
|
||||||
"updateFlags": "업데이트 표시",
|
"versionScope": "업데이트 표시",
|
||||||
"exampleImages": "예시 이미지",
|
"exampleImages": "예시 이미지",
|
||||||
"autoOrganize": "자동 정리",
|
"autoOrganize": "자동 정리",
|
||||||
"metadata": "메타데이터",
|
"metadata": "메타데이터",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "모델별 그룹화",
|
||||||
|
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||||
"displayDensity": "표시 밀도",
|
"displayDensity": "표시 밀도",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "기본",
|
"default": "기본",
|
||||||
@@ -586,7 +589,7 @@
|
|||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"restartRequired": "재시작 필요"
|
"restartRequired": "재시작 필요"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "업데이트 표시 전략",
|
"label": "업데이트 표시 전략",
|
||||||
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
|
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "작은 순서",
|
"sizeAsc": "작은 순서",
|
||||||
"usage": "사용 횟수",
|
"usage": "사용 횟수",
|
||||||
"usageDesc": "많은 순",
|
"usageDesc": "많은 순",
|
||||||
"usageAsc": "적은 순"
|
"usageAsc": "적은 순",
|
||||||
|
"versionsCount": "로컬 버전 수",
|
||||||
|
"versionsCountDesc": "버전 수 많은 순",
|
||||||
|
"versionsCountAsc": "버전 수 적은 순",
|
||||||
|
"versionIdDesc": "최신 버전순"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "모델 목록 새로고침",
|
"title": "모델 목록 새로고침",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||||
"modelFailed": "모델 노드 업데이트 실패",
|
"modelFailed": "모델 노드 업데이트 실패",
|
||||||
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
|
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
|
||||||
"embeddingFailed": "Embedding 추가 실패"
|
"embeddingFailed": "Embedding 추가 실패",
|
||||||
|
"promptSent": "프롬프트를 워크플로에 보냈습니다",
|
||||||
|
"promptFailed": "프롬프트 보내기 실패"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "레시피",
|
"recipe": "레시피",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "임베딩",
|
"embedding": "임베딩",
|
||||||
|
"prompt": "프롬프트",
|
||||||
"replace": "교체",
|
"replace": "교체",
|
||||||
"append": "추가",
|
"append": "추가",
|
||||||
"selectTargetNode": "대상 노드 선택",
|
"selectTargetNode": "대상 노드 선택",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
|
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
|
||||||
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
|
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
|
||||||
"reconnectFailed": "LoRA 다시 연결 오류: {message}",
|
"reconnectFailed": "LoRA 다시 연결 오류: {message}",
|
||||||
|
"noPromptToSend": "보낼 프롬프트가 없습니다",
|
||||||
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
||||||
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
||||||
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "Количество использований"
|
"timesUsed": "Количество использований"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} версий",
|
||||||
|
"viewAllVersions": "Показать все локальные версии"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Управление исключёнными моделями"
|
"label": "Управление исключёнными моделями"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "Группировать по модели"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "Статистика"
|
"statistics": "Статистика"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Поиск...",
|
"placeholder": "Поиск",
|
||||||
"placeholders": {
|
|
||||||
"loras": "Поиск LoRAs...",
|
|
||||||
"recipes": "Поиск рецептов...",
|
|
||||||
"checkpoints": "Поиск checkpoints...",
|
|
||||||
"embeddings": "Поиск embeddings..."
|
|
||||||
},
|
|
||||||
"options": "Опции поиска",
|
"options": "Опции поиска",
|
||||||
"searchIn": "Искать в:",
|
"searchIn": "Искать в:",
|
||||||
"notAvailable": "Поиск недоступен на странице статистики",
|
"notAvailable": "Поиск недоступен на странице статистики",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||||
"priorityTags": "Приоритетные теги",
|
"priorityTags": "Приоритетные теги",
|
||||||
"updateFlags": "Метки обновлений",
|
"versionScope": "Метки обновлений",
|
||||||
"exampleImages": "Примеры изображений",
|
"exampleImages": "Примеры изображений",
|
||||||
"autoOrganize": "Автоорганизация",
|
"autoOrganize": "Автоорганизация",
|
||||||
"metadata": "Метаданные",
|
"metadata": "Метаданные",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "Группировать по модели",
|
||||||
|
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
|
||||||
"displayDensity": "Плотность отображения",
|
"displayDensity": "Плотность отображения",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "По умолчанию",
|
"default": "По умолчанию",
|
||||||
@@ -586,7 +589,7 @@
|
|||||||
"download": "Загрузить",
|
"download": "Загрузить",
|
||||||
"restartRequired": "Требует перезапуска"
|
"restartRequired": "Требует перезапуска"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "Стратегия меток обновлений",
|
"label": "Стратегия меток обновлений",
|
||||||
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
|
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "Наименьшим",
|
"sizeAsc": "Наименьшим",
|
||||||
"usage": "Число использований",
|
"usage": "Число использований",
|
||||||
"usageDesc": "Больше",
|
"usageDesc": "Больше",
|
||||||
"usageAsc": "Меньше"
|
"usageAsc": "Меньше",
|
||||||
|
"versionsCount": "Локальные версии",
|
||||||
|
"versionsCountDesc": "Сначала больше версий",
|
||||||
|
"versionsCountAsc": "Сначала меньше версий",
|
||||||
|
"versionIdDesc": "Сначала новые версии"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список моделей",
|
"title": "Обновить список моделей",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "Модель обновлена в workflow",
|
"modelUpdated": "Модель обновлена в workflow",
|
||||||
"modelFailed": "Не удалось обновить узел модели",
|
"modelFailed": "Не удалось обновить узел модели",
|
||||||
"embeddingAdded": "Embedding добавлен в workflow",
|
"embeddingAdded": "Embedding добавлен в workflow",
|
||||||
"embeddingFailed": "Не удалось добавить embedding"
|
"embeddingFailed": "Не удалось добавить embedding",
|
||||||
|
"promptSent": "Запрос отправлен в workflow",
|
||||||
|
"promptFailed": "Не удалось отправить запрос"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "Рецепт",
|
"recipe": "Рецепт",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Эмбеддинг",
|
"embedding": "Эмбеддинг",
|
||||||
|
"prompt": "Запрос",
|
||||||
"replace": "Заменить",
|
"replace": "Заменить",
|
||||||
"append": "Добавить",
|
"append": "Добавить",
|
||||||
"selectTargetNode": "Выберите целевой узел",
|
"selectTargetNode": "Выберите целевой узел",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
|
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
|
||||||
"reconnectedSuccessfully": "LoRA успешно переподключена",
|
"reconnectedSuccessfully": "LoRA успешно переподключена",
|
||||||
"reconnectFailed": "Ошибка переподключения LoRA: {message}",
|
"reconnectFailed": "Ошибка переподключения LoRA: {message}",
|
||||||
|
"noPromptToSend": "Нет запроса для отправки",
|
||||||
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
|
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
|
||||||
"sendFailed": "Не удалось отправить рецепт в workflow",
|
"sendFailed": "Не удалось отправить рецепт в workflow",
|
||||||
"sendError": "Ошибка отправки рецепта в workflow",
|
"sendError": "Ошибка отправки рецепта в workflow",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "使用次数"
|
"timesUsed": "使用次数"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} 个版本",
|
||||||
|
"viewAllVersions": "查看所有本地版本"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "管理已排除的模型"
|
"label": "管理已排除的模型"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "按模型分组"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "统计"
|
"statistics": "统计"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "搜索...",
|
"placeholder": "搜索",
|
||||||
"placeholders": {
|
|
||||||
"loras": "搜索 LoRA...",
|
|
||||||
"recipes": "搜索配方...",
|
|
||||||
"checkpoints": "搜索 Checkpoint...",
|
|
||||||
"embeddings": "搜索 Embedding..."
|
|
||||||
},
|
|
||||||
"options": "搜索选项",
|
"options": "搜索选项",
|
||||||
"searchIn": "搜索范围:",
|
"searchIn": "搜索范围:",
|
||||||
"notAvailable": "统计页面不可用搜索",
|
"notAvailable": "统计页面不可用搜索",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "额外文件夹路径",
|
"extraFolderPaths": "额外文件夹路径",
|
||||||
"downloadPathTemplates": "下载路径模板",
|
"downloadPathTemplates": "下载路径模板",
|
||||||
"priorityTags": "优先标签",
|
"priorityTags": "优先标签",
|
||||||
"updateFlags": "更新标记",
|
"versionScope": "版本范围",
|
||||||
"exampleImages": "示例图片",
|
"exampleImages": "示例图片",
|
||||||
"autoOrganize": "自动整理",
|
"autoOrganize": "自动整理",
|
||||||
"metadata": "元数据",
|
"metadata": "元数据",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "按模型分组",
|
||||||
|
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||||
"displayDensity": "显示密度",
|
"displayDensity": "显示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
@@ -586,12 +589,12 @@
|
|||||||
"download": "下载",
|
"download": "下载",
|
||||||
"restartRequired": "需要重启"
|
"restartRequired": "需要重启"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "更新标记策略",
|
"label": "版本分组",
|
||||||
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
|
"help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
|
||||||
"options": {
|
"options": {
|
||||||
"sameBase": "按基础模型匹配更新",
|
"sameBase": "按基础模型分组",
|
||||||
"any": "显示任何可用更新"
|
"any": "显示所有版本"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hideEarlyAccessUpdates": {
|
"hideEarlyAccessUpdates": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "最小",
|
"sizeAsc": "最小",
|
||||||
"usage": "使用次数",
|
"usage": "使用次数",
|
||||||
"usageDesc": "最多",
|
"usageDesc": "最多",
|
||||||
"usageAsc": "最少"
|
"usageAsc": "最少",
|
||||||
|
"versionsCount": "本地版本数",
|
||||||
|
"versionsCountDesc": "版本数从多到少",
|
||||||
|
"versionsCountAsc": "版本数从少到多",
|
||||||
|
"versionIdDesc": "最新版本优先"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新模型列表",
|
"title": "刷新模型列表",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "模型已更新到工作流",
|
"modelUpdated": "模型已更新到工作流",
|
||||||
"modelFailed": "更新模型节点失败",
|
"modelFailed": "更新模型节点失败",
|
||||||
"embeddingAdded": "Embedding 已追加到工作流",
|
"embeddingAdded": "Embedding 已追加到工作流",
|
||||||
"embeddingFailed": "添加 Embedding 失败"
|
"embeddingFailed": "添加 Embedding 失败",
|
||||||
|
"promptSent": "提示词已发送到工作流",
|
||||||
|
"promptFailed": "提示词发送失败"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "配方",
|
"recipe": "配方",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "提示词",
|
||||||
"replace": "替换",
|
"replace": "替换",
|
||||||
"append": "追加",
|
"append": "追加",
|
||||||
"selectTargetNode": "选择目标节点",
|
"selectTargetNode": "选择目标节点",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "请输入 LoRA 名称或语法",
|
"enterLoraName": "请输入 LoRA 名称或语法",
|
||||||
"reconnectedSuccessfully": "LoRA 重新连接成功",
|
"reconnectedSuccessfully": "LoRA 重新连接成功",
|
||||||
"reconnectFailed": "LoRA 重新连接出错:{message}",
|
"reconnectFailed": "LoRA 重新连接出错:{message}",
|
||||||
|
"noPromptToSend": "没有可发送的提示词",
|
||||||
"cannotSend": "无法发送配方:缺少配方 ID",
|
"cannotSend": "无法发送配方:缺少配方 ID",
|
||||||
"sendFailed": "发送配方到工作流失败",
|
"sendFailed": "发送配方到工作流失败",
|
||||||
"sendError": "发送配方到工作流出错",
|
"sendError": "发送配方到工作流出错",
|
||||||
|
|||||||
@@ -145,6 +145,10 @@
|
|||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"timesUsed": "使用次數"
|
"timesUsed": "使用次數"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"versionCount": "{count} 個版本",
|
||||||
|
"viewAllVersions": "檢視所有本地版本"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalContextMenu": {
|
"globalContextMenu": {
|
||||||
@@ -183,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "管理已排除的模型"
|
"label": "管理已排除的模型"
|
||||||
|
},
|
||||||
|
"groupByModel": {
|
||||||
|
"label": "按模型分組"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -195,13 +202,7 @@
|
|||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "搜尋...",
|
"placeholder": "搜尋",
|
||||||
"placeholders": {
|
|
||||||
"loras": "搜尋 LoRA...",
|
|
||||||
"recipes": "搜尋配方...",
|
|
||||||
"checkpoints": "搜尋 checkpoint...",
|
|
||||||
"embeddings": "搜尋 embedding..."
|
|
||||||
},
|
|
||||||
"options": "搜尋選項",
|
"options": "搜尋選項",
|
||||||
"searchIn": "搜尋範圍:",
|
"searchIn": "搜尋範圍:",
|
||||||
"notAvailable": "統計頁面無法搜尋",
|
"notAvailable": "統計頁面無法搜尋",
|
||||||
@@ -325,7 +326,7 @@
|
|||||||
"extraFolderPaths": "額外資料夾路徑",
|
"extraFolderPaths": "額外資料夾路徑",
|
||||||
"downloadPathTemplates": "下載路徑範本",
|
"downloadPathTemplates": "下載路徑範本",
|
||||||
"priorityTags": "優先標籤",
|
"priorityTags": "優先標籤",
|
||||||
"updateFlags": "更新標記",
|
"versionScope": "版本範圍",
|
||||||
"exampleImages": "範例圖片",
|
"exampleImages": "範例圖片",
|
||||||
"autoOrganize": "自動整理",
|
"autoOrganize": "自動整理",
|
||||||
"metadata": "中繼資料",
|
"metadata": "中繼資料",
|
||||||
@@ -430,6 +431,8 @@
|
|||||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||||
},
|
},
|
||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
|
"groupByModel": "按模型分組",
|
||||||
|
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||||
"displayDensity": "顯示密度",
|
"displayDensity": "顯示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "預設",
|
"default": "預設",
|
||||||
@@ -586,7 +589,7 @@
|
|||||||
"download": "下載",
|
"download": "下載",
|
||||||
"restartRequired": "需要重新啟動"
|
"restartRequired": "需要重新啟動"
|
||||||
},
|
},
|
||||||
"updateFlagStrategy": {
|
"versionGrouping": {
|
||||||
"label": "更新標記策略",
|
"label": "更新標記策略",
|
||||||
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
|
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
|
||||||
"options": {
|
"options": {
|
||||||
@@ -670,7 +673,11 @@
|
|||||||
"sizeAsc": "最小",
|
"sizeAsc": "最小",
|
||||||
"usage": "使用次數",
|
"usage": "使用次數",
|
||||||
"usageDesc": "最多",
|
"usageDesc": "最多",
|
||||||
"usageAsc": "最少"
|
"usageAsc": "最少",
|
||||||
|
"versionsCount": "本地版本數",
|
||||||
|
"versionsCountDesc": "版本數從多到少",
|
||||||
|
"versionsCountAsc": "版本數從少到多",
|
||||||
|
"versionIdDesc": "最新版本優先"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理模型列表",
|
"title": "重新整理模型列表",
|
||||||
@@ -1608,12 +1615,15 @@
|
|||||||
"modelUpdated": "模型已更新到工作流",
|
"modelUpdated": "模型已更新到工作流",
|
||||||
"modelFailed": "更新模型節點失敗",
|
"modelFailed": "更新模型節點失敗",
|
||||||
"embeddingAdded": "Embedding 已附加到工作流",
|
"embeddingAdded": "Embedding 已附加到工作流",
|
||||||
"embeddingFailed": "傳送 Embedding 到工作流失敗"
|
"embeddingFailed": "傳送 Embedding 到工作流失敗",
|
||||||
|
"promptSent": "提示詞已發送到工作流",
|
||||||
|
"promptFailed": "提示詞發送失敗"
|
||||||
},
|
},
|
||||||
"nodeSelector": {
|
"nodeSelector": {
|
||||||
"recipe": "配方",
|
"recipe": "配方",
|
||||||
"lora": "LoRA",
|
"lora": "LoRA",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
|
"prompt": "提示詞",
|
||||||
"replace": "取代",
|
"replace": "取代",
|
||||||
"append": "附加",
|
"append": "附加",
|
||||||
"selectTargetNode": "選擇目標節點",
|
"selectTargetNode": "選擇目標節點",
|
||||||
@@ -1800,6 +1810,7 @@
|
|||||||
"enterLoraName": "請輸入 LoRA 名稱或語法",
|
"enterLoraName": "請輸入 LoRA 名稱或語法",
|
||||||
"reconnectedSuccessfully": "LoRA 重新連結成功",
|
"reconnectedSuccessfully": "LoRA 重新連結成功",
|
||||||
"reconnectFailed": "LoRA 重新連結錯誤:{message}",
|
"reconnectFailed": "LoRA 重新連結錯誤:{message}",
|
||||||
|
"noPromptToSend": "沒有可發送的提示詞",
|
||||||
"cannotSend": "無法傳送配方:缺少配方 ID",
|
"cannotSend": "無法傳送配方:缺少配方 ID",
|
||||||
"sendFailed": "傳送配方到工作流失敗",
|
"sendFailed": "傳送配方到工作流失敗",
|
||||||
"sendError": "傳送配方到工作流錯誤",
|
"sendError": "傳送配方到工作流錯誤",
|
||||||
|
|||||||
@@ -608,7 +608,7 @@ class SaveImageLM:
|
|||||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||||
|
|
||||||
# Generate filename with counter if needed
|
# Generate filename with counter if needed
|
||||||
base_filename = filename
|
base_filename = filename.replace("%batch_num%", str(i))
|
||||||
if add_counter_to_filename:
|
if add_counter_to_filename:
|
||||||
# Use counter + i to ensure unique filenames for all images in batch
|
# Use counter + i to ensure unique filenames for all images in batch
|
||||||
current_counter = counter + i
|
current_counter = counter + i
|
||||||
|
|||||||
@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
if model_hash_from_hashes:
|
if model_hash_from_hashes:
|
||||||
metadata["model_hash"] = 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)
|
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||||
if not hashes_match and lora_hashes_match:
|
if lora_hashes_match:
|
||||||
try:
|
try:
|
||||||
lora_hashes_str = lora_hashes_match.group(1)
|
lora_hashes_str = lora_hashes_match.group(1)
|
||||||
lora_hash_entries = lora_hashes_str.split(', ')
|
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")
|
# Parse each lora hash entry (format: "name: hash")
|
||||||
for entry in lora_hash_entries:
|
for entry in lora_hash_entries:
|
||||||
if ': ' in entry:
|
if ': ' in entry:
|
||||||
lora_name, lora_hash = entry.split(': ', 1)
|
lora_name, lora_hash = entry.split(': ', 1)
|
||||||
# Add as lora type in the same format as regular hashes
|
lora_hash = lora_hash.strip()
|
||||||
metadata["hashes"][f"lora:{lora_name}"] = 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
|
# Remove lora hashes from params section
|
||||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
# Only process lora or hypernet types
|
# Only process lora or hypernet types
|
||||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||||
continue
|
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)
|
lora_type, lora_name = hash_key.split(':', 1)
|
||||||
|
|
||||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
# Try to get info from Civitai
|
# Try to get info from Civitai
|
||||||
if metadata_provider:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
if lora_hash:
|
civitai_info = await metadata_provider.get_model_by_hash(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
|
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
|
|||||||
@@ -535,6 +535,7 @@ class NodeRegistry:
|
|||||||
"capabilities": capabilities,
|
"capabilities": capabilities,
|
||||||
"widget_names": widget_names,
|
"widget_names": widget_names,
|
||||||
"mode": node.get("mode"),
|
"mode": node.get("mode"),
|
||||||
|
"marker_role": node.get("marker_role"),
|
||||||
}
|
}
|
||||||
logger.debug("Registered %s nodes in registry", len(nodes))
|
logger.debug("Registered %s nodes in registry", len(nodes))
|
||||||
self._registry_updated.set()
|
self._registry_updated.set()
|
||||||
@@ -3104,13 +3105,17 @@ class NodeRegistryHandler:
|
|||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
widget_name = data.get("widget_name")
|
widget_name = data.get("widget_name")
|
||||||
|
action = data.get("action")
|
||||||
value = data.get("value")
|
value = data.get("value")
|
||||||
mode = data.get("mode", "replace")
|
mode = data.get("mode", "replace")
|
||||||
node_ids = data.get("node_ids")
|
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(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing widget_name parameter"},
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing parameter: provide either 'action' or 'widget_name'",
|
||||||
|
},
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3149,12 +3154,15 @@ class NodeRegistryHandler:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
parsed_node_id = node_identifier
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
payload = {
|
payload: dict = {
|
||||||
"id": parsed_node_id,
|
"id": parsed_node_id,
|
||||||
"widget_name": widget_name,
|
|
||||||
"value": value,
|
"value": value,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
}
|
}
|
||||||
|
if action:
|
||||||
|
payload["action"] = action
|
||||||
|
if widget_name:
|
||||||
|
payload["widget_name"] = widget_name
|
||||||
|
|
||||||
if graph_identifier is not None:
|
if graph_identifier is not None:
|
||||||
payload["graph_id"] = str(graph_identifier)
|
payload["graph_id"] = str(graph_identifier)
|
||||||
|
|||||||
@@ -233,6 +233,8 @@ class ModelListingHandler:
|
|||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
params = self._parse_common_params(request)
|
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)
|
result = await self._service.get_excluded_paginated_data(**params)
|
||||||
|
|
||||||
format_start = time.perf_counter()
|
format_start = time.perf_counter()
|
||||||
@@ -366,6 +368,19 @@ class ModelListingHandler:
|
|||||||
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
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 {
|
return {
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
@@ -389,6 +404,8 @@ class ModelListingHandler:
|
|||||||
"name_pattern_include": name_pattern_include,
|
"name_pattern_include": name_pattern_include,
|
||||||
"name_pattern_exclude": name_pattern_exclude,
|
"name_pattern_exclude": name_pattern_exclude,
|
||||||
"name_pattern_use_regex": name_pattern_use_regex,
|
"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),
|
**self._parse_specific_params(request),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class Aria2Downloader:
|
|||||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||||
self._poll_interval = 0.5
|
self._poll_interval = 0.5
|
||||||
self._state_store = Aria2TransferStateStore()
|
self._state_store = Aria2TransferStateStore()
|
||||||
|
self._stderr_reader_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@@ -115,7 +116,7 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
status = await self.get_status(download_id)
|
status = await self._get_status_with_retry(download_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
return False, "aria2 download not found"
|
return False, "aria2 download not found"
|
||||||
|
|
||||||
@@ -136,6 +137,35 @@ class Aria2Downloader:
|
|||||||
finally:
|
finally:
|
||||||
self._transfers.pop(download_id, None)
|
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(
|
async def _schedule_download(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -312,6 +342,16 @@ class Aria2Downloader:
|
|||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Shut down the RPC process and session."""
|
"""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:
|
if self._rpc_session is not None:
|
||||||
await self._rpc_session.close()
|
await self._rpc_session.close()
|
||||||
self._rpc_session = None
|
self._rpc_session = None
|
||||||
@@ -331,6 +371,23 @@ class Aria2Downloader:
|
|||||||
process.kill()
|
process.kill()
|
||||||
await process.wait()
|
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:
|
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
||||||
try:
|
try:
|
||||||
result = callback(snapshot, snapshot)
|
result = callback(snapshot, snapshot)
|
||||||
@@ -465,6 +522,17 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
await self._wait_until_ready()
|
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:
|
def _resolve_executable(self) -> str:
|
||||||
settings = get_settings_manager()
|
settings = get_settings_manager()
|
||||||
configured_path = (settings.get("aria2c_path") or "").strip()
|
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:
|
if self._rpc_session is None or self._rpc_session.closed:
|
||||||
async with self._rpc_session_lock:
|
async with self._rpc_session_lock:
|
||||||
if self._rpc_session is None or self._rpc_session.closed:
|
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)
|
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
||||||
return self._rpc_session
|
return self._rpc_session
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,100 @@ class BaseModelService(ABC):
|
|||||||
fetch_duration = time.perf_counter() - t0
|
fetch_duration = time.perf_counter() - t0
|
||||||
initial_count = len(sorted_data)
|
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()
|
t1 = time.perf_counter()
|
||||||
if hash_filters:
|
if hash_filters:
|
||||||
filtered_data = await self._apply_hash_filters(sorted_data, 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
|
overall_duration = time.perf_counter() - overall_start
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
"%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__,
|
self.__class__.__name__,
|
||||||
overall_duration,
|
overall_duration,
|
||||||
fetch_duration,
|
fetch_duration,
|
||||||
@@ -181,6 +275,7 @@ class BaseModelService(ABC):
|
|||||||
pagination_duration,
|
pagination_duration,
|
||||||
annotate_duration,
|
annotate_duration,
|
||||||
initial_count,
|
initial_count,
|
||||||
|
dedup_lost,
|
||||||
post_filter_count,
|
post_filter_count,
|
||||||
final_count,
|
final_count,
|
||||||
)
|
)
|
||||||
@@ -495,7 +590,7 @@ class BaseModelService(ABC):
|
|||||||
if not ordered_ids:
|
if not ordered_ids:
|
||||||
return annotated
|
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():
|
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||||
strategy = strategy_value.strip().lower()
|
strategy = strategy_value.strip().lower()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class CheckpointService(BaseModelService):
|
|||||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||||
|
"version_count": checkpoint_data.get("version_count"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -1288,10 +1288,24 @@ class DownloadManager:
|
|||||||
"download_id": download_id,
|
"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
|
is_diffusion_model = False
|
||||||
if model_type == "checkpoint":
|
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
|
is_diffusion_model = True
|
||||||
logger.info(
|
logger.info(
|
||||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||||
@@ -1420,7 +1434,7 @@ class DownloadManager:
|
|||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary")
|
if f.get("primary")
|
||||||
and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1451,7 +1465,7 @@ class DownloadManager:
|
|||||||
(
|
(
|
||||||
f
|
f
|
||||||
for f in files
|
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,
|
None,
|
||||||
)
|
)
|
||||||
@@ -2029,7 +2043,21 @@ class DownloadManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
last_error = result
|
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:
|
try:
|
||||||
os.remove(save_path)
|
os.remove(save_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class EmbeddingService(BaseModelService):
|
|||||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||||
|
"version_count": embedding_data.get("version_count"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class LoraService(BaseModelService):
|
|||||||
lora_data.get("civitai", {}), minimal=True
|
lora_data.get("civitai", {}), minimal=True
|
||||||
),
|
),
|
||||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||||
|
"version_count": lora_data.get("version_count"),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
|
|||||||
('size', 'desc'),
|
('size', 'desc'),
|
||||||
('usage', 'asc'),
|
('usage', 'asc'),
|
||||||
('usage', 'desc'),
|
('usage', 'desc'),
|
||||||
|
('versions_count', 'asc'),
|
||||||
|
('versions_count', 'desc'),
|
||||||
]
|
]
|
||||||
# Is this in use?
|
# Is this in use?
|
||||||
|
|
||||||
@@ -263,6 +265,17 @@ class ModelCache:
|
|||||||
),
|
),
|
||||||
reverse=reverse
|
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:
|
else:
|
||||||
# Fallback: no sort
|
# Fallback: no sort
|
||||||
result = list(data)
|
result = list(data)
|
||||||
|
|||||||
@@ -724,6 +724,16 @@ class ModelUpdateService:
|
|||||||
"Refreshing update metadata for %d %s models", total_models, model_type
|
"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] = {}
|
results: Dict[int, ModelUpdateRecord] = {}
|
||||||
prefetched: Dict[int, Mapping] = {}
|
prefetched: Dict[int, Mapping] = {}
|
||||||
|
|
||||||
@@ -762,6 +772,12 @@ class ModelUpdateService:
|
|||||||
for index, (model_id, version_ids) in enumerate(
|
for index, (model_id, version_ids) in enumerate(
|
||||||
local_versions.items(), start=1
|
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(
|
record = await self._refresh_single_model(
|
||||||
model_type,
|
model_type,
|
||||||
model_id,
|
model_id,
|
||||||
@@ -769,6 +785,7 @@ class ModelUpdateService:
|
|||||||
metadata_provider,
|
metadata_provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
prefetched_response=prefetched.get(model_id),
|
prefetched_response=prefetched.get(model_id),
|
||||||
|
all_local_version_ids=all_vids,
|
||||||
)
|
)
|
||||||
if scanner.is_cancelled():
|
if scanner.is_cancelled():
|
||||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||||
@@ -964,8 +981,16 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
prefetched_response: Optional[Mapping] = None,
|
prefetched_response: Optional[Mapping] = None,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> Optional[ModelUpdateRecord]:
|
) -> Optional[ModelUpdateRecord]:
|
||||||
normalized_local = self._normalize_sequence(local_versions)
|
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()
|
now = time.time()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
existing = self._get_record(model_type, model_id)
|
existing = self._get_record(model_type, model_id)
|
||||||
@@ -973,6 +998,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=now,
|
last_checked_at=now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
record = replace(record, should_ignore_model=True)
|
record = replace(record, should_ignore_model=True)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
|
|||||||
fetched_versions,
|
fetched_versions,
|
||||||
existing,
|
existing,
|
||||||
now,
|
now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=existing.last_checked_at if existing else None,
|
last_checked_at=existing.last_checked_at if existing else None,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
|
|||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
normalized_local: Sequence[int],
|
normalized_local: Sequence[int],
|
||||||
*,
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
model_type: Optional[str] = None,
|
model_type: Optional[str] = None,
|
||||||
model_id: Optional[int] = None,
|
model_id: Optional[int] = None,
|
||||||
last_checked_at: Optional[float] = None,
|
last_checked_at: Optional[float] = None,
|
||||||
version_info: Optional[Mapping] = None,
|
version_info: Optional[Mapping] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(normalized_local)
|
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] = []
|
versions: List[ModelVersionRecord] = []
|
||||||
ignore_map: Dict[int, bool] = {}
|
ignore_map: Dict[int, bool] = {}
|
||||||
if existing:
|
if existing:
|
||||||
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
|
|||||||
versions.append(
|
versions.append(
|
||||||
replace(
|
replace(
|
||||||
version,
|
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:
|
elif model_type is None or model_id is None:
|
||||||
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
|
|||||||
remote_versions: Sequence[ModelVersionRecord],
|
remote_versions: Sequence[ModelVersionRecord],
|
||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
timestamp: float,
|
timestamp: float,
|
||||||
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(local_versions)
|
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 {}
|
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 {}
|
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 {}
|
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,
|
released_at=remote_version.released_at,
|
||||||
size_bytes=remote_version.size_bytes,
|
size_bytes=remote_version.size_bytes,
|
||||||
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
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),
|
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
||||||
sort_index=sort_map.get(version_id, index),
|
sort_index=sort_map.get(version_id, index),
|
||||||
early_access_ends_at=remote_version.early_access_ends_at,
|
early_access_ends_at=remote_version.early_access_ends_at,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"lora_syntax_format": "legacy",
|
"lora_syntax_format": "legacy",
|
||||||
"model_card_footer_action": "replace_preview",
|
"model_card_footer_action": "replace_preview",
|
||||||
"show_version_on_card": True,
|
"show_version_on_card": True,
|
||||||
"update_flag_strategy": "same_base",
|
"version_grouping": "same_base",
|
||||||
"auto_organize_exclusions": [],
|
"auto_organize_exclusions": [],
|
||||||
"metadata_refresh_skip_paths": [],
|
"metadata_refresh_skip_paths": [],
|
||||||
"skip_previously_downloaded_model_versions": False,
|
"skip_previously_downloaded_model_versions": False,
|
||||||
@@ -106,6 +106,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"backup_auto_enabled": True,
|
"backup_auto_enabled": True,
|
||||||
"backup_retention_count": 5,
|
"backup_retention_count": 5,
|
||||||
"use_new_license_icons": True,
|
"use_new_license_icons": True,
|
||||||
|
"group_by_model": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -744,6 +745,7 @@ class SettingsManager:
|
|||||||
"includeTriggerWords": "include_trigger_words",
|
"includeTriggerWords": "include_trigger_words",
|
||||||
"compactMode": "compact_mode",
|
"compactMode": "compact_mode",
|
||||||
"modelCardFooterAction": "model_card_footer_action",
|
"modelCardFooterAction": "model_card_footer_action",
|
||||||
|
"update_flag_strategy": "version_grouping",
|
||||||
}
|
}
|
||||||
|
|
||||||
updated = False
|
updated = False
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
|||||||
"Qwen",
|
"Qwen",
|
||||||
"ZImageBase",
|
"ZImageBase",
|
||||||
"ZImageTurbo",
|
"ZImageTurbo",
|
||||||
|
# Krea 2 — loaded via UNETLoader in ComfyUI
|
||||||
|
"Krea 2",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"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 */
|
/* Style for selected cards */
|
||||||
.model-card.selected {
|
.model-card.selected {
|
||||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -509,6 +509,50 @@
|
|||||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
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 — flex container for badges + version names */
|
||||||
.version-row {
|
.version-row {
|
||||||
display: flex;
|
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 */
|
/* Add after the existing .model-card:hover styles */
|
||||||
|
|
||||||
@keyframes update-pulse {
|
@keyframes update-pulse {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
padding-left: 2.25rem !important;
|
padding-left: 2.25rem !important;
|
||||||
padding-right: 5rem !important;
|
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -190,6 +190,81 @@
|
|||||||
right: 2.25rem;
|
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-options-toggle:hover,
|
||||||
.header-search .search-filter-toggle:hover,
|
.header-search .search-filter-toggle:hover,
|
||||||
.header-search .search-filter-toggle:focus-visible {
|
.header-search .search-filter-toggle:focus-visible {
|
||||||
|
|||||||
@@ -229,6 +229,19 @@
|
|||||||
gap: 10px;
|
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 */
|
/* Styling for parameters tags */
|
||||||
.params-tags {
|
.params-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -272,13 +285,25 @@
|
|||||||
margin-top: var(--space-2);
|
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 {
|
.metadata-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-prompt-wrapper {
|
.metadata-prompt-wrapper {
|
||||||
@@ -286,7 +311,7 @@
|
|||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
border: 1px solid var(--lora-border);
|
border: 1px solid var(--lora-border);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 6px 30px 6px 8px;
|
padding: 6px 8px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
max-height: 80px; /* Reduced from 120px */
|
max-height: 80px; /* Reduced from 120px */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -302,22 +327,26 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-prompt-btn {
|
.copy-prompt-btn,
|
||||||
position: absolute;
|
.send-prompt-btn,
|
||||||
top: 6px;
|
.send-params-btn {
|
||||||
right: 6px;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 3px;
|
padding: 3px 6px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
transition: var(--transition-base);
|
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;
|
opacity: 1;
|
||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
|
background: var(--lora-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling for metadata panel */
|
/* Scrollbar styling for metadata panel */
|
||||||
|
|||||||
@@ -264,6 +264,174 @@
|
|||||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
|
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 */
|
/* Ensure hidden class works properly */
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
|||||||
@@ -59,3 +59,5 @@
|
|||||||
.initialization-notice .loading-spinner {
|
.initialization-notice .loading-spinner {
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- reused from shared styles ---------- */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.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 {
|
import {
|
||||||
getCompleteApiConfig,
|
getCompleteApiConfig,
|
||||||
getCurrentModelType,
|
getCurrentModelType,
|
||||||
@@ -115,7 +115,10 @@ export class BaseModelApiClient {
|
|||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
try {
|
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;
|
pageState.isLoading = true;
|
||||||
if (resetPage) {
|
if (resetPage) {
|
||||||
@@ -154,7 +157,14 @@ export class BaseModelApiClient {
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pageState.isLoading = false;
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1271,6 +1281,12 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
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 (!isExcludedView && pageState.filters) {
|
||||||
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
||||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||||
@@ -1352,6 +1368,24 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_addModelSpecificParams(params, pageState) {
|
_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') {
|
if (this.modelType === 'loras') {
|
||||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
|
|||||||
* Add LoRA-specific parameters to query
|
* Add LoRA-specific parameters to query
|
||||||
*/
|
*/
|
||||||
_addModelSpecificParams(params, pageState) {
|
_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 filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
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 cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
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) {
|
if (isRecipesPage) {
|
||||||
modelUpdateItem?.classList.add('hidden');
|
modelUpdateItem?.classList.add('hidden');
|
||||||
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
downloadExamplesItem?.classList.add('hidden');
|
downloadExamplesItem?.classList.add('hidden');
|
||||||
cleanupExamplesItem?.classList.add('hidden');
|
cleanupExamplesItem?.classList.add('hidden');
|
||||||
excludedModelsItem?.classList.add('hidden');
|
excludedModelsItem?.classList.add('hidden');
|
||||||
|
groupByModelItem?.classList.add('hidden');
|
||||||
repairRecipesItem?.classList.remove('hidden');
|
repairRecipesItem?.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
modelUpdateItem?.classList.remove('hidden');
|
modelUpdateItem?.classList.remove('hidden');
|
||||||
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
downloadExamplesItem?.classList.remove('hidden');
|
downloadExamplesItem?.classList.remove('hidden');
|
||||||
cleanupExamplesItem?.classList.remove('hidden');
|
cleanupExamplesItem?.classList.remove('hidden');
|
||||||
excludedModelsItem?.classList.remove('hidden');
|
excludedModelsItem?.classList.remove('hidden');
|
||||||
|
groupByModelItem?.classList.remove('hidden');
|
||||||
repairRecipesItem?.classList.add('hidden');
|
repairRecipesItem?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
case 'manage-excluded-models':
|
case 'manage-excluded-models':
|
||||||
this.manageExcludedModels();
|
this.manageExcludedModels();
|
||||||
break;
|
break;
|
||||||
|
case 'toggle-group-by-model':
|
||||||
|
this.toggleGroupByModel();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Unhandled global context menu action: ${action}`);
|
console.warn(`Unhandled global context menu action: ${action}`);
|
||||||
break;
|
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) {
|
async downloadExampleImages(menuItem) {
|
||||||
const downloadPath = state?.global?.settings?.example_images_path;
|
const downloadPath = state?.global?.settings?.example_images_path;
|
||||||
if (!downloadPath) {
|
if (!downloadPath) {
|
||||||
|
|||||||
@@ -338,7 +338,6 @@ export class HeaderManager {
|
|||||||
const headerSearch = document.getElementById('headerSearch');
|
const headerSearch = document.getElementById('headerSearch');
|
||||||
const searchInput = headerSearch?.querySelector('#searchInput');
|
const searchInput = headerSearch?.querySelector('#searchInput');
|
||||||
const searchButtons = headerSearch?.querySelectorAll('button');
|
const searchButtons = headerSearch?.querySelectorAll('button');
|
||||||
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
|
|
||||||
|
|
||||||
if (this.currentPage === 'statistics' && headerSearch) {
|
if (this.currentPage === 'statistics' && headerSearch) {
|
||||||
headerSearch.classList.add('disabled');
|
headerSearch.classList.add('disabled');
|
||||||
@@ -353,7 +352,7 @@ export class HeaderManager {
|
|||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.disabled = false;
|
searchInput.disabled = false;
|
||||||
// Use i18nHelpers to update placeholder
|
// Use i18nHelpers to update placeholder
|
||||||
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, '');
|
updateElementAttribute(searchInput, 'placeholder', 'header.search.placeholder', {}, '');
|
||||||
}
|
}
|
||||||
searchButtons?.forEach(btn => btn.disabled = false);
|
searchButtons?.forEach(btn => btn.disabled = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Recipe Modal Component
|
// 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 { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
@@ -40,6 +40,16 @@ const GEN_PARAM_NORMALIZATION = {
|
|||||||
'Denoising strength': 'denoising_strength',
|
'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 {
|
class RecipeModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.promptEditorState = {};
|
this.promptEditorState = {};
|
||||||
@@ -588,10 +598,11 @@ class RecipeModal {
|
|||||||
|
|
||||||
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
||||||
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||||
|
const displayName = PARAM_DISPLAY_NAMES[key] || key;
|
||||||
const paramTag = document.createElement('div');
|
const paramTag = document.createElement('div');
|
||||||
paramTag.className = 'param-tag';
|
paramTag.className = 'param-tag';
|
||||||
paramTag.innerHTML = `
|
paramTag.innerHTML = `
|
||||||
<span class="param-name">${key}:</span>
|
<span class="param-name">${displayName}:</span>
|
||||||
<span class="param-value">${value}</span>
|
<span class="param-value">${value}</span>
|
||||||
`;
|
`;
|
||||||
otherParamsElement.appendChild(paramTag);
|
otherParamsElement.appendChild(paramTag);
|
||||||
@@ -1200,6 +1211,53 @@ class RecipeModal {
|
|||||||
this.sendRecipeToWorkflow();
|
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.
|
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||||
*/
|
*/
|
||||||
static stripLoraTags(text) {
|
static stripLoraTags(text) {
|
||||||
return text
|
return stripLoraTags(text);
|
||||||
.replace(/<lora:[^>]*>/gi, '')
|
|
||||||
.replace(/<lora:[^&]*>/gi, '')
|
|
||||||
.replace(/,(\s*,)+/g, ',')
|
|
||||||
.replace(/^,\s*/, '')
|
|
||||||
.replace(/,\s*$/, '')
|
|
||||||
.replace(/\s{2,}/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldStripLoraOnCopy() {
|
shouldStripLoraOnCopy() {
|
||||||
|
|||||||
@@ -95,6 +95,23 @@ export class CheckpointsControls extends PageControls {
|
|||||||
* Clear checkpoint custom filter and reload
|
* Clear checkpoint custom filter and reload
|
||||||
*/
|
*/
|
||||||
async clearCustomFilter() {
|
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_filterHash');
|
||||||
removeSessionItem('recipe_to_checkpoint_filterHashes');
|
removeSessionItem('recipe_to_checkpoint_filterHashes');
|
||||||
removeSessionItem('filterCheckpointRecipeName');
|
removeSessionItem('filterCheckpointRecipeName');
|
||||||
@@ -106,14 +123,4 @@ export class CheckpointsControls extends PageControls {
|
|||||||
|
|
||||||
await resetAndReload();
|
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
|
* Clear the custom filter and reload the page
|
||||||
*/
|
*/
|
||||||
async clearCustomFilter() {
|
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...");
|
console.log("Clearing custom filter...");
|
||||||
// Remove filter parameters from session storage
|
// Remove filter parameters from session storage
|
||||||
removeSessionItem('recipe_to_lora_filterLoraHash');
|
removeSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
@@ -134,16 +150,6 @@ export class LorasControls extends PageControls {
|
|||||||
await resetAndReload();
|
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
|
* Initialize the alphabet bar component
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
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 { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
import { sidebarManager } from '../SidebarManager.js';
|
import { sidebarManager } from '../SidebarManager.js';
|
||||||
|
import { initSortDropdown } from './SortDropdown.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PageControls class - Unified control management for model pages
|
* PageControls class - Unified control management for model pages
|
||||||
@@ -106,6 +107,7 @@ export class PageControls {
|
|||||||
// Sort select handler
|
// Sort select handler
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy;
|
sortSelect.value = this.pageState.sortBy;
|
||||||
sortSelect.addEventListener('change', async (e) => {
|
sortSelect.addEventListener('change', async (e) => {
|
||||||
this.pageState.sortBy = e.target.value;
|
this.pageState.sortBy = e.target.value;
|
||||||
@@ -129,6 +131,9 @@ export class PageControls {
|
|||||||
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
|
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for View Local Versions filter
|
||||||
|
this.checkVlmFilter();
|
||||||
|
|
||||||
// Page-specific event listeners
|
// Page-specific event listeners
|
||||||
this.initPageSpecificListeners();
|
this.initPageSpecificListeners();
|
||||||
}
|
}
|
||||||
@@ -311,7 +316,12 @@ export class PageControls {
|
|||||||
* Load sort preference from storage
|
* Load sort preference from storage
|
||||||
*/
|
*/
|
||||||
loadSortPreference() {
|
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) {
|
if (savedSort) {
|
||||||
// Handle legacy format conversion
|
// Handle legacy format conversion
|
||||||
const convertedSort = this.convertLegacySortFormat(savedSort);
|
const convertedSort = this.convertLegacySortFormat(savedSort);
|
||||||
@@ -355,7 +365,11 @@ export class PageControls {
|
|||||||
};
|
};
|
||||||
return;
|
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();
|
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
|
* Clear custom filter
|
||||||
*/
|
*/
|
||||||
async clearCustomFilter() {
|
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) {
|
if (!this.api) {
|
||||||
console.error('API methods not registered');
|
console.error('API methods not registered');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.api.clearCustomFilter();
|
await this.api.clearCustomFilter();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -475,6 +685,14 @@ export class PageControls {
|
|||||||
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
|
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
|
* Initialize the favorites filter button state
|
||||||
|
|||||||
294
static/js/components/controls/SortDropdown.js
Normal file
294
static/js/components/controls/SortDropdown.js
Normal file
@@ -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, '\\$&');
|
||||||
|
}
|
||||||
@@ -100,6 +100,12 @@ function handleModelCardEvent_internal(event, modelType) {
|
|||||||
return true; // Stop propagation
|
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)
|
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||||
handleCardClick(card, modelType);
|
handleCardClick(card, modelType);
|
||||||
return false; // Continue with other handlers (e.g., bulk selection)
|
return false; // Continue with other handlers (e.g., bulk selection)
|
||||||
@@ -265,6 +271,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) {
|
function handleCardClick(card, modelType) {
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
@@ -448,6 +470,10 @@ export function createModelCard(model, modelType) {
|
|||||||
const hasUpdateAvailable = Boolean(model.update_available);
|
const hasUpdateAvailable = Boolean(model.update_available);
|
||||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? '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.
|
// To only show usage_count when sorting by usage.
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
@@ -659,16 +685,28 @@ export function createModelCard(model, modelType) {
|
|||||||
const autoTags = model.auto_tags || [];
|
const autoTags = model.auto_tags || [];
|
||||||
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
||||||
const hasVersionName = model.civitai?.name;
|
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 density = state.global.settings.display_density || 'default';
|
||||||
const shortLabels = density === 'medium' || density === 'compact';
|
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 cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
|
||||||
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
||||||
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
||||||
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
||||||
}).join('');
|
}).join('') : '';
|
||||||
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
|
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>`;
|
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
|
||||||
})()}
|
})()}
|
||||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||||
|
|||||||
@@ -752,6 +752,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
modelId: civitaiModelId,
|
modelId: civitaiModelId,
|
||||||
currentVersionId: civitaiVersionId,
|
currentVersionId: civitaiVersionId,
|
||||||
currentBaseModel: modelWithFullData.base_model,
|
currentBaseModel: modelWithFullData.base_model,
|
||||||
|
modelName: model.model_name,
|
||||||
onUpdateStatusChange: handleUpdateStatusChange,
|
onUpdateStatusChange: handleUpdateStatusChange,
|
||||||
});
|
});
|
||||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { translate } from '../../utils/i18nHelpers.js';
|
|||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
||||||
import { formatFileSize } from './utils.js';
|
import { formatFileSize } from './utils.js';
|
||||||
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||||
@@ -306,7 +307,7 @@ function getToggleTooltipText(mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultDisplayMode() {
|
function getDefaultDisplayMode() {
|
||||||
const strategy = state?.global?.settings?.update_flag_strategy;
|
const strategy = state?.global?.settings?.version_grouping;
|
||||||
return strategy === DISPLAY_FILTER_MODES.SAME_BASE
|
return strategy === DISPLAY_FILTER_MODES.SAME_BASE
|
||||||
? DISPLAY_FILTER_MODES.SAME_BASE
|
? DISPLAY_FILTER_MODES.SAME_BASE
|
||||||
: DISPLAY_FILTER_MODES.ANY;
|
: DISPLAY_FILTER_MODES.ANY;
|
||||||
@@ -338,7 +339,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
|||||||
return false;
|
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 sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||||
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
|
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">
|
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
|
||||||
${escapeHtml(ignoreText)}
|
${escapeHtml(ignoreText)}
|
||||||
</button>
|
</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)}
|
${escapeHtml(viewLocalText)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -792,6 +793,7 @@ export function initVersionsTab({
|
|||||||
modelId,
|
modelId,
|
||||||
currentVersionId,
|
currentVersionId,
|
||||||
currentBaseModel,
|
currentBaseModel,
|
||||||
|
modelName,
|
||||||
onUpdateStatusChange,
|
onUpdateStatusChange,
|
||||||
}) {
|
}) {
|
||||||
const pane = document.querySelector(`#${modalId} #versions-tab`);
|
const pane = document.querySelector(`#${modalId} #versions-tab`);
|
||||||
@@ -1019,6 +1021,39 @@ export function initVersionsTab({
|
|||||||
render(controller.record);
|
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) {
|
async function handleToggleVersionIgnore(button, versionId) {
|
||||||
if (!controller.record) {
|
if (!controller.record) {
|
||||||
return;
|
return;
|
||||||
@@ -1348,6 +1383,10 @@ export function initVersionsTab({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleToggleVersionDisplayMode();
|
handleToggleVersionDisplayMode();
|
||||||
break;
|
break;
|
||||||
|
case 'view-local':
|
||||||
|
event.preventDefault();
|
||||||
|
handleViewLocalVersions();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Media-specific utility functions for showcase components
|
* Media-specific utility functions for showcase components
|
||||||
* (Moved from uiHelpers.js to better organize code)
|
* (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 { state } from '../../../state/index.js';
|
||||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.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
|
// Prevent panel scroll from causing modal scroll
|
||||||
metadataPanel.addEventListener('wheel', (e) => {
|
metadataPanel.addEventListener('wheel', (e) => {
|
||||||
const isAtTop = metadataPanel.scrollTop === 0;
|
const isAtTop = metadataPanel.scrollTop === 0;
|
||||||
|
|||||||
@@ -28,14 +28,24 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
|||||||
|
|
||||||
if (hasParams) {
|
if (hasParams) {
|
||||||
content += `
|
content += `
|
||||||
<div class="params-tags">
|
<div class="metadata-row params-row">
|
||||||
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
|
<div class="param-header">
|
||||||
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
|
<span class="metadata-label">Params:</span>
|
||||||
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
|
<div class="param-actions">
|
||||||
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
|
<button class="send-params-btn" title="Send Params to Workflow">
|
||||||
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
|
<i class="fas fa-paper-plane"></i>
|
||||||
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
|
</button>
|
||||||
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -53,12 +63,19 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
|||||||
prompt = escapeHtml(prompt);
|
prompt = escapeHtml(prompt);
|
||||||
content += `
|
content += `
|
||||||
<div class="metadata-row prompt-row">
|
<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-wrapper">
|
||||||
<div class="metadata-prompt">${prompt}</div>
|
<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>
|
</div>
|
||||||
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</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);
|
negativePrompt = escapeHtml(negativePrompt);
|
||||||
content += `
|
content += `
|
||||||
<div class="metadata-row prompt-row">
|
<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-wrapper">
|
||||||
<div class="metadata-prompt">${negativePrompt}</div>
|
<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>
|
</div>
|
||||||
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
|
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class BulkManager {
|
|||||||
this.isMarqueeActive = false;
|
this.isMarqueeActive = false;
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
this.marqueeStart = { x: 0, y: 0 };
|
this.marqueeStart = { x: 0, y: 0 };
|
||||||
|
this.marqueeStartDoc = { x: 0, y: 0 }; // Marquee start in document coordinates
|
||||||
this.marqueeElement = null;
|
this.marqueeElement = null;
|
||||||
this.initialSelectedModels = new Set();
|
this.initialSelectedModels = new Set();
|
||||||
|
|
||||||
@@ -29,6 +30,11 @@ export class BulkManager {
|
|||||||
this.mouseDownTime = 0;
|
this.mouseDownTime = 0;
|
||||||
this.mouseDownPosition = { x: 0, y: 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
|
// Model type specific action configurations
|
||||||
this.actionConfig = {
|
this.actionConfig = {
|
||||||
[MODEL_TYPES.LORA]: {
|
[MODEL_TYPES.LORA]: {
|
||||||
@@ -168,7 +174,10 @@ export class BulkManager {
|
|||||||
|
|
||||||
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
||||||
if (this.isMarqueeActive) {
|
if (this.isMarqueeActive) {
|
||||||
|
this.lastClientX = e.clientX;
|
||||||
|
this.lastClientY = e.clientY;
|
||||||
this.updateMarqueeSelection(e);
|
this.updateMarqueeSelection(e);
|
||||||
|
this.startAutoScroll();
|
||||||
} else if (this.mouseDownTime && !this.isDragging) {
|
} else if (this.mouseDownTime && !this.isDragging) {
|
||||||
// Check if we've moved enough to consider it a drag
|
// Check if we've moved enough to consider it a drag
|
||||||
const dx = e.clientX - this.mouseDownPosition.x;
|
const dx = e.clientX - this.mouseDownPosition.x;
|
||||||
@@ -237,6 +246,7 @@ export class BulkManager {
|
|||||||
* Clean up event handlers
|
* Clean up event handlers
|
||||||
*/
|
*/
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
this.stopAutoScroll();
|
||||||
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
|
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
|
||||||
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
|
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
|
||||||
eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
|
eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
|
||||||
@@ -1727,10 +1737,15 @@ export class BulkManager {
|
|||||||
* @param {boolean} isDragging - Whether this is triggered from a drag operation
|
* @param {boolean} isDragging - Whether this is triggered from a drag operation
|
||||||
*/
|
*/
|
||||||
startMarqueeSelection(e, isDragging = false) {
|
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.x = this.mouseDownPosition.x;
|
||||||
this.marqueeStart.y = this.mouseDownPosition.y;
|
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
|
// Store initial selection state
|
||||||
this.initialSelectedModels = new Set(state.selectedModels);
|
this.initialSelectedModels = new Set(state.selectedModels);
|
||||||
|
|
||||||
@@ -1776,46 +1791,67 @@ export class BulkManager {
|
|||||||
*/
|
*/
|
||||||
updateMarqueeSelection(e) {
|
updateMarqueeSelection(e) {
|
||||||
if (!this.marqueeElement) return;
|
if (!this.marqueeElement) return;
|
||||||
|
this.updateMarqueeSelectionFromPosition(e.clientX, e.clientY);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
updateMarqueeSelectionFromPosition(clientX, clientY) {
|
||||||
const cards = document.querySelectorAll('.model-card');
|
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 newSelection = new Set(this.initialSelectedModels);
|
||||||
|
const visibleFilepaths = new Set();
|
||||||
|
|
||||||
cards.forEach(card => {
|
// Step 1: Process visible DOM cards using getBoundingClientRect + scroll offset
|
||||||
const rect = card.getBoundingClientRect();
|
document.querySelectorAll('.model-card').forEach(card => {
|
||||||
|
|
||||||
// Check if card intersects with marquee rectangle
|
|
||||||
const intersects = !(rect.right < left ||
|
|
||||||
rect.left > right ||
|
|
||||||
rect.bottom < top ||
|
|
||||||
rect.top > bottom);
|
|
||||||
|
|
||||||
const filepath = card.dataset.filepath;
|
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) {
|
if (intersects) {
|
||||||
// Add to selection if intersecting
|
|
||||||
newSelection.add(filepath);
|
newSelection.add(filepath);
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
|
|
||||||
@@ -1825,12 +1861,43 @@ export class BulkManager {
|
|||||||
this.updateMetadataCacheFromCard(filepath, card);
|
this.updateMetadataCacheFromCard(filepath, card);
|
||||||
}
|
}
|
||||||
} else if (!this.initialSelectedModels.has(filepath)) {
|
} else if (!this.initialSelectedModels.has(filepath)) {
|
||||||
// Remove from selection if not intersecting and wasn't initially selected
|
|
||||||
newSelection.delete(filepath);
|
newSelection.delete(filepath);
|
||||||
card.classList.remove('selected');
|
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
|
// Update global selection state
|
||||||
state.selectedModels = newSelection;
|
state.selectedModels = newSelection;
|
||||||
|
|
||||||
@@ -1849,6 +1916,9 @@ export class BulkManager {
|
|||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
this.mouseDownTime = 0;
|
this.mouseDownTime = 0;
|
||||||
|
|
||||||
|
// Stop any active auto-scroll
|
||||||
|
this.stopAutoScroll();
|
||||||
|
|
||||||
// Update event manager state
|
// Update event manager state
|
||||||
eventManager.setState('marqueeActive', false);
|
eventManager.setState('marqueeActive', false);
|
||||||
|
|
||||||
@@ -1874,6 +1944,79 @@ export class BulkManager {
|
|||||||
// Clear initial selection state
|
// Clear initial selection state
|
||||||
this.initialSelectedModels.clear();
|
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();
|
export const bulkManager = new BulkManager();
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export class DownloadManager {
|
|||||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||||
|
|
||||||
// Count model-type files per version
|
// 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 primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
|
||||||
const fileSize = version.modelSizeKB ?
|
const fileSize = version.modelSizeKB ?
|
||||||
(version.modelSizeKB / 1024).toFixed(2) :
|
(version.modelSizeKB / 1024).toFixed(2) :
|
||||||
@@ -478,7 +478,7 @@ export class DownloadManager {
|
|||||||
if (!version) return;
|
if (!version) return;
|
||||||
|
|
||||||
this.currentVersion = version;
|
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('versionStep').style.display = 'none';
|
||||||
document.getElementById('fileSelectionStep').style.display = 'block';
|
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||||
@@ -534,7 +534,7 @@ export class DownloadManager {
|
|||||||
const version = this.currentVersion;
|
const version = this.currentVersion;
|
||||||
if (!version) return;
|
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);
|
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||||
|
|
||||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
@@ -954,7 +954,7 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
if (!this.isBatchMode) {
|
if (!this.isBatchMode) {
|
||||||
const fileParams = this.selectedFile ? {
|
const fileParams = this.selectedFile ? {
|
||||||
type: 'Model',
|
type: this.selectedFile.type || 'Model',
|
||||||
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
||||||
size: this.selectedFile.metadata?.size || 'full',
|
size: this.selectedFile.metadata?.size || 'full',
|
||||||
fp: this.selectedFile.metadata?.fp,
|
fp: this.selectedFile.metadata?.fp,
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export class SearchManager {
|
|||||||
// Create clear button for search input
|
// Create clear button for search input
|
||||||
this.createClearButton();
|
this.createClearButton();
|
||||||
|
|
||||||
|
// Keyboard shortcut cue element (static, exists in the HTML)
|
||||||
|
this.searchShortcutCue = document.getElementById('searchShortcutCue');
|
||||||
|
|
||||||
this.initEventListeners();
|
this.initEventListeners();
|
||||||
this.loadSearchPreferences();
|
this.loadSearchPreferences();
|
||||||
this.setupKeyboardShortcuts();
|
this.setupKeyboardShortcuts();
|
||||||
@@ -163,8 +166,13 @@ export class SearchManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateClearButtonVisibility() {
|
updateClearButtonVisibility() {
|
||||||
|
const hasText = this.searchInput.value.length > 0;
|
||||||
if (this.clearButton) {
|
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;
|
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
|
// Set model name display setting
|
||||||
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
||||||
if (modelNameDisplaySelect) {
|
if (modelNameDisplaySelect) {
|
||||||
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
|
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateFlagStrategySelect = document.getElementById('updateFlagStrategy');
|
const versionGroupingSelect = document.getElementById('versionGrouping');
|
||||||
if (updateFlagStrategySelect) {
|
if (versionGroupingSelect) {
|
||||||
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
|
versionGroupingSelect.value = state.global.settings.version_grouping || 'same_base';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set hide early access updates setting
|
// 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();
|
this.reloadContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2060,7 +2070,7 @@ export class SettingsManager {
|
|||||||
if (
|
if (
|
||||||
settingKey === 'model_name_display'
|
settingKey === 'model_name_display'
|
||||||
|| settingKey === 'model_card_footer_action'
|
|| settingKey === 'model_card_footer_action'
|
||||||
|| settingKey === 'update_flag_strategy'
|
|| settingKey === 'version_grouping'
|
||||||
|| settingKey === 'mature_blur_level'
|
|| settingKey === 'mature_blur_level'
|
||||||
) {
|
) {
|
||||||
this.reloadContent();
|
this.reloadContent();
|
||||||
@@ -3046,6 +3056,10 @@ export class SettingsManager {
|
|||||||
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
|
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
|
||||||
document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
|
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { ImportManager } from './managers/ImportManager.js';
|
|||||||
import { BatchImportManager } from './managers/BatchImportManager.js';
|
import { BatchImportManager } from './managers/BatchImportManager.js';
|
||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { state, getCurrentPageState } from './state/index.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 { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.js';
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
import { initSortDropdown } from './components/controls/SortDropdown.js';
|
||||||
|
|
||||||
class RecipePageControls {
|
class RecipePageControls {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -236,12 +237,18 @@ class RecipeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initEventListeners() {
|
initEventListeners() {
|
||||||
// Sort select
|
// Sort select — load saved preference, persist on change
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
const savedSort = getStorageItem('recipes_sort');
|
||||||
|
if (savedSort) {
|
||||||
|
this.pageState.sortBy = savedSort;
|
||||||
|
}
|
||||||
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
this.pageState.sortBy = sortSelect.value;
|
||||||
|
setStorageItem('recipes_sort', sortSelect.value);
|
||||||
refreshVirtualScroll();
|
refreshVirtualScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
include_trigger_words: false,
|
include_trigger_words: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||||
update_flag_strategy: 'same_base',
|
version_grouping: 'same_base',
|
||||||
hide_early_access_updates: false,
|
hide_early_access_updates: false,
|
||||||
auto_organize_exclusions: [],
|
auto_organize_exclusions: [],
|
||||||
metadata_refresh_skip_paths: [],
|
metadata_refresh_skip_paths: [],
|
||||||
@@ -54,6 +54,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
backup_retention_count: 5,
|
backup_retention_count: 5,
|
||||||
strip_lora_on_copy: false,
|
strip_lora_on_copy: false,
|
||||||
use_new_license_icons: true,
|
use_new_license_icons: true,
|
||||||
|
group_by_model: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createDefaultSettings() {
|
export function createDefaultSettings() {
|
||||||
|
|||||||
@@ -657,6 +657,9 @@ export class VirtualScroller {
|
|||||||
this.resizeObserver.disconnect();
|
this.resizeObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove any active grid loading overlay
|
||||||
|
this.hideGridLoading();
|
||||||
|
|
||||||
// Remove rendered elements
|
// Remove rendered elements
|
||||||
this.clearRenderedItems();
|
this.clearRenderedItems();
|
||||||
|
|
||||||
@@ -1130,4 +1133,30 @@ export class VirtualScroller {
|
|||||||
index: targetIndex
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
296
static/js/utils/genParamsMapper.js
Normal file
296
static/js/utils/genParamsMapper.js
Normal file
@@ -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 { bannerService } from '../managers/BannerService.js';
|
||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.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_ID = 'civitai-host-preference';
|
||||||
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
|
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
|
||||||
@@ -518,6 +519,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() {
|
async function fetchWorkflowRegistry() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/lm/get-registry');
|
const response = await fetch('/api/lm/get-registry');
|
||||||
@@ -840,11 +857,12 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
|||||||
successMessage = 'Updated workflow node',
|
successMessage = 'Updated workflow node',
|
||||||
failureMessage = 'Failed to update workflow node',
|
failureMessage = 'Failed to update workflow node',
|
||||||
missingTargetMessage = 'No target node selected',
|
missingTargetMessage = 'No target node selected',
|
||||||
|
silent = false,
|
||||||
} = messages;
|
} = messages;
|
||||||
|
|
||||||
const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
|
const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
|
||||||
if (targetIds.length === 0) {
|
if (targetIds.length === 0) {
|
||||||
showToast(missingTargetMessage, {}, 'warning');
|
if (!silent) showToast(missingTargetMessage, {}, 'warning');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,7 +871,7 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
|||||||
.filter((reference) => reference && reference.node_id !== undefined);
|
.filter((reference) => reference && reference.node_id !== undefined);
|
||||||
|
|
||||||
if (references.length === 0) {
|
if (references.length === 0) {
|
||||||
showToast(missingTargetMessage, {}, 'warning');
|
if (!silent) showToast(missingTargetMessage, {}, 'warning');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,16 +890,16 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast(successMessage, {}, 'success');
|
if (!silent) showToast(successMessage, {}, 'success');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = result?.error || failureMessage;
|
const errorMessage = result?.error || failureMessage;
|
||||||
showToast(errorMessage, {}, 'error');
|
if (!silent) showToast(errorMessage, {}, 'error');
|
||||||
return false;
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send widget value to workflow:', error);
|
console.error('Failed to send widget value to workflow:', error);
|
||||||
showToast(failureMessage, {}, 'error');
|
if (!silent) showToast(failureMessage, {}, 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -915,7 +933,7 @@ async function sendTextToNodes(nodeIds, nodesMap, text, mode, messages = {}) {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
widget_name: 'text',
|
action: 'inject_text',
|
||||||
value: text,
|
value: text,
|
||||||
mode: mode || 'append',
|
mode: mode || 'append',
|
||||||
node_ids: references,
|
node_ids: references,
|
||||||
@@ -948,7 +966,10 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
|
|||||||
if (!isNodeEnabled(node)) {
|
if (!isNodeEnabled(node)) {
|
||||||
return false;
|
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);
|
const nodeKeys = Object.keys(textNodes);
|
||||||
@@ -980,6 +1001,184 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
|
|||||||
return true;
|
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
|
// Global variable to track active node selector state
|
||||||
let nodeSelectorState = {
|
let nodeSelectorState = {
|
||||||
isActive: false,
|
isActive: false,
|
||||||
|
|||||||
@@ -158,6 +158,11 @@
|
|||||||
<div class="context-menu-item" data-action="manage-excluded-models">
|
<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>
|
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
|
||||||
</div>
|
</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">
|
<div class="context-menu-item" data-action="repair-recipes">
|
||||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
|
<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>
|
||||||
<select id="sortSelect">
|
<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') }}">
|
<optgroup label="{{ t('loras.controls.sort.name') }}">
|
||||||
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
||||||
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
||||||
@@ -37,6 +42,12 @@
|
|||||||
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
|
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% 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' %}
|
{% if page_id == 'recipes' %}
|
||||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||||
|
|||||||
@@ -45,14 +45,14 @@
|
|||||||
|
|
||||||
<!-- Center section: Search -->
|
<!-- Center section: Search -->
|
||||||
{% set search_disabled = current_page == 'statistics' %}
|
{% set search_disabled = current_page == 'statistics' %}
|
||||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholder' %}
|
||||||
current_page %}
|
|
||||||
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
||||||
<div class="{{ header_search_class }}" id="headerSearch">
|
<div class="{{ header_search_class }}" id="headerSearch">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||||
disabled{% endif %} />
|
disabled{% endif %} />
|
||||||
<i class="fas fa-search search-icon"></i>
|
<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
|
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}" {% if
|
||||||
search_disabled %} disabled aria-disabled="true" {% endif %}>
|
search_disabled %} disabled aria-disabled="true" {% endif %}>
|
||||||
<i class="fas fa-sliders-h"></i>
|
<i class="fas fa-sliders-h"></i>
|
||||||
|
|||||||
@@ -536,6 +536,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -1081,23 +1100,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Update Flags -->
|
<!-- Version Scope -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
<h4>{{ t('settings.sections.updateFlags') }}</h4>
|
<h4>{{ t('settings.sections.versionScope') }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label for="updateFlagStrategy">
|
<label for="versionGrouping">
|
||||||
{{ t('settings.updateFlagStrategy.label') }}
|
{{ t('settings.versionGrouping.label') }}
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.updateFlagStrategy.help') }}"></i>
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.versionGrouping.help') }}"></i>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control select-control">
|
<div class="setting-control select-control">
|
||||||
<select id="updateFlagStrategy" onchange="settingsManager.saveSelectSetting('updateFlagStrategy', 'update_flag_strategy')">
|
<select id="versionGrouping" onchange="settingsManager.saveSelectSetting('versionGrouping', 'version_grouping')">
|
||||||
<option value="same_base">{{ t('settings.updateFlagStrategy.options.sameBase') }}</option>
|
<option value="same_base">{{ t('settings.versionGrouping.options.sameBase') }}</option>
|
||||||
<option value="any">{{ t('settings.updateFlagStrategy.options.any') }}</option>
|
<option value="any">{{ t('settings.versionGrouping.options.any') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
<div class="param-header">
|
<div class="param-header">
|
||||||
<label>Prompt</label>
|
<label>Prompt</label>
|
||||||
<div class="param-actions">
|
<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">
|
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -62,6 +65,9 @@
|
|||||||
<div class="param-header">
|
<div class="param-header">
|
||||||
<label>Negative Prompt</label>
|
<label>Negative Prompt</label>
|
||||||
<div class="param-actions">
|
<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">
|
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -84,7 +90,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Other Parameters -->
|
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1117,9 +1117,9 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available');
|
expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available');
|
||||||
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available');
|
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available');
|
||||||
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
|
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
|
||||||
expect(otherParamsText).toContain('sampler:');
|
expect(otherParamsText).toContain('Sampler:');
|
||||||
expect(otherParamsText).toContain('dpmpp_2m');
|
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 () => {
|
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;
|
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
|
||||||
expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt');
|
expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt');
|
||||||
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative');
|
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative');
|
||||||
expect(otherParamsText).toContain('sampler:');
|
expect(otherParamsText).toContain('Sampler:');
|
||||||
expect(otherParamsText).toContain('cfg_scale:');
|
expect(otherParamsText).toContain('CFG:');
|
||||||
expect(otherParamsText).not.toContain('Version');
|
expect(otherParamsText).not.toContain('Version');
|
||||||
expect(otherParamsText).not.toContain('raw_metadata');
|
expect(otherParamsText).not.toContain('raw_metadata');
|
||||||
expect(otherParamsText).not.toContain('RNG');
|
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('recipePrompt').textContent).not.toContain('stale prompt');
|
||||||
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative');
|
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative');
|
||||||
expect(document.getElementById('recipeNegativePrompt').textContent).not.toContain('stale 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).toContain('7');
|
||||||
expect(otherParamsText).not.toContain('3');
|
expect(otherParamsText).not.toContain('3');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const stateMock = {
|
|||||||
global: {
|
global: {
|
||||||
settings: {
|
settings: {
|
||||||
autoplay_on_hover: false,
|
autoplay_on_hover: false,
|
||||||
update_flag_strategy: 'any',
|
version_grouping: 'any',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -67,7 +67,7 @@ describe('ModelVersionsTab media rendering', () => {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stateMock.global.settings.autoplay_on_hover = false;
|
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));
|
({ getModelApiClient } = await import(API_FACTORY_MODULE));
|
||||||
fetchModelUpdateVersions = vi.fn();
|
fetchModelUpdateVersions = vi.fn();
|
||||||
getModelApiClient.mockReturnValue({
|
getModelApiClient.mockReturnValue({
|
||||||
@@ -157,7 +157,7 @@ describe('ModelVersionsTab media rendering', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows a stable label with a short state indicator', async () => {
|
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({
|
fetchModelUpdateVersions.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
record: {
|
record: {
|
||||||
@@ -192,7 +192,7 @@ describe('ModelVersionsTab media rendering', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('filters versions to the current base model when strategy is same_base', async () => {
|
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({
|
fetchModelUpdateVersions.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
record: {
|
record: {
|
||||||
@@ -235,7 +235,7 @@ describe('ModelVersionsTab media rendering', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('toggle button can switch to display all versions', async () => {
|
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({
|
fetchModelUpdateVersions.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
record: {
|
record: {
|
||||||
@@ -286,7 +286,7 @@ describe('ModelVersionsTab media rendering', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows a newer version badge when viewing same-base results', async () => {
|
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({
|
fetchModelUpdateVersions.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
record: {
|
record: {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function renderControlsDom(pageKey) {
|
|||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input id="searchInput" />
|
<input id="searchInput" />
|
||||||
<i class="fas fa-search search-icon"></i>
|
<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="searchOptionsToggle" class="search-options-toggle"></button>
|
||||||
<button id="filterButton" class="search-filter-toggle">
|
<button id="filterButton" class="search-filter-toggle">
|
||||||
<span id="activeFiltersCount" class="filter-badge" style="display: none">0</span>
|
<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).toHaveBeenCalledWith(true, false);
|
||||||
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1);
|
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', () => {
|
describe('FilterManager tag and base model filters', () => {
|
||||||
|
|||||||
246
tests/frontend/utils/genParamsMapper.test.js
Normal file
246
tests/frontend/utils/genParamsMapper.test.js
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -352,3 +352,104 @@ async def test_resolve_authenticated_redirect_url_returns_location(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == "https://signed.example.com/file.safetensors"
|
assert result == "https://signed.example.com/file.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_passes_through_success(monkeypatch):
|
||||||
|
"""A successful first call returns immediately, no retries."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return {"status": "active", "completedLength": "50", "totalLength": "100"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
|
||||||
|
result = await downloader._get_status_with_retry("dummy")
|
||||||
|
assert result is not None
|
||||||
|
assert result["status"] == "active"
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_succeeds_after_transient_failure(monkeypatch):
|
||||||
|
"""A transient Aria2Error on the first call is retried and succeeds."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise Aria2Error("timeout")
|
||||||
|
return {"status": "complete", "completedLength": "100", "totalLength": "100"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||||
|
|
||||||
|
result = await downloader._get_status_with_retry("dummy")
|
||||||
|
assert result is not None
|
||||||
|
assert result["status"] == "complete"
|
||||||
|
assert call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_raises_after_all_retries_exhausted(monkeypatch):
|
||||||
|
"""All retry attempts fail → Aria2Error with a descriptive message."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
raise Aria2Error("connection reset")
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||||
|
|
||||||
|
with pytest.raises(Aria2Error) as exc_info:
|
||||||
|
await downloader._get_status_with_retry("dummy")
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "after 4 attempts" in msg
|
||||||
|
assert "connection reset" in msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_returns_none_when_not_tracked(monkeypatch):
|
||||||
|
"""No transfer in _transfers → get_status returns None → no retry needed."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
# get_status returns None when the download_id has no transfer;
|
||||||
|
# _get_status_with_retry should propagate that without raising.
|
||||||
|
result = await downloader._get_status_with_retry("nonexistent")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait_until_ready_includes_stderr_in_error():
|
||||||
|
"""When the subprocess exits early, its stderr output must be in Aria2Error."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
# Start a subprocess that writes a message to stderr and exits with code 28.
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable, "-c",
|
||||||
|
"import sys; print('ERROR: unknown option --fsync', file=sys.stderr); sys.exit(28)",
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Let the process exit
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
# Point the downloader at this dead process and let _wait_until_ready
|
||||||
|
# discover the exit and read stderr.
|
||||||
|
downloader._process = proc
|
||||||
|
|
||||||
|
with pytest.raises(Aria2Error) as exc_info:
|
||||||
|
await downloader._wait_until_ready()
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "code 28" in msg
|
||||||
|
assert "ERROR: unknown option --fsync" in msg
|
||||||
|
|||||||
@@ -64,6 +64,74 @@ async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeyp
|
|||||||
assert result["loras"] == []
|
assert result["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypatch):
|
||||||
|
"""When Hashes JSON has empty lora hashes but Lora hashes text field has
|
||||||
|
real ones, the real hashes should be used and those LoRAs resolved
|
||||||
|
correctly; entries with empty hashes in both sources should be skipped."""
|
||||||
|
lora_version_info = {
|
||||||
|
"id": 947620,
|
||||||
|
"modelId": 98765,
|
||||||
|
"model": {"name": "cfg_scale_boost", "type": "LORA"},
|
||||||
|
"name": "v1",
|
||||||
|
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
|
||||||
|
"baseModel": "illustrious",
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/models/947620",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"sizeKB": 1024,
|
||||||
|
"name": "cfg_scale_boost.safetensors",
|
||||||
|
"hashes": {"SHA256": "4605b2de07"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
assert model_hash == "4605b2de07"
|
||||||
|
return lora_version_info, None
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
raise AssertionError("get_model_version_info should not be called")
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = AutomaticMetadataParser()
|
||||||
|
|
||||||
|
metadata_text = (
|
||||||
|
"a cyberpunk portrait <lora:cfg_scale_boost:0.6>\n"
|
||||||
|
"Negative prompt: low quality\n"
|
||||||
|
"Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 512x768, "
|
||||||
|
"Model hash: abc123, Model: test.safetensors, "
|
||||||
|
'Lora hashes: "cfg_scale_boost: 4605b2de07, EmptyLora: ", '
|
||||||
|
'Hashes: {"model": "abc123", "lora:cfg_scale_boost": "", "lora:EmptyLora": "", "lora:UnusedLora": ""}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(metadata_text)
|
||||||
|
|
||||||
|
# cfg_scale_boost should be resolved (hash from Lora hashes overrode empty Hashes JSON)
|
||||||
|
loras = result.get("loras", [])
|
||||||
|
assert len(loras) == 1, f"Expected 1 LoRA, got {len(loras)}"
|
||||||
|
lora = loras[0]
|
||||||
|
assert lora["name"] == "cfg_scale_boost", f"Expected cfg_scale_boost, got {lora['name']}"
|
||||||
|
assert lora["hash"] == "4605b2de07", f"Expected hash 4605b2de07, got {lora['hash']}"
|
||||||
|
assert lora.get("isDeleted") in (None, False), f"LoRA should not be deleted"
|
||||||
|
assert lora["weight"] == 0.6, f"Expected weight 0.6, got {lora['weight']}"
|
||||||
|
|
||||||
|
# EmptyLora and UnusedLora should be skipped (no hash in either source)
|
||||||
|
lora_names = [l["name"] for l in loras]
|
||||||
|
assert "EmptyLora" not in lora_names, "EmptyLora should have been skipped"
|
||||||
|
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
||||||
checkpoint_info = {
|
checkpoint_info = {
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ async def test_get_paginated_data_annotates_update_flags_with_bulk_dedup():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_flag_strategy_same_base_prefers_matching_base():
|
async def test_version_grouping_same_base_prefers_matching_base():
|
||||||
items = [
|
items = [
|
||||||
{
|
{
|
||||||
"model_name": "Pony Version",
|
"model_name": "Pony Version",
|
||||||
@@ -551,7 +551,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
|
|||||||
should_ignore_model=False,
|
should_ignore_model=False,
|
||||||
)
|
)
|
||||||
update_service = StubUpdateServiceWithRecords({1: record})
|
update_service = StubUpdateServiceWithRecords({1: record})
|
||||||
settings = StubSettings({"update_flag_strategy": "same_base"})
|
settings = StubSettings({"version_grouping": "same_base"})
|
||||||
|
|
||||||
service = DummyService(
|
service = DummyService(
|
||||||
model_type="stub",
|
model_type="stub",
|
||||||
@@ -579,7 +579,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_flag_strategy_same_base_honors_latest_local_version():
|
async def test_version_grouping_same_base_honors_latest_local_version():
|
||||||
items = [
|
items = [
|
||||||
{
|
{
|
||||||
"model_name": "Pony v0.1",
|
"model_name": "Pony v0.1",
|
||||||
@@ -648,7 +648,7 @@ async def test_update_flag_strategy_same_base_honors_latest_local_version():
|
|||||||
should_ignore_model=False,
|
should_ignore_model=False,
|
||||||
)
|
)
|
||||||
update_service = StubUpdateServiceWithRecords({1: record})
|
update_service = StubUpdateServiceWithRecords({1: record})
|
||||||
settings = StubSettings({"update_flag_strategy": "same_base"})
|
settings = StubSettings({"version_grouping": "same_base"})
|
||||||
|
|
||||||
service = DummyService(
|
service = DummyService(
|
||||||
model_type="stub",
|
model_type="stub",
|
||||||
@@ -746,6 +746,264 @@ async def test_get_paginated_data_update_available_only_without_update_service()
|
|||||||
assert response["total_pages"] == 0
|
assert response["total_pages"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_group_by_model_dedup():
|
||||||
|
"""group_by_model deduplicates items sharing the same civitai modelId,
|
||||||
|
keeping only the item with the highest version (civitai.id)."""
|
||||||
|
items = [
|
||||||
|
# Two versions of the same model (modelId=1)
|
||||||
|
{"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
|
||||||
|
{"model_name": "SameModel", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
|
||||||
|
# Another model with two versions
|
||||||
|
{"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
|
||||||
|
{"model_name": "AnotherModel", "folder": "root", "civitai": {"modelId": 2, "id": 99}},
|
||||||
|
# A standalone item with no civitai metadata (no modelId)
|
||||||
|
{"model_name": "Standalone", "folder": "root"},
|
||||||
|
]
|
||||||
|
repository = StubRepository(items)
|
||||||
|
filter_set = PassThroughFilterSet()
|
||||||
|
search_strategy = NoSearchStrategy()
|
||||||
|
settings = StubSettings({})
|
||||||
|
|
||||||
|
service = DummyService(
|
||||||
|
model_type="stub",
|
||||||
|
scanner=object(),
|
||||||
|
metadata_class=BaseModelMetadata,
|
||||||
|
cache_repository=repository,
|
||||||
|
filter_set=filter_set,
|
||||||
|
search_strategy=search_strategy,
|
||||||
|
settings_provider=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
# With group_by_model=True — modelId=1 keeps id=200, modelId=2 keeps id=99
|
||||||
|
response = await service.get_paginated_data(
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_by="name:asc",
|
||||||
|
group_by_model=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
names = {item["model_name"] for item in response["items"]}
|
||||||
|
assert names == {"SameModel", "AnotherModel", "Standalone"}
|
||||||
|
assert response["total"] == 3
|
||||||
|
# Verify the kept items have the highest version id
|
||||||
|
for item in response["items"]:
|
||||||
|
if item.get("civitai", {}).get("modelId") == 1:
|
||||||
|
assert item["civitai"]["id"] == 200
|
||||||
|
# version_count should reflect total versions for this model
|
||||||
|
assert item.get("version_count") == 2, f"Expected version_count=2, got {item.get('version_count')}"
|
||||||
|
elif item.get("civitai", {}).get("modelId") == 2:
|
||||||
|
assert item["civitai"]["id"] == 99
|
||||||
|
assert item.get("version_count") == 2, f"Expected version_count=2, got {item.get('version_count')}"
|
||||||
|
else:
|
||||||
|
# Standalone item should NOT have version_count
|
||||||
|
assert "version_count" not in item, f"Standalone should not have version_count"
|
||||||
|
|
||||||
|
# With group_by_model=False (default) — all 5 items pass through
|
||||||
|
response_all = await service.get_paginated_data(
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_by="name:asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response_all["total"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_versions_count_non_grouped_desc():
|
||||||
|
"""Non-grouped, versions_count:desc — groups by model, sorts by count desc,
|
||||||
|
within-group by version id desc, then flattens."""
|
||||||
|
items = [
|
||||||
|
# modelId=1 has 3 versions
|
||||||
|
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 300}},
|
||||||
|
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
|
||||||
|
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
|
||||||
|
# modelId=2 has 2 versions
|
||||||
|
{"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 99}},
|
||||||
|
{"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
|
||||||
|
# modelId=3 has 1 version
|
||||||
|
{"model_name": "ModelC", "folder": "root", "civitai": {"modelId": 3, "id": 1}},
|
||||||
|
# standalone (no modelId)
|
||||||
|
{"model_name": "Standalone", "folder": "root"},
|
||||||
|
]
|
||||||
|
repository = StubRepository(items)
|
||||||
|
filter_set = PassThroughFilterSet()
|
||||||
|
search_strategy = NoSearchStrategy()
|
||||||
|
settings = StubSettings({})
|
||||||
|
|
||||||
|
service = DummyService(
|
||||||
|
model_type="stub",
|
||||||
|
scanner=object(),
|
||||||
|
metadata_class=BaseModelMetadata,
|
||||||
|
cache_repository=repository,
|
||||||
|
filter_set=filter_set,
|
||||||
|
search_strategy=search_strategy,
|
||||||
|
settings_provider=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await service.get_paginated_data(
|
||||||
|
page=1, page_size=10, sort_by="versions_count:desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]]
|
||||||
|
# modelId=1 (3 versions): id descending → 300, 200, 100
|
||||||
|
# modelId=2 (2 versions): id descending → 99, 50
|
||||||
|
# modelId=3 (1 version) → 1
|
||||||
|
assert ids == [300, 200, 100, 99, 50, 1], f"Unexpected order: {ids}"
|
||||||
|
assert response["total"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_versions_count_non_grouped_asc():
|
||||||
|
"""Non-grouped, versions_count:asc — groups by model, sorts by count asc,
|
||||||
|
then flattens."""
|
||||||
|
items = [
|
||||||
|
# modelId=1 has 3 versions
|
||||||
|
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 300}},
|
||||||
|
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
|
||||||
|
{"model_name": "ModelA", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
|
||||||
|
# modelId=2 has 2 versions
|
||||||
|
{"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 99}},
|
||||||
|
{"model_name": "ModelB", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
|
||||||
|
# modelId=3 has 1 version
|
||||||
|
{"model_name": "ModelC", "folder": "root", "civitai": {"modelId": 3, "id": 1}},
|
||||||
|
# standalone (no modelId)
|
||||||
|
{"model_name": "Standalone", "folder": "root"},
|
||||||
|
]
|
||||||
|
repository = StubRepository(items)
|
||||||
|
filter_set = PassThroughFilterSet()
|
||||||
|
search_strategy = NoSearchStrategy()
|
||||||
|
settings = StubSettings({})
|
||||||
|
|
||||||
|
service = DummyService(
|
||||||
|
model_type="stub",
|
||||||
|
scanner=object(),
|
||||||
|
metadata_class=BaseModelMetadata,
|
||||||
|
cache_repository=repository,
|
||||||
|
filter_set=filter_set,
|
||||||
|
search_strategy=search_strategy,
|
||||||
|
settings_provider=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await service.get_paginated_data(
|
||||||
|
page=1, page_size=10, sort_by="versions_count:asc",
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]]
|
||||||
|
# modelId=3 (1 version) → 1
|
||||||
|
# modelId=2 (2 versions): id descending → 99, 50
|
||||||
|
# modelId=1 (3 versions): id descending → 300, 200, 100
|
||||||
|
assert ids == [1, 99, 50, 300, 200, 100], f"Unexpected order: {ids}"
|
||||||
|
assert response["total"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_versions_count_non_grouped_same_base():
|
||||||
|
"""Non-grouped, versions_count with version_grouping=same_base —
|
||||||
|
models with same modelId but different base_model are separate groups."""
|
||||||
|
items = [
|
||||||
|
# modelId=1, base_model="sd15" — 2 versions
|
||||||
|
{"model_name": "ModelA", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 1, "id": 200}},
|
||||||
|
{"model_name": "ModelA", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 1, "id": 100}},
|
||||||
|
# modelId=1, base_model="sdxl" — 3 versions
|
||||||
|
{"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 30}},
|
||||||
|
{"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 20}},
|
||||||
|
{"model_name": "ModelA", "folder": "root", "base_model": "sdxl", "civitai": {"modelId": 1, "id": 10}},
|
||||||
|
# modelId=2, base_model="sd15" — 1 version
|
||||||
|
{"model_name": "ModelB", "folder": "root", "base_model": "sd15", "civitai": {"modelId": 2, "id": 1}},
|
||||||
|
]
|
||||||
|
repository = StubRepository(items)
|
||||||
|
filter_set = PassThroughFilterSet()
|
||||||
|
search_strategy = NoSearchStrategy()
|
||||||
|
settings = StubSettings({"version_grouping": "same_base"})
|
||||||
|
|
||||||
|
service = DummyService(
|
||||||
|
model_type="stub",
|
||||||
|
scanner=object(),
|
||||||
|
metadata_class=BaseModelMetadata,
|
||||||
|
cache_repository=repository,
|
||||||
|
filter_set=filter_set,
|
||||||
|
search_strategy=search_strategy,
|
||||||
|
settings_provider=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await service.get_paginated_data(
|
||||||
|
page=1, page_size=10, sort_by="versions_count:desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
ids = [item["civitai"]["id"] for item in response["items"] if "civitai" in item and "id" in item["civitai"]]
|
||||||
|
# (1, "sdxl") — 3 versions: 30, 20, 10
|
||||||
|
# (1, "sd15") — 2 versions: 200, 100
|
||||||
|
# (2, "sd15") — 1 version: 1
|
||||||
|
assert ids == [30, 20, 10, 200, 100, 1], f"Unexpected order: {ids}"
|
||||||
|
assert response["total"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_paginated_data_filters_by_civitai_model_id():
|
||||||
|
"""civitai_model_id filter returns only items matching the given modelId,
|
||||||
|
and bypasses group_by_model dedup so all versions appear."""
|
||||||
|
items = [
|
||||||
|
# Two versions of modelId=1
|
||||||
|
{"model_name": "Model1_v1", "folder": "root", "civitai": {"modelId": 1, "id": 100}},
|
||||||
|
{"model_name": "Model1_v2", "folder": "root", "civitai": {"modelId": 1, "id": 200}},
|
||||||
|
# One version of modelId=2
|
||||||
|
{"model_name": "Model2", "folder": "root", "civitai": {"modelId": 2, "id": 50}},
|
||||||
|
# Standalone (no civitai data)
|
||||||
|
{"model_name": "Standalone", "folder": "root"},
|
||||||
|
]
|
||||||
|
repository = StubRepository(items)
|
||||||
|
filter_set = PassThroughFilterSet()
|
||||||
|
search_strategy = NoSearchStrategy()
|
||||||
|
settings = StubSettings({})
|
||||||
|
|
||||||
|
service = DummyService(
|
||||||
|
model_type="stub",
|
||||||
|
scanner=object(),
|
||||||
|
metadata_class=BaseModelMetadata,
|
||||||
|
cache_repository=repository,
|
||||||
|
filter_set=filter_set,
|
||||||
|
search_strategy=search_strategy,
|
||||||
|
settings_provider=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by modelId=1 — both versions should appear
|
||||||
|
response = await service.get_paginated_data(
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_by="name:asc",
|
||||||
|
civitai_model_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
names = {item["model_name"] for item in response["items"]}
|
||||||
|
assert names == {"Model1_v1", "Model1_v2"}
|
||||||
|
assert response["total"] == 2
|
||||||
|
|
||||||
|
# Filter by modelId=2 — single version
|
||||||
|
response2 = await service.get_paginated_data(
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_by="name:asc",
|
||||||
|
civitai_model_id=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response2["total"] == 1
|
||||||
|
assert response2["items"][0]["model_name"] == "Model2"
|
||||||
|
|
||||||
|
# civitai_model_id + group_by_model=True — still shows all versions (no dedup)
|
||||||
|
response_dedup = await service.get_paginated_data(
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
sort_by="name:asc",
|
||||||
|
civitai_model_id=1,
|
||||||
|
group_by_model=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response_dedup["total"] == 2
|
||||||
|
# Verify both versions are present (dedup was skipped)
|
||||||
|
version_ids = {item["civitai"]["id"] for item in response_dedup["items"]}
|
||||||
|
assert version_ids == {100, 200}
|
||||||
|
|
||||||
|
|
||||||
def test_model_filter_set_handles_include_and_exclude_tag_filters():
|
def test_model_filter_set_handles_include_and_exclude_tag_filters():
|
||||||
settings = StubSettings({})
|
settings = StubSettings({})
|
||||||
filter_set = ModelFilterSet(settings)
|
filter_set = ModelFilterSet(settings)
|
||||||
|
|||||||
@@ -579,3 +579,45 @@ async def test_update_in_library_versions_populates_metadata(tmp_path):
|
|||||||
assert version.preview_url == "https://example.com/preview.png"
|
assert version.preview_url == "https://example.com/preview.png"
|
||||||
assert version.is_in_library is True
|
assert version.is_in_library is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_folder_filter_considers_cross_folder_versions(tmp_path):
|
||||||
|
"""When refreshing by folder, versions in other folders must still be
|
||||||
|
considered in-library so they aren't reported as available updates."""
|
||||||
|
db_path = tmp_path / "updates.sqlite"
|
||||||
|
service = ModelUpdateService(str(db_path), ttl_seconds=0)
|
||||||
|
# Same model (modelId=1) in two folders with different versions
|
||||||
|
raw_data = [
|
||||||
|
{"civitai": {"modelId": 1, "id": 11}, "folder": "folder_a"},
|
||||||
|
{"civitai": {"modelId": 1, "id": 15}, "folder": "folder_b"},
|
||||||
|
]
|
||||||
|
scanner = DummyScanner(raw_data)
|
||||||
|
# Remote offers: 11 (in folder_a), 15 (in folder_b), 20 (truly new)
|
||||||
|
provider = DummyProvider(
|
||||||
|
{
|
||||||
|
"modelVersions": [
|
||||||
|
{"id": 11, "files": [], "images": []},
|
||||||
|
{"id": 15, "files": [], "images": []},
|
||||||
|
{"id": 20, "files": [], "images": []},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.refresh_for_model_type(
|
||||||
|
"lora", scanner, provider, folder_path="folder_a",
|
||||||
|
)
|
||||||
|
record = await service.get_record("lora", 1)
|
||||||
|
|
||||||
|
assert record is not None
|
||||||
|
|
||||||
|
# Version 15 is in folder_b — must be in_library even when filtering by folder_a
|
||||||
|
v15 = next(v for v in record.versions if v.version_id == 15)
|
||||||
|
assert v15.is_in_library is True
|
||||||
|
|
||||||
|
# Version 20 is truly new — should not be in_library
|
||||||
|
v20 = next(v for v in record.versions if v.version_id == 20)
|
||||||
|
assert v20.is_in_library is False
|
||||||
|
|
||||||
|
# has_update must be True (version 20 > max_in_library=15)
|
||||||
|
assert record.has_update() is True
|
||||||
|
|
||||||
|
|||||||
@@ -726,3 +726,25 @@ body.lm-lora-reordering * {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(226, 232, 240, 0.6);
|
color: rgba(226, 232, 240, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Widget flash highlight (visual cue after a value is sent to a node) ---- */
|
||||||
|
/* Applied to a widget row element when its value is updated by LoRA Manager.
|
||||||
|
Shifts the value text color to the LM brand accent with a CSS transition
|
||||||
|
for fade-in/fade-out. Removal (timeout / hover) is handled by JS.
|
||||||
|
|
||||||
|
The transition is declared on .lm-flash-host (added alongside .lm-flash)
|
||||||
|
rather than on ComfyUI's .lg-node-widget, so we don't impose a global
|
||||||
|
color transition on every widget input. The host class persists until
|
||||||
|
cleanup so fade-out still applies after .lm-flash is removed. */
|
||||||
|
.lm-flash-host input,
|
||||||
|
.lm-flash-host textarea,
|
||||||
|
.lm-flash-host [role="combobox"] {
|
||||||
|
transition: color 0.25s ease, -webkit-text-fill-color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lm-flash input,
|
||||||
|
.lm-flash textarea,
|
||||||
|
.lm-flash [role="combobox"] {
|
||||||
|
color: #4299E0 !important;
|
||||||
|
-webkit-text-fill-color: #4299E0 !important;
|
||||||
|
}
|
||||||
|
|||||||
126
web/comfyui/node_marker.js
Normal file
126
web/comfyui/node_marker.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Node Marker – right-click node marking (no dedicated node required)
|
||||||
|
//
|
||||||
|
// Adds a "🎯 Mark as →" submenu with role options to any node's context menu.
|
||||||
|
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
|
||||||
|
// persist with the workflow JSON.
|
||||||
|
//
|
||||||
|
// The workflow registry reads these markers and makes them available to the
|
||||||
|
// standalone UI (e.g. ``sendEmbeddingToWorkflow`` also considers nodes marked
|
||||||
|
// as ``send_prompt_target``).
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const ROLES = {
|
||||||
|
send_prompt_target: {
|
||||||
|
label: "Send Prompt Target",
|
||||||
|
emoji: "\uD83D\uDCDD",
|
||||||
|
},
|
||||||
|
send_gen_params: {
|
||||||
|
label: "Send Gen Params Target",
|
||||||
|
emoji: "\uD83D\uDD27",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function getMarker(node) {
|
||||||
|
return node?.properties?.lm_marker_role ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMarker(node, roleKey) {
|
||||||
|
if (!node || !ROLES[roleKey]) return;
|
||||||
|
node.properties = node.properties || {};
|
||||||
|
node.properties.lm_marker_role = roleKey;
|
||||||
|
|
||||||
|
// Save original title if not already saved, then prefix with emoji
|
||||||
|
if (!node.properties.lm_marker_original_title) {
|
||||||
|
node.properties.lm_marker_original_title = node.title || "";
|
||||||
|
}
|
||||||
|
const def = ROLES[roleKey];
|
||||||
|
node.title = `${def.emoji} ${node.properties.lm_marker_original_title}`;
|
||||||
|
|
||||||
|
if (typeof node.setDirtyCanvas === "function") {
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
triggerRegistryRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMarker(node) {
|
||||||
|
if (!node) return;
|
||||||
|
delete node.properties.lm_marker_role;
|
||||||
|
|
||||||
|
// Restore original title: prefer stripping emoji from current title
|
||||||
|
// (captures user renames after marking), fall back to saved original.
|
||||||
|
const cleaned = node.title?.replace(
|
||||||
|
/^(\u2709\uFE0F?|\u2699\uFE0F?|\uD83D\uDCDD|\uD83C\uDF9B\uFE0F?|\uD83D\uDD27)\s*/,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
if (cleaned && cleaned !== node.title) {
|
||||||
|
node.title = cleaned;
|
||||||
|
} else {
|
||||||
|
const orig = node.properties.lm_marker_original_title;
|
||||||
|
if (orig !== undefined) {
|
||||||
|
node.title = orig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete node.properties.lm_marker_original_title;
|
||||||
|
|
||||||
|
if (typeof node.setDirtyCanvas === "function") {
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
triggerRegistryRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerRegistryRefresh() {
|
||||||
|
// workflow_registry.js listens for this event to re-scan the graph.
|
||||||
|
window.dispatchEvent(new CustomEvent("lm_marker_changed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Submenu builder --------------------------------------------------------
|
||||||
|
|
||||||
|
function buildSubmenuOptions(node) {
|
||||||
|
const currentRole = getMarker(node);
|
||||||
|
const options = [];
|
||||||
|
|
||||||
|
for (const [key, def] of Object.entries(ROLES)) {
|
||||||
|
const isActive = currentRole === key;
|
||||||
|
options.push({
|
||||||
|
content: `${isActive ? "\u2713 " : ""}${def.label}`,
|
||||||
|
disabled: isActive,
|
||||||
|
callback: () => setMarker(node, key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRole) {
|
||||||
|
options.push({
|
||||||
|
content: "Clear marker",
|
||||||
|
callback: () => clearMarker(node),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMenuItems(node) {
|
||||||
|
return [
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
content: "\uD83C\uDFAF Mark as",
|
||||||
|
has_submenu: true,
|
||||||
|
submenu: {
|
||||||
|
options: buildSubmenuOptions(node),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Extension --------------------------------------------------------------
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "LoraManager.NodeMarker",
|
||||||
|
getNodeMenuItems(node) {
|
||||||
|
return buildMenuItems(node);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -260,7 +260,6 @@ function createTagElement({
|
|||||||
}) {
|
}) {
|
||||||
const tagEl = document.createElement("div");
|
const tagEl = document.createElement("div");
|
||||||
tagEl.className = "comfy-tag";
|
tagEl.className = "comfy-tag";
|
||||||
tagEl.dataset.captureWheel = "true";
|
|
||||||
|
|
||||||
const baseStyles = {
|
const baseStyles = {
|
||||||
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
|
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
|
||||||
@@ -619,6 +618,36 @@ function showTagContextMenu(event, tagData, index, widget, anchorEl) {
|
|||||||
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Singleton window capture-phase wheel hook: focuses the tags container when a
|
||||||
|
// wheel event occurs inside it, so that ComfyUI's wheelCapturedByFocusedElement
|
||||||
|
// recognises this zone and does NOT forward the event to canvas (which would
|
||||||
|
// trigger zoom and stopPropagation, preventing the strength-adjustment handler).
|
||||||
|
/** @type {boolean} */
|
||||||
|
let tagWheelCaptureHookInstalled = false;
|
||||||
|
function installTagWheelCaptureHook() {
|
||||||
|
if (tagWheelCaptureHookInstalled) return;
|
||||||
|
tagWheelCaptureHookInstalled = true;
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
// Only handle vertical mouse wheel (not pinch-zoom or horizontal swipe)
|
||||||
|
if (event.ctrlKey || event.metaKey) return;
|
||||||
|
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
|
||||||
|
|
||||||
|
const target = /** @type {Element} */ (event.target);
|
||||||
|
if (!target?.closest) return;
|
||||||
|
const targetContainer = target.closest(
|
||||||
|
'.comfy-tags-container[data-capture-wheel="true"]'
|
||||||
|
);
|
||||||
|
if (!targetContainer) return;
|
||||||
|
|
||||||
|
targetContainer.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
|
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.className = "comfy-tags-container";
|
container.className = "comfy-tags-container";
|
||||||
@@ -628,6 +657,29 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
forwardMiddleMouseToCanvas(container);
|
forwardMiddleMouseToCanvas(container);
|
||||||
forwardWheelToCanvas(container);
|
forwardWheelToCanvas(container);
|
||||||
|
|
||||||
|
// Vue render mode: ComfyUI's TransformPane uses a capture-phase wheel handler
|
||||||
|
// (TransformPane @wheel.capture) that checks wheelCapturedByFocusedElement.
|
||||||
|
// For that check to return true (preventing canvas zoom and allowing our
|
||||||
|
// strength-adjustment wheel handler to fire), the container needs both
|
||||||
|
// data-capture-wheel AND document.activeElement inside it.
|
||||||
|
// We make the container focusable and auto-focus it on wheel events via a
|
||||||
|
// window capture-phase hook.
|
||||||
|
container.dataset.captureWheel = "true";
|
||||||
|
container.tabIndex = -1;
|
||||||
|
|
||||||
|
// Blur on mouseleave to avoid lingering focus side effects.
|
||||||
|
container.addEventListener("mouseleave", () => {
|
||||||
|
if (document.activeElement === container) {
|
||||||
|
container.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Singleton window capture-phase wheel handler: focuses our container when
|
||||||
|
// a wheel event occurs inside it, so that wheelCapturedByFocusedElement
|
||||||
|
// recognises this zone and does NOT forward the event to canvas (which would
|
||||||
|
// trigger zoom and stopPropagation, preventing our strength handler).
|
||||||
|
installTagWheelCaptureHook();
|
||||||
|
|
||||||
Object.assign(container.style, {
|
Object.assign(container.style, {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
@@ -641,6 +693,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
alignContent: "flex-start",
|
alignContent: "flex-start",
|
||||||
|
outline: "none",
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialTagsData = opts?.defaultVal || [];
|
const initialTagsData = opts?.defaultVal || [];
|
||||||
|
|||||||
@@ -186,32 +186,59 @@ const createExtensionObject = (useActionBar) => {
|
|||||||
};
|
};
|
||||||
injectStyles();
|
injectStyles();
|
||||||
|
|
||||||
const replaceButtonIcon = () => {
|
const applyIconToButton = (button) => {
|
||||||
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
// Skip if the SVG icon is already in place
|
||||||
buttons.forEach(button => {
|
if (button.querySelector('svg')) return;
|
||||||
button.classList.add('lm-top-menu-button');
|
button.classList.add('lm-top-menu-button');
|
||||||
button.innerHTML = getLoraManagerIcon();
|
button.innerHTML = getLoraManagerIcon();
|
||||||
button.style.borderRadius = '4px';
|
button.style.borderRadius = '4px';
|
||||||
button.style.padding = '6px';
|
button.style.padding = '6px';
|
||||||
button.style.backgroundColor = 'var(--primary-bg)';
|
button.style.backgroundColor = 'var(--primary-bg)';
|
||||||
const svg = button.querySelector('svg');
|
const svg = button.querySelector('svg');
|
||||||
if (svg) {
|
if (svg) {
|
||||||
svg.style.width = '20px';
|
svg.style.width = '20px';
|
||||||
svg.style.height = '20px';
|
svg.style.height = '20px';
|
||||||
}
|
|
||||||
});
|
|
||||||
if (buttons.length === 0) {
|
|
||||||
requestAnimationFrame(replaceButtonIcon);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
requestAnimationFrame(replaceButtonIcon);
|
|
||||||
|
// Initial application — retry until the button is rendered by Vue
|
||||||
|
const pollUntilFound = () => {
|
||||||
|
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
||||||
|
if (buttons.length > 0) {
|
||||||
|
buttons.forEach(applyIconToButton);
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(pollUntilFound);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(pollUntilFound);
|
||||||
|
|
||||||
|
// MutationObserver: keep the SVG icon in place after Vue re-renders
|
||||||
|
// (e.g. when the properties panel is toggled inside a subgraph)
|
||||||
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
||||||
|
buttons.forEach(button => {
|
||||||
|
// Only re-apply when Vue has reset innerHTML back to <i>
|
||||||
|
if (button.querySelector('i')) {
|
||||||
|
applyIconToButton(button);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Watch the action bar and a broad ancestor so we cover re-mounts
|
||||||
|
const watchNode = document.querySelector('[data-testid="action-bar-buttons"]')
|
||||||
|
|| document.querySelector('.actionbar-container')
|
||||||
|
|| document.body;
|
||||||
|
observer.observe(watchNode, { childList: true, subtree: true });
|
||||||
|
// Store reference for potential cleanup
|
||||||
|
window.__lmIconObserver = observer;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (useActionBar) {
|
if (useActionBar) {
|
||||||
extensionObj.actionBarButtons = [
|
extensionObj.actionBarButtons = [
|
||||||
{
|
{
|
||||||
icon: "icon-[mdi--alpha-l-box] size-4",
|
icon: "icon-[lucide--layers] size-4",
|
||||||
tooltip: BUTTON_TOOLTIP,
|
tooltip: BUTTON_TOOLTIP,
|
||||||
onClick: openLoraManager
|
onClick: openLoraManager
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
|
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
|
||||||
|
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||||
|
|
||||||
const LORA_NODE_CLASSES = new Set([
|
const LORA_NODE_CLASSES = new Set([
|
||||||
"Lora Loader (LoraManager)",
|
"Lora Loader (LoraManager)",
|
||||||
@@ -17,10 +18,68 @@ const TEXT_CAPABLE_CLASSES = new Set([
|
|||||||
"CLIPTextEncode",
|
"CLIPTextEncode",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a hex color (#RGB or #RRGGBB) into an [r, g, b] tuple.
|
||||||
|
*/
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
let h = hex.slice(1);
|
||||||
|
if (h.length === 3) {
|
||||||
|
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||||
|
}
|
||||||
|
const n = parseInt(h, 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linearly interpolate between two [r, g, b] tuples.
|
||||||
|
*/
|
||||||
|
function lerpColor(from, to, t) {
|
||||||
|
return [
|
||||||
|
Math.round(from[0] + (to[0] - from[0]) * t),
|
||||||
|
Math.round(from[1] + (to[1] - from[1]) * t),
|
||||||
|
Math.round(from[2] + (to[2] - from[2]) * t),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a short rAF-driven color fade on a canvas-drawn widget's text_color.
|
||||||
|
* Sets text_color to an interpolated rgb() string each frame. Returns a
|
||||||
|
* cancel function.
|
||||||
|
*
|
||||||
|
* @param widget the widget instance (must have a configurable text_color)
|
||||||
|
* @param fromColor [r, g, b] start color
|
||||||
|
* @param toColor [r, g, b] end color
|
||||||
|
* @param duration fade duration in ms
|
||||||
|
* @returns {function} cancel function — stops the fade immediately.
|
||||||
|
*/
|
||||||
|
function fadeWidgetTextColor(widget, fromColor, toColor, duration) {
|
||||||
|
let rafId = null;
|
||||||
|
const start = performance.now();
|
||||||
|
const tick = () => {
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
const t = Math.min(1, elapsed / duration);
|
||||||
|
// Ease-out cubic for a smooth deceleration.
|
||||||
|
const eased = 1 - Math.pow(1 - t, 3);
|
||||||
|
const [r, g, b] = lerpColor(fromColor, toColor, eased);
|
||||||
|
Object.defineProperty(widget, 'text_color', {
|
||||||
|
value: `rgb(${r},${g},${b})`,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
if (t < 1) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => { if (rafId) cancelAnimationFrame(rafId); };
|
||||||
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.WorkflowRegistry",
|
name: "LoraManager.WorkflowRegistry",
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
|
ensureLmStyles();
|
||||||
|
|
||||||
api.addEventListener("lora_registry_refresh", () => {
|
api.addEventListener("lora_registry_refresh", () => {
|
||||||
this.refreshRegistry();
|
this.refreshRegistry();
|
||||||
});
|
});
|
||||||
@@ -28,6 +87,11 @@ app.registerExtension({
|
|||||||
api.addEventListener("lm_widget_update", (event) => {
|
api.addEventListener("lm_widget_update", (event) => {
|
||||||
this.applyWidgetUpdate(event?.detail ?? {});
|
this.applyWidgetUpdate(event?.detail ?? {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// React to marker changes from the Node Marker extension
|
||||||
|
window.addEventListener("lm_marker_changed", () => {
|
||||||
|
this.refreshRegistry();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshRegistry() {
|
async refreshRegistry() {
|
||||||
@@ -49,8 +113,10 @@ app.registerExtension({
|
|||||||
const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass);
|
const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass);
|
||||||
const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name));
|
const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name));
|
||||||
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
|
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
|
||||||
|
const markerRole = node.properties?.lm_marker_role ?? null;
|
||||||
|
|
||||||
if (!supportsLora && !hasTargetWidget && !hasTextWidget) {
|
// Skip nodes with no relevant capability UNLESS they are marked
|
||||||
|
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +137,7 @@ app.registerExtension({
|
|||||||
type: node.comfyClass,
|
type: node.comfyClass,
|
||||||
comfy_class: node.comfyClass,
|
comfy_class: node.comfyClass,
|
||||||
mode: node.mode,
|
mode: node.mode,
|
||||||
|
marker_role: markerRole,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supports_lora: supportsLora,
|
supports_lora: supportsLora,
|
||||||
has_text_widget: hasTextWidget,
|
has_text_widget: hasTextWidget,
|
||||||
@@ -102,11 +169,12 @@ app.registerExtension({
|
|||||||
applyWidgetUpdate(message) {
|
applyWidgetUpdate(message) {
|
||||||
const nodeId = message?.node_id ?? message?.id;
|
const nodeId = message?.node_id ?? message?.id;
|
||||||
const graphId = message?.graph_id;
|
const graphId = message?.graph_id;
|
||||||
|
const action = message?.action;
|
||||||
const widgetName = message?.widget_name;
|
const widgetName = message?.widget_name;
|
||||||
const value = message?.value;
|
const value = message?.value;
|
||||||
const mode = message?.mode ?? "replace";
|
const mode = message?.mode ?? "replace";
|
||||||
|
|
||||||
if (nodeId == null || !widgetName) {
|
if (nodeId == null || (!action && !widgetName)) {
|
||||||
console.warn("LoRA Manager: invalid widget update payload", message);
|
console.warn("LoRA Manager: invalid widget update payload", message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -126,33 +194,72 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const widgetIndex = node.widgets.findIndex((widget) => widget?.name === widgetName);
|
// ---- Resolve target widget ----
|
||||||
if (widgetIndex === -1) {
|
let targetWidget = null;
|
||||||
console.warn(
|
|
||||||
"LoRA Manager: target widget not found on node",
|
if (action === "inject_text") {
|
||||||
widgetName,
|
// Find the first text-capable widget by type.
|
||||||
node
|
// Normalise to lowercase for case-insensitive matching.
|
||||||
);
|
const TEXT_TYPES = new Set(["string", "customtext"]);
|
||||||
|
targetWidget = node.widgets.find((w) => {
|
||||||
|
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
|
||||||
|
if (TEXT_TYPES.has(t)) return true;
|
||||||
|
// Broad fallback for unknown composite types.
|
||||||
|
if (t.includes("string")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (!targetWidget) {
|
||||||
|
// Last resort: pick the first widget that is not a hidden/internal type
|
||||||
|
targetWidget = node.widgets.find((w) => w?.name && !w.name.startsWith("_"));
|
||||||
|
if (!targetWidget) {
|
||||||
|
console.warn(
|
||||||
|
"LoRA Manager: no suitable widget for inject_text on node",
|
||||||
|
node.id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (widgetName) {
|
||||||
|
// Legacy: find widget by name
|
||||||
|
targetWidget = node.widgets.find((w) => w?.name === widgetName);
|
||||||
|
if (!targetWidget) {
|
||||||
|
console.warn(
|
||||||
|
"LoRA Manager: target widget not found on node",
|
||||||
|
widgetName,
|
||||||
|
node
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("LoRA Manager: no action or widget_name in payload", message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const widget = node.widgets[widgetIndex];
|
// ---- Update widget value ----
|
||||||
|
const widgetIndex = node.widgets.indexOf(targetWidget);
|
||||||
let newValue = value;
|
let newValue = value;
|
||||||
|
|
||||||
if (mode === "append") {
|
if (mode === "append") {
|
||||||
const separator = widget.value && widget.value.length > 0 ? " " : "";
|
const separator =
|
||||||
newValue = widget.value + separator + value;
|
targetWidget.value && targetWidget.value.length > 0 ? " " : "";
|
||||||
|
newValue = targetWidget.value + separator + value;
|
||||||
}
|
}
|
||||||
|
|
||||||
widget.value = newValue;
|
targetWidget.value = newValue;
|
||||||
|
|
||||||
if (Array.isArray(node.widgets_values) && node.widgets_values.length > widgetIndex) {
|
if (
|
||||||
|
Array.isArray(node.widgets_values) &&
|
||||||
|
widgetIndex >= 0 &&
|
||||||
|
node.widgets_values.length > widgetIndex
|
||||||
|
) {
|
||||||
node.widgets_values[widgetIndex] = newValue;
|
node.widgets_values[widgetIndex] = newValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof widget.callback === "function") {
|
if (typeof targetWidget.callback === "function") {
|
||||||
try {
|
try {
|
||||||
widget.callback(newValue);
|
targetWidget.callback(newValue);
|
||||||
} catch (callbackError) {
|
} catch (callbackError) {
|
||||||
console.error("LoRA Manager: widget callback failed", callbackError);
|
console.error("LoRA Manager: widget callback failed", callbackError);
|
||||||
}
|
}
|
||||||
@@ -165,5 +272,296 @@ app.registerExtension({
|
|||||||
if (typeof app.graph?.setDirtyCanvas === "function") {
|
if (typeof app.graph?.setDirtyCanvas === "function") {
|
||||||
app.graph.setDirtyCanvas(true, true);
|
app.graph.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Visual cue: briefly highlight the updated widget ----
|
||||||
|
this.flashWidget(node, targetWidget);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a temporary visual highlight to a widget after its value is updated.
|
||||||
|
*
|
||||||
|
* Both rendering modes shift the value text color to the LM brand accent
|
||||||
|
* (#4299E0) with a fade-in/fade-out, then restore it after FLASH_DURATION
|
||||||
|
* (3s) or on hover:
|
||||||
|
* - Vue Nodes mode: add a `.lm-flash` class to the widget row. CSS
|
||||||
|
* `transition: color 0.25s` handles fade-in/out. A MutationObserver
|
||||||
|
* re-applies the class if Vue re-renders the row during the flash.
|
||||||
|
* - Canvas mode: DOM widgets (customtext/autocomplete) use inline
|
||||||
|
* `transition` for fade; canvas-drawn widgets (combo/number/toggle) use
|
||||||
|
* a short rAF color interpolation for fade-in (250ms) and fade-out
|
||||||
|
* (400ms). A low-frequency poll checks hover dismissal via
|
||||||
|
* app.canvas.getWidgetAtCursor().
|
||||||
|
*/
|
||||||
|
flashWidget(node, widget) {
|
||||||
|
const FLASH_DURATION = 3000;
|
||||||
|
const FADE_IN_MS = 250;
|
||||||
|
const VALUE_COLOR = '#4299E0'; // LM brand accent — consistent with selection/border/drop-indicator
|
||||||
|
const nodeId = node.id;
|
||||||
|
|
||||||
|
// ---- Vue Nodes mode: CSS class for value text color ----
|
||||||
|
const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`);
|
||||||
|
if (nodeEl) {
|
||||||
|
this._flashVueWidget(nodeEl, widget, node, {
|
||||||
|
FLASH_DURATION, VALUE_COLOR,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Canvas mode ----
|
||||||
|
this._flashCanvasWidget(node, widget, {
|
||||||
|
FLASH_DURATION, FADE_IN_MS, VALUE_COLOR,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue/DOM flash: add `.lm-flash` class to the widget row for the value text
|
||||||
|
* color shift. Re-applies on re-render via MutationObserver. Removes on
|
||||||
|
* timeout / hover.
|
||||||
|
*/
|
||||||
|
_flashVueWidget(nodeEl, widget, graphNode, { FLASH_DURATION, VALUE_COLOR }) {
|
||||||
|
const FLASH_CLASS = 'lm-flash';
|
||||||
|
|
||||||
|
// Find the widget row in the DOM. Vue renders widget rows as
|
||||||
|
// [data-testid="node-widget"] elements whose order matches node.widgets[].
|
||||||
|
// Match strategy (in priority order):
|
||||||
|
// 1. By label text via [data-testid="widget-layout-field-label"] (combo/number/toggle)
|
||||||
|
// 2. By <label> text (CLIPTextEncode customtext has a bare <label>)
|
||||||
|
// 3. By widget index — graph node.widgets[i] ↔ Nth DOM row (text widgets
|
||||||
|
// like Prompt LM have no label at all, so index is the only stable match)
|
||||||
|
const widgetIndex = Array.isArray(graphNode?.widgets)
|
||||||
|
? graphNode.widgets.indexOf(widget)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const findRowEl = () => {
|
||||||
|
const rows = nodeEl.querySelectorAll('[data-testid="node-widget"]');
|
||||||
|
// Strategy 1: data-testid label
|
||||||
|
for (const r of rows) {
|
||||||
|
const label = r.querySelector('[data-testid="widget-layout-field-label"]');
|
||||||
|
if (label && label.textContent.trim() === widget.name) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strategy 2: bare <label> element
|
||||||
|
for (const r of rows) {
|
||||||
|
const label = r.querySelector('label');
|
||||||
|
if (label && label.textContent.trim() === widget.name) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strategy 3: index match
|
||||||
|
if (widgetIndex >= 0 && widgetIndex < rows.length) {
|
||||||
|
return rows[widgetIndex];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cleanedUp = false;
|
||||||
|
const cleanupFns = [];
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
for (const fn of cleanupFns) {
|
||||||
|
try { fn(); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
// Remove .lm-flash to trigger the CSS color fade-out. Keep
|
||||||
|
// .lm-flash-host (which carries the transition rule) until the
|
||||||
|
// fade-out completes, then remove it.
|
||||||
|
const row = findRowEl();
|
||||||
|
if (row) {
|
||||||
|
row.classList.remove(FLASH_CLASS);
|
||||||
|
// Remove the host class after the transition completes.
|
||||||
|
setTimeout(() => {
|
||||||
|
const r = findRowEl();
|
||||||
|
if (r) r.classList.remove('lm-flash-host');
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial application
|
||||||
|
const apply = () => {
|
||||||
|
const row = findRowEl();
|
||||||
|
if (row && !row.classList.contains(FLASH_CLASS)) {
|
||||||
|
// Restart the animation by toggling the class off-on.
|
||||||
|
row.classList.remove(FLASH_CLASS);
|
||||||
|
// Force reflow so the animation restarts.
|
||||||
|
void row.offsetWidth;
|
||||||
|
row.classList.add('lm-flash-host');
|
||||||
|
row.classList.add(FLASH_CLASS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
|
||||||
|
// Re-apply if Vue re-renders and drops the class.
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
observer.observe(nodeEl, { childList: true, subtree: true });
|
||||||
|
cleanupFns.push(() => observer.disconnect());
|
||||||
|
|
||||||
|
// Hard timeout: remove the class after FLASH_DURATION.
|
||||||
|
const timeoutId = setTimeout(cleanup, FLASH_DURATION + 100);
|
||||||
|
cleanupFns.push(() => clearTimeout(timeoutId));
|
||||||
|
|
||||||
|
// Hover dismissal: clear the flash when the user mouses over the row.
|
||||||
|
const onHover = (e) => {
|
||||||
|
const row = findRowEl();
|
||||||
|
if (row && row.contains(e.target)) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
nodeEl.addEventListener('mouseover', onHover);
|
||||||
|
cleanupFns.push(() => nodeEl.removeEventListener('mouseover', onHover));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas flash: set text_color (canvas-drawn widgets) and inline color
|
||||||
|
* (DOM widgets). Canvas-drawn widgets get a rAF-driven color fade-in/out;
|
||||||
|
* DOM widgets use CSS transition. A low-frequency poll checks hover
|
||||||
|
* dismissal via app.canvas.getWidgetAtCursor().
|
||||||
|
*/
|
||||||
|
_flashCanvasWidget(node, widget, { FLASH_DURATION, FADE_IN_MS, VALUE_COLOR }) {
|
||||||
|
const FADE_OUT_MS = 400;
|
||||||
|
const FADE_OUT_START = FLASH_DURATION - FADE_OUT_MS;
|
||||||
|
const DEFAULT_RGB = hexToRgb('#DDD'); // LiteGraph WIDGET_TEXT_COLOR
|
||||||
|
const FLASH_RGB = hexToRgb(VALUE_COLOR);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a widget is a DOM-based widget (customtext / autocomplete)
|
||||||
|
* that renders a real <textarea>/<input> element rather than being
|
||||||
|
* canvas-drawn. Evaluated per-widget so batch cleanup handles each
|
||||||
|
* widget correctly regardless of when it was added to the batch.
|
||||||
|
*/
|
||||||
|
const isDomWidget = (w) =>
|
||||||
|
(w.inputEl && (w.inputEl.tagName === 'TEXTAREA' || w.inputEl.tagName === 'INPUT'))
|
||||||
|
|| !!w.element?.querySelector?.('textarea, input');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the DOM element for a DOM-based widget.
|
||||||
|
*/
|
||||||
|
const getDomEl = (w) =>
|
||||||
|
(w.inputEl && (w.inputEl.tagName === 'TEXTAREA' || w.inputEl.tagName === 'INPUT'))
|
||||||
|
? w.inputEl
|
||||||
|
: w.element?.querySelector?.('textarea, input') ?? null;
|
||||||
|
|
||||||
|
// --- Track fade-out cancellers per widget so batch cleanup can stop
|
||||||
|
// any in-progress fade for ALL widgets in the batch, not just the
|
||||||
|
// latest one. ---
|
||||||
|
if (!node._lmFadeCancels) node._lmFadeCancels = {};
|
||||||
|
|
||||||
|
// --- DOM widget color (customtext / autocomplete text) ---
|
||||||
|
// CSS transition handles both fade-in and fade-out automatically.
|
||||||
|
if (isDomWidget(widget)) {
|
||||||
|
const domEl = getDomEl(widget);
|
||||||
|
if (domEl) {
|
||||||
|
domEl.style.transition = `color ${FADE_IN_MS}ms ease`;
|
||||||
|
domEl.style.color = VALUE_COLOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Canvas-drawn widget: kick off fade-in via rAF ---
|
||||||
|
if (!isDomWidget(widget)) {
|
||||||
|
// Set immediately to start (rAF will refine from first frame).
|
||||||
|
Object.defineProperty(widget, 'text_color', {
|
||||||
|
value: VALUE_COLOR,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const cancel = fadeWidgetTextColor(widget, DEFAULT_RGB, FLASH_RGB, FADE_IN_MS);
|
||||||
|
node._lmFadeCancels[widget.name] = cancel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Track flashed widgets for batch cleanup ---
|
||||||
|
if (!node._lmFlashedWidgets) node._lmFlashedWidgets = [];
|
||||||
|
if (!node._lmFlashedWidgets.includes(widget)) {
|
||||||
|
node._lmFlashedWidgets.push(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Track fade-out scheduling per widget ---
|
||||||
|
if (!node._lmFadeOutTimers) node._lmFadeOutTimers = {};
|
||||||
|
|
||||||
|
if (typeof node.setDirtyCanvas === 'function') {
|
||||||
|
node.setDirtyCanvas(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Poll for hover dismissal ---
|
||||||
|
let pollId = null;
|
||||||
|
let cleanedUp = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
cleanedUp = true;
|
||||||
|
if (pollId) clearInterval(pollId);
|
||||||
|
pollId = null;
|
||||||
|
|
||||||
|
for (const w of (node._lmFlashedWidgets || [])) {
|
||||||
|
// Cancel any pending fade-out timer for this widget
|
||||||
|
if (node._lmFadeOutTimers?.[w.name]) {
|
||||||
|
clearTimeout(node._lmFadeOutTimers[w.name]);
|
||||||
|
delete node._lmFadeOutTimers[w.name];
|
||||||
|
}
|
||||||
|
// Cancel any in-progress fade-in/out rAF for this widget
|
||||||
|
if (node._lmFadeCancels?.[w.name]) {
|
||||||
|
node._lmFadeCancels[w.name]();
|
||||||
|
delete node._lmFadeCancels[w.name];
|
||||||
|
}
|
||||||
|
delete w.text_color;
|
||||||
|
delete w.secondary_text_color;
|
||||||
|
// Clear DOM widget inline color first (transition plays the
|
||||||
|
// fade-out), then remove the transition property after it
|
||||||
|
// completes. Keeping transition until then is essential.
|
||||||
|
if (isDomWidget(w)) {
|
||||||
|
const el = getDomEl(w);
|
||||||
|
if (el) {
|
||||||
|
el.style.color = '';
|
||||||
|
// Remove the transition property after the fade completes.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (el) el.style.transition = '';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete node._lmFlashedWidgets;
|
||||||
|
delete node._lmFadeOutTimers;
|
||||||
|
delete node._lmFadeCancels;
|
||||||
|
delete node._lmFlashCleanup;
|
||||||
|
if (typeof node.setDirtyCanvas === 'function') {
|
||||||
|
node.setDirtyCanvas(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schedule fade-out for canvas-drawn widgets only (DOM widgets fade
|
||||||
|
// automatically when we clear the inline color in cleanup).
|
||||||
|
if (!isDomWidget(widget)) {
|
||||||
|
// Clear any previous fade-out timer for this widget
|
||||||
|
if (node._lmFadeOutTimers[widget.name]) {
|
||||||
|
clearTimeout(node._lmFadeOutTimers[widget.name]);
|
||||||
|
}
|
||||||
|
node._lmFadeOutTimers[widget.name] = setTimeout(() => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
const cancel = fadeWidgetTextColor(widget, FLASH_RGB, DEFAULT_RGB, FADE_OUT_MS);
|
||||||
|
node._lmFadeCancels[widget.name] = cancel;
|
||||||
|
delete node._lmFadeOutTimers[widget.name];
|
||||||
|
}, FADE_OUT_START);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-frequency poll (~100ms) for hover dismissal.
|
||||||
|
const checkHover = () => {
|
||||||
|
if (cleanedUp) return;
|
||||||
|
const canvas = window.app?.canvas;
|
||||||
|
if (canvas) {
|
||||||
|
const hovered = canvas.getWidgetAtCursor?.();
|
||||||
|
if (hovered && node._lmFlashedWidgets?.includes(hovered)) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pollId = setInterval(checkHover, 100);
|
||||||
|
|
||||||
|
// Hard timeout fallback.
|
||||||
|
if (node._lmFlashCleanup) clearTimeout(node._lmFlashCleanup);
|
||||||
|
node._lmFlashCleanup = setTimeout(cleanup, FLASH_DURATION + 50);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user