Compare commits
23 Commits
25605c5e78
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
908464bc0a | ||
|
|
0ffee3a854 | ||
|
|
8aa9739c44 | ||
|
|
50739bbb43 | ||
|
|
e849303763 | ||
|
|
241b2e15d2 | ||
|
|
88da754504 | ||
|
|
b4a706651f | ||
|
|
ff7cc6d9bb | ||
|
|
454210a47c | ||
|
|
2d7c404ebb | ||
|
|
e23d803ecf | ||
|
|
0cc640cfaa | ||
|
|
2ac0eb0f9d | ||
|
|
f028625ce9 | ||
|
|
06acc7f576 | ||
|
|
d324b57274 | ||
|
|
502b7eab31 | ||
|
|
be75ad930e | ||
|
|
763c4f4dad | ||
|
|
d32c492bdb | ||
|
|
5dcfde36ea | ||
|
|
1d035361a4 |
@@ -12,33 +12,39 @@
|
|||||||
"2018cfh",
|
"2018cfh",
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
"wackop",
|
"wackop",
|
||||||
"Takkan",
|
"Phil",
|
||||||
"Carl G.",
|
"Carl G.",
|
||||||
|
"Arlecchino Shion",
|
||||||
|
"stone9k",
|
||||||
"$MetaSamsara",
|
"$MetaSamsara",
|
||||||
"itismyelement",
|
"itismyelement",
|
||||||
|
"Gingko Biloba",
|
||||||
"onesecondinosaur",
|
"onesecondinosaur",
|
||||||
"stone9k",
|
"Takkan",
|
||||||
|
"Charles Blakemore",
|
||||||
|
"Rob Williams",
|
||||||
"Rosenthal",
|
"Rosenthal",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
|
"Tobi_Swagg",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
"Gooohokrbe",
|
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
"JongWon Han",
|
"JongWon Han",
|
||||||
"OldBones",
|
|
||||||
"VantAI",
|
"VantAI",
|
||||||
"runte3221",
|
"runte3221",
|
||||||
|
"Illrigger",
|
||||||
"FreelancerZ",
|
"FreelancerZ",
|
||||||
"Edgar Tejeda",
|
"Edgar Tejeda",
|
||||||
|
"Jorge Hussni",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
"Fraser Cross",
|
"Fraser Cross",
|
||||||
"Polymorphic Indeterminate",
|
"Polymorphic Indeterminate",
|
||||||
"Birdy",
|
|
||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
"Jorge Hussni",
|
"Birdy",
|
||||||
"Kiba",
|
|
||||||
"Skalabananen",
|
"Skalabananen",
|
||||||
|
"Kiba",
|
||||||
"Reno Lam",
|
"Reno Lam",
|
||||||
|
"Mozzel",
|
||||||
"sig",
|
"sig",
|
||||||
"Christian Byrne",
|
"Christian Byrne",
|
||||||
"DM",
|
"DM",
|
||||||
@@ -46,39 +52,41 @@
|
|||||||
"Estragon",
|
"Estragon",
|
||||||
"J\\B/ 8r0wns0n",
|
"J\\B/ 8r0wns0n",
|
||||||
"Snaggwort",
|
"Snaggwort",
|
||||||
"Arlecchino Shion",
|
|
||||||
"Charles Blakemore",
|
|
||||||
"Rob Williams",
|
|
||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
|
"Jonathan Ross",
|
||||||
"KD",
|
"KD",
|
||||||
"Omnidex",
|
"Omnidex",
|
||||||
|
"Nazono_hito",
|
||||||
"Tyler Trebuchon",
|
"Tyler Trebuchon",
|
||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
"Tobi_Swagg",
|
"contrite831",
|
||||||
"SG",
|
"SG",
|
||||||
"carozzz",
|
"carozzz",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
"jmack",
|
"jmack",
|
||||||
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
"Cosmosis",
|
"Anthony Rizzo",
|
||||||
"iamresist",
|
"iamresist",
|
||||||
|
"Gooohokrbe",
|
||||||
"RedrockVP",
|
"RedrockVP",
|
||||||
"Wolffen",
|
"Wolffen",
|
||||||
"FloPro4Sho",
|
|
||||||
"James Todd",
|
"James Todd",
|
||||||
|
"OldBones",
|
||||||
"Steven Pfeiffer",
|
"Steven Pfeiffer",
|
||||||
"Tim",
|
"Tim",
|
||||||
|
"Timmy",
|
||||||
|
"Johnny",
|
||||||
"Lisster",
|
"Lisster",
|
||||||
"Michael Wong",
|
"Michael Wong",
|
||||||
"Illrigger",
|
"whudunit",
|
||||||
"Tom Corrigan",
|
"Tom Corrigan",
|
||||||
|
"dl0901dm",
|
||||||
"JackieWang",
|
"JackieWang",
|
||||||
"fnkylove",
|
"fnkylove",
|
||||||
"Julian V",
|
|
||||||
"Steven Owens",
|
|
||||||
"Yushio",
|
"Yushio",
|
||||||
"Vik71it",
|
"Vik71it",
|
||||||
"Echo",
|
"Echo",
|
||||||
@@ -86,147 +94,137 @@
|
|||||||
"Robert Stacey",
|
"Robert Stacey",
|
||||||
"PM",
|
"PM",
|
||||||
"Todd Keck",
|
"Todd Keck",
|
||||||
"Mozzel",
|
"Briton Heilbrun",
|
||||||
"Gingko Biloba",
|
"Aleksander Wujczyk",
|
||||||
"Sterilized",
|
|
||||||
"BadassArabianMofo",
|
"BadassArabianMofo",
|
||||||
|
"Sterilized",
|
||||||
"Pascal Dahle",
|
"Pascal Dahle",
|
||||||
"quarz",
|
"quarz",
|
||||||
"Greg",
|
|
||||||
"Penfore",
|
"Penfore",
|
||||||
|
"Greg",
|
||||||
"JSST",
|
"JSST",
|
||||||
"esthe",
|
|
||||||
"lmsupporter",
|
"lmsupporter",
|
||||||
"IamAyam",
|
"zounic",
|
||||||
"wfpearl",
|
"wfpearl",
|
||||||
"Baekdoosixt",
|
"Baekdoosixt",
|
||||||
"Jonathan Ross",
|
|
||||||
"Jack B Nimble",
|
"Jack B Nimble",
|
||||||
"Nazono_hito",
|
|
||||||
"Melville Parrish",
|
"Melville Parrish",
|
||||||
"daniel dove",
|
"daniel dove",
|
||||||
"Lustre",
|
"Lustre",
|
||||||
"JW Sin",
|
"JW Sin",
|
||||||
"contrite831",
|
|
||||||
"Alex",
|
"Alex",
|
||||||
"bh",
|
"bh",
|
||||||
"confiscated Zyra",
|
|
||||||
"Marlon Daniels",
|
"Marlon Daniels",
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
"Aaron Bleuer",
|
"Aaron Bleuer",
|
||||||
"LacesOut!",
|
"LacesOut!",
|
||||||
"greebles",
|
"greebles",
|
||||||
"Adam Shaw",
|
"Cosmosis",
|
||||||
"Tee Gee",
|
|
||||||
"Anthony Rizzo",
|
|
||||||
"tarek helmi",
|
|
||||||
"M Postkasse",
|
"M Postkasse",
|
||||||
|
"FloPro4Sho",
|
||||||
"ASLPro3D",
|
"ASLPro3D",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
"FinalyFree",
|
||||||
"Weasyl",
|
"Weasyl",
|
||||||
"Timmy",
|
"Lex Song",
|
||||||
"Johnny",
|
|
||||||
"Cory Paza",
|
"Cory Paza",
|
||||||
"Tak",
|
"Tak",
|
||||||
"Gonzalo Andre Allendes Lopez",
|
"Gonzalo Andre Allendes Lopez",
|
||||||
"Zach Gonser",
|
"Zach Gonser",
|
||||||
"Big Red",
|
"Big Red",
|
||||||
"whudunit",
|
"Jimmy Ledbetter",
|
||||||
"Luc Job",
|
"Luc Job",
|
||||||
"dl0901dm",
|
|
||||||
"Philip Hempel",
|
"Philip Hempel",
|
||||||
"corde",
|
"corde",
|
||||||
"Nick Walker",
|
"Nick Walker",
|
||||||
"lh qwe",
|
"Julian V",
|
||||||
|
"Steven Owens",
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
"conner",
|
|
||||||
"aai",
|
"aai",
|
||||||
"Briton Heilbrun",
|
|
||||||
"Tori",
|
"Tori",
|
||||||
"wildnut",
|
"wildnut",
|
||||||
"Princess Bright Eyes",
|
|
||||||
"AbstractAss",
|
|
||||||
"Felipe dos Santos",
|
|
||||||
"ViperC",
|
|
||||||
"jean jahren",
|
"jean jahren",
|
||||||
"Aleksander Wujczyk",
|
|
||||||
"AM Kuro",
|
"AM Kuro",
|
||||||
"Markus",
|
"ViperC",
|
||||||
"S Sang",
|
"Ran C",
|
||||||
|
"Sangheili460",
|
||||||
|
"MagnaInsomnia",
|
||||||
"Karl P.",
|
"Karl P.",
|
||||||
"Akira_HentAI",
|
"Akira_HentAI",
|
||||||
"MagnaInsomnia",
|
|
||||||
"Gordon Cole",
|
"Gordon Cole",
|
||||||
"yuxz69",
|
"yuxz69",
|
||||||
"Douglas Gaspar",
|
"esthe",
|
||||||
"AlexDuKaNa",
|
|
||||||
"George",
|
|
||||||
"andrew.tappan",
|
"andrew.tappan",
|
||||||
"dw",
|
|
||||||
"N/A",
|
"N/A",
|
||||||
"The Spawn",
|
"The Spawn",
|
||||||
"Phil",
|
|
||||||
"graysock",
|
"graysock",
|
||||||
|
"Pozadine1",
|
||||||
"Greenmoustache",
|
"Greenmoustache",
|
||||||
"zounic",
|
|
||||||
"fancypants",
|
"fancypants",
|
||||||
|
"IamAyam",
|
||||||
|
"Eldithor",
|
||||||
|
"Joboshy",
|
||||||
"Digital",
|
"Digital",
|
||||||
"JaxMax",
|
"JaxMax",
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
"奚明 刘",
|
"Bohemian Corporal",
|
||||||
|
"Dan",
|
||||||
|
"confiscated Zyra",
|
||||||
"Jwk0205",
|
"Jwk0205",
|
||||||
"Bro Xie",
|
"Bro Xie",
|
||||||
"준희 김",
|
"yer fey",
|
||||||
"batblue",
|
"batblue",
|
||||||
"carey6409",
|
"carey6409",
|
||||||
"Olive",
|
"Olive",
|
||||||
"太郎 ゲーム",
|
"太郎 ゲーム",
|
||||||
|
"Tee Gee",
|
||||||
"Some Guy Named Barry",
|
"Some Guy Named Barry",
|
||||||
|
"jinxedx",
|
||||||
|
"tarek helmi",
|
||||||
"Max Marklund",
|
"Max Marklund",
|
||||||
"Tomohiro Baba",
|
|
||||||
"David Ortega",
|
|
||||||
"AELOX",
|
"AELOX",
|
||||||
|
"Dankin",
|
||||||
"Nicfit23",
|
"Nicfit23",
|
||||||
"Noora",
|
|
||||||
"wamekukyouzin",
|
"wamekukyouzin",
|
||||||
"drum matthieu",
|
"drum matthieu",
|
||||||
"Dogmaster",
|
"Dogmaster",
|
||||||
"Matt Wenzel",
|
"Matt Wenzel",
|
||||||
"Mattssn",
|
"Frank Nitty",
|
||||||
"Lex Song",
|
"Pronredn",
|
||||||
"John Saveas",
|
|
||||||
"Christopher Michel",
|
"Christopher Michel",
|
||||||
"Serge Bekenkamp",
|
"Serge Bekenkamp",
|
||||||
"Jimmy Ledbetter",
|
"DougPeterson",
|
||||||
"LeoZero",
|
"LeoZero",
|
||||||
"Antonio Pontes",
|
"Antonio Pontes",
|
||||||
"ApathyJones",
|
"ApathyJones",
|
||||||
"nahinahi9",
|
"nahinahi9",
|
||||||
|
"lh qwe",
|
||||||
|
"Kevin John Duck",
|
||||||
|
"conner",
|
||||||
"Dustin Chen",
|
"Dustin Chen",
|
||||||
"dan",
|
"dan",
|
||||||
"Yaboi",
|
"Blackfish95",
|
||||||
"Mouthlessman",
|
"Mouthlessman",
|
||||||
"Steam Steam",
|
"Princess Bright Eyes",
|
||||||
"Damon Cunliffe",
|
"Paul Kroll",
|
||||||
"CryptoTraderJK",
|
"AbstractAss",
|
||||||
"Davaitamin",
|
|
||||||
"otaku fra",
|
"otaku fra",
|
||||||
"Ran C",
|
"Felipe dos Santos",
|
||||||
"tedcor",
|
"Bas Imagineer",
|
||||||
"Fotek Design",
|
"Markus",
|
||||||
|
"MiraiKuriyamaSy",
|
||||||
"Adam Taylor",
|
"Adam Taylor",
|
||||||
|
"Douglas Gaspar",
|
||||||
"Weird_With_A_Beard",
|
"Weird_With_A_Beard",
|
||||||
"MadSpin",
|
"AlexDuKaNa",
|
||||||
"Pozadine1",
|
"George",
|
||||||
|
"dw",
|
||||||
"Qarob",
|
"Qarob",
|
||||||
"AIGooner",
|
"AIGooner",
|
||||||
"inbijiburu",
|
|
||||||
"Luc",
|
"Luc",
|
||||||
"ProtonPrince",
|
"ProtonPrince",
|
||||||
"DiffDuck",
|
"DiffDuck",
|
||||||
"elu3199",
|
"elu3199",
|
||||||
"Nick “Loadstone” D",
|
|
||||||
"Hasturkun",
|
"Hasturkun",
|
||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
"Ubivis",
|
"Ubivis",
|
||||||
@@ -234,54 +232,45 @@
|
|||||||
"thesoftwaredruid",
|
"thesoftwaredruid",
|
||||||
"wundershark",
|
"wundershark",
|
||||||
"mr_dinosaur",
|
"mr_dinosaur",
|
||||||
|
"Tyrswood",
|
||||||
"linnfrey",
|
"linnfrey",
|
||||||
"Gamalonia",
|
|
||||||
"Vir",
|
|
||||||
"Pkrsky",
|
"Pkrsky",
|
||||||
"Joboshy",
|
"奚明 刘",
|
||||||
"Bohemian Corporal",
|
|
||||||
"Dan",
|
|
||||||
"Josef Lanzl",
|
"Josef Lanzl",
|
||||||
"Seth Christensen",
|
"Nerezza",
|
||||||
"Griffin Dahlberg",
|
"Griffin Dahlberg",
|
||||||
"Draven T",
|
"준희 김",
|
||||||
"yer fey",
|
|
||||||
"Error_Rule34_Not_found",
|
"Error_Rule34_Not_found",
|
||||||
"Gerald Welly",
|
"Gerald Welly",
|
||||||
"Roslynd",
|
"Roslynd",
|
||||||
"Geolog",
|
"Geolog",
|
||||||
"jinxedx",
|
|
||||||
"Neco28",
|
"Neco28",
|
||||||
"Aquatic Coffee",
|
"Tomohiro Baba",
|
||||||
"Dankin",
|
"David Ortega",
|
||||||
"ethanfel",
|
"Noora",
|
||||||
"Cristian Vazquez",
|
"Cristian Vazquez",
|
||||||
"Frank Nitty",
|
"Mattssn",
|
||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"Focuschannel",
|
|
||||||
"DougPeterson",
|
|
||||||
"Jeff",
|
"Jeff",
|
||||||
"Bruce",
|
"Bruce",
|
||||||
"Kevin John Duck",
|
|
||||||
"Anthony Faxlandez",
|
|
||||||
"Kevin Christopher",
|
"Kevin Christopher",
|
||||||
"Ouro Boros",
|
"Ouro Boros",
|
||||||
"Blackfish95",
|
"Chad Idk",
|
||||||
|
"Yaboi",
|
||||||
"dd",
|
"dd",
|
||||||
"Paul Kroll",
|
"Steam Steam",
|
||||||
"MiraiKuriyamaSy",
|
"CryptoTraderJK",
|
||||||
"semicolon drainpipe",
|
"Davaitamin",
|
||||||
"Thesharingbrother",
|
"Dušan Ryban",
|
||||||
"Bas Imagineer",
|
"tedcor",
|
||||||
"Pat Hen",
|
"Fotek Design",
|
||||||
|
"sjon kreutz",
|
||||||
"John Statham",
|
"John Statham",
|
||||||
"ResidentDeviant",
|
"MadSpin",
|
||||||
"Nihongasuki",
|
"Metryman55",
|
||||||
"JC",
|
"inbijiburu",
|
||||||
"Prompt Pirate",
|
|
||||||
"uwutismxd",
|
|
||||||
"decoy",
|
"decoy",
|
||||||
"Tyrswood",
|
"Nick “Loadstone” D",
|
||||||
"Ray Wing",
|
"Ray Wing",
|
||||||
"Ranzitho",
|
"Ranzitho",
|
||||||
"Gus",
|
"Gus",
|
||||||
@@ -290,6 +279,7 @@
|
|||||||
"David LaVallee",
|
"David LaVallee",
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
|
"Gamalonia",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"capn",
|
"capn",
|
||||||
"Joseph",
|
"Joseph",
|
||||||
@@ -302,77 +292,60 @@
|
|||||||
"Moon Knight",
|
"Moon Knight",
|
||||||
"몽타주",
|
"몽타주",
|
||||||
"Kland",
|
"Kland",
|
||||||
"zenobeus",
|
"Hailshem",
|
||||||
"Jackthemind",
|
"kudari",
|
||||||
"ryoma",
|
"Naomi Hale Danchi",
|
||||||
"Stryker",
|
"dc7431",
|
||||||
"raf8osz",
|
"Vir",
|
||||||
"ElitaSSJ4",
|
|
||||||
"blikkies",
|
|
||||||
"Chris",
|
|
||||||
"Brian M",
|
"Brian M",
|
||||||
"Nerezza",
|
|
||||||
"sanborondon",
|
"sanborondon",
|
||||||
|
"Seth Christensen",
|
||||||
|
"Draven T",
|
||||||
"Taylor Funk",
|
"Taylor Funk",
|
||||||
"aezin",
|
"aezin",
|
||||||
"Thought2Form",
|
"Thought2Form",
|
||||||
"jcay015",
|
"jcay015",
|
||||||
"Kevin Picco",
|
"Kevin Picco",
|
||||||
"Erik Lopez",
|
"Erik Lopez",
|
||||||
"Shock Shockor",
|
|
||||||
"Mateo Curić",
|
"Mateo Curić",
|
||||||
"Goldwaters",
|
"Aquatic Coffee",
|
||||||
"Zude",
|
|
||||||
"Eris3D",
|
"Eris3D",
|
||||||
"m",
|
"m",
|
||||||
|
"ethanfel",
|
||||||
"Pierce McBride",
|
"Pierce McBride",
|
||||||
"Joshua Gray",
|
"Joshua Gray",
|
||||||
"Kyler",
|
"Focuschannel",
|
||||||
"Mikko Hemilä",
|
"Mikko Hemilä",
|
||||||
"aRtFuL_DodGeR",
|
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
"a _",
|
"a _",
|
||||||
"James Coleman",
|
"James Coleman",
|
||||||
"CrimsonDX",
|
|
||||||
"Martial",
|
"Martial",
|
||||||
|
"Anthony Faxlandez",
|
||||||
"battu",
|
"battu",
|
||||||
"Emil Andersson",
|
"Emil Andersson",
|
||||||
"Chad Idk",
|
|
||||||
"DarkSunset",
|
|
||||||
"Billy Gladky",
|
|
||||||
"Yuji Kaneko",
|
"Yuji Kaneko",
|
||||||
"Probis",
|
"Pat Hen",
|
||||||
"Dušan Ryban",
|
"semicolon drainpipe",
|
||||||
"ItsGeneralButtNaked",
|
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
"Rops Alot",
|
"Rops Alot",
|
||||||
|
"Thesharingbrother",
|
||||||
"Sam",
|
"Sam",
|
||||||
"sjon kreutz",
|
|
||||||
"Nimess",
|
|
||||||
"SRDB",
|
|
||||||
"Ace Ventura",
|
"Ace Ventura",
|
||||||
"g unit",
|
"ResidentDeviant",
|
||||||
"Youguang",
|
"Nihongasuki",
|
||||||
"Metryman55",
|
"JC",
|
||||||
"andrewzpong",
|
"Prompt Pirate",
|
||||||
"FrxzenSnxw",
|
"uwutismxd",
|
||||||
"BossGame",
|
|
||||||
"lrdchs",
|
|
||||||
"momokai",
|
"momokai",
|
||||||
"Hailshem",
|
"zenobeus",
|
||||||
"kudari",
|
|
||||||
"Naomi Hale Danchi",
|
|
||||||
"dc7431",
|
|
||||||
"ken",
|
"ken",
|
||||||
"Inversity",
|
|
||||||
"AIVORY3D",
|
|
||||||
"epicgamer0020690",
|
"epicgamer0020690",
|
||||||
"Joshua Porrata",
|
"Joshua Porrata",
|
||||||
"keemun",
|
"keemun",
|
||||||
"SuBu",
|
"SuBu",
|
||||||
"RedPIXel",
|
"RedPIXel",
|
||||||
"Kevinj",
|
|
||||||
"Wind",
|
"Wind",
|
||||||
|
"Jackthemind",
|
||||||
"Nexus",
|
"Nexus",
|
||||||
"Ramneek“Guy”Ashok",
|
"Ramneek“Guy”Ashok",
|
||||||
"squid_actually",
|
"squid_actually",
|
||||||
@@ -385,80 +358,81 @@
|
|||||||
"emyth",
|
"emyth",
|
||||||
"chriphost",
|
"chriphost",
|
||||||
"KitKatM",
|
"KitKatM",
|
||||||
|
"ryoma",
|
||||||
"socrasteeze",
|
"socrasteeze",
|
||||||
"ResidentDeviant",
|
"OrganicArtifact",
|
||||||
|
"Stryker",
|
||||||
|
"MudkipMedkitz",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"Welkor",
|
"raf8osz",
|
||||||
"John Martin",
|
"ElitaSSJ4",
|
||||||
"Richard",
|
"Richard",
|
||||||
|
"blikkies",
|
||||||
"Andrew",
|
"Andrew",
|
||||||
|
"Chris",
|
||||||
"Robert Wegemund",
|
"Robert Wegemund",
|
||||||
"Littlehuggy",
|
"Littlehuggy",
|
||||||
"moranqianlong",
|
|
||||||
"Gregory Kozhemiak",
|
"Gregory Kozhemiak",
|
||||||
"mrjuan",
|
"mrjuan",
|
||||||
"Brian Buie",
|
"Brian Buie",
|
||||||
|
"Shock Shockor",
|
||||||
"Sadlip",
|
"Sadlip",
|
||||||
"Haru Yotu",
|
"Goldwaters",
|
||||||
"Eric Whitney",
|
"Eric Whitney",
|
||||||
"Joey Callahan",
|
"Joey Callahan",
|
||||||
|
"Zude",
|
||||||
"Ivan Tadic",
|
"Ivan Tadic",
|
||||||
"Mike Simone",
|
"Mike Simone",
|
||||||
|
"John J Linehan",
|
||||||
|
"Kyler",
|
||||||
|
"Elliot E",
|
||||||
"Morgandel",
|
"Morgandel",
|
||||||
"Kyron Mahan",
|
"Theerat Jiramate",
|
||||||
"Matura Arbeit",
|
"aRtFuL_DodGeR",
|
||||||
"Noah",
|
"Noah",
|
||||||
"Jacob McDaniel",
|
"Jacob McDaniel",
|
||||||
"X",
|
"X",
|
||||||
"Sloan Steddy",
|
"Sloan Steddy",
|
||||||
"TBitz33",
|
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
|
||||||
"Temikus",
|
"Temikus",
|
||||||
"Artokun",
|
"Artokun",
|
||||||
"Michael Taylor",
|
"Michael Taylor",
|
||||||
"SendingRavens",
|
|
||||||
"Derek Baker",
|
"Derek Baker",
|
||||||
|
"CrimsonDX",
|
||||||
"Michael Anthony Scott",
|
"Michael Anthony Scott",
|
||||||
|
"DarkSunset",
|
||||||
"Atilla Berke Pekduyar",
|
"Atilla Berke Pekduyar",
|
||||||
"Michael Docherty",
|
|
||||||
"Nathan",
|
"Nathan",
|
||||||
|
"Billy Gladky",
|
||||||
|
"NICHOLAS BAXLEY",
|
||||||
"Decx _",
|
"Decx _",
|
||||||
"Paul Hartsuyker",
|
"Probis",
|
||||||
"elitassj",
|
"Ed Wang",
|
||||||
"Jacob Winter",
|
"ItsGeneralButtNaked",
|
||||||
|
"Nimess",
|
||||||
|
"SRDB",
|
||||||
|
"g unit",
|
||||||
"Distortik",
|
"Distortik",
|
||||||
"David",
|
"Youguang",
|
||||||
"Meilo",
|
|
||||||
"Pen Bouryoung",
|
|
||||||
"四糸凜音",
|
"四糸凜音",
|
||||||
"shinonomeiro",
|
"Saya",
|
||||||
"Snille",
|
"andrewzpong",
|
||||||
"MaartenAlbers",
|
"FrxzenSnxw",
|
||||||
"khanh duy",
|
"BossGame",
|
||||||
"xybrightsummer",
|
"lrdchs",
|
||||||
"jreedatchison",
|
|
||||||
"PhilW",
|
|
||||||
"Tree Tagger",
|
"Tree Tagger",
|
||||||
"Janik",
|
"Inversity",
|
||||||
"Crocket",
|
"Crocket",
|
||||||
"Cruel",
|
"AIVORY3D",
|
||||||
"MRBlack",
|
"Kevinj",
|
||||||
"Mitchell Robson",
|
"Mitchell Robson",
|
||||||
"Kiyoe",
|
|
||||||
"humptynutz",
|
|
||||||
"michael.isaza",
|
|
||||||
"Kalnei",
|
|
||||||
"Whitepinetrader",
|
"Whitepinetrader",
|
||||||
"OrganicArtifact",
|
"ResidentDeviant",
|
||||||
"Scott",
|
|
||||||
"MudkipMedkitz",
|
|
||||||
"deanbrian",
|
"deanbrian",
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
"Alex Wortman",
|
"Alex Wortman",
|
||||||
"Cody",
|
"Cody",
|
||||||
"Raku",
|
"Raku",
|
||||||
"smart.edge5178",
|
"smart.edge5178",
|
||||||
"emadsultan",
|
|
||||||
"InformedViewz",
|
"InformedViewz",
|
||||||
"CHKeeho80",
|
"CHKeeho80",
|
||||||
"Bubbafett",
|
"Bubbafett",
|
||||||
@@ -466,76 +440,152 @@
|
|||||||
"Menard",
|
"Menard",
|
||||||
"Skyfire83",
|
"Skyfire83",
|
||||||
"Adam Rinehart",
|
"Adam Rinehart",
|
||||||
"D",
|
|
||||||
"Pitpe11",
|
"Pitpe11",
|
||||||
"TheD1rtyD03",
|
"TheD1rtyD03",
|
||||||
"moonpetal",
|
"moonpetal",
|
||||||
"SomeDude",
|
"SomeDude",
|
||||||
"g9p0o",
|
"g9p0o",
|
||||||
"nanana",
|
|
||||||
"TheHolySheep",
|
"TheHolySheep",
|
||||||
"Monte Won",
|
"Monte Won",
|
||||||
"SpringBootisTrash",
|
"SpringBootisTrash",
|
||||||
"carsten",
|
"carsten",
|
||||||
"ikok",
|
"ikok",
|
||||||
|
"Nathen+Choi",
|
||||||
|
"T",
|
||||||
|
"LarsesFPC",
|
||||||
|
"cocona",
|
||||||
|
"sfasdfasfdsa",
|
||||||
"Buecyb99",
|
"Buecyb99",
|
||||||
"4IXplr0r3r",
|
"Welkor",
|
||||||
"dfklsjfkljslfjd",
|
"David Schenck",
|
||||||
"hayden",
|
"John Martin",
|
||||||
"ahoystan",
|
|
||||||
"Leland Saunders",
|
|
||||||
"Wolfe7D1",
|
"Wolfe7D1",
|
||||||
"Ink Temptation",
|
"Ink Temptation",
|
||||||
"Bob Barker",
|
"moranqianlong",
|
||||||
"edk",
|
|
||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
"Aeternyx",
|
|
||||||
"elleshar666",
|
"elleshar666",
|
||||||
"YOU SINWOO",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
"ja s",
|
"Haru Yotu",
|
||||||
"Doug Mason",
|
|
||||||
"Kauffy",
|
"Kauffy",
|
||||||
"Jeremy Townsend",
|
|
||||||
"EpicElric",
|
"EpicElric",
|
||||||
"Sean voets",
|
"Kyron Mahan",
|
||||||
"Owen Gwosdz",
|
|
||||||
"John J Linehan",
|
|
||||||
"Elliot E",
|
|
||||||
"Thomas Wanner",
|
|
||||||
"Theerat Jiramate",
|
|
||||||
"Edward Kennedy",
|
"Edward Kennedy",
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"Devil Lude",
|
"Matura Arbeit",
|
||||||
"Nick Kage",
|
"Nick Kage",
|
||||||
"kevin stoddard",
|
"TBitz33",
|
||||||
"Jack Dole",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
"Vane Holzer",
|
"Vane Holzer",
|
||||||
"psytrax",
|
"psytrax",
|
||||||
|
"Cyrus Fett",
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
|
"SendingRavens",
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"CptNeo",
|
|
||||||
"notedfakes",
|
"notedfakes",
|
||||||
"Maso",
|
"Michael Docherty",
|
||||||
"Eric Ketchum",
|
|
||||||
"NICHOLAS BAXLEY",
|
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
"Kevin Wallace",
|
"Paul Hartsuyker",
|
||||||
"Matheus Couto",
|
"elitassj",
|
||||||
"Saya",
|
"Jacob Winter",
|
||||||
"ChicRic",
|
|
||||||
"mercur",
|
|
||||||
"J C",
|
|
||||||
"Ed Wang",
|
|
||||||
"Ryan Presley Ng",
|
"Ryan Presley Ng",
|
||||||
"Wes Sims",
|
"Wes Sims",
|
||||||
"Donor4115",
|
"Donor4115",
|
||||||
|
"Lyavph",
|
||||||
|
"David",
|
||||||
|
"Meilo",
|
||||||
|
"Filippo Ferrari",
|
||||||
|
"Pen Bouryoung",
|
||||||
|
"shinonomeiro",
|
||||||
|
"Snille",
|
||||||
|
"MaartenAlbers",
|
||||||
|
"khanh duy",
|
||||||
|
"xybrightsummer",
|
||||||
|
"jreedatchison",
|
||||||
|
"PhilW",
|
||||||
|
"Janik",
|
||||||
|
"Cruel",
|
||||||
|
"MRBlack",
|
||||||
|
"Kiyoe",
|
||||||
|
"humptynutz",
|
||||||
|
"michael.isaza",
|
||||||
|
"Kalnei",
|
||||||
|
"Scott",
|
||||||
|
"Muratoraccio",
|
||||||
|
"Ginnie",
|
||||||
|
"emadsultan",
|
||||||
|
"D",
|
||||||
|
"nanana",
|
||||||
|
"Fthehappy",
|
||||||
|
"rsamerica",
|
||||||
|
"Alan+Cano",
|
||||||
|
"FeralOpticsAI",
|
||||||
|
"Pavlaki",
|
||||||
|
"generic404",
|
||||||
|
"Doug+Rintoul",
|
||||||
|
"Noor",
|
||||||
|
"Yorunai",
|
||||||
|
"quantenmecha",
|
||||||
|
"abattoirblues",
|
||||||
|
"Jason+Nash",
|
||||||
|
"BillyBoy84",
|
||||||
|
"zounik",
|
||||||
|
"DarkRoast",
|
||||||
|
"letzte",
|
||||||
|
"Nasty+Hobbit",
|
||||||
|
"Sora+Yori",
|
||||||
|
"lrdchs2",
|
||||||
|
"Duk3+Rand0m",
|
||||||
|
"4IXplr0r3r",
|
||||||
|
"hayden",
|
||||||
|
"ahoystan",
|
||||||
|
"Leland Saunders",
|
||||||
|
"Bob Barker",
|
||||||
|
"edk",
|
||||||
|
"JBsuede",
|
||||||
|
"Time Valentine",
|
||||||
|
"Aeternyx",
|
||||||
|
"YOU SINWOO",
|
||||||
|
"りん あめ",
|
||||||
|
"ja s",
|
||||||
|
"Михал Михалыч",
|
||||||
|
"Matt",
|
||||||
|
"Doug Mason",
|
||||||
|
"Jeremy Townsend",
|
||||||
|
"Frogmilk",
|
||||||
|
"Sean voets",
|
||||||
|
"Owen Gwosdz",
|
||||||
|
"SPJ",
|
||||||
|
"Thomas Wanner",
|
||||||
|
"Bryan Rutkowski",
|
||||||
|
"Devil Lude",
|
||||||
|
"David Murcko",
|
||||||
|
"kevin stoddard",
|
||||||
|
"Jack Dole",
|
||||||
|
"max blo",
|
||||||
|
"Xenon Xue",
|
||||||
|
"CptNeo",
|
||||||
|
"JackJohnnyJim",
|
||||||
|
"Dmitry Ryzhov",
|
||||||
|
"Maso",
|
||||||
|
"Edward Ten Eyck",
|
||||||
|
"Eric Ketchum",
|
||||||
|
"Kevin Wallace",
|
||||||
|
"Matheus Couto",
|
||||||
|
"ChicRic",
|
||||||
|
"Henrique Faiolli",
|
||||||
|
"mercur",
|
||||||
|
"Solixer",
|
||||||
|
"J C",
|
||||||
|
"jinksta187",
|
||||||
|
"Andrew Wilkinson",
|
||||||
|
"Manu Thetug",
|
||||||
|
"Karlanx",
|
||||||
"Yves Poezevara",
|
"Yves Poezevara",
|
||||||
|
"operationancut",
|
||||||
"Teriak47",
|
"Teriak47",
|
||||||
"Just me",
|
"Just me",
|
||||||
"Raf Stahelin",
|
"Raf Stahelin",
|
||||||
"Вячеслав Маринин",
|
"Вячеслав Маринин",
|
||||||
"Lyavph",
|
|
||||||
"Filippo Ferrari",
|
|
||||||
"Cola Matthew",
|
"Cola Matthew",
|
||||||
"OniNoKen",
|
"OniNoKen",
|
||||||
"Iain Wisely",
|
"Iain Wisely",
|
||||||
@@ -576,98 +626,121 @@
|
|||||||
"dg",
|
"dg",
|
||||||
"Maarten Harms",
|
"Maarten Harms",
|
||||||
"Israel",
|
"Israel",
|
||||||
"Muratoraccio",
|
|
||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"Ginnie",
|
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
"Alan+Cano",
|
"lighthawke",
|
||||||
"FeralOpticsAI",
|
"Terraformer",
|
||||||
"Pavlaki",
|
"GDS+DEV",
|
||||||
"generic404",
|
"4rt+r3d",
|
||||||
|
"low9",
|
||||||
|
"Winged",
|
||||||
|
"you+halo9",
|
||||||
|
"YassineKhaled",
|
||||||
|
"YK12",
|
||||||
|
"MatteKey",
|
||||||
|
"Flob",
|
||||||
|
"ShiroSenpai",
|
||||||
|
"Somebody",
|
||||||
|
"Inkognito",
|
||||||
|
"Somebody",
|
||||||
|
"Gramer+Gumbyte",
|
||||||
|
"Crescent~San",
|
||||||
|
"Tan+Huynh",
|
||||||
|
"AiGirlTS",
|
||||||
|
"D",
|
||||||
|
"datasl4ve",
|
||||||
|
"Somebody",
|
||||||
|
"Dark_Pest",
|
||||||
|
"Aza",
|
||||||
|
"Jacky+Ho",
|
||||||
|
"koopa990",
|
||||||
|
"Karru",
|
||||||
|
"ChaChanoKo",
|
||||||
|
"null",
|
||||||
|
"bo",
|
||||||
|
"The+Forgetful+Dev",
|
||||||
|
"redcarrot",
|
||||||
|
"powerbot99",
|
||||||
"Mateusz+Kosela",
|
"Mateusz+Kosela",
|
||||||
"Doug+Rintoul",
|
|
||||||
"Noor",
|
|
||||||
"Yorunai",
|
|
||||||
"Bula",
|
"Bula",
|
||||||
"quantenmecha",
|
|
||||||
"abattoirblues",
|
|
||||||
"Jason+Nash",
|
|
||||||
"BillyBoy84",
|
|
||||||
"DarkRoast",
|
|
||||||
"zounik",
|
|
||||||
"letzte",
|
|
||||||
"Nasty+Hobbit",
|
|
||||||
"SgtFluffles",
|
|
||||||
"lrdchs2",
|
|
||||||
"Duk3+Rand0m",
|
|
||||||
"KUJYAKU",
|
"KUJYAKU",
|
||||||
"NathenChoi",
|
|
||||||
"Thomas+Reck",
|
|
||||||
"Larses",
|
|
||||||
"cocona",
|
|
||||||
"Coeur+de+cochon",
|
"Coeur+de+cochon",
|
||||||
"David Schenck",
|
|
||||||
"han b",
|
"han b",
|
||||||
"Nico",
|
"Nico",
|
||||||
"Banana Joe",
|
"Banana Joe",
|
||||||
"_ G3n",
|
"_ G3n",
|
||||||
"Donovan Jenkins",
|
"Donovan Jenkins",
|
||||||
"JBsuede",
|
"Tú Nguyễn Lý Hoàng",
|
||||||
"Michael Eid",
|
"Michael Eid",
|
||||||
"beersandbacon",
|
"beersandbacon",
|
||||||
"Maximilian Pyko",
|
"Maximilian Pyko",
|
||||||
"Invis",
|
"Invis",
|
||||||
"Justin Houston",
|
"Bob barker",
|
||||||
"Time Valentine",
|
"Ben D",
|
||||||
|
"Garrett Wood",
|
||||||
|
"Ronan Delevacq",
|
||||||
"james",
|
"james",
|
||||||
|
"Christian Schäfer",
|
||||||
"OrochiNights",
|
"OrochiNights",
|
||||||
"Michael Zhu",
|
"Michael Zhu",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
|
||||||
"gonzalo",
|
"gonzalo",
|
||||||
"Seraphy",
|
"Seraphy",
|
||||||
"Михал Михалыч",
|
|
||||||
"雨の心 落",
|
"雨の心 落",
|
||||||
"Matt",
|
|
||||||
"AllTimeNoobie",
|
"AllTimeNoobie",
|
||||||
"jumpd",
|
"jumpd",
|
||||||
"John C",
|
"John C",
|
||||||
"Rim",
|
"Rim",
|
||||||
|
"Dave Abraham",
|
||||||
|
"Joaquin Hierrezuelo",
|
||||||
"Dismem",
|
"Dismem",
|
||||||
"Frogmilk",
|
"Locrospiel",
|
||||||
"SPJ",
|
"Jairus Knudsen",
|
||||||
|
"Jarrid Lee",
|
||||||
"Xan Dionysus",
|
"Xan Dionysus",
|
||||||
"Nathan lee",
|
"Nathan lee",
|
||||||
|
"Kor",
|
||||||
|
"Joseph Hanson",
|
||||||
"Mewtora",
|
"Mewtora",
|
||||||
"Middo",
|
"Middo",
|
||||||
"Forbidden Atelier",
|
"Forbidden Atelier",
|
||||||
"Bryan Rutkowski",
|
"John Rednoulf",
|
||||||
|
"Spire",
|
||||||
"Adictedtohumping",
|
"Adictedtohumping",
|
||||||
|
"Boba Smith",
|
||||||
"Towelie",
|
"Towelie",
|
||||||
"Cyrus Fett",
|
"MR.Bear",
|
||||||
|
"dsffsdfsdfsdfsdfsdf",
|
||||||
"Jean-françois SEMA",
|
"Jean-françois SEMA",
|
||||||
"Kurt",
|
"Kurt",
|
||||||
"max blo",
|
"ivistorm",
|
||||||
"Xenon Xue",
|
"Sauv",
|
||||||
"JackJohnnyJim",
|
"Steven",
|
||||||
"Edward Ten Eyck",
|
"TenaciousD",
|
||||||
|
"Khánh Đặng",
|
||||||
"Chase Kwon",
|
"Chase Kwon",
|
||||||
|
"Ted Cart",
|
||||||
"Inyoshu",
|
"Inyoshu",
|
||||||
"Goober719",
|
"Goober719",
|
||||||
"Chad Barnes",
|
"Chad Barnes",
|
||||||
|
"Person Y",
|
||||||
|
"David Spearing",
|
||||||
"James Ming",
|
"James Ming",
|
||||||
"vanditking",
|
"vanditking",
|
||||||
"kripitonga",
|
"kripitonga",
|
||||||
"Rizzi",
|
"Rizzi",
|
||||||
"nimin",
|
"nimin",
|
||||||
"OMAR LUCIANO",
|
"OMAR LUCIANO",
|
||||||
|
"Ken+Suzuki",
|
||||||
"hannibal",
|
"hannibal",
|
||||||
"Jo+Example",
|
"Jo+Example",
|
||||||
"BrentBertram",
|
"BrentBertram",
|
||||||
|
"Tigon",
|
||||||
"eumelzocker",
|
"eumelzocker",
|
||||||
"dxjaymz",
|
"dxjaymz",
|
||||||
"L C",
|
"L C",
|
||||||
"Dude"
|
"Dude",
|
||||||
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 666
|
"totalCount": 739
|
||||||
}
|
}
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
## Overview
|
|
||||||
|
|
||||||
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
|
|
||||||
|
|
||||||
✅ Instantly see which models are already present in your local library
|
|
||||||
✅ Download new models with a single click
|
|
||||||
✅ Manage downloads efficiently with queue and parallel download support
|
|
||||||
✅ Keep your downloaded models automatically organized according to your custom settings
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why Supporter Access?
|
|
||||||
|
|
||||||
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time.
|
|
||||||
|
|
||||||
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone.
|
|
||||||
|
|
||||||
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Supported Browsers & Installation Methods
|
|
||||||
|
|
||||||
| Browser | Installation Method |
|
|
||||||
|--------------------|-------------------------------------------------------------------------------------|
|
|
||||||
| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
|
|
||||||
| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Brave Browser** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Opera** | Install via Chrome Web Store (compatible) |
|
|
||||||
| **Firefox** | <div id="firefox-install" class="install-ok"><a href="https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi">📦 Install Firefox Extension (reviewed and verified by Mozilla)</a></div> |
|
|
||||||
|
|
||||||
For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extension’s Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Privacy & Security
|
|
||||||
|
|
||||||
I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
|
|
||||||
|
|
||||||
- **Reviewed and Verified**
|
|
||||||
This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozilla’s Add-on review**.
|
|
||||||
|
|
||||||
- **Minimal Network Access**
|
|
||||||
The only external server this extension connects to is:
|
|
||||||
**`https://willmiao.shop`** — used solely for **license validation**.
|
|
||||||
|
|
||||||
It does **not collect, transmit, or store any personal or usage data**.
|
|
||||||
No browsing history, no user IDs, no analytics, no hidden trackers.
|
|
||||||
|
|
||||||
- **Local-Only Model Detection**
|
|
||||||
Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
|
|
||||||
|
|
||||||
I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Use
|
|
||||||
|
|
||||||
After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
|
|
||||||
|
|
||||||
When the extension is correctly installed and your license is valid:
|
|
||||||
|
|
||||||
- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
|
|
||||||
- ✅ Models already present in your local library
|
|
||||||
- ⬇️ A download button for models not in your library
|
|
||||||
|
|
||||||
Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
|
|
||||||
|
|
||||||
### Visual Indicators Appear On:
|
|
||||||
|
|
||||||
- **Home Page** — Featured models
|
|
||||||
- **Models Page**
|
|
||||||
- **Creator Profiles** — If the creator has set their models to be visible
|
|
||||||
- **Recommended Resources** — On individual model pages
|
|
||||||
|
|
||||||
### Version Buttons on Model Pages
|
|
||||||
|
|
||||||
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
|
|
||||||
|
|
||||||
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button:
|
|
||||||
|
|
||||||
- The new **dedicated download button** directly triggers download via **LoRA Manager**
|
|
||||||
- The **original download button** remains unchanged for standard browser downloads
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Hide Models Already in Library (Beta)
|
|
||||||
|
|
||||||
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
|
|
||||||
|
|
||||||
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
|
|
||||||
|
|
||||||
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
|
|
||||||
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
|
|
||||||
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
[](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Model Download Location & LoRA Manager Settings
|
|
||||||
|
|
||||||
To use the **one-click download function**, you must first set:
|
|
||||||
|
|
||||||
- Your **Default LoRAs Root**
|
|
||||||
- Your **Default Checkpoints Root**
|
|
||||||
|
|
||||||
These are set within LoRA Manager's settings.
|
|
||||||
|
|
||||||
When everything is configured, downloaded model files will be placed in:
|
|
||||||
|
|
||||||
`<Default_Models_Root>/<Base_Model_of_the_Model>/<First_Tag_of_the_Model>`
|
|
||||||
|
|
||||||
|
|
||||||
### Update: Default Path Customization (2025-07-21)
|
|
||||||
|
|
||||||
A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Port Configuration
|
|
||||||
|
|
||||||
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
|
|
||||||
|
|
||||||
After correctly setting and saving the port, you'll see in the extension's header area:
|
|
||||||
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Connecting to a Remote LoRA Manager
|
|
||||||
|
|
||||||
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
|
|
||||||
|
|
||||||
> **Why can't you set a remote IP directly?**
|
|
||||||
>
|
|
||||||
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
|
|
||||||
|
|
||||||
**Solution: Port Forwarding with `socat`**
|
|
||||||
|
|
||||||
On your browser computer, run:
|
|
||||||
|
|
||||||
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
|
|
||||||
|
|
||||||
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
|
|
||||||
- Adjust the port if needed.
|
|
||||||
|
|
||||||
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
|
|
||||||
|
|
||||||
_Thanks to user **Temikus** for sharing this solution!_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
|
|
||||||
|
|
||||||
- [x] Support for **additional model types** (e.g., embeddings)
|
|
||||||
- [x] One-click **Recipe Import**
|
|
||||||
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
|
|
||||||
- [x] One-click **Auto-organize Models**
|
|
||||||
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
|
|
||||||
|
|
||||||
**Stay tuned — and thank you for your support!**
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"close": "Schließen"
|
"close": "Schließen",
|
||||||
|
"menu": "Menü"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "Früher Zugriff",
|
"earlyAccess": "Früher Zugriff",
|
||||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||||
"ignored": "Ignoriert",
|
"ignored": "Ignoriert",
|
||||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
|
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
||||||
|
"onSiteOnly": "Nur On-Site",
|
||||||
|
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloadTooltip": "Diese Version herunterladen",
|
"downloadTooltip": "Diese Version herunterladen",
|
||||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||||
|
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"deleteTooltip": "Diese lokale Version löschen",
|
"deleteTooltip": "Diese lokale Version löschen",
|
||||||
"ignore": "Ignorieren",
|
"ignore": "Ignorieren",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
||||||
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
||||||
"exportSuccess": "Diagnosepaket exportiert.",
|
"exportSuccess": "Diagnosepaket exportiert.",
|
||||||
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
|
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}",
|
||||||
|
"conflictsResolved": "{count} Dateinamenskonflikt(e) gelöst.",
|
||||||
|
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"menu": "Menu"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "Early Access",
|
"earlyAccess": "Early Access",
|
||||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||||
"ignored": "Ignored",
|
"ignored": "Ignored",
|
||||||
"ignoredTooltip": "Update notifications are disabled for this version"
|
"ignoredTooltip": "Update notifications are disabled for this version",
|
||||||
|
"onSiteOnly": "On-Site Only",
|
||||||
|
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTooltip": "Download this version",
|
"downloadTooltip": "Download this version",
|
||||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteTooltip": "Delete this local version",
|
"deleteTooltip": "Delete this local version",
|
||||||
"ignore": "Ignore",
|
"ignore": "Ignore",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "Cache rebuild completed.",
|
"repairSuccess": "Cache rebuild completed.",
|
||||||
"repairFailed": "Cache rebuild failed: {message}",
|
"repairFailed": "Cache rebuild failed: {message}",
|
||||||
"exportSuccess": "Diagnostics bundle exported.",
|
"exportSuccess": "Diagnostics bundle exported.",
|
||||||
"exportFailed": "Failed to export diagnostics bundle: {message}"
|
"exportFailed": "Failed to export diagnostics bundle: {message}",
|
||||||
|
"conflictsResolved": "{count} filename conflict(s) resolved.",
|
||||||
|
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"help": "Ayuda",
|
"help": "Ayuda",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"close": "Cerrar"
|
"close": "Cerrar",
|
||||||
|
"menu": "Menú"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "Acceso temprano",
|
"earlyAccess": "Acceso temprano",
|
||||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||||
"ignored": "Ignorada",
|
"ignored": "Ignorada",
|
||||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
|
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
||||||
|
"onSiteOnly": "Solo en Sitio",
|
||||||
|
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"downloadTooltip": "Descargar esta versión",
|
"downloadTooltip": "Descargar esta versión",
|
||||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"deleteTooltip": "Eliminar esta versión local",
|
"deleteTooltip": "Eliminar esta versión local",
|
||||||
"ignore": "Ignorar",
|
"ignore": "Ignorar",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "Reconstrucción de caché completada.",
|
"repairSuccess": "Reconstrucción de caché completada.",
|
||||||
"repairFailed": "Error al reconstruir la caché: {message}",
|
"repairFailed": "Error al reconstruir la caché: {message}",
|
||||||
"exportSuccess": "Paquete de diagnósticos exportado.",
|
"exportSuccess": "Paquete de diagnósticos exportado.",
|
||||||
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
|
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}",
|
||||||
|
"conflictsResolved": "{count} conflicto(s) de nombre de archivo resuelto(s).",
|
||||||
|
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"close": "Fermer"
|
"close": "Fermer",
|
||||||
|
"menu": "Menu"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "Accès anticipé",
|
"earlyAccess": "Accès anticipé",
|
||||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||||
"ignored": "Ignorée",
|
"ignored": "Ignorée",
|
||||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
|
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
||||||
|
"onSiteOnly": "Uniquement sur Site",
|
||||||
|
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"downloadTooltip": "Télécharger cette version",
|
"downloadTooltip": "Télécharger cette version",
|
||||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"deleteTooltip": "Supprimer cette version locale",
|
"deleteTooltip": "Supprimer cette version locale",
|
||||||
"ignore": "Ignorer",
|
"ignore": "Ignorer",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "Reconstruction du cache terminée.",
|
"repairSuccess": "Reconstruction du cache terminée.",
|
||||||
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
||||||
"exportSuccess": "Lot de diagnostics exporté.",
|
"exportSuccess": "Lot de diagnostics exporté.",
|
||||||
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
|
"exportFailed": "Échec de l'export du lot de diagnostics : {message}",
|
||||||
|
"conflictsResolved": "{count} conflit(s) de nom de fichier résolu(s).",
|
||||||
|
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "הגדרות",
|
"settings": "הגדרות",
|
||||||
"help": "עזרה",
|
"help": "עזרה",
|
||||||
"add": "הוספה",
|
"add": "הוספה",
|
||||||
"close": "סגור"
|
"close": "סגור",
|
||||||
|
"menu": "תפריט"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "גישה מוקדמת",
|
"earlyAccess": "גישה מוקדמת",
|
||||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||||
"ignored": "התעלם",
|
"ignored": "התעלם",
|
||||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||||
|
"onSiteOnly": "רק באתר",
|
||||||
|
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "הורדה",
|
"download": "הורדה",
|
||||||
"downloadTooltip": "הורד את הגרסה הזו",
|
"downloadTooltip": "הורד את הגרסה הזו",
|
||||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||||
"delete": "מחיקה",
|
"delete": "מחיקה",
|
||||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||||
"ignore": "התעלם",
|
"ignore": "התעלם",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||||
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||||
"exportSuccess": "חבילת האבחון יוצאה.",
|
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
|
||||||
|
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
|
||||||
|
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
"close": "閉じる"
|
"close": "閉じる",
|
||||||
|
"menu": "メニュー"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "早期アクセス",
|
"earlyAccess": "早期アクセス",
|
||||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||||
"ignored": "無視中",
|
"ignored": "無視中",
|
||||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||||
|
"onSiteOnly": "サイト内のみ",
|
||||||
|
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"downloadTooltip": "このバージョンをダウンロード",
|
"downloadTooltip": "このバージョンをダウンロード",
|
||||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||||
|
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"deleteTooltip": "このローカルバージョンを削除",
|
"deleteTooltip": "このローカルバージョンを削除",
|
||||||
"ignore": "無視",
|
"ignore": "無視",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "キャッシュの再構築が完了しました。",
|
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||||
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||||
"exportSuccess": "診断パッケージをエクスポートしました。",
|
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
|
||||||
|
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
|
||||||
|
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"help": "도움말",
|
"help": "도움말",
|
||||||
"add": "추가",
|
"add": "추가",
|
||||||
"close": "닫기"
|
"close": "닫기",
|
||||||
|
"menu": "메뉴"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "얼리 액세스",
|
"earlyAccess": "얼리 액세스",
|
||||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||||
"ignored": "무시됨",
|
"ignored": "무시됨",
|
||||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||||
|
"onSiteOnly": "사이트 내 전용",
|
||||||
|
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"downloadTooltip": "이 버전 다운로드",
|
"downloadTooltip": "이 버전 다운로드",
|
||||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||||
|
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"deleteTooltip": "이 로컬 버전 삭제",
|
"deleteTooltip": "이 로컬 버전 삭제",
|
||||||
"ignore": "무시",
|
"ignore": "무시",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||||
"repairFailed": "캐시 재구성 실패: {message}",
|
"repairFailed": "캐시 재구성 실패: {message}",
|
||||||
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||||
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
"exportFailed": "진단 번들 내보내기 실패: {message}",
|
||||||
|
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
|
||||||
|
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"close": "Закрыть"
|
"close": "Закрыть",
|
||||||
|
"menu": "Меню"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "Ранний доступ",
|
"earlyAccess": "Ранний доступ",
|
||||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||||
"ignored": "Игнорируется",
|
"ignored": "Игнорируется",
|
||||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||||
|
"onSiteOnly": "Только на Сайте",
|
||||||
|
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"downloadTooltip": "Скачать эту версию",
|
"downloadTooltip": "Скачать эту версию",
|
||||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"deleteTooltip": "Удалить эту локальную версию",
|
"deleteTooltip": "Удалить эту локальную версию",
|
||||||
"ignore": "Игнорировать",
|
"ignore": "Игнорировать",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "Перестройка кэша завершена.",
|
"repairSuccess": "Перестройка кэша завершена.",
|
||||||
"repairFailed": "Не удалось перестроить кэш: {message}",
|
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||||
"exportSuccess": "Диагностический пакет экспортирован.",
|
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
|
||||||
|
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
|
||||||
|
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"menu": "菜单"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "抢先体验",
|
"earlyAccess": "抢先体验",
|
||||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已关闭更新通知"
|
"ignoredTooltip": "此版本已关闭更新通知",
|
||||||
|
"onSiteOnly": "仅站内生成",
|
||||||
|
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTooltip": "下载此版本",
|
"downloadTooltip": "下载此版本",
|
||||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||||
|
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleteTooltip": "删除此本地版本",
|
"deleteTooltip": "删除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "缓存重建完成。",
|
"repairSuccess": "缓存重建完成。",
|
||||||
"repairFailed": "缓存重建失败:{message}",
|
"repairFailed": "缓存重建失败:{message}",
|
||||||
"exportSuccess": "诊断包已导出。",
|
"exportSuccess": "诊断包已导出。",
|
||||||
"exportFailed": "导出诊断包失败:{message}"
|
"exportFailed": "导出诊断包失败:{message}",
|
||||||
|
"conflictsResolved": "已解决 {count} 个文件名冲突。",
|
||||||
|
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "說明",
|
"help": "說明",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
"close": "關閉"
|
"close": "關閉",
|
||||||
|
"menu": "選單"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
@@ -1292,12 +1293,15 @@
|
|||||||
"earlyAccess": "搶先體驗",
|
"earlyAccess": "搶先體驗",
|
||||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已關閉更新通知"
|
"ignoredTooltip": "此版本已關閉更新通知",
|
||||||
|
"onSiteOnly": "僅站內生成",
|
||||||
|
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"downloadTooltip": "下載此版本",
|
"downloadTooltip": "下載此版本",
|
||||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||||
|
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"deleteTooltip": "刪除此本地版本",
|
"deleteTooltip": "刪除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
@@ -1906,7 +1910,9 @@
|
|||||||
"repairSuccess": "快取重建完成。",
|
"repairSuccess": "快取重建完成。",
|
||||||
"repairFailed": "快取重建失敗:{message}",
|
"repairFailed": "快取重建失敗:{message}",
|
||||||
"exportSuccess": "診斷套件已匯出。",
|
"exportSuccess": "診斷套件已匯出。",
|
||||||
"exportFailed": "匯出診斷套件失敗:{message}"
|
"exportFailed": "匯出診斷套件失敗:{message}",
|
||||||
|
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
|
||||||
|
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -560,8 +560,14 @@ class MetadataProcessor:
|
|||||||
|
|
||||||
params["loras"] = " ".join(lora_parts)
|
params["loras"] = " ".join(lora_parts)
|
||||||
|
|
||||||
# Set default clip_skip value
|
# Extract clip_skip from any SAMPLING node that provides it
|
||||||
params["clip_skip"] = "1" # Common default
|
for sampler_info in metadata.get(SAMPLING, {}).values():
|
||||||
|
clip_skip = sampler_info.get("parameters", {}).get("clip_skip")
|
||||||
|
if clip_skip is not None:
|
||||||
|
params["clip_skip"] = clip_skip
|
||||||
|
break
|
||||||
|
if params["clip_skip"] is None:
|
||||||
|
params["clip_skip"] = "1"
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
|||||||
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
||||||
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class EasyComfyLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "ckpt_name" in inputs:
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, inputs["ckpt_name"])
|
||||||
|
|
||||||
|
# Only extract from optional_lora_stack — skip the single lora_name to
|
||||||
|
# avoid double-counting LoRAs that come through the LORA_STACK path.
|
||||||
|
active_loras = []
|
||||||
|
optional_lora_stack = inputs.get("optional_lora_stack")
|
||||||
|
if optional_lora_stack is not None and isinstance(optional_lora_stack, (list, tuple)):
|
||||||
|
for item in optional_lora_stack:
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
lora_path = item[0]
|
||||||
|
model_strength = item[1]
|
||||||
|
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": model_strength
|
||||||
|
})
|
||||||
|
|
||||||
|
if active_loras:
|
||||||
|
metadata[LORAS][node_id] = {
|
||||||
|
"lora_list": active_loras,
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
positive_text = inputs.get("positive", "")
|
||||||
|
negative_text = inputs.get("negative", "")
|
||||||
|
|
||||||
|
if positive_text or negative_text:
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
metadata[PROMPTS][node_id]["positive_text"] = positive_text
|
||||||
|
metadata[PROMPTS][node_id]["negative_text"] = negative_text
|
||||||
|
|
||||||
|
if "clip_skip" in inputs:
|
||||||
|
clip_skip = inputs["clip_skip"]
|
||||||
|
if node_id not in metadata[SAMPLING]:
|
||||||
|
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
|
||||||
|
metadata[SAMPLING][node_id]["parameters"]["clip_skip"] = clip_skip
|
||||||
|
|
||||||
|
width = inputs.get("empty_latent_width")
|
||||||
|
height = inputs.get("empty_latent_height")
|
||||||
|
if width is not None and height is not None:
|
||||||
|
if SIZE not in metadata:
|
||||||
|
metadata[SIZE] = {}
|
||||||
|
metadata[SIZE][node_id] = {
|
||||||
|
"width": int(width),
|
||||||
|
"height": int(height),
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
# outputs: [(pipe_dict, model, vae), ...]
|
||||||
|
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||||
|
return
|
||||||
|
first_output = outputs[0]
|
||||||
|
if not isinstance(first_output, tuple) or len(first_output) < 1:
|
||||||
|
return
|
||||||
|
pipe = first_output[0]
|
||||||
|
if not isinstance(pipe, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
positive_conditioning = pipe.get("positive")
|
||||||
|
negative_conditioning = pipe.get("negative")
|
||||||
|
|
||||||
|
if positive_conditioning is not None or negative_conditioning is not None:
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
if positive_conditioning is not None:
|
||||||
|
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
||||||
|
if negative_conditioning is not None:
|
||||||
|
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class EasyPreSamplingExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
sampling_params = {}
|
||||||
|
for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
|
||||||
|
if key in inputs:
|
||||||
|
sampling_params[key] = inputs[key]
|
||||||
|
|
||||||
|
metadata[SAMPLING][node_id] = {
|
||||||
|
"parameters": sampling_params,
|
||||||
|
"node_id": node_id,
|
||||||
|
IS_SAMPLER: True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EasySeedExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "seed" not in inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata[SAMPLING][node_id] = {
|
||||||
|
"parameters": {"seed": inputs["seed"]},
|
||||||
|
"node_id": node_id,
|
||||||
|
IS_SAMPLER: False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -1013,9 +1125,12 @@ NODE_EXTRACTORS = {
|
|||||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||||
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
||||||
|
# ComfyUI-Easy-Use pre-sampling / seed
|
||||||
|
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
|
||||||
|
"easySeed": EasySeedExtractor, # easy seed
|
||||||
# Loaders
|
# Loaders
|
||||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
|
||||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import folder_paths # type: ignore
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from ..config import config
|
||||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_within_loras(abs_path):
|
||||||
|
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
|
||||||
|
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
|
||||||
|
for root in all_roots:
|
||||||
|
try:
|
||||||
|
return os.path.relpath(abs_path, root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
class WanVideoLoraSelectLM:
|
class WanVideoLoraSelectLM:
|
||||||
NAME = "WanVideo Lora Select (LoraManager)"
|
NAME = "WanVideo Lora Select (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/stackers"
|
CATEGORY = "Lora Manager/stackers"
|
||||||
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
|
|||||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||||
|
|
||||||
# Get lora path and trigger words
|
# Get lora path and trigger words
|
||||||
lora_path, trigger_words = get_lora_info(lora_name)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||||
|
|
||||||
# Create lora item for WanVideo format
|
# Create lora item for WanVideo format
|
||||||
lora_item = {
|
lora_item = {
|
||||||
"path": folder_paths.get_full_path("loras", lora_path),
|
"path": lora_path,
|
||||||
"strength": model_strength,
|
"strength": model_strength,
|
||||||
"name": lora_path.split(".")[0],
|
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import folder_paths # type: ignore
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from ..config import config
|
||||||
from .utils import any_type
|
from .utils import any_type
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# 初始化日志记录器
|
# 初始化日志记录器
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_within_loras(abs_path):
|
||||||
|
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
|
||||||
|
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
|
||||||
|
for root in all_roots:
|
||||||
|
try:
|
||||||
|
return os.path.relpath(abs_path, root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
# 定义新节点的类
|
# 定义新节点的类
|
||||||
class WanVideoLoraTextSelectLM:
|
class WanVideoLoraTextSelectLM:
|
||||||
# 节点在UI中显示的名称
|
# 节点在UI中显示的名称
|
||||||
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lora_path, trigger_words = get_lora_info(lora_name_raw)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
|
||||||
|
|
||||||
lora_item = {
|
lora_item = {
|
||||||
"path": folder_paths.get_full_path("loras", lora_path),
|
"path": lora_path,
|
||||||
"strength": model_strength,
|
"strength": model_strength,
|
||||||
"name": lora_path.split(".")[0],
|
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
|
|||||||
|
|
||||||
def _find_model_file(self, files):
|
def _find_model_file(self, files):
|
||||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
||||||
return next((file for file in files if file.get("type") == "Model" and file.get("primary") is True), None)
|
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
|
||||||
|
|
||||||
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
||||||
"""Expose handlers for subclasses or tests."""
|
"""Expose handlers for subclasses or tests."""
|
||||||
|
|||||||
@@ -33,15 +33,18 @@ from ...services.metadata_service import (
|
|||||||
update_metadata_providers,
|
update_metadata_providers,
|
||||||
)
|
)
|
||||||
from ...services.service_registry import ServiceRegistry
|
from ...services.service_registry import ServiceRegistry
|
||||||
|
from ...services.model_lifecycle_service import delete_model_artifacts
|
||||||
from ...services.settings_manager import get_settings_manager
|
from ...services.settings_manager import get_settings_manager
|
||||||
from ...services.websocket_manager import ws_manager
|
from ...services.websocket_manager import ws_manager
|
||||||
from ...services.downloader import get_downloader
|
from ...services.downloader import get_downloader
|
||||||
from ...services.errors import ResourceNotFoundError
|
from ...services.errors import ResourceNotFoundError
|
||||||
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||||
|
from ...utils.models import BaseModelMetadata
|
||||||
from ...utils.constants import (
|
from ...utils.constants import (
|
||||||
CIVITAI_USER_MODEL_TYPES,
|
CIVITAI_USER_MODEL_TYPES,
|
||||||
DEFAULT_NODE_COLOR,
|
DEFAULT_NODE_COLOR,
|
||||||
NODE_TYPES,
|
NODE_TYPES,
|
||||||
|
PREVIEW_EXTENSIONS,
|
||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
@@ -617,6 +620,7 @@ class DoctorHandler:
|
|||||||
diagnostics = [
|
diagnostics = [
|
||||||
await self._check_civitai_api_key(),
|
await self._check_civitai_api_key(),
|
||||||
await self._check_cache_health(),
|
await self._check_cache_health(),
|
||||||
|
await self._check_filename_conflicts(),
|
||||||
self._check_ui_version(client_version, app_version),
|
self._check_ui_version(client_version, app_version),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -681,6 +685,145 @@ class DoctorHandler:
|
|||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
|
renamed: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
try:
|
||||||
|
scanner = await factory()
|
||||||
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
|
if hash_index is None:
|
||||||
|
continue
|
||||||
|
duplicates = {
|
||||||
|
filename: list(paths)
|
||||||
|
for filename, paths in hash_index.get_duplicate_filenames().items()
|
||||||
|
}
|
||||||
|
if not duplicates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
path_to_model = {m["file_path"]: m for m in cache.raw_data}
|
||||||
|
|
||||||
|
used_basenames: set[str] = set()
|
||||||
|
for paths in duplicates.values():
|
||||||
|
if paths:
|
||||||
|
used_basenames.add(
|
||||||
|
os.path.splitext(os.path.basename(paths[0]))[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
for filename, paths in duplicates.items():
|
||||||
|
for idx, path in enumerate(paths):
|
||||||
|
if idx == 0:
|
||||||
|
continue
|
||||||
|
dirname = os.path.dirname(path)
|
||||||
|
base_name = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
ext = os.path.splitext(path)[1]
|
||||||
|
if not ext:
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_data = path_to_model.get(path)
|
||||||
|
sha256 = (
|
||||||
|
model_data.get("sha256", "") if model_data else ""
|
||||||
|
)
|
||||||
|
hash_provider = (
|
||||||
|
lambda s=sha256: s if s else "0000"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_filename = (
|
||||||
|
BaseModelMetadata.generate_unique_filename(
|
||||||
|
dirname,
|
||||||
|
base_name,
|
||||||
|
ext,
|
||||||
|
hash_provider=hash_provider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_base = os.path.splitext(new_filename)[0]
|
||||||
|
counter = 1
|
||||||
|
original_base = candidate_base
|
||||||
|
while candidate_base in used_basenames:
|
||||||
|
candidate_base = f"{original_base}-{counter}"
|
||||||
|
new_filename = f"{candidate_base}{ext}"
|
||||||
|
counter += 1
|
||||||
|
used_basenames.add(candidate_base)
|
||||||
|
|
||||||
|
new_path = os.path.join(dirname, new_filename)
|
||||||
|
|
||||||
|
if new_filename == os.path.basename(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_base_no_ext = os.path.splitext(path)[0]
|
||||||
|
new_base_no_ext = (
|
||||||
|
os.path.splitext(new_path)[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
os.rename(path, new_path)
|
||||||
|
|
||||||
|
for suffix in (".metadata.json", ".civitai.info"):
|
||||||
|
old_sidecar = old_base_no_ext + suffix
|
||||||
|
new_sidecar = new_base_no_ext + suffix
|
||||||
|
if os.path.exists(old_sidecar):
|
||||||
|
os.rename(old_sidecar, new_sidecar)
|
||||||
|
|
||||||
|
for preview_ext in PREVIEW_EXTENSIONS:
|
||||||
|
old_preview = old_base_no_ext + preview_ext
|
||||||
|
new_preview = new_base_no_ext + preview_ext
|
||||||
|
if os.path.exists(old_preview):
|
||||||
|
os.rename(old_preview, new_preview)
|
||||||
|
|
||||||
|
entry = path_to_model.get(path)
|
||||||
|
if entry:
|
||||||
|
entry = dict(entry)
|
||||||
|
entry["file_name"] = os.path.splitext(new_filename)[0]
|
||||||
|
if entry.get("preview_url"):
|
||||||
|
old_preview_url = entry["preview_url"].replace("\\", "/")
|
||||||
|
preview_ext = os.path.splitext(old_preview_url)[1]
|
||||||
|
if preview_ext:
|
||||||
|
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
|
||||||
|
await scanner.update_single_model_cache(
|
||||||
|
path, new_path, entry
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Resolved duplicate filename '%s': "
|
||||||
|
"renamed '%s' to '%s'",
|
||||||
|
filename,
|
||||||
|
path,
|
||||||
|
new_path,
|
||||||
|
)
|
||||||
|
renamed.append({
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"filename": filename,
|
||||||
|
"old_path": path,
|
||||||
|
"new_path": new_path,
|
||||||
|
"new_filename": new_filename,
|
||||||
|
})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error(
|
||||||
|
"Failed to resolve filename conflicts for %s: %s",
|
||||||
|
model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"renamed": renamed,
|
||||||
|
"count": len(renamed),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Error resolving filename conflicts: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
|
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
@@ -846,6 +989,79 @@ class DoctorHandler:
|
|||||||
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
|
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _check_filename_conflicts(self) -> dict[str, Any]:
|
||||||
|
all_conflicts: list[dict[str, Any]] = []
|
||||||
|
total_conflict_groups = 0
|
||||||
|
total_conflict_files = 0
|
||||||
|
|
||||||
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
try:
|
||||||
|
scanner = await factory()
|
||||||
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
|
if hash_index is None:
|
||||||
|
continue
|
||||||
|
duplicates = hash_index.get_duplicate_filenames()
|
||||||
|
if not duplicates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_conflict_groups += len(duplicates)
|
||||||
|
for filename, paths in duplicates.items():
|
||||||
|
total_conflict_files += len(paths)
|
||||||
|
all_conflicts.append({
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"filename": filename,
|
||||||
|
"paths": paths,
|
||||||
|
})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error(
|
||||||
|
"Doctor filename conflict check failed for %s: %s",
|
||||||
|
model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all_conflicts:
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "ok",
|
||||||
|
"summary": "No duplicate filenames found across model directories.",
|
||||||
|
"details": [],
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"{total_conflict_groups} filename(s) shared by "
|
||||||
|
f"{total_conflict_files} files across your library. "
|
||||||
|
f"This causes ambiguity when loading LoRAs by name."
|
||||||
|
)
|
||||||
|
details: list[str | dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"conflict_groups": total_conflict_groups,
|
||||||
|
"total_conflict_files": total_conflict_files,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
for conflict in all_conflicts:
|
||||||
|
details.append(
|
||||||
|
f"[{conflict['label']}] '{conflict['filename']}' "
|
||||||
|
f"found in {len(conflict['paths'])} locations"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "warning",
|
||||||
|
"summary": summary,
|
||||||
|
"details": details,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "resolve-filename-conflicts",
|
||||||
|
"label": "Resolve Conflicts",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
|
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
|
||||||
if client_version and client_version != app_version:
|
if client_version and client_version != app_version:
|
||||||
return {
|
return {
|
||||||
@@ -1576,29 +1792,33 @@ class ModelLibraryHandler:
|
|||||||
exists = True
|
exists = True
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"exists": True,
|
||||||
|
"modelType": model_type,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
has_been_downloaded = False
|
has_been_downloaded = False
|
||||||
history_type = model_type
|
history_type = None
|
||||||
if history_type:
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
has_been_downloaded = await history_service.has_been_downloaded(
|
if await history_service.has_been_downloaded(
|
||||||
history_type,
|
candidate_type,
|
||||||
model_version_id,
|
model_version_id,
|
||||||
)
|
):
|
||||||
else:
|
has_been_downloaded = True
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
history_type = candidate_type
|
||||||
if await history_service.has_been_downloaded(
|
break
|
||||||
candidate_type,
|
|
||||||
model_version_id,
|
|
||||||
):
|
|
||||||
has_been_downloaded = True
|
|
||||||
history_type = candidate_type
|
|
||||||
break
|
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"exists": exists,
|
"exists": False,
|
||||||
"modelType": model_type if exists else history_type,
|
"modelType": history_type,
|
||||||
"hasBeenDownloaded": has_been_downloaded,
|
"hasBeenDownloaded": has_been_downloaded,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1618,40 +1838,46 @@ class ModelLibraryHandler:
|
|||||||
model_type = None
|
model_type = None
|
||||||
versions = []
|
versions = []
|
||||||
downloaded_version_ids = []
|
downloaded_version_ids = []
|
||||||
history_service = await self._get_download_history_service()
|
|
||||||
if lora_versions:
|
if lora_versions:
|
||||||
model_type = "lora"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(lora_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "lora",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(lora_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
elif checkpoint_versions:
|
if checkpoint_versions:
|
||||||
model_type = "checkpoint"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(checkpoint_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "checkpoint",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
elif embedding_versions:
|
if embedding_versions:
|
||||||
model_type = "embedding"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(embedding_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "embedding",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(embedding_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
history_service = await self._get_download_history_service()
|
||||||
candidate_downloaded_version_ids = (
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
await history_service.get_downloaded_version_ids(
|
candidate_downloaded_version_ids = (
|
||||||
candidate_type,
|
await history_service.get_downloaded_version_ids(
|
||||||
model_id,
|
candidate_type,
|
||||||
)
|
model_id,
|
||||||
)
|
)
|
||||||
if candidate_downloaded_version_ids:
|
)
|
||||||
model_type = candidate_type
|
if candidate_downloaded_version_ids:
|
||||||
downloaded_version_ids = candidate_downloaded_version_ids
|
model_type = candidate_type
|
||||||
break
|
downloaded_version_ids = candidate_downloaded_version_ids
|
||||||
|
break
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1665,6 +1891,86 @@ class ModelLibraryHandler:
|
|||||||
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
logger.error("Failed to check model existence: %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 check_models_exist(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_ids_raw = request.query.get("modelIds", "")
|
||||||
|
if not model_ids_raw:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_ids = model_ids_raw.split(",")
|
||||||
|
seen: set[int] = set()
|
||||||
|
model_ids: list[int] = []
|
||||||
|
for raw in raw_ids:
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mid = int(stripped)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if mid not in seen:
|
||||||
|
seen.add(mid)
|
||||||
|
model_ids.append(mid)
|
||||||
|
|
||||||
|
if not model_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
for model_id in model_ids:
|
||||||
|
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if lora_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "lora",
|
||||||
|
"versions": self._with_downloaded_flag(lora_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if checkpoint_scanner:
|
||||||
|
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if checkpoint_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "checkpoint",
|
||||||
|
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if embedding_scanner:
|
||||||
|
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if embedding_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "embedding",
|
||||||
|
"versions": self._with_downloaded_flag(embedding_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": None,
|
||||||
|
"versions": [],
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": results}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to check models existence: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_version_download_status(
|
async def get_model_version_download_status(
|
||||||
self, request: web.Request
|
self, request: web.Request
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
@@ -1777,6 +2083,78 @@ class ModelLibraryHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def delete_model_version(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_version_id_str = request.query.get("modelVersionId")
|
||||||
|
if not model_version_id_str:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing required parameter: modelVersionId"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model_version_id = int(model_version_id_str)
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
|
found_type = None
|
||||||
|
file_path = None
|
||||||
|
found_cache = None
|
||||||
|
|
||||||
|
for model_type, scanner in (
|
||||||
|
("lora", lora_scanner),
|
||||||
|
("checkpoint", checkpoint_scanner),
|
||||||
|
("embedding", embedding_scanner),
|
||||||
|
):
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
if cache and model_version_id in cache.version_index:
|
||||||
|
found_type = model_type
|
||||||
|
found_cache = cache
|
||||||
|
entry = cache.version_index[model_version_id]
|
||||||
|
file_path = entry.get("file_path")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model version not found in any scanner cache"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
target_dir = os.path.dirname(file_path)
|
||||||
|
base_name = os.path.basename(file_path)
|
||||||
|
file_name, extension = os.path.splitext(base_name)
|
||||||
|
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||||
|
|
||||||
|
if found_cache:
|
||||||
|
found_cache.raw_data = [
|
||||||
|
item
|
||||||
|
for item in found_cache.raw_data
|
||||||
|
if item.get("file_path") != file_path
|
||||||
|
]
|
||||||
|
await found_cache.resort()
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
await history_service.mark_not_downloaded(found_type, model_version_id)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"modelType": found_type,
|
||||||
|
"modelVersionId": model_version_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete model version: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
model_id_str = request.query.get("modelId")
|
model_id_str = request.query.get("modelId")
|
||||||
@@ -2796,6 +3174,7 @@ class MiscHandlerSet:
|
|||||||
"update_settings": self.settings.update_settings,
|
"update_settings": self.settings.update_settings,
|
||||||
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
||||||
"repair_doctor_cache": self.doctor.repair_doctor_cache,
|
"repair_doctor_cache": self.doctor.repair_doctor_cache,
|
||||||
|
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
|
||||||
"export_doctor_bundle": self.doctor.export_doctor_bundle,
|
"export_doctor_bundle": self.doctor.export_doctor_bundle,
|
||||||
"get_priority_tags": self.settings.get_priority_tags,
|
"get_priority_tags": self.settings.get_priority_tags,
|
||||||
"get_settings_libraries": self.settings.get_libraries,
|
"get_settings_libraries": self.settings.get_libraries,
|
||||||
@@ -2809,8 +3188,10 @@ class MiscHandlerSet:
|
|||||||
"update_node_widget": self.node_registry.update_node_widget,
|
"update_node_widget": self.node_registry.update_node_widget,
|
||||||
"get_registry": self.node_registry.get_registry,
|
"get_registry": self.node_registry.get_registry,
|
||||||
"check_model_exists": self.model_library.check_model_exists,
|
"check_model_exists": self.model_library.check_model_exists,
|
||||||
|
"check_models_exist": self.model_library.check_models_exist,
|
||||||
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
||||||
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
||||||
|
"delete_model_version": self.model_library.delete_model_version,
|
||||||
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
||||||
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
||||||
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
||||||
|
|||||||
@@ -2423,6 +2423,7 @@ class ModelUpdateHandler:
|
|||||||
"shouldIgnore": version.should_ignore,
|
"shouldIgnore": version.should_ignore,
|
||||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||||
"isEarlyAccess": is_early_access,
|
"isEarlyAccess": is_early_access,
|
||||||
|
"usageControl": version.usage_control,
|
||||||
"filePath": context.get("file_path"),
|
"filePath": context.get("file_path"),
|
||||||
"fileName": context.get("file_name"),
|
"fileName": context.get("file_name"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||||
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||||
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||||
|
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
|
||||||
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
||||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||||
@@ -42,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||||
|
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET",
|
"GET",
|
||||||
"/api/lm/model-version-download-status",
|
"/api/lm/model-version-download-status",
|
||||||
@@ -89,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
||||||
),
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1364,7 +1364,7 @@ class DownloadManager:
|
|||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary")
|
if f.get("primary")
|
||||||
and f.get("type") in ("Model", "Negative")
|
and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1395,7 +1395,7 @@ class DownloadManager:
|
|||||||
(
|
(
|
||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary") and f.get("type") in ("Model", "Negative")
|
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
|
|||||||
self._db_path = db_path or _resolve_database_path()
|
self._db_path = db_path or _resolve_database_path()
|
||||||
self._settings = settings_manager or get_settings_manager()
|
self._settings = settings_manager or get_settings_manager()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._conn: sqlite3.Connection | None = None
|
||||||
self._schema_initialized = False
|
self._schema_initialized = False
|
||||||
self._ensure_directory()
|
self._ensure_directory()
|
||||||
self._initialize_schema()
|
self._initialize_schema()
|
||||||
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
def _get_conn(self) -> sqlite3.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
return self._conn
|
||||||
|
|
||||||
def _initialize_schema(self) -> None:
|
def _initialize_schema(self) -> None:
|
||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
@@ -116,33 +123,33 @@ class DownloadedVersionHistoryService:
|
|||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 0
|
is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
normalized_type,
|
normalized_type,
|
||||||
normalized_version_id,
|
normalized_version_id,
|
||||||
normalized_model_id,
|
normalized_model_id,
|
||||||
timestamp,
|
timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
source,
|
source,
|
||||||
file_path,
|
file_path,
|
||||||
active_library_name,
|
active_library_name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def mark_downloaded_bulk(
|
async def mark_downloaded_bulk(
|
||||||
self,
|
self,
|
||||||
@@ -180,24 +187,24 @@ class DownloadedVersionHistoryService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 0
|
is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
@@ -208,28 +215,28 @@ class DownloadedVersionHistoryService:
|
|||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||||
source, last_file_path, last_library_name, is_deleted_override
|
source, last_file_path, last_library_name, is_deleted_override
|
||||||
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||||
last_seen_at = excluded.last_seen_at,
|
last_seen_at = excluded.last_seen_at,
|
||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||||
is_deleted_override = 1
|
is_deleted_override = 1
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
normalized_type,
|
normalized_type,
|
||||||
normalized_version_id,
|
normalized_version_id,
|
||||||
timestamp,
|
timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
self._get_active_library_name(),
|
self._get_active_library_name(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
@@ -238,15 +245,15 @@ class DownloadedVersionHistoryService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT is_deleted_override
|
SELECT is_deleted_override
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ? AND version_id = ?
|
WHERE model_type = ? AND version_id = ?
|
||||||
""",
|
""",
|
||||||
(normalized_type, normalized_version_id),
|
(normalized_type, normalized_version_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return bool(row) and not bool(row["is_deleted_override"])
|
return bool(row) and not bool(row["is_deleted_override"])
|
||||||
|
|
||||||
async def get_downloaded_version_ids(
|
async def get_downloaded_version_ids(
|
||||||
@@ -258,16 +265,16 @@ class DownloadedVersionHistoryService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT version_id
|
SELECT version_id
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||||
ORDER BY version_id ASC
|
ORDER BY version_id ASC
|
||||||
""",
|
""",
|
||||||
(normalized_type, normalized_model_id),
|
(normalized_type, normalized_model_id),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [int(row["version_id"]) for row in rows]
|
return [int(row["version_id"]) for row in rows]
|
||||||
|
|
||||||
async def get_downloaded_version_ids_bulk(
|
async def get_downloaded_version_ids_bulk(
|
||||||
@@ -291,17 +298,17 @@ class DownloadedVersionHistoryService:
|
|||||||
params: list[object] = [normalized_type, *normalized_model_ids]
|
params: list[object] = [normalized_type, *normalized_model_ids]
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT model_id, version_id
|
SELECT model_id, version_id
|
||||||
FROM downloaded_model_versions
|
FROM downloaded_model_versions
|
||||||
WHERE model_type = ?
|
WHERE model_type = ?
|
||||||
AND model_id IN ({placeholders})
|
AND model_id IN ({placeholders})
|
||||||
AND is_deleted_override = 0
|
AND is_deleted_override = 0
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
result: dict[int, set[int]] = {}
|
result: dict[int, set[int]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ class ModelHashIndex:
|
|||||||
hash_val = h
|
hash_val = h
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if hash_val is None:
|
||||||
|
for h, paths in self._duplicate_hashes.items():
|
||||||
|
if file_path in paths:
|
||||||
|
hash_val = h
|
||||||
|
break
|
||||||
|
|
||||||
# If we didn't find a hash, nothing to do
|
# If we didn't find a hash, nothing to do
|
||||||
if not hash_val:
|
if not hash_val:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1072,14 +1072,6 @@ class ModelScanner:
|
|||||||
excluded_models.append(model_data['file_path'])
|
excluded_models.append(model_data['file_path'])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check for duplicate filename before adding to hash index
|
|
||||||
# filename = os.path.splitext(os.path.basename(file_path))[0]
|
|
||||||
# existing_hash = hash_index.get_hash_by_filename(filename)
|
|
||||||
# if existing_hash and existing_hash != model_data.get('sha256', '').lower():
|
|
||||||
# existing_path = hash_index.get_path(existing_hash)
|
|
||||||
# if existing_path and existing_path != file_path:
|
|
||||||
# logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
|
|
||||||
|
|
||||||
return model_data
|
return model_data
|
||||||
|
|
||||||
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
|
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
|
||||||
@@ -1105,6 +1097,31 @@ class ModelScanner:
|
|||||||
|
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
|
self._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
def _log_duplicate_filename_summary(self) -> None:
|
||||||
|
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
||||||
|
if self._hash_index is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
duplicates = self._hash_index.get_duplicate_filenames()
|
||||||
|
if not duplicates:
|
||||||
|
return
|
||||||
|
|
||||||
|
total_files = sum(len(paths) for paths in duplicates.values())
|
||||||
|
conflict_count = len(duplicates)
|
||||||
|
model_type_label = self.model_type or "model"
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Duplicate filename conflict detected: %d %s filename(s) "
|
||||||
|
"are shared by %d files total, causing ambiguity in %s resolution. "
|
||||||
|
"Open the Doctor panel to resolve one-click.",
|
||||||
|
conflict_count,
|
||||||
|
model_type_label,
|
||||||
|
total_files,
|
||||||
|
model_type_label.capitalize(),
|
||||||
|
)
|
||||||
|
|
||||||
async def _sync_download_history(
|
async def _sync_download_history(
|
||||||
self,
|
self,
|
||||||
raw_data: List[Mapping[str, Any]],
|
raw_data: List[Mapping[str, Any]],
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ModelVersionRecord:
|
|||||||
early_access_ends_at: Optional[str] = None
|
early_access_ends_at: Optional[str] = None
|
||||||
sort_index: int = 0
|
sort_index: int = 0
|
||||||
is_early_access: bool = False
|
is_early_access: bool = False
|
||||||
|
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
|
|||||||
|
|
||||||
return [version.version_id for version in self.versions if version.is_in_library]
|
return [version.version_id for version in self.versions if version.is_in_library]
|
||||||
|
|
||||||
def has_update(self, hide_early_access: bool = False) -> bool:
|
def has_update(
|
||||||
|
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
|
||||||
|
) -> bool:
|
||||||
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
hide_early_access: If True, exclude early access versions from update check.
|
||||||
|
hide_non_downloadable: If True, exclude versions that don't allow downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.should_ignore_model:
|
if self.should_ignore_model:
|
||||||
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
|
|||||||
not version.is_in_library
|
not version.is_in_library
|
||||||
and not version.should_ignore
|
and not version.should_ignore
|
||||||
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
||||||
|
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
|
||||||
for version in self.versions
|
for version in self.versions
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
|
continue
|
||||||
if version.version_id > max_in_library:
|
if version.version_id > max_in_library:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
|
|||||||
# Phase 1: Basic EA flag from bulk API
|
# Phase 1: Basic EA flag from bulk API
|
||||||
return version.is_early_access
|
return version.is_early_access
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_downloadable(version: ModelVersionRecord) -> bool:
|
||||||
|
if version.usage_control is None:
|
||||||
|
return True
|
||||||
|
return version.usage_control == "Download"
|
||||||
|
|
||||||
def has_update_for_base(
|
def has_update_for_base(
|
||||||
self,
|
self,
|
||||||
local_version_id: Optional[int],
|
local_version_id: Optional[int],
|
||||||
local_base_model: Optional[str],
|
local_base_model: Optional[str],
|
||||||
hide_early_access: bool = False,
|
hide_early_access: bool = False,
|
||||||
|
hide_non_downloadable: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True when a newer remote version with the same base model exists.
|
"""Return True when a newer remote version with the same base model exists.
|
||||||
|
|
||||||
@@ -167,6 +181,7 @@ class ModelUpdateRecord:
|
|||||||
local_version_id: The current local version id.
|
local_version_id: The current local version id.
|
||||||
local_base_model: The base model to filter by.
|
local_base_model: The base model to filter by.
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
hide_early_access: If True, exclude early access versions from update check.
|
||||||
|
hide_non_downloadable: If True, exclude versions that don't allow downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.should_ignore_model:
|
if self.should_ignore_model:
|
||||||
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
|
continue
|
||||||
version_base = _normalize_base_model(version.base_model)
|
version_base = _normalize_base_model(version.base_model)
|
||||||
if version_base != normalized_base:
|
if version_base != normalized_base:
|
||||||
continue
|
continue
|
||||||
@@ -230,6 +247,7 @@ class ModelUpdateService:
|
|||||||
preview_url TEXT,
|
preview_url TEXT,
|
||||||
is_in_library INTEGER NOT NULL DEFAULT 0,
|
is_in_library INTEGER NOT NULL DEFAULT 0,
|
||||||
should_ignore INTEGER NOT NULL DEFAULT 0,
|
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||||
|
usage_control TEXT,
|
||||||
PRIMARY KEY (model_id, version_id),
|
PRIMARY KEY (model_id, version_id),
|
||||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -465,6 +483,10 @@ class ModelUpdateService:
|
|||||||
"ALTER TABLE model_update_versions "
|
"ALTER TABLE model_update_versions "
|
||||||
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
||||||
),
|
),
|
||||||
|
"usage_control": (
|
||||||
|
"ALTER TABLE model_update_versions "
|
||||||
|
"ADD COLUMN usage_control TEXT"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, statement in migrations.items():
|
for column, statement in migrations.items():
|
||||||
@@ -1337,6 +1359,7 @@ class ModelUpdateService:
|
|||||||
# Check availability field from bulk API for basic EA detection
|
# Check availability field from bulk API for basic EA detection
|
||||||
availability = _normalize_string(entry.get("availability"))
|
availability = _normalize_string(entry.get("availability"))
|
||||||
is_early_access = availability == "EarlyAccess"
|
is_early_access = availability == "EarlyAccess"
|
||||||
|
usage_control = _normalize_string(entry.get("usageControl"))
|
||||||
|
|
||||||
return ModelVersionRecord(
|
return ModelVersionRecord(
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
@@ -1350,6 +1373,7 @@ class ModelUpdateService:
|
|||||||
early_access_ends_at=early_access_ends_at,
|
early_access_ends_at=early_access_ends_at,
|
||||||
sort_index=index,
|
sort_index=index,
|
||||||
is_early_access=is_early_access,
|
is_early_access=is_early_access,
|
||||||
|
usage_control=usage_control,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||||
@@ -1464,7 +1488,7 @@ class ModelUpdateService:
|
|||||||
f"""
|
f"""
|
||||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access
|
is_early_access, usage_control
|
||||||
FROM model_update_versions
|
FROM model_update_versions
|
||||||
WHERE model_id IN ({placeholders})
|
WHERE model_id IN ({placeholders})
|
||||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||||
@@ -1492,6 +1516,7 @@ class ModelUpdateService:
|
|||||||
early_access_ends_at=row["early_access_ends_at"],
|
early_access_ends_at=row["early_access_ends_at"],
|
||||||
sort_index=_normalize_int(row["sort_index"]) or 0,
|
sort_index=_normalize_int(row["sort_index"]) or 0,
|
||||||
is_early_access=bool(row["is_early_access"]),
|
is_early_access=bool(row["is_early_access"]),
|
||||||
|
usage_control=row["usage_control"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1548,8 +1573,8 @@ class ModelUpdateService:
|
|||||||
INSERT INTO model_update_versions (
|
INSERT INTO model_update_versions (
|
||||||
version_id, model_id, sort_index, name, base_model, released_at,
|
version_id, model_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access
|
is_early_access, usage_control
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
version.version_id,
|
version.version_id,
|
||||||
@@ -1564,6 +1589,7 @@ class ModelUpdateService:
|
|||||||
1 if version.should_ignore else 0,
|
1 if version.should_ignore else 0,
|
||||||
version.early_access_ends_at,
|
version.early_access_ends_at,
|
||||||
1 if version.is_early_access else 0,
|
1 if version.is_early_access else 0,
|
||||||
|
version.usage_control,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
354
scripts/migrate_legacy_metadata.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.
|
||||||
|
|
||||||
|
This script automatically discovers model folders from LoRA Manager's settings.json,
|
||||||
|
finds JSON files with the same basename as model files (e.g., `model.json` for
|
||||||
|
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.
|
||||||
|
|
||||||
|
Fields migrated:
|
||||||
|
- "activation text" → civitai.trainedWords (array of trigger words)
|
||||||
|
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
|
||||||
|
- "notes" → notes (user-defined notes)
|
||||||
|
|
||||||
|
Supported model types: LoRA, Checkpoint
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Read settings.json to find all configured model folders
|
||||||
|
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
|
||||||
|
3. Find corresponding legacy metadata JSON files
|
||||||
|
4. Migrate data to .metadata.json files
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
|
||||||
|
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_path() -> Path:
|
||||||
|
repo_root = Path(__file__).parent.parent.resolve()
|
||||||
|
portable = repo_root / "settings.json"
|
||||||
|
if portable.exists():
|
||||||
|
payload = load_json(portable)
|
||||||
|
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||||
|
return portable
|
||||||
|
|
||||||
|
config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if config_home:
|
||||||
|
return Path(config_home).expanduser() / APP_NAME / "settings.json"
|
||||||
|
return Path.home() / ".config" / APP_NAME / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logger.error(f"Invalid JSON in {path}: {exc}")
|
||||||
|
return {}
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(f"Cannot read {path}: {exc}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_path(value: str) -> str:
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [expand_path(value)] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [expand_path(item) for item in value if isinstance(item, str) and item]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
roots: dict[str, list[str]] = {}
|
||||||
|
active_library = settings.get("active_library") or "default"
|
||||||
|
sources = [settings]
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict):
|
||||||
|
sources.insert(0, library)
|
||||||
|
for source in sources:
|
||||||
|
folder_paths = source.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, dict):
|
||||||
|
for key, value in folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(normalize_path_list(value))
|
||||||
|
for default_key, folder_key in (
|
||||||
|
("default_lora_root", "loras"),
|
||||||
|
("default_checkpoint_root", "checkpoints"),
|
||||||
|
("default_embedding_root", "embeddings"),
|
||||||
|
("default_unet_root", "unet"),
|
||||||
|
):
|
||||||
|
value = settings.get(default_key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
roots.setdefault(folder_key, []).append(expand_path(value))
|
||||||
|
return {key: dedupe(values) for key, values in roots.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def find_model_files(directory: Path) -> list[Path]:
|
||||||
|
model_files = []
|
||||||
|
for ext in MODEL_EXTENSIONS:
|
||||||
|
model_files.extend(directory.rglob(f"*{ext}"))
|
||||||
|
return model_files
|
||||||
|
|
||||||
|
|
||||||
|
def find_legacy_metadata(model_path: Path) -> Path | None:
|
||||||
|
base_name = model_path.stem
|
||||||
|
legacy_path = model_path.with_name(f"{base_name}.json")
|
||||||
|
if legacy_path.exists() and legacy_path.is_file():
|
||||||
|
return legacy_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
with open(legacy_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading legacy file {legacy_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_metadata(metadata_path: Path) -> dict[str, Any]:
|
||||||
|
if not metadata_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading metadata file {metadata_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
|
||||||
|
if dry_run:
|
||||||
|
logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
|
||||||
|
return True
|
||||||
|
temp_path = metadata_path.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(temp_path, metadata_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving metadata to {metadata_path}: {e}")
|
||||||
|
if temp_path.exists():
|
||||||
|
try:
|
||||||
|
temp_path.unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_metadata(
|
||||||
|
legacy_data: dict[str, Any],
|
||||||
|
existing_metadata: dict[str, Any],
|
||||||
|
model_type: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
metadata = existing_metadata.copy()
|
||||||
|
changes_made = False
|
||||||
|
if "civitai" not in metadata:
|
||||||
|
metadata["civitai"] = {}
|
||||||
|
activation_text = legacy_data.get("activation text")
|
||||||
|
if activation_text and isinstance(activation_text, str):
|
||||||
|
trigger_words = [
|
||||||
|
word.strip()
|
||||||
|
for word in activation_text.replace("\n", ",").split(",")
|
||||||
|
if word.strip()
|
||||||
|
]
|
||||||
|
if trigger_words:
|
||||||
|
existing_trained = metadata["civitai"].get("trainedWords", [])
|
||||||
|
if not isinstance(existing_trained, list):
|
||||||
|
existing_trained = []
|
||||||
|
merged = list(dict.fromkeys(existing_trained + trigger_words))
|
||||||
|
if merged != existing_trained:
|
||||||
|
metadata["civitai"]["trainedWords"] = merged
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(f" Migrated activation text: {trigger_words}")
|
||||||
|
if model_type == "lora":
|
||||||
|
preferred_weight = legacy_data.get("preferred weight")
|
||||||
|
if preferred_weight is not None:
|
||||||
|
try:
|
||||||
|
weight_value = float(preferred_weight)
|
||||||
|
usage_tips_str = metadata.get("usage_tips", "{}")
|
||||||
|
if isinstance(usage_tips_str, str):
|
||||||
|
try:
|
||||||
|
usage_tips = json.loads(usage_tips_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
usage_tips = {}
|
||||||
|
elif isinstance(usage_tips_str, dict):
|
||||||
|
usage_tips = usage_tips_str
|
||||||
|
else:
|
||||||
|
usage_tips = {}
|
||||||
|
if "strength" not in usage_tips:
|
||||||
|
usage_tips["strength"] = weight_value
|
||||||
|
metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(f" Migrated preferred weight: {weight_value}")
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning(f" Could not parse preferred weight '{preferred_weight}': {e}")
|
||||||
|
else:
|
||||||
|
if legacy_data.get("preferred weight") is not None:
|
||||||
|
logger.debug(" Skipping 'preferred weight' for non-LoRA model")
|
||||||
|
notes = legacy_data.get("notes")
|
||||||
|
if notes and isinstance(notes, str) and notes.strip():
|
||||||
|
existing_notes = metadata.get("notes", "")
|
||||||
|
if not existing_notes:
|
||||||
|
metadata["notes"] = notes.strip()
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(" Migrated notes")
|
||||||
|
elif notes.strip() not in existing_notes:
|
||||||
|
metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(" Appended notes")
|
||||||
|
return metadata if changes_made else None
|
||||||
|
|
||||||
|
|
||||||
|
def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
|
||||||
|
legacy_path = find_legacy_metadata(model_path)
|
||||||
|
if not legacy_path:
|
||||||
|
return True
|
||||||
|
logger.info(f"Processing: {model_path.name} ({model_type})")
|
||||||
|
logger.info(f" Found legacy metadata: {legacy_path.name}")
|
||||||
|
legacy_data = load_legacy_metadata(legacy_path)
|
||||||
|
if legacy_data is None:
|
||||||
|
return False
|
||||||
|
metadata_path = model_path.with_suffix(".metadata.json")
|
||||||
|
existing_metadata = load_metadata(metadata_path)
|
||||||
|
migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
|
||||||
|
if migrated is None:
|
||||||
|
logger.info(" No changes needed (fields already exist or no migratable data)")
|
||||||
|
return True
|
||||||
|
if save_metadata(metadata_path, migrated, dry_run):
|
||||||
|
logger.info(f" ✓ Successfully migrated metadata to: {metadata_path.name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(" ✗ Failed to save metadata")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python scripts/migrate_legacy_metadata.py
|
||||||
|
python scripts/migrate_legacy_metadata.py --dry-run
|
||||||
|
python scripts/migrate_legacy_metadata.py --verbose
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Preview changes without modifying any files"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable verbose output"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
settings_path = resolve_settings_path()
|
||||||
|
logger.info(f"Using settings: {settings_path}")
|
||||||
|
settings = load_json(settings_path)
|
||||||
|
if not settings:
|
||||||
|
logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
|
||||||
|
return 1
|
||||||
|
roots = get_model_roots(settings)
|
||||||
|
if not roots:
|
||||||
|
logger.error("No model folders configured in settings.json.")
|
||||||
|
return 1
|
||||||
|
lora_roots = roots.get("loras", [])
|
||||||
|
checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
|
||||||
|
all_roots = []
|
||||||
|
for root_list in [lora_roots, checkpoint_roots]:
|
||||||
|
for root in root_list:
|
||||||
|
path = Path(root)
|
||||||
|
if path.exists() and path.is_dir():
|
||||||
|
all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
|
||||||
|
if not all_roots:
|
||||||
|
logger.error("No valid model folders found.")
|
||||||
|
return 1
|
||||||
|
logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
|
||||||
|
processed = 0
|
||||||
|
migrated = 0
|
||||||
|
errors = 0
|
||||||
|
skipped = 0
|
||||||
|
lora_count = 0
|
||||||
|
checkpoint_count = 0
|
||||||
|
for root_path, model_type in all_roots:
|
||||||
|
logger.info(f"Scanning: {root_path} ({model_type})")
|
||||||
|
model_files = find_model_files(root_path)
|
||||||
|
logger.debug(f" Found {len(model_files)} model files")
|
||||||
|
for model_path in model_files:
|
||||||
|
legacy_path = find_legacy_metadata(model_path)
|
||||||
|
if not legacy_path:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
processed += 1
|
||||||
|
if process_model(model_path, model_type, dry_run=args.dry_run):
|
||||||
|
migrated += 1
|
||||||
|
if model_type == "lora":
|
||||||
|
lora_count += 1
|
||||||
|
else:
|
||||||
|
checkpoint_count += 1
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
logger.info("\n" + "=" * 50)
|
||||||
|
logger.info("Migration Summary:")
|
||||||
|
logger.info(f" Models with legacy metadata: {processed}")
|
||||||
|
logger.info(f" Successfully migrated: {migrated}")
|
||||||
|
logger.info(f" - LoRA models: {lora_count}")
|
||||||
|
logger.info(f" - Checkpoint models: {checkpoint_count}")
|
||||||
|
logger.info(f" Errors: {errors}")
|
||||||
|
logger.info(f" Skipped (no legacy file): {skipped}")
|
||||||
|
if args.dry_run:
|
||||||
|
logger.info("\n [DRY RUN MODE - No files were modified]")
|
||||||
|
return 0 if errors == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
||||||
content: '\f00c';
|
content: '\f00c';
|
||||||
font-family: 'Font Awesome 6 Free';
|
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--lora-text);
|
color: var(--lora-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||||
max-width: 260px; /* Base size */
|
max-width: 260px; /* Base size */
|
||||||
|
min-width: 200px; /* Prevent cards from becoming too narrow */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -328,7 +329,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-actions i {
|
.card-actions i {
|
||||||
margin-left: var(--space-1);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: white;
|
color: white;
|
||||||
transition: opacity 0.2s, transform 0.15s ease;
|
transition: opacity 0.2s, transform 0.15s ease;
|
||||||
@@ -370,7 +370,16 @@
|
|||||||
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
@media (max-width: 1200px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card {
|
||||||
|
max-width: 240px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
||||||
@@ -378,6 +387,7 @@
|
|||||||
|
|
||||||
.model-card {
|
.model-card {
|
||||||
max-width: 100%; /* Allow cards to fill available space on mobile */
|
max-width: 100%; /* Allow cards to fill available space on mobile */
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,8 +573,13 @@ body.hide-card-version .civitai-version {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
|
margin: 0;
|
||||||
width: 100%; /* Allow width to be set by the VirtualScroller */
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow cards to grow beyond 260px in virtual scroll mode */
|
||||||
|
.virtual-scroll-item.model-card {
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroll-item:hover {
|
.virtual-scroll-item:hover {
|
||||||
@@ -576,11 +591,11 @@ body.hide-card-version .civitai-version {
|
|||||||
.card-grid.virtual-scroll {
|
.card-grid.virtual-scroll {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 0 auto;
|
margin: 0; /* Remove auto margins - positioning handled by VirtualScroller leftOffset */
|
||||||
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1400px; /* Keep the max-width from original grid */
|
max-width: none; /* Remove max-width constraint - handled by VirtualScroller */
|
||||||
box-sizing: border-box; /* Include padding in width calculation */
|
box-sizing: border-box; /* Include padding in width calculation */
|
||||||
overflow-x: hidden; /* Prevent horizontal overflow */
|
overflow-x: hidden; /* Prevent horizontal overflow */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,22 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left section: Logo + Navigation */
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right section: Controls */
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive header container for larger screens */
|
/* Responsive header container for larger screens */
|
||||||
@media (min-width: 2150px) {
|
@media (min-width: 2150px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -77,6 +93,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover,
|
.nav-item:hover,
|
||||||
@@ -97,13 +114,99 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header search */
|
/* Header search - Centered with VS Code command palette style */
|
||||||
.header-search {
|
.header-search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* VS Code command palette style search container */
|
||||||
|
.header-search .search-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg, var(--card-bg));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm, 6px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container:focus-within {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
padding-left: 2.25rem !important;
|
||||||
|
padding-right: 5rem !important;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle,
|
||||||
|
.header-search .search-filter-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle {
|
||||||
|
right: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle:hover,
|
||||||
|
.header-search .search-filter-toggle:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .filter-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--lora-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Disabled state for header search */
|
/* Disabled state for header search */
|
||||||
.header-search.disabled {
|
.header-search.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -247,44 +350,207 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile adjustments */
|
/* Hamburger menu button - hidden by default */
|
||||||
@media (max-width: 768px) {
|
.hamburger-menu-btn {
|
||||||
.app-title {
|
display: none;
|
||||||
display: none;
|
width: 32px;
|
||||||
/* Hide text title on mobile */
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger dropdown menu */
|
||||||
|
.hamburger-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm, 6px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: var(--z-dropdown, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item i {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.header-container {
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls>div {
|
.header-controls > div {
|
||||||
width: 28px;
|
width: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Hide nav icons at 1100px to save space */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.nav-item {
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
.app-title {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.active {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0 0.5rem;
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
margin-right: 0.5rem;
|
gap: 0.25rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For very small screens */
|
/* Responsive: Compact mode at 768px */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-search input {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
padding-left: 2rem !important;
|
||||||
|
padding-right: 4.5rem !important;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container {
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For very small screens - switch nav to icons only */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: none;
|
display: flex;
|
||||||
/* Hide navigation on very small screens */
|
gap: 0.15rem;
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.nav-item {
|
||||||
flex: 1;
|
padding: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Position relative for hamburger menu positioning */
|
||||||
|
.header-right {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|||||||
@@ -374,6 +374,14 @@
|
|||||||
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-action-disabled {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.version-action:disabled {
|
.version-action:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -67,7 +67,6 @@
|
|||||||
|
|
||||||
.early-access-info {
|
.early-access-info {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
@@ -97,7 +96,6 @@
|
|||||||
|
|
||||||
.local-path {
|
.local-path {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|||||||
@@ -271,11 +271,16 @@
|
|||||||
|
|
||||||
/* Enhanced Sidebar Breadcrumb Styles */
|
/* Enhanced Sidebar Breadcrumb Styles */
|
||||||
.sidebar-breadcrumb-container {
|
.sidebar-breadcrumb-container {
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
|
/* Sticky positioning to stick below header when scrolling
|
||||||
|
top: 0 means stick at the top of the scroll container (page-content)
|
||||||
|
which is at header height (48px) from the viewport */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: calc(var(--z-header) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-breadcrumb-nav {
|
.sidebar-breadcrumb-nav {
|
||||||
@@ -284,7 +289,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
padding: 0 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-breadcrumb-item {
|
.sidebar-breadcrumb-item {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
top: -54px;
|
top: -54px;
|
||||||
z-index: calc(var(--z-header) - 1);
|
z-index: calc(var(--z-header) - 1);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
padding: var(--space-2) 0;
|
padding: var(--space-1) 0;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +371,14 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
|
||||||
|
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
|
||||||
|
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
|
||||||
|
the sticky breadcrumb. */
|
||||||
|
.controls:has(.dropdown-group.active) {
|
||||||
|
z-index: var(--z-header);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 6px 15px;
|
padding: 6px 15px;
|
||||||
@@ -397,6 +405,33 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce button sizes to fit better */
|
||||||
|
.control-group button {
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.actions {
|
.actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -129,6 +129,126 @@ export class HeaderManager {
|
|||||||
|
|
||||||
// Hide search functionality on Statistics page
|
// Hide search functionality on Statistics page
|
||||||
this.updateHeaderForPage();
|
this.updateHeaderForPage();
|
||||||
|
|
||||||
|
// Initialize hamburger menu for mobile
|
||||||
|
this.initializeHamburgerMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeHamburgerMenu() {
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
||||||
|
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
|
||||||
|
|
||||||
|
if (!hamburgerBtn || !hamburgerDropdown) return;
|
||||||
|
|
||||||
|
// Toggle dropdown on hamburger button click
|
||||||
|
hamburgerBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
hamburgerDropdown.classList.toggle('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
if (hamburgerDropdown.classList.contains('active')) {
|
||||||
|
icon.classList.remove('fa-bars');
|
||||||
|
icon.classList.add('fa-times');
|
||||||
|
} else {
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle dropdown item clicks
|
||||||
|
const dropdownItems = hamburgerDropdown.querySelectorAll('.dropdown-item');
|
||||||
|
dropdownItems.forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
const action = item.dataset.action;
|
||||||
|
this.handleHamburgerAction(action);
|
||||||
|
hamburgerDropdown.classList.remove('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!hamburgerDropdown.contains(e.target) && !hamburgerBtn.contains(e.target)) {
|
||||||
|
hamburgerDropdown.classList.remove('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme icon in hamburger menu based on current theme
|
||||||
|
this.updateHamburgerThemeIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHamburgerAction(action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'theme':
|
||||||
|
if (typeof toggleTheme === 'function') {
|
||||||
|
const newTheme = toggleTheme();
|
||||||
|
// Update theme toggle in header if it exists
|
||||||
|
const themeToggle = document.querySelector('.theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||||||
|
themeToggle.classList.add(`theme-${newTheme}`);
|
||||||
|
this.updateThemeTooltip(themeToggle, newTheme);
|
||||||
|
}
|
||||||
|
this.updateHamburgerThemeIcon();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
if (window.settingsManager) {
|
||||||
|
window.settingsManager.toggleSettings();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
const helpToggle = document.getElementById('helpToggleBtn');
|
||||||
|
if (helpToggle) {
|
||||||
|
helpToggle.click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'notifications':
|
||||||
|
updateService.toggleUpdateModal();
|
||||||
|
break;
|
||||||
|
case 'support':
|
||||||
|
if (window.modalManager) {
|
||||||
|
window.modalManager.toggleModal('supportModal');
|
||||||
|
renderSupporters().catch(error => {
|
||||||
|
console.error('Error loading supporters:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHamburgerThemeIcon() {
|
||||||
|
const themeItem = document.querySelector('.dropdown-item[data-action="theme"]');
|
||||||
|
if (!themeItem) return;
|
||||||
|
|
||||||
|
const currentTheme = getStorageItem('theme') || 'auto';
|
||||||
|
const icon = themeItem.querySelector('i');
|
||||||
|
const text = themeItem.querySelector('span');
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('fa-moon', 'fa-sun', 'fa-adjust');
|
||||||
|
if (currentTheme === 'light') {
|
||||||
|
icon.classList.add('fa-sun');
|
||||||
|
} else if (currentTheme === 'dark') {
|
||||||
|
icon.classList.add('fa-moon');
|
||||||
|
} else {
|
||||||
|
icon.classList.add('fa-adjust');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update text based on current theme
|
||||||
|
if (text) {
|
||||||
|
const key = currentTheme === 'light' ? 'header.theme.switchToDark' :
|
||||||
|
currentTheme === 'dark' ? 'header.theme.switchToLight' :
|
||||||
|
'header.theme.toggle';
|
||||||
|
updateElementAttribute(themeItem, 'aria-label', key, {}, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHeaderForPage() {
|
updateHeaderForPage() {
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ function isEarlyAccessActive(version) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDownloadAllowed(version) {
|
||||||
|
if (!version.usageControl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return version.usageControl === 'Download';
|
||||||
|
}
|
||||||
|
|
||||||
function buildMetaMarkup(version, options = {}) {
|
function buildMetaMarkup(version, options = {}) {
|
||||||
const segments = [];
|
const segments = [];
|
||||||
if (version.baseModel) {
|
if (version.baseModel) {
|
||||||
@@ -230,12 +237,17 @@ function buildBadge(label, tone, options = {}) {
|
|||||||
function buildActionButton(label, variant, action, options = {}) {
|
function buildActionButton(label, variant, action, options = {}) {
|
||||||
const attributes = [
|
const attributes = [
|
||||||
`class="version-action ${variant}"`,
|
`class="version-action ${variant}"`,
|
||||||
`data-version-action="${escapeHtml(action)}"`,
|
|
||||||
];
|
];
|
||||||
|
if (action) {
|
||||||
|
attributes.push(`data-version-action="${escapeHtml(action)}"`);
|
||||||
|
}
|
||||||
if (options.title) {
|
if (options.title) {
|
||||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||||
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||||
}
|
}
|
||||||
|
if (options.disabled) {
|
||||||
|
attributes.push('disabled');
|
||||||
|
}
|
||||||
if (options.extraAttributes) {
|
if (options.extraAttributes) {
|
||||||
attributes.push(options.extraAttributes);
|
attributes.push(options.extraAttributes);
|
||||||
}
|
}
|
||||||
@@ -371,6 +383,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
|||||||
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!isDownloadAllowed(version)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const versionBase = normalizeBaseModelName(version.baseModel);
|
const versionBase = normalizeBaseModelName(version.baseModel);
|
||||||
if (versionBase !== normalizedBase) {
|
if (versionBase !== normalizedBase) {
|
||||||
return false;
|
return false;
|
||||||
@@ -502,6 +517,17 @@ function renderRow(version, options) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isDownloadAllowed(version)) {
|
||||||
|
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
|
||||||
|
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.badges.onSiteOnlyTooltip',
|
||||||
|
{},
|
||||||
|
'This version is only available for on-site generation on Civitai'
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (version.shouldIgnore) {
|
if (version.shouldIgnore) {
|
||||||
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
||||||
title: translate(
|
title: translate(
|
||||||
@@ -524,25 +550,36 @@ function renderRow(version, options) {
|
|||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
if (!version.isInLibrary) {
|
if (!version.isInLibrary) {
|
||||||
// Download button with optional EA bolt icon
|
const canDownload = isDownloadAllowed(version);
|
||||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||||
|
let downloadTitle;
|
||||||
|
if (!canDownload) {
|
||||||
|
downloadTitle = translate(
|
||||||
|
'modals.model.versions.actions.downloadNotAllowedTooltip',
|
||||||
|
{},
|
||||||
|
'This version is only available for on-site generation on Civitai'
|
||||||
|
);
|
||||||
|
} else if (isEarlyAccess) {
|
||||||
|
downloadTitle = translate(
|
||||||
|
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||||
|
{},
|
||||||
|
'Download this early access version from Civitai'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
downloadTitle = translate(
|
||||||
|
'modals.model.versions.actions.downloadTooltip',
|
||||||
|
{},
|
||||||
|
'Download this version'
|
||||||
|
);
|
||||||
|
}
|
||||||
actions.push(buildActionButton(
|
actions.push(buildActionButton(
|
||||||
downloadLabel,
|
downloadLabel,
|
||||||
'version-action-primary',
|
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
||||||
'download',
|
canDownload ? 'download' : '',
|
||||||
{
|
{
|
||||||
title: isEarlyAccess
|
title: downloadTitle,
|
||||||
? translate(
|
|
||||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
|
||||||
{},
|
|
||||||
'Download this early access version from Civitai'
|
|
||||||
)
|
|
||||||
: translate(
|
|
||||||
'modals.model.versions.actions.downloadTooltip',
|
|
||||||
{},
|
|
||||||
'Download this version'
|
|
||||||
),
|
|
||||||
iconMarkup: downloadIcon,
|
iconMarkup: downloadIcon,
|
||||||
|
disabled: !canDownload,
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
} else if (version.filePath) {
|
} else if (version.filePath) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { escapeHtml } from '../components/shared/utils.js';
|
import { escapeHtml } from '../components/shared/utils.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
const MAX_CONSOLE_ENTRIES = 200;
|
const MAX_CONSOLE_ENTRIES = 200;
|
||||||
|
|
||||||
@@ -258,6 +259,15 @@ export class DoctorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderInlineDetail(detail) {
|
renderInlineDetail(detail) {
|
||||||
|
if (detail.conflict_groups || detail.total_conflict_files) {
|
||||||
|
return `
|
||||||
|
<div class="doctor-inline-detail">
|
||||||
|
<strong>${escapeHtml(translate('doctor.status.warning', {}, 'Conflicts'))}</strong>
|
||||||
|
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
if (detail.client_version || detail.server_version) {
|
if (detail.client_version || detail.server_version) {
|
||||||
return `
|
return `
|
||||||
<div class="doctor-inline-detail">
|
<div class="doctor-inline-detail">
|
||||||
@@ -317,6 +327,9 @@ export class DoctorManager {
|
|||||||
case 'repair-cache':
|
case 'repair-cache':
|
||||||
await this.repairCache();
|
await this.repairCache();
|
||||||
break;
|
break;
|
||||||
|
case 'resolve-filename-conflicts':
|
||||||
|
await this.resolveFilenameConflicts();
|
||||||
|
break;
|
||||||
case 'reload-page':
|
case 'reload-page':
|
||||||
this.reloadUi();
|
this.reloadUi();
|
||||||
break;
|
break;
|
||||||
@@ -345,6 +358,47 @@ export class DoctorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveFilenameConflicts() {
|
||||||
|
try {
|
||||||
|
this.setLoading(true);
|
||||||
|
const response = await fetch('/api/lm/doctor/resolve-filename-conflicts', { method: 'POST' });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
throw new Error(payload.error || 'Failed to resolve filename conflicts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const renamedCount = payload.count || 0;
|
||||||
|
showToast(
|
||||||
|
'doctor.toast.conflictsResolved',
|
||||||
|
{ count: renamedCount },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update scroller items so model cards reflect new filenames immediately
|
||||||
|
if (state.virtualScroller && payload.renamed) {
|
||||||
|
for (const renamed of payload.renamed) {
|
||||||
|
const baseName = renamed.new_filename.replace(/\.[^.]+$/, '');
|
||||||
|
state.virtualScroller.updateSingleItem(renamed.old_path, {
|
||||||
|
file_name: baseName,
|
||||||
|
file_path: renamed.new_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshDiagnostics({ silent: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Doctor filename conflict resolution failed:', error);
|
||||||
|
showToast(
|
||||||
|
'doctor.toast.conflictsResolveFailed',
|
||||||
|
{ message: error.message },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exportBundle() {
|
async exportBundle() {
|
||||||
try {
|
try {
|
||||||
this.setLoading(true);
|
this.setLoading(true);
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
@@ -682,7 +682,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class SearchManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes(true); // true to reset pagination
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
|
|||||||
@@ -2863,7 +2863,7 @@ export class SettingsManager {
|
|||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} else if (this.currentPage === 'recipes') {
|
} else if (this.currentPage === 'recipes') {
|
||||||
// Reload the recipes without updating folders
|
// Reload the recipes without updating folders
|
||||||
await window.recipeManager.loadRecipes();
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class RecipePageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
refreshVirtualScroll();
|
await refreshVirtualScroll({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
|
|||||||
@@ -104,69 +104,74 @@ export class VirtualScroller {
|
|||||||
// Get display density setting
|
// Get display density setting
|
||||||
const displayDensity = state.global.settings?.display_density || 'default';
|
const displayDensity = state.global.settings?.display_density || 'default';
|
||||||
|
|
||||||
// Set exact column counts and grid widths to match CSS container widths
|
// Base gap between cards
|
||||||
let maxColumns, maxGridWidth;
|
const baseGap = 12;
|
||||||
|
this.columnGap = baseGap;
|
||||||
|
|
||||||
// Match exact column counts and CSS container width values based on density
|
// Define minimum card width based on density setting to ensure usability
|
||||||
|
// Cards smaller than this become hard to interact with and view
|
||||||
|
const minCardWidths = {
|
||||||
|
'default': 240, // Default: comfortable minimum
|
||||||
|
'medium': 200, // Medium: slightly smaller
|
||||||
|
'compact': 170 // Compact: smallest usable size
|
||||||
|
};
|
||||||
|
const minCardWidth = minCardWidths[displayDensity] || 240;
|
||||||
|
|
||||||
|
// Calculate maximum possible columns that fit in available width
|
||||||
|
// Formula: maxColumns = floor((availableWidth + gap) / (minCardWidth + gap))
|
||||||
|
const maxPossibleColumns = Math.floor((availableContentWidth + this.columnGap) / (minCardWidth + this.columnGap));
|
||||||
|
|
||||||
|
// Ensure at least 1 column
|
||||||
|
const maxColumns = Math.max(1, maxPossibleColumns);
|
||||||
|
|
||||||
|
// Define preferred maximum columns based on display density and screen size
|
||||||
|
// These are upper limits to prevent too many columns on ultra-wide screens
|
||||||
|
let preferredMaxColumns;
|
||||||
if (window.innerWidth >= 3000) { // 4K
|
if (window.innerWidth >= 3000) { // 4K
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 8;
|
preferredMaxColumns = 8;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 9;
|
preferredMaxColumns = 10;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 10;
|
preferredMaxColumns = 12;
|
||||||
}
|
}
|
||||||
maxGridWidth = 2400; // Match exact CSS container width for 4K
|
|
||||||
} else if (window.innerWidth >= 2150) { // 2K/1440p
|
} else if (window.innerWidth >= 2150) { // 2K/1440p
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 6;
|
preferredMaxColumns = 6;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 7;
|
preferredMaxColumns = 8;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 8;
|
preferredMaxColumns = 10;
|
||||||
}
|
}
|
||||||
maxGridWidth = 1800; // Match exact CSS container width for 2K
|
} else { // 1080p and smaller
|
||||||
} else {
|
|
||||||
// 1080p
|
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 5;
|
preferredMaxColumns = 5;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 6;
|
preferredMaxColumns = 6;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 7;
|
preferredMaxColumns = 8;
|
||||||
}
|
}
|
||||||
maxGridWidth = 1400; // Match exact CSS container width for 1080p
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate baseCardWidth based on desired column count and available space
|
// Use the smaller of: max columns that fit, or preferred max
|
||||||
// Formula: (maxGridWidth - (columns-1)*gap) / columns
|
// This ensures cards are never smaller than minCardWidth
|
||||||
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
|
this.columnsCount = Math.min(maxColumns, preferredMaxColumns);
|
||||||
|
|
||||||
// Use the smaller of available content width or max grid width
|
// Calculate card width to perfectly fill available space
|
||||||
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
// Formula: (availableWidth - totalGap) / columns
|
||||||
|
const totalGap = (this.columnsCount - 1) * this.columnGap;
|
||||||
|
this.itemWidth = (availableContentWidth - totalGap) / this.columnsCount;
|
||||||
|
|
||||||
// Set exact column count based on screen size and mode
|
// Calculate height based on aspect ratio (896/1152)
|
||||||
this.columnsCount = maxColumns;
|
|
||||||
|
|
||||||
// When available width is smaller than maxGridWidth, recalculate columns
|
|
||||||
if (availableContentWidth < maxGridWidth) {
|
|
||||||
// Calculate how many columns can fit in the available space
|
|
||||||
this.columnsCount = Math.max(1, Math.floor(
|
|
||||||
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate actual item width
|
|
||||||
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
|
|
||||||
|
|
||||||
// Calculate height based on aspect ratio
|
|
||||||
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
||||||
|
|
||||||
// Calculate the left offset to center the grid within the content area
|
// Edge-to-edge layout: no offset, grid fills container
|
||||||
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
|
this.leftOffset = 0;
|
||||||
|
const actualGridWidth = this.itemWidth * this.columnsCount + totalGap;
|
||||||
|
|
||||||
// Update grid element max-width to match available width
|
// Update grid element to fill available width
|
||||||
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
||||||
|
this.gridElement.style.width = `${actualGridWidth}px`;
|
||||||
|
|
||||||
// Add or remove density classes for style adjustments
|
// Add or remove density classes for style adjustments
|
||||||
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
||||||
@@ -478,6 +483,12 @@ export class VirtualScroller {
|
|||||||
element.style.width = `${this.itemWidth}px`;
|
element.style.width = `${this.itemWidth}px`;
|
||||||
element.style.height = `${this.itemHeight}px`;
|
element.style.height = `${this.itemHeight}px`;
|
||||||
|
|
||||||
|
// Remove max-width constraint from model-card to allow dynamic sizing
|
||||||
|
const modelCard = element.querySelector('.model-card');
|
||||||
|
if (modelCard) {
|
||||||
|
modelCard.style.maxWidth = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
5
templates/components/breadcrumb.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
||||||
|
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
||||||
|
<!-- Breadcrumbs will be populated by JavaScript -->
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
@@ -129,11 +129,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Breadcrumb Navigation -->
|
|
||||||
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
|
||||||
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
|
||||||
<!-- Breadcrumbs will be populated by JavaScript -->
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,50 +1,53 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
<div class="header-branding">
|
<!-- Left section: Logo + Navigation -->
|
||||||
<a href="/loras" class="logo-link">
|
<div class="header-left">
|
||||||
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
<div class="header-branding">
|
||||||
<span class="app-title">{{ t('header.appTitle') }}</span>
|
<a href="/loras" class="logo-link">
|
||||||
</a>
|
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||||
|
<span class="app-title">{{ t('header.appTitle') }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% set current_path = request.path %}
|
||||||
|
{% if current_path.startswith('/loras/recipes') %}
|
||||||
|
{% set current_page = 'recipes' %}
|
||||||
|
{% elif current_path.startswith('/checkpoints') %}
|
||||||
|
{% set current_page = 'checkpoints' %}
|
||||||
|
{% elif current_path.startswith('/embeddings') %}
|
||||||
|
{% set current_page = 'embeddings' %}
|
||||||
|
{% elif current_path.startswith('/statistics') %}
|
||||||
|
{% set current_page = 'statistics' %}
|
||||||
|
{% else %}
|
||||||
|
{% set current_page = 'loras' %}
|
||||||
|
{% endif %}
|
||||||
|
<nav class="main-nav">
|
||||||
|
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||||
|
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
||||||
|
id="recipesNavItem">
|
||||||
|
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
||||||
|
id="checkpointsNavItem">
|
||||||
|
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
||||||
|
id="embeddingsNavItem">
|
||||||
|
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||||
|
id="statisticsNavItem">
|
||||||
|
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{% set current_path = request.path %}
|
|
||||||
{% if current_path.startswith('/loras/recipes') %}
|
<!-- Center section: Search -->
|
||||||
{% set current_page = 'recipes' %}
|
|
||||||
{% elif current_path.startswith('/checkpoints') %}
|
|
||||||
{% set current_page = 'checkpoints' %}
|
|
||||||
{% elif current_path.startswith('/embeddings') %}
|
|
||||||
{% set current_page = 'embeddings' %}
|
|
||||||
{% elif current_path.startswith('/statistics') %}
|
|
||||||
{% set current_page = 'statistics' %}
|
|
||||||
{% else %}
|
|
||||||
{% set current_page = 'loras' %}
|
|
||||||
{% endif %}
|
|
||||||
{% set search_disabled = current_page == 'statistics' %}
|
{% set search_disabled = current_page == 'statistics' %}
|
||||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
||||||
current_page %}
|
current_page %}
|
||||||
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
||||||
<nav class="main-nav">
|
|
||||||
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
|
||||||
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
|
||||||
</a>
|
|
||||||
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
|
||||||
id="recipesNavItem">
|
|
||||||
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
|
||||||
</a>
|
|
||||||
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
|
||||||
id="checkpointsNavItem">
|
|
||||||
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
|
||||||
</a>
|
|
||||||
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
|
||||||
id="embeddingsNavItem">
|
|
||||||
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
|
||||||
</a>
|
|
||||||
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
|
||||||
id="statisticsNavItem">
|
|
||||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Context-aware search container -->
|
|
||||||
<div class="{{ header_search_class }}" id="headerSearch">
|
<div class="{{ header_search_class }}" id="headerSearch">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||||
@@ -62,9 +65,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<!-- Right section: Controls -->
|
||||||
<!-- Integrated corner controls -->
|
<div class="header-right">
|
||||||
<div class="header-controls">
|
<div class="header-controls" id="headerControls">
|
||||||
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
|
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
|
||||||
<i class="fas fa-moon dark-icon"></i>
|
<i class="fas fa-moon dark-icon"></i>
|
||||||
<i class="fas fa-sun light-icon"></i>
|
<i class="fas fa-sun light-icon"></i>
|
||||||
@@ -85,6 +88,34 @@
|
|||||||
<i class="fas fa-heart"></i>
|
<i class="fas fa-heart"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Hamburger menu button (visible on mobile) -->
|
||||||
|
<button class="hamburger-menu-btn" id="hamburgerMenuBtn" title="{{ t('common.actions.menu') }}">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Hamburger dropdown menu -->
|
||||||
|
<div class="hamburger-dropdown" id="hamburgerDropdown">
|
||||||
|
<div class="dropdown-item theme-toggle-item" data-action="theme">
|
||||||
|
<i class="fas fa-moon"></i>
|
||||||
|
<span>{{ t('header.theme.toggle') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="settings">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
<span>{{ t('common.actions.settings') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="help">
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
<span>{{ t('common.actions.help') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="notifications">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>{{ t('header.actions.notifications') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<div class="dropdown-item" data-action="support">
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
<span>{{ t('header.actions.support') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
from py.routes.handlers.misc_handlers import (
|
from py.routes.handlers.misc_handlers import (
|
||||||
BackupHandler,
|
BackupHandler,
|
||||||
DoctorHandler,
|
DoctorHandler,
|
||||||
@@ -78,10 +79,11 @@ async def dummy_downloader_factory():
|
|||||||
|
|
||||||
|
|
||||||
class DummyDoctorScanner:
|
class DummyDoctorScanner:
|
||||||
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None):
|
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None, hash_index=None):
|
||||||
self.model_type = model_type
|
self.model_type = model_type
|
||||||
self._raw_data = list(raw_data or [])
|
self._raw_data = list(raw_data or [])
|
||||||
self._rebuild_error = rebuild_error
|
self._rebuild_error = rebuild_error
|
||||||
|
self._hash_index = hash_index
|
||||||
self._persistent_cache = SimpleNamespace(
|
self._persistent_cache = SimpleNamespace(
|
||||||
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
|
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
|
||||||
)
|
)
|
||||||
@@ -91,6 +93,16 @@ class DummyDoctorScanner:
|
|||||||
raise self._rebuild_error
|
raise self._rebuild_error
|
||||||
return SimpleNamespace(raw_data=list(self._raw_data))
|
return SimpleNamespace(raw_data=list(self._raw_data))
|
||||||
|
|
||||||
|
async def update_single_model_cache(self, original_path, new_path, metadata):
|
||||||
|
for item in self._raw_data:
|
||||||
|
if item.get("file_path") == original_path:
|
||||||
|
item["file_path"] = new_path
|
||||||
|
item["file_name"] = metadata.get("file_name", item.get("file_name", ""))
|
||||||
|
if metadata.get("preview_url"):
|
||||||
|
item["preview_url"] = metadata["preview_url"]
|
||||||
|
break
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class DummyCivitaiClient:
|
class DummyCivitaiClient:
|
||||||
def __init__(self, *, success=True, result=None):
|
def __init__(self, *, success=True, result=None):
|
||||||
@@ -1582,3 +1594,107 @@ def test_wsl_to_windows_path_returns_none_on_subprocess_error(tmp_path):
|
|||||||
):
|
):
|
||||||
result = _wsl_to_windows_path("/mnt/c/test")
|
result = _wsl_to_windows_path("/mnt/c/test")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── DoctorHandler filename conflict tests ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_filename_conflicts_returns_ok_when_no_duplicates():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora", raw_data=[], hash_index=hash_index
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
|
||||||
|
assert diagnostic_map["filename_conflicts"]["status"] == "ok"
|
||||||
|
assert "No duplicate filenames" in diagnostic_map["filename_conflicts"]["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_filename_conflicts_detects_duplicates():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
hash_index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
hash_index.add_entry("def456", "/b/lora.safetensors")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora",
|
||||||
|
raw_data=[
|
||||||
|
{"file_path": "/a/lora.safetensors"},
|
||||||
|
{"file_path": "/b/lora.safetensors"},
|
||||||
|
],
|
||||||
|
hash_index=hash_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
|
||||||
|
conflict_diag = diagnostic_map["filename_conflicts"]
|
||||||
|
assert conflict_diag["status"] == "warning"
|
||||||
|
assert "1 filename(s)" in conflict_diag["summary"]
|
||||||
|
assert any("resolve-filename-conflicts" in str(a) for a in conflict_diag.get("actions", []))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_filename_conflicts_returns_renamed_list():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
hash_index.add_entry("abc123", "lora.safetensors")
|
||||||
|
hash_index.add_entry("def456", "lora.safetensors")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora",
|
||||||
|
raw_data=[],
|
||||||
|
hash_index=hash_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
# Files don't exist on disk, so nothing gets renamed
|
||||||
|
assert payload["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_filename_conflicts_handles_scanner_error_gracefully():
|
||||||
|
class ErrorScanner:
|
||||||
|
model_type = "lora"
|
||||||
|
|
||||||
|
async def get_cached_data(self):
|
||||||
|
raise RuntimeError("scanner unavailable")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return ErrorScanner()
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["count"] == 0
|
||||||
|
|||||||
113
tests/services/test_model_hash_index.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import pytest
|
||||||
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelHashIndexRemoveByPath:
|
||||||
|
def test_remove_by_path_finds_hash_in_hash_to_path(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
index.remove_by_path("/models/lora.safetensors")
|
||||||
|
assert len(index) == 0
|
||||||
|
assert not index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
def test_remove_by_path_falls_back_to_duplicate_hashes(self):
|
||||||
|
"""When a path is only tracked in _duplicate_hashes, remove_by_path
|
||||||
|
should still find and remove it."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora_v1.safetensors")
|
||||||
|
index.add_entry("abc123", "/models/lora_v2.safetensors")
|
||||||
|
|
||||||
|
# lora_v1 is the primary (_hash_to_path), lora_v2 is in _duplicate_hashes
|
||||||
|
index.remove_by_path("/models/lora_v2.safetensors")
|
||||||
|
|
||||||
|
assert len(index) == 1
|
||||||
|
assert index._hash_to_path.get("abc123") == "/models/lora_v1.safetensors"
|
||||||
|
assert "abc123" not in index._duplicate_hashes
|
||||||
|
|
||||||
|
def test_remove_by_path_cleans_up_duplicate_filenames(self):
|
||||||
|
"""After removing a path, _duplicate_filenames should be updated."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/other/mylora.safetensors")
|
||||||
|
|
||||||
|
assert "mylora" in index.get_duplicate_filenames()
|
||||||
|
assert len(index.get_duplicate_filenames()["mylora"]) == 2
|
||||||
|
|
||||||
|
index.remove_by_path("/other/mylora.safetensors")
|
||||||
|
|
||||||
|
# After removing one duplicate, only one path remains — no longer a duplicate
|
||||||
|
assert "mylora" not in index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
def test_remove_by_path_keeps_duplicate_filenames_with_three_entries(self):
|
||||||
|
"""With 3 entries for the same filename, removing one should leave 2."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/other/mylora.safetensors")
|
||||||
|
index.add_entry("ghi789", "/third/mylora.safetensors")
|
||||||
|
|
||||||
|
index.remove_by_path("/other/mylora.safetensors")
|
||||||
|
|
||||||
|
assert "mylora" in index.get_duplicate_filenames()
|
||||||
|
paths = index.get_duplicate_filenames()["mylora"]
|
||||||
|
assert len(paths) == 2
|
||||||
|
assert "/other/mylora.safetensors" not in paths
|
||||||
|
|
||||||
|
def test_remove_by_path_noop_on_unknown_path(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
# Should not raise
|
||||||
|
index.remove_by_path("/nonexistent/lora.safetensors")
|
||||||
|
assert len(index) == 1
|
||||||
|
|
||||||
|
def test_remove_by_path_handles_hash_from_duplicate_hashes_only(self):
|
||||||
|
"""Remove a path whose hash exists ONLY in _duplicate_hashes,
|
||||||
|
not in _hash_to_path (edge case from index rebuilds)."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/model.safetensors")
|
||||||
|
index.add_entry("abc123", "/b/model.safetensors")
|
||||||
|
|
||||||
|
# Manually remove the primary entry to simulate edge case
|
||||||
|
del index._hash_to_path["abc123"]
|
||||||
|
# Now the path is only referenced in _duplicate_hashes
|
||||||
|
assert "abc123" in index._duplicate_hashes
|
||||||
|
|
||||||
|
index.remove_by_path("/b/model.safetensors")
|
||||||
|
# The remaining path is promoted to _hash_to_path, duplicates cleared
|
||||||
|
assert "abc123" not in index._duplicate_hashes
|
||||||
|
assert index._hash_to_path.get("abc123") == "/a/model.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelHashIndexGetDuplicateFilenames:
|
||||||
|
def test_empty_index_returns_empty_dict(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
|
|
||||||
|
def test_no_duplicates_returns_empty_dict(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
index.add_entry("def456", "/models/other.safetensors")
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
|
|
||||||
|
def test_duplicate_filenames_detected(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/b/mylora.safetensors")
|
||||||
|
dupes = index.get_duplicate_filenames()
|
||||||
|
assert "mylora" in dupes
|
||||||
|
assert len(dupes["mylora"]) == 2
|
||||||
|
|
||||||
|
def test_same_hash_same_name_not_a_filename_duplicate(self):
|
||||||
|
"""Same hash with same filename = hash duplicate, not filename conflict."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
# Same hash, same filename — this is a true duplicate (hash collision)
|
||||||
|
# but the filename index only tracks different files with same name
|
||||||
|
# Currently add_entry for same hash+path would update, not create duplicate
|
||||||
|
# This is correct behavior — filename dupes are for different files
|
||||||
|
|
||||||
|
def test_add_entry_idempotent_for_same_path_and_hash(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
assert len(index) == 1
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
@@ -624,3 +624,42 @@ async def test_reconcile_cache_removes_duplicate_alias_when_same_real_file_seen_
|
|||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
cached_paths = {item["file_path"] for item in cache.raw_data}
|
cached_paths = {item["file_path"] for item in cache.raw_data}
|
||||||
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
|
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplog):
|
||||||
|
"""When duplicate filenames exist, _log_duplicate_filename_summary should emit
|
||||||
|
a single warning log with the conflict count and total file count."""
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
scanner = DummyScanner(root)
|
||||||
|
|
||||||
|
# Simulate duplicate filenames in the hash index
|
||||||
|
scanner._hash_index.add_entry("aaa111", str(root / "model.safetensors"))
|
||||||
|
scanner._hash_index.add_entry("bbb222", str(root / "dir" / "model.safetensors"))
|
||||||
|
|
||||||
|
scanner._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
assert len(caplog.records) >= 1
|
||||||
|
log_msg = caplog.records[-1].message
|
||||||
|
assert "Duplicate filename conflict detected" in log_msg
|
||||||
|
assert "1 dummy filename(s)" in log_msg
|
||||||
|
assert "2 files total" in log_msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path: Path, caplog):
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
scanner = DummyScanner(root)
|
||||||
|
scanner._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
# No warning should be logged when there are no duplicates
|
||||||
|
for record in caplog.records:
|
||||||
|
assert "Duplicate filename conflict detected" not in record.message
|
||||||
|
|||||||
@@ -232,9 +232,13 @@ export function initDrag(
|
|||||||
onDragEnd();
|
onDragEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now do the re-render after drag is complete
|
// Commit final value through options.setValue so external observers are notified.
|
||||||
if (renderFunction) {
|
// During drag, handleStrengthDrag mutates widgetValue in-place (updateWidget=false),
|
||||||
renderFunction(widget.value, widget);
|
// bypassing widget.value setter and options.setValue entirely. This assignment
|
||||||
|
// flushes the in-place mutation through the setter so any setValue wrappers fire.
|
||||||
|
widget.value = widget.value;
|
||||||
|
if (typeof widget.callback === 'function') {
|
||||||
|
widget.callback(widget.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -349,11 +353,15 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
|
|||||||
document.body.classList.remove('lm-lora-strength-dragging');
|
document.body.classList.remove('lm-lora-strength-dragging');
|
||||||
|
|
||||||
// Only re-render if we actually dragged
|
// Only re-render if we actually dragged
|
||||||
if (wasDragging && renderFunction) {
|
if (wasDragging) {
|
||||||
renderFunction(widget.value, widget);
|
// Commit final value through options.setValue so external observers are notified.
|
||||||
|
widget.value = widget.value;
|
||||||
|
if (typeof widget.callback === 'function') {
|
||||||
|
widget.callback(widget.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle pointer up to end dragging
|
// Handle pointer up to end dragging
|
||||||
headerEl.addEventListener('pointerup', endDrag);
|
headerEl.addEventListener('pointerup', endDrag);
|
||||||
|
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ app.registerExtension({
|
|||||||
const savedItem = consumeQueuedState(itemState, itemText);
|
const savedItem = consumeQueuedState(itemState, itemText);
|
||||||
return {
|
return {
|
||||||
text: itemText,
|
text: itemText,
|
||||||
active: savedItem ? savedItem.active : defaultActive,
|
active: savedItem ? savedItem.active : true,
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
strength: null,
|
strength: null,
|
||||||
};
|
};
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 872 KiB |
|
Before Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 639 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 529 KiB |