mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-24 12:01:16 -03:00
Compare commits
21 Commits
b58abbad7c
...
v1.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48de05102 | ||
|
|
93ad81ed87 | ||
|
|
ea14d211be | ||
|
|
8052cefd46 | ||
|
|
845815b9b7 | ||
|
|
609dc5d783 | ||
|
|
7a71b34b54 | ||
|
|
71a459422f | ||
|
|
cd2628a0ee | ||
|
|
85da7175bc | ||
|
|
d3bf0a164b | ||
|
|
afb6ca1b8d | ||
|
|
94f43426d7 | ||
|
|
2b361f4f5d | ||
|
|
7438072f8c | ||
|
|
26c54fd358 | ||
|
|
7cb6b04c63 | ||
|
|
fc29cde82a | ||
|
|
559ca946dc | ||
|
|
2b8e7c7504 | ||
|
|
6816d75933 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ model_cache/
|
||||
.codex
|
||||
.omo
|
||||
reasonix.toml
|
||||
.reasonix/
|
||||
.codegraph/
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
"Insomnia Art Designs",
|
||||
"2018cfh",
|
||||
"Arlecchino Shion",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"Charles Blakemore",
|
||||
"W+K+White",
|
||||
"$MetaSamsara",
|
||||
"wackop",
|
||||
@@ -20,23 +20,32 @@
|
||||
"Carl G.",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"Polymorphic Indeterminate",
|
||||
"Skalabananen",
|
||||
"Marc Whiffen",
|
||||
"Birdy",
|
||||
"itismyelement",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Kiba",
|
||||
"Reno Lam",
|
||||
"onesecondinosaur",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"zenbound",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Ricky Carter",
|
||||
"James Todd",
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"レプサイ",
|
||||
@@ -47,6 +56,7 @@
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
@@ -54,19 +64,14 @@
|
||||
"Edgar Tejeda",
|
||||
"Fraser Cross",
|
||||
"Liam MacDougal",
|
||||
"Polymorphic Indeterminate",
|
||||
"Sterilized",
|
||||
"JORGE+LUIZ+HUSSNI+MESSIAS",
|
||||
"Marc Whiffen",
|
||||
"Skalabananen",
|
||||
"Birdy",
|
||||
"quarz",
|
||||
"Reno Lam",
|
||||
"Greg",
|
||||
"JSST",
|
||||
"sig",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"Takkan",
|
||||
"wfpearl",
|
||||
"Matt+J",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
@@ -84,9 +89,9 @@
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
@@ -94,23 +99,24 @@
|
||||
"iamresist",
|
||||
"RedrockVP",
|
||||
"Wolffen",
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Steven Pfeiffer",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"nwalker94",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
@@ -120,16 +126,17 @@
|
||||
"AM Kuro",
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"Karl P.",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"graysock",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"fancypants",
|
||||
"Eldithor",
|
||||
"Jack B Nimble",
|
||||
"JaxMax",
|
||||
@@ -137,23 +144,25 @@
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"Olive",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Some Guy Named Barry",
|
||||
"M Postkasse",
|
||||
"AELOX",
|
||||
"Gooohokrbe",
|
||||
"Nicfit23",
|
||||
"wamekukyouzin",
|
||||
"OldBones",
|
||||
"Jacob Hoehler",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Christopher Michel",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Zach Gonser",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"Philip Hempel",
|
||||
"LeoZero",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"aai",
|
||||
"Mouthlessman",
|
||||
@@ -163,21 +172,22 @@
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"Penfore",
|
||||
"Karl P.",
|
||||
"Gordon Cole",
|
||||
"Adam Taylor",
|
||||
"AbstractAss",
|
||||
"Weird_With_A_Beard",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
"Jackthemind",
|
||||
"fancypants",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"takyamtom",
|
||||
@@ -186,6 +196,7 @@
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"Error_Rule34_Not_found",
|
||||
"carey6409",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
@@ -193,17 +204,12 @@
|
||||
"Neco28",
|
||||
"Cosmosis",
|
||||
"David Ortega",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"FloPro4Sho",
|
||||
"Cristian Vazquez",
|
||||
"drum matthieu",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Christopher Michel",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"Bruce",
|
||||
@@ -211,7 +217,7 @@
|
||||
"Steven Owens",
|
||||
"nahinahi9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Kevin Christopher",
|
||||
"Blackfish95",
|
||||
"Paul Kroll",
|
||||
"Bas Imagineer",
|
||||
@@ -219,10 +225,6 @@
|
||||
"yuxz69",
|
||||
"esthe",
|
||||
"decoy",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
"CloudValley",
|
||||
"thesoftwaredruid",
|
||||
"wundershark",
|
||||
@@ -236,6 +238,8 @@
|
||||
"linnfrey",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"IamAyam",
|
||||
"skaterb949",
|
||||
"Brian M",
|
||||
@@ -243,11 +247,12 @@
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"confiscated Zyra",
|
||||
"Error_Rule34_Not_found",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Gerald Welly",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Tee Gee",
|
||||
@@ -255,6 +260,7 @@
|
||||
"tarek helmi",
|
||||
"Eris3D",
|
||||
"Max Marklund",
|
||||
"m",
|
||||
"Pronredn",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
@@ -262,14 +268,16 @@
|
||||
"lh qwe",
|
||||
"James Coleman",
|
||||
"conner",
|
||||
"Kevin Christopher",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"Princess Bright Eyes",
|
||||
"Yuji Kaneko",
|
||||
"Dušan Ryban",
|
||||
"Felipe dos Santos",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Ace Ventura",
|
||||
"Douglas Gaspar",
|
||||
"Metryman55",
|
||||
"AlexDuKaNa",
|
||||
@@ -278,8 +286,7 @@
|
||||
"地獄の禄",
|
||||
"Gamalonia",
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"momokai",
|
||||
"Mirko Katzula",
|
||||
"dan",
|
||||
"Piccio08",
|
||||
@@ -294,8 +301,19 @@
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
"Nat_20",
|
||||
"Edward Weeks",
|
||||
"kyoumei",
|
||||
"RadStorm04",
|
||||
"JohnDoe42054",
|
||||
"gzmzmvp",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Andrew",
|
||||
@@ -303,12 +321,12 @@
|
||||
"Littlehuggy",
|
||||
"준희 김",
|
||||
"Brian Buie",
|
||||
"Thought2Form",
|
||||
"Kevin Picco",
|
||||
"Sadlip",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Tomohiro Baba",
|
||||
"m",
|
||||
"Mike Simone",
|
||||
"Noora",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
@@ -322,37 +340,24 @@
|
||||
"Martial",
|
||||
"Michael Anthony Scott",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Davaitamin",
|
||||
"Rops Alot",
|
||||
"tedcor",
|
||||
"Fotek Design",
|
||||
"Ace Ventura",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"LarsesFPC",
|
||||
"MadSpin",
|
||||
"FrxzenSnxw",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"momokai",
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
"Nat_20",
|
||||
"Edward Weeks",
|
||||
"kyoumei",
|
||||
"RadStorm04",
|
||||
"JohnDoe42054",
|
||||
"BillyHill",
|
||||
"emyth",
|
||||
"chriphost",
|
||||
@@ -374,26 +379,30 @@
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"gzmzmvp",
|
||||
"moonpetal",
|
||||
"g9p0o",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"Gregory Kozhemiak",
|
||||
"Draven T",
|
||||
"mrjuan",
|
||||
"Eric Whitney",
|
||||
"elleshar666",
|
||||
"Aquatic Coffee",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"John J Linehan",
|
||||
"ethanfel",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"Focuschannel",
|
||||
"Edward Kennedy",
|
||||
"Noah",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Vane Holzer",
|
||||
"hexxish",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"notedfakes",
|
||||
"Nathan",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Pat Hen",
|
||||
@@ -406,24 +415,24 @@
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"FrxzenSnxw",
|
||||
"zenobeus",
|
||||
"ryoma",
|
||||
"Whitepinetrader",
|
||||
"Stryker",
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"Menard",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"Pkrsky",
|
||||
"TheHolySheep",
|
||||
"nanana",
|
||||
"raf8osz",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
@@ -441,7 +450,6 @@
|
||||
"blikkies",
|
||||
"Chris",
|
||||
"Time Valentine",
|
||||
"elleshar666",
|
||||
"Shock Shockor",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
@@ -451,15 +459,13 @@
|
||||
"Zude",
|
||||
"SPJ",
|
||||
"Kyler",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
"aRtFuL_DodGeR",
|
||||
"Nick Kage",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"Xenon Xue",
|
||||
"notedfakes",
|
||||
"Edward Ten Eyck",
|
||||
"Billy Gladky",
|
||||
"Michael Scott",
|
||||
"Probis",
|
||||
@@ -468,6 +474,7 @@
|
||||
"ItsGeneralButtNaked",
|
||||
"Donor4115",
|
||||
"Distortik",
|
||||
"Manu Thetug",
|
||||
"Filippo Ferrari",
|
||||
"Youguang",
|
||||
"andrewzpong",
|
||||
@@ -478,9 +485,10 @@
|
||||
"AIVORY3D",
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"Whitepinetrader",
|
||||
"POPPIN",
|
||||
"nanana",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"Bob+Barker",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Alex",
|
||||
@@ -497,9 +505,6 @@
|
||||
"Alan+Cano",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"BillyBoy84",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
@@ -508,8 +513,10 @@
|
||||
"JBsuede",
|
||||
"moranqianlong",
|
||||
"Kalli Core",
|
||||
"Ronan Delevacq",
|
||||
"Christian Schäfer",
|
||||
"りん あめ",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"Frogmilk",
|
||||
@@ -519,16 +526,17 @@
|
||||
"John Rednoulf",
|
||||
"Kyron Mahan",
|
||||
"Bryan Rutkowski",
|
||||
"Boba Smith",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"JackJohnnyJim",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Khánh Đặng",
|
||||
"Edward Ten Eyck",
|
||||
"Michael Docherty",
|
||||
"Jimmy Borup",
|
||||
"Paul Hartsuyker",
|
||||
@@ -539,7 +547,6 @@
|
||||
"jinksta187",
|
||||
"RHopkirk",
|
||||
"Andrew Wilkinson",
|
||||
"Manu Thetug",
|
||||
"Karlanx",
|
||||
"Lyavph",
|
||||
"Maxim",
|
||||
@@ -564,6 +571,15 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"SAVEagleBasement",
|
||||
"Kevin+Isom",
|
||||
"Rune+Osnes",
|
||||
"you+halo9",
|
||||
"cloudghost",
|
||||
"Yongkwan+Lee",
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
@@ -590,33 +606,28 @@
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Inkognito",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"Jacky+Ho",
|
||||
"generic404",
|
||||
"abattoirblues",
|
||||
"zounik",
|
||||
"4IXplr0r3r",
|
||||
"hayden",
|
||||
"Obsidian.Studios",
|
||||
"ahoystan",
|
||||
"Bob Barker",
|
||||
"edk",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Ben D",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"ja s",
|
||||
"Leslie Andrew Ridings",
|
||||
"Doug Mason",
|
||||
"Jeremy Townsend",
|
||||
"Dave Abraham",
|
||||
"scoreswazey",
|
||||
"Owen Gwosdz",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Spire",
|
||||
"Mythspire",
|
||||
"AZ Party Oasis",
|
||||
"Boba Smith",
|
||||
"Devil Lude",
|
||||
"David Murcko",
|
||||
"MR.Bear",
|
||||
@@ -626,8 +637,8 @@
|
||||
"Terminuz",
|
||||
"ivistorm",
|
||||
"max blo",
|
||||
"Sauv",
|
||||
"CptNeo",
|
||||
"Jack Lawfield",
|
||||
"Borte",
|
||||
"Maso",
|
||||
"Ted Cart",
|
||||
@@ -642,6 +653,7 @@
|
||||
"SkibidiRizzler",
|
||||
"Tania Nayelli Fernandez",
|
||||
"Draconach",
|
||||
"Kalle Björk",
|
||||
"Yavizu3d",
|
||||
"Yves Poezevara",
|
||||
"Teriak47",
|
||||
@@ -694,6 +706,10 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"thomasand01",
|
||||
"Shiba+Sama",
|
||||
"miduzza",
|
||||
"KB",
|
||||
"shw",
|
||||
"Celestial+Kitten",
|
||||
"bakeliteboy",
|
||||
@@ -716,21 +732,12 @@
|
||||
"imer",
|
||||
"Akkas+Haque",
|
||||
"Kachac",
|
||||
"tyrant2811",
|
||||
"Kevin",
|
||||
"Rune+Osnes",
|
||||
"jcx29",
|
||||
"cloudghost",
|
||||
"Yongkwan+Lee",
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Drizzly",
|
||||
"Nebuleux",
|
||||
"Join+Chun",
|
||||
"GDS+DEV",
|
||||
"4rt+r3d",
|
||||
"you+halo9",
|
||||
"Somebody",
|
||||
"Somebody",
|
||||
"Crescent~San",
|
||||
@@ -743,7 +750,6 @@
|
||||
"Bula",
|
||||
"KUJYAKU",
|
||||
"Coeur+de+cochon",
|
||||
"Obsidian.Studios",
|
||||
"han b",
|
||||
"Zomba Mann",
|
||||
"Aquaneo",
|
||||
@@ -769,9 +775,12 @@
|
||||
"Seraphy",
|
||||
"雨の心 落",
|
||||
"AllTimeNoobie",
|
||||
"swra",
|
||||
"JollRodrigo",
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"Oliverfish",
|
||||
"yfx507",
|
||||
"Room Light",
|
||||
"Jairus Knudsen",
|
||||
@@ -798,7 +807,6 @@
|
||||
"Bouya shaka",
|
||||
"john Greene",
|
||||
"Faburizu",
|
||||
"Jack Lawfield",
|
||||
"jimyjomson",
|
||||
"JaeHyun Jang",
|
||||
"Homero Banda",
|
||||
@@ -807,7 +815,7 @@
|
||||
"yyuvuvu",
|
||||
"Inyoshu",
|
||||
"Chad Barnes",
|
||||
"Person Y",
|
||||
"Adam Gardner",
|
||||
"Nomki",
|
||||
"inusanorthcape",
|
||||
"James Ming",
|
||||
@@ -829,5 +837,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 826
|
||||
"totalCount": 834
|
||||
}
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Verwendungsanzahl"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} Versionen",
|
||||
"viewAllVersions": "Alle lokalen Versionen anzeigen"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Nach Modell gruppieren"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "Statistiken"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Suchen...",
|
||||
"placeholders": {
|
||||
"loras": "LoRAs suchen...",
|
||||
"recipes": "Rezepte suchen...",
|
||||
"checkpoints": "Checkpoints suchen...",
|
||||
"embeddings": "Embeddings suchen..."
|
||||
},
|
||||
"placeholder": "Suchen",
|
||||
"options": "Suchoptionen",
|
||||
"searchIn": "Suchen in:",
|
||||
"notAvailable": "Suche auf Statistikseite nicht verfügbar",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||
"priorityTags": "Prioritäts-Tags",
|
||||
"updateFlags": "Update-Markierungen",
|
||||
"versionScope": "Update-Markierungen",
|
||||
"exampleImages": "Beispielbilder",
|
||||
"autoOrganize": "Auto-Organisierung",
|
||||
"metadata": "Metadaten",
|
||||
@@ -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."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Nach Modell gruppieren",
|
||||
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
||||
"displayDensity": "Anzeige-Dichte",
|
||||
"displayDensityOptions": {
|
||||
"default": "Standard",
|
||||
@@ -586,7 +589,7 @@
|
||||
"download": "Herunterladen",
|
||||
"restartRequired": "Neustart erforderlich"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Strategie für Update-Markierungen",
|
||||
"help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.",
|
||||
"options": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "Kleinste",
|
||||
"usage": "Anzahl Nutzung",
|
||||
"usageDesc": "Meiste",
|
||||
"usageAsc": "Wenigste"
|
||||
"usageAsc": "Wenigste",
|
||||
"versionsCount": "Lokale Versionen",
|
||||
"versionsCountDesc": "Meiste Versionen zuerst",
|
||||
"versionsCountAsc": "Wenigste Versionen zuerst",
|
||||
"versionIdDesc": "Neueste Version zuerst"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Modelliste aktualisieren",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
||||
"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": {
|
||||
"recipe": "Rezept",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Ersetzen",
|
||||
"append": "Anhängen",
|
||||
"selectTargetNode": "Zielknoten auswählen",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
|
||||
"reconnectedSuccessfully": "LoRA erfolgreich neu verbunden",
|
||||
"reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}",
|
||||
"noPromptToSend": "Kein zu sendender Prompt",
|
||||
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
|
||||
"sendFailed": "Fehler beim Senden des Rezepts an Workflow",
|
||||
"sendError": "Fehler beim Senden des Rezepts an Workflow",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Times used"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versions",
|
||||
"viewAllVersions": "View all local versions"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Manage Excluded Models"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Group by Model"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "Stats"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search...",
|
||||
"placeholders": {
|
||||
"loras": "Search LoRAs...",
|
||||
"recipes": "Search recipes...",
|
||||
"checkpoints": "Search checkpoints...",
|
||||
"embeddings": "Search embeddings..."
|
||||
},
|
||||
"placeholder": "Search",
|
||||
"options": "Search Options",
|
||||
"searchIn": "Search In:",
|
||||
"notAvailable": "Search not available on statistics page",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "Extra Folder Paths",
|
||||
"downloadPathTemplates": "Download Path Templates",
|
||||
"priorityTags": "Priority Tags",
|
||||
"updateFlags": "Update Flags",
|
||||
"versionScope": "Version Scope",
|
||||
"exampleImages": "Example Images",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"metadata": "Metadata",
|
||||
@@ -430,6 +431,8 @@
|
||||
"help": "When enabled, versions downloaded before will be skipped."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Group by Model",
|
||||
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
"default": "Default",
|
||||
@@ -586,12 +589,12 @@
|
||||
"download": "Download",
|
||||
"restartRequired": "Requires restart"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "Update Flag Strategy",
|
||||
"help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.",
|
||||
"versionGrouping": {
|
||||
"label": "Version Grouping",
|
||||
"help": "Decide how versions are grouped for display: by base model or all together. Also controls update badge logic and the VLM version list filtering.",
|
||||
"options": {
|
||||
"sameBase": "Match updates by base model",
|
||||
"any": "Flag any available update"
|
||||
"sameBase": "Group by base model (same_base)",
|
||||
"any": "Show all versions (any)"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "Smallest",
|
||||
"usage": "Use Count",
|
||||
"usageDesc": "Most",
|
||||
"usageAsc": "Least"
|
||||
"usageAsc": "Least",
|
||||
"versionsCount": "Local Versions",
|
||||
"versionsCountDesc": "Most versions first",
|
||||
"versionsCountAsc": "Fewest versions first",
|
||||
"versionIdDesc": "Newest version first"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh model list",
|
||||
@@ -1463,7 +1470,7 @@
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
"viewLocalVersions": "View all local versions",
|
||||
"viewLocalTooltip": "Coming soon"
|
||||
"viewLocalTooltip": "Show all local versions of this model on the main page"
|
||||
},
|
||||
"filters": {
|
||||
"label": "Base filter",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "Model updated in workflow",
|
||||
"modelFailed": "Failed to update model node",
|
||||
"embeddingAdded": "Embedding added to workflow",
|
||||
"embeddingFailed": "Failed to add embedding"
|
||||
"embeddingFailed": "Failed to add embedding",
|
||||
"promptSent": "Prompt sent to workflow",
|
||||
"promptFailed": "Failed to send prompt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Replace",
|
||||
"append": "Append",
|
||||
"selectTargetNode": "Select target node",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "Please enter a LoRA name or syntax",
|
||||
"reconnectedSuccessfully": "LoRA reconnected successfully",
|
||||
"reconnectFailed": "Error reconnecting LoRA: {message}",
|
||||
"noPromptToSend": "No prompt to send",
|
||||
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
||||
"sendFailed": "Failed to send recipe to workflow",
|
||||
"sendError": "Error sending recipe to workflow",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Veces usado"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versiones",
|
||||
"viewAllVersions": "Ver todas las versiones locales"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Agrupar por modelo"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "Estadísticas"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar...",
|
||||
"placeholders": {
|
||||
"loras": "Buscar LoRAs...",
|
||||
"recipes": "Buscar recetas...",
|
||||
"checkpoints": "Buscar checkpoints...",
|
||||
"embeddings": "Buscar embeddings..."
|
||||
},
|
||||
"placeholder": "Buscar",
|
||||
"options": "Opciones de búsqueda",
|
||||
"searchIn": "Buscar en:",
|
||||
"notAvailable": "Búsqueda no disponible en la página de estadísticas",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||
"priorityTags": "Etiquetas prioritarias",
|
||||
"updateFlags": "Indicadores de actualización",
|
||||
"versionScope": "Indicadores de actualización",
|
||||
"exampleImages": "Imágenes de ejemplo",
|
||||
"autoOrganize": "Organización automática",
|
||||
"metadata": "Metadatos",
|
||||
@@ -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."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Agrupar por modelo",
|
||||
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
|
||||
"displayDensity": "Densidad de visualización",
|
||||
"displayDensityOptions": {
|
||||
"default": "Predeterminado",
|
||||
@@ -586,7 +589,7 @@
|
||||
"download": "Descargar",
|
||||
"restartRequired": "Requiere reinicio"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Estrategia de indicadores de actualización",
|
||||
"help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.",
|
||||
"options": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "Menor",
|
||||
"usage": "Número de usos",
|
||||
"usageDesc": "Más",
|
||||
"usageAsc": "Menos"
|
||||
"usageAsc": "Menos",
|
||||
"versionsCount": "Versiones locales",
|
||||
"versionsCountDesc": "Más versiones primero",
|
||||
"versionsCountAsc": "Menos versiones primero",
|
||||
"versionIdDesc": "Versión más nueva primero"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de modelos",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||
"modelFailed": "Error al actualizar nodo de modelo",
|
||||
"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": {
|
||||
"recipe": "Receta",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Reemplazar",
|
||||
"append": "Añadir",
|
||||
"selectTargetNode": "Seleccionar nodo de destino",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
|
||||
"reconnectedSuccessfully": "LoRA reconectado exitosamente",
|
||||
"reconnectFailed": "Error reconectando LoRA: {message}",
|
||||
"noPromptToSend": "No hay prompt para enviar",
|
||||
"cannotSend": "No se puede enviar receta: Falta ID de receta",
|
||||
"sendFailed": "Error al enviar receta al flujo de trabajo",
|
||||
"sendError": "Error enviando receta al flujo de trabajo",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Nombre d'utilisations"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} versions",
|
||||
"viewAllVersions": "Voir toutes les versions locales"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Grouper par modèle"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "Statistiques"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher...",
|
||||
"placeholders": {
|
||||
"loras": "Rechercher des LoRAs...",
|
||||
"recipes": "Rechercher des recipes...",
|
||||
"checkpoints": "Rechercher des checkpoints...",
|
||||
"embeddings": "Rechercher des embeddings..."
|
||||
},
|
||||
"placeholder": "Rechercher",
|
||||
"options": "Options de recherche",
|
||||
"searchIn": "Rechercher dans :",
|
||||
"notAvailable": "Recherche non disponible sur la page de statistiques",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||
"priorityTags": "Étiquettes prioritaires",
|
||||
"updateFlags": "Indicateurs de mise à jour",
|
||||
"versionScope": "Indicateurs de mise à jour",
|
||||
"exampleImages": "Images d'exemple",
|
||||
"autoOrganize": "Organisation automatique",
|
||||
"metadata": "Métadonnées",
|
||||
@@ -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."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Grouper par modèle",
|
||||
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
|
||||
"displayDensity": "Densité d'affichage",
|
||||
"displayDensityOptions": {
|
||||
"default": "Par défaut",
|
||||
@@ -586,7 +589,7 @@
|
||||
"download": "Télécharger",
|
||||
"restartRequired": "Redémarrage requis"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Stratégie des indicateurs de mise à jour",
|
||||
"help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.",
|
||||
"options": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "Plus petit",
|
||||
"usage": "Nombre d'utilisations",
|
||||
"usageDesc": "Plus",
|
||||
"usageAsc": "Moins"
|
||||
"usageAsc": "Moins",
|
||||
"versionsCount": "Versions locales",
|
||||
"versionsCountDesc": "Plus de versions d'abord",
|
||||
"versionsCountAsc": "Moins de versions d'abord",
|
||||
"versionIdDesc": "Version la plus récente d'abord"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des modèles",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
||||
"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": {
|
||||
"recipe": "Recipe",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "Prompt",
|
||||
"replace": "Remplacer",
|
||||
"append": "Ajouter",
|
||||
"selectTargetNode": "Sélectionner le nœud cible",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
|
||||
"reconnectedSuccessfully": "LoRA reconnecté avec succès",
|
||||
"reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}",
|
||||
"noPromptToSend": "Aucun prompt à envoyer",
|
||||
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
|
||||
"sendFailed": "Échec de l'envoi de la recipe vers le workflow",
|
||||
"sendError": "Erreur lors de l'envoi de la recipe vers le workflow",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "מספר שימושים"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} גרסאות",
|
||||
"viewAllVersions": "הצג את כל הגרסאות המקומיות"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "קיבוץ לפי דגם"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "סטטיסטיקה"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "חפש...",
|
||||
"placeholders": {
|
||||
"loras": "חפש LoRAs...",
|
||||
"recipes": "חפש מתכונים...",
|
||||
"checkpoints": "חפש checkpoints...",
|
||||
"embeddings": "חפש embeddings..."
|
||||
},
|
||||
"placeholder": "חיפוש",
|
||||
"options": "אפשרויות חיפוש",
|
||||
"searchIn": "חפש ב:",
|
||||
"notAvailable": "חיפוש לא זמין בדף הסטטיסטיקה",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||
"priorityTags": "תגיות עדיפות",
|
||||
"updateFlags": "תגי עדכון",
|
||||
"versionScope": "תגי עדכון",
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"autoOrganize": "ארגון אוטומטי",
|
||||
"metadata": "מטא-נתונים",
|
||||
@@ -430,6 +431,8 @@
|
||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "קיבוץ לפי דגם",
|
||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
"default": "ברירת מחדל",
|
||||
@@ -586,7 +589,7 @@
|
||||
"download": "הורד",
|
||||
"restartRequired": "דורש הפעלה מחדש"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "אסטרטגיית תגי עדכון",
|
||||
"help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.",
|
||||
"options": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "הקטן ביותר",
|
||||
"usage": "מספר שימושים",
|
||||
"usageDesc": "הכי הרבה",
|
||||
"usageAsc": "הכי פחות"
|
||||
"usageAsc": "הכי פחות",
|
||||
"versionsCount": "גרסאות מקומיות",
|
||||
"versionsCountDesc": "הכי הרבה גרסאות ראשונות",
|
||||
"versionsCountAsc": "הכי מעט גרסאות ראשונות",
|
||||
"versionIdDesc": "גרסה חדשה ביותר ראשונה"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מודלים",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "מודל עודכן ב-workflow",
|
||||
"modelFailed": "עדכון צומת המודל נכשל",
|
||||
"embeddingAdded": "Embedding נוסף ל-workflow",
|
||||
"embeddingFailed": "הוספת Embedding נכשלה"
|
||||
"embeddingFailed": "הוספת Embedding נכשלה",
|
||||
"promptSent": "הנחיה נשלחה ל-workflow",
|
||||
"promptFailed": "שליחת ההנחיה נכשלה"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "מתכון",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "הנחיה",
|
||||
"replace": "החלף",
|
||||
"append": "הוסף",
|
||||
"selectTargetNode": "בחר צומת יעד",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "אנא הזן שם LoRA או תחביר",
|
||||
"reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה",
|
||||
"reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}",
|
||||
"noPromptToSend": "אין הנחיה לשליחה",
|
||||
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
|
||||
"sendFailed": "שליחת המתכון ל-workflow נכשלה",
|
||||
"sendError": "שגיאה בשליחת המתכון ל-workflow",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用回数"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} バージョン",
|
||||
"viewAllVersions": "ローカルの全バージョンを表示"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "モデルでグループ化"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "検索...",
|
||||
"placeholders": {
|
||||
"loras": "LoRAを検索...",
|
||||
"recipes": "レシピを検索...",
|
||||
"checkpoints": "checkpointを検索...",
|
||||
"embeddings": "embeddingを検索..."
|
||||
},
|
||||
"placeholder": "検索",
|
||||
"options": "検索オプション",
|
||||
"searchIn": "検索対象:",
|
||||
"notAvailable": "統計ページでは検索は利用できません",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "追加フォルダーパス",
|
||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||
"priorityTags": "優先タグ",
|
||||
"updateFlags": "アップデートフラグ",
|
||||
"versionScope": "アップデートフラグ",
|
||||
"exampleImages": "例画像",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "メタデータ",
|
||||
@@ -430,6 +431,8 @@
|
||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "モデルでグループ化",
|
||||
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "デフォルト",
|
||||
@@ -586,7 +589,7 @@
|
||||
"download": "ダウンロード",
|
||||
"restartRequired": "再起動が必要"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "アップデートフラグの表示戦略",
|
||||
"help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。",
|
||||
"options": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "小さい順",
|
||||
"usage": "使用回数",
|
||||
"usageDesc": "多い",
|
||||
"usageAsc": "少ない"
|
||||
"usageAsc": "少ない",
|
||||
"versionsCount": "ローカルバージョン数",
|
||||
"versionsCountDesc": "バージョン数の多い順",
|
||||
"versionsCountAsc": "バージョン数の少ない順",
|
||||
"versionIdDesc": "最新バージョン順"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "モデルがワークフローで更新されました",
|
||||
"modelFailed": "モデルノードの更新に失敗しました",
|
||||
"embeddingAdded": "Embeddingをワークフローに追加しました",
|
||||
"embeddingFailed": "Embeddingの追加に失敗しました"
|
||||
"embeddingFailed": "Embeddingの追加に失敗しました",
|
||||
"promptSent": "プロンプトをワークフローに送信しました",
|
||||
"promptFailed": "プロンプトの送信に失敗しました"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "プロンプト",
|
||||
"replace": "置換",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "ターゲットノードを選択",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "LoRA名または構文を入力してください",
|
||||
"reconnectedSuccessfully": "LoRAが正常に再接続されました",
|
||||
"reconnectFailed": "LoRA再接続エラー:{message}",
|
||||
"noPromptToSend": "送信するプロンプトがありません",
|
||||
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
||||
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
||||
"sendError": "レシピのワークフロー送信エラー",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "사용 횟수"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count}개 버전",
|
||||
"viewAllVersions": "모든 로컬 버전 보기"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "모델별 그룹화"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "통계"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "검색...",
|
||||
"placeholders": {
|
||||
"loras": "LoRA 검색...",
|
||||
"recipes": "레시피 검색...",
|
||||
"checkpoints": "Checkpoint 검색...",
|
||||
"embeddings": "Embedding 검색..."
|
||||
},
|
||||
"placeholder": "검색",
|
||||
"options": "검색 옵션",
|
||||
"searchIn": "검색 범위:",
|
||||
"notAvailable": "통계 페이지에서는 검색을 사용할 수 없습니다",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "추가 폴다 경로",
|
||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||
"priorityTags": "우선순위 태그",
|
||||
"updateFlags": "업데이트 표시",
|
||||
"versionScope": "업데이트 표시",
|
||||
"exampleImages": "예시 이미지",
|
||||
"autoOrganize": "자동 정리",
|
||||
"metadata": "메타데이터",
|
||||
@@ -430,6 +431,8 @@
|
||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "모델별 그룹화",
|
||||
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
"default": "기본",
|
||||
@@ -586,7 +589,7 @@
|
||||
"download": "다운로드",
|
||||
"restartRequired": "재시작 필요"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "업데이트 표시 전략",
|
||||
"help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.",
|
||||
"options": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "작은 순서",
|
||||
"usage": "사용 횟수",
|
||||
"usageDesc": "많은 순",
|
||||
"usageAsc": "적은 순"
|
||||
"usageAsc": "적은 순",
|
||||
"versionsCount": "로컬 버전 수",
|
||||
"versionsCountDesc": "버전 수 많은 순",
|
||||
"versionsCountAsc": "버전 수 적은 순",
|
||||
"versionIdDesc": "최신 버전순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||
"modelFailed": "모델 노드 업데이트 실패",
|
||||
"embeddingAdded": "Embedding을 워크플로에 추가했습니다",
|
||||
"embeddingFailed": "Embedding 추가 실패"
|
||||
"embeddingFailed": "Embedding 추가 실패",
|
||||
"promptSent": "프롬프트를 워크플로에 보냈습니다",
|
||||
"promptFailed": "프롬프트 보내기 실패"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
"lora": "LoRA",
|
||||
"embedding": "임베딩",
|
||||
"prompt": "프롬프트",
|
||||
"replace": "교체",
|
||||
"append": "추가",
|
||||
"selectTargetNode": "대상 노드 선택",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
|
||||
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
|
||||
"reconnectFailed": "LoRA 다시 연결 오류: {message}",
|
||||
"noPromptToSend": "보낼 프롬프트가 없습니다",
|
||||
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
||||
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
||||
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "Количество использований"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} версий",
|
||||
"viewAllVersions": "Показать все локальные версии"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "Группировать по модели"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "Статистика"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск...",
|
||||
"placeholders": {
|
||||
"loras": "Поиск LoRAs...",
|
||||
"recipes": "Поиск рецептов...",
|
||||
"checkpoints": "Поиск checkpoints...",
|
||||
"embeddings": "Поиск embeddings..."
|
||||
},
|
||||
"placeholder": "Поиск",
|
||||
"options": "Опции поиска",
|
||||
"searchIn": "Искать в:",
|
||||
"notAvailable": "Поиск недоступен на странице статистики",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||
"priorityTags": "Приоритетные теги",
|
||||
"updateFlags": "Метки обновлений",
|
||||
"versionScope": "Метки обновлений",
|
||||
"exampleImages": "Примеры изображений",
|
||||
"autoOrganize": "Автоорганизация",
|
||||
"metadata": "Метаданные",
|
||||
@@ -430,6 +431,8 @@
|
||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Группировать по модели",
|
||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
"default": "По умолчанию",
|
||||
@@ -586,7 +589,7 @@
|
||||
"download": "Загрузить",
|
||||
"restartRequired": "Требует перезапуска"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "Стратегия меток обновлений",
|
||||
"help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.",
|
||||
"options": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "Наименьшим",
|
||||
"usage": "Число использований",
|
||||
"usageDesc": "Больше",
|
||||
"usageAsc": "Меньше"
|
||||
"usageAsc": "Меньше",
|
||||
"versionsCount": "Локальные версии",
|
||||
"versionsCountDesc": "Сначала больше версий",
|
||||
"versionsCountAsc": "Сначала меньше версий",
|
||||
"versionIdDesc": "Сначала новые версии"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "Модель обновлена в workflow",
|
||||
"modelFailed": "Не удалось обновить узел модели",
|
||||
"embeddingAdded": "Embedding добавлен в workflow",
|
||||
"embeddingFailed": "Не удалось добавить embedding"
|
||||
"embeddingFailed": "Не удалось добавить embedding",
|
||||
"promptSent": "Запрос отправлен в workflow",
|
||||
"promptFailed": "Не удалось отправить запрос"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Эмбеддинг",
|
||||
"prompt": "Запрос",
|
||||
"replace": "Заменить",
|
||||
"append": "Добавить",
|
||||
"selectTargetNode": "Выберите целевой узел",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
|
||||
"reconnectedSuccessfully": "LoRA успешно переподключена",
|
||||
"reconnectFailed": "Ошибка переподключения LoRA: {message}",
|
||||
"noPromptToSend": "Нет запроса для отправки",
|
||||
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
|
||||
"sendFailed": "Не удалось отправить рецепт в workflow",
|
||||
"sendError": "Ошибка отправки рецепта в workflow",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次数"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} 个版本",
|
||||
"viewAllVersions": "查看所有本地版本"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "按模型分组"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "统计"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索...",
|
||||
"placeholders": {
|
||||
"loras": "搜索 LoRA...",
|
||||
"recipes": "搜索配方...",
|
||||
"checkpoints": "搜索 Checkpoint...",
|
||||
"embeddings": "搜索 Embedding..."
|
||||
},
|
||||
"placeholder": "搜索",
|
||||
"options": "搜索选项",
|
||||
"searchIn": "搜索范围:",
|
||||
"notAvailable": "统计页面不可用搜索",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "额外文件夹路径",
|
||||
"downloadPathTemplates": "下载路径模板",
|
||||
"priorityTags": "优先标签",
|
||||
"updateFlags": "更新标记",
|
||||
"versionScope": "版本范围",
|
||||
"exampleImages": "示例图片",
|
||||
"autoOrganize": "自动整理",
|
||||
"metadata": "元数据",
|
||||
@@ -430,6 +431,8 @@
|
||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分组",
|
||||
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "默认",
|
||||
@@ -586,12 +589,12 @@
|
||||
"download": "下载",
|
||||
"restartRequired": "需要重启"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"label": "更新标记策略",
|
||||
"help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。",
|
||||
"versionGrouping": {
|
||||
"label": "版本分组",
|
||||
"help": "控制版本在 UI 中的分组方式:按基础模型分组或合并显示。同时影响更新徽章逻辑和版本列表的筛选行为。",
|
||||
"options": {
|
||||
"sameBase": "按基础模型匹配更新",
|
||||
"any": "显示任何可用更新"
|
||||
"sameBase": "按基础模型分组",
|
||||
"any": "显示所有版本"
|
||||
}
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次数",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
"usageAsc": "最少",
|
||||
"versionsCount": "本地版本数",
|
||||
"versionsCountDesc": "版本数从多到少",
|
||||
"versionsCountAsc": "版本数从少到多",
|
||||
"versionIdDesc": "最新版本优先"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型节点失败",
|
||||
"embeddingAdded": "Embedding 已追加到工作流",
|
||||
"embeddingFailed": "添加 Embedding 失败"
|
||||
"embeddingFailed": "添加 Embedding 失败",
|
||||
"promptSent": "提示词已发送到工作流",
|
||||
"promptFailed": "提示词发送失败"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "提示词",
|
||||
"replace": "替换",
|
||||
"append": "追加",
|
||||
"selectTargetNode": "选择目标节点",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "请输入 LoRA 名称或语法",
|
||||
"reconnectedSuccessfully": "LoRA 重新连接成功",
|
||||
"reconnectFailed": "LoRA 重新连接出错:{message}",
|
||||
"noPromptToSend": "没有可发送的提示词",
|
||||
"cannotSend": "无法发送配方:缺少配方 ID",
|
||||
"sendFailed": "发送配方到工作流失败",
|
||||
"sendError": "发送配方到工作流出错",
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
},
|
||||
"usage": {
|
||||
"timesUsed": "使用次數"
|
||||
},
|
||||
"footer": {
|
||||
"versionCount": "{count} 個版本",
|
||||
"viewAllVersions": "檢視所有本地版本"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -183,6 +187,9 @@
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
"groupByModel": {
|
||||
"label": "按模型分組"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -195,13 +202,7 @@
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜尋...",
|
||||
"placeholders": {
|
||||
"loras": "搜尋 LoRA...",
|
||||
"recipes": "搜尋配方...",
|
||||
"checkpoints": "搜尋 checkpoint...",
|
||||
"embeddings": "搜尋 embedding..."
|
||||
},
|
||||
"placeholder": "搜尋",
|
||||
"options": "搜尋選項",
|
||||
"searchIn": "搜尋範圍:",
|
||||
"notAvailable": "統計頁面無法搜尋",
|
||||
@@ -325,7 +326,7 @@
|
||||
"extraFolderPaths": "額外資料夾路徑",
|
||||
"downloadPathTemplates": "下載路徑範本",
|
||||
"priorityTags": "優先標籤",
|
||||
"updateFlags": "更新標記",
|
||||
"versionScope": "版本範圍",
|
||||
"exampleImages": "範例圖片",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "中繼資料",
|
||||
@@ -430,6 +431,8 @@
|
||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分組",
|
||||
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "預設",
|
||||
@@ -586,7 +589,7 @@
|
||||
"download": "下載",
|
||||
"restartRequired": "需要重新啟動"
|
||||
},
|
||||
"updateFlagStrategy": {
|
||||
"versionGrouping": {
|
||||
"label": "更新標記策略",
|
||||
"help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。",
|
||||
"options": {
|
||||
@@ -670,7 +673,11 @@
|
||||
"sizeAsc": "最小",
|
||||
"usage": "使用次數",
|
||||
"usageDesc": "最多",
|
||||
"usageAsc": "最少"
|
||||
"usageAsc": "最少",
|
||||
"versionsCount": "本地版本數",
|
||||
"versionsCountDesc": "版本數從多到少",
|
||||
"versionsCountAsc": "版本數從少到多",
|
||||
"versionIdDesc": "最新版本優先"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
@@ -1608,12 +1615,15 @@
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型節點失敗",
|
||||
"embeddingAdded": "Embedding 已附加到工作流",
|
||||
"embeddingFailed": "傳送 Embedding 到工作流失敗"
|
||||
"embeddingFailed": "傳送 Embedding 到工作流失敗",
|
||||
"promptSent": "提示詞已發送到工作流",
|
||||
"promptFailed": "提示詞發送失敗"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
"lora": "LoRA",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "提示詞",
|
||||
"replace": "取代",
|
||||
"append": "附加",
|
||||
"selectTargetNode": "選擇目標節點",
|
||||
@@ -1800,6 +1810,7 @@
|
||||
"enterLoraName": "請輸入 LoRA 名稱或語法",
|
||||
"reconnectedSuccessfully": "LoRA 重新連結成功",
|
||||
"reconnectFailed": "LoRA 重新連結錯誤:{message}",
|
||||
"noPromptToSend": "沒有可發送的提示詞",
|
||||
"cannotSend": "無法傳送配方:缺少配方 ID",
|
||||
"sendFailed": "傳送配方到工作流失敗",
|
||||
"sendError": "傳送配方到工作流錯誤",
|
||||
|
||||
@@ -608,7 +608,7 @@ class SaveImageLM:
|
||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||
|
||||
# Generate filename with counter if needed
|
||||
base_filename = filename
|
||||
base_filename = filename.replace("%batch_num%", str(i))
|
||||
if add_counter_to_filename:
|
||||
# Use counter + i to ensure unique filenames for all images in batch
|
||||
current_counter = counter + i
|
||||
|
||||
@@ -535,6 +535,7 @@ class NodeRegistry:
|
||||
"capabilities": capabilities,
|
||||
"widget_names": widget_names,
|
||||
"mode": node.get("mode"),
|
||||
"marker_role": node.get("marker_role"),
|
||||
}
|
||||
logger.debug("Registered %s nodes in registry", len(nodes))
|
||||
self._registry_updated.set()
|
||||
@@ -3104,13 +3105,17 @@ class NodeRegistryHandler:
|
||||
try:
|
||||
data = await request.json()
|
||||
widget_name = data.get("widget_name")
|
||||
action = data.get("action")
|
||||
value = data.get("value")
|
||||
mode = data.get("mode", "replace")
|
||||
node_ids = data.get("node_ids")
|
||||
|
||||
if not isinstance(widget_name, str) or not widget_name:
|
||||
if not action and (not isinstance(widget_name, str) or not widget_name):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing widget_name parameter"},
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing parameter: provide either 'action' or 'widget_name'",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -3149,12 +3154,15 @@ class NodeRegistryHandler:
|
||||
except (TypeError, ValueError):
|
||||
parsed_node_id = node_identifier
|
||||
|
||||
payload = {
|
||||
payload: dict = {
|
||||
"id": parsed_node_id,
|
||||
"widget_name": widget_name,
|
||||
"value": value,
|
||||
"mode": mode,
|
||||
}
|
||||
if action:
|
||||
payload["action"] = action
|
||||
if widget_name:
|
||||
payload["widget_name"] = widget_name
|
||||
|
||||
if graph_identifier is not None:
|
||||
payload["graph_id"] = str(graph_identifier)
|
||||
|
||||
@@ -233,6 +233,8 @@ class ModelListingHandler:
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
params = self._parse_common_params(request)
|
||||
# group_by_model is meaningless for excluded view; strip it
|
||||
params.pop("group_by_model", None)
|
||||
result = await self._service.get_excluded_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
@@ -366,6 +368,19 @@ class ModelListingHandler:
|
||||
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Group-by-model flag: deduplicate versions sharing the same civitai modelId
|
||||
group_by_model = (
|
||||
request.query.get("group_by_model", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# View-local-versions filter: show all local versions of a specific model
|
||||
civitai_model_id = request.query.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
try:
|
||||
civitai_model_id = int(civitai_model_id)
|
||||
except (TypeError, ValueError):
|
||||
civitai_model_id = None
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -389,6 +404,8 @@ class ModelListingHandler:
|
||||
"name_pattern_include": name_pattern_include,
|
||||
"name_pattern_exclude": name_pattern_exclude,
|
||||
"name_pattern_use_regex": name_pattern_use_regex,
|
||||
"group_by_model": group_by_model,
|
||||
"civitai_model_id": civitai_model_id,
|
||||
**self._parse_specific_params(request),
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,100 @@ class BaseModelService(ABC):
|
||||
fetch_duration = time.perf_counter() - t0
|
||||
initial_count = len(sorted_data)
|
||||
|
||||
# Optionally filter by civitai model ID (shows all local versions of a specific model)
|
||||
civitai_model_id = kwargs.get("civitai_model_id")
|
||||
if civitai_model_id is not None:
|
||||
sorted_data = [
|
||||
item for item in sorted_data
|
||||
if self._extract_model_id(item) == civitai_model_id
|
||||
]
|
||||
# VLM mode: always sort by version ID descending (newest version first),
|
||||
# regardless of the current sort_by preference.
|
||||
sorted_data.sort(
|
||||
key=lambda x: self._extract_version_id(x) or 0,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Optionally group by civitai modelId, showing only the latest version per model
|
||||
dedup_lost = 0
|
||||
if kwargs.get("group_by_model") and civitai_model_id is None:
|
||||
# Determine whether to further sub-group by base model
|
||||
# When version_grouping is "same_base", versions with different
|
||||
# base models are effectively different groups — the dedup key
|
||||
# needs to include base_model so the version count and VLM flow
|
||||
# stay consistent (card shows correct count for its base model).
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
dedup_map = {} # (modelId [,base_model]) -> (item, version_id)
|
||||
version_counter = {} # same-key -> count
|
||||
standalone = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_model_id(item)
|
||||
if mid is None:
|
||||
standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
# Count all versions per key
|
||||
version_counter[key] = version_counter.get(key, 0) + 1
|
||||
vid = self._extract_version_id(item) or 0
|
||||
if key not in dedup_map or vid > dedup_map[key][1]:
|
||||
dedup_map[key] = (item, vid)
|
||||
# Attach version_count to each surviving grouped item (shallow copy
|
||||
# to avoid mutating cached dicts — the cache is shared across requests)
|
||||
for key, (item, vid) in dedup_map.items():
|
||||
item = dict(item)
|
||||
item["version_count"] = version_counter[key]
|
||||
dedup_map[key] = (item, vid)
|
||||
dedup_lost = len(sorted_data) - (len(dedup_map) + len(standalone))
|
||||
sorted_data = [entry[0] for entry in dedup_map.values()] + standalone
|
||||
|
||||
# Re-sort by version_count (grouped: after dedup; non-grouped: group internally, sort, expand)
|
||||
if sort_params.key == "versions_count" and civitai_model_id is None:
|
||||
reverse = sort_params.order == "desc"
|
||||
if kwargs.get("group_by_model"):
|
||||
# Grouped mode: items are already dedup'd with version_count attached
|
||||
sorted_data.sort(
|
||||
key=lambda x: (
|
||||
x.get("version_count", 0),
|
||||
(x.get("model_name") or x.get("file_name") or "").lower(),
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
reverse=reverse,
|
||||
)
|
||||
else:
|
||||
# Non-grouped mode: group internally, sort groups by count, expand
|
||||
# Respect the version_grouping setting (same logic as grouped dedup)
|
||||
ufs = self.settings.get("version_grouping", "same_base")
|
||||
group_by_base = ufs == "same_base"
|
||||
|
||||
model_groups: Dict[Any, List[Dict]] = {}
|
||||
ungrouped_standalone: List[Dict] = []
|
||||
for item in sorted_data:
|
||||
mid = self._extract_model_id(item)
|
||||
if mid is None:
|
||||
ungrouped_standalone.append(item)
|
||||
continue
|
||||
key = (mid, item.get("base_model") or "") if group_by_base else mid
|
||||
model_groups.setdefault(key, []).append(item)
|
||||
# Sort versions within each group by version id descending
|
||||
for items in model_groups.values():
|
||||
items.sort(
|
||||
key=lambda x: self._extract_version_id(x) or 0,
|
||||
reverse=True,
|
||||
)
|
||||
# Sort groups by version count
|
||||
sorted_groups = sorted(
|
||||
model_groups.values(),
|
||||
key=lambda items: len(items),
|
||||
reverse=reverse,
|
||||
)
|
||||
# Flatten: grouped items first, standalone items last
|
||||
sorted_data = []
|
||||
for items in sorted_groups:
|
||||
sorted_data.extend(items)
|
||||
sorted_data.extend(ungrouped_standalone)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
if hash_filters:
|
||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||
@@ -172,7 +266,7 @@ class BaseModelService(ABC):
|
||||
overall_duration = time.perf_counter() - overall_start
|
||||
logger.debug(
|
||||
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
|
||||
"Counts: initial=%d, post_filter=%d, final=%d",
|
||||
"Counts: initial=%d, dedup=%d, post_filter=%d, final=%d",
|
||||
self.__class__.__name__,
|
||||
overall_duration,
|
||||
fetch_duration,
|
||||
@@ -181,6 +275,7 @@ class BaseModelService(ABC):
|
||||
pagination_duration,
|
||||
annotate_duration,
|
||||
initial_count,
|
||||
dedup_lost,
|
||||
post_filter_count,
|
||||
final_count,
|
||||
)
|
||||
@@ -495,7 +590,7 @@ class BaseModelService(ABC):
|
||||
if not ordered_ids:
|
||||
return annotated
|
||||
|
||||
strategy_value = self.settings.get("update_flag_strategy")
|
||||
strategy_value = self.settings.get("version_grouping")
|
||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||
strategy = strategy_value.strip().lower()
|
||||
else:
|
||||
|
||||
@@ -48,6 +48,7 @@ class CheckpointService(BaseModelService):
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||
"version_count": checkpoint_data.get("version_count"),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -48,6 +48,7 @@ class EmbeddingService(BaseModelService):
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||
"version_count": embedding_data.get("version_count"),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
|
||||
@@ -59,6 +59,7 @@ class LoraService(BaseModelService):
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
),
|
||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||
"version_count": lora_data.get("version_count"),
|
||||
}
|
||||
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
|
||||
@@ -18,6 +18,8 @@ SUPPORTED_SORT_MODES = [
|
||||
('size', 'desc'),
|
||||
('usage', 'asc'),
|
||||
('usage', 'desc'),
|
||||
('versions_count', 'asc'),
|
||||
('versions_count', 'desc'),
|
||||
]
|
||||
# Is this in use?
|
||||
|
||||
@@ -263,6 +265,17 @@ class ModelCache:
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'versions_count':
|
||||
# Pre-dedup sort: fall back to name sort.
|
||||
# Actual re-sort by version_count happens in get_paginated_data after dedup.
|
||||
result = natsorted(
|
||||
data,
|
||||
key=lambda x: (
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
else:
|
||||
# Fallback: no sort
|
||||
result = list(data)
|
||||
|
||||
@@ -98,7 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"lora_syntax_format": "legacy",
|
||||
"model_card_footer_action": "replace_preview",
|
||||
"show_version_on_card": True,
|
||||
"update_flag_strategy": "same_base",
|
||||
"version_grouping": "same_base",
|
||||
"auto_organize_exclusions": [],
|
||||
"metadata_refresh_skip_paths": [],
|
||||
"skip_previously_downloaded_model_versions": False,
|
||||
@@ -106,6 +106,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
"group_by_model": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -744,6 +745,7 @@ class SettingsManager:
|
||||
"includeTriggerWords": "include_trigger_words",
|
||||
"compactMode": "compact_mode",
|
||||
"modelCardFooterAction": "model_card_footer_action",
|
||||
"update_flag_strategy": "version_grouping",
|
||||
}
|
||||
|
||||
updated = False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -509,6 +509,50 @@
|
||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||
}
|
||||
|
||||
/* Clickable version count link (shown in group-by-model mode) */
|
||||
.version-count-link {
|
||||
display: inline-block;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
border: 1px solid var(--color-accent-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 1px 6px;
|
||||
background: var(--color-accent-subtle);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.version-count-link:hover {
|
||||
background: var(--color-accent-border);
|
||||
border-color: var(--color-accent-transparent);
|
||||
}
|
||||
|
||||
/* Medium density adjustments for version count link */
|
||||
.medium-density .version-count-link {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.medium-density .badge-version-unit .version-count-link {
|
||||
max-width: 90px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Compact density adjustments for version count link */
|
||||
.compact-density .version-count-link {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.compact-density .badge-version-unit .version-count-link {
|
||||
max-width: 70px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Version row — flex container for badges + version names */
|
||||
.version-row {
|
||||
display: flex;
|
||||
@@ -690,6 +734,21 @@ body.hide-card-version .hl-badge {
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid-scoped loading overlay (replaces full-page overlay for VirtualScroller refreshes) */
|
||||
.grid-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--lora-bg-transparent, oklch(0% 0 0 / 0.3));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Add after the existing .model-card:hover styles */
|
||||
|
||||
@keyframes update-pulse {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-left: 2.25rem !important;
|
||||
padding-right: 5rem !important;
|
||||
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
@@ -190,6 +190,81 @@
|
||||
right: 2.25rem;
|
||||
}
|
||||
|
||||
/* Clear button: sit immediately left of the search-options toggle */
|
||||
.header-search .search-clear {
|
||||
position: absolute;
|
||||
right: 4.25rem; /* 2.25rem (options toggle) + 28px toggle width + 4px gap */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-xs, 4px);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: background-color var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
.header-search .search-clear.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-search .search-clear:hover {
|
||||
background: color-mix(in oklch, var(--text-muted) 15%, transparent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Keyboard shortcut cue: shown when search is empty, hidden when typing */
|
||||
.header-search .search-shortcut-cue {
|
||||
position: absolute;
|
||||
right: 4.25rem; /* same slot as clear button */
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
pointer-events: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.header-search .search-shortcut-cue kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
/* Subtle tint derived from text color so it adapts to both light & dark themes */
|
||||
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
|
||||
border-radius: var(--border-radius-xs, 3px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-search .search-shortcut-cue.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-search.disabled .search-shortcut-cue {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-search .search-options-toggle:hover,
|
||||
.header-search .search-filter-toggle:hover,
|
||||
.header-search .search-filter-toggle:focus-visible {
|
||||
|
||||
@@ -229,6 +229,19 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Header row for params section */
|
||||
.metadata-row.params-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metadata-row.params-row .param-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Styling for parameters tags */
|
||||
.params-tags {
|
||||
display: flex;
|
||||
@@ -272,13 +285,25 @@
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.metadata-row.prompt-row .param-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metadata-row.prompt-row .param-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metadata-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
font-size: 0.85em;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metadata-prompt-wrapper {
|
||||
@@ -286,7 +311,7 @@
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 6px 30px 6px 8px;
|
||||
padding: 6px 8px;
|
||||
margin-top: 2px;
|
||||
max-height: 80px; /* Reduced from 120px */
|
||||
overflow-y: auto;
|
||||
@@ -302,22 +327,26 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.copy-prompt-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
.copy-prompt-btn,
|
||||
.send-prompt-btn,
|
||||
.send-params-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: var(--transition-base);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.copy-prompt-btn:hover {
|
||||
.copy-prompt-btn:hover,
|
||||
.send-prompt-btn:hover,
|
||||
.send-params-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--lora-accent);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
/* Scrollbar styling for metadata panel */
|
||||
|
||||
@@ -264,6 +264,23 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* Ensure hidden class works properly */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
|
||||
@@ -59,3 +59,5 @@
|
||||
.initialization-notice .loading-spinner {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---------- reused from shared styles ---------- */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||
import { getStorageItem, getSessionItem, removeSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||
import {
|
||||
getCompleteApiConfig,
|
||||
getCurrentModelType,
|
||||
@@ -115,7 +115,10 @@ export class BaseModelApiClient {
|
||||
const pageState = this.getPageState();
|
||||
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`);
|
||||
// Use grid-scoped loading instead of full-page overlay
|
||||
if (state.virtualScroller?.showGridLoading) {
|
||||
state.virtualScroller.showGridLoading();
|
||||
}
|
||||
|
||||
pageState.isLoading = true;
|
||||
if (resetPage) {
|
||||
@@ -154,7 +157,14 @@ export class BaseModelApiClient {
|
||||
throw error;
|
||||
} finally {
|
||||
pageState.isLoading = false;
|
||||
state.loadingManager.hide();
|
||||
// Wait for the next rAF so refreshWithData's scheduleRender has
|
||||
// completed rendering new cards before hiding the grid loading overlay.
|
||||
// This eliminates the ~6.7ms blank-frame gap that caused the flicker.
|
||||
if (state.virtualScroller?.hideGridLoading) {
|
||||
requestAnimationFrame(() => {
|
||||
state.virtualScroller.hideGridLoading();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1271,6 +1281,12 @@ export class BaseModelApiClient {
|
||||
|
||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||
|
||||
// Pass group-by-model mode to backend (skip when showing all versions of a specific model)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (state.global.settings.group_by_model && !vlmModelId) {
|
||||
params.append('group_by_model', 'true');
|
||||
}
|
||||
|
||||
if (!isExcludedView && pageState.filters) {
|
||||
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||
@@ -1352,6 +1368,24 @@ export class BaseModelApiClient {
|
||||
}
|
||||
|
||||
_addModelSpecificParams(params, pageState) {
|
||||
// Check for View Local Versions filter (takes priority over recipe filters)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
const vlmPageType = getSessionItem('vlm_page_type');
|
||||
if (vlmModelId && vlmPageType === this.modelType) {
|
||||
params.append('civitai_model_id', vlmModelId);
|
||||
const vlmBaseModel = getSessionItem('vlm_base_model');
|
||||
if (vlmBaseModel) {
|
||||
params.append('base_model', vlmBaseModel);
|
||||
}
|
||||
return;
|
||||
} else if (vlmModelId && vlmPageType !== this.modelType) {
|
||||
// Stale VLM data from a different page type — clean up
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
}
|
||||
|
||||
if (this.modelType === 'loras') {
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
@@ -9,6 +9,13 @@ export class LoraApiClient extends BaseModelApiClient {
|
||||
* Add LoRA-specific parameters to query
|
||||
*/
|
||||
_addModelSpecificParams(params, pageState) {
|
||||
// Let parent handle View Local Versions filter first
|
||||
super._addModelSpecificParams(params, pageState);
|
||||
// If VLM filter was applied, skip recipe-specific filters
|
||||
if (params.has('civitai_model_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
|
||||
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
|
||||
|
||||
// Update check indicator for group-by-model
|
||||
if (groupByModelCheck) {
|
||||
const isEnabled = !!state.global.settings.group_by_model;
|
||||
groupByModelCheck.style.display = isEnabled ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (isRecipesPage) {
|
||||
modelUpdateItem?.classList.add('hidden');
|
||||
@@ -31,6 +39,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
downloadExamplesItem?.classList.add('hidden');
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
groupByModelItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
@@ -38,6 +47,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
downloadExamplesItem?.classList.remove('hidden');
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
groupByModelItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -74,6 +84,9 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
case 'manage-excluded-models':
|
||||
this.manageExcludedModels();
|
||||
break;
|
||||
case 'toggle-group-by-model':
|
||||
this.toggleGroupByModel();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
@@ -86,6 +99,30 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
});
|
||||
}
|
||||
|
||||
toggleGroupByModel() {
|
||||
const sm = window.settingsManager;
|
||||
if (!sm) {
|
||||
console.error('settingsManager not available on window');
|
||||
return;
|
||||
}
|
||||
const newValue = !state.global.settings.group_by_model;
|
||||
state.global.settings.group_by_model = newValue;
|
||||
|
||||
// Save/restore sort preference when toggling group_by_model
|
||||
if (window.pageControls?.onGroupByModelToggled) {
|
||||
window.pageControls.onGroupByModelToggled(newValue);
|
||||
}
|
||||
|
||||
sm.saveSetting('group_by_model', newValue).catch((error) => {
|
||||
console.error('Failed to save group_by_model setting:', error);
|
||||
// Revert state on failure
|
||||
state.global.settings.group_by_model = !newValue;
|
||||
});
|
||||
|
||||
sm.applyFrontendSettings();
|
||||
sm.reloadContent();
|
||||
}
|
||||
|
||||
async downloadExampleImages(menuItem) {
|
||||
const downloadPath = state?.global?.settings?.example_images_path;
|
||||
if (!downloadPath) {
|
||||
|
||||
@@ -338,7 +338,6 @@ export class HeaderManager {
|
||||
const headerSearch = document.getElementById('headerSearch');
|
||||
const searchInput = headerSearch?.querySelector('#searchInput');
|
||||
const searchButtons = headerSearch?.querySelectorAll('button');
|
||||
const placeholderKey = 'header.search.placeholders.' + this.currentPage;
|
||||
|
||||
if (this.currentPage === 'statistics' && headerSearch) {
|
||||
headerSearch.classList.add('disabled');
|
||||
@@ -353,7 +352,7 @@ export class HeaderManager {
|
||||
if (searchInput) {
|
||||
searchInput.disabled = false;
|
||||
// Use i18nHelpers to update placeholder
|
||||
updateElementAttribute(searchInput, 'placeholder', placeholderKey, {}, '');
|
||||
updateElementAttribute(searchInput, 'placeholder', 'header.search.placeholder', {}, '');
|
||||
}
|
||||
searchButtons?.forEach(btn => btn.disabled = false);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Recipe Modal Component
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
@@ -40,6 +40,16 @@ const GEN_PARAM_NORMALIZATION = {
|
||||
'Denoising strength': 'denoising_strength',
|
||||
};
|
||||
|
||||
const PARAM_DISPLAY_NAMES = {
|
||||
steps: 'Steps',
|
||||
sampler: 'Sampler',
|
||||
cfg_scale: 'CFG',
|
||||
seed: 'Seed',
|
||||
size: 'Size',
|
||||
clip_skip: 'Clip Skip',
|
||||
denoising_strength: 'Denoising Strength',
|
||||
};
|
||||
|
||||
class RecipeModal {
|
||||
constructor() {
|
||||
this.promptEditorState = {};
|
||||
@@ -588,10 +598,11 @@ class RecipeModal {
|
||||
|
||||
for (const [key, value] of Object.entries(sanitizedGenParams)) {
|
||||
if (!excludedParams.includes(key) && value !== undefined && value !== null) {
|
||||
const displayName = PARAM_DISPLAY_NAMES[key] || key;
|
||||
const paramTag = document.createElement('div');
|
||||
paramTag.className = 'param-tag';
|
||||
paramTag.innerHTML = `
|
||||
<span class="param-name">${key}:</span>
|
||||
<span class="param-name">${displayName}:</span>
|
||||
<span class="param-value">${value}</span>
|
||||
`;
|
||||
otherParamsElement.appendChild(paramTag);
|
||||
@@ -1200,6 +1211,53 @@ class RecipeModal {
|
||||
this.sendRecipeToWorkflow();
|
||||
});
|
||||
}
|
||||
|
||||
// Send prompt to workflow buttons
|
||||
const sendPromptBtn = document.getElementById('sendPromptBtn');
|
||||
const sendNegativePromptBtn = document.getElementById('sendNegativePromptBtn');
|
||||
|
||||
if (sendPromptBtn) {
|
||||
sendPromptBtn.addEventListener('click', () => {
|
||||
let promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||
if (this.shouldStripLoraOnCopy()) {
|
||||
promptText = RecipeModal.stripLoraTags(promptText);
|
||||
}
|
||||
if (!promptText.trim()) {
|
||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
sendPromptToWorkflow(promptText);
|
||||
});
|
||||
}
|
||||
|
||||
if (sendNegativePromptBtn) {
|
||||
sendNegativePromptBtn.addEventListener('click', () => {
|
||||
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||
if (this.shouldStripLoraOnCopy()) {
|
||||
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
|
||||
}
|
||||
if (!negativePromptText.trim()) {
|
||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
sendPromptToWorkflow(negativePromptText, {
|
||||
actionTypeText: 'Negative Prompt',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Send params to workflow button
|
||||
const sendParamsBtn = document.getElementById('sendParamsBtn');
|
||||
if (sendParamsBtn) {
|
||||
sendParamsBtn.addEventListener('click', () => {
|
||||
const genParams = this.currentRecipe?.gen_params || {};
|
||||
if (!genParams || Object.keys(genParams).length === 0) {
|
||||
showToast('No generation parameters available', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
sendGenParamsToWorkflow(genParams);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1208,14 +1266,7 @@ class RecipeModal {
|
||||
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||
*/
|
||||
static stripLoraTags(text) {
|
||||
return text
|
||||
.replace(/<lora:[^>]*>/gi, '')
|
||||
.replace(/<lora:[^&]*>/gi, '')
|
||||
.replace(/,(\s*,)+/g, ',')
|
||||
.replace(/^,\s*/, '')
|
||||
.replace(/,\s*$/, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
return stripLoraTags(text);
|
||||
}
|
||||
|
||||
shouldStripLoraOnCopy() {
|
||||
|
||||
@@ -95,6 +95,23 @@ export class CheckpointsControls extends PageControls {
|
||||
* Clear checkpoint custom filter and reload
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
this._restoreSortAfterVlm();
|
||||
// Hide the indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
await resetAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
removeSessionItem('recipe_to_checkpoint_filterHash');
|
||||
removeSessionItem('recipe_to_checkpoint_filterHashes');
|
||||
removeSessionItem('filterCheckpointRecipeName');
|
||||
@@ -106,14 +123,4 @@ export class CheckpointsControls extends PageControls {
|
||||
|
||||
await resetAndReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to truncate text with ellipsis
|
||||
* @param {string} text
|
||||
* @param {number} maxLength
|
||||
* @returns {string}
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,22 @@ export class LorasControls extends PageControls {
|
||||
* Clear the custom filter and reload the page
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first (handles VLM and reloads)
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
this._restoreSortAfterVlm();
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
await resetAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Clearing custom filter...");
|
||||
// Remove filter parameters from session storage
|
||||
removeSessionItem('recipe_to_lora_filterLoraHash');
|
||||
@@ -134,16 +150,6 @@ export class LorasControls extends PageControls {
|
||||
await resetAndReload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to truncate text with ellipsis
|
||||
* @param {string} text - Text to truncate
|
||||
* @param {number} maxLength - Maximum length before truncating
|
||||
* @returns {string} - Truncated text
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the alphabet bar component
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { sidebarManager } from '../SidebarManager.js';
|
||||
@@ -129,6 +129,9 @@ export class PageControls {
|
||||
clearFilterBtn.addEventListener('click', () => this.clearCustomFilter());
|
||||
}
|
||||
|
||||
// Check for View Local Versions filter
|
||||
this.checkVlmFilter();
|
||||
|
||||
// Page-specific event listeners
|
||||
this.initPageSpecificListeners();
|
||||
}
|
||||
@@ -459,15 +462,220 @@ export class PageControls {
|
||||
this.api.toggleBulkMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom filter
|
||||
*/
|
||||
/**
|
||||
* Dynamically add the VLM sort option (version_id:desc) to the sort dropdown.
|
||||
* It is not a permanent option — only present while VLM is active.
|
||||
*/
|
||||
_addVlmSortOption() {
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (!sortSelect) return;
|
||||
// Only add if not already present
|
||||
if (sortSelect.querySelector('option[value="version_id:desc"]')) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'version_id:desc';
|
||||
opt.textContent = this._t('loras.controls.sort.versionIdDesc', 'Newest version first');
|
||||
sortSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the VLM sort option from the sort dropdown.
|
||||
*/
|
||||
_removeVlmSortOption() {
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (!sortSelect) return;
|
||||
const opt = sortSelect.querySelector('option[value="version_id:desc"]');
|
||||
if (opt) opt.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a translation key via the global i18n helper, falling back to
|
||||
* a plain-text default when the key is missing or i18n is unavailable.
|
||||
*/
|
||||
_t(key, fallback) {
|
||||
if (typeof window.i18n?.t === 'function') {
|
||||
return window.i18n.t(key, { defaultValue: fallback });
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the sort dropdown state after VLM is cleared.
|
||||
* Shared by PageControls.clearCustomFilter() and subclass overrides.
|
||||
*/
|
||||
_restoreSortAfterVlm() {
|
||||
const prevSort = getSessionItem('vlm_prev_sort');
|
||||
removeSessionItem('vlm_prev_sort');
|
||||
const restoredSort = prevSort || 'name:asc';
|
||||
this.pageState.sortBy = restoredSort;
|
||||
this.saveSortPreference(restoredSort);
|
||||
this._removeVlmSortOption();
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = restoredSort;
|
||||
sortSelect.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger View Local Versions without page reload
|
||||
* Sets sessionStorage and reloads data via the API.
|
||||
*/
|
||||
triggerVlmView(modelId, modelName, baseModel, pageType) {
|
||||
const targetPageType = pageType || this.pageType;
|
||||
setSessionItem('vlm_model_id', String(modelId));
|
||||
setSessionItem('vlm_model_name', modelName || String(modelId));
|
||||
setSessionItem('vlm_page_type', targetPageType);
|
||||
if (baseModel) {
|
||||
setSessionItem('vlm_base_model', baseModel);
|
||||
} else {
|
||||
removeSessionItem('vlm_base_model');
|
||||
}
|
||||
// Save current sort preference so it can be restored when VLM is cleared
|
||||
setSessionItem('vlm_prev_sort', this.pageState.sortBy);
|
||||
// Inject the temporary sort option and force version_id:desc
|
||||
this._addVlmSortOption();
|
||||
this.pageState.sortBy = 'version_id:desc';
|
||||
this.saveSortPreference('version_id:desc');
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = 'version_id:desc';
|
||||
sortSelect.disabled = true;
|
||||
}
|
||||
// Reload data via API (no page reload)
|
||||
this.resetAndReload(true).then(() => {
|
||||
// Show the VLM indicator after data loads
|
||||
this.checkVlmFilter();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when group_by_model is toggled.
|
||||
* Saves current sort when entering grouped mode, restores normal sort
|
||||
* when leaving — prevents "Most versions first" persisting after exit.
|
||||
*/
|
||||
onGroupByModelToggled(isEnabled) {
|
||||
const normalKey = `${this.pageType}_sort_normal`;
|
||||
const groupedKey = `${this.pageType}_sort_grouped`;
|
||||
|
||||
if (isEnabled) {
|
||||
// Entering group mode: save current sort for later restoration
|
||||
setStorageItem(normalKey, this.pageState.sortBy);
|
||||
// Restore previously saved grouped sort, if any
|
||||
const savedGroupedSort = getStorageItem(groupedKey);
|
||||
if (savedGroupedSort) {
|
||||
this.pageState.sortBy = savedGroupedSort;
|
||||
this.saveSortPreference(savedGroupedSort);
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = savedGroupedSort;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Leaving group mode: save current grouped sort aside, restore normal
|
||||
const currentSort = this.pageState.sortBy;
|
||||
if (currentSort && currentSort.startsWith('versions_count')) {
|
||||
setStorageItem(groupedKey, currentSort);
|
||||
}
|
||||
const savedNormalSort = getStorageItem(normalKey);
|
||||
if (savedNormalSort) {
|
||||
removeStorageItem(normalKey);
|
||||
this.pageState.sortBy = savedNormalSort;
|
||||
this.saveSortPreference(savedNormalSort);
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = savedNormalSort;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for View Local Versions filter in sessionStorage (page-type-scoped)
|
||||
*/
|
||||
checkVlmFilter() {
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
const vlmPageType = getSessionItem('vlm_page_type');
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
|
||||
// Only show VLM indicator when it belongs to the current page type
|
||||
if (vlmModelId && vlmPageType !== this.pageType) {
|
||||
// Stale VLM data from a different page — clean up
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
removeSessionItem('vlm_prev_sort');
|
||||
this._removeVlmSortOption();
|
||||
if (sortSelect) sortSelect.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const vlmModelName = getSessionItem('vlm_model_name');
|
||||
const vlmBaseModel = getSessionItem('vlm_base_model');
|
||||
|
||||
if (vlmModelId && vlmModelName) {
|
||||
// VLM is active — inject sort option, disable dropdown, show indicator
|
||||
this._addVlmSortOption();
|
||||
if (sortSelect) {
|
||||
sortSelect.value = 'version_id:desc';
|
||||
sortSelect.disabled = true;
|
||||
}
|
||||
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const filterText = indicator?.querySelector('.customFilterText');
|
||||
|
||||
if (indicator && filterText) {
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
const prefix = vlmBaseModel
|
||||
? 'Showing same-base versions from'
|
||||
: 'Showing all versions from';
|
||||
const displayText = `${prefix}: ${vlmModelName}`;
|
||||
|
||||
filterText.textContent = this._truncateText(displayText, 40);
|
||||
filterText.setAttribute('title', displayText);
|
||||
}
|
||||
} else {
|
||||
// No VLM — ensure sort option is removed and dropdown is enabled
|
||||
this._removeVlmSortOption();
|
||||
if (sortSelect) sortSelect.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom filter
|
||||
*/
|
||||
async clearCustomFilter() {
|
||||
// Check for View Local Versions filter first
|
||||
const vlmModelId = getSessionItem('vlm_model_id');
|
||||
if (vlmModelId) {
|
||||
removeSessionItem('vlm_model_id');
|
||||
removeSessionItem('vlm_model_name');
|
||||
removeSessionItem('vlm_base_model');
|
||||
removeSessionItem('vlm_page_type');
|
||||
|
||||
this._restoreSortAfterVlm();
|
||||
|
||||
// Hide the indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Reload data via API (no page reload)
|
||||
await this.resetAndReload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise delegate to subclass for recipe filters
|
||||
if (!this.api) {
|
||||
console.error('API methods not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await this.api.clearCustomFilter();
|
||||
} catch (error) {
|
||||
@@ -475,6 +683,14 @@ export class PageControls {
|
||||
showToast('toast.controls.clearFilterFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
_truncateText(text, maxLength) {
|
||||
if (!text) return '';
|
||||
return text.length > maxLength ? `${text.substring(0, maxLength - 3)}...` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the favorites filter button state
|
||||
|
||||
@@ -100,6 +100,12 @@ function handleModelCardEvent_internal(event, modelType) {
|
||||
return true; // Stop propagation
|
||||
}
|
||||
|
||||
if (event.target.closest('.version-count-link')) {
|
||||
event.stopPropagation();
|
||||
handleViewLocalVersionsFromCard(card, modelType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||
handleCardClick(card, modelType);
|
||||
return false; // Continue with other handlers (e.g., bulk selection)
|
||||
@@ -265,6 +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) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
@@ -448,6 +470,10 @@ export function createModelCard(model, modelType) {
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
// Store version_count for group-by-model display
|
||||
if (model.version_count !== undefined) {
|
||||
card.dataset.version_count = model.version_count;
|
||||
}
|
||||
|
||||
// To only show usage_count when sorting by usage.
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -659,16 +685,28 @@ export function createModelCard(model, modelType) {
|
||||
const autoTags = model.auto_tags || [];
|
||||
const hlTags = autoTags.filter(t => t === 'HIGH' || t === 'LOW');
|
||||
const hasVersionName = model.civitai?.name;
|
||||
if (!hlTags.length && !hasVersionName) return '';
|
||||
// When group_by_model is active and model has multiple versions,
|
||||
// show clickable version count instead of version name (and hide badges)
|
||||
const isGroupByModel = state.global.settings.group_by_model;
|
||||
const versionCount = model.version_count;
|
||||
const showVersionCount = isGroupByModel && versionCount > 1;
|
||||
if (!hlTags.length && !hasVersionName && !showVersionCount) return '';
|
||||
const density = state.global.settings.display_density || 'default';
|
||||
const shortLabels = density === 'medium' || density === 'compact';
|
||||
const badges = hlTags.map(t => {
|
||||
// Don't show HIGH/LOW badges when showing version count (confusing in grouped mode)
|
||||
const badges = !showVersionCount ? hlTags.map(t => {
|
||||
const cls = t === 'HIGH' ? 'hl-badge hl-badge--high' : 'hl-badge hl-badge--low';
|
||||
const label = shortLabels ? (t === 'HIGH' ? 'H' : 'L') : t;
|
||||
const titleAttr = shortLabels ? ` title="${t}"` : '';
|
||||
return `<span class="${cls}"${titleAttr}>${label}</span>`;
|
||||
}).join('');
|
||||
const versionHtml = hasVersionName ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : '';
|
||||
}).join('') : '';
|
||||
let versionHtml = '';
|
||||
if (showVersionCount) {
|
||||
const countLabel = translate('modelCard.footer.versionCount', { count: versionCount }, `${versionCount} versions`);
|
||||
versionHtml = `<span class="version-count-link" title="${translate('modelCard.footer.viewAllVersions', {}, 'View all local versions')}">${countLabel}</span>`;
|
||||
} else if (hasVersionName) {
|
||||
versionHtml = `<span class="version-name civitai-version">${model.civitai.name}</span>`;
|
||||
}
|
||||
return `<span class="badge-version-unit">${badges}${versionHtml}</span>`;
|
||||
})()}
|
||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||
|
||||
@@ -752,6 +752,7 @@ export async function showModelModal(model, modelType) {
|
||||
modelId: civitaiModelId,
|
||||
currentVersionId: civitaiVersionId,
|
||||
currentBaseModel: modelWithFullData.base_model,
|
||||
modelName: model.model_name,
|
||||
onUpdateStatusChange: handleUpdateStatusChange,
|
||||
});
|
||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from './utils.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||
@@ -306,7 +307,7 @@ function getToggleTooltipText(mode) {
|
||||
}
|
||||
|
||||
function getDefaultDisplayMode() {
|
||||
const strategy = state?.global?.settings?.update_flag_strategy;
|
||||
const strategy = state?.global?.settings?.version_grouping;
|
||||
return strategy === DISPLAY_FILTER_MODES.SAME_BASE
|
||||
? DISPLAY_FILTER_MODES.SAME_BASE
|
||||
: DISPLAY_FILTER_MODES.ANY;
|
||||
@@ -338,7 +339,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const strategy = state?.global?.settings?.update_flag_strategy;
|
||||
const strategy = state?.global?.settings?.version_grouping;
|
||||
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
|
||||
|
||||
@@ -744,7 +745,7 @@ function renderToolbar(record, toolbarState = {}) {
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
|
||||
${escapeHtml(ignoreText)}
|
||||
</button>
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled>
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Show all local versions of this model on the main page'))}">
|
||||
${escapeHtml(viewLocalText)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -792,6 +793,7 @@ export function initVersionsTab({
|
||||
modelId,
|
||||
currentVersionId,
|
||||
currentBaseModel,
|
||||
modelName,
|
||||
onUpdateStatusChange,
|
||||
}) {
|
||||
const pane = document.querySelector(`#${modalId} #versions-tab`);
|
||||
@@ -1019,6 +1021,39 @@ export function initVersionsTab({
|
||||
render(controller.record);
|
||||
}
|
||||
|
||||
function handleViewLocalVersions() {
|
||||
if (!controller.record || !modelId) {
|
||||
return;
|
||||
}
|
||||
// Determine base model filter based on current display mode
|
||||
const baseModelInfo = getCurrentVersionBaseModel(controller.record, normalizedCurrentVersionId);
|
||||
const isFilteringActive =
|
||||
displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
|
||||
Boolean(baseModelInfo.normalized);
|
||||
|
||||
// Write filter params to sessionStorage (page-scoped)
|
||||
setSessionItem('vlm_model_id', String(modelId));
|
||||
setSessionItem('vlm_model_name', modelName || String(modelId));
|
||||
setSessionItem('vlm_page_type', modelType);
|
||||
if (isFilteringActive) {
|
||||
// Use raw (non-normalized) base model for exact backend matching
|
||||
setSessionItem('vlm_base_model', baseModelInfo.raw);
|
||||
} else {
|
||||
removeSessionItem('vlm_base_model');
|
||||
}
|
||||
|
||||
// Close the modal and navigate via no-reload VLM flow
|
||||
modalManager.closeModal(modalId);
|
||||
if (window.pageControls && typeof window.pageControls.triggerVlmView === 'function') {
|
||||
window.pageControls.triggerVlmView(
|
||||
modelId,
|
||||
modelName || String(modelId),
|
||||
isFilteringActive ? baseModelInfo.raw : undefined,
|
||||
modelType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleVersionIgnore(button, versionId) {
|
||||
if (!controller.record) {
|
||||
return;
|
||||
@@ -1348,6 +1383,10 @@ export function initVersionsTab({
|
||||
event.preventDefault();
|
||||
handleToggleVersionDisplayMode();
|
||||
break;
|
||||
case 'view-local':
|
||||
event.preventDefault();
|
||||
handleViewLocalVersions();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Media-specific utility functions for showcase components
|
||||
* (Moved from uiHelpers.js to better organize code)
|
||||
*/
|
||||
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, getNSFWLevelName, sendPromptToWorkflow, stripLoraTags, sendGenParamsToWorkflow } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
@@ -318,6 +318,74 @@ export function initMetadataPanelHandlers(container) {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle send prompt buttons
|
||||
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
|
||||
sendBtns.forEach(sendBtn => {
|
||||
const promptIndex = sendBtn.dataset.promptIndex;
|
||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
sendBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
let promptText = promptElement.textContent || '';
|
||||
if (!promptText.trim()) {
|
||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect strip <lora> setting from global state
|
||||
if (state.global.settings?.strip_lora_on_copy) {
|
||||
promptText = stripLoraTags(promptText);
|
||||
}
|
||||
|
||||
sendPromptToWorkflow(promptText);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle send params buttons
|
||||
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
|
||||
if (paramsBtn) {
|
||||
paramsBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Collect gen params from the param-tag elements
|
||||
const tagsContainer = wrapper.querySelector('.params-tags');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
const paramTags = tagsContainer.querySelectorAll('.param-tag');
|
||||
const genParams = {};
|
||||
|
||||
// Map display labels to genParams keys
|
||||
const labelToKey = {
|
||||
'Seed': 'seed',
|
||||
'Steps': 'steps',
|
||||
'Sampler': 'sampler',
|
||||
'CFG': 'cfg_scale',
|
||||
};
|
||||
|
||||
paramTags.forEach(tag => {
|
||||
const nameEl = tag.querySelector('.param-name');
|
||||
const valueEl = tag.querySelector('.param-value');
|
||||
if (!nameEl || !valueEl) return;
|
||||
|
||||
const label = nameEl.textContent.replace(':', '').trim();
|
||||
const key = labelToKey[label];
|
||||
if (key) {
|
||||
genParams[key] = valueEl.textContent.trim();
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(genParams).length === 0) {
|
||||
showToast('No sendable parameters found', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendGenParamsToWorkflow(genParams);
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent panel scroll from causing modal scroll
|
||||
metadataPanel.addEventListener('wheel', (e) => {
|
||||
const isAtTop = metadataPanel.scrollTop === 0;
|
||||
|
||||
@@ -28,14 +28,24 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
|
||||
if (hasParams) {
|
||||
content += `
|
||||
<div class="params-tags">
|
||||
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
|
||||
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
|
||||
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
|
||||
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
|
||||
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
|
||||
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
|
||||
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
|
||||
<div class="metadata-row params-row">
|
||||
<div class="param-header">
|
||||
<span class="metadata-label">Params:</span>
|
||||
<div class="param-actions">
|
||||
<button class="send-params-btn" title="Send Params to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="params-tags">
|
||||
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
|
||||
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
|
||||
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
|
||||
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
|
||||
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
|
||||
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
|
||||
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -53,12 +63,19 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
prompt = escapeHtml(prompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Prompt:</span>
|
||||
<div class="param-header">
|
||||
<span class="metadata-label">Prompt:</span>
|
||||
<div class="param-actions">
|
||||
<button class="send-prompt-btn" data-prompt-index="${promptIndex}" title="Send Prompt to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}" title="Copy Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-prompt-wrapper">
|
||||
<div class="metadata-prompt">${prompt}</div>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
|
||||
@@ -69,12 +86,19 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
negativePrompt = escapeHtml(negativePrompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Negative Prompt:</span>
|
||||
<div class="param-header">
|
||||
<span class="metadata-label">Negative Prompt:</span>
|
||||
<div class="param-actions">
|
||||
<button class="send-prompt-btn" data-prompt-index="${negPromptIndex}" title="Send Negative Prompt to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}" title="Copy Negative Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-prompt-wrapper">
|
||||
<div class="metadata-prompt">${negativePrompt}</div>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
|
||||
|
||||
@@ -27,6 +27,9 @@ export class SearchManager {
|
||||
// Create clear button for search input
|
||||
this.createClearButton();
|
||||
|
||||
// Keyboard shortcut cue element (static, exists in the HTML)
|
||||
this.searchShortcutCue = document.getElementById('searchShortcutCue');
|
||||
|
||||
this.initEventListeners();
|
||||
this.loadSearchPreferences();
|
||||
this.setupKeyboardShortcuts();
|
||||
@@ -163,8 +166,13 @@ export class SearchManager {
|
||||
}
|
||||
|
||||
updateClearButtonVisibility() {
|
||||
const hasText = this.searchInput.value.length > 0;
|
||||
if (this.clearButton) {
|
||||
this.clearButton.classList.toggle('visible', this.searchInput.value.length > 0);
|
||||
this.clearButton.classList.toggle('visible', hasText);
|
||||
}
|
||||
// Toggle the keyboard shortcut cue: visible only when search is empty
|
||||
if (this.searchShortcutCue) {
|
||||
this.searchShortcutCue.classList.toggle('hidden', hasText);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -905,15 +905,21 @@ export class SettingsManager {
|
||||
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
|
||||
}
|
||||
|
||||
// Set group by model
|
||||
const groupByModelCheckbox = document.getElementById('groupByModel');
|
||||
if (groupByModelCheckbox) {
|
||||
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
|
||||
}
|
||||
|
||||
// Set model name display setting
|
||||
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
||||
if (modelNameDisplaySelect) {
|
||||
modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name';
|
||||
}
|
||||
|
||||
const updateFlagStrategySelect = document.getElementById('updateFlagStrategy');
|
||||
if (updateFlagStrategySelect) {
|
||||
updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base';
|
||||
const versionGroupingSelect = document.getElementById('versionGrouping');
|
||||
if (versionGroupingSelect) {
|
||||
versionGroupingSelect.value = state.global.settings.version_grouping || 'same_base';
|
||||
}
|
||||
|
||||
// Set hide early access updates setting
|
||||
@@ -2011,7 +2017,11 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') {
|
||||
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content' || settingKey === 'group_by_model') {
|
||||
// Save/restore sort preference when toggling group_by_model
|
||||
if (settingKey === 'group_by_model' && window.pageControls?.onGroupByModelToggled) {
|
||||
window.pageControls.onGroupByModelToggled(value);
|
||||
}
|
||||
this.reloadContent();
|
||||
}
|
||||
|
||||
@@ -2060,7 +2070,7 @@ export class SettingsManager {
|
||||
if (
|
||||
settingKey === 'model_name_display'
|
||||
|| settingKey === 'model_card_footer_action'
|
||||
|| settingKey === 'update_flag_strategy'
|
||||
|| settingKey === 'version_grouping'
|
||||
|| settingKey === 'mature_blur_level'
|
||||
) {
|
||||
this.reloadContent();
|
||||
@@ -3046,6 +3056,10 @@ export class SettingsManager {
|
||||
const useNewLicenseIcons = state.global.settings.use_new_license_icons !== false;
|
||||
document.body.classList.toggle('use-new-license-icons', useNewLicenseIcons);
|
||||
|
||||
// Apply group-by-model mode
|
||||
const groupByModel = !!state.global.settings.group_by_model;
|
||||
document.body.classList.toggle('group-by-model', groupByModel);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,9 +149,10 @@ class RecipeManager {
|
||||
|
||||
_showCustomFilterIndicator() {
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const textElement = document.getElementById('customFilterText');
|
||||
if (!indicator) return;
|
||||
const textElement = indicator.querySelector('.customFilterText');
|
||||
|
||||
if (!indicator || !textElement) return;
|
||||
if (!textElement) return;
|
||||
|
||||
// Update text based on filter type
|
||||
let filterText = '';
|
||||
@@ -250,6 +251,11 @@ class RecipeManager {
|
||||
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
|
||||
}
|
||||
|
||||
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
|
||||
if (duplicatesButton) {
|
||||
duplicatesButton.addEventListener('click', () => this.findDuplicateRecipes());
|
||||
}
|
||||
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
if (favoriteFilterBtn) {
|
||||
favoriteFilterBtn.addEventListener('click', () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
include_trigger_words: false,
|
||||
compact_mode: false,
|
||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||
update_flag_strategy: 'same_base',
|
||||
version_grouping: 'same_base',
|
||||
hide_early_access_updates: false,
|
||||
auto_organize_exclusions: [],
|
||||
metadata_refresh_skip_paths: [],
|
||||
@@ -54,6 +54,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
backup_retention_count: 5,
|
||||
strip_lora_on_copy: false,
|
||||
use_new_license_icons: true,
|
||||
group_by_model: false,
|
||||
});
|
||||
|
||||
export function createDefaultSettings() {
|
||||
|
||||
@@ -657,6 +657,9 @@ export class VirtualScroller {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
// Remove any active grid loading overlay
|
||||
this.hideGridLoading();
|
||||
|
||||
// Remove rendered elements
|
||||
this.clearRenderedItems();
|
||||
|
||||
@@ -1130,4 +1133,30 @@ export class VirtualScroller {
|
||||
index: targetIndex
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a grid-scoped loading indicator (replaces full-page overlay)
|
||||
* Only covers the card grid area, leaving header/sidebar unaffected.
|
||||
*/
|
||||
showGridLoading() {
|
||||
// Remove any stale overlay from a prior deferred hide (e.g. from final rAF)
|
||||
this.hideGridLoading();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'grid-loading-overlay';
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'loading-spinner';
|
||||
overlay.appendChild(spinner);
|
||||
this.gridElement.appendChild(overlay);
|
||||
this.gridLoadingOverlay = overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the grid-scoped loading indicator.
|
||||
*/
|
||||
hideGridLoading() {
|
||||
if (this.gridLoadingOverlay) {
|
||||
this.gridLoadingOverlay.remove();
|
||||
this.gridLoadingOverlay = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { modalManager } from '../managers/ModalManager.js';
|
||||
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
|
||||
import { resolveSamplerScheduler, findMatchingWidgets } from './genParamsMapper.js';
|
||||
|
||||
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
|
||||
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
|
||||
@@ -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() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/get-registry');
|
||||
@@ -840,11 +857,12 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
||||
successMessage = 'Updated workflow node',
|
||||
failureMessage = 'Failed to update workflow node',
|
||||
missingTargetMessage = 'No target node selected',
|
||||
silent = false,
|
||||
} = messages;
|
||||
|
||||
const targetIds = Array.isArray(nodeIds) ? nodeIds : [];
|
||||
if (targetIds.length === 0) {
|
||||
showToast(missingTargetMessage, {}, 'warning');
|
||||
if (!silent) showToast(missingTargetMessage, {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -853,7 +871,7 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
||||
.filter((reference) => reference && reference.node_id !== undefined);
|
||||
|
||||
if (references.length === 0) {
|
||||
showToast(missingTargetMessage, {}, 'warning');
|
||||
if (!silent) showToast(missingTargetMessage, {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -872,16 +890,16 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showToast(successMessage, {}, 'success');
|
||||
if (!silent) showToast(successMessage, {}, 'success');
|
||||
return true;
|
||||
}
|
||||
|
||||
const errorMessage = result?.error || failureMessage;
|
||||
showToast(errorMessage, {}, 'error');
|
||||
if (!silent) showToast(errorMessage, {}, 'error');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to send widget value to workflow:', error);
|
||||
showToast(failureMessage, {}, 'error');
|
||||
if (!silent) showToast(failureMessage, {}, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -915,7 +933,7 @@ async function sendTextToNodes(nodeIds, nodesMap, text, mode, messages = {}) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
widget_name: 'text',
|
||||
action: 'inject_text',
|
||||
value: text,
|
||||
mode: mode || 'append',
|
||||
node_ids: references,
|
||||
@@ -948,7 +966,10 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
|
||||
if (!isNodeEnabled(node)) {
|
||||
return false;
|
||||
}
|
||||
return node.capabilities?.has_text_widget === true;
|
||||
return (
|
||||
node.capabilities?.has_text_widget === true ||
|
||||
node.marker_role === "send_prompt_target"
|
||||
);
|
||||
});
|
||||
|
||||
const nodeKeys = Object.keys(textNodes);
|
||||
@@ -980,6 +1001,184 @@ export async function sendEmbeddingToWorkflow(embeddingCode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt text to workflow text-capable nodes (replaces existing content).
|
||||
* Uses the same target node discovery as sendEmbeddingToWorkflow.
|
||||
* @param {string} promptText - The prompt/negative prompt text to send
|
||||
* @param {Object} [options] - Optional messages overrides
|
||||
* @param {string} [options.actionTypeText] - Label for the action type (default "Prompt")
|
||||
* @param {string} [options.successMessage] - Success toast message
|
||||
* @param {string} [options.failureMessage] - Failure toast message
|
||||
* @param {string} [options.missingNodesMessage] - No nodes warning message
|
||||
* @param {string} [options.missingTargetMessage] - No target selected warning message
|
||||
* @returns {Promise<boolean>} Whether the send succeeded
|
||||
*/
|
||||
export async function sendPromptToWorkflow(promptText, options = {}) {
|
||||
const registry = await fetchWorkflowRegistry();
|
||||
if (!registry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textNodes = filterRegistryNodes(registry.nodes, (node) => {
|
||||
if (!isNodeEnabled(node)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
node.capabilities?.has_text_widget === true ||
|
||||
node.marker_role === "send_prompt_target"
|
||||
);
|
||||
});
|
||||
|
||||
const nodeKeys = Object.keys(textNodes);
|
||||
if (nodeKeys.length === 0) {
|
||||
showToast(options.missingNodesMessage || 'uiHelpers.workflow.noMatchingNodes', {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
const messages = {
|
||||
successMessage: options.successMessage || translate('uiHelpers.workflow.promptSent', {}, 'Prompt sent to workflow'),
|
||||
failureMessage: options.failureMessage || translate('uiHelpers.workflow.promptFailed', {}, 'Failed to send prompt'),
|
||||
missingTargetMessage: options.missingTargetMessage || translate('uiHelpers.workflow.noTargetNodeSelected', {}, 'No target node selected'),
|
||||
};
|
||||
|
||||
const handleSend = (selectedNodeIds) =>
|
||||
sendTextToNodes(selectedNodeIds, textNodes, promptText, 'replace', messages);
|
||||
|
||||
if (nodeKeys.length === 1) {
|
||||
return await handleSend([nodeKeys[0]]);
|
||||
}
|
||||
|
||||
const actionType = options.actionTypeText || translate('uiHelpers.nodeSelector.prompt', {}, 'Prompt');
|
||||
|
||||
showNodeSelector(textNodes, {
|
||||
actionType,
|
||||
actionMode: translate('uiHelpers.nodeSelector.replace', {}, 'Replace'),
|
||||
onSend: handleSend,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send generation parameters (seed, steps, cfg, sampler, scheduler) to
|
||||
* workflow nodes that have been marked with "Send Gen Params Target".
|
||||
*
|
||||
* @param {Object} genParams - Raw gen_params from recipe or showcase metadata
|
||||
* @returns {Promise<boolean>} Whether the send succeeded
|
||||
*/
|
||||
export async function sendGenParamsToWorkflow(genParams) {
|
||||
if (!genParams || typeof genParams !== 'object') {
|
||||
showToast('No generation parameters to send', {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Extract relevant params (skip prompt, negative_prompt, clip_skip, denoising_strength)
|
||||
const raw = {
|
||||
seed: genParams.seed,
|
||||
steps: genParams.steps,
|
||||
cfg: genParams.cfg_scale,
|
||||
};
|
||||
|
||||
// 2. Resolve sampler/scheduler
|
||||
const resolved = resolveSamplerScheduler(genParams.sampler);
|
||||
if (resolved) {
|
||||
if (resolved.sampler) raw.sampler = resolved.sampler;
|
||||
if (resolved.scheduler) raw.scheduler = resolved.scheduler;
|
||||
}
|
||||
|
||||
// Check if we have anything to send
|
||||
const hasAny = Object.values(raw).some(v => v !== undefined && v !== null && v !== '');
|
||||
if (!hasAny) {
|
||||
showToast('No sendable parameters found', {}, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Fetch workflow registry
|
||||
const registry = await fetchWorkflowRegistry();
|
||||
if (!registry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Filter nodes by marker_role === "send_gen_params"
|
||||
const targetNodes = filterRegistryNodes(registry.nodes, (node) => {
|
||||
return node.marker_role === 'send_gen_params' && isNodeEnabled(node);
|
||||
});
|
||||
|
||||
const nodeKeys = Object.keys(targetNodes);
|
||||
if (nodeKeys.length === 0) {
|
||||
showToast(
|
||||
'No node marked as Send Gen Params Target.\nRight-click a node in ComfyUI → Mark as → Send Gen Params Target',
|
||||
{},
|
||||
'warning'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. For each candidate node, find matching widgets
|
||||
// Also collect widget_names from registry for matching
|
||||
const sendToNode = async (nodeIds) => {
|
||||
const targetIds = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
|
||||
let allSuccess = true;
|
||||
let totalSent = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const nodeKey of targetIds) {
|
||||
const node = targetNodes[nodeKey];
|
||||
if (!node) continue;
|
||||
|
||||
const widgetNames = node.widget_names || [];
|
||||
const updates = findMatchingWidgets(widgetNames, raw);
|
||||
|
||||
if (updates.length === 0) {
|
||||
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
|
||||
allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send each widget value sequentially
|
||||
for (const update of updates) {
|
||||
const success = await sendWidgetValueToNodes(
|
||||
[nodeKey],
|
||||
targetNodes,
|
||||
update.widgetName,
|
||||
update.value,
|
||||
{
|
||||
silent: true,
|
||||
}
|
||||
);
|
||||
if (success) {
|
||||
totalSent++;
|
||||
} else {
|
||||
totalFailed++;
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show single summary toast
|
||||
if (totalSent > 0 && totalFailed === 0) {
|
||||
showToast(`Sent ${totalSent} parameter${totalSent > 1 ? 's' : ''} to workflow`, {}, 'success');
|
||||
} else if (totalFailed > 0 && totalSent > 0) {
|
||||
showToast(`Partially updated (${totalSent} ok, ${totalFailed} failed)`, {}, 'warning');
|
||||
} else if (totalFailed > 0) {
|
||||
showToast('Failed to update parameters', {}, 'error');
|
||||
}
|
||||
return allSuccess;
|
||||
};
|
||||
|
||||
// 6. If multiple nodes, show node selector; otherwise send directly
|
||||
if (nodeKeys.length === 1) {
|
||||
return await sendToNode([nodeKeys[0]]);
|
||||
}
|
||||
|
||||
showNodeSelector(targetNodes, {
|
||||
actionType: 'Gen Params',
|
||||
actionMode: 'Update',
|
||||
onSend: sendToNode,
|
||||
enableSendAll: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Global variable to track active node selector state
|
||||
let nodeSelectorState = {
|
||||
isActive: false,
|
||||
|
||||
@@ -158,6 +158,11 @@
|
||||
<div class="context-menu-item" data-action="manage-excluded-models">
|
||||
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="toggle-group-by-model">
|
||||
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
|
||||
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="repair-recipes">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<div class="controls">
|
||||
{% if page_id != 'recipes' %}
|
||||
<div id="excludedViewBanner" class="excluded-view-banner hidden">
|
||||
<div class="excluded-view-banner__content">
|
||||
<div class="excluded-view-banner__title">
|
||||
@@ -11,42 +12,58 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">
|
||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
|
||||
<select id="sortSelect">
|
||||
<optgroup label="{{ t('loras.controls.sort.name') }}">
|
||||
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
||||
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('loras.controls.sort.date') }}">
|
||||
<optgroup label="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.date') }}{% else %}{{ t('loras.controls.sort.date') }}{% endif %}">
|
||||
<option value="date:desc">{{ t('loras.controls.sort.dateDesc') }}</option>
|
||||
<option value="date:asc">{{ t('loras.controls.sort.dateAsc') }}</option>
|
||||
</optgroup>
|
||||
{% if page_id != 'recipes' %}
|
||||
<optgroup label="{{ t('loras.controls.sort.size') }}">
|
||||
<option value="size:desc">{{ t('loras.controls.sort.sizeDesc') }}</option>
|
||||
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
|
||||
</optgroup>
|
||||
{% if page_id != 'embeddings' %}
|
||||
{% endif %}
|
||||
{% if page_id != 'embeddings' and page_id != 'recipes' %}
|
||||
<optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}">
|
||||
<option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option>
|
||||
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if page_id != 'recipes' %}
|
||||
<optgroup class="sort-option-versions-count" label="{{ t('loras.controls.sort.versionsCount', default='Local Versions') }}">
|
||||
<option value="versions_count:desc">{{ t('loras.controls.sort.versionsCountDesc', default='Most versions first') }}</option>
|
||||
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% if page_id == 'recipes' %}
|
||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('loras.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}{% else %}{{ t('loras.controls.refresh.fullTooltip') }}{% endif %}">
|
||||
<i class="fas fa-tools"></i> <span>{% if page_id == 'recipes' %}{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}{% else %}{{ t('loras.controls.refresh.full') }}{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if page_id != 'recipes' %}
|
||||
<div class="control-group">
|
||||
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
|
||||
</div>
|
||||
@@ -55,6 +72,15 @@
|
||||
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if page_id == 'recipes' %}
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
|
||||
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{ t('recipes.batchImport.action') }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="control-group">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
|
||||
@@ -71,6 +97,7 @@
|
||||
<i class="fas fa-star"></i> <span>{{ t('loras.controls.favorites.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
{% if page_id != 'recipes' %}
|
||||
<div class="control-group dropdown-group update-filter-group">
|
||||
<button id="updateFilterBtn" data-action="toggle-updates" class="dropdown-main update-filter" title="{{ t('loras.controls.updates.title') }}">
|
||||
<i class="fas fa-exclamation-circle"></i> <span>{{ t('loras.controls.updates.action') }}</span>
|
||||
@@ -84,6 +111,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
|
||||
|
||||
@@ -45,14 +45,14 @@
|
||||
|
||||
<!-- Center section: Search -->
|
||||
{% set search_disabled = current_page == 'statistics' %}
|
||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
||||
current_page %}
|
||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholder' %}
|
||||
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
||||
<div class="{{ header_search_class }}" id="headerSearch">
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||
disabled{% endif %} />
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<span class="search-shortcut-cue" id="searchShortcutCue"><kbd>Ctrl</kbd><kbd>F</kbd></span>
|
||||
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}" {% if
|
||||
search_disabled %} disabled aria-disabled="true" {% endif %}>
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
|
||||
@@ -536,6 +536,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group by model toggle -->
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="groupByModel">
|
||||
{{ t('settings.layoutSettings.groupByModel') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.groupByModelHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="groupByModel"
|
||||
onchange="settingsManager.saveToggleSetting('groupByModel', 'group_by_model')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
@@ -1081,23 +1100,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Flags -->
|
||||
<!-- Version Scope -->
|
||||
<div class="settings-subsection">
|
||||
<div class="settings-subsection-header">
|
||||
<h4>{{ t('settings.sections.updateFlags') }}</h4>
|
||||
<h4>{{ t('settings.sections.versionScope') }}</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="updateFlagStrategy">
|
||||
{{ t('settings.updateFlagStrategy.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.updateFlagStrategy.help') }}"></i>
|
||||
<label for="versionGrouping">
|
||||
{{ t('settings.versionGrouping.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.versionGrouping.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="updateFlagStrategy" onchange="settingsManager.saveSelectSetting('updateFlagStrategy', 'update_flag_strategy')">
|
||||
<option value="same_base">{{ t('settings.updateFlagStrategy.options.sameBase') }}</option>
|
||||
<option value="any">{{ t('settings.updateFlagStrategy.options.any') }}</option>
|
||||
<select id="versionGrouping" onchange="settingsManager.saveSelectSetting('versionGrouping', 'version_grouping')">
|
||||
<option value="same_base">{{ t('settings.versionGrouping.options.sameBase') }}</option>
|
||||
<option value="any">{{ t('settings.versionGrouping.options.any') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
<div class="param-header">
|
||||
<label>Prompt</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="sendPromptBtn" title="Send Prompt to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
@@ -62,6 +65,9 @@
|
||||
<div class="param-header">
|
||||
<label>Negative Prompt</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="sendNegativePromptBtn" title="Send Negative Prompt to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
@@ -84,7 +90,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Other Parameters -->
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Params</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="sendParamsBtn" title="Send Params to Workflow">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,91 +62,9 @@
|
||||
|
||||
{% block content %}
|
||||
<!-- Recipe controls -->
|
||||
<div class="controls">
|
||||
<div class="actions">
|
||||
<div class="action-buttons">
|
||||
<div class="control-group">
|
||||
<select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
|
||||
<optgroup label="{{ t('recipes.controls.sort.name') }}">
|
||||
<option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
|
||||
<option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('recipes.controls.sort.date') }}">
|
||||
<option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
|
||||
<option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
|
||||
t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||
t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
|
||||
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
|
||||
t('recipes.batchImport.action') }}</button>
|
||||
</div>
|
||||
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
|
||||
<div class="shortcut-key">B</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add duplicate detection button -->
|
||||
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
|
||||
t('loras.controls.duplicates.action') }}</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
|
||||
title="{{ t('recipes.controls.favorites.title') }}">
|
||||
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Custom filter indicator button (hidden by default) -->
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
|
||||
}}</span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<div class="control-group doctor-control-group">
|
||||
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<span>{{ t('doctor.title', default='Doctor') }}</span>
|
||||
<span id="doctorStatusBadge" class="doctor-status-badge hidden" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
||||
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
||||
<!-- Breadcrumbs will be populated by JavaScript -->
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'components/controls.html' %}
|
||||
<!-- Breadcrumb Navigation -->
|
||||
{% include 'components/breadcrumb.html' %}
|
||||
|
||||
<!-- Duplicates banner (hidden by default) -->
|
||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||
|
||||
@@ -1117,9 +1117,9 @@ describe('Interaction-level regression coverage', () => {
|
||||
expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available');
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available');
|
||||
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
|
||||
expect(otherParamsText).toContain('sampler:');
|
||||
expect(otherParamsText).toContain('Sampler:');
|
||||
expect(otherParamsText).toContain('dpmpp_2m');
|
||||
expect(otherParamsText).not.toContain('cfg_scale');
|
||||
expect(otherParamsText).not.toContain('CFG');
|
||||
});
|
||||
|
||||
it('filters dirty generation params from recipe modal display', async () => {
|
||||
@@ -1168,8 +1168,8 @@ describe('Interaction-level regression coverage', () => {
|
||||
const otherParamsText = document.getElementById('recipeOtherParams').textContent;
|
||||
expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt');
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative');
|
||||
expect(otherParamsText).toContain('sampler:');
|
||||
expect(otherParamsText).toContain('cfg_scale:');
|
||||
expect(otherParamsText).toContain('Sampler:');
|
||||
expect(otherParamsText).toContain('CFG:');
|
||||
expect(otherParamsText).not.toContain('Version');
|
||||
expect(otherParamsText).not.toContain('raw_metadata');
|
||||
expect(otherParamsText).not.toContain('RNG');
|
||||
@@ -1222,7 +1222,7 @@ describe('Interaction-level regression coverage', () => {
|
||||
expect(document.getElementById('recipePrompt').textContent).not.toContain('stale prompt');
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative');
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).not.toContain('stale negative');
|
||||
expect(otherParamsText).toContain('cfg_scale:');
|
||||
expect(otherParamsText).toContain('CFG:');
|
||||
expect(otherParamsText).toContain('7');
|
||||
expect(otherParamsText).not.toContain('3');
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ const stateMock = {
|
||||
global: {
|
||||
settings: {
|
||||
autoplay_on_hover: false,
|
||||
update_flag_strategy: 'any',
|
||||
version_grouping: 'any',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -67,7 +67,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
</div>
|
||||
`;
|
||||
stateMock.global.settings.autoplay_on_hover = false;
|
||||
stateMock.global.settings.update_flag_strategy = 'any';
|
||||
stateMock.global.settings.version_grouping = 'any';
|
||||
({ getModelApiClient } = await import(API_FACTORY_MODULE));
|
||||
fetchModelUpdateVersions = vi.fn();
|
||||
getModelApiClient.mockReturnValue({
|
||||
@@ -157,7 +157,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('shows a stable label with a short state indicator', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'any';
|
||||
stateMock.global.settings.version_grouping = 'any';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -192,7 +192,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('filters versions to the current base model when strategy is same_base', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -235,7 +235,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('toggle button can switch to display all versions', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
@@ -286,7 +286,7 @@ describe('ModelVersionsTab media rendering', () => {
|
||||
});
|
||||
|
||||
it('shows a newer version badge when viewing same-base results', async () => {
|
||||
stateMock.global.settings.update_flag_strategy = 'same_base';
|
||||
stateMock.global.settings.version_grouping = 'same_base';
|
||||
fetchModelUpdateVersions.mockResolvedValue({
|
||||
success: true,
|
||||
record: {
|
||||
|
||||
@@ -96,6 +96,7 @@ function renderControlsDom(pageKey) {
|
||||
<div class="search-container">
|
||||
<input id="searchInput" />
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<span class="search-shortcut-cue" id="searchShortcutCue"><kbd>Ctrl</kbd><kbd>F</kbd></span>
|
||||
<button id="searchOptionsToggle" class="search-options-toggle"></button>
|
||||
<button id="filterButton" class="search-filter-toggle">
|
||||
<span id="activeFiltersCount" class="filter-badge" style="display: none">0</span>
|
||||
@@ -215,6 +216,40 @@ describe('SearchManager filtering scenarios', () => {
|
||||
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledWith(true, false);
|
||||
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['loras'],
|
||||
['checkpoints'],
|
||||
])('toggles clear button and shortcut cue visibility for %s page', async (pageKey) => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderControlsDom(pageKey);
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState(pageKey);
|
||||
const { SearchManager } = await import('../../../static/js/managers/SearchManager.js');
|
||||
|
||||
new SearchManager({ page: pageKey, searchDelay: 0 });
|
||||
|
||||
const input = document.getElementById('searchInput');
|
||||
const cue = document.getElementById('searchShortcutCue');
|
||||
const clearBtn = document.querySelector('.search-clear');
|
||||
|
||||
// Initially empty: cue visible, clear hidden
|
||||
expect(cue.classList.contains('hidden')).toBe(false);
|
||||
expect(clearBtn.classList.contains('visible')).toBe(false);
|
||||
|
||||
// Type something: cue hidden, clear visible
|
||||
input.value = 'flux';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
expect(cue.classList.contains('hidden')).toBe(true);
|
||||
expect(clearBtn.classList.contains('visible')).toBe(true);
|
||||
|
||||
// Clear via click: cue visible, clear hidden
|
||||
clearBtn.click();
|
||||
expect(input.value).toBe('');
|
||||
expect(cue.classList.contains('hidden')).toBe(false);
|
||||
expect(clearBtn.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilterManager tag and base model filters', () => {
|
||||
|
||||
@@ -143,6 +143,19 @@ describe('RecipeManager', () => {
|
||||
|
||||
renderRecipesPage();
|
||||
|
||||
// Inject controls DOM that would normally come from components/controls.html
|
||||
// (raw template rendering doesn't process Jinja2 {% include %} tags)
|
||||
const customFilterIndicator = document.createElement('div');
|
||||
customFilterIndicator.id = 'customFilterIndicator';
|
||||
customFilterIndicator.className = 'control-group hidden';
|
||||
customFilterIndicator.innerHTML = `
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(customFilterIndicator);
|
||||
|
||||
({ RecipeManager } = await import('../../../static/js/recipes.js'));
|
||||
});
|
||||
|
||||
@@ -288,7 +301,7 @@ describe('RecipeManager', () => {
|
||||
});
|
||||
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const filterText = indicator.querySelector('#customFilterText');
|
||||
const filterText = indicator.querySelector('.customFilterText');
|
||||
|
||||
expect(filterText.innerHTML).toContain('Recipes using checkpoint:');
|
||||
expect(filterText.innerHTML).toContain('Flux Base');
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -482,7 +482,7 @@ async def test_get_paginated_data_annotates_update_flags_with_bulk_dedup():
|
||||
|
||||
|
||||
@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 = [
|
||||
{
|
||||
"model_name": "Pony Version",
|
||||
@@ -551,7 +551,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
|
||||
should_ignore_model=False,
|
||||
)
|
||||
update_service = StubUpdateServiceWithRecords({1: record})
|
||||
settings = StubSettings({"update_flag_strategy": "same_base"})
|
||||
settings = StubSettings({"version_grouping": "same_base"})
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
@@ -579,7 +579,7 @@ async def test_update_flag_strategy_same_base_prefers_matching_base():
|
||||
|
||||
|
||||
@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 = [
|
||||
{
|
||||
"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,
|
||||
)
|
||||
update_service = StubUpdateServiceWithRecords({1: record})
|
||||
settings = StubSettings({"update_flag_strategy": "same_base"})
|
||||
settings = StubSettings({"version_grouping": "same_base"})
|
||||
|
||||
service = DummyService(
|
||||
model_type="stub",
|
||||
@@ -746,6 +746,264 @@ async def test_get_paginated_data_update_available_only_without_update_service()
|
||||
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():
|
||||
settings = StubSettings({})
|
||||
filter_set = ModelFilterSet(settings)
|
||||
|
||||
@@ -726,3 +726,25 @@ body.lm-lora-reordering * {
|
||||
font-size: 12px;
|
||||
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: "Mark as",
|
||||
has_submenu: true,
|
||||
submenu: {
|
||||
options: buildSubmenuOptions(node),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---- Extension --------------------------------------------------------------
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.NodeMarker",
|
||||
getNodeMenuItems(node) {
|
||||
return buildMenuItems(node);
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
|
||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||
|
||||
const LORA_NODE_CLASSES = new Set([
|
||||
"Lora Loader (LoraManager)",
|
||||
@@ -17,10 +18,68 @@ const TEXT_CAPABLE_CLASSES = new Set([
|
||||
"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({
|
||||
name: "LoraManager.WorkflowRegistry",
|
||||
|
||||
setup() {
|
||||
ensureLmStyles();
|
||||
|
||||
api.addEventListener("lora_registry_refresh", () => {
|
||||
this.refreshRegistry();
|
||||
});
|
||||
@@ -28,6 +87,11 @@ app.registerExtension({
|
||||
api.addEventListener("lm_widget_update", (event) => {
|
||||
this.applyWidgetUpdate(event?.detail ?? {});
|
||||
});
|
||||
|
||||
// React to marker changes from the Node Marker extension
|
||||
window.addEventListener("lm_marker_changed", () => {
|
||||
this.refreshRegistry();
|
||||
});
|
||||
},
|
||||
|
||||
async refreshRegistry() {
|
||||
@@ -49,8 +113,10 @@ app.registerExtension({
|
||||
const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass);
|
||||
const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -71,6 +137,7 @@ app.registerExtension({
|
||||
type: node.comfyClass,
|
||||
comfy_class: node.comfyClass,
|
||||
mode: node.mode,
|
||||
marker_role: markerRole,
|
||||
capabilities: {
|
||||
supports_lora: supportsLora,
|
||||
has_text_widget: hasTextWidget,
|
||||
@@ -102,11 +169,12 @@ app.registerExtension({
|
||||
applyWidgetUpdate(message) {
|
||||
const nodeId = message?.node_id ?? message?.id;
|
||||
const graphId = message?.graph_id;
|
||||
const action = message?.action;
|
||||
const widgetName = message?.widget_name;
|
||||
const value = message?.value;
|
||||
const mode = message?.mode ?? "replace";
|
||||
|
||||
if (nodeId == null || !widgetName) {
|
||||
if (nodeId == null || (!action && !widgetName)) {
|
||||
console.warn("LoRA Manager: invalid widget update payload", message);
|
||||
return;
|
||||
}
|
||||
@@ -126,33 +194,72 @@ app.registerExtension({
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetIndex = node.widgets.findIndex((widget) => widget?.name === widgetName);
|
||||
if (widgetIndex === -1) {
|
||||
console.warn(
|
||||
"LoRA Manager: target widget not found on node",
|
||||
widgetName,
|
||||
node
|
||||
);
|
||||
// ---- Resolve target widget ----
|
||||
let targetWidget = null;
|
||||
|
||||
if (action === "inject_text") {
|
||||
// Find the first text-capable widget by type.
|
||||
// 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;
|
||||
}
|
||||
|
||||
const widget = node.widgets[widgetIndex];
|
||||
// ---- Update widget value ----
|
||||
const widgetIndex = node.widgets.indexOf(targetWidget);
|
||||
let newValue = value;
|
||||
|
||||
if (mode === "append") {
|
||||
const separator = widget.value && widget.value.length > 0 ? " " : "";
|
||||
newValue = widget.value + separator + value;
|
||||
const separator =
|
||||
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;
|
||||
}
|
||||
|
||||
if (typeof widget.callback === "function") {
|
||||
if (typeof targetWidget.callback === "function") {
|
||||
try {
|
||||
widget.callback(newValue);
|
||||
targetWidget.callback(newValue);
|
||||
} catch (callbackError) {
|
||||
console.error("LoRA Manager: widget callback failed", callbackError);
|
||||
}
|
||||
@@ -165,5 +272,296 @@ app.registerExtension({
|
||||
if (typeof app.graph?.setDirtyCanvas === "function") {
|
||||
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