mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-10 16:00:14 -03:00
Compare commits
12 Commits
d916375abe
...
v1.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8a95abf7 | ||
|
|
c8e7e543d6 | ||
|
|
a9dbb15ffa | ||
|
|
cf64043f7d | ||
|
|
ccaff92c18 | ||
|
|
585b5c922a | ||
|
|
ea80c2224c | ||
|
|
8b0f56c1a6 | ||
|
|
8022d12f03 | ||
|
|
3939f7f91b | ||
|
|
aebf2e37dd | ||
|
|
f53f859a71 |
@@ -17,6 +17,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.nodes.lora_cycler import LoraCyclerLM
|
||||
from .py.nodes.lora_info import LoraInfoLM
|
||||
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
||||
from .py.nodes.create_hook_lora import CreateHookLoraLM
|
||||
from .py.metadata_collector import init as init_metadata_collector
|
||||
except (
|
||||
ImportError
|
||||
@@ -62,6 +63,9 @@ except (
|
||||
LoraSyntaxToPath = importlib.import_module(
|
||||
"py.nodes.lora_syntax_to_path"
|
||||
).LoraSyntaxToPath
|
||||
CreateHookLoraLM = importlib.import_module(
|
||||
"py.nodes.create_hook_lora"
|
||||
).CreateHookLoraLM
|
||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -83,6 +87,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
LoraCyclerLM.NAME: LoraCyclerLM,
|
||||
LoraInfoLM.NAME: LoraInfoLM,
|
||||
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
@@ -10,13 +10,17 @@
|
||||
"Brennok",
|
||||
"2018cfh",
|
||||
"Insomnia Art Designs",
|
||||
"Rob Williams",
|
||||
"Arlecchino Shion",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"$MetaSamsara",
|
||||
"W+K+White",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Birdy",
|
||||
"Kiba",
|
||||
"onesecondinosaur",
|
||||
"Christian Byrne",
|
||||
@@ -27,22 +31,25 @@
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"Dsperado",
|
||||
"Rosenthal",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"レプサイ",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Edgar Tejeda",
|
||||
"Liam MacDougal",
|
||||
"Polymorphic Indeterminate",
|
||||
@@ -50,9 +57,7 @@
|
||||
"Dogwalkerbr",
|
||||
"Skalabananen",
|
||||
"Marc Whiffen",
|
||||
"Birdy",
|
||||
"itismyelement",
|
||||
"Mozzel",
|
||||
"quarz",
|
||||
"Reno Lam",
|
||||
"jean jahren",
|
||||
@@ -65,40 +70,46 @@
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Nazono_hito",
|
||||
"Nolife_M",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"SG",
|
||||
"bh",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"RedrockVP",
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Steven Pfeiffer",
|
||||
"レプサイ",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"runte3221",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"corde",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"wildnut",
|
||||
@@ -108,53 +119,51 @@
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"Karl P.",
|
||||
"MiraiKuriyamaSy",
|
||||
"otaku fra",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"Takkan",
|
||||
"N/A",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"ElitaSSJ4",
|
||||
"Matt+J",
|
||||
"Jack B Nimble",
|
||||
"Melville Parrish",
|
||||
"Lustre",
|
||||
"bh",
|
||||
"Jwk0205",
|
||||
"Marlon Daniels",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Olive",
|
||||
"greebles",
|
||||
"Some Guy Named Barry",
|
||||
"Resist's Creations - Spicy Edition 🔥",
|
||||
"M Postkasse",
|
||||
"Wolffen",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"DogmaR34",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Christopher Michel",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"Luc Job",
|
||||
"LeoZero",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"nwalker94",
|
||||
"dan",
|
||||
"aai",
|
||||
"Tori",
|
||||
"Mouthlessman",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"Karl P.",
|
||||
"Adam Taylor",
|
||||
"Weird_With_A_Beard",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
@@ -162,7 +171,8 @@
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"Greenmoustache",
|
||||
"DiffDuck",
|
||||
"elu3199",
|
||||
"fancypants",
|
||||
"John+Edwards",
|
||||
"Joboshy",
|
||||
@@ -172,42 +182,39 @@
|
||||
"contrite831",
|
||||
"Dan",
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"Neco28",
|
||||
"David Ortega",
|
||||
"AELOX",
|
||||
"Gooohokrbe",
|
||||
"Dankin-Pics",
|
||||
"Nicfit23",
|
||||
"Cristian Vazquez",
|
||||
"wamekukyouzin",
|
||||
"OldBones",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Christopher Michel",
|
||||
"Zach Gonser",
|
||||
"Serge Bekenkamp",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"nahinahi9",
|
||||
"Bruce",
|
||||
"kushiroK9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"Kevin Christopher",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Paul Kroll",
|
||||
"Penfore",
|
||||
"Bas Imagineer",
|
||||
"John Statham",
|
||||
"Gordon Cole",
|
||||
"AbstractAss",
|
||||
"Dušan Ryban",
|
||||
"decoy",
|
||||
"DiffDuck",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
@@ -222,34 +229,34 @@
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"linnfrey",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Jackthemind",
|
||||
"griffin+dahlberg",
|
||||
"jeaness",
|
||||
"takyamtom",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"yer fey",
|
||||
"sanborondon",
|
||||
"Error_Rule34_Not_found",
|
||||
"aezin",
|
||||
"jcay015",
|
||||
"Erik Lopez",
|
||||
"Roslynd",
|
||||
"Mateo Curić",
|
||||
"Geolog",
|
||||
"Neco28",
|
||||
"Cosmosis",
|
||||
"Eris3D",
|
||||
"David Ortega",
|
||||
"m",
|
||||
"FloPro4Sho",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"Steven Owens",
|
||||
"James Coleman",
|
||||
"Kevin Christopher",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"John Statham",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"yuxz69",
|
||||
"LarsesFPC",
|
||||
@@ -257,8 +264,6 @@
|
||||
"esthe",
|
||||
"AlexDuKaNa",
|
||||
"地獄の禄",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"capn",
|
||||
"Joseph",
|
||||
@@ -272,12 +277,16 @@
|
||||
"Hailshem",
|
||||
"Naomi Hale Danchi",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"IamAyam",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"Robert Wegemund",
|
||||
"sanborondon",
|
||||
"confiscated Zyra",
|
||||
"Littlehuggy",
|
||||
"Andrew Marshall",
|
||||
"Brian Buie",
|
||||
"Taylor Funk",
|
||||
"Thought2Form",
|
||||
"Gerald Welly",
|
||||
@@ -285,15 +294,19 @@
|
||||
"Sadlip",
|
||||
"Tee Gee",
|
||||
"tarek helmi",
|
||||
"Joey Callahan",
|
||||
"Max Marklund",
|
||||
"m",
|
||||
"Mike Simone",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
"Pronredn",
|
||||
"Mikko Hemilä",
|
||||
"Jamie Ogletree",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"lh qwe",
|
||||
"Martial",
|
||||
"conner",
|
||||
@@ -305,24 +318,21 @@
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Rops Alot",
|
||||
"Sam",
|
||||
"Ace Ventura",
|
||||
"四糸凜音",
|
||||
"Xeeosat",
|
||||
"Douglas Gaspar",
|
||||
"Saya",
|
||||
"George",
|
||||
"dw",
|
||||
"FrxzenSnxw",
|
||||
"WRL_SPR",
|
||||
"momokai",
|
||||
"몽타주",
|
||||
"kudari",
|
||||
"ken",
|
||||
"Crocket",
|
||||
"Joshua Porrata",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
@@ -337,37 +347,36 @@
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"OrganicArtifact",
|
||||
"ResidentDeviant",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"gzmzmvp",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Littlehuggy",
|
||||
"Aberr",
|
||||
"Gregory Kozhemiak",
|
||||
"준희 김",
|
||||
"Brian Buie",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Tomohiro Baba",
|
||||
"Mike Simone",
|
||||
"Noora",
|
||||
"John J Linehan",
|
||||
"Mattssn",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Artokun",
|
||||
"hexxish",
|
||||
"Derek Baker",
|
||||
"Steam Steam",
|
||||
"NICHOLAS BAXLEY",
|
||||
"CryptoTraderJK",
|
||||
@@ -378,23 +387,14 @@
|
||||
"Fotek Design",
|
||||
"Nihongasuki",
|
||||
"MadSpin",
|
||||
"FrxzenSnxw",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"starbugx",
|
||||
"dc7431",
|
||||
"ResidentDeviant",
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Vir",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"g9p0o",
|
||||
"Pkrsky",
|
||||
@@ -403,6 +403,8 @@
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"DarkRoast",
|
||||
"Nasty+Hobbit",
|
||||
"letzte",
|
||||
@@ -414,12 +416,15 @@
|
||||
"David Schenck",
|
||||
"Wolfe7D1",
|
||||
"Draven T",
|
||||
"Time Valentine",
|
||||
"elleshar666",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Aquatic Coffee",
|
||||
"Kauffy",
|
||||
"ethanfel",
|
||||
"SPJ",
|
||||
"Focuschannel",
|
||||
"Edward Kennedy",
|
||||
"Nick Kage",
|
||||
@@ -432,12 +437,13 @@
|
||||
"notedfakes",
|
||||
"Michael Scott",
|
||||
"Pat Hen",
|
||||
"Saya",
|
||||
"Solixer",
|
||||
"Jordan Shaw",
|
||||
"Wes Sims",
|
||||
"Donor4115",
|
||||
"g unit",
|
||||
"Jimmy Borup",
|
||||
"Manu Thetug",
|
||||
"Filippo Ferrari",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
@@ -451,6 +457,11 @@
|
||||
"SomeDude",
|
||||
"nanana",
|
||||
"raf8osz",
|
||||
"Bob+Barker",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Alex",
|
||||
"Karru",
|
||||
"ChaChanoKo",
|
||||
"redcarrot",
|
||||
@@ -467,36 +478,34 @@
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"cocona",
|
||||
"blikkies",
|
||||
"JBsuede",
|
||||
"Time Valentine",
|
||||
"Shock Shockor",
|
||||
"りん あめ",
|
||||
"Matt",
|
||||
"Goldwaters",
|
||||
"Zude",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Sean voets",
|
||||
"Kyler",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Bryan Rutkowski",
|
||||
"Justin Blaylock",
|
||||
"aRtFuL_DodGeR",
|
||||
"Steven",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Edward Ten Eyck",
|
||||
"Billy Gladky",
|
||||
"Probis",
|
||||
"Solixer",
|
||||
"Pete Pain",
|
||||
"ItsGeneralButtNaked",
|
||||
"RHopkirk",
|
||||
"jinksta187",
|
||||
"robin.kok.",
|
||||
"Manu Thetug",
|
||||
"Maxim",
|
||||
"Karlanx",
|
||||
"Lyavph",
|
||||
@@ -504,6 +513,7 @@
|
||||
"Youguang",
|
||||
"andrewzpong",
|
||||
"BossGame",
|
||||
"Marcus thronico",
|
||||
"lrdchs",
|
||||
"Tree Tagger",
|
||||
"Inversity",
|
||||
@@ -511,6 +521,15 @@
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"POPPIN",
|
||||
"PoorStudent",
|
||||
"Alex+Zaw",
|
||||
"Supporter",
|
||||
"ExLightSaber",
|
||||
"Mobius2020",
|
||||
"YaboiRay",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
"2turbo",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"tanjin90",
|
||||
@@ -528,11 +547,6 @@
|
||||
"Inkognito",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"Bob+Barker",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Eldithor",
|
||||
"Alex",
|
||||
"BillyBoy84",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
@@ -545,28 +559,27 @@
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"Christian Schäfer",
|
||||
"Leslie Andrew Ridings",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"Sean voets",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Kyron Mahan",
|
||||
"Mythspire",
|
||||
"Boba Smith",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"MR.Bear",
|
||||
"matt",
|
||||
"somethingtosay8",
|
||||
"Ezokewn",
|
||||
"Terminuz",
|
||||
"ivistorm",
|
||||
"SendingRavens",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"JackJohnnyJim",
|
||||
"Khánh Đặng",
|
||||
"Borte",
|
||||
"Michael Docherty",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
@@ -574,6 +587,7 @@
|
||||
"Paul Hartsuyker",
|
||||
"elitassj",
|
||||
"Tigon",
|
||||
"SkibidiRizzler",
|
||||
"Tania Nayelli Fernandez",
|
||||
"Draconach",
|
||||
"Jacob Winter",
|
||||
@@ -581,6 +595,8 @@
|
||||
"Andrew Wilkinson",
|
||||
"David",
|
||||
"Meilo",
|
||||
"Nacho Ferrando",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Dkom22",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
@@ -589,7 +605,6 @@
|
||||
"xybrightsummer",
|
||||
"jreedatchison",
|
||||
"PhilW",
|
||||
"Marcus thronico",
|
||||
"Janik",
|
||||
"Cruel",
|
||||
"MRBlack",
|
||||
@@ -601,6 +616,13 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"Somebody",
|
||||
"Celestial+Kitten",
|
||||
"TequiTequi",
|
||||
"Homero+Banda",
|
||||
"bakeliteboy",
|
||||
"Nick",
|
||||
"てぃんてぃんひーろー",
|
||||
"Gold_miner_ego",
|
||||
"IshouI;_;",
|
||||
"Monix",
|
||||
@@ -620,17 +642,8 @@
|
||||
"you+halo9",
|
||||
"cloudghost",
|
||||
"Yongkwan+Lee",
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"nickname",
|
||||
"Sildoren",
|
||||
"Darv",
|
||||
"Seon+Song",
|
||||
"Somebody",
|
||||
"Balut+Omelette",
|
||||
"eriick",
|
||||
"Lev+Lanevskiy",
|
||||
@@ -651,37 +664,35 @@
|
||||
"Vinarus",
|
||||
"Josh Snyder",
|
||||
"ja s",
|
||||
"Leslie Andrew Ridings",
|
||||
"Doug Mason",
|
||||
"scoreswazey",
|
||||
"Oliverfish",
|
||||
"Owen Gwosdz",
|
||||
"Room Light",
|
||||
"Patryk Serious",
|
||||
"AZ Party Oasis",
|
||||
"Devil Lude",
|
||||
"Gentle Sartori",
|
||||
"Snorklebort",
|
||||
"David Murcko",
|
||||
"vinter",
|
||||
"TheFusion",
|
||||
"Jack Dole",
|
||||
"matt",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"max blo",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Slacks",
|
||||
"Bouya shaka",
|
||||
"Jack Lawfield",
|
||||
"Borte",
|
||||
"Maso",
|
||||
"Homero Banda",
|
||||
"yyuvuvu",
|
||||
"Eric Ketchum",
|
||||
"Nomki",
|
||||
"Kevin Wallace",
|
||||
"ChicRic",
|
||||
"BastardSama",
|
||||
"mercur",
|
||||
"SkibidiRizzler",
|
||||
"Never_M",
|
||||
"Kalle Björk",
|
||||
"Yavizu3d",
|
||||
@@ -689,9 +700,7 @@
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
"Nacho Ferrando",
|
||||
"Вячеслав Маринин",
|
||||
"Marcos Tortosa Carmona",
|
||||
"Cola Matthew",
|
||||
"OniNoKen",
|
||||
"Iain Wisely",
|
||||
@@ -734,6 +743,15 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"Brandon+G",
|
||||
"fazefour33",
|
||||
"plonk",
|
||||
"Kotetsu",
|
||||
"o",
|
||||
"Tony+V",
|
||||
"Anvil+G",
|
||||
"draganjankovic1975dj528",
|
||||
"MrSEIGE88",
|
||||
"yarsev",
|
||||
"M+Alsulaiti",
|
||||
"Mark+Staaf",
|
||||
@@ -751,11 +769,6 @@
|
||||
"miduzza",
|
||||
"KB",
|
||||
"shw",
|
||||
"Celestial+Kitten",
|
||||
"bakeliteboy",
|
||||
"TequiTequi",
|
||||
"Homero+Banda",
|
||||
"Nick",
|
||||
"Jim",
|
||||
"JoL",
|
||||
"YoruHime",
|
||||
@@ -781,14 +794,14 @@
|
||||
"han b",
|
||||
"Nico",
|
||||
"Maximilian Krischan",
|
||||
"Banana Joe",
|
||||
"socialcat",
|
||||
"proto merp",
|
||||
"_ G3n",
|
||||
"Brandon Thomas",
|
||||
"Donovan Jenkins",
|
||||
"Hans Meier",
|
||||
"Dustin Hendel",
|
||||
"sicarius",
|
||||
"jboul",
|
||||
"Michael Eid",
|
||||
"Liberation",
|
||||
"Bob barker",
|
||||
@@ -805,7 +818,6 @@
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"Oliverfish",
|
||||
"yfx507",
|
||||
"uruksayshi",
|
||||
"Jairus Knudsen",
|
||||
@@ -814,24 +826,25 @@
|
||||
"nk8",
|
||||
"lylepaul",
|
||||
"Middo",
|
||||
"Gary Chaboya",
|
||||
"Forbidden Atelier",
|
||||
"Thomas Sankowski",
|
||||
"DrB",
|
||||
"Nimhloth",
|
||||
"Adictedtohumping",
|
||||
"Moneymaker412K",
|
||||
"vinter",
|
||||
"Tsani Prodanov",
|
||||
"Towelie",
|
||||
"Jean-françois SEMA",
|
||||
"Myrthrac",
|
||||
"Taylor Dominy",
|
||||
"Andrew Ly",
|
||||
"Slacks",
|
||||
"Glenn Hoetker",
|
||||
"john Greene",
|
||||
"Faburizu",
|
||||
"jimyjomson",
|
||||
"JaeHyun Jang",
|
||||
"Michael Hicks",
|
||||
"Homero Banda",
|
||||
"Chase Kwon",
|
||||
"Bob Ling",
|
||||
"Inyoshu",
|
||||
@@ -858,5 +871,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 855
|
||||
"totalCount": 868
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||
"baseModel": "Basis-Modell",
|
||||
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTags": "Tags",
|
||||
"modelTypes": "Modelltypen",
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||
"noTags": "Keine Tags",
|
||||
"tagSearchPlaceholder": "Tags durchsuchen...",
|
||||
"noTagMatches": "Keine Tags entsprechen der aktuellen Suche.",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||
"clearAll": "Alle Filter löschen",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "Wiederholen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Preset name...",
|
||||
"baseModel": "Base Model",
|
||||
"baseModelSearchPlaceholder": "Search base models...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTags": "Tags",
|
||||
"modelTypes": "Model Types",
|
||||
"license": "License",
|
||||
"noCreditRequired": "No Credit Required",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
||||
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
||||
"noTags": "No tags",
|
||||
"tagSearchPlaceholder": "Search tags...",
|
||||
"noTagMatches": "No tags match the current search.",
|
||||
"autoTags": "Auto Tags",
|
||||
"noBaseModelMatches": "No base models match the current search.",
|
||||
"clearAll": "Clear All Filters",
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||
"baseModel": "Modelo base",
|
||||
"baseModelSearchPlaceholder": "Buscar modelos base...",
|
||||
"modelTags": "Etiquetas (Top 20)",
|
||||
"modelTags": "Etiquetas",
|
||||
"modelTypes": "Tipos de modelos",
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||
"noTags": "Sin etiquetas",
|
||||
"tagSearchPlaceholder": "Buscar etiquetas...",
|
||||
"noTagMatches": "Ninguna etiqueta coincide con la búsqueda actual.",
|
||||
"autoTags": "Etiquetas automáticas",
|
||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||
"clearAll": "Limpiar todos los filtros",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "Reintentar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Nom du préréglage...",
|
||||
"baseModel": "Modèle de base",
|
||||
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTags": "Tags",
|
||||
"modelTypes": "Types de modèles",
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
||||
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
||||
"noTags": "Aucun tag",
|
||||
"tagSearchPlaceholder": "Rechercher des tags...",
|
||||
"noTagMatches": "Aucun tag ne correspond à la recherche actuelle.",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||
"clearAll": "Effacer tous les filtres",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "Réessayer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||
"baseModel": "מודל בסיס",
|
||||
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
|
||||
"modelTags": "תגיות (20 המובילות)",
|
||||
"modelTags": "תגיות",
|
||||
"modelTypes": "סוגי מודלים",
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||
"noTags": "ללא תגיות",
|
||||
"tagSearchPlaceholder": "חיפוש תגיות...",
|
||||
"noTagMatches": "אין תגיות שתואמות את החיפוש הנוכחי.",
|
||||
"autoTags": "תגיות אוטומטיות",
|
||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||
"clearAll": "נקה את כל המסננים",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "נסה שוב"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "プリセット名...",
|
||||
"baseModel": "ベースモデル",
|
||||
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
||||
"modelTags": "タグ(上位20)",
|
||||
"modelTags": "タグ",
|
||||
"modelTypes": "モデルタイプ",
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||
"noTags": "タグなし",
|
||||
"tagSearchPlaceholder": "タグを検索...",
|
||||
"noTagMatches": "現在の検索に一致するタグはありません。",
|
||||
"autoTags": "自動タグ",
|
||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||
"clearAll": "すべてのフィルタをクリア",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "再試行"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "프리셋 이름...",
|
||||
"baseModel": "베이스 모델",
|
||||
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
||||
"modelTags": "태그 (상위 20개)",
|
||||
"modelTags": "태그",
|
||||
"modelTypes": "모델 유형",
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||
"noTags": "태그 없음",
|
||||
"tagSearchPlaceholder": "태그 검색...",
|
||||
"noTagMatches": "현재 검색과 일치하는 태그가 없습니다.",
|
||||
"autoTags": "자동 태그",
|
||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||
"clearAll": "모든 필터 지우기",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "다시 시도"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "Имя пресета...",
|
||||
"baseModel": "Базовая модель",
|
||||
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
|
||||
"modelTags": "Теги (Топ 20)",
|
||||
"modelTags": "Теги",
|
||||
"modelTypes": "Типы моделей",
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||
"noTags": "Без тегов",
|
||||
"tagSearchPlaceholder": "Поиск тегов...",
|
||||
"noTagMatches": "Нет тегов, соответствующих текущему поиску.",
|
||||
"autoTags": "Авто-теги",
|
||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||
"clearAll": "Очистить все фильтры",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "Повторить"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "预设名称...",
|
||||
"baseModel": "基础模型",
|
||||
"baseModelSearchPlaceholder": "搜索基础模型...",
|
||||
"modelTags": "标签(前20)",
|
||||
"modelTags": "标签",
|
||||
"modelTypes": "模型类型",
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||
"noTags": "无标签",
|
||||
"tagSearchPlaceholder": "搜索标签...",
|
||||
"noTagMatches": "没有匹配当前搜索的标签。",
|
||||
"autoTags": "自动标签",
|
||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||
"clearAll": "清除所有筛选",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
"presetNamePlaceholder": "預設名稱...",
|
||||
"baseModel": "基礎模型",
|
||||
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
||||
"modelTags": "標籤(前 20)",
|
||||
"modelTags": "標籤",
|
||||
"modelTypes": "模型類型",
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
@@ -241,6 +241,8 @@
|
||||
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||
"noTags": "無標籤",
|
||||
"tagSearchPlaceholder": "搜尋標籤...",
|
||||
"noTagMatches": "沒有符合目前搜尋的標籤。",
|
||||
"autoTags": "自動標籤",
|
||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||
"clearAll": "清除所有篩選",
|
||||
@@ -2232,4 +2234,4 @@
|
||||
"retry": "重試"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
117
py/nodes/create_hook_lora.py
Normal file
117
py/nodes/create_hook_lora.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Create Hook LoRA (LoraManager) — multi-LoRA hook node compatible with ComfyUI's built-in hook pipeline.
|
||||
|
||||
Produces ``("HOOKS",)`` output that chains seamlessly with downstream hook consumers
|
||||
(ConditioningSetProperties, SetHookKeyframes, CombineHooks, SetClipHooks, etc.).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..utils.utils import get_lora_info_absolute
|
||||
from .utils import (
|
||||
FlexibleOptionalInputType,
|
||||
any_type,
|
||||
apply_lora_syntax_format,
|
||||
get_loras_list,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateHookLoraLM:
|
||||
NAME = "Create Hook LoRA (LoraManager)"
|
||||
CATEGORY = "Lora Manager/hooks"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
"AUTOCOMPLETE_TEXT_LORAS",
|
||||
{
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": (
|
||||
"Search and select LoRAs. Each LoRA gets its own "
|
||||
"model/clip strength. Hooks chain with prev_hooks."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
||||
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||
FUNCTION = "create_hook"
|
||||
|
||||
def create_hook(self, text: str, **kwargs):
|
||||
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
|
||||
|
||||
Each active LoRA from the widget is loaded and wrapped in a WeightHook
|
||||
via :func:`comfy.hooks.create_hook_lora`. All hooks are combined into a
|
||||
single group and returned alongside trigger words and a human-readable
|
||||
summary of the active LoRAs.
|
||||
"""
|
||||
del text # used by the frontend widget only
|
||||
|
||||
# Lazy imports: comfy is not available in CI/test environment at module level
|
||||
import comfy.hooks # type: ignore # noqa: C0415
|
||||
import comfy.utils # type: ignore # noqa: C0415
|
||||
|
||||
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
|
||||
|
||||
hook_group = prev_hooks.clone() if prev_hooks is not None else comfy.hooks.HookGroup()
|
||||
|
||||
all_trigger_words: list[str] = []
|
||||
active_loras: list[tuple[str, float, float]] = []
|
||||
|
||||
for lora in get_loras_list(kwargs):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
|
||||
lora_name = apply_lora_syntax_format(lora["name"])
|
||||
model_strength = float(lora["strength"])
|
||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||
|
||||
# Skip useless no-op entries (both strengths are zero)
|
||||
if model_strength == 0.0 and clip_strength == 0.0:
|
||||
continue
|
||||
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
if not lora_path or not os.path.isfile(lora_path):
|
||||
logger.warning("LoRA '%s' not found — skipping", lora_name)
|
||||
continue
|
||||
|
||||
try:
|
||||
lora_weights = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
|
||||
lora_hooks = comfy.hooks.create_hook_lora(
|
||||
lora=lora_weights,
|
||||
strength_model=model_strength,
|
||||
strength_clip=clip_strength,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to load LoRA '%s' — skipping", lora_name)
|
||||
continue
|
||||
hook_group = hook_group.clone_and_combine(lora_hooks)
|
||||
|
||||
active_loras.append((lora_name, model_strength, clip_strength))
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# Format trigger words (group mode separator)
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
# Format active LoRAs summary
|
||||
formatted_loras = []
|
||||
for name, model_s, clip_s in active_loras:
|
||||
if abs(model_s - clip_s) > 0.001:
|
||||
formatted_loras.append(
|
||||
f"<lora:{name}:{model_s}:{clip_s}>"
|
||||
)
|
||||
else:
|
||||
formatted_loras.append(f"<lora:{name}:{model_s}>")
|
||||
active_loras_text = " ".join(formatted_loras)
|
||||
|
||||
return (hook_group, trigger_words_text, active_loras_text)
|
||||
@@ -973,6 +973,8 @@ class ModelQueryHandler:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
top_tags = await self._service.get_top_tags(limit)
|
||||
return web.json_response({"success": True, "tags": top_tags})
|
||||
except Exception as exc:
|
||||
@@ -981,6 +983,22 @@ class ModelQueryHandler:
|
||||
{"success": False, "error": "Internal server error"}, status=500
|
||||
)
|
||||
|
||||
async def search_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
query = request.query.get("q", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
tags = await self._service.search_tags(query, limit)
|
||||
return web.json_response({"success": True, "tags": tags})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error searching tags: %s", exc, exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Internal server error"}, status=500
|
||||
)
|
||||
|
||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
@@ -2947,6 +2965,7 @@ class ModelHandlerSet:
|
||||
"bulk_delete_models": self.management.bulk_delete_models,
|
||||
"verify_duplicates": self.management.verify_duplicates,
|
||||
"get_top_tags": self.query.get_top_tags,
|
||||
"search_tags": self.query.search_tags,
|
||||
"get_base_models": self.query.get_base_models,
|
||||
"get_model_types": self.query.get_model_types,
|
||||
"scan_models": self.query.scan_models,
|
||||
|
||||
@@ -72,6 +72,7 @@ class RecipeHandlerSet:
|
||||
"save_recipe": self.management.save_recipe,
|
||||
"delete_recipe": self.management.delete_recipe,
|
||||
"get_top_tags": self.query.get_top_tags,
|
||||
"search_tags": self.query.search_tags,
|
||||
"get_base_models": self.query.get_base_models,
|
||||
"get_roots": self.query.get_roots,
|
||||
"get_folders": self.query.get_folders,
|
||||
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
|
||||
tag_counts: Dict[str, int] = {}
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
for tag in recipe.get("tags", []) or []:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
||||
|
||||
sorted_tags = [
|
||||
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
||||
@@ -333,6 +333,55 @@ class RecipeQueryHandler:
|
||||
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def search_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
query = request.query.get("q", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
if limit < 0:
|
||||
limit = 20
|
||||
elif limit > 200:
|
||||
limit = 20
|
||||
|
||||
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
|
||||
normalized_query = (query or "").strip().lower()
|
||||
if not normalized_query:
|
||||
sorted_tags = [
|
||||
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
||||
]
|
||||
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
return web.json_response(
|
||||
{"success": True, "tags": sorted_tags[: (limit if limit > 0 else 20)]}
|
||||
)
|
||||
|
||||
matched = [
|
||||
{"tag": tag, "count": count}
|
||||
for tag, count in tag_counts.items()
|
||||
if normalized_query in tag.lower()
|
||||
]
|
||||
matched.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
if limit == 0:
|
||||
result = matched
|
||||
else:
|
||||
result = matched[:limit]
|
||||
return web.json_response({"success": True, "tags": result})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error searching recipe tags: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def _get_recipe_tag_counts(self, recipe_scanner) -> Dict[str, int]:
|
||||
"""Compute tag->count mapping from cached recipe data."""
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
tag_counts: Dict[str, int] = {}
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
for tag in recipe.get("tags", []) or []:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
return tag_counts
|
||||
|
||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
|
||||
@@ -46,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
|
||||
|
||||
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/search-tags", "search_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||
|
||||
@@ -804,6 +804,12 @@ class BaseModelService(ABC):
|
||||
"""Get top tags sorted by frequency"""
|
||||
return await self.scanner.get_top_tags(limit)
|
||||
|
||||
async def search_tags(
|
||||
self, query: str, limit: int = 50
|
||||
) -> List[Dict]:
|
||||
"""Search tags by substring, sorted by frequency"""
|
||||
return await self.scanner.search_tags(query, limit)
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict]:
|
||||
"""Get base models sorted by frequency"""
|
||||
return await self.scanner.get_base_models(limit)
|
||||
|
||||
@@ -1389,7 +1389,17 @@ class DownloadManager:
|
||||
|
||||
# Update save directory with relative path if provided
|
||||
if relative_path:
|
||||
base_save_dir = save_dir
|
||||
save_dir = os.path.join(save_dir, relative_path)
|
||||
# Security: validate path containment after joining
|
||||
resolved_dir = os.path.realpath(os.path.normpath(save_dir))
|
||||
base_dir = os.path.realpath(os.path.normpath(base_save_dir))
|
||||
if not resolved_dir.startswith(base_dir + os.sep) and resolved_dir != base_dir:
|
||||
logger.warning(
|
||||
"Path traversal detected: %s escapes %s",
|
||||
resolved_dir, base_dir,
|
||||
)
|
||||
return {"success": False, "error": "Download path is outside allowed directory"}
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
@@ -1827,6 +1837,9 @@ class DownloadManager:
|
||||
model_tags, model_type
|
||||
)
|
||||
|
||||
if not first_tag:
|
||||
first_tag = "no tags" # Default if no tags available
|
||||
|
||||
# Format the template with available data
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||
@@ -1842,6 +1855,15 @@ class DownloadManager:
|
||||
if model_type == "embedding":
|
||||
formatted_path = formatted_path.replace(" ", "_")
|
||||
|
||||
# Sanitize the resolved path to prevent path traversal:
|
||||
# - Strip leading slashes (prevents os.path.join from treating path as absolute)
|
||||
# - Collapse double slashes from empty placeholder substitutions
|
||||
# - Strip trailing slashes for cleanliness
|
||||
formatted_path = formatted_path.lstrip("/")
|
||||
while "//" in formatted_path:
|
||||
formatted_path = formatted_path.replace("//", "/")
|
||||
formatted_path = formatted_path.rstrip("/")
|
||||
|
||||
return formatted_path
|
||||
|
||||
async def _execute_download(
|
||||
|
||||
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -493,6 +494,9 @@ class ModelMoveService:
|
||||
Dictionary with move result
|
||||
"""
|
||||
try:
|
||||
_require_path_in_library_roots(file_path, self.scanner, label="Source path")
|
||||
_require_path_in_library_roots(target_path, self.scanner, label="Target path")
|
||||
|
||||
if use_default_paths:
|
||||
# Find the model in cache to get metadata
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
@@ -48,6 +48,35 @@ async def delete_model_artifacts(
|
||||
return deleted
|
||||
|
||||
|
||||
def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "path") -> None:
|
||||
"""Raise ``ValueError`` if *file_path* is not inside a configured model root.
|
||||
|
||||
Uses ``os.path.realpath()`` to resolve symlinks before comparing,
|
||||
so symlink-based escapes are also caught. Skips when the scanner
|
||||
does not expose ``get_model_roots`` or the list is empty.
|
||||
"""
|
||||
|
||||
roots = None
|
||||
if hasattr(scanner, "get_model_roots"):
|
||||
try:
|
||||
roots = scanner.get_model_roots()
|
||||
except NotImplementedError:
|
||||
roots = None
|
||||
if not roots:
|
||||
return
|
||||
|
||||
resolved = os.path.realpath(os.path.normpath(file_path))
|
||||
|
||||
for root in roots:
|
||||
root_resolved = os.path.realpath(os.path.normpath(root))
|
||||
if resolved == root_resolved or resolved.startswith(root_resolved + os.sep):
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"{label} '{file_path}' is outside configured library directories"
|
||||
)
|
||||
|
||||
|
||||
class ModelLifecycleService:
|
||||
"""Co-ordinate destructive and mutating model operations."""
|
||||
|
||||
@@ -74,6 +103,8 @@ class ModelLifecycleService:
|
||||
if not file_path:
|
||||
raise ValueError("Model path is required")
|
||||
|
||||
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||
|
||||
cache = await self._scanner.get_cached_data()
|
||||
|
||||
cached_entry = None
|
||||
@@ -182,6 +213,8 @@ class ModelLifecycleService:
|
||||
if not file_path:
|
||||
raise ValueError("Model path is required")
|
||||
|
||||
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
metadata["exclude"] = True
|
||||
@@ -229,6 +262,8 @@ class ModelLifecycleService:
|
||||
if not file_path:
|
||||
raise ValueError("Model path is required")
|
||||
|
||||
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise ValueError("Model file does not exist")
|
||||
|
||||
@@ -270,6 +305,9 @@ class ModelLifecycleService:
|
||||
if not file_paths:
|
||||
raise ValueError("No file paths provided for deletion")
|
||||
|
||||
for path in file_paths:
|
||||
_require_path_in_library_roots(path, self._scanner, label="File path")
|
||||
|
||||
return await self._scanner.bulk_delete_models(file_paths)
|
||||
|
||||
async def rename_model(
|
||||
@@ -280,6 +318,8 @@ class ModelLifecycleService:
|
||||
if not file_path or not new_file_name:
|
||||
raise ValueError("File path and new file name are required")
|
||||
|
||||
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||
|
||||
invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
|
||||
if any(char in new_file_name for char in invalid_chars):
|
||||
raise ValueError("Invalid characters in file name")
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
from .model_lifecycle_service import delete_model_artifacts
|
||||
from .model_lifecycle_service import delete_model_artifacts, _require_path_in_library_roots
|
||||
from .service_registry import ServiceRegistry
|
||||
from .websocket_manager import ws_manager
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
@@ -1394,6 +1394,9 @@ class ModelScanner:
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
||||
source_dir = os.path.dirname(source_path)
|
||||
|
||||
_require_path_in_library_roots(source_path, self, label="Source path")
|
||||
_require_path_in_library_roots(target_path, self, label="Target path")
|
||||
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
@@ -1830,7 +1833,32 @@ class ModelScanner:
|
||||
if limit == 0:
|
||||
return sorted_tags
|
||||
return sorted_tags[:limit]
|
||||
|
||||
|
||||
async def search_tags(
|
||||
self, query: str, limit: int = 50
|
||||
) -> List[Dict[str, any]]:
|
||||
"""Search tags by case-insensitive substring match, sorted by count.
|
||||
|
||||
If query is empty, behaves like get_top_tags (returns top ``limit``
|
||||
tags). If limit is 0, all matching tags are returned.
|
||||
"""
|
||||
await self.get_cached_data()
|
||||
|
||||
normalized_query = (query or "").strip().lower()
|
||||
if not normalized_query:
|
||||
return await self.get_top_tags(limit if limit > 0 else 20)
|
||||
|
||||
matched = [
|
||||
{"tag": tag, "count": count}
|
||||
for tag, count in self._tags_count.items()
|
||||
if normalized_query in tag.lower()
|
||||
]
|
||||
matched.sort(key=lambda x: x["count"], reverse=True)
|
||||
|
||||
if limit == 0:
|
||||
return matched
|
||||
return matched[:limit]
|
||||
|
||||
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
"""Get base models sorted by count. If limit is 0, return all."""
|
||||
cache = await self.get_cached_data()
|
||||
@@ -1946,6 +1974,8 @@ class ModelScanner:
|
||||
break
|
||||
|
||||
try:
|
||||
_require_path_in_library_roots(file_path, self, label="File path")
|
||||
|
||||
target_dir = os.path.dirname(file_path)
|
||||
base_name = os.path.basename(file_path)
|
||||
file_name, main_extension = os.path.splitext(base_name)
|
||||
|
||||
@@ -12,6 +12,7 @@ NODE_TYPES = {
|
||||
"Lora Loader (LoraManager)": 1,
|
||||
"Lora Stacker (LoraManager)": 2,
|
||||
"WanVideo Lora Select (LoraManager)": 3,
|
||||
"Create Hook LoRA (LoraManager)": 4,
|
||||
}
|
||||
|
||||
# Default ComfyUI node color when bgcolor is null
|
||||
|
||||
@@ -488,6 +488,12 @@ def calculate_relative_path_for_model(
|
||||
if model_type == "embedding":
|
||||
formatted_path = formatted_path.replace(" ", "_")
|
||||
|
||||
# Sanitize the resolved path to prevent path traversal
|
||||
formatted_path = formatted_path.lstrip("/")
|
||||
while "//" in formatted_path:
|
||||
formatted_path = formatted_path.replace("//", "/")
|
||||
formatted_path = formatted_path.rstrip("/")
|
||||
|
||||
return formatted_path
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.1.7"
|
||||
version = "1.1.9"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -274,6 +274,11 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Inline extra tags (selected but not in top-20/appended after API results) */
|
||||
.filter-tag.extra-tag {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* Ensure solid border and full opacity when active or excluded */
|
||||
.filter-tag.special-tag.active,
|
||||
.filter-tag.special-tag.exclude {
|
||||
|
||||
@@ -49,10 +49,6 @@ export const MODEL_CONFIG = {
|
||||
* @returns {Object} Object containing all API endpoints for the model type
|
||||
*/
|
||||
export function getApiEndpoints(modelType) {
|
||||
if (!Object.values(MODEL_TYPES).includes(modelType)) {
|
||||
throw new Error(`Invalid model type: ${modelType}`);
|
||||
}
|
||||
|
||||
return {
|
||||
// Base CRUD operations
|
||||
list: `/api/lm/${modelType}/list`,
|
||||
@@ -93,6 +89,7 @@ export function getApiEndpoints(modelType) {
|
||||
// Query operations
|
||||
scan: `/api/lm/${modelType}/scan`,
|
||||
topTags: `/api/lm/${modelType}/top-tags`,
|
||||
searchTags: `/api/lm/${modelType}/search-tags`,
|
||||
baseModels: `/api/lm/${modelType}/base-models`,
|
||||
roots: `/api/lm/${modelType}/roots`,
|
||||
folders: `/api/lm/${modelType}/folders`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { getApiEndpoints } from '../api/apiConfig.js';
|
||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
@@ -24,6 +25,12 @@ export class FilterManager {
|
||||
this.baseModelOptions = [];
|
||||
this.tagsLoaded = false;
|
||||
|
||||
// Tag search state
|
||||
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
|
||||
this.tagSearchDebounceTimer = null;
|
||||
this.tagSearchAbortController = null;
|
||||
this.tagSearchQuery = '';
|
||||
|
||||
// Initialize preset manager
|
||||
this.presetManager = new FilterPresetManager({
|
||||
page: this.currentPage,
|
||||
@@ -123,6 +130,60 @@ export class FilterManager {
|
||||
this.renderBaseModelTags();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.modelTagsSearchInput) {
|
||||
this.modelTagsSearchInput.addEventListener('input', () => {
|
||||
clearTimeout(this.tagSearchDebounceTimer);
|
||||
this.tagSearchDebounceTimer = setTimeout(() => {
|
||||
this.handleTagSearchInput();
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleTagSearchInput() {
|
||||
const query = (this.modelTagsSearchInput?.value || '').trim();
|
||||
const trimmedQuery = query.toLowerCase();
|
||||
if (trimmedQuery === this.tagSearchQuery) return;
|
||||
this.tagSearchQuery = trimmedQuery;
|
||||
|
||||
if (!trimmedQuery) {
|
||||
// Empty query: reload top tags (default/common view)
|
||||
this.loadTopTags();
|
||||
return;
|
||||
}
|
||||
this.searchTags(trimmedQuery);
|
||||
}
|
||||
|
||||
async searchTags(query) {
|
||||
// Abort any in-flight search request
|
||||
if (this.tagSearchAbortController) {
|
||||
this.tagSearchAbortController.abort();
|
||||
}
|
||||
this.tagSearchAbortController = new AbortController();
|
||||
const controller = this.tagSearchAbortController;
|
||||
|
||||
try {
|
||||
const tagsEndpoint = `${getApiEndpoints(this.currentPage).searchTags}?q=${encodeURIComponent(query)}&limit=20`;
|
||||
const response = await fetch(tagsEndpoint, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error('Failed to search tags');
|
||||
const data = await response.json();
|
||||
if (controller.signal.aborted) return; // stale response
|
||||
if (data.success && data.tags) {
|
||||
this.createTagFilterElements(data.tags);
|
||||
} else {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return; // expected, ignore
|
||||
console.error('Error searching tags:', error);
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
if (tagsContainer) {
|
||||
tagsContainer.innerHTML = '<div class="tags-error">Failed to search tags</div>';
|
||||
}
|
||||
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
getNormalizedSearchQuery(input) {
|
||||
@@ -146,15 +207,24 @@ export class FilterManager {
|
||||
}
|
||||
|
||||
async loadTopTags() {
|
||||
// Abort any in-flight tag search request
|
||||
if (this.tagSearchAbortController) {
|
||||
this.tagSearchAbortController.abort();
|
||||
this.tagSearchAbortController = null;
|
||||
}
|
||||
this.tagSearchQuery = '';
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||
if (!tagsContainer) return;
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
|
||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||
|
||||
// Determine the API endpoint based on the page type
|
||||
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`;
|
||||
const tagsEndpoint = `${getApiEndpoints(this.currentPage).topTags}?limit=20`;
|
||||
|
||||
const response = await fetch(tagsEndpoint);
|
||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||
@@ -179,29 +249,38 @@ export class FilterManager {
|
||||
|
||||
createTagFilterElements(tags) {
|
||||
const tagsContainer = document.getElementById('modelTagsFilter');
|
||||
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
tagsContainer.innerHTML = '';
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
|
||||
// Collect existing tag names from the API response
|
||||
const existingTagNames = new Set(tags.map(t => t.tag));
|
||||
|
||||
// Add any active filter tags that aren't in the top 20
|
||||
// Collect active filter tags that aren't in the response (excluding __no_tags__)
|
||||
const missingSelectedTags = [];
|
||||
if (this.filters.tags) {
|
||||
Object.keys(this.filters.tags).forEach(tagName => {
|
||||
// Skip special tags like __no_tags__
|
||||
if (tagName.startsWith('__')) return;
|
||||
|
||||
if (!existingTagNames.has(tagName)) {
|
||||
// Add this tag to the list with count 0 (unknown)
|
||||
tags.push({ tag: tagName, count: 0 });
|
||||
missingSelectedTags.push({ tag: tagName, count: 0 });
|
||||
existingTagNames.add(tagName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Append missing selected tags after the API results so they appear inline
|
||||
for (const t of missingSelectedTags) {
|
||||
tags.push(t);
|
||||
}
|
||||
|
||||
if (!tags.length) {
|
||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||
if (this.tagSearchQuery) {
|
||||
if (emptyState) emptyState.hidden = false;
|
||||
} else {
|
||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,6 +288,10 @@ export class FilterManager {
|
||||
const tagEl = document.createElement('div');
|
||||
tagEl.className = 'filter-tag tag-filter';
|
||||
const tagName = tag.tag;
|
||||
|
||||
if (missingSelectedTags.some(t => t.tag === tagName)) {
|
||||
tagEl.classList.add('extra-tag');
|
||||
}
|
||||
tagEl.dataset.tag = tagName;
|
||||
|
||||
// Show count only if it's > 0 (known count)
|
||||
@@ -234,26 +317,28 @@ export class FilterManager {
|
||||
tagsContainer.appendChild(tagEl);
|
||||
});
|
||||
|
||||
// Add "No tags" as a special filter at the end
|
||||
const noTagsEl = document.createElement('div');
|
||||
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||
const noTagsKey = '__no_tags__';
|
||||
noTagsEl.dataset.tag = noTagsKey;
|
||||
noTagsEl.innerHTML = noTagsLabel;
|
||||
// Add "No tags" as a special filter at the end (skip during search)
|
||||
if (!this.tagSearchQuery) {
|
||||
const noTagsEl = document.createElement('div');
|
||||
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||
const noTagsKey = '__no_tags__';
|
||||
noTagsEl.dataset.tag = noTagsKey;
|
||||
noTagsEl.innerHTML = noTagsLabel;
|
||||
|
||||
noTagsEl.addEventListener('click', async () => {
|
||||
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
||||
const newState = this.getNextTriStateState(currentState);
|
||||
this.setTagFilterState(noTagsKey, newState);
|
||||
this.applyTagElementState(noTagsEl, newState);
|
||||
noTagsEl.addEventListener('click', async () => {
|
||||
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
|
||||
const newState = this.getNextTriStateState(currentState);
|
||||
this.setTagFilterState(noTagsKey, newState);
|
||||
this.applyTagElementState(noTagsEl, newState);
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
this.updateActiveFiltersCount();
|
||||
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(noTagsEl);
|
||||
tagsContainer.appendChild(noTagsEl);
|
||||
}
|
||||
this.updateTagSelections();
|
||||
}
|
||||
|
||||
@@ -341,7 +426,7 @@ export class FilterManager {
|
||||
if (!baseModelTagsContainer) return;
|
||||
|
||||
// Set the API endpoint based on current page
|
||||
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`;
|
||||
const apiEndpoint = `${getApiEndpoints(this.currentPage).baseModels}?limit=0`;
|
||||
|
||||
// Fetch base models
|
||||
fetch(apiEndpoint)
|
||||
@@ -721,6 +806,16 @@ export class FilterManager {
|
||||
tagLogic: 'any'
|
||||
});
|
||||
|
||||
// Clear tag search input and reset search state
|
||||
if (this.modelTagsSearchInput) {
|
||||
this.modelTagsSearchInput.value = '';
|
||||
}
|
||||
this.tagSearchQuery = '';
|
||||
if (this.tagSearchAbortController) {
|
||||
this.tagSearchAbortController.abort();
|
||||
this.tagSearchAbortController = null;
|
||||
}
|
||||
|
||||
// Update tag logic toggle UI
|
||||
this.updateTagLogicToggleUI();
|
||||
|
||||
@@ -731,6 +826,10 @@ export class FilterManager {
|
||||
// Update UI
|
||||
this.updateTagSelections();
|
||||
this.updateActiveFiltersCount();
|
||||
// Reload tag area to drop any non-top-20 tags from the deactivated preset
|
||||
if (this.tagsLoaded) {
|
||||
await this.loadTopTags();
|
||||
}
|
||||
this.presetManager.renderPresets(); // Re-render to remove active state
|
||||
|
||||
// Remove from local Storage
|
||||
|
||||
@@ -478,11 +478,9 @@ export class FilterPresetManager {
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.filters = this.filterManager.cloneFilters();
|
||||
|
||||
// If tags haven't been loaded yet, load them first
|
||||
if (!this.filterManager.tagsLoaded) {
|
||||
await this.filterManager.loadTopTags();
|
||||
this.filterManager.tagsLoaded = true;
|
||||
}
|
||||
// Refresh tag display so preset's non-top-20 tags appear inline
|
||||
await this.filterManager.loadTopTags();
|
||||
this.filterManager.tagsLoaded = true;
|
||||
|
||||
// Check again after async operation
|
||||
if (requestId !== this.applyPresetRequestId) return;
|
||||
@@ -745,8 +743,16 @@ export class FilterPresetManager {
|
||||
presetEl.classList.add('active');
|
||||
}
|
||||
|
||||
presetEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Apply preset on click (toggle if already active)
|
||||
// Bind to the whole .filter-preset div so clicking anywhere inside triggers apply
|
||||
presetEl.addEventListener('click', async () => {
|
||||
this.cancelPendingDelete();
|
||||
|
||||
if (this.activePreset === preset.name) {
|
||||
await this.filterManager.clearFilters();
|
||||
} else {
|
||||
await this.applyPreset(preset.name);
|
||||
}
|
||||
});
|
||||
|
||||
const presetName = document.createElement('span');
|
||||
@@ -759,18 +765,6 @@ export class FilterPresetManager {
|
||||
deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
|
||||
|
||||
// Apply preset on name click (toggle if already active)
|
||||
presetName.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
this.cancelPendingDelete();
|
||||
|
||||
if (this.activePreset === preset.name) {
|
||||
await this.filterManager.clearFilters();
|
||||
} else {
|
||||
await this.applyPreset(preset.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Two-step delete on delete button click
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -369,21 +369,24 @@ export function getMatureBlurThreshold(settings = {}) {
|
||||
export const NODE_TYPES = {
|
||||
LORA_LOADER: 1,
|
||||
LORA_STACKER: 2,
|
||||
WAN_VIDEO_LORA_SELECT: 3
|
||||
WAN_VIDEO_LORA_SELECT: 3,
|
||||
HOOK_LORA: 4
|
||||
};
|
||||
|
||||
// Node type names to IDs mapping
|
||||
export const NODE_TYPE_NAMES = {
|
||||
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
|
||||
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER,
|
||||
"WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT
|
||||
"WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT,
|
||||
"Create Hook LoRA (LoraManager)": NODE_TYPES.HOOK_LORA
|
||||
};
|
||||
|
||||
// Node type icons
|
||||
export const NODE_TYPE_ICONS = {
|
||||
[NODE_TYPES.LORA_LOADER]: "fas fa-l",
|
||||
[NODE_TYPES.LORA_STACKER]: "fas fa-s",
|
||||
[NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w"
|
||||
[NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w",
|
||||
[NODE_TYPES.HOOK_LORA]: "fas fa-h"
|
||||
};
|
||||
|
||||
// Default ComfyUI node color when bgcolor is null
|
||||
|
||||
@@ -134,7 +134,10 @@ export async function copyToClipboard(text, successMessage = null) {
|
||||
}
|
||||
|
||||
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||||
const message = translate(key, params, fallback);
|
||||
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
|
||||
// to avoid spurious "Translation key not found" warnings from i18next
|
||||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||||
const message = isPlainMessage ? key : translate(key, params, fallback);
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
@@ -251,10 +251,15 @@
|
||||
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" id="modelTagsSearchInput" class="filter-search-input"
|
||||
placeholder="{{ t('header.filter.tagSearchPlaceholder') }}" autocomplete="off">
|
||||
<div class="filter-tags" id="modelTagsFilter">
|
||||
<!-- Top tags will be dynamically inserted here -->
|
||||
<div class="tags-loading">{{ t('common.status.loading') }}</div>
|
||||
</div>
|
||||
<div id="modelTagsEmptyState" class="filter-empty-state" hidden>
|
||||
{{ t('header.filter.noTagMatches') }}
|
||||
</div>
|
||||
</div>
|
||||
{% if current_page == 'loras' or current_page == 'checkpoints' %}
|
||||
<div class="filter-section">
|
||||
|
||||
@@ -85,6 +85,7 @@ sys.modules['comfy.utils'] = comfy_mock.utils
|
||||
sys.modules['comfy.sd'] = comfy_mock.sd
|
||||
sys.modules['comfy.model_management'] = comfy_mock.model_management
|
||||
sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types
|
||||
sys.modules['comfy.hooks'] = MockModule("comfy.hooks")
|
||||
|
||||
execution_mock = MockModule("execution")
|
||||
execution_mock.PromptExecutor = mock.MagicMock()
|
||||
|
||||
@@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
|
||||
<div id="baseModelEmptyState" hidden></div>
|
||||
<div id="filterPresets" class="filter-presets"></div>
|
||||
<div id="modelTagsFilter" class="filter-tags"></div>
|
||||
<input id="modelTagsSearchInput" />
|
||||
<div id="modelTagsEmptyState" hidden></div>
|
||||
<button class="clear-filter"></button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
@@ -961,4 +963,198 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
||||
expect(stateModule.state.bulkMode).toBe(true);
|
||||
expect(pageState.duplicatesMode).toBe(true);
|
||||
});
|
||||
|
||||
describe('tag search', () => {
|
||||
it('fetches /search-tags when typing in the tag search input (debounced)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const searchTagsUrls = [];
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes('/search-tags')) {
|
||||
searchTagsUrls.push(url);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
|
||||
});
|
||||
}
|
||||
if (url.includes('/top-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/base-models')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||
}
|
||||
if (url.includes('/model-types')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
// Open the panel so tags load
|
||||
manager.toggleFilterPanel();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const input = document.getElementById('modelTagsSearchInput');
|
||||
input.value = 'ani';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// Before debounce fires, no search-tags call yet
|
||||
expect(searchTagsUrls.length).toBe(0);
|
||||
|
||||
// Advance past the 150ms debounce
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(searchTagsUrls.length).toBe(1);
|
||||
expect(searchTagsUrls[0]).toContain('/search-tags');
|
||||
expect(searchTagsUrls[0]).toContain('q=ani');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders selected-but-missing tags in a dedicated group at the top', async () => {
|
||||
vi.useFakeTimers();
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes('/search-tags')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
|
||||
});
|
||||
}
|
||||
if (url.includes('/top-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/base-models')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||
}
|
||||
if (url.includes('/model-types')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
// Pre-seed an active tag filter that won't appear in search results
|
||||
manager.filters.tags = { 'my-custom-tag': 'include' };
|
||||
|
||||
// Open panel and let top-tags load (empty)
|
||||
manager.toggleFilterPanel();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Type a search query
|
||||
const input = document.getElementById('modelTagsSearchInput');
|
||||
input.value = 'ani';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const container = document.getElementById('modelTagsFilter');
|
||||
const extraTag = container.querySelector('.filter-tag.extra-tag');
|
||||
expect(extraTag).not.toBeNull();
|
||||
expect(extraTag.dataset.tag).toBe('my-custom-tag');
|
||||
|
||||
// The search result tag should also be present
|
||||
const resultTag = container.querySelector('.filter-tag.tag-filter[data-tag="anime"]');
|
||||
expect(resultTag).not.toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows empty state when search returns no matches and no selected tags', async () => {
|
||||
vi.useFakeTimers();
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes('/search-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/top-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/base-models')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||
}
|
||||
if (url.includes('/model-types')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
manager.toggleFilterPanel();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const input = document.getElementById('modelTagsSearchInput');
|
||||
input.value = 'zzz';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const emptyState = document.getElementById('modelTagsEmptyState');
|
||||
expect(emptyState.hidden).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('reloads top tags when search input is cleared', async () => {
|
||||
vi.useFakeTimers();
|
||||
let topTagsCallCount = 0;
|
||||
global.fetch = vi.fn((url) => {
|
||||
if (url.includes('/search-tags')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }) });
|
||||
}
|
||||
if (url.includes('/top-tags')) {
|
||||
topTagsCallCount++;
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
|
||||
}
|
||||
if (url.includes('/base-models')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
|
||||
}
|
||||
if (url.includes('/model-types')) {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
|
||||
});
|
||||
|
||||
renderControlsDom('loras');
|
||||
const stateModule = await import('../../../static/js/state/index.js');
|
||||
stateModule.initPageState('loras');
|
||||
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||
const manager = new FilterManager({ page: 'loras' });
|
||||
|
||||
manager.toggleFilterPanel();
|
||||
await vi.runAllTimersAsync();
|
||||
const callsAfterOpen = topTagsCallCount;
|
||||
expect(callsAfterOpen).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Type, then clear
|
||||
const input = document.getElementById('modelTagsSearchInput');
|
||||
input.value = 'ani';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
vi.advanceTimersByTime(160);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// An additional top-tags call should have happened after clearing
|
||||
expect(topTagsCallCount).toBeGreaterThan(callsAfterOpen);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,3 +36,53 @@ async def test_model_query_handler_rejects_negative_limit_for_base_models():
|
||||
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
|
||||
|
||||
assert service.received_limit == 20
|
||||
|
||||
|
||||
class DummySearchTagsService:
|
||||
"""Minimal service stub recording search_tags arguments."""
|
||||
|
||||
def __init__(self, result=None):
|
||||
self.received_query = None
|
||||
self.received_limit = None
|
||||
self._result = result or []
|
||||
|
||||
async def search_tags(self, query, limit):
|
||||
self.received_query = query
|
||||
self.received_limit = limit
|
||||
return self._result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_query_handler_search_tags_passes_query_and_limit():
|
||||
service = DummySearchTagsService(result=[{"tag": "anime", "count": 3}])
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
response = await handler.search_tags(
|
||||
SimpleNamespace(query={"q": "ani", "limit": "50"})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["tags"] == [{"tag": "anime", "count": 3}]
|
||||
assert service.received_query == "ani"
|
||||
assert service.received_limit == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_query_handler_search_tags_defaults_limit_to_20():
|
||||
service = DummySearchTagsService()
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
await handler.search_tags(SimpleNamespace(query={}))
|
||||
|
||||
assert service.received_limit == 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_query_handler_search_tags_clamps_negative_limit():
|
||||
service = DummySearchTagsService()
|
||||
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
|
||||
|
||||
await handler.search_tags(SimpleNamespace(query={"limit": "-5"}))
|
||||
|
||||
assert service.received_limit == 20
|
||||
|
||||
@@ -1189,6 +1189,65 @@ def test_relative_path_sanitizes_model_and_version_placeholders():
|
||||
assert relative_path == "Fancy_Model/Version_One"
|
||||
|
||||
|
||||
def test_relative_path_empty_first_tag_fallback():
|
||||
"""Test that empty first_tag falls back to 'no tags'."""
|
||||
manager = DownloadManager()
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["download_path_templates"]["lora"] = (
|
||||
"{base_model}/{first_tag}"
|
||||
)
|
||||
|
||||
version_info = {
|
||||
"baseModel": "SDXL",
|
||||
"model": {"name": "Test Model", "tags": []},
|
||||
"creator": {"username": "Author"},
|
||||
}
|
||||
|
||||
relative_path = manager._calculate_relative_path(version_info, "lora")
|
||||
|
||||
assert relative_path == "SDXL/no tags"
|
||||
|
||||
|
||||
def test_relative_path_empty_base_model_and_first_tag():
|
||||
"""Test that empty base_model + empty first_tag does NOT produce a leading slash."""
|
||||
manager = DownloadManager()
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["download_path_templates"]["lora"] = (
|
||||
"{base_model}/{first_tag}"
|
||||
)
|
||||
|
||||
version_info = {
|
||||
"baseModel": "",
|
||||
"model": {"name": "Test Model", "tags": []},
|
||||
"creator": {"username": "Author"},
|
||||
}
|
||||
|
||||
relative_path = manager._calculate_relative_path(version_info, "lora")
|
||||
|
||||
assert not relative_path.startswith("/")
|
||||
assert relative_path == "no tags"
|
||||
|
||||
|
||||
def test_relative_path_sanitizes_double_slashes():
|
||||
"""Test that empty placeholder substitutions don't produce double slashes."""
|
||||
manager = DownloadManager()
|
||||
settings_manager = get_settings_manager()
|
||||
settings_manager.settings["download_path_templates"]["lora"] = (
|
||||
"{base_model}/{first_tag}/{author}"
|
||||
)
|
||||
|
||||
version_info = {
|
||||
"baseModel": "SDXL",
|
||||
"model": {"name": "Test Model", "tags": []},
|
||||
"creator": {"username": "Author"},
|
||||
}
|
||||
|
||||
relative_path = manager._calculate_relative_path(version_info, "lora")
|
||||
|
||||
assert "//" not in relative_path
|
||||
assert relative_path == "SDXL/no tags/Author"
|
||||
|
||||
|
||||
def test_distribute_preview_to_entries_moves_and_copies(tmp_path):
|
||||
"""Test that preview distribution moves file to first entry and copies to others."""
|
||||
manager = DownloadManager()
|
||||
|
||||
@@ -3,11 +3,164 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.model_lifecycle_service import ModelLifecycleService
|
||||
from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.utils.models import LoraMetadata
|
||||
|
||||
|
||||
class ScannerWithRoots:
|
||||
def __init__(self, roots):
|
||||
self._roots = list(roots)
|
||||
|
||||
def get_model_roots(self):
|
||||
return self._roots
|
||||
|
||||
|
||||
class TestRequirePathInLibraryRoots:
|
||||
def test_accepts_path_within_root(self, tmp_path):
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model = root / "model.safetensors"
|
||||
model.write_text("")
|
||||
|
||||
scanner = ScannerWithRoots([str(root)])
|
||||
_require_path_in_library_roots(str(model), scanner)
|
||||
|
||||
def test_rejects_path_outside_roots(self, tmp_path):
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside" / "model.safetensors"
|
||||
outside.parent.mkdir(parents=True)
|
||||
outside.write_text("")
|
||||
|
||||
scanner = ScannerWithRoots([str(root)])
|
||||
with pytest.raises(ValueError, match="outside configured library"):
|
||||
_require_path_in_library_roots(str(outside), scanner)
|
||||
|
||||
def test_passes_when_no_roots_configured(self, tmp_path):
|
||||
f = tmp_path / "model.safetensors"
|
||||
f.write_text("")
|
||||
|
||||
scanner = ScannerWithRoots([])
|
||||
_require_path_in_library_roots(str(f), scanner)
|
||||
|
||||
def test_accepts_path_matching_root_exactly(self, tmp_path):
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
|
||||
scanner = ScannerWithRoots([str(root)])
|
||||
_require_path_in_library_roots(str(root), scanner)
|
||||
|
||||
def test_rejects_symlink_escape(self, tmp_path):
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model = root / "model.safetensors"
|
||||
model.write_text("")
|
||||
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
outside_file = outside_dir / "escaped.safetensors"
|
||||
outside_file.write_text("")
|
||||
|
||||
symlink = root / "link.safetensors"
|
||||
symlink.symlink_to(outside_file)
|
||||
|
||||
scanner = ScannerWithRoots([str(root)])
|
||||
with pytest.raises(ValueError, match="outside configured library"):
|
||||
_require_path_in_library_roots(str(symlink), scanner)
|
||||
|
||||
|
||||
class ScannerForDelete:
|
||||
def __init__(self, raw_data, roots, model_type="lora"):
|
||||
self.model_type = model_type
|
||||
self.cache = DummyCache(raw_data)
|
||||
self._hash_index = DummyHashIndex()
|
||||
self._roots = list(roots)
|
||||
self._persist_calls = []
|
||||
|
||||
def get_model_roots(self):
|
||||
return self._roots
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self.cache
|
||||
|
||||
async def _persist_current_cache(self):
|
||||
self._persist_calls.append(True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_rejects_path_outside_roots(tmp_path: Path):
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model = root / "model.safetensors"
|
||||
model.write_bytes(b"data")
|
||||
|
||||
scanner = ScannerForDelete(
|
||||
raw_data=[{"file_path": str(model)}],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=DummyMetadataManager({"civitai": {"modelId": 1}}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
# Path within root should work (model file exists)
|
||||
result = await service.delete_model(str(model))
|
||||
assert result["success"] is True
|
||||
|
||||
# Path outside root should be rejected
|
||||
outside = tmp_path / "outside.safetensors"
|
||||
outside.write_bytes(b"data")
|
||||
scanner2 = ScannerForDelete(
|
||||
raw_data=[],
|
||||
roots=[str(root)],
|
||||
)
|
||||
service2 = ModelLifecycleService(
|
||||
scanner=scanner2,
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
with pytest.raises(ValueError, match="outside configured library"):
|
||||
await service2.delete_model(str(outside))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_model_rejects_path_outside_roots(tmp_path: Path):
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
|
||||
scanner = ScannerWithRoots([str(root)])
|
||||
service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
outside = tmp_path / "outside.safetensors"
|
||||
outside.write_bytes(b"data")
|
||||
|
||||
with pytest.raises(ValueError, match="outside configured library"):
|
||||
await service.rename_model(file_path=str(outside), new_file_name="new_name")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_rejects_any_path_outside_roots(tmp_path: Path):
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
model_ok = root / "model.safetensors"
|
||||
model_ok.write_bytes(b"data")
|
||||
outside = tmp_path / "outside.safetensors"
|
||||
outside.write_bytes(b"data")
|
||||
|
||||
scanner = ScannerWithRoots([str(root)])
|
||||
service = ModelLifecycleService(
|
||||
scanner=scanner,
|
||||
metadata_manager=DummyMetadataManager({}),
|
||||
metadata_loader=lambda x: {},
|
||||
)
|
||||
with pytest.raises(ValueError, match="outside configured library"):
|
||||
await service.bulk_delete_models([str(model_ok), str(outside)])
|
||||
|
||||
|
||||
class DummyCache:
|
||||
def __init__(self, raw_data):
|
||||
self.raw_data = raw_data
|
||||
|
||||
@@ -114,6 +114,38 @@ def test_calculate_relative_path_sanitizes_model_and_version_names(isolated_sett
|
||||
assert relative_path == "Fancy_Model/Version_One"
|
||||
|
||||
|
||||
def test_calculate_relative_path_sanitizes_leading_slash(isolated_settings):
|
||||
"""Test that empty base_model does NOT produce a leading slash in the path."""
|
||||
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}"
|
||||
|
||||
model_data = {
|
||||
"base_model": "",
|
||||
"tags": [],
|
||||
"civitai": {"id": 1, "creator": {"username": "Author"}},
|
||||
}
|
||||
|
||||
relative_path = calculate_relative_path_for_model(model_data, "lora")
|
||||
|
||||
assert not relative_path.startswith("/")
|
||||
assert relative_path == "no tags"
|
||||
|
||||
|
||||
def test_calculate_relative_path_sanitizes_double_slashes(isolated_settings):
|
||||
"""Test that empty substitutions don't produce double slashes."""
|
||||
isolated_settings["download_path_templates"]["lora"] = "{base_model}/{first_tag}/{author}"
|
||||
|
||||
model_data = {
|
||||
"base_model": "",
|
||||
"tags": [],
|
||||
"civitai": {"id": 1, "creator": {"username": "Author"}},
|
||||
}
|
||||
|
||||
relative_path = calculate_relative_path_for_model(model_data, "lora")
|
||||
|
||||
assert "//" not in relative_path
|
||||
assert relative_path == "no tags/Author"
|
||||
|
||||
|
||||
def test_calculate_recipe_fingerprint_filters_and_sorts():
|
||||
loras = [
|
||||
{"hash": "ABC", "strength": 0.1234},
|
||||
|
||||
@@ -16,6 +16,7 @@ export const LORA_PROVIDER_NODE_TYPES = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
"Lora Randomizer (LoraManager)",
|
||||
"Lora Cycler (LoraManager)",
|
||||
"Create Hook LoRA (LoraManager)",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
143
web/comfyui/create_hook_lora.js
Normal file
143
web/comfyui/create_hook_lora.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import {
|
||||
getActiveLorasFromNode,
|
||||
updateConnectedTriggerWords,
|
||||
chainCallback,
|
||||
mergeLoras,
|
||||
getWidgetByName,
|
||||
getWidgetSerializedValue,
|
||||
} from "./utils.js";
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
import { applyLoraValuesToText, debounce } from "./lora_syntax_utils.js";
|
||||
import { applySelectionHighlight } from "./trigger_word_highlight.js";
|
||||
import { updateConnectedLoraInfoNodes } from "./lora_info.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.CreateHookLora",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeType.comfyClass === "Create Hook LoRA (LoraManager)") {
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
// Enable widget serialization so loras widget state is persisted
|
||||
this.serialize_widgets = true;
|
||||
|
||||
this.addInput("prev_hooks", "HOOKS", {
|
||||
shape: 7,
|
||||
});
|
||||
|
||||
// Flags to prevent callback loops between text widget ↔ loras widget
|
||||
let isUpdating = false;
|
||||
let isSyncingInput = false;
|
||||
|
||||
// Get the text input widget (AUTOCOMPLETE_TEXT_LORAS type, created by Vue widgets)
|
||||
const inputWidget = getWidgetByName(this, "text");
|
||||
if (!inputWidget) {
|
||||
console.warn(
|
||||
"LoRA Manager: text widget not found for Create Hook LoRA"
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.inputWidget = inputWidget;
|
||||
|
||||
const scheduleInputSync = debounce((lorasValue) => {
|
||||
if (isSyncingInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSyncingInput = true;
|
||||
isUpdating = true;
|
||||
|
||||
try {
|
||||
const nextText = applyLoraValuesToText(
|
||||
inputWidget.value,
|
||||
lorasValue
|
||||
);
|
||||
|
||||
if (inputWidget.value !== nextText) {
|
||||
inputWidget.value = nextText;
|
||||
}
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
isSyncingInput = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Create the LoRA list widget
|
||||
const result = addLorasWidget(
|
||||
this,
|
||||
"loras",
|
||||
{
|
||||
onSelectionChange: (selection) => {
|
||||
applySelectionHighlight(this, selection);
|
||||
updateConnectedLoraInfoNodes(this, selection);
|
||||
},
|
||||
},
|
||||
(value) => {
|
||||
// Prevent recursive calls
|
||||
if (isUpdating) return;
|
||||
isUpdating = true;
|
||||
|
||||
try {
|
||||
// Update connected trigger word toggles with active LoRA names
|
||||
const activeLoraNames = new Set();
|
||||
value.forEach((lora) => {
|
||||
if (lora.active) {
|
||||
activeLoraNames.add(lora.name);
|
||||
}
|
||||
});
|
||||
updateConnectedTriggerWords(this, activeLoraNames);
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
}
|
||||
|
||||
scheduleInputSync(value);
|
||||
}
|
||||
);
|
||||
|
||||
this.lorasWidget = result.widget;
|
||||
|
||||
// Set up callback for the text input widget to trigger merge logic
|
||||
inputWidget.callback = (value) => {
|
||||
if (isUpdating) return;
|
||||
isUpdating = true;
|
||||
|
||||
try {
|
||||
const currentLoras = this.lorasWidget?.value || [];
|
||||
const mergedLoras = mergeLoras(value, currentLoras);
|
||||
if (this.lorasWidget) {
|
||||
this.lorasWidget.value = mergedLoras;
|
||||
}
|
||||
|
||||
// Update connected trigger word toggles
|
||||
const activeLoraNames = getActiveLorasFromNode(this);
|
||||
updateConnectedTriggerWords(this, activeLoraNames);
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async loadedGraphNode(node) {
|
||||
if (node.comfyClass === "Create Hook LoRA (LoraManager)") {
|
||||
// Restore saved loras widget values on workflow load
|
||||
let existingLoras = [];
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
const savedValue = getWidgetSerializedValue(node, "loras");
|
||||
existingLoras = savedValue || [];
|
||||
}
|
||||
// Merge the loras data from text widget with saved values
|
||||
const inputWidget =
|
||||
node.inputWidget || getWidgetByName(node, "text");
|
||||
if (!inputWidget) {
|
||||
console.warn(
|
||||
"LoRA Manager: text widget not found while restoring Create Hook LoRA"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const mergedLoras = mergeLoras(inputWidget.value, existingLoras);
|
||||
node.lorasWidget.value = mergedLoras;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,7 @@ const LORA_NODE_CLASSES = new Set([
|
||||
"Lora Loader (LoraManager)",
|
||||
"Lora Stacker (LoraManager)",
|
||||
"WanVideo Lora Select (LoraManager)",
|
||||
"Create Hook LoRA (LoraManager)",
|
||||
]);
|
||||
|
||||
function normalizeTriggerWordList(triggerWords) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const LORA_PROVIDER_NODE_TYPES = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
"Lora Randomizer (LoraManager)",
|
||||
"Lora Cycler (LoraManager)",
|
||||
"Create Hook LoRA (LoraManager)",
|
||||
];
|
||||
|
||||
export const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
||||
|
||||
@@ -15656,7 +15656,8 @@ function createVueWidgetCleanup(vueApp, onCleanup) {
|
||||
const LORA_PROVIDER_NODE_TYPES$1 = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
"Lora Randomizer (LoraManager)",
|
||||
"Lora Cycler (LoraManager)"
|
||||
"Lora Cycler (LoraManager)",
|
||||
"Create Hook LoRA (LoraManager)"
|
||||
];
|
||||
const LORA_STACK_AGGREGATOR_NODE_TYPES$1 = [
|
||||
"Lora Stack Combiner (LoraManager)"
|
||||
@@ -15781,7 +15782,8 @@ const ROOT_GRAPH_ID = "root";
|
||||
const LORA_PROVIDER_NODE_TYPES = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
"Lora Randomizer (LoraManager)",
|
||||
"Lora Cycler (LoraManager)"
|
||||
"Lora Cycler (LoraManager)",
|
||||
"Create Hook LoRA (LoraManager)"
|
||||
];
|
||||
const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
||||
"Lora Stack Combiner (LoraManager)"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ const LORA_NODE_CLASSES = new Set([
|
||||
"Lora Loader (LoraManager)",
|
||||
"Lora Stacker (LoraManager)",
|
||||
"WanVideo Lora Select (LoraManager)",
|
||||
"Create Hook LoRA (LoraManager)",
|
||||
]);
|
||||
|
||||
const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]);
|
||||
|
||||
Reference in New Issue
Block a user