mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-13 17:23:22 -03:00
Compare commits
25 Commits
e04c22f83f
..
v1.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
| ce8a95abf7 | |||
| c8e7e543d6 | |||
| a9dbb15ffa | |||
| cf64043f7d | |||
| ccaff92c18 | |||
| 585b5c922a | |||
| ea80c2224c | |||
| 8b0f56c1a6 | |||
| 8022d12f03 | |||
| 3939f7f91b | |||
| aebf2e37dd | |||
| f53f859a71 | |||
| d916375abe | |||
| 57983df4bd | |||
| c68d7559a0 | |||
| 9a8f5bf2d6 | |||
| a8d742b031 | |||
| c27e4d1bfc | |||
| d15a8aa9a2 | |||
| 74a7d12ca4 | |||
| 2f94a9773e | |||
| 37bdfa21ea | |||
| f0bf2728c9 | |||
| dc715aa273 | |||
| 7ee2361e87 |
@@ -17,6 +17,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.lora_cycler import LoraCyclerLM
|
from .py.nodes.lora_cycler import LoraCyclerLM
|
||||||
from .py.nodes.lora_info import LoraInfoLM
|
from .py.nodes.lora_info import LoraInfoLM
|
||||||
from .py.nodes.lora_syntax_to_path import LoraSyntaxToPath
|
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
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except (
|
except (
|
||||||
ImportError
|
ImportError
|
||||||
@@ -62,6 +63,9 @@ except (
|
|||||||
LoraSyntaxToPath = importlib.import_module(
|
LoraSyntaxToPath = importlib.import_module(
|
||||||
"py.nodes.lora_syntax_to_path"
|
"py.nodes.lora_syntax_to_path"
|
||||||
).LoraSyntaxToPath
|
).LoraSyntaxToPath
|
||||||
|
CreateHookLoraLM = importlib.import_module(
|
||||||
|
"py.nodes.create_hook_lora"
|
||||||
|
).CreateHookLoraLM
|
||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -83,6 +87,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraCyclerLM.NAME: LoraCyclerLM,
|
LoraCyclerLM.NAME: LoraCyclerLM,
|
||||||
LoraInfoLM.NAME: LoraInfoLM,
|
LoraInfoLM.NAME: LoraInfoLM,
|
||||||
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
LoraSyntaxToPath.NAME: LoraSyntaxToPath,
|
||||||
|
CreateHookLoraLM.NAME: CreateHookLoraLM,
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
+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
@@ -359,6 +359,47 @@ class Config:
|
|||||||
"Failed to rename legacy 'default' library: %s", rename_error
|
"Failed to rename legacy 'default' library: %s", rename_error
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clean up a stale "default" library entry that has no meaningful
|
||||||
|
# paths configured (e.g. leftover bootstrap artifact). This only
|
||||||
|
# fires when "comfyui" already exists so we never delete the last
|
||||||
|
# remaining library.
|
||||||
|
if (
|
||||||
|
"default" in libraries
|
||||||
|
and "comfyui" in libraries
|
||||||
|
and isinstance(default_library, Mapping)
|
||||||
|
):
|
||||||
|
default_folder_paths = _normalize_library_folder_paths(
|
||||||
|
default_library
|
||||||
|
)
|
||||||
|
default_extra_paths = default_library.get("extra_folder_paths", {})
|
||||||
|
has_meaningful_paths = bool(default_folder_paths) or bool(
|
||||||
|
default_extra_paths
|
||||||
|
) or any(
|
||||||
|
default_library.get(key)
|
||||||
|
for key in (
|
||||||
|
"default_lora_root",
|
||||||
|
"default_checkpoint_root",
|
||||||
|
"default_unet_root",
|
||||||
|
"default_embedding_root",
|
||||||
|
"recipes_path",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not has_meaningful_paths:
|
||||||
|
try:
|
||||||
|
settings_service.delete_library("default")
|
||||||
|
libraries_changed = True
|
||||||
|
logger.info(
|
||||||
|
"Removed stale 'default' library entry "
|
||||||
|
"with no meaningful paths configured"
|
||||||
|
)
|
||||||
|
libraries = settings_service.get_libraries()
|
||||||
|
comfy_library = libraries.get("comfyui", {})
|
||||||
|
except Exception as delete_error:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to remove stale 'default' library: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
default_lora_root = _resolve_valid_default_root(
|
default_lora_root = _resolve_valid_default_root(
|
||||||
comfy_library.get("default_lora_root", ""),
|
comfy_library.get("default_lora_root", ""),
|
||||||
list(self.loras_roots or []),
|
list(self.loras_roots or []),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
@@ -3471,7 +3475,7 @@ class NodeRegistryHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(value, str) or not value:
|
if value is None or (isinstance(value, str) and not value):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing value parameter"}, status=400
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
)
|
)
|
||||||
@@ -3578,7 +3582,7 @@ class NodeRegistryHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(value, str) or not value:
|
if value is None or (isinstance(value, str) and not value):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Missing value parameter"}, status=400
|
{"success": False, "error": "Missing value parameter"}, status=400
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -1787,14 +1805,20 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
item_id = int(request.query.get("id", "0"))
|
download_id = request.query.get("download_id")
|
||||||
if not item_id:
|
id_str = request.query.get("id")
|
||||||
|
item_id = int(id_str) if id_str else None
|
||||||
|
|
||||||
|
if not download_id and not item_id:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "id is required"}, status=400
|
{"success": False, "error": "id or download_id is required"},
|
||||||
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
deleted = await service.delete_history_item(item_id)
|
deleted = await service.delete_history_item(
|
||||||
|
id=item_id, download_id=download_id
|
||||||
|
)
|
||||||
return web.json_response({"success": deleted})
|
return web.json_response({"success": deleted})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -1804,14 +1828,20 @@ class ModelDownloadHandler:
|
|||||||
|
|
||||||
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
item_id = int(request.query.get("id", "0"))
|
download_id = request.query.get("download_id")
|
||||||
if not item_id:
|
id_str = request.query.get("id")
|
||||||
|
item_id = int(id_str) if id_str else None
|
||||||
|
|
||||||
|
if not download_id and not item_id:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "id is required"}, status=400
|
{"success": False, "error": "id or download_id is required"},
|
||||||
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
service = await DownloadQueueService.get_instance()
|
service = await DownloadQueueService.get_instance()
|
||||||
item = await service.retry_from_history(item_id)
|
item = await service.retry_from_history(
|
||||||
|
item_id=item_id, download_id=download_id
|
||||||
|
)
|
||||||
if item is None:
|
if item is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "History item not found or not retryable"},
|
{"success": False, "error": "History item not found or not retryable"},
|
||||||
@@ -2935,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)
|
||||||
@@ -1092,6 +1098,11 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
Listing/search endpoints return lightweight cache entries; this method performs
|
Listing/search endpoints return lightweight cache entries; this method performs
|
||||||
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
||||||
|
|
||||||
|
As a beneficial side effect, the in-memory and persistent caches are
|
||||||
|
opportunistically synchronised with the on-disk metadata — this keeps the
|
||||||
|
caches fresh even when a ``.metadata.json`` file was edited outside of the
|
||||||
|
normal save path (e.g. manually or by an external script).
|
||||||
"""
|
"""
|
||||||
metadata, should_skip = await MetadataManager.load_metadata(
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
file_path, self.metadata_class
|
file_path, self.metadata_class
|
||||||
@@ -1109,6 +1120,19 @@ class BaseModelService(ABC):
|
|||||||
MetadataManager.save_metadata(file_path, metadata)
|
MetadataManager.save_metadata(file_path, metadata)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Opportunistically sync the in-memory + persistent caches.
|
||||||
|
# The .metadata.json disk read is already paid for; the sync only
|
||||||
|
# performs work when the cache is actually stale, and uses targeted,
|
||||||
|
# in-place operations to minimise overhead even with large model sets.
|
||||||
|
#
|
||||||
|
# Fire-and-forget by design: the task is intentionally untracked.
|
||||||
|
# sync_cache_from_metadata handles its own errors internally.
|
||||||
|
asyncio.create_task(
|
||||||
|
self.scanner.sync_cache_from_metadata(
|
||||||
|
file_path, metadata.to_dict()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -682,7 +682,10 @@ class DownloadManager:
|
|||||||
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
u for u in download_urls if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
]
|
]
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
download_urls = non_civitai_urls + civitai_urls
|
||||||
else:
|
|
||||||
|
# Fallback: when mirrors is empty or all mirrors have been deleted,
|
||||||
|
# use the file's downloadUrl directly (e.g. CivitAI download endpoint).
|
||||||
|
if not download_urls:
|
||||||
download_url = file_info.get("downloadUrl")
|
download_url = file_info.get("downloadUrl")
|
||||||
if download_url:
|
if download_url:
|
||||||
download_urls.append(normalize_civitai_download_url(download_url))
|
download_urls.append(normalize_civitai_download_url(download_url))
|
||||||
@@ -1386,7 +1389,17 @@ class DownloadManager:
|
|||||||
|
|
||||||
# Update save directory with relative path if provided
|
# Update save directory with relative path if provided
|
||||||
if relative_path:
|
if relative_path:
|
||||||
|
base_save_dir = save_dir
|
||||||
save_dir = os.path.join(save_dir, relative_path)
|
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
|
# Create directory if it doesn't exist
|
||||||
os.makedirs(save_dir, exist_ok=True)
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
|
||||||
@@ -1520,35 +1533,8 @@ class DownloadManager:
|
|||||||
|
|
||||||
if not file_info:
|
if not file_info:
|
||||||
return {"success": False, "error": "No suitable file found in metadata"}
|
return {"success": False, "error": "No suitable file found in metadata"}
|
||||||
mirrors = file_info.get("mirrors") or []
|
|
||||||
download_urls = []
|
|
||||||
if mirrors:
|
|
||||||
for mirror in mirrors:
|
|
||||||
if mirror.get("deletedAt") is None and mirror.get("url"):
|
|
||||||
download_urls.append(
|
|
||||||
normalize_civitai_download_url(mirror["url"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# When source is 'civarchive', prioritize non-Civitai URLs
|
download_urls = self._build_download_urls_from_file_info(file_info, source=source)
|
||||||
# This avoids failed downloads from deleted Civitai models
|
|
||||||
if source == "civarchive" and len(download_urls) > 1:
|
|
||||||
civitai_urls = [
|
|
||||||
u
|
|
||||||
for u in download_urls
|
|
||||||
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
||||||
]
|
|
||||||
non_civitai_urls = [
|
|
||||||
u
|
|
||||||
for u in download_urls
|
|
||||||
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
||||||
]
|
|
||||||
download_urls = non_civitai_urls + civitai_urls
|
|
||||||
else:
|
|
||||||
download_url = file_info.get("downloadUrl")
|
|
||||||
if download_url:
|
|
||||||
download_urls.append(
|
|
||||||
normalize_civitai_download_url(download_url)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not download_urls:
|
if not download_urls:
|
||||||
return {"success": False, "error": "No mirror URL found"}
|
return {"success": False, "error": "No mirror URL found"}
|
||||||
@@ -1851,6 +1837,9 @@ class DownloadManager:
|
|||||||
model_tags, model_type
|
model_tags, model_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not first_tag:
|
||||||
|
first_tag = "no tags" # Default if no tags available
|
||||||
|
|
||||||
# Format the template with available data
|
# Format the template with available data
|
||||||
formatted_path = path_template
|
formatted_path = path_template
|
||||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||||
@@ -1866,6 +1855,15 @@ class DownloadManager:
|
|||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
formatted_path = formatted_path.replace(" ", "_")
|
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
|
return formatted_path
|
||||||
|
|
||||||
async def _execute_download(
|
async def _execute_download(
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class DownloadQueueService:
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dh_download_id
|
||||||
|
ON download_history(download_id) WHERE download_id IS NOT NULL;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -390,7 +392,7 @@ class DownloadQueueService:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO download_history (
|
INSERT OR IGNORE INTO download_history (
|
||||||
download_id, model_id, model_version_id, model_name,
|
download_id, model_id, model_version_id, model_name,
|
||||||
version_name, thumbnail_url, status, error, file_path,
|
version_name, thumbnail_url, status, error, file_path,
|
||||||
bytes_downloaded, total_bytes, completed_at
|
bytes_downloaded, total_bytes, completed_at
|
||||||
@@ -547,17 +549,27 @@ class DownloadQueueService:
|
|||||||
"offset": offset,
|
"offset": offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def delete_history_item(self, id: int) -> bool:
|
async def delete_history_item(
|
||||||
"""Delete a single history entry by its *id*.
|
self, id: Optional[int] = None, download_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Delete a single history entry by *download_id* (preferred) or *id*.
|
||||||
|
|
||||||
Returns ``True`` if a row was deleted.
|
Returns ``True`` if a row was deleted.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
cursor = conn.execute(
|
if download_id:
|
||||||
"DELETE FROM download_history WHERE id = ?",
|
cursor = conn.execute(
|
||||||
(id,),
|
"DELETE FROM download_history WHERE download_id = ?",
|
||||||
)
|
(download_id,),
|
||||||
|
)
|
||||||
|
elif id is not None:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
|
(id,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
@@ -614,21 +626,34 @@ class DownloadQueueService:
|
|||||||
# Retry
|
# Retry
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
|
async def retry_from_history(
|
||||||
|
self,
|
||||||
|
item_id: Optional[int] = None,
|
||||||
|
download_id: Optional[str] = None,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
"""Re-queue a failed or canceled download from history.
|
"""Re-queue a failed or canceled download from history.
|
||||||
|
|
||||||
Looks up the history record by its primary key. If the status is
|
Looks up the history record by *download_id* (preferred) or
|
||||||
``failed`` or ``canceled`` a new queue entry is created with the
|
*item_id*. If the status is ``failed`` or ``canceled`` a new
|
||||||
same model metadata and a fresh download id, and the original
|
queue entry is created with the same model metadata and a fresh
|
||||||
history entry is **deleted** to prevent exponential growth when
|
download id, and the original history entry is **deleted** to
|
||||||
the retried item is later canceled or fails again and re-retried.
|
prevent exponential growth when the retried item is later
|
||||||
|
canceled or fails again and re-retried.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
conn = self._get_conn()
|
conn = self._get_conn()
|
||||||
row = conn.execute(
|
if download_id:
|
||||||
"SELECT * FROM download_history WHERE id = ?",
|
row = conn.execute(
|
||||||
(item_id,),
|
"SELECT * FROM download_history WHERE download_id = ?",
|
||||||
).fetchone()
|
(download_id,),
|
||||||
|
).fetchone()
|
||||||
|
elif item_id is not None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM download_history WHERE id = ?",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
status = str(row["status"])
|
status = str(row["status"])
|
||||||
@@ -660,7 +685,7 @@ class DownloadQueueService:
|
|||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM download_history WHERE id = ?",
|
"DELETE FROM download_history WHERE id = ?",
|
||||||
(item_id,),
|
(row["id"],),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
queued = conn.execute(
|
queued = conn.execute(
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
|||||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
|
from ..services.model_lifecycle_service import _require_path_in_library_roots
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -493,6 +494,9 @@ class ModelMoveService:
|
|||||||
Dictionary with move result
|
Dictionary with move result
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
if use_default_paths:
|
||||||
# Find the model in cache to get metadata
|
# Find the model in cache to get metadata
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|||||||
@@ -48,6 +48,35 @@ async def delete_model_artifacts(
|
|||||||
return deleted
|
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:
|
class ModelLifecycleService:
|
||||||
"""Co-ordinate destructive and mutating model operations."""
|
"""Co-ordinate destructive and mutating model operations."""
|
||||||
|
|
||||||
@@ -74,6 +103,8 @@ class ModelLifecycleService:
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise ValueError("Model path is required")
|
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()
|
cache = await self._scanner.get_cached_data()
|
||||||
|
|
||||||
cached_entry = None
|
cached_entry = None
|
||||||
@@ -182,6 +213,8 @@ class ModelLifecycleService:
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise ValueError("Model path is required")
|
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_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||||
metadata = await self._metadata_loader(metadata_path)
|
metadata = await self._metadata_loader(metadata_path)
|
||||||
metadata["exclude"] = True
|
metadata["exclude"] = True
|
||||||
@@ -229,6 +262,8 @@ class ModelLifecycleService:
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise ValueError("Model path is required")
|
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):
|
if not os.path.exists(file_path):
|
||||||
raise ValueError("Model file does not exist")
|
raise ValueError("Model file does not exist")
|
||||||
|
|
||||||
@@ -270,6 +305,9 @@ class ModelLifecycleService:
|
|||||||
if not file_paths:
|
if not file_paths:
|
||||||
raise ValueError("No file paths provided for deletion")
|
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)
|
return await self._scanner.bulk_delete_models(file_paths)
|
||||||
|
|
||||||
async def rename_model(
|
async def rename_model(
|
||||||
@@ -280,6 +318,8 @@ class ModelLifecycleService:
|
|||||||
if not file_path or not new_file_name:
|
if not file_path or not new_file_name:
|
||||||
raise ValueError("File path and new file name are required")
|
raise ValueError("File path and new file name are required")
|
||||||
|
|
||||||
|
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||||
|
|
||||||
invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
|
invalid_chars = {"/", "\\", ":", "*", "?", '"', "<", ">", "|"}
|
||||||
if any(char in new_file_name for char in invalid_chars):
|
if any(char in new_file_name for char in invalid_chars):
|
||||||
raise ValueError("Invalid characters in file name")
|
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 ..utils.civitai_utils import resolve_license_info
|
||||||
from .model_cache import ModelCache
|
from .model_cache import ModelCache
|
||||||
from .model_hash_index import ModelHashIndex
|
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 .service_registry import ServiceRegistry
|
||||||
from .websocket_manager import ws_manager
|
from .websocket_manager import ws_manager
|
||||||
from .persistent_model_cache import get_persistent_cache
|
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]
|
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
||||||
source_dir = os.path.dirname(source_path)
|
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)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
|
||||||
@@ -1566,6 +1569,218 @@ class ModelScanner:
|
|||||||
|
|
||||||
return cache_entry if metadata else True
|
return cache_entry if metadata else True
|
||||||
|
|
||||||
|
async def sync_cache_from_metadata(
|
||||||
|
self, file_path: str, metadata_dict: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
"""Opportunistically sync in-memory and persistent caches from metadata.
|
||||||
|
|
||||||
|
Builds a prospective cache entry from *metadata_dict* (deserialized
|
||||||
|
``.metadata.json`` content) and compares it against the current cache
|
||||||
|
entry. When the two are already identical this method returns
|
||||||
|
``False`` without touching anything — avoiding the overhead of
|
||||||
|
``update_single_model_cache``, which always removes and re-inserts
|
||||||
|
the entry, triggers a full resort, and persists via the heavyweight
|
||||||
|
``save_cache()``.
|
||||||
|
|
||||||
|
When differences are detected the update is applied **in-place** with
|
||||||
|
targeted operations:
|
||||||
|
|
||||||
|
* The existing ``raw_data`` entry is modified rather than removed and
|
||||||
|
re-appended (O(1) instead of O(n)).
|
||||||
|
* Tag counts and the hash index are updated incrementally.
|
||||||
|
* The version index is rebuilt only for the affected entry.
|
||||||
|
* ``resort()`` is called **only** when a sort-relevant field changed
|
||||||
|
(``model_name`` / ``file_name`` for name-sort, ``modified`` for
|
||||||
|
date-sort, ``size`` for size-sort).
|
||||||
|
* The persistent (SQLite) cache receives a targeted single-row update
|
||||||
|
via :meth:`PersistentModelCache.update_single_model` rather than a
|
||||||
|
full-table ``save_cache()``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if any cache update was performed, ``False`` if the
|
||||||
|
caches were already in sync.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This is a **best-effort** operation. Failures are logged but
|
||||||
|
never propagated — callers should fire-and-forget via
|
||||||
|
:func:`asyncio.create_task`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self._sync_cache_from_metadata_impl(
|
||||||
|
file_path, metadata_dict
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"sync_cache_from_metadata failed for %s",
|
||||||
|
file_path,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _sync_cache_from_metadata_impl(
|
||||||
|
self, file_path: str, metadata_dict: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
|
# Locate the existing cache entry -----------------------------------
|
||||||
|
existing_idx: Optional[int] = None
|
||||||
|
existing_entry: Optional[Dict[str, Any]] = None
|
||||||
|
for i, item in enumerate(cache.raw_data):
|
||||||
|
if item.get("file_path") == file_path:
|
||||||
|
existing_entry = item
|
||||||
|
existing_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build the desired entry from metadata ------------------------------
|
||||||
|
folder_value = (
|
||||||
|
existing_entry.get("folder", "")
|
||||||
|
if existing_entry
|
||||||
|
else self._calculate_folder(file_path)
|
||||||
|
)
|
||||||
|
desired_entry = self._build_cache_entry(
|
||||||
|
metadata_dict,
|
||||||
|
folder=folder_value,
|
||||||
|
file_path_override=file_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure sha256 is populated (defensive — metadata should have it)
|
||||||
|
if (
|
||||||
|
not desired_entry.get("sha256")
|
||||||
|
and file_path
|
||||||
|
and os.path.exists(file_path)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
sha256 = await calculate_sha256(file_path)
|
||||||
|
if sha256:
|
||||||
|
desired_entry["sha256"] = sha256.lower()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Not in cache at all — delegate to the full update path ------------
|
||||||
|
if existing_entry is None:
|
||||||
|
result = await self.update_single_model_cache(
|
||||||
|
file_path, file_path, metadata_dict
|
||||||
|
)
|
||||||
|
return bool(result)
|
||||||
|
|
||||||
|
# Compare — skip everything if already in sync -----------------------
|
||||||
|
if not self._cache_entries_differ(existing_entry, desired_entry):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Re-validate: the cache may have been replaced concurrently
|
||||||
|
# (e.g. by _apply_scan_result). Use identity check, not equality,
|
||||||
|
# so we detect when the raw_data list was swapped out from under us.
|
||||||
|
if self._cache is None or not any(
|
||||||
|
item is existing_entry for item in self._cache.raw_data
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- Differences detected: apply targeted, in-place updates --------
|
||||||
|
|
||||||
|
# Snapshot old values for delta computations
|
||||||
|
old_tags = list(existing_entry.get("tags") or [])
|
||||||
|
old_sha256: str = existing_entry.get("sha256", "") or ""
|
||||||
|
old_model_name: str = existing_entry.get("model_name", "") or ""
|
||||||
|
old_file_name: str = existing_entry.get("file_name", "") or ""
|
||||||
|
old_modified: float = float(existing_entry.get("modified", 0.0) or 0.0)
|
||||||
|
old_size: int = int(existing_entry.get("size", 0) or 0)
|
||||||
|
old_civitai = existing_entry.get("civitai")
|
||||||
|
|
||||||
|
# ---- In-place update of the cache entry ----
|
||||||
|
existing_entry.clear()
|
||||||
|
existing_entry.update(desired_entry)
|
||||||
|
|
||||||
|
# ---- Incremental tag count update ----
|
||||||
|
new_tags: set = set(desired_entry.get("tags") or [])
|
||||||
|
old_tag_set: set = set(old_tags)
|
||||||
|
for tag in old_tag_set - new_tags:
|
||||||
|
current = self._tags_count.get(tag, 0)
|
||||||
|
if current <= 1:
|
||||||
|
self._tags_count.pop(tag, None)
|
||||||
|
else:
|
||||||
|
self._tags_count[tag] = current - 1
|
||||||
|
for tag in new_tags - old_tag_set:
|
||||||
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
|
# ---- Incremental hash index update ----
|
||||||
|
new_sha = (desired_entry.get("sha256", "") or "").lower()
|
||||||
|
old_sha = (old_sha256 or "").lower()
|
||||||
|
if new_sha != old_sha:
|
||||||
|
if old_sha:
|
||||||
|
self._hash_index.remove_by_path(file_path)
|
||||||
|
if new_sha:
|
||||||
|
self._hash_index.add_entry(new_sha, file_path)
|
||||||
|
|
||||||
|
# ---- Incremental version index update ----
|
||||||
|
new_civitai = desired_entry.get("civitai")
|
||||||
|
if old_civitai != new_civitai:
|
||||||
|
temp_old = {
|
||||||
|
"file_path": file_path,
|
||||||
|
"file_name": old_file_name,
|
||||||
|
"civitai": old_civitai,
|
||||||
|
}
|
||||||
|
cache.remove_from_version_index(temp_old)
|
||||||
|
cache.add_to_version_index(existing_entry)
|
||||||
|
|
||||||
|
# ---- Conditional resort (only when sort-key fields changed) ----
|
||||||
|
need_resort = False
|
||||||
|
_last = cache._last_sort
|
||||||
|
sort_key: Optional[str] = _last[0] if _last != (None, None) else None
|
||||||
|
if sort_key == "name":
|
||||||
|
if (
|
||||||
|
old_model_name != desired_entry.get("model_name", "")
|
||||||
|
or old_file_name != desired_entry.get("file_name", "")
|
||||||
|
):
|
||||||
|
need_resort = True
|
||||||
|
elif sort_key == "date":
|
||||||
|
if old_modified != float(desired_entry.get("modified", 0.0) or 0.0):
|
||||||
|
need_resort = True
|
||||||
|
elif sort_key == "size":
|
||||||
|
if old_size != int(desired_entry.get("size", 0) or 0):
|
||||||
|
need_resort = True
|
||||||
|
|
||||||
|
if need_resort:
|
||||||
|
await cache.resort()
|
||||||
|
|
||||||
|
# ---- Targeted SQL update (single row, not full save_cache) ----
|
||||||
|
persistent = getattr(self, "_persistent_cache", None)
|
||||||
|
if persistent is not None:
|
||||||
|
old_item_for_sql: Dict[str, Any] = {
|
||||||
|
"file_path": file_path,
|
||||||
|
"tags": old_tags,
|
||||||
|
"sha256": old_sha256,
|
||||||
|
}
|
||||||
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
persistent.update_single_model,
|
||||||
|
self.model_type,
|
||||||
|
desired_entry,
|
||||||
|
old_item_for_sql,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_entries_differ(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
||||||
|
"""Return ``True`` when two cache-entry dicts differ in any field.
|
||||||
|
|
||||||
|
Tag lists are compared order-insensitively; all other keys use
|
||||||
|
standard equality.
|
||||||
|
"""
|
||||||
|
a_tags = sorted(a.get("tags") or [])
|
||||||
|
b_tags = sorted(b.get("tags") or [])
|
||||||
|
if a_tags != b_tags:
|
||||||
|
return True
|
||||||
|
|
||||||
|
all_keys = set(a.keys()) | set(b.keys())
|
||||||
|
for key in all_keys:
|
||||||
|
if key == "tags":
|
||||||
|
continue
|
||||||
|
if a.get(key) != b.get(key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, sha256: str) -> bool:
|
||||||
"""Check if a model with given hash exists"""
|
"""Check if a model with given hash exists"""
|
||||||
return self._hash_index.has_hash(sha256.lower())
|
return self._hash_index.has_hash(sha256.lower())
|
||||||
@@ -1618,7 +1833,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()
|
||||||
@@ -1734,6 +1974,8 @@ class ModelScanner:
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_require_path_in_library_roots(file_path, self, label="File path")
|
||||||
|
|
||||||
target_dir = os.path.dirname(file_path)
|
target_dir = os.path.dirname(file_path)
|
||||||
base_name = os.path.basename(file_path)
|
base_name = os.path.basename(file_path)
|
||||||
file_name, main_extension = os.path.splitext(base_name)
|
file_name, main_extension = os.path.splitext(base_name)
|
||||||
|
|||||||
@@ -587,6 +587,95 @@ class PersistentModelCache:
|
|||||||
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
|
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
|
||||||
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
|
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
|
||||||
|
|
||||||
|
def update_single_model(
|
||||||
|
self,
|
||||||
|
model_type: str,
|
||||||
|
new_item: Dict,
|
||||||
|
old_item: Optional[Dict] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update a single model row in the persistent cache.
|
||||||
|
|
||||||
|
A lightweight alternative to :meth:`save_cache` that performs a targeted
|
||||||
|
DELETE + INSERT for the model row and computes incremental tag / hash-index
|
||||||
|
deltas from *old_item*. When *old_item* is omitted the previous tags and
|
||||||
|
hash are not cleaned up (callers should only omit it for brand-new entries).
|
||||||
|
|
||||||
|
All operations run inside a single transaction so readers see a consistent
|
||||||
|
view.
|
||||||
|
"""
|
||||||
|
if not self.is_enabled():
|
||||||
|
return
|
||||||
|
if not self._schema_initialized:
|
||||||
|
self._initialize_schema()
|
||||||
|
if not self._schema_initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path: Optional[str] = new_item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._db_lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
|
||||||
|
# --- model row (DELETE + INSERT = upsert) ---
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
|
||||||
|
(model_type, file_path),
|
||||||
|
)
|
||||||
|
row = self._prepare_model_row(model_type, new_item)
|
||||||
|
conn.execute(self._insert_model_sql(), row)
|
||||||
|
|
||||||
|
# --- tags ---
|
||||||
|
new_tags: set = set(new_item.get("tags") or [])
|
||||||
|
old_tags: set = set(old_item.get("tags") or []) if old_item else set()
|
||||||
|
tags_to_delete = old_tags - new_tags
|
||||||
|
tags_to_insert = new_tags - old_tags
|
||||||
|
|
||||||
|
if tags_to_delete:
|
||||||
|
conn.executemany(
|
||||||
|
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
|
||||||
|
[(model_type, file_path, t) for t in tags_to_delete],
|
||||||
|
)
|
||||||
|
if tags_to_insert:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
|
||||||
|
[(model_type, file_path, t) for t in tags_to_insert],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- hash_index ---
|
||||||
|
new_sha: Optional[str] = (new_item.get("sha256") or "").lower() or None
|
||||||
|
old_sha: Optional[str] = (
|
||||||
|
(old_item.get("sha256") or "").lower() or None
|
||||||
|
) if old_item else None
|
||||||
|
if new_sha != old_sha:
|
||||||
|
if old_sha:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
|
||||||
|
(model_type, old_sha, file_path),
|
||||||
|
)
|
||||||
|
if new_sha:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
|
||||||
|
(model_type, new_sha, file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update single model in persistent cache (%s): %s",
|
||||||
|
file_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
|
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
|
||||||
tag_rows = conn.execute(
|
tag_rows = conn.execute(
|
||||||
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ NODE_TYPES = {
|
|||||||
"Lora Loader (LoraManager)": 1,
|
"Lora Loader (LoraManager)": 1,
|
||||||
"Lora Stacker (LoraManager)": 2,
|
"Lora Stacker (LoraManager)": 2,
|
||||||
"WanVideo Lora Select (LoraManager)": 3,
|
"WanVideo Lora Select (LoraManager)": 3,
|
||||||
|
"Create Hook LoRA (LoraManager)": 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default ComfyUI node color when bgcolor is null
|
# Default ComfyUI node color when bgcolor is null
|
||||||
|
|||||||
@@ -113,6 +113,35 @@ def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return legacy_folder
|
return legacy_folder
|
||||||
|
elif not os.path.exists(resolved_folder):
|
||||||
|
# Reverse migration: when consolidating from multi-library to
|
||||||
|
# single-library mode (e.g. after "default" was cleaned up), look
|
||||||
|
# for existing example images inside library-named subdirectories
|
||||||
|
# and bring them back to the root level.
|
||||||
|
root = get_example_images_root()
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
for entry in os.listdir(root):
|
||||||
|
entry_path = os.path.join(root, entry)
|
||||||
|
if not os.path.isdir(entry_path):
|
||||||
|
continue
|
||||||
|
if is_hash_folder(entry) or entry == "_deleted":
|
||||||
|
continue
|
||||||
|
if not _library_folder_has_only_hash_dirs(entry_path):
|
||||||
|
continue
|
||||||
|
legacy = os.path.join(entry_path, normalized_hash)
|
||||||
|
if os.path.exists(legacy):
|
||||||
|
shutil.move(legacy, resolved_folder)
|
||||||
|
logger.info(
|
||||||
|
"Consolidated example images from '%s' to '%s'",
|
||||||
|
legacy, resolved_folder,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to consolidate example images during "
|
||||||
|
"library merge: %s", exc,
|
||||||
|
)
|
||||||
|
|
||||||
return resolved_folder
|
return resolved_folder
|
||||||
|
|
||||||
|
|||||||
@@ -488,6 +488,12 @@ def calculate_relative_path_for_model(
|
|||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
formatted_path = formatted_path.replace(" ", "_")
|
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
|
return formatted_path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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.9"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
# Ensure the script's directory is on sys.path so that py.* imports resolve
|
||||||
|
# regardless of the current working directory (e.g. when launched via
|
||||||
|
# ComfyUI's python_embeded from the ComfyUI root directory).
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
from py.middleware.cache_middleware import cache_control
|
from py.middleware.cache_middleware import cache_control
|
||||||
from py.middleware.error_middleware import api_json_error
|
from py.middleware.error_middleware import api_json_error
|
||||||
from py.utils.settings_paths import ensure_settings_file
|
from py.utils.settings_paths import ensure_settings_file
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ export const MODEL_CONFIG = {
|
|||||||
* @returns {Object} Object containing all API endpoints for the model type
|
* @returns {Object} Object containing all API endpoints for the model type
|
||||||
*/
|
*/
|
||||||
export function getApiEndpoints(modelType) {
|
export function getApiEndpoints(modelType) {
|
||||||
if (!Object.values(MODEL_TYPES).includes(modelType)) {
|
|
||||||
throw new Error(`Invalid model type: ${modelType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Base CRUD operations
|
// Base CRUD operations
|
||||||
list: `/api/lm/${modelType}/list`,
|
list: `/api/lm/${modelType}/list`,
|
||||||
@@ -93,6 +89,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: '',
|
||||||
|
|||||||
@@ -369,21 +369,24 @@ export function getMatureBlurThreshold(settings = {}) {
|
|||||||
export const NODE_TYPES = {
|
export const NODE_TYPES = {
|
||||||
LORA_LOADER: 1,
|
LORA_LOADER: 1,
|
||||||
LORA_STACKER: 2,
|
LORA_STACKER: 2,
|
||||||
WAN_VIDEO_LORA_SELECT: 3
|
WAN_VIDEO_LORA_SELECT: 3,
|
||||||
|
HOOK_LORA: 4
|
||||||
};
|
};
|
||||||
|
|
||||||
// Node type names to IDs mapping
|
// Node type names to IDs mapping
|
||||||
export const NODE_TYPE_NAMES = {
|
export const NODE_TYPE_NAMES = {
|
||||||
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
|
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
|
||||||
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER,
|
"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
|
// Node type icons
|
||||||
export const NODE_TYPE_ICONS = {
|
export const NODE_TYPE_ICONS = {
|
||||||
[NODE_TYPES.LORA_LOADER]: "fas fa-l",
|
[NODE_TYPES.LORA_LOADER]: "fas fa-l",
|
||||||
[NODE_TYPES.LORA_STACKER]: "fas fa-s",
|
[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
|
// Default ComfyUI node color when bgcolor is null
|
||||||
|
|||||||
@@ -141,6 +141,20 @@ const PARAM_TO_WIDGET_CANDIDATES = {
|
|||||||
scheduler: ['scheduler'],
|
scheduler: ['scheduler'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Node-type-specific widget name overrides.
|
||||||
|
// Keys are ComfyUI node class names (e.g. "GlobalSeed //Inspire").
|
||||||
|
// Values are partial PARAM_TO_WIDGET_CANDIDATES maps; the per-node candidates
|
||||||
|
// are tried *before* the global ones. Only the params listed here are
|
||||||
|
// overridden — every other param still uses the global candidates.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const NODE_TYPE_WIDGET_OVERRIDES = {
|
||||||
|
// Inspire Pack — Global Seed node stores the seed in a widget named "value"
|
||||||
|
'GlobalSeed //Inspire': {
|
||||||
|
seed: ['value'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parse a combined sampler+scheduler value (space-separated or underscore)
|
// Parse a combined sampler+scheduler value (space-separated or underscore)
|
||||||
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
|
// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta"
|
||||||
@@ -235,7 +249,7 @@ function resolveSamplerScheduler(rawValue) {
|
|||||||
// Find which gen params can be sent to a given node, matching by widget names
|
// Find which gen params can be sent to a given node, matching by widget names
|
||||||
// Returns array of { widgetName, value } objects
|
// Returns array of { widgetName, value } objects
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
function findMatchingWidgets(nodeWidgetNames, resolvedParams, nodeType) {
|
||||||
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
|
if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -243,6 +257,26 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
|
const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase()));
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
|
// Resolve node-type-specific overrides (if any)
|
||||||
|
const typeOverrides =
|
||||||
|
nodeType && typeof nodeType === 'string'
|
||||||
|
? (NODE_TYPE_WIDGET_OVERRIDES[nodeType] || {})
|
||||||
|
: {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the effective candidate list for a parameter:
|
||||||
|
* type-specific overrides (if any) come first, then the global candidates.
|
||||||
|
*/
|
||||||
|
function getCandidates(key) {
|
||||||
|
const global = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
|
||||||
|
const extra = typeOverrides[key];
|
||||||
|
if (extra && Array.isArray(extra) && extra.length > 0) {
|
||||||
|
// Prepend type-specific candidates; keep global as fallback
|
||||||
|
return [...extra, ...global];
|
||||||
|
}
|
||||||
|
return global;
|
||||||
|
}
|
||||||
|
|
||||||
// Simple numeric/string params: seed, steps, cfg
|
// Simple numeric/string params: seed, steps, cfg
|
||||||
const simpleParams = [
|
const simpleParams = [
|
||||||
{ key: 'seed', value: resolvedParams.seed },
|
{ key: 'seed', value: resolvedParams.seed },
|
||||||
@@ -251,10 +285,10 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
];
|
];
|
||||||
for (const { key, value } of simpleParams) {
|
for (const { key, value } of simpleParams) {
|
||||||
if (value === undefined || value === null || value === '') continue;
|
if (value === undefined || value === null || value === '') continue;
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key];
|
const candidates = getCandidates(key);
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: String(value) });
|
updates.push({ widgetName: candidate, value });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,7 +296,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
|
|
||||||
// Sampler
|
// Sampler
|
||||||
if (resolvedParams.sampler) {
|
if (resolvedParams.sampler) {
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler;
|
const candidates = getCandidates('sampler');
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
|
updates.push({ widgetName: candidate, value: resolvedParams.sampler });
|
||||||
@@ -273,7 +307,7 @@ function findMatchingWidgets(nodeWidgetNames, resolvedParams) {
|
|||||||
|
|
||||||
// Scheduler
|
// Scheduler
|
||||||
if (resolvedParams.scheduler) {
|
if (resolvedParams.scheduler) {
|
||||||
const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler;
|
const candidates = getCandidates('scheduler');
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (widgetSet.has(candidate.toLowerCase())) {
|
if (widgetSet.has(candidate.toLowerCase())) {
|
||||||
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
|
updates.push({ widgetName: candidate, value: resolvedParams.scheduler });
|
||||||
@@ -290,6 +324,7 @@ export {
|
|||||||
SCHEDULER_SUFFIXES,
|
SCHEDULER_SUFFIXES,
|
||||||
SCHEDULER_ONLY_VALUES,
|
SCHEDULER_ONLY_VALUES,
|
||||||
PARAM_TO_WIDGET_CANDIDATES,
|
PARAM_TO_WIDGET_CANDIDATES,
|
||||||
|
NODE_TYPE_WIDGET_OVERRIDES,
|
||||||
parseCombinedSamplerName,
|
parseCombinedSamplerName,
|
||||||
resolveSamplerScheduler,
|
resolveSamplerScheduler,
|
||||||
findMatchingWidgets,
|
findMatchingWidgets,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -605,7 +608,7 @@ function isNodeEnabled(node) {
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// ComfyUI node mode: 0 = Normal/Enabled, others = Always/Never/OnEvent
|
// ComfyUI node mode (LGraphEventMode): 0 = Always, 2 = Never, 4 = Bypass
|
||||||
return node.mode === undefined || node.mode === 0;
|
return node.mode === undefined || node.mode === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1144,8 +1147,8 @@ export async function sendGenParamsToWorkflow(genParams) {
|
|||||||
const node = targetNodes[nodeKey];
|
const node = targetNodes[nodeKey];
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
|
|
||||||
const widgetNames = node.widget_names || [];
|
const widgetNames = getWidgetNames(node);
|
||||||
const updates = findMatchingWidgets(widgetNames, raw);
|
const updates = findMatchingWidgets(widgetNames, raw, node.type_name);
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
|
showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -823,3 +823,73 @@ def test_apply_library_settings_ignores_extra_lora_path_overlapping_primary_root
|
|||||||
"same lora folder" in record.message.lower()
|
"same lora folder" in record.message.lower()
|
||||||
for record in caplog.records
|
for record in caplog.records
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_paths_removes_stale_empty_default_when_comfyui_exists(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path,
|
||||||
|
):
|
||||||
|
"""When an empty-shell 'default' library coexists with 'comfyui', the
|
||||||
|
stale 'default' entry should be removed and 'comfyui' activated."""
|
||||||
|
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
class FakeSettingsService:
|
||||||
|
def __init__(self):
|
||||||
|
# Replicate the user's settings.json: empty default + populated comfyui
|
||||||
|
self.libraries = {
|
||||||
|
"default": {
|
||||||
|
"folder_paths": {},
|
||||||
|
"extra_folder_paths": {},
|
||||||
|
"default_lora_root": "",
|
||||||
|
"default_checkpoint_root": "",
|
||||||
|
"default_unet_root": "",
|
||||||
|
"default_embedding_root": "",
|
||||||
|
"recipes_path": "",
|
||||||
|
},
|
||||||
|
"comfyui": {
|
||||||
|
"folder_paths": {
|
||||||
|
key: list(value) for key, value in folder_paths.items()
|
||||||
|
},
|
||||||
|
"default_lora_root": folder_paths["loras"][0],
|
||||||
|
"default_checkpoint_root": folder_paths["checkpoints"][0],
|
||||||
|
"default_embedding_root": folder_paths["embeddings"][0],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# No active_library key — get_active_library_name() falls back to
|
||||||
|
# dict order, returning "default".
|
||||||
|
self.active_library = "default"
|
||||||
|
self.delete_calls: list[str] = []
|
||||||
|
self.upsert_calls: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
|
def get_libraries(self):
|
||||||
|
return dict(self.libraries)
|
||||||
|
|
||||||
|
def delete_library(self, name: str):
|
||||||
|
self.delete_calls.append(name)
|
||||||
|
self.libraries.pop(name, None)
|
||||||
|
|
||||||
|
def rename_library(self, *_):
|
||||||
|
raise AssertionError("rename_library should not be invoked")
|
||||||
|
|
||||||
|
def get_active_library_name(self):
|
||||||
|
return self.active_library
|
||||||
|
|
||||||
|
def upsert_library(self, name: str, **payload):
|
||||||
|
self.upsert_calls.append((name, payload))
|
||||||
|
self.libraries[name] = {**payload}
|
||||||
|
if payload.get("activate"):
|
||||||
|
self.active_library = name
|
||||||
|
|
||||||
|
fake_settings = FakeSettingsService()
|
||||||
|
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||||
|
|
||||||
|
config_module.Config()
|
||||||
|
|
||||||
|
assert fake_settings.delete_calls == ["default"]
|
||||||
|
assert "default" not in fake_settings.libraries
|
||||||
|
assert set(fake_settings.libraries.keys()) == {"comfyui"}
|
||||||
|
|
||||||
|
assert len(fake_settings.upsert_calls) == 1
|
||||||
|
name, payload = fake_settings.upsert_calls[0]
|
||||||
|
assert name == "comfyui"
|
||||||
|
assert payload["activate"] is True
|
||||||
|
assert fake_settings.active_library == "comfyui"
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ sys.modules['comfy.utils'] = comfy_mock.utils
|
|||||||
sys.modules['comfy.sd'] = comfy_mock.sd
|
sys.modules['comfy.sd'] = comfy_mock.sd
|
||||||
sys.modules['comfy.model_management'] = comfy_mock.model_management
|
sys.modules['comfy.model_management'] = comfy_mock.model_management
|
||||||
sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types
|
sys.modules['comfy.comfy_types'] = comfy_mock.comfy_types
|
||||||
|
sys.modules['comfy.hooks'] = MockModule("comfy.hooks")
|
||||||
|
|
||||||
execution_mock = MockModule("execution")
|
execution_mock = MockModule("execution")
|
||||||
execution_mock.PromptExecutor = mock.MagicMock()
|
execution_mock.PromptExecutor = mock.MagicMock()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
parseCombinedSamplerName,
|
parseCombinedSamplerName,
|
||||||
resolveSamplerScheduler,
|
resolveSamplerScheduler,
|
||||||
findMatchingWidgets,
|
findMatchingWidgets,
|
||||||
|
NODE_TYPE_WIDGET_OVERRIDES,
|
||||||
} from '../../../static/js/utils/genParamsMapper.js';
|
} from '../../../static/js/utils/genParamsMapper.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -204,9 +205,9 @@ describe('findMatchingWidgets', () => {
|
|||||||
|
|
||||||
it('matches seed to seed widget', () => {
|
it('matches seed to seed widget', () => {
|
||||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
expect(updates).toContainEqual({ widgetName: 'seed', value: '42' });
|
expect(updates).toContainEqual({ widgetName: 'seed', value: 42 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'steps', value: '30' });
|
expect(updates).toContainEqual({ widgetName: 'steps', value: 30 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' });
|
expect(updates).toContainEqual({ widgetName: 'cfg', value: 7 });
|
||||||
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
|
expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' });
|
||||||
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
|
expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' });
|
||||||
});
|
});
|
||||||
@@ -221,7 +222,7 @@ describe('findMatchingWidgets', () => {
|
|||||||
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
|
const seedUpdate = updates.find(u => u.widgetName === 'noise_seed');
|
||||||
expect(seedUpdate).toBeDefined();
|
expect(seedUpdate).toBeDefined();
|
||||||
expect(seedUpdate.value).toBe('42');
|
expect(seedUpdate.value).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches rgthree-style sampler widget name', () => {
|
it('matches rgthree-style sampler widget name', () => {
|
||||||
@@ -243,4 +244,53 @@ describe('findMatchingWidgets', () => {
|
|||||||
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved);
|
||||||
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
|
expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- node-type-specific overrides ---
|
||||||
|
it('matches GlobalSeed //Inspire value widget for seed param', () => {
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['value', 'mode', 'action', 'last_seed'],
|
||||||
|
{ seed: 42 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'value', value: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores nodeType when it does not match any override entry', () => {
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['value', 'mode', 'action', 'last_seed'],
|
||||||
|
{ seed: 42 },
|
||||||
|
'SomeOtherNode'
|
||||||
|
);
|
||||||
|
expect(updates).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still falls back to global candidates when override candidates do not match', () => {
|
||||||
|
// GlobalSeed override does not include steps — should use global candidate "steps"
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['steps', 'cfg', 'sampler_name'],
|
||||||
|
{ steps: 20 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'steps', value: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers overrides when both override and global candidates match', () => {
|
||||||
|
// If a hypothetical node has both "value" and "seed" widgets AND a
|
||||||
|
// GlobalSeed override, the override candidate "value" should take precedence
|
||||||
|
const updates = findMatchingWidgets(
|
||||||
|
['seed', 'noise_seed', 'value', 'mode'],
|
||||||
|
{ seed: 99 },
|
||||||
|
'GlobalSeed //Inspire'
|
||||||
|
);
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0].widgetName).toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits nodeType argument and still matches via global candidates', () => {
|
||||||
|
const updates = findMatchingWidgets(['seed', 'steps', 'cfg'], { seed: 7 });
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toEqual({ widgetName: 'seed', value: 7 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1189,6 +1189,65 @@ def test_relative_path_sanitizes_model_and_version_placeholders():
|
|||||||
assert relative_path == "Fancy_Model/Version_One"
|
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):
|
def test_distribute_preview_to_entries_moves_and_copies(tmp_path):
|
||||||
"""Test that preview distribution moves file to first entry and copies to others."""
|
"""Test that preview distribution moves file to first entry and copies to others."""
|
||||||
manager = DownloadManager()
|
manager = DownloadManager()
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Unit tests for DownloadQueueService history operations.
|
||||||
|
|
||||||
|
Covers the new ``download_id``-based code paths in
|
||||||
|
``delete_history_item`` and ``retry_from_history``, plus backward
|
||||||
|
compatibility with ``id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.download_queue_service import DownloadQueueService
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_service(tmp_path: Path) -> DownloadQueueService:
|
||||||
|
"""Create a DownloadQueueService backed by a temporary database."""
|
||||||
|
return DownloadQueueService(db_path=str(tmp_path / "queue.sqlite"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(
|
||||||
|
svc: DownloadQueueService,
|
||||||
|
download_id: str,
|
||||||
|
status: str = "failed",
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Insert a history row and return (autoincrement id, download_id)."""
|
||||||
|
row_id = await svc.add_to_history(
|
||||||
|
download_id=download_id,
|
||||||
|
model_id=1,
|
||||||
|
model_version_id=100,
|
||||||
|
model_name="TestModel",
|
||||||
|
version_name="v1",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return row_id, download_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# delete_history_item
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_download_id(tmp_path: Path) -> None:
|
||||||
|
"""delete_history_item(download_id=...) removes the correct row."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-aaa")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item(download_id=did)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
# Verify gone from history
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_by_id_legacy(tmp_path: Path) -> None:
|
||||||
|
"""delete_history_item(id=...) still works (backward compat)."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, _did = await _seed(svc, "dl-bbb")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item(id=rid)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_no_params_returns_false(tmp_path: Path) -> None:
|
||||||
|
"""Calling delete_history_item with no params returns False."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-ccc")
|
||||||
|
|
||||||
|
deleted = await svc.delete_history_item()
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
# Row is still there
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_download_id_precedence(tmp_path: Path) -> None:
|
||||||
|
"""When both id and download_id are given, download_id is used."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
# Insert two rows
|
||||||
|
rid_a, did_a = await _seed(svc, "dl-aaa")
|
||||||
|
rid_b, did_b = await _seed(svc, "dl-bbb")
|
||||||
|
|
||||||
|
# Delete by download_id while also passing the *wrong* id
|
||||||
|
deleted = await svc.delete_history_item(id=rid_b, download_id=did_a)
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
ids_left = [it["id"] for it in history["items"]]
|
||||||
|
assert rid_a not in ids_left # dl-aaa was deleted
|
||||||
|
assert rid_b in ids_left # dl-bbb (wrong id) was ignored
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# retry_from_history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_download_id(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history(download_id=...) re-queues and deletes history."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-fail", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
# History row must be deleted (the bug fix)
|
||||||
|
history = await svc.get_history()
|
||||||
|
ids_in_history = [it["id"] for it in history["items"]]
|
||||||
|
assert rid not in ids_in_history
|
||||||
|
|
||||||
|
# Queue must contain the new item
|
||||||
|
queue = await svc.get_queue()
|
||||||
|
assert len(queue) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_download_id_canceled(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history works for 'canceled' status too."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, did = await _seed(svc, "dl-cancel", status="canceled")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_by_id_legacy(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history(item_id=...) still works (backward compat)."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
rid, _did = await _seed(svc, "dl-legacy", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(item_id=rid)
|
||||||
|
assert item is not None
|
||||||
|
assert item["status"] == "queued"
|
||||||
|
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_no_params_returns_none(tmp_path: Path) -> None:
|
||||||
|
"""Calling retry_from_history with no params returns None."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-none", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history()
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
# History untouched
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_non_retryable_status(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history returns None for 'completed' status."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
_rid, did = await _seed(svc, "dl-ok", status="completed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id=did)
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
# History untouched
|
||||||
|
history = await svc.get_history()
|
||||||
|
assert len(history["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_unknown_download_id(tmp_path: Path) -> None:
|
||||||
|
"""retry_from_history returns None for a non-existent download_id."""
|
||||||
|
svc = _make_service(tmp_path)
|
||||||
|
await _seed(svc, "dl-real", status="failed")
|
||||||
|
|
||||||
|
item = await svc.retry_from_history(download_id="dl-nope")
|
||||||
|
assert item is None
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,164 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
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.metadata_manager import MetadataManager
|
||||||
from py.utils.models import LoraMetadata
|
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:
|
class DummyCache:
|
||||||
def __init__(self, raw_data):
|
def __init__(self, raw_data):
|
||||||
self.raw_data = raw_data
|
self.raw_data = raw_data
|
||||||
|
|||||||
@@ -667,3 +667,310 @@ async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path
|
|||||||
# No warning should be logged when there are no duplicates
|
# No warning should be logged when there are no duplicates
|
||||||
for record in caplog.records:
|
for record in caplog.records:
|
||||||
assert "Duplicate filename conflict detected" not in record.message
|
assert "Duplicate filename conflict detected" not in record.message
|
||||||
|
|
||||||
|
|
||||||
|
# ── _cache_entries_differ ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"a_tags, b_tags, expect_differ",
|
||||||
|
[
|
||||||
|
(["alpha", "beta"], ["beta", "alpha"], False), # order-insensitive
|
||||||
|
(["alpha"], ["alpha", "beta"], True), # count differs
|
||||||
|
([], ["alpha"], True),
|
||||||
|
(None, [], False), # None ≈ []
|
||||||
|
(["alpha"], None, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cache_entries_differ_tags(a_tags, b_tags, expect_differ):
|
||||||
|
base = {"file_path": "/m/a.safetensors", "model_name": "A", "size": 1}
|
||||||
|
entry_a = {**base, "tags": a_tags}
|
||||||
|
entry_b = {**base, "tags": b_tags}
|
||||||
|
assert ModelScanner._cache_entries_differ(entry_a, entry_b) == expect_differ
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_entries_differ_identical():
|
||||||
|
entry = {
|
||||||
|
"file_path": "/m/a.safetensors", "model_name": "A", "size": 1,
|
||||||
|
"tags": ["x"], "civitai": {"id": 1}, "notes": "hi",
|
||||||
|
}
|
||||||
|
assert ModelScanner._cache_entries_differ(entry, dict(entry)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_entries_differ_field_changed():
|
||||||
|
a = {"file_path": "/m/a.safetensors", "model_name": "A", "size": 1}
|
||||||
|
b = {**a, "model_name": "B"}
|
||||||
|
assert ModelScanner._cache_entries_differ(a, b) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_entries_differ_extra_key():
|
||||||
|
a = {"file_path": "/m/a.safetensors", "model_name": "A"}
|
||||||
|
b = {**a, "extra_field": "value"}
|
||||||
|
assert ModelScanner._cache_entries_differ(a, b) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── sync_cache_from_metadata ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cache_entry(**overrides) -> dict:
|
||||||
|
entry = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "TestModel",
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "old note",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"metadata_source": None,
|
||||||
|
"exclude": False,
|
||||||
|
"db_checked": False,
|
||||||
|
"last_checked_at": 0.0,
|
||||||
|
"tags": ["alpha"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"civitai_deleted": False,
|
||||||
|
"skip_metadata_refresh": False,
|
||||||
|
"hf_url": "",
|
||||||
|
"license_flags": 113,
|
||||||
|
"hash_status": "completed",
|
||||||
|
}
|
||||||
|
entry.update(overrides)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_no_change(tmp_path: Path):
|
||||||
|
"""When metadata matches the cache entry, return False and mutate nothing."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
entry = _make_cache_entry()
|
||||||
|
scanner._cache = ModelCache(
|
||||||
|
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
|
||||||
|
)
|
||||||
|
await scanner._cache.resort()
|
||||||
|
scanner._tags_count = {"alpha": 1}
|
||||||
|
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
|
||||||
|
|
||||||
|
# metadata_dict that would produce the identical cache entry
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "TestModel",
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "old note",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": ["alpha"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/a.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is False
|
||||||
|
# Verify cache was NOT mutated
|
||||||
|
cached = await scanner.get_cached_data()
|
||||||
|
assert cached.raw_data[0]["notes"] == "old note"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_in_place_update(tmp_path: Path):
|
||||||
|
"""When metadata differs, update the cache entry in-place."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
entry = _make_cache_entry(notes="old note", tags=["alpha"], model_name="OldName")
|
||||||
|
scanner._cache = ModelCache(
|
||||||
|
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
|
||||||
|
)
|
||||||
|
await scanner._cache.resort()
|
||||||
|
scanner._tags_count = {"alpha": 1}
|
||||||
|
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
|
||||||
|
|
||||||
|
# Capture the exact dict object in raw_data before sync
|
||||||
|
original_entry_ref = scanner._cache.raw_data[0]
|
||||||
|
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "NewName",
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "new note",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": ["beta", "gamma"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/a.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is True
|
||||||
|
|
||||||
|
cached = await scanner.get_cached_data()
|
||||||
|
updated = cached.raw_data[0]
|
||||||
|
# In-place: the same dict object persisted in raw_data
|
||||||
|
assert updated is original_entry_ref
|
||||||
|
assert updated["notes"] == "new note"
|
||||||
|
assert updated["model_name"] == "NewName"
|
||||||
|
assert sorted(updated["tags"]) == ["beta", "gamma"]
|
||||||
|
# Tag counts updated incrementally
|
||||||
|
assert scanner._tags_count.get("alpha", 0) == 0
|
||||||
|
assert scanner._tags_count.get("beta", 0) == 1
|
||||||
|
assert scanner._tags_count.get("gamma", 0) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_not_in_cache_delegates(tmp_path: Path):
|
||||||
|
"""When the file_path is not in the cache at all, fall back to full update."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
scanner._cache = ModelCache(raw_data=[], folders=[], name_display_mode="model_name")
|
||||||
|
await scanner._cache.resort()
|
||||||
|
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/b.safetensors",
|
||||||
|
"model_name": "BrandNew",
|
||||||
|
"file_name": "b",
|
||||||
|
"folder": "",
|
||||||
|
"size": 200,
|
||||||
|
"modified": 20.0,
|
||||||
|
"sha256": "def456",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": [],
|
||||||
|
"civitai": {},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/b.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is True
|
||||||
|
cached = await scanner.get_cached_data()
|
||||||
|
assert len(cached.raw_data) == 1
|
||||||
|
assert cached.raw_data[0]["model_name"] == "BrandNew"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_conditional_resort_skipped(tmp_path: Path, monkeypatch):
|
||||||
|
"""When only non-sort-key fields change, resort() is NOT called."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
entry = _make_cache_entry(notes="old note", model_name="SameName")
|
||||||
|
scanner._cache = ModelCache(
|
||||||
|
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
|
||||||
|
)
|
||||||
|
await scanner._cache.resort()
|
||||||
|
scanner._cache._last_sort = ("name", "asc") # name sort is active
|
||||||
|
scanner._tags_count = {"alpha": 1}
|
||||||
|
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
|
||||||
|
|
||||||
|
# Track resort calls
|
||||||
|
resort_called = False
|
||||||
|
original_resort = scanner._cache.resort
|
||||||
|
|
||||||
|
async def tracking_resort():
|
||||||
|
nonlocal resort_called
|
||||||
|
resort_called = True
|
||||||
|
await original_resort()
|
||||||
|
|
||||||
|
monkeypatch.setattr(scanner._cache, "resort", tracking_resort)
|
||||||
|
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "SameName", # unchanged — no resort needed
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "updated note", # changed, but not sort-relevant
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": ["alpha"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/a.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is True
|
||||||
|
assert resort_called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_cache_conditional_resort_triggered(tmp_path: Path, monkeypatch):
|
||||||
|
"""When the sort-key field changes, resort() IS called."""
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
entry = _make_cache_entry(model_name="OldName")
|
||||||
|
scanner._cache = ModelCache(
|
||||||
|
raw_data=[dict(entry)], folders=[], name_display_mode="model_name"
|
||||||
|
)
|
||||||
|
await scanner._cache.resort()
|
||||||
|
scanner._cache._last_sort = ("name", "asc")
|
||||||
|
scanner._tags_count = {"alpha": 1}
|
||||||
|
scanner._hash_index.add_entry("abc123", "/m/a.safetensors")
|
||||||
|
|
||||||
|
resort_calls = 0
|
||||||
|
original_resort = scanner._cache.resort
|
||||||
|
|
||||||
|
async def tracking_resort():
|
||||||
|
nonlocal resort_calls
|
||||||
|
resort_calls += 1
|
||||||
|
await original_resort()
|
||||||
|
|
||||||
|
monkeypatch.setattr(scanner._cache, "resort", tracking_resort)
|
||||||
|
|
||||||
|
metadata_dict = {
|
||||||
|
"file_path": "/m/a.safetensors",
|
||||||
|
"model_name": "NewName", # changed — should trigger resort
|
||||||
|
"file_name": "a",
|
||||||
|
"folder": "",
|
||||||
|
"size": 100,
|
||||||
|
"modified": 10.0,
|
||||||
|
"sha256": "abc123",
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"preview_url": "",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"from_civitai": True,
|
||||||
|
"favorite": False,
|
||||||
|
"notes": "old note",
|
||||||
|
"usage_tips": "{}",
|
||||||
|
"tags": ["alpha"],
|
||||||
|
"civitai": {"id": 111, "modelId": 222, "name": "v1"},
|
||||||
|
"hf_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = await scanner.sync_cache_from_metadata(
|
||||||
|
"/m/a.safetensors", metadata_dict
|
||||||
|
)
|
||||||
|
assert changed is True
|
||||||
|
assert resort_calls == 1
|
||||||
|
|||||||
@@ -225,3 +225,119 @@ def test_incremental_updates_only_touch_changed_rows(tmp_path: Path, monkeypatch
|
|||||||
assert second['metadata_source'] == 'archive_db'
|
assert second['metadata_source'] == 'archive_db'
|
||||||
assert second['civitai_deleted'] is True
|
assert second['civitai_deleted'] is True
|
||||||
assert second['civitai']['creator']['username'] == 'builder_v2'
|
assert second['civitai']['creator']['username'] == 'builder_v2'
|
||||||
|
|
||||||
|
|
||||||
|
# ── update_single_model ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_single_model_insert(tmp_path: Path, monkeypatch):
|
||||||
|
"""Insert a brand-new model row via update_single_model."""
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
db_path = tmp_path / 'cache.sqlite'
|
||||||
|
store = PersistentModelCache(db_path=str(db_path))
|
||||||
|
|
||||||
|
file_path = (tmp_path / 'x.safetensors').as_posix()
|
||||||
|
new_item = {
|
||||||
|
'file_path': file_path,
|
||||||
|
'file_name': 'x',
|
||||||
|
'model_name': 'Model X',
|
||||||
|
'folder': '',
|
||||||
|
'size': 42,
|
||||||
|
'modified': 1.0,
|
||||||
|
'sha256': 'sha-x',
|
||||||
|
'base_model': 'SDXL',
|
||||||
|
'preview_url': '',
|
||||||
|
'preview_nsfw_level': 0,
|
||||||
|
'from_civitai': True,
|
||||||
|
'favorite': True,
|
||||||
|
'notes': 'test note',
|
||||||
|
'usage_tips': '{}',
|
||||||
|
'metadata_source': None,
|
||||||
|
'exclude': False,
|
||||||
|
'db_checked': False,
|
||||||
|
'last_checked_at': 0.0,
|
||||||
|
'tags': ['test', 'new'],
|
||||||
|
'civitai': None,
|
||||||
|
'civitai_deleted': False,
|
||||||
|
'skip_metadata_refresh': False,
|
||||||
|
'license_flags': DEFAULT_LICENSE_FLAGS,
|
||||||
|
'hash_status': 'completed',
|
||||||
|
'hf_url': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
store.update_single_model('dummy', new_item)
|
||||||
|
|
||||||
|
persisted = store.load_cache('dummy')
|
||||||
|
assert persisted is not None
|
||||||
|
items = {item['file_path']: item for item in persisted.raw_data}
|
||||||
|
assert file_path in items
|
||||||
|
assert items[file_path]['model_name'] == 'Model X'
|
||||||
|
assert items[file_path]['favorite'] is True
|
||||||
|
assert sorted(items[file_path]['tags']) == ['new', 'test']
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_single_model_update_tags(tmp_path: Path, monkeypatch):
|
||||||
|
"""Tags are updated incrementally: old tags removed, new tags added."""
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
db_path = tmp_path / 'cache.sqlite'
|
||||||
|
store = PersistentModelCache(db_path=str(db_path))
|
||||||
|
|
||||||
|
file_path = (tmp_path / 'y.safetensors').as_posix()
|
||||||
|
base = {
|
||||||
|
'file_path': file_path, 'file_name': 'y', 'model_name': 'Y',
|
||||||
|
'folder': '', 'size': 1, 'modified': 1.0, 'sha256': 'sha-y',
|
||||||
|
'base_model': '', 'preview_url': '', 'preview_nsfw_level': 0,
|
||||||
|
'from_civitai': True, 'favorite': False, 'notes': '', 'usage_tips': '{}',
|
||||||
|
'metadata_source': None, 'exclude': False, 'db_checked': False,
|
||||||
|
'last_checked_at': 0.0, 'civitai': None, 'civitai_deleted': False,
|
||||||
|
'skip_metadata_refresh': False, 'license_flags': DEFAULT_LICENSE_FLAGS,
|
||||||
|
'hash_status': 'completed', 'hf_url': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# First insert with tags [alpha, beta]
|
||||||
|
store.update_single_model('dummy', {**base, 'tags': ['alpha', 'beta']})
|
||||||
|
|
||||||
|
# Now update: replace with [beta, gamma]
|
||||||
|
old_item = {'file_path': file_path, 'tags': ['alpha', 'beta'], 'sha256': 'sha-y'}
|
||||||
|
new_item = {**base, 'tags': ['beta', 'gamma']}
|
||||||
|
store.update_single_model('dummy', new_item, old_item=old_item)
|
||||||
|
|
||||||
|
persisted = store.load_cache('dummy')
|
||||||
|
assert persisted is not None
|
||||||
|
items = {item['file_path']: item for item in persisted.raw_data}
|
||||||
|
assert sorted(items[file_path]['tags']) == ['beta', 'gamma']
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_single_model_update_hash(tmp_path: Path, monkeypatch):
|
||||||
|
"""When sha256 changes, the hash_index is updated incrementally."""
|
||||||
|
monkeypatch.setenv('LORA_MANAGER_DISABLE_PERSISTENT_CACHE', '0')
|
||||||
|
db_path = tmp_path / 'cache.sqlite'
|
||||||
|
store = PersistentModelCache(db_path=str(db_path))
|
||||||
|
|
||||||
|
file_path = (tmp_path / 'z.safetensors').as_posix()
|
||||||
|
base = {
|
||||||
|
'file_path': file_path, 'file_name': 'z', 'model_name': 'Z',
|
||||||
|
'folder': '', 'size': 1, 'modified': 1.0, 'base_model': '',
|
||||||
|
'preview_url': '', 'preview_nsfw_level': 0, 'from_civitai': True,
|
||||||
|
'favorite': False, 'notes': '', 'usage_tips': '{}',
|
||||||
|
'metadata_source': None, 'exclude': False, 'db_checked': False,
|
||||||
|
'last_checked_at': 0.0, 'tags': [], 'civitai': None,
|
||||||
|
'civitai_deleted': False, 'skip_metadata_refresh': False,
|
||||||
|
'license_flags': DEFAULT_LICENSE_FLAGS, 'hash_status': 'completed', 'hf_url': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
store.update_single_model('dummy', {**base, 'sha256': 'old-hash'})
|
||||||
|
|
||||||
|
old_item = {'file_path': file_path, 'tags': [], 'sha256': 'old-hash'}
|
||||||
|
new_item = {**base, 'sha256': 'new-hash'}
|
||||||
|
store.update_single_model('dummy', new_item, old_item=old_item)
|
||||||
|
|
||||||
|
persisted = store.load_cache('dummy')
|
||||||
|
assert persisted is not None
|
||||||
|
# old hash should be gone from hash_index
|
||||||
|
old_hash_pairs = [p for p in persisted.hash_rows if p[0] == 'old-hash']
|
||||||
|
assert len(old_hash_pairs) == 0
|
||||||
|
# new hash should be present
|
||||||
|
new_hash_pairs = [p for p in persisted.hash_rows if p[0] == 'new-hash']
|
||||||
|
assert len(new_hash_pairs) == 1
|
||||||
|
assert new_hash_pairs[0][1] == file_path
|
||||||
|
|||||||
@@ -114,6 +114,38 @@ def test_calculate_relative_path_sanitizes_model_and_version_names(isolated_sett
|
|||||||
assert relative_path == "Fancy_Model/Version_One"
|
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():
|
def test_calculate_recipe_fingerprint_filters_and_sorts():
|
||||||
loras = [
|
loras = [
|
||||||
{"hash": "ABC", "strength": 0.1234},
|
{"hash": "ABC", "strength": 0.1234},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const LORA_PROVIDER_NODE_TYPES = [
|
|||||||
"Lora Stacker (LoraManager)",
|
"Lora Stacker (LoraManager)",
|
||||||
"Lora Randomizer (LoraManager)",
|
"Lora Randomizer (LoraManager)",
|
||||||
"Lora Cycler (LoraManager)",
|
"Lora Cycler (LoraManager)",
|
||||||
|
"Create Hook LoRA (LoraManager)",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function createMockToast() {
|
|||||||
function createMockWidget(value?: unknown) {
|
function createMockWidget(value?: unknown) {
|
||||||
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
|
type PendingInfo = { name: string; notes: string; filePath: string; activeTab?: string } | null
|
||||||
const widget = {
|
const widget = {
|
||||||
|
options: {} as { getValue?: () => unknown; setValue?: (v: unknown) => void },
|
||||||
serializeValue: (async () => null) as () => Promise<unknown>,
|
serializeValue: (async () => null) as () => Promise<unknown>,
|
||||||
value: (value ?? undefined) as unknown,
|
value: (value ?? undefined) as unknown,
|
||||||
onSetValue: undefined as unknown as ((v: unknown) => void),
|
onSetValue: undefined as unknown as ((v: unknown) => void),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const LORA_NODE_CLASSES = new Set([
|
|||||||
"Lora Loader (LoraManager)",
|
"Lora Loader (LoraManager)",
|
||||||
"Lora Stacker (LoraManager)",
|
"Lora Stacker (LoraManager)",
|
||||||
"WanVideo Lora Select (LoraManager)",
|
"WanVideo Lora Select (LoraManager)",
|
||||||
|
"Create Hook LoRA (LoraManager)",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeTriggerWordList(triggerWords) {
|
function normalizeTriggerWordList(triggerWords) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const LORA_PROVIDER_NODE_TYPES = [
|
|||||||
"Lora Stacker (LoraManager)",
|
"Lora Stacker (LoraManager)",
|
||||||
"Lora Randomizer (LoraManager)",
|
"Lora Randomizer (LoraManager)",
|
||||||
"Lora Cycler (LoraManager)",
|
"Lora Cycler (LoraManager)",
|
||||||
|
"Create Hook LoRA (LoraManager)",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
export const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
||||||
|
|||||||
@@ -15656,7 +15656,8 @@ function createVueWidgetCleanup(vueApp, onCleanup) {
|
|||||||
const LORA_PROVIDER_NODE_TYPES$1 = [
|
const LORA_PROVIDER_NODE_TYPES$1 = [
|
||||||
"Lora Stacker (LoraManager)",
|
"Lora Stacker (LoraManager)",
|
||||||
"Lora Randomizer (LoraManager)",
|
"Lora Randomizer (LoraManager)",
|
||||||
"Lora Cycler (LoraManager)"
|
"Lora Cycler (LoraManager)",
|
||||||
|
"Create Hook LoRA (LoraManager)"
|
||||||
];
|
];
|
||||||
const LORA_STACK_AGGREGATOR_NODE_TYPES$1 = [
|
const LORA_STACK_AGGREGATOR_NODE_TYPES$1 = [
|
||||||
"Lora Stack Combiner (LoraManager)"
|
"Lora Stack Combiner (LoraManager)"
|
||||||
@@ -15781,7 +15782,8 @@ const ROOT_GRAPH_ID = "root";
|
|||||||
const LORA_PROVIDER_NODE_TYPES = [
|
const LORA_PROVIDER_NODE_TYPES = [
|
||||||
"Lora Stacker (LoraManager)",
|
"Lora Stacker (LoraManager)",
|
||||||
"Lora Randomizer (LoraManager)",
|
"Lora Randomizer (LoraManager)",
|
||||||
"Lora Cycler (LoraManager)"
|
"Lora Cycler (LoraManager)",
|
||||||
|
"Create Hook LoRA (LoraManager)"
|
||||||
];
|
];
|
||||||
const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
const LORA_STACK_AGGREGATOR_NODE_TYPES = [
|
||||||
"Lora Stack Combiner (LoraManager)"
|
"Lora Stack Combiner (LoraManager)"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback } from "./utils.js";
|
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback, getLinkFromGraph } from "./utils.js";
|
||||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||||
|
|
||||||
const DEBOUNCE_DELAY = 500;
|
const DEBOUNCE_DELAY = 500;
|
||||||
@@ -9,6 +9,7 @@ const LORA_NODE_CLASSES = new Set([
|
|||||||
"Lora Loader (LoraManager)",
|
"Lora Loader (LoraManager)",
|
||||||
"Lora Stacker (LoraManager)",
|
"Lora Stacker (LoraManager)",
|
||||||
"WanVideo Lora Select (LoraManager)",
|
"WanVideo Lora Select (LoraManager)",
|
||||||
|
"Create Hook LoRA (LoraManager)",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]);
|
const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]);
|
||||||
@@ -76,6 +77,84 @@ function fadeWidgetTextColor(widget, fromColor, toColor, duration) {
|
|||||||
return () => { if (rafId) cancelAnimationFrame(rafId); };
|
return () => { if (rafId) cancelAnimationFrame(rafId); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Primitive node helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set of node type names that represent Primitive value nodes.
|
||||||
|
* Includes both the dynamic PrimitiveNode (created by double-clicking
|
||||||
|
* a widget input) and the static typed primitives from the node library.
|
||||||
|
*/
|
||||||
|
const PRIMITIVE_NODE_TYPES = new Set([
|
||||||
|
"PrimitiveNode", // dynamic (double-click a widget input)
|
||||||
|
"PrimitiveInt",
|
||||||
|
"PrimitiveFloat",
|
||||||
|
"PrimitiveString",
|
||||||
|
"PrimitiveBoolean",
|
||||||
|
"PrimitiveStringMultiline",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when `node` is any flavour of Primitive node.
|
||||||
|
* @param {Object} node - LiteGraph node instance
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isPrimitiveNodeType(node) {
|
||||||
|
return PRIMITIVE_NODE_TYPES.has(node?.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the 0-based input slot index whose widget name matches `widgetName`.
|
||||||
|
* Returns -1 when no matching input is found.
|
||||||
|
*
|
||||||
|
* Matching strategy (in order):
|
||||||
|
* 1. `input.widget?.name === widgetName` — direct widget ref (preferred)
|
||||||
|
* 2. `input.name === widgetName` — fallback by slot name
|
||||||
|
*
|
||||||
|
* @param {Object} node - LiteGraph node instance
|
||||||
|
* @param {string} widgetName
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function findInputSlotForWidget(node, widgetName) {
|
||||||
|
if (!node || !Array.isArray(node.inputs)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return node.inputs.findIndex(
|
||||||
|
(inp) => inp?.widget?.name === widgetName || inp?.name === widgetName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the input slot that backs `widgetName` on `node` is connected to a
|
||||||
|
* Primitive node, return that Primitive node. Otherwise return null.
|
||||||
|
*
|
||||||
|
* This is the key bridge for the "send gen params → Primitive" flow:
|
||||||
|
* when a KSampler widget (e.g. "steps") has an incoming wire from a
|
||||||
|
* Primitive node, we want to update the Primitive's value instead of the
|
||||||
|
* KSampler widget, because ComfyUI's execution engine reads from the
|
||||||
|
* connected input, not the widget.
|
||||||
|
*
|
||||||
|
* @param {Object} node - the target node (e.g. KSampler)
|
||||||
|
* @param {string} widgetName - e.g. "steps", "cfg", "seed"
|
||||||
|
* @returns {Object|null} - the connected Primitive node, or null
|
||||||
|
*/
|
||||||
|
function tryResolvePrimitiveConnection(node, widgetName) {
|
||||||
|
const slotIndex = findInputSlotForWidget(node, widgetName);
|
||||||
|
if (slotIndex === -1) return null;
|
||||||
|
|
||||||
|
const input = node.inputs[slotIndex];
|
||||||
|
if (input?.link == null) return null;
|
||||||
|
|
||||||
|
const link = getLinkFromGraph(node.graph, input.link);
|
||||||
|
if (!link) return null;
|
||||||
|
|
||||||
|
const originNode = node.graph?.getNodeById?.(link.origin_id);
|
||||||
|
if (!originNode) return null;
|
||||||
|
|
||||||
|
return isPrimitiveNodeType(originNode) ? originNode : null;
|
||||||
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "LoraManager.WorkflowRegistry",
|
name: "LoraManager.WorkflowRegistry",
|
||||||
|
|
||||||
@@ -309,6 +388,60 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Redirect to connected Primitive node when present ----
|
||||||
|
// When a widget input (e.g. "steps", "cfg", "seed" on KSampler)
|
||||||
|
// is wired to a Primitive node, the Primitive's value overrides
|
||||||
|
// the widget value during execution. Update the Primitive
|
||||||
|
// directly so the change actually takes effect.
|
||||||
|
if (widgetName) {
|
||||||
|
const primitiveNode = tryResolvePrimitiveConnection(node, widgetName);
|
||||||
|
if (primitiveNode) {
|
||||||
|
const primWidget = primitiveNode.widgets?.[0];
|
||||||
|
if (primWidget) {
|
||||||
|
let primNewValue = value;
|
||||||
|
if (mode === "append") {
|
||||||
|
const sep =
|
||||||
|
primWidget.value && primWidget.value.length > 0
|
||||||
|
? " "
|
||||||
|
: "";
|
||||||
|
primNewValue = primWidget.value + sep + value;
|
||||||
|
}
|
||||||
|
primWidget.value = primNewValue;
|
||||||
|
if (
|
||||||
|
Array.isArray(primitiveNode.widgets_values) &&
|
||||||
|
primitiveNode.widgets_values.length > 0
|
||||||
|
) {
|
||||||
|
primitiveNode.widgets_values[0] = primNewValue;
|
||||||
|
}
|
||||||
|
if (typeof primWidget.callback === "function") {
|
||||||
|
try {
|
||||||
|
primWidget.callback(primNewValue);
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error(
|
||||||
|
"LoRA Manager: primitive widget callback failed",
|
||||||
|
callbackError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof primitiveNode.setDirtyCanvas === "function") {
|
||||||
|
primitiveNode.setDirtyCanvas(true);
|
||||||
|
}
|
||||||
|
if (typeof app.graph?.setDirtyCanvas === "function") {
|
||||||
|
app.graph.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
this.flashWidget(primitiveNode, primWidget);
|
||||||
|
console.debug(
|
||||||
|
"LoRA Manager: redirected widget update to Primitive node %s (id=%d) ← %s = %o",
|
||||||
|
primitiveNode.type,
|
||||||
|
primitiveNode.id,
|
||||||
|
widgetName,
|
||||||
|
primNewValue
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Update widget value ----
|
// ---- Update widget value ----
|
||||||
const widgetIndex = node.widgets.indexOf(targetWidget);
|
const widgetIndex = node.widgets.indexOf(targetWidget);
|
||||||
let newValue = value;
|
let newValue = value;
|
||||||
|
|||||||
Reference in New Issue
Block a user