mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -03:00
Compare commits
4 Commits
d916375abe
...
v1.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8022d12f03 | ||
|
|
3939f7f91b | ||
|
|
aebf2e37dd | ||
|
|
f53f859a71 |
@@ -10,13 +10,17 @@
|
||||
"Brennok",
|
||||
"2018cfh",
|
||||
"Insomnia Art Designs",
|
||||
"Rob Williams",
|
||||
"Arlecchino Shion",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"$MetaSamsara",
|
||||
"W+K+White",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Birdy",
|
||||
"Kiba",
|
||||
"onesecondinosaur",
|
||||
"Christian Byrne",
|
||||
@@ -27,22 +31,25 @@
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"Dsperado",
|
||||
"Rosenthal",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"レプサイ",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Edgar Tejeda",
|
||||
"Liam MacDougal",
|
||||
"Polymorphic Indeterminate",
|
||||
@@ -50,9 +57,7 @@
|
||||
"Dogwalkerbr",
|
||||
"Skalabananen",
|
||||
"Marc Whiffen",
|
||||
"Birdy",
|
||||
"itismyelement",
|
||||
"Mozzel",
|
||||
"quarz",
|
||||
"Reno Lam",
|
||||
"jean jahren",
|
||||
@@ -65,40 +70,46 @@
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Nazono_hito",
|
||||
"Nolife_M",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"SG",
|
||||
"bh",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"RedrockVP",
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Steven Pfeiffer",
|
||||
"レプサイ",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"runte3221",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"corde",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"wildnut",
|
||||
@@ -108,53 +119,51 @@
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"Karl P.",
|
||||
"MiraiKuriyamaSy",
|
||||
"otaku fra",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"Takkan",
|
||||
"N/A",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"ElitaSSJ4",
|
||||
"Matt+J",
|
||||
"Jack B Nimble",
|
||||
"Melville Parrish",
|
||||
"Lustre",
|
||||
"bh",
|
||||
"Jwk0205",
|
||||
"Marlon Daniels",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Olive",
|
||||
"greebles",
|
||||
"Some Guy Named Barry",
|
||||
"Resist's Creations - Spicy Edition 🔥",
|
||||
"M Postkasse",
|
||||
"Wolffen",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"DogmaR34",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Christopher Michel",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"Luc Job",
|
||||
"LeoZero",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"nwalker94",
|
||||
"dan",
|
||||
"aai",
|
||||
"Tori",
|
||||
"Mouthlessman",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"Karl P.",
|
||||
"Adam Taylor",
|
||||
"Weird_With_A_Beard",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
@@ -162,7 +171,8 @@
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"Greenmoustache",
|
||||
"DiffDuck",
|
||||
"elu3199",
|
||||
"fancypants",
|
||||
"John+Edwards",
|
||||
"Joboshy",
|
||||
@@ -172,42 +182,39 @@
|
||||
"contrite831",
|
||||
"Dan",
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"Neco28",
|
||||
"David Ortega",
|
||||
"AELOX",
|
||||
"Gooohokrbe",
|
||||
"Dankin-Pics",
|
||||
"Nicfit23",
|
||||
"Cristian Vazquez",
|
||||
"wamekukyouzin",
|
||||
"OldBones",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Christopher Michel",
|
||||
"Zach Gonser",
|
||||
"Serge Bekenkamp",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"nahinahi9",
|
||||
"Bruce",
|
||||
"kushiroK9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Kevin Christopher",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Paul Kroll",
|
||||
"Penfore",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
"Gordon Cole",
|
||||
"AbstractAss",
|
||||
"Dušan Ryban",
|
||||
"decoy",
|
||||
"DiffDuck",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
@@ -222,34 +229,34 @@
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"linnfrey",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Jackthemind",
|
||||
"griffin+dahlberg",
|
||||
"jeaness",
|
||||
"takyamtom",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"yer fey",
|
||||
"sanborondon",
|
||||
"Error_Rule34_Not_found",
|
||||
"aezin",
|
||||
"jcay015",
|
||||
"Erik Lopez",
|
||||
"Roslynd",
|
||||
"Mateo Curić",
|
||||
"Geolog",
|
||||
"Neco28",
|
||||
"Cosmosis",
|
||||
"Eris3D",
|
||||
"David Ortega",
|
||||
"m",
|
||||
"FloPro4Sho",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"Steven Owens",
|
||||
"James Coleman",
|
||||
"Kevin Christopher",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"John Statham",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"yuxz69",
|
||||
"LarsesFPC",
|
||||
@@ -257,8 +264,6 @@
|
||||
"esthe",
|
||||
"AlexDuKaNa",
|
||||
"地獄の禄",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"capn",
|
||||
"Joseph",
|
||||
@@ -272,12 +277,16 @@
|
||||
"Hailshem",
|
||||
"Naomi Hale Danchi",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"IamAyam",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"Robert Wegemund",
|
||||
"sanborondon",
|
||||
"confiscated Zyra",
|
||||
"Littlehuggy",
|
||||
"Andrew Marshall",
|
||||
"Brian Buie",
|
||||
"Taylor Funk",
|
||||
"Thought2Form",
|
||||
"Gerald Welly",
|
||||
@@ -285,15 +294,19 @@
|
||||
"Sadlip",
|
||||
"Tee Gee",
|
||||
"tarek helmi",
|
||||
"Joey Callahan",
|
||||
"Max Marklund",
|
||||
"m",
|
||||
"Mike Simone",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
"Pronredn",
|
||||
"Mikko Hemilä",
|
||||
"Jamie Ogletree",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"lh qwe",
|
||||
"Martial",
|
||||
"conner",
|
||||
@@ -305,24 +318,21 @@
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Rops Alot",
|
||||
"Sam",
|
||||
"Ace Ventura",
|
||||
"四糸凜音",
|
||||
"Xeeosat",
|
||||
"Douglas Gaspar",
|
||||
"Saya",
|
||||
"George",
|
||||
"dw",
|
||||
"FrxzenSnxw",
|
||||
"WRL_SPR",
|
||||
"momokai",
|
||||
"몽타주",
|
||||
"kudari",
|
||||
"ken",
|
||||
"Crocket",
|
||||
"Joshua Porrata",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
@@ -337,37 +347,36 @@
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"OrganicArtifact",
|
||||
"ResidentDeviant",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"gzmzmvp",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Littlehuggy",
|
||||
"Aberr",
|
||||
"Gregory Kozhemiak",
|
||||
"준희 김",
|
||||
"Brian Buie",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Tomohiro Baba",
|
||||
"Mike Simone",
|
||||
"Noora",
|
||||
"John J Linehan",
|
||||
"Mattssn",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Artokun",
|
||||
"hexxish",
|
||||
"Derek Baker",
|
||||
"Steam Steam",
|
||||
"NICHOLAS BAXLEY",
|
||||
"CryptoTraderJK",
|
||||
@@ -378,23 +387,14 @@
|
||||
"Fotek Design",
|
||||
"Nihongasuki",
|
||||
"MadSpin",
|
||||
"FrxzenSnxw",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"ResidentDeviant",
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Vir",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"g9p0o",
|
||||
"Pkrsky",
|
||||
@@ -403,6 +403,8 @@
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
"Nasty+Hobbit",
|
||||
"letzte",
|
||||
@@ -414,12 +416,15 @@
|
||||
"David Schenck",
|
||||
"Wolfe7D1",
|
||||
"Draven T",
|
||||
"Time Valentine",
|
||||
"elleshar666",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Aquatic Coffee",
|
||||
"Kauffy",
|
||||
"ethanfel",
|
||||
"SPJ",
|
||||
"Focuschannel",
|
||||
"Edward Kennedy",
|
||||
"Nick Kage",
|
||||
@@ -432,12 +437,13 @@
|
||||
"notedfakes",
|
||||
"Michael Scott",
|
||||
"Pat Hen",
|
||||
"Saya",
|
||||
"Solixer",
|
||||
"Jordan Shaw",
|
||||
"Wes Sims",
|
||||
"Donor4115",
|
||||
"g unit",
|
||||
"Jimmy Borup",
|
||||
"Manu Thetug",
|
||||
"Filippo Ferrari",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
@@ -451,6 +457,11 @@
|
||||
"SomeDude",
|
||||
"nanana",
|
||||
"raf8osz",
|
||||
"Bob+Barker",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
"redcarrot",
|
||||
@@ -467,36 +478,34 @@
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"cocona",
|
||||
"blikkies",
|
||||
"JBsuede",
|
||||
"Time Valentine",
|
||||
"Shock Shockor",
|
||||
"りん あめ",
|
||||
"Matt",
|
||||
"Goldwaters",
|
||||
"Zude",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Sean voets",
|
||||
"Kyler",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Bryan Rutkowski",
|
||||
"Justin Blaylock",
|
||||
"aRtFuL_DodGeR",
|
||||
"Steven",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Edward Ten Eyck",
|
||||
"Billy Gladky",
|
||||
"Probis",
|
||||
"Solixer",
|
||||
"Pete Pain",
|
||||
"ItsGeneralButtNaked",
|
||||
"RHopkirk",
|
||||
"jinksta187",
|
||||
"robin.kok.",
|
||||
"Manu Thetug",
|
||||
"Maxim",
|
||||
"Karlanx",
|
||||
"Lyavph",
|
||||
@@ -504,6 +513,7 @@
|
||||
"Youguang",
|
||||
"andrewzpong",
|
||||
"BossGame",
|
||||
"Marcus thronico",
|
||||
"lrdchs",
|
||||
"Tree Tagger",
|
||||
"Inversity",
|
||||
@@ -511,6 +521,15 @@
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"POPPIN",
|
||||
"PoorStudent",
|
||||
"Alex+Zaw",
|
||||
"Supporter",
|
||||
"ExLightSaber",
|
||||
"Mobius2020",
|
||||
"YaboiRay",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
"2turbo",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"tanjin90",
|
||||
@@ -528,11 +547,6 @@
|
||||
"Inkognito",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"Bob+Barker",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Alex",
|
||||
"BillyBoy84",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
@@ -545,28 +559,27 @@
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"Christian Schäfer",
|
||||
"Leslie Andrew Ridings",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"Sean voets",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Kyron Mahan",
|
||||
"Mythspire",
|
||||
"Boba Smith",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"MR.Bear",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"Ezokewn",
|
||||
"Terminuz",
|
||||
"ivistorm",
|
||||
"SendingRavens",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"JackJohnnyJim",
|
||||
"Khánh Đặng",
|
||||
"Borte",
|
||||
"Michael Docherty",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
@@ -574,6 +587,7 @@
|
||||
"Paul Hartsuyker",
|
||||
"elitassj",
|
||||
"Tigon",
|
||||
"SkibidiRizzler",
|
||||
"Tania Nayelli Fernandez",
|
||||
"Draconach",
|
||||
"Jacob Winter",
|
||||
@@ -581,6 +595,8 @@
|
||||
"Andrew Wilkinson",
|
||||
"David",
|
||||
"Meilo",
|
||||
"Nacho Ferrando",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Dkom22",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
@@ -589,7 +605,6 @@
|
||||
"xybrightsummer",
|
||||
"jreedatchison",
|
||||
"PhilW",
|
||||
"Marcus thronico",
|
||||
"Janik",
|
||||
"Cruel",
|
||||
"MRBlack",
|
||||
@@ -601,6 +616,13 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"Somebody",
|
||||
"Celestial+Kitten",
|
||||
"TequiTequi",
|
||||
"Homero+Banda",
|
||||
"bakeliteboy",
|
||||
"Nick",
|
||||
"てぃんてぃんひーろー",
|
||||
"Gold_miner_ego",
|
||||
"IshouI;_;",
|
||||
"Monix",
|
||||
@@ -620,17 +642,8 @@
|
||||
"you+halo9",
|
||||
"cloudghost",
|
||||
"Yongkwan+Lee",
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"nickname",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
"Somebody",
|
||||
"Balut+Omelette",
|
||||
"eriick",
|
||||
"Lev+Lanevskiy",
|
||||
@@ -651,37 +664,35 @@
|
||||
"Vinarus",
|
||||
"Josh Snyder",
|
||||
"ja s",
|
||||
"Leslie Andrew Ridings",
|
||||
"Doug Mason",
|
||||
"scoreswazey",
|
||||
"Oliverfish",
|
||||
"Owen Gwosdz",
|
||||
"Room Light",
|
||||
"Patryk Serious",
|
||||
"AZ Party Oasis",
|
||||
"Devil Lude",
|
||||
"Gentle Sartori",
|
||||
"Snorklebort",
|
||||
"David Murcko",
|
||||
"vinter",
|
||||
"TheFusion",
|
||||
"Jack Dole",
|
||||
"matt",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"max blo",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Slacks",
|
||||
"Bouya shaka",
|
||||
"Jack Lawfield",
|
||||
"Borte",
|
||||
"Maso",
|
||||
"Homero Banda",
|
||||
"yyuvuvu",
|
||||
"Eric Ketchum",
|
||||
"Nomki",
|
||||
"Kevin Wallace",
|
||||
"ChicRic",
|
||||
"BastardSama",
|
||||
"mercur",
|
||||
"SkibidiRizzler",
|
||||
"Never_M",
|
||||
"Kalle Björk",
|
||||
"Yavizu3d",
|
||||
@@ -689,9 +700,7 @@
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
"Nacho Ferrando",
|
||||
"Вячеслав Маринин",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Cola Matthew",
|
||||
"OniNoKen",
|
||||
"Iain Wisely",
|
||||
@@ -734,6 +743,15 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"Brandon+G",
|
||||
"fazefour33",
|
||||
"plonk",
|
||||
"Kotetsu",
|
||||
"o",
|
||||
"Tony+V",
|
||||
"Anvil+G",
|
||||
"draganjankovic1975dj528",
|
||||
"MrSEIGE88",
|
||||
"yarsev",
|
||||
"M+Alsulaiti",
|
||||
"Mark+Staaf",
|
||||
@@ -751,11 +769,6 @@
|
||||
"miduzza",
|
||||
"KB",
|
||||
"shw",
|
||||
"Celestial+Kitten",
|
||||
"bakeliteboy",
|
||||
"TequiTequi",
|
||||
"Homero+Banda",
|
||||
"Nick",
|
||||
"Jim",
|
||||
"JoL",
|
||||
"YoruHime",
|
||||
@@ -781,14 +794,14 @@
|
||||
"han b",
|
||||
"Nico",
|
||||
"Maximilian Krischan",
|
||||
"Banana Joe",
|
||||
"socialcat",
|
||||
"proto merp",
|
||||
"_ G3n",
|
||||
"Brandon Thomas",
|
||||
"Donovan Jenkins",
|
||||
"Hans Meier",
|
||||
"Dustin Hendel",
|
||||
"sicarius",
|
||||
"jboul",
|
||||
"Michael Eid",
|
||||
"Liberation",
|
||||
"Bob barker",
|
||||
@@ -805,7 +818,6 @@
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"Oliverfish",
|
||||
"yfx507",
|
||||
"uruksayshi",
|
||||
"Jairus Knudsen",
|
||||
@@ -814,24 +826,25 @@
|
||||
"nk8",
|
||||
"lylepaul",
|
||||
"Middo",
|
||||
"Gary Chaboya",
|
||||
"Forbidden Atelier",
|
||||
"Thomas Sankowski",
|
||||
"DrB",
|
||||
"Nimhloth",
|
||||
"Adictedtohumping",
|
||||
"Moneymaker412K",
|
||||
"vinter",
|
||||
"Tsani Prodanov",
|
||||
"Towelie",
|
||||
"Jean-françois SEMA",
|
||||
"Myrthrac",
|
||||
"Taylor Dominy",
|
||||
"Andrew Ly",
|
||||
"Slacks",
|
||||
"Glenn Hoetker",
|
||||
"john Greene",
|
||||
"Faburizu",
|
||||
"jimyjomson",
|
||||
"JaeHyun Jang",
|
||||
"Michael Hicks",
|
||||
"Homero Banda",
|
||||
"Chase Kwon",
|
||||
"Bob Ling",
|
||||
"Inyoshu",
|
||||
@@ -858,5 +871,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 855
|
||||
"totalCount": 868
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||
"baseModel": "Basis-Modell",
|
||||
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTags": "Tags",
|
||||
"modelTypes": "Modelltypen",
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||
"noTags": "Keine Tags",
|
||||
"tagSearchPlaceholder": "Tags durchsuchen...",
|
||||
"noTagMatches": "Keine Tags entsprechen der aktuellen Suche.",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||
"clearAll": "Alle Filter löschen",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "Wiederholen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Preset name...",
|
||||
"baseModel": "Base Model",
|
||||
"baseModelSearchPlaceholder": "Search base models...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTags": "Tags",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "License",
|
||||
"noCreditRequired": "No Credit Required",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
||||
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
||||
"noTags": "No tags",
|
||||
"tagSearchPlaceholder": "Search tags...",
|
||||
"noTagMatches": "No tags match the current search.",
|
||||
"autoTags": "Auto Tags",
|
||||
"noBaseModelMatches": "No base models match the current search.",
|
||||
"clearAll": "Clear All Filters",
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||
"baseModel": "Modelo base",
|
||||
"baseModelSearchPlaceholder": "Buscar modelos base...",
|
||||
"modelTags": "Etiquetas (Top 20)",
|
||||
"modelTags": "Etiquetas",
|
||||
"modelTypes": "Tipos de modelos",
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||
"noTags": "Sin etiquetas",
|
||||
"tagSearchPlaceholder": "Buscar etiquetas...",
|
||||
"noTagMatches": "Ninguna etiqueta coincide con la búsqueda actual.",
|
||||
"autoTags": "Etiquetas automáticas",
|
||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||
"clearAll": "Limpiar todos los filtros",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "Reintentar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Nom du préréglage...",
|
||||
"baseModel": "Modèle de base",
|
||||
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTags": "Tags",
|
||||
"modelTypes": "Types de modèles",
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
||||
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
||||
"noTags": "Aucun tag",
|
||||
"tagSearchPlaceholder": "Rechercher des tags...",
|
||||
"noTagMatches": "Aucun tag ne correspond à la recherche actuelle.",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||
"clearAll": "Effacer tous les filtres",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||
"baseModel": "מודל בסיס",
|
||||
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
|
||||
"modelTags": "תגיות (20 המובילות)",
|
||||
"modelTags": "תגיות",
|
||||
"modelTypes": "סוגי מודלים",
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||
"noTags": "ללא תגיות",
|
||||
"tagSearchPlaceholder": "חיפוש תגיות...",
|
||||
"noTagMatches": "אין תגיות שתואמות את החיפוש הנוכחי.",
|
||||
"autoTags": "תגיות אוטומטיות",
|
||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||
"clearAll": "נקה את כל המסננים",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "נסה שוב"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "プリセット名...",
|
||||
"baseModel": "ベースモデル",
|
||||
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
||||
"modelTags": "タグ(上位20)",
|
||||
"modelTags": "タグ",
|
||||
"modelTypes": "モデルタイプ",
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||
"noTags": "タグなし",
|
||||
"tagSearchPlaceholder": "タグを検索...",
|
||||
"noTagMatches": "現在の検索に一致するタグはありません。",
|
||||
"autoTags": "自動タグ",
|
||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||
"clearAll": "すべてのフィルタをクリア",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "再試行"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "프리셋 이름...",
|
||||
"baseModel": "베이스 모델",
|
||||
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
||||
"modelTags": "태그 (상위 20개)",
|
||||
"modelTags": "태그",
|
||||
"modelTypes": "모델 유형",
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||
"noTags": "태그 없음",
|
||||
"tagSearchPlaceholder": "태그 검색...",
|
||||
"noTagMatches": "현재 검색과 일치하는 태그가 없습니다.",
|
||||
"autoTags": "자동 태그",
|
||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||
"clearAll": "모든 필터 지우기",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "다시 시도"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Имя пресета...",
|
||||
"baseModel": "Базовая модель",
|
||||
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
|
||||
"modelTags": "Теги (Топ 20)",
|
||||
"modelTags": "Теги",
|
||||
"modelTypes": "Типы моделей",
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||
"noTags": "Без тегов",
|
||||
"tagSearchPlaceholder": "Поиск тегов...",
|
||||
"noTagMatches": "Нет тегов, соответствующих текущему поиску.",
|
||||
"autoTags": "Авто-теги",
|
||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||
"clearAll": "Очистить все фильтры",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "Повторить"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "预设名称...",
|
||||
"baseModel": "基础模型",
|
||||
"baseModelSearchPlaceholder": "搜索基础模型...",
|
||||
"modelTags": "标签(前20)",
|
||||
"modelTags": "标签",
|
||||
"modelTypes": "模型类型",
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||
"noTags": "无标签",
|
||||
"tagSearchPlaceholder": "搜索标签...",
|
||||
"noTagMatches": "没有匹配当前搜索的标签。",
|
||||
"autoTags": "自动标签",
|
||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||
"clearAll": "清除所有筛选",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "預設名稱...",
|
||||
"baseModel": "基礎模型",
|
||||
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
||||
"modelTags": "標籤(前 20)",
|
||||
"modelTags": "標籤",
|
||||
"modelTypes": "模型類型",
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||
"noTags": "無標籤",
|
||||
"tagSearchPlaceholder": "搜尋標籤...",
|
||||
"noTagMatches": "沒有符合目前搜尋的標籤。",
|
||||
"autoTags": "自動標籤",
|
||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||
"clearAll": "清除所有篩選",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "重試"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,6 +973,8 @@ class ModelQueryHandler:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
top_tags = await self._service.get_top_tags(limit)
|
||||
return web.json_response({"success": True, "tags": top_tags})
|
||||
except Exception as exc:
|
||||
@@ -981,6 +983,22 @@ class ModelQueryHandler:
|
||||
{"success": False, "error": "Internal server error"}, status=500
|
||||
)
|
||||
|
||||
async def search_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
query = request.query.get("q", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
tags = await self._service.search_tags(query, limit)
|
||||
return web.json_response({"success": True, "tags": tags})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error searching tags: %s", exc, exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Internal server error"}, status=500
|
||||
)
|
||||
|
||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
@@ -2947,6 +2965,7 @@ class ModelHandlerSet:
|
||||
"bulk_delete_models": self.management.bulk_delete_models,
|
||||
"verify_duplicates": self.management.verify_duplicates,
|
||||
"get_top_tags": self.query.get_top_tags,
|
||||
"search_tags": self.query.search_tags,
|
||||
"get_base_models": self.query.get_base_models,
|
||||
"get_model_types": self.query.get_model_types,
|
||||
"scan_models": self.query.scan_models,
|
||||
|
||||
@@ -72,6 +72,7 @@ class RecipeHandlerSet:
|
||||
"save_recipe": self.management.save_recipe,
|
||||
"delete_recipe": self.management.delete_recipe,
|
||||
"get_top_tags": self.query.get_top_tags,
|
||||
"search_tags": self.query.search_tags,
|
||||
"get_base_models": self.query.get_base_models,
|
||||
"get_roots": self.query.get_roots,
|
||||
"get_folders": self.query.get_folders,
|
||||
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
|
||||
tag_counts: Dict[str, int] = {}
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
for tag in recipe.get("tags", []) or []:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
||||
|
||||
sorted_tags = [
|
||||
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
||||
@@ -333,6 +333,55 @@ class RecipeQueryHandler:
|
||||
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def search_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
query = request.query.get("q", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
|
||||
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
||||
normalized_query = (query or "").strip().lower()
|
||||
if not normalized_query:
|
||||
sorted_tags = [
|
||||
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
||||
]
|
||||
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
return web.json_response(
|
||||
{"success": True, "tags": sorted_tags[: (limit if limit > 0 else 20)]}
|
||||
)
|
||||
|
||||
matched = [
|
||||
{"tag": tag, "count": count}
|
||||
for tag, count in tag_counts.items()
|
||||
if normalized_query in tag.lower()
|
||||
]
|
||||
matched.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
if limit == 0:
|
||||
result = matched
|
||||
else:
|
||||
result = matched[:limit]
|
||||
return web.json_response({"success": True, "tags": result})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error searching recipe tags: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def _get_recipe_tag_counts(self, recipe_scanner) -> Dict[str, int]:
|
||||
"""Compute tag->count mapping from cached recipe data."""
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
tag_counts: Dict[str, int] = {}
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
for tag in recipe.get("tags", []) or []:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
return tag_counts
|
||||
|
||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
|
||||
@@ -46,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
|
||||
|
||||
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/search-tags", "search_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||
|
||||
@@ -804,6 +804,12 @@ class BaseModelService(ABC):
|
||||
"""Get top tags sorted by frequency"""
|
||||
return await self.scanner.get_top_tags(limit)
|
||||
|
||||
async def search_tags(
|
||||
self, query: str, limit: int = 50
|
||||
) -> List[Dict]:
|
||||
"""Search tags by substring, sorted by frequency"""
|
||||
return await self.scanner.search_tags(query, limit)
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict]:
|
||||
"""Get base models sorted by frequency"""
|
||||
return await self.scanner.get_base_models(limit)
|
||||
|
||||
@@ -1830,7 +1830,32 @@ class ModelScanner:
|
||||
if limit == 0:
|
||||
return sorted_tags
|
||||
return sorted_tags[:limit]
|
||||
|
||||
|
||||
async def search_tags(
|
||||
self, query: str, limit: int = 50
|
||||
) -> List[Dict[str, any]]:
|
||||
"""Search tags by case-insensitive substring match, sorted by count.
|
||||
|
||||
If query is empty, behaves like get_top_tags (returns top ``limit``
|
||||
tags). If limit is 0, all matching tags are returned.
|
||||
"""
|
||||
await self.get_cached_data()
|
||||
|
||||
normalized_query = (query or "").strip().lower()
|
||||
if not normalized_query:
|
||||
return await self.get_top_tags(limit if limit > 0 else 20)
|
||||
|
||||
matched = [
|
||||
{"tag": tag, "count": count}
|
||||
for tag, count in self._tags_count.items()
|
||||
if normalized_query in tag.lower()
|
||||
]
|
||||
matched.sort(key=lambda x: x["count"], reverse=True)
|
||||
|
||||
if limit == 0:
|
||||
return matched
|
||||
return matched[:limit]
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
"""Get base models sorted by count. If limit is 0, return all."""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.7"
|
||||
version = "1.1.8"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -274,6 +274,11 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Inline extra tags (selected but not in top-20/appended after API results) */
|
||||
.filter-tag.extra-tag {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* Ensure solid border and full opacity when active or excluded */
|
||||
.filter-tag.special-tag.active,
|
||||
.filter-tag.special-tag.exclude {
|
||||
|
||||
@@ -93,6 +93,7 @@ export function getApiEndpoints(modelType) {
|
||||
// Query operations
|
||||
scan: `/api/lm/${modelType}/scan`,
|
||||
topTags: `/api/lm/${modelType}/top-tags`,
|
||||
searchTags: `/api/lm/${modelType}/search-tags`,
|
||||
baseModels: `/api/lm/${modelType}/base-models`,
|
||||
roots: `/api/lm/${modelType}/roots`,
|
||||
folders: `/api/lm/${modelType}/folders`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { getApiEndpoints } from '../api/apiConfig.js';
|
||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
@@ -24,6 +25,12 @@ export class FilterManager {
|
||||
this.baseModelOptions = [];
|
||||
this.tagsLoaded = false;
|
||||
|
||||
// Tag search state
|
||||
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
|
||||
this.tagSearchDebounceTimer = null;
|
||||
this.tagSearchAbortController = null;
|
||||
this.tagSearchQuery = '';
|
||||
|
||||
// Initialize preset manager
|
||||
this.presetManager = new FilterPresetManager({
|
||||
page: this.currentPage,
|
||||
@@ -123,6 +130,60 @@ export class FilterManager {
|
||||
this.renderBaseModelTags();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.modelTagsSearchInput) {
|
||||
this.modelTagsSearchInput.addEventListener('input', () => {
|
||||
clearTimeout(this.tagSearchDebounceTimer);
|
||||
this.tagSearchDebounceTimer = setTimeout(() => {
|
||||
this.handleTagSearchInput();
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleTagSearchInput() {
|
||||
const query = (this.modelTagsSearchInput?.value || '').trim();
|
||||
const trimmedQuery = query.toLowerCase();
|
||||
if (trimmedQuery === this.tagSearchQuery) return;
|
||||
this.tagSearchQuery = trimmedQuery;
|
||||
|
||||
if (!trimmedQuery) {
|
||||
// Empty query: reload top tags (default/common view)
|
||||
this.loadTopTags();
|
||||
return;
|
||||
}
|
||||
this.searchTags(trimmedQuery);
|
||||
}
|
||||
|
||||
async searchTags(query) {
|
||||
// Abort any in-flight search request
|
||||
if (this.tagSearchAbortController) {
|
||||
this.tagSearchAbortController.abort();
|
||||
}
|
||||
this.tagSearchAbortController = new AbortController();
|
||||
const controller = this.tagSearchAbortController;
|
||||
|
||||
try {
|
||||
const tagsEndpoint = `${getApiEndpoints(this.currentPage).searchTags}?q=${encodeURIComponent(query)}&limit=20`;
|
||||
const response = await fetch(tagsEndpoint, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error('Failed to search tags');
|
||||
const data = await response.json();
|
||||
if (controller.signal.aborted) return; // stale response
|
||||
if (data.success && data.tags) {
|
||||
this.createTagFilterElements(data.tags);
|
||||
} else {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return; // expected, ignore
|
||||
console.error('Error searching tags:', error);
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
if (tagsContainer) {
|
||||
tagsContainer.innerHTML = '<div class="tags-error">Failed to search tags</div>';
|
||||
}
|
||||
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
getNormalizedSearchQuery(input) {
|
||||
@@ -146,15 +207,24 @@ export class FilterManager {
|
||||
}
|
||||
|
||||
async loadTopTags() {
|
||||
// Abort any in-flight tag search request
|
||||
if (this.tagSearchAbortController) {
|
||||
this.tagSearchAbortController.abort();
|
||||
this.tagSearchAbortController = null;
|
||||
}
|
||||
this.tagSearchQuery = '';
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||
if (!tagsContainer) return;
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
|
||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||
|
||||
// Determine the API endpoint based on the page type
|
||||
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
|
||||
const tagsEndpoint = `${getApiEndpoints(this.currentPage).topTags}?limit=20`;
|
||||
|
||||
const response = await fetch(tagsEndpoint);
|
||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||
@@ -179,29 +249,38 @@ export class FilterManager {
|
||||
|
||||
createTagFilterElements(tags) {
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
tagsContainer.innerHTML = '';
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
|
||||
// Collect existing tag names from the API response
|
||||
const existingTagNames = new Set(tags.map(t => t.tag));
|
||||
|
||||
// Add any active filter tags that aren't in the top 20
|
||||
// Collect active filter tags that aren't in the response (excluding __no_tags__)
|
||||
const missingSelectedTags = [];
|
||||
if (this.filters.tags) {
|
||||
Object.keys(this.filters.tags).forEach(tagName => {
|
||||
// Skip special tags like __no_tags__
|
||||
if (tagName.startsWith('__')) return;
|
||||
|
||||
if (!existingTagNames.has(tagName)) {
|
||||
// Add this tag to the list with count 0 (unknown)
|
||||
tags.push({ tag: tagName, count: 0 });
|
||||
missingSelectedTags.push({ tag: tagName, count: 0 });
|
||||
existingTagNames.add(tagName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Append missing selected tags after the API results so they appear inline
|
||||
for (const t of missingSelectedTags) {
|
||||
tags.push(t);
|
||||
}
|
||||
|
||||
if (!tags.length) {
|
||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||
if (this.tagSearchQuery) {
|
||||
if (emptyState) emptyState.hidden = false;
|
||||
} else {
|
||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,6 +288,10 @@ export class FilterManager {
|
||||
const tagEl = document.createElement('div');
|
||||
tagEl.className = 'filter-tag tag-filter';
|
||||
const tagName = tag.tag;
|
||||
|
||||
if (missingSelectedTags.some(t => t.tag === tagName)) {
|
||||
tagEl.classList.add('extra-tag');
|
||||
}
|
||||
tagEl.dataset.tag = tagName;
|
||||
|
||||
// Show count only if it's > 0 (known count)
|
||||
@@ -234,26 +317,28 @@ export class FilterManager {
|
||||
tagsContainer.appendChild(tagEl);
|
||||
});
|
||||
|
||||
// Add "No tags" as a special filter at the end
|
||||
const noTagsEl = document.createElement('div');
|
||||
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||
const noTagsKey = '__no_tags__';
|
||||
noTagsEl.dataset.tag = noTagsKey;
|
||||
noTagsEl.innerHTML = noTagsLabel;
|
||||
// Add "No tags" as a special filter at the end (skip during search)
|
||||
if (!this.tagSearchQuery) {
|
||||
const noTagsEl = document.createElement('div');
|
||||
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||
const noTagsKey = '__no_tags__';
|
||||
noTagsEl.dataset.tag = noTagsKey;
|
||||
noTagsEl.innerHTML = noTagsLabel;
|
||||
|
||||
noTagsEl.addEventListener('click', async () => {
|
||||
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
||||
const newState = this.getNextTriStateState(currentState);
|
||||
this.setTagFilterState(noTagsKey, newState);
|
||||
this.applyTagElementState(noTagsEl, newState);
|
||||
noTagsEl.addEventListener('click', async () => {
|
||||
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
||||
const newState = this.getNextTriStateState(currentState);
|
||||
this.setTagFilterState(noTagsKey, newState);
|
||||
this.applyTagElementState(noTagsEl, newState);
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(noTagsEl);
|
||||
tagsContainer.appendChild(noTagsEl);
|
||||
}
|
||||
this.updateTagSelections();
|
||||
}
|
||||
|
||||
@@ -341,7 +426,7 @@ export class FilterManager {
|
||||
if (!baseModelTagsContainer) return;
|
||||
|
||||
// Set the API endpoint based on current page
|
||||
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`;
|
||||
const apiEndpoint = `${getApiEndpoints(this.currentPage).baseModels}?limit=0`;
|
||||
|
||||
// Fetch base models
|
||||
fetch(apiEndpoint)
|
||||
@@ -721,6 +806,16 @@ export class FilterManager {
|
||||
tagLogic: 'any'
|
||||
});
|
||||
|
||||
// Clear tag search input and reset search state
|
||||
if (this.modelTagsSearchInput) {
|
||||
this.modelTagsSearchInput.value = '';
|
||||
}
|
||||
this.tagSearchQuery = '';
|
||||
if (this.tagSearchAbortController) {
|
||||
this.tagSearchAbortController.abort();
|
||||
this.tagSearchAbortController = null;
|
||||
}
|
||||
|
||||
// Update tag logic toggle UI
|
||||
this.updateTagLogicToggleUI();
|
||||
|
||||
@@ -731,6 +826,10 @@ export class FilterManager {
|
||||
// Update UI
|
||||
this.updateTagSelections();
|
||||
this.updateActiveFiltersCount();
|
||||
// Reload tag area to drop any non-top-20 tags from the deactivated preset
|
||||
if (this.tagsLoaded) {
|
||||
await this.loadTopTags();
|
||||
}
|
||||
this.presetManager.renderPresets(); // Re-render to remove active state
|
||||
|
||||
// Remove from local Storage
|
||||
|
||||
@@ -478,11 +478,9 @@ export class FilterPresetManager {
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.filters = this.filterManager.cloneFilters();
|
||||
|
||||
// If tags haven't been loaded yet, load them first
|
||||
if (!this.filterManager.tagsLoaded) {
|
||||
await this.filterManager.loadTopTags();
|
||||
this.filterManager.tagsLoaded = true;
|
||||
}
|
||||
// Refresh tag display so preset's non-top-20 tags appear inline
|
||||
await this.filterManager.loadTopTags();
|
||||
this.filterManager.tagsLoaded = true;
|
||||
|
||||
// Check again after async operation
|
||||
if (requestId !== this.applyPresetRequestId) return;
|
||||
@@ -745,8 +743,16 @@ export class FilterPresetManager {
|
||||
presetEl.classList.add('active');
|
||||
}
|
||||
|
||||
presetEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Apply preset on click (toggle if already active)
|
||||
// Bind to the whole .filter-preset div so clicking anywhere inside triggers apply
|
||||
presetEl.addEventListener('click', async () => {
|
||||
this.cancelPendingDelete();
|
||||
|
||||
if (this.activePreset === preset.name) {
|
||||
await this.filterManager.clearFilters();
|
||||
} else {
|
||||
await this.applyPreset(preset.name);
|
||||
}
|
||||
});
|
||||
|
||||
const presetName = document.createElement('span');
|
||||
@@ -759,18 +765,6 @@ export class FilterPresetManager {
|
||||
deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
|
||||
|
||||
// Apply preset on name click (toggle if already active)
|
||||
presetName.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
this.cancelPendingDelete();
|
||||
|
||||
if (this.activePreset === preset.name) {
|
||||
await this.filterManager.clearFilters();
|
||||
} else {
|
||||
await this.applyPreset(preset.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Two-step delete on delete button click
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -134,7 +134,10 @@ export async function copyToClipboard(text, successMessage = null) {
|
||||
}
|
||||
|
||||
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||||
const message = translate(key, params, fallback);
|
||||
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
|
||||
// to avoid spurious "Translation key not found" warnings from i18next
|
||||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||||
const message = isPlainMessage ? key : translate(key, params, fallback);
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
@@ -251,10 +251,15 @@
|
||||
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" id="modelTagsSearchInput" class="filter-search-input"
|
||||
placeholder="{{ t('header.filter.tagSearchPlaceholder') }}" autocomplete="off">
|
||||
<div class="filter-tags" id="modelTagsFilter">
|
||||
<!-- Top tags will be dynamically inserted here -->
|
||||
<div class="tags-loading">{{ t('common.status.loading') }}</div>
|
||||
</div>
|
||||
<div id="modelTagsEmptyState" class="filter-empty-state" hidden>
|
||||
{{ t('header.filter.noTagMatches') }}
|
||||
</div>
|
||||
</div>
|
||||
{% if current_page == 'loras' or current_page == 'checkpoints' %}
|
||||
<div class="filter-section">
|
||||
|
||||
@@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
|
||||
<div id="baseModelEmptyState" hidden></div>
|
||||
<div id="filterPresets" class="filter-presets"></div>
|
||||
<div id="modelTagsFilter" class="filter-tags"></div>
|
||||
<input id="modelTagsSearchInput" />
|
||||
<div id="modelTagsEmptyState" hidden></div>
|
||||
<button class="clear-filter"></button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
@@ -961,4 +963,198 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
||||
expect(stateModule.state.bulkMode).toBe(true);
|
||||
expect(pageState.duplicatesMode).toBe(true);
|
||||
});
|
||||
|
||||
describe('tag search', () => {
|
||||
it('fetches /search-tags when typing in the tag search input (debounced)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const searchTagsUrls = [];
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes('/search-tags')) {
|
||||
searchTagsUrls.push(url);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
|
||||
});
|
||||
}
|
||||
if (url.includes('/top-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/base-models')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||
}
|
||||
if (url.includes('/model-types')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
// Open the panel so tags load
|
||||
manager.toggleFilterPanel();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const input = document.getElementById('modelTagsSearchInput');
|
||||
input.value = 'ani';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Before debounce fires, no search-tags call yet
|
||||
expect(searchTagsUrls.length).toBe(0);
|
||||
|
||||
// Advance past the 150ms debounce
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(searchTagsUrls.length).toBe(1);
|
||||
expect(searchTagsUrls[0]).toContain('/search-tags');
|
||||
expect(searchTagsUrls[0]).toContain('q=ani');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders selected-but-missing tags in a dedicated group at the top', async () => {
|
||||
vi.useFakeTimers();
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes('/search-tags')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
|
||||
});
|
||||
}
|
||||
if (url.includes('/top-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/base-models')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||
}
|
||||
if (url.includes('/model-types')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
// Pre-seed an active tag filter that won't appear in search results
|
||||
manager.filters.tags = { 'my-custom-tag': 'include' };
|
||||
|
||||
// Open panel and let top-tags load (empty)
|
||||
manager.toggleFilterPanel();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Type a search query
|
||||
const input = document.getElementById('modelTagsSearchInput');
|
||||
input.value = 'ani';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const container = document.getElementById('modelTagsFilter');
|
||||
const extraTag = container.querySelector('.filter-tag.extra-tag');
|
||||
expect(extraTag).not.toBeNull();
|
||||
expect(extraTag.dataset.tag).toBe('my-custom-tag');
|
||||
|
||||
// The search result tag should also be present
|
||||
const resultTag = container.querySelector('.filter-tag.tag-filter[data-tag="anime"]');
|
||||
expect(resultTag).not.toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows empty state when search returns no matches and no selected tags', async () => {
|
||||
vi.useFakeTimers();
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes('/search-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/top-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/base-models')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||
}
|
||||
if (url.includes('/model-types')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
manager.toggleFilterPanel();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const input = document.getElementById('modelTagsSearchInput');
|
||||
input.value = 'zzz';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||
expect(emptyState.hidden).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('reloads top tags when search input is cleared', async () => {
|
||||
vi.useFakeTimers();
|
||||
let topTagsCallCount = 0;
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes('/search-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }) });
|
||||
}
|
||||
if (url.includes('/top-tags')) {
|
||||
topTagsCallCount++;
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/base-models')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||
}
|
||||
if (url.includes('/model-types')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
manager.toggleFilterPanel();
|
||||
await vi.runAllTimersAsync();
|
||||
const callsAfterOpen = topTagsCallCount;
|
||||
expect(callsAfterOpen).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Type, then clear
|
||||
const input = document.getElementById('modelTagsSearchInput');
|
||||
input.value = 'ani';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// An additional top-tags call should have happened after clearing
|
||||
expect(topTagsCallCount).toBeGreaterThan(callsAfterOpen);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,3 +36,53 @@ async def test_model_query_handler_rejects_negative_limit_for_base_models():
|
||||
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
|
||||
|
||||
assert service.received_limit == 20
|
||||
|
||||
|
||||
class DummySearchTagsService:
|
||||
"""Minimal service stub recording search_tags arguments."""
|
||||
|
||||
def __init__(self, result=None):
|
||||
self.received_query = None
|
||||
self.received_limit = None
|
||||
self._result = result or []
|
||||
|
||||
async def search_tags(self, query, limit):
|
||||
self.received_query = query
|
||||
self.received_limit = limit
|
||||
return self._result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_query_handler_search_tags_passes_query_and_limit():
|
||||
service = DummySearchTagsService(result=[{"tag": "anime", "count": 3}])
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.search_tags(
|
||||
SimpleNamespace(query={"q": "ani", "limit": "50"})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["tags"] == [{"tag": "anime", "count": 3}]
|
||||
assert service.received_query == "ani"
|
||||
assert service.received_limit == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_query_handler_search_tags_defaults_limit_to_20():
|
||||
service = DummySearchTagsService()
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
await handler.search_tags(SimpleNamespace(query={}))
|
||||
|
||||
assert service.received_limit == 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_query_handler_search_tags_clamps_negative_limit():
|
||||
service = DummySearchTagsService()
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
await handler.search_tags(SimpleNamespace(query={"limit": "-5"}))
|
||||
|
||||
assert service.received_limit == 20
|
||||
|
||||
Reference in New Issue
Block a user