mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-13 09:20:14 -03:00
Compare commits
9 Commits
c27e4d1bfc
..
v1.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 8022d12f03 | |||
| 3939f7f91b | |||
| aebf2e37dd | |||
| f53f859a71 | |||
| d916375abe | |||
| 57983df4bd | |||
| c68d7559a0 | |||
| 9a8f5bf2d6 | |||
| a8d742b031 |
+138
-125
@@ -10,13 +10,17 @@
|
|||||||
"Brennok",
|
"Brennok",
|
||||||
"2018cfh",
|
"2018cfh",
|
||||||
"Insomnia Art Designs",
|
"Insomnia Art Designs",
|
||||||
"Rob Williams",
|
|
||||||
"Arlecchino Shion",
|
"Arlecchino Shion",
|
||||||
"Charles Blakemore",
|
"Charles Blakemore",
|
||||||
|
"Rob Williams",
|
||||||
"$MetaSamsara",
|
"$MetaSamsara",
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
"stone9k",
|
"stone9k",
|
||||||
|
"Rosenthal",
|
||||||
|
"Francisco Tatis",
|
||||||
|
"Mozzel",
|
||||||
"Gingko Biloba",
|
"Gingko Biloba",
|
||||||
|
"Birdy",
|
||||||
"Kiba",
|
"Kiba",
|
||||||
"onesecondinosaur",
|
"onesecondinosaur",
|
||||||
"Christian Byrne",
|
"Christian Byrne",
|
||||||
@@ -27,22 +31,25 @@
|
|||||||
"Phil",
|
"Phil",
|
||||||
"Carl G.",
|
"Carl G.",
|
||||||
"Dsperado",
|
"Dsperado",
|
||||||
"Rosenthal",
|
|
||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
"Francisco Tatis",
|
|
||||||
"Tobi_Swagg",
|
"Tobi_Swagg",
|
||||||
|
"SG",
|
||||||
|
"jmack",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
"JongWon Han",
|
"JongWon Han",
|
||||||
"VantAI",
|
"VantAI",
|
||||||
|
"レプサイ",
|
||||||
"Michael Wong",
|
"Michael Wong",
|
||||||
"Illrigger",
|
"Illrigger",
|
||||||
"Tom Corrigan",
|
"Tom Corrigan",
|
||||||
"JackieWang",
|
"JackieWang",
|
||||||
"FreelancerZ",
|
"FreelancerZ",
|
||||||
"fnkylove",
|
"fnkylove",
|
||||||
|
"Lilleman",
|
||||||
"Robert Stacey",
|
"Robert Stacey",
|
||||||
|
"PM",
|
||||||
"Edgar Tejeda",
|
"Edgar Tejeda",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
"Polymorphic Indeterminate",
|
"Polymorphic Indeterminate",
|
||||||
@@ -50,9 +57,7 @@
|
|||||||
"Dogwalkerbr",
|
"Dogwalkerbr",
|
||||||
"Skalabananen",
|
"Skalabananen",
|
||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
"Birdy",
|
|
||||||
"itismyelement",
|
"itismyelement",
|
||||||
"Mozzel",
|
|
||||||
"quarz",
|
"quarz",
|
||||||
"Reno Lam",
|
"Reno Lam",
|
||||||
"jean jahren",
|
"jean jahren",
|
||||||
@@ -65,40 +70,46 @@
|
|||||||
"Jonathan Ross",
|
"Jonathan Ross",
|
||||||
"KD",
|
"KD",
|
||||||
"Omnidex",
|
"Omnidex",
|
||||||
"Nazono_hito",
|
"Nolife_M",
|
||||||
|
"Melville Parrish",
|
||||||
"daniel dove",
|
"daniel dove",
|
||||||
|
"Lustre",
|
||||||
"Tyler Trebuchon",
|
"Tyler Trebuchon",
|
||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
"JW Sin",
|
"JW Sin",
|
||||||
"Alex",
|
"Alex",
|
||||||
"SG",
|
"bh",
|
||||||
"carozzz",
|
"carozzz",
|
||||||
|
"Marlon Daniels",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
"jmack",
|
"Aaron Bleuer",
|
||||||
|
"LacesOut!",
|
||||||
"Adam Shaw",
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
"RedrockVP",
|
"RedrockVP",
|
||||||
"James Todd",
|
"James Todd",
|
||||||
"Wicked Choices by ASLPro3D",
|
"Wicked Choices by ASLPro3D",
|
||||||
|
"FinalyFree",
|
||||||
|
"Weasyl",
|
||||||
"Steven Pfeiffer",
|
"Steven Pfeiffer",
|
||||||
"レプサイ",
|
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"Johnny",
|
"Johnny",
|
||||||
|
"Cory Paza",
|
||||||
"Tak",
|
"Tak",
|
||||||
"Lisster",
|
"Lisster",
|
||||||
"runte3221",
|
"runte3221",
|
||||||
"Big Red",
|
"Big Red",
|
||||||
"whudunit",
|
"whudunit",
|
||||||
|
"Luc Job",
|
||||||
"dl0901dm",
|
"dl0901dm",
|
||||||
|
"corde",
|
||||||
"Yushio",
|
"Yushio",
|
||||||
"Vik71it",
|
"Vik71it",
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
"Echo",
|
"Echo",
|
||||||
"Lilleman",
|
|
||||||
"PM",
|
|
||||||
"Todd Keck",
|
"Todd Keck",
|
||||||
"Briton Heilbrun",
|
"Briton Heilbrun",
|
||||||
"wildnut",
|
"wildnut",
|
||||||
@@ -108,53 +119,51 @@
|
|||||||
"BadassArabianMofo",
|
"BadassArabianMofo",
|
||||||
"Pascal Dahle",
|
"Pascal Dahle",
|
||||||
"Greg",
|
"Greg",
|
||||||
|
"Sangheili460",
|
||||||
|
"MagnaInsomnia",
|
||||||
|
"Akira_HentAI",
|
||||||
|
"Karl P.",
|
||||||
"MiraiKuriyamaSy",
|
"MiraiKuriyamaSy",
|
||||||
"otaku fra",
|
"otaku fra",
|
||||||
"lmsupporter",
|
"lmsupporter",
|
||||||
"andrew.tappan",
|
"andrew.tappan",
|
||||||
"Takkan",
|
"Takkan",
|
||||||
|
"N/A",
|
||||||
|
"Greenmoustache",
|
||||||
"zounic",
|
"zounic",
|
||||||
"wfpearl",
|
"wfpearl",
|
||||||
"ElitaSSJ4",
|
"ElitaSSJ4",
|
||||||
"Matt+J",
|
"Matt+J",
|
||||||
"Jack B Nimble",
|
"Jack B Nimble",
|
||||||
"Melville Parrish",
|
|
||||||
"Lustre",
|
|
||||||
"bh",
|
|
||||||
"Jwk0205",
|
"Jwk0205",
|
||||||
"Marlon Daniels",
|
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
"Aaron Bleuer",
|
"Olive",
|
||||||
"LacesOut!",
|
|
||||||
"greebles",
|
"greebles",
|
||||||
"Some Guy Named Barry",
|
"Some Guy Named Barry",
|
||||||
"Resist's Creations - Spicy Edition 🔥",
|
"Resist's Creations - Spicy Edition 🔥",
|
||||||
"M Postkasse",
|
"M Postkasse",
|
||||||
"Wolffen",
|
"Wolffen",
|
||||||
|
"wamekukyouzin",
|
||||||
|
"drum matthieu",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
"DogmaR34",
|
||||||
"Matt Wenzel",
|
"Matt Wenzel",
|
||||||
"Weasyl",
|
|
||||||
"Lex Song",
|
"Lex Song",
|
||||||
"Cory Paza",
|
"Christopher Michel",
|
||||||
"Gonzalo Andre Allendes Lopez",
|
"Gonzalo Andre Allendes Lopez",
|
||||||
|
"Serge Bekenkamp",
|
||||||
"Jimmy Ledbetter",
|
"Jimmy Ledbetter",
|
||||||
"Luc Job",
|
"LeoZero",
|
||||||
"Philip Hempel",
|
"Philip Hempel",
|
||||||
"corde",
|
|
||||||
"nwalker94",
|
"nwalker94",
|
||||||
"dan",
|
"dan",
|
||||||
"aai",
|
"aai",
|
||||||
"Tori",
|
"Tori",
|
||||||
|
"Mouthlessman",
|
||||||
"Ran C",
|
"Ran C",
|
||||||
"ViperC",
|
"ViperC",
|
||||||
"Sangheili460",
|
|
||||||
"MagnaInsomnia",
|
|
||||||
"Akira_HentAI",
|
|
||||||
"Karl P.",
|
|
||||||
"Adam Taylor",
|
"Adam Taylor",
|
||||||
"Weird_With_A_Beard",
|
"Weird_With_A_Beard",
|
||||||
"N/A",
|
|
||||||
"The Spawn",
|
"The Spawn",
|
||||||
"graysock",
|
"graysock",
|
||||||
"Pozadine1",
|
"Pozadine1",
|
||||||
@@ -162,7 +171,8 @@
|
|||||||
"AIGooner",
|
"AIGooner",
|
||||||
"Luc",
|
"Luc",
|
||||||
"ProtonPrince",
|
"ProtonPrince",
|
||||||
"Greenmoustache",
|
"DiffDuck",
|
||||||
|
"elu3199",
|
||||||
"fancypants",
|
"fancypants",
|
||||||
"John+Edwards",
|
"John+Edwards",
|
||||||
"Joboshy",
|
"Joboshy",
|
||||||
@@ -172,42 +182,39 @@
|
|||||||
"contrite831",
|
"contrite831",
|
||||||
"Dan",
|
"Dan",
|
||||||
"Bro Xie",
|
"Bro Xie",
|
||||||
|
"yer fey",
|
||||||
"batblue",
|
"batblue",
|
||||||
"carey6409",
|
"carey6409",
|
||||||
"Olive",
|
|
||||||
"太郎 ゲーム",
|
"太郎 ゲーム",
|
||||||
|
"Roslynd",
|
||||||
"jinxedx",
|
"jinxedx",
|
||||||
|
"Neco28",
|
||||||
|
"David Ortega",
|
||||||
"AELOX",
|
"AELOX",
|
||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
"Dankin-Pics",
|
"Dankin-Pics",
|
||||||
"Nicfit23",
|
"Nicfit23",
|
||||||
"Cristian Vazquez",
|
"Cristian Vazquez",
|
||||||
"wamekukyouzin",
|
|
||||||
"OldBones",
|
"OldBones",
|
||||||
"drum matthieu",
|
|
||||||
"Dogmaster",
|
|
||||||
"Frank Nitty",
|
"Frank Nitty",
|
||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"Christopher Michel",
|
|
||||||
"Zach Gonser",
|
"Zach Gonser",
|
||||||
"Serge Bekenkamp",
|
|
||||||
"DougPeterson",
|
"DougPeterson",
|
||||||
"LeoZero",
|
|
||||||
"Antonio Pontes",
|
"Antonio Pontes",
|
||||||
"nahinahi9",
|
"Bruce",
|
||||||
|
"kushiroK9",
|
||||||
"Kevin John Duck",
|
"Kevin John Duck",
|
||||||
"Dustin Chen",
|
"Dustin Chen",
|
||||||
|
"Kevin Christopher",
|
||||||
"Blackfish95",
|
"Blackfish95",
|
||||||
"Mouthlessman",
|
|
||||||
"Paul Kroll",
|
"Paul Kroll",
|
||||||
"Penfore",
|
"Penfore",
|
||||||
"Bas Imagineer",
|
"Bas Imagineer",
|
||||||
|
"John Statham",
|
||||||
"Gordon Cole",
|
"Gordon Cole",
|
||||||
"AbstractAss",
|
"AbstractAss",
|
||||||
"Dušan Ryban",
|
"Dušan Ryban",
|
||||||
"decoy",
|
"decoy",
|
||||||
"DiffDuck",
|
|
||||||
"elu3199",
|
|
||||||
"Hasturkun",
|
"Hasturkun",
|
||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
"Ubivis",
|
"Ubivis",
|
||||||
@@ -222,34 +229,34 @@
|
|||||||
"MJG",
|
"MJG",
|
||||||
"David LaVallee",
|
"David LaVallee",
|
||||||
"linnfrey",
|
"linnfrey",
|
||||||
|
"ae",
|
||||||
|
"Tr4shP4nda",
|
||||||
"Jackthemind",
|
"Jackthemind",
|
||||||
"griffin+dahlberg",
|
"griffin+dahlberg",
|
||||||
"jeaness",
|
"jeaness",
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
|
"Brian M",
|
||||||
"Josef Lanzl",
|
"Josef Lanzl",
|
||||||
"Nerezza",
|
"Nerezza",
|
||||||
"yer fey",
|
"sanborondon",
|
||||||
"Error_Rule34_Not_found",
|
"Error_Rule34_Not_found",
|
||||||
"aezin",
|
"aezin",
|
||||||
"jcay015",
|
"jcay015",
|
||||||
"Erik Lopez",
|
"Erik Lopez",
|
||||||
"Roslynd",
|
|
||||||
"Mateo Curić",
|
"Mateo Curić",
|
||||||
"Geolog",
|
"Geolog",
|
||||||
"Neco28",
|
|
||||||
"Cosmosis",
|
"Cosmosis",
|
||||||
"Eris3D",
|
"Eris3D",
|
||||||
"David Ortega",
|
"m",
|
||||||
"FloPro4Sho",
|
"FloPro4Sho",
|
||||||
|
"Jamie Ogletree",
|
||||||
"a _",
|
"a _",
|
||||||
"Jeff",
|
"Jeff",
|
||||||
"Bruce",
|
|
||||||
"Steven Owens",
|
"Steven Owens",
|
||||||
"James Coleman",
|
"James Coleman",
|
||||||
"Kevin Christopher",
|
|
||||||
"Chad Idk",
|
"Chad Idk",
|
||||||
"dd",
|
"dd",
|
||||||
"John Statham",
|
"Sam",
|
||||||
"sjon kreutz",
|
"sjon kreutz",
|
||||||
"yuxz69",
|
"yuxz69",
|
||||||
"LarsesFPC",
|
"LarsesFPC",
|
||||||
@@ -257,8 +264,6 @@
|
|||||||
"esthe",
|
"esthe",
|
||||||
"AlexDuKaNa",
|
"AlexDuKaNa",
|
||||||
"地獄の禄",
|
"地獄の禄",
|
||||||
"ae",
|
|
||||||
"Tr4shP4nda",
|
|
||||||
"Gamalonia",
|
"Gamalonia",
|
||||||
"capn",
|
"capn",
|
||||||
"Joseph",
|
"Joseph",
|
||||||
@@ -272,12 +277,16 @@
|
|||||||
"Hailshem",
|
"Hailshem",
|
||||||
"Naomi Hale Danchi",
|
"Naomi Hale Danchi",
|
||||||
"epicgamer0020690",
|
"epicgamer0020690",
|
||||||
|
"Joshua Porrata",
|
||||||
|
"SuBu",
|
||||||
|
"RedPIXel",
|
||||||
|
"Wind",
|
||||||
"IamAyam",
|
"IamAyam",
|
||||||
"Andrew",
|
"Andrew",
|
||||||
"Brian M",
|
|
||||||
"Robert Wegemund",
|
"Robert Wegemund",
|
||||||
"sanborondon",
|
"Littlehuggy",
|
||||||
"confiscated Zyra",
|
"Andrew Marshall",
|
||||||
|
"Brian Buie",
|
||||||
"Taylor Funk",
|
"Taylor Funk",
|
||||||
"Thought2Form",
|
"Thought2Form",
|
||||||
"Gerald Welly",
|
"Gerald Welly",
|
||||||
@@ -285,15 +294,19 @@
|
|||||||
"Sadlip",
|
"Sadlip",
|
||||||
"Tee Gee",
|
"Tee Gee",
|
||||||
"tarek helmi",
|
"tarek helmi",
|
||||||
|
"Joey Callahan",
|
||||||
"Max Marklund",
|
"Max Marklund",
|
||||||
"m",
|
"Mike Simone",
|
||||||
"Pierce McBride",
|
"Pierce McBride",
|
||||||
"Joshua Gray",
|
"Joshua Gray",
|
||||||
"Pronredn",
|
"Pronredn",
|
||||||
"Mikko Hemilä",
|
"Mikko Hemilä",
|
||||||
"Jamie Ogletree",
|
"Jacob McDaniel",
|
||||||
|
"X",
|
||||||
"Temikus",
|
"Temikus",
|
||||||
|
"Artokun",
|
||||||
"Michael Taylor",
|
"Michael Taylor",
|
||||||
|
"Derek Baker",
|
||||||
"lh qwe",
|
"lh qwe",
|
||||||
"Martial",
|
"Martial",
|
||||||
"conner",
|
"conner",
|
||||||
@@ -305,24 +318,21 @@
|
|||||||
"Decx _",
|
"Decx _",
|
||||||
"Yuji Kaneko",
|
"Yuji Kaneko",
|
||||||
"Rops Alot",
|
"Rops Alot",
|
||||||
"Sam",
|
|
||||||
"Ace Ventura",
|
"Ace Ventura",
|
||||||
"四糸凜音",
|
"四糸凜音",
|
||||||
"Xeeosat",
|
"Xeeosat",
|
||||||
"Douglas Gaspar",
|
"Douglas Gaspar",
|
||||||
|
"Saya",
|
||||||
"George",
|
"George",
|
||||||
"dw",
|
"dw",
|
||||||
|
"FrxzenSnxw",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"momokai",
|
"momokai",
|
||||||
"몽타주",
|
"몽타주",
|
||||||
"kudari",
|
"kudari",
|
||||||
"ken",
|
"ken",
|
||||||
"Crocket",
|
"Crocket",
|
||||||
"Joshua Porrata",
|
|
||||||
"keemun",
|
"keemun",
|
||||||
"SuBu",
|
|
||||||
"RedPIXel",
|
|
||||||
"Wind",
|
|
||||||
"Nexus",
|
"Nexus",
|
||||||
"Ramneek“Guy”Ashok",
|
"Ramneek“Guy”Ashok",
|
||||||
"squid_actually",
|
"squid_actually",
|
||||||
@@ -337,37 +347,36 @@
|
|||||||
"KitKatM",
|
"KitKatM",
|
||||||
"socrasteeze",
|
"socrasteeze",
|
||||||
"OrganicArtifact",
|
"OrganicArtifact",
|
||||||
|
"ResidentDeviant",
|
||||||
"MudkipMedkitz",
|
"MudkipMedkitz",
|
||||||
"deanbrian",
|
"deanbrian",
|
||||||
"Alex Wortman",
|
"Alex Wortman",
|
||||||
"Cody",
|
"Cody",
|
||||||
"emadsultan",
|
"emadsultan",
|
||||||
|
"InformedViewz",
|
||||||
|
"CHKeeho80",
|
||||||
|
"Bubbafett",
|
||||||
|
"leaf",
|
||||||
|
"Adam Rinehart",
|
||||||
|
"Pitpe11",
|
||||||
|
"TheD1rtyD03",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"Richard",
|
"Richard",
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"Littlehuggy",
|
|
||||||
"Aberr",
|
"Aberr",
|
||||||
"Gregory Kozhemiak",
|
"Gregory Kozhemiak",
|
||||||
"준희 김",
|
"준희 김",
|
||||||
"Brian Buie",
|
|
||||||
"Eric Whitney",
|
"Eric Whitney",
|
||||||
"Joey Callahan",
|
|
||||||
"Ivan Tadic",
|
"Ivan Tadic",
|
||||||
"Tomohiro Baba",
|
"Tomohiro Baba",
|
||||||
"Mike Simone",
|
|
||||||
"Noora",
|
"Noora",
|
||||||
"John J Linehan",
|
"John J Linehan",
|
||||||
"Mattssn",
|
|
||||||
"Elliot E",
|
"Elliot E",
|
||||||
"Morgandel",
|
"Morgandel",
|
||||||
"Theerat Jiramate",
|
"Theerat Jiramate",
|
||||||
"Noah",
|
"Noah",
|
||||||
"Jacob McDaniel",
|
|
||||||
"X",
|
|
||||||
"Sloan Steddy",
|
"Sloan Steddy",
|
||||||
"Artokun",
|
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"Derek Baker",
|
|
||||||
"Steam Steam",
|
"Steam Steam",
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
"CryptoTraderJK",
|
"CryptoTraderJK",
|
||||||
@@ -378,23 +387,14 @@
|
|||||||
"Fotek Design",
|
"Fotek Design",
|
||||||
"Nihongasuki",
|
"Nihongasuki",
|
||||||
"MadSpin",
|
"MadSpin",
|
||||||
"FrxzenSnxw",
|
|
||||||
"inbijiburu",
|
"inbijiburu",
|
||||||
"Nick “Loadstone” D",
|
"Nick “Loadstone” D",
|
||||||
"starbugx",
|
"starbugx",
|
||||||
"dc7431",
|
"dc7431",
|
||||||
"ResidentDeviant",
|
|
||||||
"Ginnie",
|
"Ginnie",
|
||||||
"Raku",
|
"Raku",
|
||||||
"InformedViewz",
|
|
||||||
"CHKeeho80",
|
|
||||||
"Bubbafett",
|
|
||||||
"leaf",
|
|
||||||
"Vir",
|
"Vir",
|
||||||
"Skyfire83",
|
"Skyfire83",
|
||||||
"Adam Rinehart",
|
|
||||||
"Pitpe11",
|
|
||||||
"TheD1rtyD03",
|
|
||||||
"moonpetal",
|
"moonpetal",
|
||||||
"g9p0o",
|
"g9p0o",
|
||||||
"Pkrsky",
|
"Pkrsky",
|
||||||
@@ -403,6 +403,8 @@
|
|||||||
"SpringBootisTrash",
|
"SpringBootisTrash",
|
||||||
"carsten",
|
"carsten",
|
||||||
"ikok",
|
"ikok",
|
||||||
|
"quantenmecha",
|
||||||
|
"Jason+Nash",
|
||||||
"DarkRoast",
|
"DarkRoast",
|
||||||
"Nasty+Hobbit",
|
"Nasty+Hobbit",
|
||||||
"letzte",
|
"letzte",
|
||||||
@@ -414,12 +416,15 @@
|
|||||||
"David Schenck",
|
"David Schenck",
|
||||||
"Wolfe7D1",
|
"Wolfe7D1",
|
||||||
"Draven T",
|
"Draven T",
|
||||||
|
"Time Valentine",
|
||||||
"elleshar666",
|
"elleshar666",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
"Михал Михалыч",
|
"Михал Михалыч",
|
||||||
|
"Matt",
|
||||||
"Aquatic Coffee",
|
"Aquatic Coffee",
|
||||||
"Kauffy",
|
"Kauffy",
|
||||||
"ethanfel",
|
"ethanfel",
|
||||||
|
"SPJ",
|
||||||
"Focuschannel",
|
"Focuschannel",
|
||||||
"Edward Kennedy",
|
"Edward Kennedy",
|
||||||
"Nick Kage",
|
"Nick Kage",
|
||||||
@@ -432,12 +437,13 @@
|
|||||||
"notedfakes",
|
"notedfakes",
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
"Saya",
|
"Solixer",
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
"Wes Sims",
|
"Wes Sims",
|
||||||
"Donor4115",
|
"Donor4115",
|
||||||
"g unit",
|
"g unit",
|
||||||
"Jimmy Borup",
|
"Jimmy Borup",
|
||||||
|
"Manu Thetug",
|
||||||
"Filippo Ferrari",
|
"Filippo Ferrari",
|
||||||
"JC",
|
"JC",
|
||||||
"Prompt Pirate",
|
"Prompt Pirate",
|
||||||
@@ -451,6 +457,11 @@
|
|||||||
"SomeDude",
|
"SomeDude",
|
||||||
"nanana",
|
"nanana",
|
||||||
"raf8osz",
|
"raf8osz",
|
||||||
|
"Bob+Barker",
|
||||||
|
"D",
|
||||||
|
"Dark_Pest",
|
||||||
|
"Eldithor",
|
||||||
|
"Alex",
|
||||||
"Karru",
|
"Karru",
|
||||||
"ChaChanoKo",
|
"ChaChanoKo",
|
||||||
"redcarrot",
|
"redcarrot",
|
||||||
@@ -467,36 +478,34 @@
|
|||||||
"Doug+Rintoul",
|
"Doug+Rintoul",
|
||||||
"Noor",
|
"Noor",
|
||||||
"Yorunai",
|
"Yorunai",
|
||||||
"quantenmecha",
|
|
||||||
"Jason+Nash",
|
|
||||||
"cocona",
|
"cocona",
|
||||||
"blikkies",
|
"blikkies",
|
||||||
"JBsuede",
|
"JBsuede",
|
||||||
"Time Valentine",
|
|
||||||
"Shock Shockor",
|
"Shock Shockor",
|
||||||
"りん あめ",
|
"りん あめ",
|
||||||
"Matt",
|
|
||||||
"Goldwaters",
|
"Goldwaters",
|
||||||
"Zude",
|
"Zude",
|
||||||
|
"Joaquin Hierrezuelo",
|
||||||
"Frogmilk",
|
"Frogmilk",
|
||||||
"SPJ",
|
"Sean voets",
|
||||||
"Kyler",
|
"Kyler",
|
||||||
"Kor",
|
"Kor",
|
||||||
|
"Joseph Hanson",
|
||||||
|
"John Rednoulf",
|
||||||
"Bryan Rutkowski",
|
"Bryan Rutkowski",
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"aRtFuL_DodGeR",
|
"aRtFuL_DodGeR",
|
||||||
|
"Steven",
|
||||||
"TenaciousD",
|
"TenaciousD",
|
||||||
"Dmitry Ryzhov",
|
"Dmitry Ryzhov",
|
||||||
"Edward Ten Eyck",
|
"Edward Ten Eyck",
|
||||||
"Billy Gladky",
|
"Billy Gladky",
|
||||||
"Probis",
|
"Probis",
|
||||||
"Solixer",
|
|
||||||
"Pete Pain",
|
"Pete Pain",
|
||||||
"ItsGeneralButtNaked",
|
"ItsGeneralButtNaked",
|
||||||
"RHopkirk",
|
"RHopkirk",
|
||||||
"jinksta187",
|
"jinksta187",
|
||||||
"robin.kok.",
|
"robin.kok.",
|
||||||
"Manu Thetug",
|
|
||||||
"Maxim",
|
"Maxim",
|
||||||
"Karlanx",
|
"Karlanx",
|
||||||
"Lyavph",
|
"Lyavph",
|
||||||
@@ -504,6 +513,7 @@
|
|||||||
"Youguang",
|
"Youguang",
|
||||||
"andrewzpong",
|
"andrewzpong",
|
||||||
"BossGame",
|
"BossGame",
|
||||||
|
"Marcus thronico",
|
||||||
"lrdchs",
|
"lrdchs",
|
||||||
"Tree Tagger",
|
"Tree Tagger",
|
||||||
"Inversity",
|
"Inversity",
|
||||||
@@ -511,6 +521,15 @@
|
|||||||
"Kevinj",
|
"Kevinj",
|
||||||
"Mitchell Robson",
|
"Mitchell Robson",
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
|
"PoorStudent",
|
||||||
|
"Alex+Zaw",
|
||||||
|
"Supporter",
|
||||||
|
"ExLightSaber",
|
||||||
|
"Mobius2020",
|
||||||
|
"YaboiRay",
|
||||||
|
"Sildoren",
|
||||||
|
"Darv",
|
||||||
|
"Seon+Song",
|
||||||
"2turbo",
|
"2turbo",
|
||||||
"Dmitry+Viznesenskiy",
|
"Dmitry+Viznesenskiy",
|
||||||
"tanjin90",
|
"tanjin90",
|
||||||
@@ -528,11 +547,6 @@
|
|||||||
"Inkognito",
|
"Inkognito",
|
||||||
"G",
|
"G",
|
||||||
"Tan+Huynh",
|
"Tan+Huynh",
|
||||||
"Bob+Barker",
|
|
||||||
"D",
|
|
||||||
"Dark_Pest",
|
|
||||||
"Eldithor",
|
|
||||||
"Alex",
|
|
||||||
"BillyBoy84",
|
"BillyBoy84",
|
||||||
"Buecyb99",
|
"Buecyb99",
|
||||||
"Welkor",
|
"Welkor",
|
||||||
@@ -545,28 +559,27 @@
|
|||||||
"G",
|
"G",
|
||||||
"Ronan Delevacq",
|
"Ronan Delevacq",
|
||||||
"Christian Schäfer",
|
"Christian Schäfer",
|
||||||
|
"Leslie Andrew Ridings",
|
||||||
"Dave Abraham",
|
"Dave Abraham",
|
||||||
"Joaquin Hierrezuelo",
|
|
||||||
"Locrospiel",
|
"Locrospiel",
|
||||||
"Sean voets",
|
|
||||||
"Jarrid Lee",
|
"Jarrid Lee",
|
||||||
"Poophead27 Blyat",
|
"Poophead27 Blyat",
|
||||||
"Joseph Hanson",
|
|
||||||
"John Rednoulf",
|
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
"Mythspire",
|
"Mythspire",
|
||||||
"Boba Smith",
|
"Boba Smith",
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
"MR.Bear",
|
"MR.Bear",
|
||||||
|
"matt",
|
||||||
"somethingtosay8",
|
"somethingtosay8",
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
|
"Terminuz",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
"SendingRavens",
|
"SendingRavens",
|
||||||
"Sauv",
|
"Sauv",
|
||||||
"Steven",
|
|
||||||
"JackJohnnyJim",
|
"JackJohnnyJim",
|
||||||
"Khánh Đặng",
|
"Khánh Đặng",
|
||||||
|
"Borte",
|
||||||
"Michael Docherty",
|
"Michael Docherty",
|
||||||
"Ted Cart",
|
"Ted Cart",
|
||||||
"Sage Himeros",
|
"Sage Himeros",
|
||||||
@@ -574,6 +587,7 @@
|
|||||||
"Paul Hartsuyker",
|
"Paul Hartsuyker",
|
||||||
"elitassj",
|
"elitassj",
|
||||||
"Tigon",
|
"Tigon",
|
||||||
|
"SkibidiRizzler",
|
||||||
"Tania Nayelli Fernandez",
|
"Tania Nayelli Fernandez",
|
||||||
"Draconach",
|
"Draconach",
|
||||||
"Jacob Winter",
|
"Jacob Winter",
|
||||||
@@ -581,6 +595,8 @@
|
|||||||
"Andrew Wilkinson",
|
"Andrew Wilkinson",
|
||||||
"David",
|
"David",
|
||||||
"Meilo",
|
"Meilo",
|
||||||
|
"Nacho Ferrando",
|
||||||
|
"Marcos Tortosa Carmona",
|
||||||
"Dkom22",
|
"Dkom22",
|
||||||
"shinonomeiro",
|
"shinonomeiro",
|
||||||
"Snille",
|
"Snille",
|
||||||
@@ -589,7 +605,6 @@
|
|||||||
"xybrightsummer",
|
"xybrightsummer",
|
||||||
"jreedatchison",
|
"jreedatchison",
|
||||||
"PhilW",
|
"PhilW",
|
||||||
"Marcus thronico",
|
|
||||||
"Janik",
|
"Janik",
|
||||||
"Cruel",
|
"Cruel",
|
||||||
"MRBlack",
|
"MRBlack",
|
||||||
@@ -601,6 +616,13 @@
|
|||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"D",
|
"D",
|
||||||
|
"Somebody",
|
||||||
|
"Celestial+Kitten",
|
||||||
|
"TequiTequi",
|
||||||
|
"Homero+Banda",
|
||||||
|
"bakeliteboy",
|
||||||
|
"Nick",
|
||||||
|
"てぃんてぃんひーろー",
|
||||||
"Gold_miner_ego",
|
"Gold_miner_ego",
|
||||||
"IshouI;_;",
|
"IshouI;_;",
|
||||||
"Monix",
|
"Monix",
|
||||||
@@ -620,17 +642,8 @@
|
|||||||
"you+halo9",
|
"you+halo9",
|
||||||
"cloudghost",
|
"cloudghost",
|
||||||
"Yongkwan+Lee",
|
"Yongkwan+Lee",
|
||||||
"PoorStudent",
|
|
||||||
"lucites",
|
"lucites",
|
||||||
"Alex+Zaw",
|
|
||||||
"Mobius2020",
|
|
||||||
"ExLightSaber",
|
|
||||||
"YaboiRay",
|
|
||||||
"nickname",
|
"nickname",
|
||||||
"Sildoren",
|
|
||||||
"Darv",
|
|
||||||
"Seon+Song",
|
|
||||||
"Somebody",
|
|
||||||
"Balut+Omelette",
|
"Balut+Omelette",
|
||||||
"eriick",
|
"eriick",
|
||||||
"Lev+Lanevskiy",
|
"Lev+Lanevskiy",
|
||||||
@@ -651,37 +664,35 @@
|
|||||||
"Vinarus",
|
"Vinarus",
|
||||||
"Josh Snyder",
|
"Josh Snyder",
|
||||||
"ja s",
|
"ja s",
|
||||||
"Leslie Andrew Ridings",
|
|
||||||
"Doug Mason",
|
"Doug Mason",
|
||||||
"scoreswazey",
|
"scoreswazey",
|
||||||
|
"Oliverfish",
|
||||||
"Owen Gwosdz",
|
"Owen Gwosdz",
|
||||||
"Room Light",
|
"Room Light",
|
||||||
"Patryk Serious",
|
"Patryk Serious",
|
||||||
"AZ Party Oasis",
|
"AZ Party Oasis",
|
||||||
"Devil Lude",
|
"Gentle Sartori",
|
||||||
"Snorklebort",
|
"Snorklebort",
|
||||||
"David Murcko",
|
"David Murcko",
|
||||||
|
"vinter",
|
||||||
"TheFusion",
|
"TheFusion",
|
||||||
"Jack Dole",
|
"Jack Dole",
|
||||||
"matt",
|
|
||||||
"3zS4QNQ4",
|
"3zS4QNQ4",
|
||||||
"Terminuz",
|
|
||||||
"max blo",
|
"max blo",
|
||||||
"Matt M.",
|
"Matt M.",
|
||||||
"Ivan Imes",
|
"Ivan Imes",
|
||||||
"J M",
|
"J M",
|
||||||
|
"Slacks",
|
||||||
"Bouya shaka",
|
"Bouya shaka",
|
||||||
"Jack Lawfield",
|
"Jack Lawfield",
|
||||||
"Borte",
|
|
||||||
"Maso",
|
"Maso",
|
||||||
|
"Homero Banda",
|
||||||
"yyuvuvu",
|
"yyuvuvu",
|
||||||
"Eric Ketchum",
|
|
||||||
"Nomki",
|
"Nomki",
|
||||||
"Kevin Wallace",
|
"Kevin Wallace",
|
||||||
"ChicRic",
|
"ChicRic",
|
||||||
"BastardSama",
|
"BastardSama",
|
||||||
"mercur",
|
"mercur",
|
||||||
"SkibidiRizzler",
|
|
||||||
"Never_M",
|
"Never_M",
|
||||||
"Kalle Björk",
|
"Kalle Björk",
|
||||||
"Yavizu3d",
|
"Yavizu3d",
|
||||||
@@ -689,9 +700,7 @@
|
|||||||
"Teriak47",
|
"Teriak47",
|
||||||
"Just me",
|
"Just me",
|
||||||
"Raf Stahelin",
|
"Raf Stahelin",
|
||||||
"Nacho Ferrando",
|
|
||||||
"Вячеслав Маринин",
|
"Вячеслав Маринин",
|
||||||
"Marcos Tortosa Carmona",
|
|
||||||
"Cola Matthew",
|
"Cola Matthew",
|
||||||
"OniNoKen",
|
"OniNoKen",
|
||||||
"Iain Wisely",
|
"Iain Wisely",
|
||||||
@@ -734,6 +743,15 @@
|
|||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
|
"Brandon+G",
|
||||||
|
"fazefour33",
|
||||||
|
"plonk",
|
||||||
|
"Kotetsu",
|
||||||
|
"o",
|
||||||
|
"Tony+V",
|
||||||
|
"Anvil+G",
|
||||||
|
"draganjankovic1975dj528",
|
||||||
|
"MrSEIGE88",
|
||||||
"yarsev",
|
"yarsev",
|
||||||
"M+Alsulaiti",
|
"M+Alsulaiti",
|
||||||
"Mark+Staaf",
|
"Mark+Staaf",
|
||||||
@@ -751,11 +769,6 @@
|
|||||||
"miduzza",
|
"miduzza",
|
||||||
"KB",
|
"KB",
|
||||||
"shw",
|
"shw",
|
||||||
"Celestial+Kitten",
|
|
||||||
"bakeliteboy",
|
|
||||||
"TequiTequi",
|
|
||||||
"Homero+Banda",
|
|
||||||
"Nick",
|
|
||||||
"Jim",
|
"Jim",
|
||||||
"JoL",
|
"JoL",
|
||||||
"YoruHime",
|
"YoruHime",
|
||||||
@@ -781,14 +794,14 @@
|
|||||||
"han b",
|
"han b",
|
||||||
"Nico",
|
"Nico",
|
||||||
"Maximilian Krischan",
|
"Maximilian Krischan",
|
||||||
"Banana Joe",
|
"socialcat",
|
||||||
"proto merp",
|
"proto merp",
|
||||||
"_ G3n",
|
"_ G3n",
|
||||||
"Brandon Thomas",
|
"Brandon Thomas",
|
||||||
"Donovan Jenkins",
|
"Donovan Jenkins",
|
||||||
"Hans Meier",
|
"Hans Meier",
|
||||||
"Dustin Hendel",
|
"Dustin Hendel",
|
||||||
"sicarius",
|
"jboul",
|
||||||
"Michael Eid",
|
"Michael Eid",
|
||||||
"Liberation",
|
"Liberation",
|
||||||
"Bob barker",
|
"Bob barker",
|
||||||
@@ -805,7 +818,6 @@
|
|||||||
"jumpd",
|
"jumpd",
|
||||||
"John C",
|
"John C",
|
||||||
"Rim",
|
"Rim",
|
||||||
"Oliverfish",
|
|
||||||
"yfx507",
|
"yfx507",
|
||||||
"uruksayshi",
|
"uruksayshi",
|
||||||
"Jairus Knudsen",
|
"Jairus Knudsen",
|
||||||
@@ -814,24 +826,25 @@
|
|||||||
"nk8",
|
"nk8",
|
||||||
"lylepaul",
|
"lylepaul",
|
||||||
"Middo",
|
"Middo",
|
||||||
|
"Gary Chaboya",
|
||||||
"Forbidden Atelier",
|
"Forbidden Atelier",
|
||||||
"Thomas Sankowski",
|
"Thomas Sankowski",
|
||||||
"DrB",
|
"DrB",
|
||||||
"Nimhloth",
|
"Nimhloth",
|
||||||
"Adictedtohumping",
|
"Adictedtohumping",
|
||||||
"Moneymaker412K",
|
"Moneymaker412K",
|
||||||
"vinter",
|
"Tsani Prodanov",
|
||||||
"Towelie",
|
"Towelie",
|
||||||
"Jean-françois SEMA",
|
"Jean-françois SEMA",
|
||||||
|
"Myrthrac",
|
||||||
|
"Taylor Dominy",
|
||||||
"Andrew Ly",
|
"Andrew Ly",
|
||||||
"Slacks",
|
|
||||||
"Glenn Hoetker",
|
"Glenn Hoetker",
|
||||||
"john Greene",
|
"john Greene",
|
||||||
"Faburizu",
|
"Faburizu",
|
||||||
"jimyjomson",
|
"jimyjomson",
|
||||||
"JaeHyun Jang",
|
"JaeHyun Jang",
|
||||||
"Michael Hicks",
|
"Michael Hicks",
|
||||||
"Homero Banda",
|
|
||||||
"Chase Kwon",
|
"Chase Kwon",
|
||||||
"Bob Ling",
|
"Bob Ling",
|
||||||
"Inyoshu",
|
"Inyoshu",
|
||||||
@@ -858,5 +871,5 @@
|
|||||||
"Somebody",
|
"Somebody",
|
||||||
"CK"
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 855
|
"totalCount": 868
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
+2202
-2194
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Preset name...",
|
"presetNamePlaceholder": "Preset name...",
|
||||||
"baseModel": "Base Model",
|
"baseModel": "Base Model",
|
||||||
"baseModelSearchPlaceholder": "Search base models...",
|
"baseModelSearchPlaceholder": "Search base models...",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags",
|
||||||
"modelTypes": "Model Types",
|
"modelTypes": "Model Types",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
||||||
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
||||||
"noTags": "No tags",
|
"noTags": "No tags",
|
||||||
|
"tagSearchPlaceholder": "Search tags...",
|
||||||
|
"noTagMatches": "No tags match the current search.",
|
||||||
"autoTags": "Auto Tags",
|
"autoTags": "Auto Tags",
|
||||||
"noBaseModelMatches": "No base models match the current search.",
|
"noBaseModelMatches": "No base models match the current search.",
|
||||||
"clearAll": "Clear All Filters",
|
"clearAll": "Clear All Filters",
|
||||||
@@ -640,7 +642,13 @@
|
|||||||
"preparing": "Preparing download...",
|
"preparing": "Preparing download...",
|
||||||
"connecting": "Connecting to download server...",
|
"connecting": "Connecting to download server...",
|
||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"downloadComplete": "Download completed successfully"
|
"downloadComplete": "Download completed successfully",
|
||||||
|
"enableCivarchiveApi": "Enable CivArchive API as metadata provider",
|
||||||
|
"enableCivarchiveApiHelp": "When on, CivArchive API is used as a fallback source for model metadata (e.g. for models deleted from CivitAI). Turn off to avoid CivArchive rate limits entirely.",
|
||||||
|
"providerOrder": "Metadata provider fallback order",
|
||||||
|
"providerOrderHelp": "CivitAI API is always tried first. Choose the order of the remaining providers when looking up metadata.",
|
||||||
|
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||||
|
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||||
},
|
},
|
||||||
"proxySettings": {
|
"proxySettings": {
|
||||||
"enableProxy": "Enable App-level Proxy",
|
"enableProxy": "Enable App-level Proxy",
|
||||||
|
|||||||
+2202
-2194
File diff suppressed because it is too large
Load Diff
+2202
-2194
File diff suppressed because it is too large
Load Diff
+2202
-2194
File diff suppressed because it is too large
Load Diff
+2202
-2194
File diff suppressed because it is too large
Load Diff
+2202
-2194
File diff suppressed because it is too large
Load Diff
+2202
-2194
File diff suppressed because it is too large
Load Diff
+2202
-2194
File diff suppressed because it is too large
Load Diff
+2202
-2194
File diff suppressed because it is too large
Load Diff
@@ -1570,7 +1570,11 @@ class SettingsHandler:
|
|||||||
else:
|
else:
|
||||||
self._settings.set(key, value)
|
self._settings.set(key, value)
|
||||||
|
|
||||||
if key == "enable_metadata_archive_db":
|
if key in (
|
||||||
|
"enable_metadata_archive_db",
|
||||||
|
"enable_civarchive_api",
|
||||||
|
"metadata_provider_order",
|
||||||
|
):
|
||||||
await self._metadata_provider_updater()
|
await self._metadata_provider_updater()
|
||||||
|
|
||||||
if key in self._PROXY_KEYS:
|
if key in self._PROXY_KEYS:
|
||||||
|
|||||||
@@ -973,6 +973,8 @@ class ModelQueryHandler:
|
|||||||
limit = int(request.query.get("limit", "20"))
|
limit = int(request.query.get("limit", "20"))
|
||||||
if limit < 0:
|
if limit < 0:
|
||||||
limit = 20
|
limit = 20
|
||||||
|
elif limit > 200:
|
||||||
|
limit = 20
|
||||||
top_tags = await self._service.get_top_tags(limit)
|
top_tags = await self._service.get_top_tags(limit)
|
||||||
return web.json_response({"success": True, "tags": top_tags})
|
return web.json_response({"success": True, "tags": top_tags})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -981,6 +983,22 @@ class ModelQueryHandler:
|
|||||||
{"success": False, "error": "Internal server error"}, status=500
|
{"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:
|
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
limit = int(request.query.get("limit", "20"))
|
limit = int(request.query.get("limit", "20"))
|
||||||
@@ -2947,6 +2965,7 @@ class ModelHandlerSet:
|
|||||||
"bulk_delete_models": self.management.bulk_delete_models,
|
"bulk_delete_models": self.management.bulk_delete_models,
|
||||||
"verify_duplicates": self.management.verify_duplicates,
|
"verify_duplicates": self.management.verify_duplicates,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
|
"search_tags": self.query.search_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"get_base_models": self.query.get_base_models,
|
||||||
"get_model_types": self.query.get_model_types,
|
"get_model_types": self.query.get_model_types,
|
||||||
"scan_models": self.query.scan_models,
|
"scan_models": self.query.scan_models,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class RecipeHandlerSet:
|
|||||||
"save_recipe": self.management.save_recipe,
|
"save_recipe": self.management.save_recipe,
|
||||||
"delete_recipe": self.management.delete_recipe,
|
"delete_recipe": self.management.delete_recipe,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
|
"search_tags": self.query.search_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"get_base_models": self.query.get_base_models,
|
||||||
"get_roots": self.query.get_roots,
|
"get_roots": self.query.get_roots,
|
||||||
"get_folders": self.query.get_folders,
|
"get_folders": self.query.get_folders,
|
||||||
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
|
|||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
limit = int(request.query.get("limit", "20"))
|
limit = int(request.query.get("limit", "20"))
|
||||||
cache = await recipe_scanner.get_cached_data()
|
if limit < 0:
|
||||||
|
limit = 20
|
||||||
tag_counts: Dict[str, int] = {}
|
elif limit > 200:
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
limit = 20
|
||||||
for tag in recipe.get("tags", []) or []:
|
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
||||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
||||||
|
|
||||||
sorted_tags = [
|
sorted_tags = [
|
||||||
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
{"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)
|
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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:
|
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
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"
|
"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}/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}/base-models", "get_base_models"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
|
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("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_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/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/base-models", "get_base_models"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||||
|
|||||||
@@ -804,6 +804,12 @@ class BaseModelService(ABC):
|
|||||||
"""Get top tags sorted by frequency"""
|
"""Get top tags sorted by frequency"""
|
||||||
return await self.scanner.get_top_tags(limit)
|
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]:
|
async def get_base_models(self, limit: int = 20) -> List[Dict]:
|
||||||
"""Get base models sorted by frequency"""
|
"""Get base models sorted by frequency"""
|
||||||
return await self.scanner.get_base_models(limit)
|
return await self.scanner.get_base_models(limit)
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ class CheckpointScanner(ModelScanner):
|
|||||||
and metadata.hash_status == "completed"
|
and metadata.hash_status == "completed"
|
||||||
and metadata.sha256
|
and metadata.sha256
|
||||||
):
|
):
|
||||||
|
# Ensure the in-memory hash index is populated even when
|
||||||
|
# the hash was already computed and persisted to the metadata
|
||||||
|
# file. Without this, usage tracking (and any other caller
|
||||||
|
# that queries get_hash_by_filename first) will miss on every
|
||||||
|
# lookup and keep calling back into this method, creating a
|
||||||
|
# tight loop that never populates the index.
|
||||||
|
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
async with self._hash_calculation_lock:
|
async with self._hash_calculation_lock:
|
||||||
@@ -125,6 +132,7 @@ class CheckpointScanner(ModelScanner):
|
|||||||
and metadata.hash_status == "completed"
|
and metadata.hash_status == "completed"
|
||||||
and metadata.sha256
|
and metadata.sha256
|
||||||
):
|
):
|
||||||
|
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
task = self._hash_calculation_tasks.get(real_path)
|
task = self._hash_calculation_tasks.get(real_path)
|
||||||
@@ -175,6 +183,9 @@ class CheckpointScanner(ModelScanner):
|
|||||||
|
|
||||||
# Check if hash is already calculated
|
# Check if hash is already calculated
|
||||||
if metadata.hash_status == "completed" and metadata.sha256:
|
if metadata.hash_status == "completed" and metadata.sha256:
|
||||||
|
# Populate the in-memory hash index even for pre-computed
|
||||||
|
# hashes, mirroring the fix in calculate_hash_for_model.
|
||||||
|
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
# Update status to calculating
|
# Update status to calculating
|
||||||
@@ -193,6 +204,20 @@ class CheckpointScanner(ModelScanner):
|
|||||||
# Update hash index
|
# Update hash index
|
||||||
self._hash_index.add_entry(sha256.lower(), file_path)
|
self._hash_index.add_entry(sha256.lower(), file_path)
|
||||||
|
|
||||||
|
# Update the in-memory cache entry so that subsequent
|
||||||
|
# _persist_current_cache / _save_persistent_cache calls
|
||||||
|
# write the hash back to the SQLite models table. Without
|
||||||
|
# this the hash only lives in the metadata file and the
|
||||||
|
# in-memory hash index, both of which are lost across
|
||||||
|
# restarts, causing the same re-computation loop on the
|
||||||
|
# next session.
|
||||||
|
if self._cache is not None and self._cache.raw_data:
|
||||||
|
for entry in self._cache.raw_data:
|
||||||
|
if entry.get("file_path") == file_path:
|
||||||
|
entry["sha256"] = sha256.lower()
|
||||||
|
entry["hash_status"] = "completed"
|
||||||
|
break
|
||||||
|
|
||||||
logger.info(f"Hash calculated for checkpoint: {file_path}")
|
logger.info(f"Hash calculated for checkpoint: {file_path}")
|
||||||
return sha256
|
return sha256
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ from .service_registry import ServiceRegistry
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PROVIDER_DISPLAY_NAMES = {
|
||||||
|
"civitai_api": "CivitAI",
|
||||||
|
"civarchive_api": "CivArchive",
|
||||||
|
"sqlite": "Archive DB",
|
||||||
|
}
|
||||||
|
|
||||||
|
_PRESET_PROVIDER_ORDERS = {
|
||||||
|
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
|
||||||
|
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
|
||||||
|
}
|
||||||
|
|
||||||
async def initialize_metadata_providers():
|
async def initialize_metadata_providers():
|
||||||
"""Initialize and configure all metadata providers based on settings"""
|
"""Initialize and configure all metadata providers based on settings"""
|
||||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||||
@@ -26,7 +37,9 @@ async def initialize_metadata_providers():
|
|||||||
# Get settings
|
# Get settings
|
||||||
settings_manager = get_settings_manager()
|
settings_manager = get_settings_manager()
|
||||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||||
|
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||||
|
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||||
|
|
||||||
providers = []
|
providers = []
|
||||||
|
|
||||||
# Initialize archive database provider if enabled
|
# Initialize archive database provider if enabled
|
||||||
@@ -59,27 +72,48 @@ async def initialize_metadata_providers():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
|
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
|
||||||
|
|
||||||
# Register CivArchive provider, and all add to fallback providers
|
# Register CivArchive provider when enabled. Civitai API is always
|
||||||
try:
|
# preferred (better metadata); CivArchive mainly recovers metadata for
|
||||||
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
# models deleted from Civitai, so it can be turned off to avoid its long
|
||||||
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
# rate-limit windows entirely.
|
||||||
provider_manager.register_provider('civarchive_api', civarchive_provider)
|
if enable_civarchive_api:
|
||||||
providers.append(('civarchive_api', civarchive_provider))
|
try:
|
||||||
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
||||||
except Exception as e:
|
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
||||||
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
provider_manager.register_provider('civarchive_api', civarchive_provider)
|
||||||
|
providers.append(('civarchive_api', civarchive_provider))
|
||||||
|
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
||||||
|
else:
|
||||||
|
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
|
||||||
|
|
||||||
|
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
|
||||||
|
# civitai_api is always first (better metadata); the remaining providers
|
||||||
|
# are arranged by the configured preset. Providers that are not
|
||||||
|
# registered (disabled/unavailable) are simply skipped, so each preset
|
||||||
|
# degrades gracefully.
|
||||||
|
desired_order = _PRESET_PROVIDER_ORDERS.get(
|
||||||
|
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
|
||||||
|
)
|
||||||
|
|
||||||
# Set up fallback provider based on available providers
|
# Set up fallback provider based on available providers
|
||||||
if len(providers) > 1:
|
if len(providers) > 1:
|
||||||
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
|
|
||||||
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
|
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
|
||||||
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api'])
|
for name in desired_order:
|
||||||
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api'])
|
ordered_providers.extend([p for p in providers if p[0] == name])
|
||||||
ordered_providers.extend([p for p in providers if p[0] == 'sqlite'])
|
# Include any provider not covered by the preset (defensive) at the end
|
||||||
|
for p in providers:
|
||||||
|
if p not in ordered_providers:
|
||||||
|
ordered_providers.append(p)
|
||||||
|
|
||||||
if ordered_providers:
|
if ordered_providers:
|
||||||
fallback_provider = FallbackMetadataProvider(ordered_providers)
|
fallback_provider = FallbackMetadataProvider(ordered_providers)
|
||||||
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
|
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
|
||||||
|
logger.debug(
|
||||||
|
"Metadata fallback provider order: %s",
|
||||||
|
", ".join(name for name, _ in ordered_providers),
|
||||||
|
)
|
||||||
elif len(providers) == 1:
|
elif len(providers) == 1:
|
||||||
# Only one provider available, set it as default
|
# Only one provider available, set it as default
|
||||||
provider_name, provider = providers[0]
|
provider_name, provider = providers[0]
|
||||||
@@ -96,11 +130,30 @@ async def update_metadata_providers():
|
|||||||
# Get current settings
|
# Get current settings
|
||||||
settings_manager = get_settings_manager()
|
settings_manager = get_settings_manager()
|
||||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||||
|
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||||
|
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||||
|
|
||||||
# Reinitialize all providers with new settings
|
# Reinitialize all providers with new settings
|
||||||
provider_manager = await initialize_metadata_providers()
|
provider_manager = await initialize_metadata_providers()
|
||||||
|
|
||||||
logger.info(f"Updated metadata providers, archive_db enabled: {enable_archive_db}")
|
# Build effective provider chain for logging (use actually-registered
|
||||||
|
# providers, not just settings, so a failed init is reflected correctly)
|
||||||
|
registered = set(provider_manager.providers.keys())
|
||||||
|
desired = _PRESET_PROVIDER_ORDERS.get(
|
||||||
|
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
|
||||||
|
)
|
||||||
|
chain = " → ".join(
|
||||||
|
_PROVIDER_DISPLAY_NAMES[p]
|
||||||
|
for p in desired
|
||||||
|
if p in registered and p in _PROVIDER_DISPLAY_NAMES
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
|
||||||
|
enable_archive_db,
|
||||||
|
enable_civarchive_api,
|
||||||
|
chain,
|
||||||
|
)
|
||||||
return provider_manager
|
return provider_manager
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update metadata providers: {e}")
|
logger.error(f"Failed to update metadata providers: {e}")
|
||||||
|
|||||||
@@ -1830,7 +1830,32 @@ class ModelScanner:
|
|||||||
if limit == 0:
|
if limit == 0:
|
||||||
return sorted_tags
|
return sorted_tags
|
||||||
return sorted_tags[:limit]
|
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]]:
|
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."""
|
"""Get base models sorted by count. If limit is 0, return all."""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Iterable, List, Dict, Optional
|
from typing import Iterable, List, Dict, Optional
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from operator import itemgetter
|
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
|
|
||||||
|
|
||||||
@@ -149,5 +148,10 @@ class RecipeCache:
|
|||||||
)
|
)
|
||||||
if not name_only:
|
if not name_only:
|
||||||
self.sorted_by_date = sorted(
|
self.sorted_by_date = sorted(
|
||||||
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True
|
self.raw_data,
|
||||||
|
key=lambda x: (
|
||||||
|
x.get("modified", x.get("created_date", 0)),
|
||||||
|
x.get("file_path", ""),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -216,11 +216,12 @@ class RecipePersistenceService:
|
|||||||
"preview_nsfw_level",
|
"preview_nsfw_level",
|
||||||
"favorite",
|
"favorite",
|
||||||
"gen_params",
|
"gen_params",
|
||||||
|
"base_model",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not any(key in updates for key in allowed_fields):
|
if not any(key in updates for key in allowed_fields):
|
||||||
raise RecipeValidationError(
|
raise RecipeValidationError(
|
||||||
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)"
|
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params or base_model)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
|
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"onboarding_completed": False,
|
"onboarding_completed": False,
|
||||||
"dismissed_banners": [],
|
"dismissed_banners": [],
|
||||||
"enable_metadata_archive_db": False,
|
"enable_metadata_archive_db": False,
|
||||||
|
"enable_civarchive_api": True,
|
||||||
|
"metadata_provider_order": "civitai_archive_sqlite",
|
||||||
"proxy_enabled": False,
|
"proxy_enabled": False,
|
||||||
"proxy_host": "",
|
"proxy_host": "",
|
||||||
"proxy_port": "",
|
"proxy_port": "",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.7"
|
version = "1.1.8"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -274,6 +274,11 @@
|
|||||||
font-style: italic;
|
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 */
|
/* Ensure solid border and full opacity when active or excluded */
|
||||||
.filter-tag.special-tag.active,
|
.filter-tag.special-tag.active,
|
||||||
.filter-tag.special-tag.exclude {
|
.filter-tag.special-tag.exclude {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function getApiEndpoints(modelType) {
|
|||||||
// Query operations
|
// Query operations
|
||||||
scan: `/api/lm/${modelType}/scan`,
|
scan: `/api/lm/${modelType}/scan`,
|
||||||
topTags: `/api/lm/${modelType}/top-tags`,
|
topTags: `/api/lm/${modelType}/top-tags`,
|
||||||
|
searchTags: `/api/lm/${modelType}/search-tags`,
|
||||||
baseModels: `/api/lm/${modelType}/base-models`,
|
baseModels: `/api/lm/${modelType}/base-models`,
|
||||||
roots: `/api/lm/${modelType}/roots`,
|
roots: `/api/lm/${modelType}/roots`,
|
||||||
folders: `/api/lm/${modelType}/folders`,
|
folders: `/api/lm/${modelType}/folders`,
|
||||||
|
|||||||
@@ -1665,13 +1665,19 @@ export class BulkManager {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isRecipesPage = state.currentPageType === 'recipes';
|
||||||
|
|
||||||
for (const filepath of state.selectedModels) {
|
for (const filepath of state.selectedModels) {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
showToast('toast.api.operationCancelled', {}, 'info');
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
|
if (isRecipesPage) {
|
||||||
|
await updateRecipeMetadata(filepath, { base_model: newBaseModel });
|
||||||
|
} else {
|
||||||
|
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
|
||||||
|
}
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { getApiEndpoints } from '../api/apiConfig.js';
|
||||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
@@ -24,6 +25,12 @@ export class FilterManager {
|
|||||||
this.baseModelOptions = [];
|
this.baseModelOptions = [];
|
||||||
this.tagsLoaded = false;
|
this.tagsLoaded = false;
|
||||||
|
|
||||||
|
// Tag search state
|
||||||
|
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
|
||||||
|
this.tagSearchDebounceTimer = null;
|
||||||
|
this.tagSearchAbortController = null;
|
||||||
|
this.tagSearchQuery = '';
|
||||||
|
|
||||||
// Initialize preset manager
|
// Initialize preset manager
|
||||||
this.presetManager = new FilterPresetManager({
|
this.presetManager = new FilterPresetManager({
|
||||||
page: this.currentPage,
|
page: this.currentPage,
|
||||||
@@ -123,6 +130,60 @@ export class FilterManager {
|
|||||||
this.renderBaseModelTags();
|
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) {
|
getNormalizedSearchQuery(input) {
|
||||||
@@ -146,15 +207,24 @@ export class FilterManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadTopTags() {
|
async loadTopTags() {
|
||||||
|
// Abort any in-flight tag search request
|
||||||
|
if (this.tagSearchAbortController) {
|
||||||
|
this.tagSearchAbortController.abort();
|
||||||
|
this.tagSearchAbortController = null;
|
||||||
|
}
|
||||||
|
this.tagSearchQuery = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
|
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
if (emptyState) emptyState.hidden = true;
|
||||||
|
|
||||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||||
|
|
||||||
// Determine the API endpoint based on the page type
|
// 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);
|
const response = await fetch(tagsEndpoint);
|
||||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||||
@@ -179,29 +249,38 @@ export class FilterManager {
|
|||||||
|
|
||||||
createTagFilterElements(tags) {
|
createTagFilterElements(tags) {
|
||||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||||
|
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
tagsContainer.innerHTML = '';
|
tagsContainer.innerHTML = '';
|
||||||
|
if (emptyState) emptyState.hidden = true;
|
||||||
|
|
||||||
// Collect existing tag names from the API response
|
// Collect existing tag names from the API response
|
||||||
const existingTagNames = new Set(tags.map(t => t.tag));
|
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) {
|
if (this.filters.tags) {
|
||||||
Object.keys(this.filters.tags).forEach(tagName => {
|
Object.keys(this.filters.tags).forEach(tagName => {
|
||||||
// Skip special tags like __no_tags__
|
|
||||||
if (tagName.startsWith('__')) return;
|
if (tagName.startsWith('__')) return;
|
||||||
|
|
||||||
if (!existingTagNames.has(tagName)) {
|
if (!existingTagNames.has(tagName)) {
|
||||||
// Add this tag to the list with count 0 (unknown)
|
missingSelectedTags.push({ tag: tagName, count: 0 });
|
||||||
tags.push({ tag: tagName, count: 0 });
|
|
||||||
existingTagNames.add(tagName);
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +288,10 @@ export class FilterManager {
|
|||||||
const tagEl = document.createElement('div');
|
const tagEl = document.createElement('div');
|
||||||
tagEl.className = 'filter-tag tag-filter';
|
tagEl.className = 'filter-tag tag-filter';
|
||||||
const tagName = tag.tag;
|
const tagName = tag.tag;
|
||||||
|
|
||||||
|
if (missingSelectedTags.some(t => t.tag === tagName)) {
|
||||||
|
tagEl.classList.add('extra-tag');
|
||||||
|
}
|
||||||
tagEl.dataset.tag = tagName;
|
tagEl.dataset.tag = tagName;
|
||||||
|
|
||||||
// Show count only if it's > 0 (known count)
|
// Show count only if it's > 0 (known count)
|
||||||
@@ -234,26 +317,28 @@ export class FilterManager {
|
|||||||
tagsContainer.appendChild(tagEl);
|
tagsContainer.appendChild(tagEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add "No tags" as a special filter at the end
|
// Add "No tags" as a special filter at the end (skip during search)
|
||||||
const noTagsEl = document.createElement('div');
|
if (!this.tagSearchQuery) {
|
||||||
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
const noTagsEl = document.createElement('div');
|
||||||
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||||
const noTagsKey = '__no_tags__';
|
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||||
noTagsEl.dataset.tag = noTagsKey;
|
const noTagsKey = '__no_tags__';
|
||||||
noTagsEl.innerHTML = noTagsLabel;
|
noTagsEl.dataset.tag = noTagsKey;
|
||||||
|
noTagsEl.innerHTML = noTagsLabel;
|
||||||
|
|
||||||
noTagsEl.addEventListener('click', async () => {
|
noTagsEl.addEventListener('click', async () => {
|
||||||
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
||||||
const newState = this.getNextTriStateState(currentState);
|
const newState = this.getNextTriStateState(currentState);
|
||||||
this.setTagFilterState(noTagsKey, newState);
|
this.setTagFilterState(noTagsKey, newState);
|
||||||
this.applyTagElementState(noTagsEl, 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();
|
this.updateTagSelections();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +426,7 @@ export class FilterManager {
|
|||||||
if (!baseModelTagsContainer) return;
|
if (!baseModelTagsContainer) return;
|
||||||
|
|
||||||
// Set the API endpoint based on current page
|
// 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 base models
|
||||||
fetch(apiEndpoint)
|
fetch(apiEndpoint)
|
||||||
@@ -721,6 +806,16 @@ export class FilterManager {
|
|||||||
tagLogic: 'any'
|
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
|
// Update tag logic toggle UI
|
||||||
this.updateTagLogicToggleUI();
|
this.updateTagLogicToggleUI();
|
||||||
|
|
||||||
@@ -731,6 +826,10 @@ export class FilterManager {
|
|||||||
// Update UI
|
// Update UI
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
this.updateActiveFiltersCount();
|
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
|
this.presetManager.renderPresets(); // Re-render to remove active state
|
||||||
|
|
||||||
// Remove from local Storage
|
// Remove from local Storage
|
||||||
|
|||||||
@@ -478,11 +478,9 @@ export class FilterPresetManager {
|
|||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
pageState.filters = this.filterManager.cloneFilters();
|
pageState.filters = this.filterManager.cloneFilters();
|
||||||
|
|
||||||
// If tags haven't been loaded yet, load them first
|
// Refresh tag display so preset's non-top-20 tags appear inline
|
||||||
if (!this.filterManager.tagsLoaded) {
|
await this.filterManager.loadTopTags();
|
||||||
await this.filterManager.loadTopTags();
|
this.filterManager.tagsLoaded = true;
|
||||||
this.filterManager.tagsLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check again after async operation
|
// Check again after async operation
|
||||||
if (requestId !== this.applyPresetRequestId) return;
|
if (requestId !== this.applyPresetRequestId) return;
|
||||||
@@ -745,8 +743,16 @@ export class FilterPresetManager {
|
|||||||
presetEl.classList.add('active');
|
presetEl.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
presetEl.addEventListener('click', (e) => {
|
// Apply preset on click (toggle if already active)
|
||||||
e.stopPropagation();
|
// 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');
|
const presetName = document.createElement('span');
|
||||||
@@ -759,18 +765,6 @@ export class FilterPresetManager {
|
|||||||
deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
|
deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||||
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
|
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
|
// Two-step delete on delete button click
|
||||||
deleteBtn.addEventListener('click', (e) => {
|
deleteBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -2346,6 +2346,16 @@ export class SettingsManager {
|
|||||||
enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false;
|
enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enableCivarchiveApiCheckbox = document.getElementById('enableCivarchiveApi');
|
||||||
|
if (enableCivarchiveApiCheckbox) {
|
||||||
|
enableCivarchiveApiCheckbox.checked = state.global.settings.enable_civarchive_api ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataProviderOrderSelect = document.getElementById('metadataProviderOrder');
|
||||||
|
if (metadataProviderOrderSelect) {
|
||||||
|
metadataProviderOrderSelect.value = state.global.settings.metadata_provider_order || 'civitai_archive_sqlite';
|
||||||
|
}
|
||||||
|
|
||||||
// Load status
|
// Load status
|
||||||
await this.updateMetadataArchiveStatus();
|
await this.updateMetadataArchiveStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
show_only_sfw: false,
|
show_only_sfw: false,
|
||||||
enable_metadata_archive_db: false,
|
enable_metadata_archive_db: false,
|
||||||
|
enable_civarchive_api: true,
|
||||||
|
metadata_provider_order: 'civitai_archive_sqlite',
|
||||||
proxy_enabled: false,
|
proxy_enabled: false,
|
||||||
proxy_type: 'http',
|
proxy_type: 'http',
|
||||||
proxy_host: '',
|
proxy_host: '',
|
||||||
|
|||||||
@@ -134,7 +134,10 @@ export async function copyToClipboard(text, successMessage = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(key, params = {}, type = 'info', fallback = 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');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
toast.textContent = message;
|
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>
|
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="text" id="modelTagsSearchInput" class="filter-search-input"
|
||||||
|
placeholder="{{ t('header.filter.tagSearchPlaceholder') }}" autocomplete="off">
|
||||||
<div class="filter-tags" id="modelTagsFilter">
|
<div class="filter-tags" id="modelTagsFilter">
|
||||||
<!-- Top tags will be dynamically inserted here -->
|
<!-- Top tags will be dynamically inserted here -->
|
||||||
<div class="tags-loading">{{ t('common.status.loading') }}</div>
|
<div class="tags-loading">{{ t('common.status.loading') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modelTagsEmptyState" class="filter-empty-state" hidden>
|
||||||
|
{{ t('header.filter.noTagMatches') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_page == 'loras' or current_page == 'checkpoints' %}
|
{% if current_page == 'loras' or current_page == 'checkpoints' %}
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
|
|||||||
@@ -144,6 +144,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>{{ t('settings.sections.downloads') }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
|
||||||
|
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
|
||||||
|
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
|
||||||
|
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
|
||||||
|
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item" id="aria2PathSetting" style="display: none;">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="text-input-wrapper">
|
||||||
|
<input type="text"
|
||||||
|
id="aria2cPath"
|
||||||
|
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
|
||||||
|
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
|
||||||
|
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- AI Provider Configuration (BYOK) -->
|
<!-- AI Provider Configuration (BYOK) -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
@@ -250,46 +290,6 @@
|
|||||||
{{ provider_models_json | safe }}
|
{{ provider_models_json | safe }}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="settings-subsection">
|
|
||||||
<div class="settings-subsection-header">
|
|
||||||
<h4>{{ t('settings.sections.downloads') }}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
|
|
||||||
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
|
|
||||||
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control select-control">
|
|
||||||
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
|
|
||||||
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
|
|
||||||
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-item" id="aria2PathSetting" style="display: none;">
|
|
||||||
<div class="setting-row">
|
|
||||||
<div class="setting-info">
|
|
||||||
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
|
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
|
|
||||||
</div>
|
|
||||||
<div class="setting-control">
|
|
||||||
<div class="text-input-wrapper">
|
|
||||||
<input type="text"
|
|
||||||
id="aria2cPath"
|
|
||||||
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
|
|
||||||
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
|
|
||||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Backup -->
|
<!-- Backup -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
@@ -1401,7 +1401,26 @@
|
|||||||
<div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div>
|
<div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Metadata Archive -->
|
<!-- CivArchive API provider toggle -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="enableCivarchiveApi">
|
||||||
|
{{ t('settings.metadataArchive.enableCivarchiveApi') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.enableCivarchiveApiHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="enableCivarchiveApi"
|
||||||
|
onchange="settingsManager.saveToggleSetting('enableCivarchiveApi', 'enable_civarchive_api')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata Archive DB -->
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -1419,13 +1438,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="metadata-archive-status" id="metadataArchiveStatus">
|
<div class="metadata-archive-status" id="metadataArchiveStatus">
|
||||||
<!-- Status will be populated by JavaScript -->
|
<!-- Status will be populated by JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -1444,6 +1463,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata provider fallback order -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="metadataProviderOrder">
|
||||||
|
{{ t('settings.metadataArchive.providerOrder') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.providerOrderHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="metadataProviderOrder" onchange="settingsManager.saveSelectSetting('metadataProviderOrder', 'metadata_provider_order')">
|
||||||
|
<option value="civitai_archive_sqlite">{{ t('settings.metadataArchive.providerOrderCivitaiArchiveSqlite') }}</option>
|
||||||
|
<option value="civitai_sqlite_archive">{{ t('settings.metadataArchive.providerOrderCivitaiSqliteArchive') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
|
|||||||
<div id="baseModelEmptyState" hidden></div>
|
<div id="baseModelEmptyState" hidden></div>
|
||||||
<div id="filterPresets" class="filter-presets"></div>
|
<div id="filterPresets" class="filter-presets"></div>
|
||||||
<div id="modelTagsFilter" class="filter-tags"></div>
|
<div id="modelTagsFilter" class="filter-tags"></div>
|
||||||
|
<input id="modelTagsSearchInput" />
|
||||||
|
<div id="modelTagsEmptyState" hidden></div>
|
||||||
<button class="clear-filter"></button>
|
<button class="clear-filter"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
@@ -961,4 +963,198 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
|||||||
expect(stateModule.state.bulkMode).toBe(true);
|
expect(stateModule.state.bulkMode).toBe(true);
|
||||||
expect(pageState.duplicatesMode).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"}))
|
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
|
||||||
|
|
||||||
assert service.received_limit == 20
|
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
|
||||||
|
|||||||
@@ -60,3 +60,114 @@ async def test_get_metadata_provider_returns_fallback_as_is(monkeypatch):
|
|||||||
provider = await metadata_service.get_metadata_provider()
|
provider = await metadata_service.get_metadata_provider()
|
||||||
|
|
||||||
assert provider is fallback
|
assert provider is fallback
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# initialize_metadata_providers — provider gating + fallback ordering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_settings(**overrides):
|
||||||
|
"""Minimal settings stub returning configured values."""
|
||||||
|
base = {
|
||||||
|
"enable_metadata_archive_db": False,
|
||||||
|
"enable_civarchive_api": True,
|
||||||
|
"metadata_provider_order": "civitai_archive_sqlite",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return SimpleNamespace(get=lambda key, default=None: base.get(key, default))
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_initialize(monkeypatch, settings):
|
||||||
|
# Fresh provider manager for each test
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service.ModelMetadataProviderManager,
|
||||||
|
"get_instance",
|
||||||
|
AsyncMock(return_value=metadata_service.ModelMetadataProviderManager()),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service, "get_settings_manager", lambda: settings
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service.ServiceRegistry,
|
||||||
|
"get_civitai_client",
|
||||||
|
AsyncMock(return_value=object()),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service.ServiceRegistry,
|
||||||
|
"get_civarchive_client",
|
||||||
|
AsyncMock(return_value=object()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make MetadataArchiveManager report a usable db path when enabled
|
||||||
|
fake_archive = SimpleNamespace(get_database_path=lambda: "/tmp/fake.db")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_service, "MetadataArchiveManager", lambda _base: fake_archive
|
||||||
|
)
|
||||||
|
# Pretend the db file exists
|
||||||
|
monkeypatch.setattr(metadata_service.os.path, "exists", lambda _p: True)
|
||||||
|
|
||||||
|
manager = await metadata_service.initialize_metadata_providers()
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_provider_order(manager):
|
||||||
|
"""Return the ordered list of provider labels inside the fallback provider."""
|
||||||
|
fallback = manager.providers.get("fallback")
|
||||||
|
assert isinstance(fallback, FallbackMetadataProvider), "expected a fallback provider"
|
||||||
|
return list(fallback._provider_labels)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_default_order(monkeypatch):
|
||||||
|
settings = _stub_settings(enable_metadata_archive_db=True)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
assert _fallback_provider_order(manager) == ["civitai_api", "civarchive_api", "sqlite"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_prefer_sqlite_order(monkeypatch):
|
||||||
|
settings = _stub_settings(
|
||||||
|
enable_metadata_archive_db=True,
|
||||||
|
metadata_provider_order="civitai_sqlite_archive",
|
||||||
|
)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
assert _fallback_provider_order(manager) == ["civitai_api", "sqlite", "civarchive_api"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_disables_civarchive(monkeypatch):
|
||||||
|
settings = _stub_settings(
|
||||||
|
enable_metadata_archive_db=True,
|
||||||
|
enable_civarchive_api=False,
|
||||||
|
)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
# civarchive_api must not be registered at all
|
||||||
|
assert "civarchive_api" not in manager.providers
|
||||||
|
assert _fallback_provider_order(manager) == ["civitai_api", "sqlite"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_skips_unavailable_sqlite_in_preset(monkeypatch):
|
||||||
|
# Preset wants sqlite before civarchive, but archive db is disabled ->
|
||||||
|
# sqlite is unavailable and must be skipped, civarchive stays.
|
||||||
|
settings = _stub_settings(
|
||||||
|
enable_metadata_archive_db=False,
|
||||||
|
metadata_provider_order="civitai_sqlite_archive",
|
||||||
|
)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
assert _fallback_provider_order(manager) == ["civitai_api", "civarchive_api"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_providers_single_provider_when_only_civitai(monkeypatch):
|
||||||
|
# Both archive db and civarchive disabled -> only civitai_api remains,
|
||||||
|
# which takes the single-provider path (registered as default, no fallback).
|
||||||
|
settings = _stub_settings(
|
||||||
|
enable_metadata_archive_db=False,
|
||||||
|
enable_civarchive_api=False,
|
||||||
|
)
|
||||||
|
manager = await _run_initialize(monkeypatch, settings)
|
||||||
|
assert "fallback" not in manager.providers
|
||||||
|
assert manager.default_provider == "civitai_api"
|
||||||
|
|
||||||
|
|||||||
@@ -438,7 +438,9 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
|
|||||||
if (firstEntry) {
|
if (firstEntry) {
|
||||||
const rect = firstEntry.getBoundingClientRect();
|
const rect = firstEntry.getBoundingClientRect();
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`;
|
// Convert GBCR visual offset to container-local space (rect/containerRect are post-scale,
|
||||||
|
// scrollTop is pre-scale), so only the visual-diff portion is divided by scale
|
||||||
|
dropIndicator.style.top = `${(rect.top - containerRect.top) / scale + container.scrollTop - 2}px`;
|
||||||
dropIndicator.style.opacity = '1';
|
dropIndicator.style.opacity = '1';
|
||||||
}
|
}
|
||||||
} else if (targetIndex < entries.length) {
|
} else if (targetIndex < entries.length) {
|
||||||
@@ -447,7 +449,7 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
|
|||||||
if (targetEntry) {
|
if (targetEntry) {
|
||||||
const rect = targetEntry.getBoundingClientRect();
|
const rect = targetEntry.getBoundingClientRect();
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`;
|
dropIndicator.style.top = `${(rect.top - containerRect.top) / scale + container.scrollTop - 2}px`;
|
||||||
dropIndicator.style.opacity = '1';
|
dropIndicator.style.opacity = '1';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -456,7 +458,7 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
|
|||||||
if (lastEntry) {
|
if (lastEntry) {
|
||||||
const rect = lastEntry.getBoundingClientRect();
|
const rect = lastEntry.getBoundingClientRect();
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
dropIndicator.style.top = `${(rect.bottom - containerRect.top + 2) / scale}px`;
|
dropIndicator.style.top = `${(rect.bottom - containerRect.top) / scale + container.scrollTop + 2}px`;
|
||||||
dropIndicator.style.opacity = '1';
|
dropIndicator.style.opacity = '1';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user