Compare commits
22 Commits
25605c5e78
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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",
|
||||
"W+K+White",
|
||||
"wackop",
|
||||
"Takkan",
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"Arlecchino Shion",
|
||||
"stone9k",
|
||||
"$MetaSamsara",
|
||||
"itismyelement",
|
||||
"Gingko Biloba",
|
||||
"onesecondinosaur",
|
||||
"stone9k",
|
||||
"Takkan",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Gooohokrbe",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"OldBones",
|
||||
"VantAI",
|
||||
"runte3221",
|
||||
"Illrigger",
|
||||
"FreelancerZ",
|
||||
"Edgar Tejeda",
|
||||
"Jorge Hussni",
|
||||
"Liam MacDougal",
|
||||
"Fraser Cross",
|
||||
"Polymorphic Indeterminate",
|
||||
"Birdy",
|
||||
"Marc Whiffen",
|
||||
"Jorge Hussni",
|
||||
"Kiba",
|
||||
"Birdy",
|
||||
"Skalabananen",
|
||||
"Kiba",
|
||||
"Reno Lam",
|
||||
"Mozzel",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
@@ -46,39 +52,41 @@
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"Arlecchino Shion",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"ClockDaemon",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Nazono_hito",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"Tobi_Swagg",
|
||||
"contrite831",
|
||||
"SG",
|
||||
"carozzz",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"Cosmosis",
|
||||
"Anthony Rizzo",
|
||||
"iamresist",
|
||||
"Gooohokrbe",
|
||||
"RedrockVP",
|
||||
"Wolffen",
|
||||
"FloPro4Sho",
|
||||
"James Todd",
|
||||
"OldBones",
|
||||
"Steven Pfeiffer",
|
||||
"Tim",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"whudunit",
|
||||
"Tom Corrigan",
|
||||
"dl0901dm",
|
||||
"JackieWang",
|
||||
"fnkylove",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
@@ -86,147 +94,137 @@
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Sterilized",
|
||||
"Briton Heilbrun",
|
||||
"Aleksander Wujczyk",
|
||||
"BadassArabianMofo",
|
||||
"Sterilized",
|
||||
"Pascal Dahle",
|
||||
"quarz",
|
||||
"Greg",
|
||||
"Penfore",
|
||||
"Greg",
|
||||
"JSST",
|
||||
"esthe",
|
||||
"lmsupporter",
|
||||
"IamAyam",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"Jack B Nimble",
|
||||
"Nazono_hito",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"JW Sin",
|
||||
"contrite831",
|
||||
"Alex",
|
||||
"bh",
|
||||
"confiscated Zyra",
|
||||
"Marlon Daniels",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Adam Shaw",
|
||||
"Tee Gee",
|
||||
"Anthony Rizzo",
|
||||
"tarek helmi",
|
||||
"Cosmosis",
|
||||
"M Postkasse",
|
||||
"FloPro4Sho",
|
||||
"ASLPro3D",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Zach Gonser",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Jimmy Ledbetter",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Nick Walker",
|
||||
"lh qwe",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"Bishoujoker",
|
||||
"conner",
|
||||
"aai",
|
||||
"Briton Heilbrun",
|
||||
"Tori",
|
||||
"wildnut",
|
||||
"Princess Bright Eyes",
|
||||
"AbstractAss",
|
||||
"Felipe dos Santos",
|
||||
"ViperC",
|
||||
"jean jahren",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"Markus",
|
||||
"S Sang",
|
||||
"ViperC",
|
||||
"Ran C",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Karl P.",
|
||||
"Akira_HentAI",
|
||||
"MagnaInsomnia",
|
||||
"Gordon Cole",
|
||||
"yuxz69",
|
||||
"Douglas Gaspar",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"esthe",
|
||||
"andrew.tappan",
|
||||
"dw",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"Phil",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"fancypants",
|
||||
"IamAyam",
|
||||
"Eldithor",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"takyamtom",
|
||||
"奚明 刘",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"confiscated Zyra",
|
||||
"Jwk0205",
|
||||
"Bro Xie",
|
||||
"준희 김",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Tee Gee",
|
||||
"Some Guy Named Barry",
|
||||
"jinxedx",
|
||||
"tarek helmi",
|
||||
"Max Marklund",
|
||||
"Tomohiro Baba",
|
||||
"David Ortega",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"Noora",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Mattssn",
|
||||
"Lex Song",
|
||||
"John Saveas",
|
||||
"Frank Nitty",
|
||||
"Pronredn",
|
||||
"Christopher Michel",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"nahinahi9",
|
||||
"lh qwe",
|
||||
"Kevin John Duck",
|
||||
"conner",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"Yaboi",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Steam Steam",
|
||||
"Damon Cunliffe",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
"Princess Bright Eyes",
|
||||
"Paul Kroll",
|
||||
"AbstractAss",
|
||||
"otaku fra",
|
||||
"Ran C",
|
||||
"tedcor",
|
||||
"Fotek Design",
|
||||
"Felipe dos Santos",
|
||||
"Bas Imagineer",
|
||||
"Markus",
|
||||
"MiraiKuriyamaSy",
|
||||
"Adam Taylor",
|
||||
"Douglas Gaspar",
|
||||
"Weird_With_A_Beard",
|
||||
"MadSpin",
|
||||
"Pozadine1",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"dw",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"inbijiburu",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"elu3199",
|
||||
"Nick “Loadstone” D",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
@@ -234,54 +232,45 @@
|
||||
"thesoftwaredruid",
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
"Tyrswood",
|
||||
"linnfrey",
|
||||
"Gamalonia",
|
||||
"Vir",
|
||||
"Pkrsky",
|
||||
"Joboshy",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"奚明 刘",
|
||||
"Josef Lanzl",
|
||||
"Seth Christensen",
|
||||
"Nerezza",
|
||||
"Griffin Dahlberg",
|
||||
"Draven T",
|
||||
"yer fey",
|
||||
"준희 김",
|
||||
"Error_Rule34_Not_found",
|
||||
"Gerald Welly",
|
||||
"Roslynd",
|
||||
"Geolog",
|
||||
"jinxedx",
|
||||
"Neco28",
|
||||
"Aquatic Coffee",
|
||||
"Dankin",
|
||||
"ethanfel",
|
||||
"Tomohiro Baba",
|
||||
"David Ortega",
|
||||
"Noora",
|
||||
"Cristian Vazquez",
|
||||
"Frank Nitty",
|
||||
"Mattssn",
|
||||
"Magic Noob",
|
||||
"Focuschannel",
|
||||
"DougPeterson",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"Kevin John Duck",
|
||||
"Anthony Faxlandez",
|
||||
"Kevin Christopher",
|
||||
"Ouro Boros",
|
||||
"Blackfish95",
|
||||
"Chad Idk",
|
||||
"Yaboi",
|
||||
"dd",
|
||||
"Paul Kroll",
|
||||
"MiraiKuriyamaSy",
|
||||
"semicolon drainpipe",
|
||||
"Thesharingbrother",
|
||||
"Bas Imagineer",
|
||||
"Pat Hen",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
"Dušan Ryban",
|
||||
"tedcor",
|
||||
"Fotek Design",
|
||||
"sjon kreutz",
|
||||
"John Statham",
|
||||
"ResidentDeviant",
|
||||
"Nihongasuki",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"MadSpin",
|
||||
"Metryman55",
|
||||
"inbijiburu",
|
||||
"decoy",
|
||||
"Tyrswood",
|
||||
"Nick “Loadstone” D",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
@@ -290,6 +279,7 @@
|
||||
"David LaVallee",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
"Joseph",
|
||||
@@ -302,77 +292,60 @@
|
||||
"Moon Knight",
|
||||
"몽타주",
|
||||
"Kland",
|
||||
"zenobeus",
|
||||
"Jackthemind",
|
||||
"ryoma",
|
||||
"Stryker",
|
||||
"raf8osz",
|
||||
"ElitaSSJ4",
|
||||
"blikkies",
|
||||
"Chris",
|
||||
"Hailshem",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"Vir",
|
||||
"Brian M",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"Seth Christensen",
|
||||
"Draven T",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Shock Shockor",
|
||||
"Mateo Curić",
|
||||
"Goldwaters",
|
||||
"Zude",
|
||||
"Aquatic Coffee",
|
||||
"Eris3D",
|
||||
"m",
|
||||
"ethanfel",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
"Kyler",
|
||||
"Focuschannel",
|
||||
"Mikko Hemilä",
|
||||
"aRtFuL_DodGeR",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"James Coleman",
|
||||
"CrimsonDX",
|
||||
"Martial",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"Emil Andersson",
|
||||
"Chad Idk",
|
||||
"DarkSunset",
|
||||
"Billy Gladky",
|
||||
"Yuji Kaneko",
|
||||
"Probis",
|
||||
"Dušan Ryban",
|
||||
"ItsGeneralButtNaked",
|
||||
"Pat Hen",
|
||||
"semicolon drainpipe",
|
||||
"Jordan Shaw",
|
||||
"Rops Alot",
|
||||
"Thesharingbrother",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Nimess",
|
||||
"SRDB",
|
||||
"Ace Ventura",
|
||||
"g unit",
|
||||
"Youguang",
|
||||
"Metryman55",
|
||||
"andrewzpong",
|
||||
"FrxzenSnxw",
|
||||
"BossGame",
|
||||
"lrdchs",
|
||||
"ResidentDeviant",
|
||||
"Nihongasuki",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"momokai",
|
||||
"Hailshem",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"zenobeus",
|
||||
"ken",
|
||||
"Inversity",
|
||||
"AIVORY3D",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Kevinj",
|
||||
"Wind",
|
||||
"Jackthemind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
@@ -385,80 +358,81 @@
|
||||
"emyth",
|
||||
"chriphost",
|
||||
"KitKatM",
|
||||
"ryoma",
|
||||
"socrasteeze",
|
||||
"ResidentDeviant",
|
||||
"OrganicArtifact",
|
||||
"Stryker",
|
||||
"MudkipMedkitz",
|
||||
"gzmzmvp",
|
||||
"Welkor",
|
||||
"John Martin",
|
||||
"raf8osz",
|
||||
"ElitaSSJ4",
|
||||
"Richard",
|
||||
"blikkies",
|
||||
"Andrew",
|
||||
"Chris",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"moranqianlong",
|
||||
"Gregory Kozhemiak",
|
||||
"mrjuan",
|
||||
"Brian Buie",
|
||||
"Shock Shockor",
|
||||
"Sadlip",
|
||||
"Haru Yotu",
|
||||
"Goldwaters",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Zude",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"John J Linehan",
|
||||
"Kyler",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Kyron Mahan",
|
||||
"Matura Arbeit",
|
||||
"Theerat Jiramate",
|
||||
"aRtFuL_DodGeR",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"SendingRavens",
|
||||
"Derek Baker",
|
||||
"CrimsonDX",
|
||||
"Michael Anthony Scott",
|
||||
"DarkSunset",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Michael Docherty",
|
||||
"Nathan",
|
||||
"Billy Gladky",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Decx _",
|
||||
"Paul Hartsuyker",
|
||||
"elitassj",
|
||||
"Jacob Winter",
|
||||
"Probis",
|
||||
"Ed Wang",
|
||||
"ItsGeneralButtNaked",
|
||||
"Nimess",
|
||||
"SRDB",
|
||||
"g unit",
|
||||
"Distortik",
|
||||
"David",
|
||||
"Meilo",
|
||||
"Pen Bouryoung",
|
||||
"Youguang",
|
||||
"四糸凜音",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
"MaartenAlbers",
|
||||
"khanh duy",
|
||||
"xybrightsummer",
|
||||
"jreedatchison",
|
||||
"PhilW",
|
||||
"Saya",
|
||||
"andrewzpong",
|
||||
"FrxzenSnxw",
|
||||
"BossGame",
|
||||
"lrdchs",
|
||||
"Tree Tagger",
|
||||
"Janik",
|
||||
"Inversity",
|
||||
"Crocket",
|
||||
"Cruel",
|
||||
"MRBlack",
|
||||
"AIVORY3D",
|
||||
"Kevinj",
|
||||
"Mitchell Robson",
|
||||
"Kiyoe",
|
||||
"humptynutz",
|
||||
"michael.isaza",
|
||||
"Kalnei",
|
||||
"Whitepinetrader",
|
||||
"OrganicArtifact",
|
||||
"Scott",
|
||||
"MudkipMedkitz",
|
||||
"ResidentDeviant",
|
||||
"deanbrian",
|
||||
"POPPIN",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
@@ -466,76 +440,152 @@
|
||||
"Menard",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"D",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"nanana",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"LarsesFPC",
|
||||
"cocona",
|
||||
"sfasdfasfdsa",
|
||||
"Buecyb99",
|
||||
"4IXplr0r3r",
|
||||
"dfklsjfkljslfjd",
|
||||
"hayden",
|
||||
"ahoystan",
|
||||
"Leland Saunders",
|
||||
"Welkor",
|
||||
"David Schenck",
|
||||
"John Martin",
|
||||
"Wolfe7D1",
|
||||
"Ink Temptation",
|
||||
"Bob Barker",
|
||||
"edk",
|
||||
"moranqianlong",
|
||||
"Kalli Core",
|
||||
"Aeternyx",
|
||||
"elleshar666",
|
||||
"YOU SINWOO",
|
||||
"ja s",
|
||||
"Doug Mason",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Haru Yotu",
|
||||
"Kauffy",
|
||||
"Jeremy Townsend",
|
||||
"EpicElric",
|
||||
"Sean voets",
|
||||
"Owen Gwosdz",
|
||||
"John J Linehan",
|
||||
"Elliot E",
|
||||
"Thomas Wanner",
|
||||
"Theerat Jiramate",
|
||||
"Kyron Mahan",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
"Devil Lude",
|
||||
"Matura Arbeit",
|
||||
"Nick Kage",
|
||||
"kevin stoddard",
|
||||
"Jack Dole",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"hexxish",
|
||||
"CptNeo",
|
||||
"notedfakes",
|
||||
"Maso",
|
||||
"Eric Ketchum",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Michael Docherty",
|
||||
"Michael Scott",
|
||||
"Kevin Wallace",
|
||||
"Matheus Couto",
|
||||
"Saya",
|
||||
"ChicRic",
|
||||
"mercur",
|
||||
"J C",
|
||||
"Ed Wang",
|
||||
"Paul Hartsuyker",
|
||||
"elitassj",
|
||||
"Jacob Winter",
|
||||
"Ryan Presley Ng",
|
||||
"Wes Sims",
|
||||
"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",
|
||||
"operationancut",
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
"Вячеслав Маринин",
|
||||
"Lyavph",
|
||||
"Filippo Ferrari",
|
||||
"Cola Matthew",
|
||||
"OniNoKen",
|
||||
"Iain Wisely",
|
||||
@@ -576,98 +626,121 @@
|
||||
"dg",
|
||||
"Maarten Harms",
|
||||
"Israel",
|
||||
"Muratoraccio",
|
||||
"SelfishMedic",
|
||||
"Ginnie",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"Alan+Cano",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"generic404",
|
||||
"lighthawke",
|
||||
"Terraformer",
|
||||
"GDS+DEV",
|
||||
"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",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"Bula",
|
||||
"quantenmecha",
|
||||
"abattoirblues",
|
||||
"Jason+Nash",
|
||||
"BillyBoy84",
|
||||
"DarkRoast",
|
||||
"zounik",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"SgtFluffles",
|
||||
"lrdchs2",
|
||||
"Duk3+Rand0m",
|
||||
"KUJYAKU",
|
||||
"NathenChoi",
|
||||
"Thomas+Reck",
|
||||
"Larses",
|
||||
"cocona",
|
||||
"Coeur+de+cochon",
|
||||
"David Schenck",
|
||||
"han b",
|
||||
"Nico",
|
||||
"Banana Joe",
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"JBsuede",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"Michael Eid",
|
||||
"beersandbacon",
|
||||
"Maximilian Pyko",
|
||||
"Invis",
|
||||
"Justin Houston",
|
||||
"Time Valentine",
|
||||
"Bob barker",
|
||||
"Ben D",
|
||||
"Garrett Wood",
|
||||
"Ronan Delevacq",
|
||||
"james",
|
||||
"Christian Schäfer",
|
||||
"OrochiNights",
|
||||
"Michael Zhu",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"gonzalo",
|
||||
"Seraphy",
|
||||
"Михал Михалыч",
|
||||
"雨の心 落",
|
||||
"Matt",
|
||||
"AllTimeNoobie",
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Dismem",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Locrospiel",
|
||||
"Jairus Knudsen",
|
||||
"Jarrid Lee",
|
||||
"Xan Dionysus",
|
||||
"Nathan lee",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"Mewtora",
|
||||
"Middo",
|
||||
"Forbidden Atelier",
|
||||
"Bryan Rutkowski",
|
||||
"John Rednoulf",
|
||||
"Spire",
|
||||
"Adictedtohumping",
|
||||
"Boba Smith",
|
||||
"Towelie",
|
||||
"Cyrus Fett",
|
||||
"MR.Bear",
|
||||
"dsffsdfsdfsdfsdfsdf",
|
||||
"Jean-françois SEMA",
|
||||
"Kurt",
|
||||
"max blo",
|
||||
"Xenon Xue",
|
||||
"JackJohnnyJim",
|
||||
"Edward Ten Eyck",
|
||||
"ivistorm",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"TenaciousD",
|
||||
"Khánh Đặng",
|
||||
"Chase Kwon",
|
||||
"Ted Cart",
|
||||
"Inyoshu",
|
||||
"Goober719",
|
||||
"Chad Barnes",
|
||||
"Person Y",
|
||||
"David Spearing",
|
||||
"James Ming",
|
||||
"vanditking",
|
||||
"kripitonga",
|
||||
"Rizzi",
|
||||
"nimin",
|
||||
"OMAR LUCIANO",
|
||||
"Ken+Suzuki",
|
||||
"hannibal",
|
||||
"Jo+Example",
|
||||
"BrentBertram",
|
||||
"Tigon",
|
||||
"eumelzocker",
|
||||
"dxjaymz",
|
||||
"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",
|
||||
"help": "Hilfe",
|
||||
"add": "Hinzufügen",
|
||||
"close": "Schließen"
|
||||
"close": "Schließen",
|
||||
"menu": "Menü"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Wird geladen...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "Früher Zugriff",
|
||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||
"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": {
|
||||
"download": "Herunterladen",
|
||||
"downloadTooltip": "Diese Version 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",
|
||||
"deleteTooltip": "Diese lokale Version löschen",
|
||||
"ignore": "Ignorieren",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
||||
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
||||
"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": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"add": "Add",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "Early Access",
|
||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||
"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": {
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download this version",
|
||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||
"delete": "Delete",
|
||||
"deleteTooltip": "Delete this local version",
|
||||
"ignore": "Ignore",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "Cache rebuild completed.",
|
||||
"repairFailed": "Cache rebuild failed: {message}",
|
||||
"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": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "Configuración",
|
||||
"help": "Ayuda",
|
||||
"add": "Añadir",
|
||||
"close": "Cerrar"
|
||||
"close": "Cerrar",
|
||||
"menu": "Menú"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||
"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": {
|
||||
"download": "Descargar",
|
||||
"downloadTooltip": "Descargar esta versión",
|
||||
"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",
|
||||
"deleteTooltip": "Eliminar esta versión local",
|
||||
"ignore": "Ignorar",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "Reconstrucción de caché completada.",
|
||||
"repairFailed": "Error al reconstruir la caché: {message}",
|
||||
"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": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "Paramètres",
|
||||
"help": "Aide",
|
||||
"add": "Ajouter",
|
||||
"close": "Fermer"
|
||||
"close": "Fermer",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||
"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": {
|
||||
"download": "Télécharger",
|
||||
"downloadTooltip": "Télécharger cette version",
|
||||
"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",
|
||||
"deleteTooltip": "Supprimer cette version locale",
|
||||
"ignore": "Ignorer",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "Reconstruction du cache terminée.",
|
||||
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
||||
"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": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "הגדרות",
|
||||
"help": "עזרה",
|
||||
"add": "הוספה",
|
||||
"close": "סגור"
|
||||
"close": "סגור",
|
||||
"menu": "תפריט"
|
||||
},
|
||||
"status": {
|
||||
"loading": "טוען...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||
"ignored": "התעלם",
|
||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||
"onSiteOnly": "רק באתר",
|
||||
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
|
||||
},
|
||||
"actions": {
|
||||
"download": "הורדה",
|
||||
"downloadTooltip": "הורד את הגרסה הזו",
|
||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||
"delete": "מחיקה",
|
||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||
"ignore": "התעלם",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
|
||||
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
|
||||
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "設定",
|
||||
"help": "ヘルプ",
|
||||
"add": "追加",
|
||||
"close": "閉じる"
|
||||
"close": "閉じる",
|
||||
"menu": "メニュー"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "早期アクセス",
|
||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||
"ignored": "無視中",
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||
"onSiteOnly": "サイト内のみ",
|
||||
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"downloadTooltip": "このバージョンをダウンロード",
|
||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||
"delete": "削除",
|
||||
"deleteTooltip": "このローカルバージョンを削除",
|
||||
"ignore": "無視",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
|
||||
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
|
||||
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "설정",
|
||||
"help": "도움말",
|
||||
"add": "추가",
|
||||
"close": "닫기"
|
||||
"close": "닫기",
|
||||
"menu": "메뉴"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||
"ignored": "무시됨",
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||
"onSiteOnly": "사이트 내 전용",
|
||||
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"downloadTooltip": "이 버전 다운로드",
|
||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||
"delete": "삭제",
|
||||
"deleteTooltip": "이 로컬 버전 삭제",
|
||||
"ignore": "무시",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||
"repairFailed": "캐시 재구성 실패: {message}",
|
||||
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
||||
"exportFailed": "진단 번들 내보내기 실패: {message}",
|
||||
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
|
||||
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "Настройки",
|
||||
"help": "Справка",
|
||||
"add": "Добавить",
|
||||
"close": "Закрыть"
|
||||
"close": "Закрыть",
|
||||
"menu": "Меню"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||
"ignored": "Игнорируется",
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||
"onSiteOnly": "Только на Сайте",
|
||||
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"downloadTooltip": "Скачать эту версию",
|
||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||
"delete": "Удалить",
|
||||
"deleteTooltip": "Удалить эту локальную версию",
|
||||
"ignore": "Игнорировать",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "Перестройка кэша завершена.",
|
||||
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
|
||||
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
|
||||
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "设置",
|
||||
"help": "帮助",
|
||||
"add": "添加",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"menu": "菜单"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "抢先体验",
|
||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已关闭更新通知"
|
||||
"ignoredTooltip": "此版本已关闭更新通知",
|
||||
"onSiteOnly": "仅站内生成",
|
||||
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"downloadTooltip": "下载此版本",
|
||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||
"delete": "删除",
|
||||
"deleteTooltip": "删除此本地版本",
|
||||
"ignore": "忽略",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "缓存重建完成。",
|
||||
"repairFailed": "缓存重建失败:{message}",
|
||||
"exportSuccess": "诊断包已导出。",
|
||||
"exportFailed": "导出诊断包失败:{message}"
|
||||
"exportFailed": "导出诊断包失败:{message}",
|
||||
"conflictsResolved": "已解决 {count} 个文件名冲突。",
|
||||
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"settings": "設定",
|
||||
"help": "說明",
|
||||
"add": "新增",
|
||||
"close": "關閉"
|
||||
"close": "關閉",
|
||||
"menu": "選單"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
@@ -1292,12 +1293,15 @@
|
||||
"earlyAccess": "搶先體驗",
|
||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已關閉更新通知"
|
||||
"ignoredTooltip": "此版本已關閉更新通知",
|
||||
"onSiteOnly": "僅站內生成",
|
||||
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"downloadTooltip": "下載此版本",
|
||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||
"delete": "刪除",
|
||||
"deleteTooltip": "刪除此本地版本",
|
||||
"ignore": "忽略",
|
||||
@@ -1906,7 +1910,9 @@
|
||||
"repairSuccess": "快取重建完成。",
|
||||
"repairFailed": "快取重建失敗:{message}",
|
||||
"exportSuccess": "診斷套件已匯出。",
|
||||
"exportFailed": "匯出診斷套件失敗:{message}"
|
||||
"exportFailed": "匯出診斷套件失敗:{message}",
|
||||
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
|
||||
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
|
||||
@@ -560,8 +560,14 @@ class MetadataProcessor:
|
||||
|
||||
params["loras"] = " ".join(lora_parts)
|
||||
|
||||
# Set default clip_skip value
|
||||
params["clip_skip"] = "1" # Common default
|
||||
# Extract clip_skip from any SAMPLING node that provides it
|
||||
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
|
||||
|
||||
|
||||
@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
metadata[PROMPTS][node_id]["positive_encoded"] = positive_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):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -1013,9 +1125,12 @@ NODE_EXTRACTORS = {
|
||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
||||
# ComfyUI-Easy-Use pre-sampling / seed
|
||||
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
|
||||
"easySeed": EasySeedExtractor, # easy seed
|
||||
# Loaders
|
||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
||||
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
|
||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
import os
|
||||
from ..utils.utils import get_lora_info_absolute
|
||||
from ..config import config
|
||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||
import logging
|
||||
|
||||
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:
|
||||
NAME = "WanVideo Lora Select (LoraManager)"
|
||||
CATEGORY = "Lora Manager/stackers"
|
||||
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
# 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
|
||||
lora_item = {
|
||||
"path": folder_paths.get_full_path("loras", lora_path),
|
||||
"path": lora_path,
|
||||
"strength": model_strength,
|
||||
"name": lora_path.split(".")[0],
|
||||
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||
"blocks": selected_blocks,
|
||||
"layer_filter": layer_filter,
|
||||
"low_mem_load": low_mem_load,
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
import os
|
||||
from ..utils.utils import get_lora_info_absolute
|
||||
from ..config import config
|
||||
from .utils import any_type
|
||||
import logging
|
||||
|
||||
# 初始化日志记录器
|
||||
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:
|
||||
# 节点在UI中显示的名称
|
||||
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
|
||||
else:
|
||||
continue
|
||||
|
||||
lora_path, trigger_words = get_lora_info(lora_name_raw)
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
|
||||
|
||||
lora_item = {
|
||||
"path": folder_paths.get_full_path("loras", lora_path),
|
||||
"path": lora_path,
|
||||
"strength": model_strength,
|
||||
"name": lora_path.split(".")[0],
|
||||
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||
"blocks": selected_blocks,
|
||||
"layer_filter": layer_filter,
|
||||
"low_mem_load": low_mem_load,
|
||||
|
||||
@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
|
||||
|
||||
def _find_model_file(self, files):
|
||||
"""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]:
|
||||
"""Expose handlers for subclasses or tests."""
|
||||
|
||||
@@ -33,15 +33,18 @@ from ...services.metadata_service import (
|
||||
update_metadata_providers,
|
||||
)
|
||||
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.websocket_manager import ws_manager
|
||||
from ...services.downloader import get_downloader
|
||||
from ...services.errors import ResourceNotFoundError
|
||||
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||
from ...utils.models import BaseModelMetadata
|
||||
from ...utils.constants import (
|
||||
CIVITAI_USER_MODEL_TYPES,
|
||||
DEFAULT_NODE_COLOR,
|
||||
NODE_TYPES,
|
||||
PREVIEW_EXTENSIONS,
|
||||
SUPPORTED_MEDIA_EXTENSIONS,
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
@@ -617,6 +620,7 @@ class DoctorHandler:
|
||||
diagnostics = [
|
||||
await self._check_civitai_api_key(),
|
||||
await self._check_cache_health(),
|
||||
await self._check_filename_conflicts(),
|
||||
self._check_ui_version(client_version, app_version),
|
||||
]
|
||||
|
||||
@@ -681,6 +685,145 @@ class DoctorHandler:
|
||||
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:
|
||||
try:
|
||||
payload = await request.json()
|
||||
@@ -846,6 +989,79 @@ class DoctorHandler:
|
||||
"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]:
|
||||
if client_version and client_version != app_version:
|
||||
return {
|
||||
@@ -1576,29 +1792,33 @@ class ModelLibraryHandler:
|
||||
exists = True
|
||||
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()
|
||||
has_been_downloaded = False
|
||||
history_type = model_type
|
||||
if history_type:
|
||||
has_been_downloaded = await history_service.has_been_downloaded(
|
||||
history_type,
|
||||
history_type = None
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
if await history_service.has_been_downloaded(
|
||||
candidate_type,
|
||||
model_version_id,
|
||||
)
|
||||
else:
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
if await history_service.has_been_downloaded(
|
||||
candidate_type,
|
||||
model_version_id,
|
||||
):
|
||||
has_been_downloaded = True
|
||||
history_type = candidate_type
|
||||
break
|
||||
):
|
||||
has_been_downloaded = True
|
||||
history_type = candidate_type
|
||||
break
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"exists": exists,
|
||||
"modelType": model_type if exists else history_type,
|
||||
"exists": False,
|
||||
"modelType": history_type,
|
||||
"hasBeenDownloaded": has_been_downloaded,
|
||||
}
|
||||
)
|
||||
@@ -1618,40 +1838,46 @@ class ModelLibraryHandler:
|
||||
model_type = None
|
||||
versions = []
|
||||
downloaded_version_ids = []
|
||||
history_service = await self._get_download_history_service()
|
||||
if lora_versions:
|
||||
model_type = "lora"
|
||||
versions = self._with_downloaded_flag(lora_versions)
|
||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||
model_type,
|
||||
model_id,
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"modelType": "lora",
|
||||
"versions": self._with_downloaded_flag(lora_versions),
|
||||
"downloadedVersionIds": [],
|
||||
}
|
||||
)
|
||||
elif checkpoint_versions:
|
||||
model_type = "checkpoint"
|
||||
versions = self._with_downloaded_flag(checkpoint_versions)
|
||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||
model_type,
|
||||
model_id,
|
||||
if checkpoint_versions:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"modelType": "checkpoint",
|
||||
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||
"downloadedVersionIds": [],
|
||||
}
|
||||
)
|
||||
elif embedding_versions:
|
||||
model_type = "embedding"
|
||||
versions = self._with_downloaded_flag(embedding_versions)
|
||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||
model_type,
|
||||
model_id,
|
||||
if embedding_versions:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"modelType": "embedding",
|
||||
"versions": self._with_downloaded_flag(embedding_versions),
|
||||
"downloadedVersionIds": [],
|
||||
}
|
||||
)
|
||||
else:
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
candidate_downloaded_version_ids = (
|
||||
await history_service.get_downloaded_version_ids(
|
||||
candidate_type,
|
||||
model_id,
|
||||
)
|
||||
|
||||
history_service = await self._get_download_history_service()
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
candidate_downloaded_version_ids = (
|
||||
await history_service.get_downloaded_version_ids(
|
||||
candidate_type,
|
||||
model_id,
|
||||
)
|
||||
if candidate_downloaded_version_ids:
|
||||
model_type = candidate_type
|
||||
downloaded_version_ids = candidate_downloaded_version_ids
|
||||
break
|
||||
)
|
||||
if candidate_downloaded_version_ids:
|
||||
model_type = candidate_type
|
||||
downloaded_version_ids = candidate_downloaded_version_ids
|
||||
break
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1665,6 +1891,86 @@ class ModelLibraryHandler:
|
||||
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
||||
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(
|
||||
self, request: web.Request
|
||||
) -> web.Response:
|
||||
@@ -1777,6 +2083,78 @@ class ModelLibraryHandler:
|
||||
)
|
||||
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:
|
||||
try:
|
||||
model_id_str = request.query.get("modelId")
|
||||
@@ -2796,6 +3174,7 @@ class MiscHandlerSet:
|
||||
"update_settings": self.settings.update_settings,
|
||||
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
||||
"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,
|
||||
"get_priority_tags": self.settings.get_priority_tags,
|
||||
"get_settings_libraries": self.settings.get_libraries,
|
||||
@@ -2809,8 +3188,10 @@ class MiscHandlerSet:
|
||||
"update_node_widget": self.node_registry.update_node_widget,
|
||||
"get_registry": self.node_registry.get_registry,
|
||||
"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,
|
||||
"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,
|
||||
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
||||
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
||||
|
||||
@@ -2423,6 +2423,7 @@ class ModelUpdateHandler:
|
||||
"shouldIgnore": version.should_ignore,
|
||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||
"isEarlyAccess": is_early_access,
|
||||
"usageControl": version.usage_control,
|
||||
"filePath": context.get("file_path"),
|
||||
"fileName": context.get("file_name"),
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||
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("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||
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("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/model-version-download-status",
|
||||
@@ -89,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"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
|
||||
for f in files
|
||||
if f.get("primary")
|
||||
and f.get("type") in ("Model", "Negative")
|
||||
and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -1395,7 +1395,7 @@ class DownloadManager:
|
||||
(
|
||||
f
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
|
||||
self._db_path = db_path or _resolve_database_path()
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._lock = asyncio.Lock()
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
self._schema_initialized = False
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
|
||||
conn.row_factory = sqlite3.Row
|
||||
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:
|
||||
if self._schema_initialized:
|
||||
return
|
||||
@@ -116,33 +123,33 @@ class DownloadedVersionHistoryService:
|
||||
timestamp = time.time()
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
normalized_model_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
source,
|
||||
file_path,
|
||||
active_library_name,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
normalized_model_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
source,
|
||||
file_path,
|
||||
active_library_name,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_downloaded_bulk(
|
||||
self,
|
||||
@@ -180,24 +187,24 @@ class DownloadedVersionHistoryService:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
conn.commit()
|
||||
conn = self._get_conn()
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
@@ -208,28 +215,28 @@ class DownloadedVersionHistoryService:
|
||||
timestamp = time.time()
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 1
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
self._get_active_library_name(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 1
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
self._get_active_library_name(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
@@ -238,15 +245,15 @@ class DownloadedVersionHistoryService:
|
||||
return False
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT is_deleted_override
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
).fetchone()
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT is_deleted_override
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
).fetchone()
|
||||
return bool(row) and not bool(row["is_deleted_override"])
|
||||
|
||||
async def get_downloaded_version_ids(
|
||||
@@ -258,16 +265,16 @@ class DownloadedVersionHistoryService:
|
||||
return []
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||
ORDER BY version_id ASC
|
||||
""",
|
||||
(normalized_type, normalized_model_id),
|
||||
).fetchall()
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||
ORDER BY version_id ASC
|
||||
""",
|
||||
(normalized_type, normalized_model_id),
|
||||
).fetchall()
|
||||
return [int(row["version_id"]) for row in rows]
|
||||
|
||||
async def get_downloaded_version_ids_bulk(
|
||||
@@ -291,17 +298,17 @@ class DownloadedVersionHistoryService:
|
||||
params: list[object] = [normalized_type, *normalized_model_ids]
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT model_id, version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ?
|
||||
AND model_id IN ({placeholders})
|
||||
AND is_deleted_override = 0
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT model_id, version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ?
|
||||
AND model_id IN ({placeholders})
|
||||
AND is_deleted_override = 0
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
result: dict[int, set[int]] = {}
|
||||
for row in rows:
|
||||
|
||||
@@ -79,6 +79,12 @@ class ModelHashIndex:
|
||||
hash_val = h
|
||||
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 not hash_val:
|
||||
return
|
||||
|
||||
@@ -1072,14 +1072,6 @@ class ModelScanner:
|
||||
excluded_models.append(model_data['file_path'])
|
||||
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
|
||||
|
||||
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
|
||||
@@ -1105,6 +1097,31 @@ class ModelScanner:
|
||||
|
||||
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(
|
||||
self,
|
||||
raw_data: List[Mapping[str, Any]],
|
||||
|
||||
@@ -69,6 +69,7 @@ class ModelVersionRecord:
|
||||
early_access_ends_at: Optional[str] = None
|
||||
sort_index: int = 0
|
||||
is_early_access: bool = False
|
||||
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
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:
|
||||
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
|
||||
not version.is_in_library
|
||||
and not version.should_ignore
|
||||
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
|
||||
)
|
||||
|
||||
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
|
||||
continue
|
||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||
continue
|
||||
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||
continue
|
||||
if version.version_id > max_in_library:
|
||||
return True
|
||||
return False
|
||||
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
|
||||
# Phase 1: Basic EA flag from bulk API
|
||||
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(
|
||||
self,
|
||||
local_version_id: Optional[int],
|
||||
local_base_model: Optional[str],
|
||||
hide_early_access: bool = False,
|
||||
hide_non_downloadable: bool = True,
|
||||
) -> bool:
|
||||
"""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_base_model: The base model to filter by.
|
||||
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:
|
||||
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
|
||||
continue
|
||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||
continue
|
||||
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||
continue
|
||||
version_base = _normalize_base_model(version.base_model)
|
||||
if version_base != normalized_base:
|
||||
continue
|
||||
@@ -230,6 +247,7 @@ class ModelUpdateService:
|
||||
preview_url TEXT,
|
||||
is_in_library INTEGER NOT NULL DEFAULT 0,
|
||||
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||
usage_control TEXT,
|
||||
PRIMARY KEY (model_id, version_id),
|
||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -465,6 +483,10 @@ class ModelUpdateService:
|
||||
"ALTER TABLE model_update_versions "
|
||||
"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():
|
||||
@@ -1337,6 +1359,7 @@ class ModelUpdateService:
|
||||
# Check availability field from bulk API for basic EA detection
|
||||
availability = _normalize_string(entry.get("availability"))
|
||||
is_early_access = availability == "EarlyAccess"
|
||||
usage_control = _normalize_string(entry.get("usageControl"))
|
||||
|
||||
return ModelVersionRecord(
|
||||
version_id=version_id,
|
||||
@@ -1350,6 +1373,7 @@ class ModelUpdateService:
|
||||
early_access_ends_at=early_access_ends_at,
|
||||
sort_index=index,
|
||||
is_early_access=is_early_access,
|
||||
usage_control=usage_control,
|
||||
)
|
||||
|
||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||
@@ -1464,7 +1488,7 @@ class ModelUpdateService:
|
||||
f"""
|
||||
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,
|
||||
is_early_access
|
||||
is_early_access, usage_control
|
||||
FROM model_update_versions
|
||||
WHERE model_id IN ({placeholders})
|
||||
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"],
|
||||
sort_index=_normalize_int(row["sort_index"]) or 0,
|
||||
is_early_access=bool(row["is_early_access"]),
|
||||
usage_control=row["usage_control"],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1548,8 +1573,8 @@ class ModelUpdateService:
|
||||
INSERT INTO model_update_versions (
|
||||
version_id, model_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||
is_early_access
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
is_early_access, usage_control
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
version.version_id,
|
||||
@@ -1564,6 +1589,7 @@ class ModelUpdateService:
|
||||
1 if version.should_ignore else 0,
|
||||
version.early_access_ends_at,
|
||||
1 if version.is_early_access else 0,
|
||||
version.usage_control,
|
||||
),
|
||||
)
|
||||
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 {
|
||||
content: '\f00c';
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||
font-weight: 900;
|
||||
color: var(--lora-text);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
transition: transform 160ms ease-out;
|
||||
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||
max-width: 260px; /* Base size */
|
||||
min-width: 200px; /* Prevent cards from becoming too narrow */
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
@@ -328,7 +329,6 @@
|
||||
}
|
||||
|
||||
.card-actions i {
|
||||
margin-left: var(--space-1);
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: opacity 0.2s, transform 0.15s ease;
|
||||
@@ -370,7 +370,16 @@
|
||||
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) {
|
||||
.card-grid {
|
||||
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
||||
@@ -378,6 +387,7 @@
|
||||
|
||||
.model-card {
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
transition: transform 160ms ease-out;
|
||||
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
|
||||
width: 100%; /* Allow width to be set by the VirtualScroller */
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Allow cards to grow beyond 260px in virtual scroll mode */
|
||||
.virtual-scroll-item.model-card {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.virtual-scroll-item:hover {
|
||||
@@ -576,11 +591,11 @@ body.hide-card-version .civitai-version {
|
||||
.card-grid.virtual-scroll {
|
||||
display: block;
|
||||
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 */
|
||||
height: auto;
|
||||
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 */
|
||||
overflow-x: hidden; /* Prevent horizontal overflow */
|
||||
}
|
||||
|
||||
@@ -22,6 +22,22 @@
|
||||
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 */
|
||||
@media (min-width: 2150px) {
|
||||
.header-container {
|
||||
@@ -77,6 +93,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
@@ -97,13 +114,99 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Header search */
|
||||
/* Header search - Centered with VS Code command palette style */
|
||||
.header-search {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
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 */
|
||||
.header-search.disabled {
|
||||
opacity: 0.5;
|
||||
@@ -247,44 +350,207 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.app-title {
|
||||
display: none;
|
||||
/* Hide text title on mobile */
|
||||
/* Hamburger menu button - hidden by default */
|
||||
.hamburger-menu-btn {
|
||||
display: none;
|
||||
width: 32px;
|
||||
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 {
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-controls>div {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
.header-controls > div {
|
||||
width: 30px;
|
||||
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 {
|
||||
max-width: none;
|
||||
margin: 0 0.5rem;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.header-container {
|
||||
padding: 0 8px;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: none;
|
||||
/* Hide navigation on very small screens */
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
flex: 1;
|
||||
.nav-item {
|
||||
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);
|
||||
}
|
||||
|
||||
.version-action-disabled {
|
||||
background: transparent;
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.version-action:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
|
||||
.early-access-info {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
@@ -97,7 +96,6 @@
|
||||
|
||||
.local-path {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
|
||||
@@ -271,11 +271,16 @@
|
||||
|
||||
/* Enhanced Sidebar Breadcrumb Styles */
|
||||
.sidebar-breadcrumb-container {
|
||||
margin-top: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
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 {
|
||||
@@ -284,7 +289,6 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
font-size: 0.85em;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-breadcrumb-item {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
top: -54px;
|
||||
z-index: calc(var(--z-header) - 1);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -371,6 +371,14 @@
|
||||
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 {
|
||||
display: block;
|
||||
padding: 6px 15px;
|
||||
@@ -397,6 +405,33 @@
|
||||
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) {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -129,6 +129,126 @@ export class HeaderManager {
|
||||
|
||||
// Hide search functionality on Statistics page
|
||||
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() {
|
||||
|
||||
@@ -181,6 +181,13 @@ function isEarlyAccessActive(version) {
|
||||
}
|
||||
}
|
||||
|
||||
function isDownloadAllowed(version) {
|
||||
if (!version.usageControl) {
|
||||
return true;
|
||||
}
|
||||
return version.usageControl === 'Download';
|
||||
}
|
||||
|
||||
function buildMetaMarkup(version, options = {}) {
|
||||
const segments = [];
|
||||
if (version.baseModel) {
|
||||
@@ -230,12 +237,17 @@ function buildBadge(label, tone, options = {}) {
|
||||
function buildActionButton(label, variant, action, options = {}) {
|
||||
const attributes = [
|
||||
`class="version-action ${variant}"`,
|
||||
`data-version-action="${escapeHtml(action)}"`,
|
||||
];
|
||||
if (action) {
|
||||
attributes.push(`data-version-action="${escapeHtml(action)}"`);
|
||||
}
|
||||
if (options.title) {
|
||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||
}
|
||||
if (options.disabled) {
|
||||
attributes.push('disabled');
|
||||
}
|
||||
if (options.extraAttributes) {
|
||||
attributes.push(options.extraAttributes);
|
||||
}
|
||||
@@ -371,6 +383,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
||||
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
||||
return false;
|
||||
}
|
||||
if (!isDownloadAllowed(version)) {
|
||||
return false;
|
||||
}
|
||||
const versionBase = normalizeBaseModelName(version.baseModel);
|
||||
if (versionBase !== normalizedBase) {
|
||||
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) {
|
||||
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
||||
title: translate(
|
||||
@@ -524,25 +550,36 @@ function renderRow(version, options) {
|
||||
|
||||
const actions = [];
|
||||
if (!version.isInLibrary) {
|
||||
// Download button with optional EA bolt icon
|
||||
const canDownload = isDownloadAllowed(version);
|
||||
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(
|
||||
downloadLabel,
|
||||
'version-action-primary',
|
||||
'download',
|
||||
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
||||
canDownload ? 'download' : '',
|
||||
{
|
||||
title: isEarlyAccess
|
||||
? translate(
|
||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||
{},
|
||||
'Download this early access version from Civitai'
|
||||
)
|
||||
: translate(
|
||||
'modals.model.versions.actions.downloadTooltip',
|
||||
{},
|
||||
'Download this version'
|
||||
),
|
||||
title: downloadTitle,
|
||||
iconMarkup: downloadIcon,
|
||||
disabled: !canDownload,
|
||||
}
|
||||
));
|
||||
} else if (version.filePath) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { escapeHtml } from '../components/shared/utils.js';
|
||||
import { state } from '../state/index.js';
|
||||
|
||||
const MAX_CONSOLE_ENTRIES = 200;
|
||||
|
||||
@@ -258,6 +259,15 @@ export class DoctorManager {
|
||||
}
|
||||
|
||||
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) {
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
@@ -317,6 +327,9 @@ export class DoctorManager {
|
||||
case 'repair-cache':
|
||||
await this.repairCache();
|
||||
break;
|
||||
case 'resolve-filename-conflicts':
|
||||
await this.resolveFilenameConflicts();
|
||||
break;
|
||||
case 'reload-page':
|
||||
this.reloadUi();
|
||||
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() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
|
||||
@@ -599,7 +599,7 @@ export class FilterManager {
|
||||
|
||||
// Call the appropriate manager's load method based on page type
|
||||
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') {
|
||||
// For models page, reset the page and reload
|
||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||
@@ -682,7 +682,7 @@ export class FilterManager {
|
||||
|
||||
// Reload data using the appropriate method for the current page
|
||||
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') {
|
||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ export class SearchManager {
|
||||
|
||||
// Call the appropriate manager's load method based on page type
|
||||
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') {
|
||||
// For models page, reset the page and reload
|
||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||
|
||||
@@ -2863,7 +2863,7 @@ export class SettingsManager {
|
||||
await resetAndReload(false);
|
||||
} else if (this.currentPage === 'recipes') {
|
||||
// Reload the recipes without updating folders
|
||||
await window.recipeManager.loadRecipes();
|
||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
// Reload the checkpoints without updating folders
|
||||
await resetAndReload(false);
|
||||
|
||||
@@ -19,7 +19,7 @@ class RecipePageControls {
|
||||
}
|
||||
|
||||
async resetAndReload() {
|
||||
refreshVirtualScroll();
|
||||
await refreshVirtualScroll({ preserveScroll: true });
|
||||
}
|
||||
|
||||
async refreshModels(fullRebuild = false) {
|
||||
|
||||
@@ -104,69 +104,74 @@ export class VirtualScroller {
|
||||
// Get display density setting
|
||||
const displayDensity = state.global.settings?.display_density || 'default';
|
||||
|
||||
// Set exact column counts and grid widths to match CSS container widths
|
||||
let maxColumns, maxGridWidth;
|
||||
// Base gap between cards
|
||||
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 (displayDensity === 'default') {
|
||||
maxColumns = 8;
|
||||
preferredMaxColumns = 8;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 9;
|
||||
preferredMaxColumns = 10;
|
||||
} else { // compact
|
||||
maxColumns = 10;
|
||||
preferredMaxColumns = 12;
|
||||
}
|
||||
maxGridWidth = 2400; // Match exact CSS container width for 4K
|
||||
} else if (window.innerWidth >= 2150) { // 2K/1440p
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 6;
|
||||
preferredMaxColumns = 6;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 7;
|
||||
preferredMaxColumns = 8;
|
||||
} else { // compact
|
||||
maxColumns = 8;
|
||||
preferredMaxColumns = 10;
|
||||
}
|
||||
maxGridWidth = 1800; // Match exact CSS container width for 2K
|
||||
} else {
|
||||
// 1080p
|
||||
} else { // 1080p and smaller
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 5;
|
||||
preferredMaxColumns = 5;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 6;
|
||||
preferredMaxColumns = 6;
|
||||
} 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
|
||||
// Formula: (maxGridWidth - (columns-1)*gap) / columns
|
||||
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
|
||||
// Use the smaller of: max columns that fit, or preferred max
|
||||
// This ensures cards are never smaller than minCardWidth
|
||||
this.columnsCount = Math.min(maxColumns, preferredMaxColumns);
|
||||
|
||||
// Use the smaller of available content width or max grid width
|
||||
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
||||
// Calculate card width to perfectly fill available space
|
||||
// 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
|
||||
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
|
||||
// Calculate height based on aspect ratio (896/1152)
|
||||
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
||||
|
||||
// Calculate the left offset to center the grid within the content area
|
||||
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
|
||||
// Edge-to-edge layout: no offset, grid fills container
|
||||
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.width = `${actualGridWidth}px`;
|
||||
|
||||
// Add or remove density classes for style adjustments
|
||||
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.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
{% block content %}
|
||||
{% include 'components/controls.html' %}
|
||||
{% include 'components/breadcrumb.html' %}
|
||||
{% include 'components/duplicates_banner.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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -1,50 +1,53 @@
|
||||
<header class="app-header">
|
||||
<div class="header-container">
|
||||
<div class="header-branding">
|
||||
<a href="/loras" class="logo-link">
|
||||
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||
<span class="app-title">{{ t('header.appTitle') }}</span>
|
||||
</a>
|
||||
<!-- Left section: Logo + Navigation -->
|
||||
<div class="header-left">
|
||||
<div class="header-branding">
|
||||
<a href="/loras" class="logo-link">
|
||||
<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>
|
||||
{% 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 %}
|
||||
|
||||
<!-- Center section: Search -->
|
||||
{% set search_disabled = current_page == 'statistics' %}
|
||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
||||
current_page %}
|
||||
{% 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="search-container">
|
||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||
@@ -62,9 +65,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<!-- Integrated corner controls -->
|
||||
<div class="header-controls">
|
||||
<!-- Right section: Controls -->
|
||||
<div class="header-right">
|
||||
<div class="header-controls" id="headerControls">
|
||||
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
|
||||
<i class="fas fa-moon dark-icon"></i>
|
||||
<i class="fas fa-sun light-icon"></i>
|
||||
@@ -85,6 +88,34 @@
|
||||
<i class="fas fa-heart"></i>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
{% block content %}
|
||||
{% include 'components/controls.html' %}
|
||||
{% include 'components/breadcrumb.html' %}
|
||||
{% include 'components/duplicates_banner.html' %}
|
||||
{% include 'components/folder_sidebar.html' %}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
{% block content %}
|
||||
{% include 'components/controls.html' %}
|
||||
{% include 'components/breadcrumb.html' %}
|
||||
{% include 'components/duplicates_banner.html' %}
|
||||
{% include 'components/folder_sidebar.html' %}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from py.services.model_hash_index import ModelHashIndex
|
||||
from py.routes.handlers.misc_handlers import (
|
||||
BackupHandler,
|
||||
DoctorHandler,
|
||||
@@ -78,10 +79,11 @@ async def dummy_downloader_factory():
|
||||
|
||||
|
||||
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._raw_data = list(raw_data or [])
|
||||
self._rebuild_error = rebuild_error
|
||||
self._hash_index = hash_index
|
||||
self._persistent_cache = SimpleNamespace(
|
||||
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
|
||||
)
|
||||
@@ -91,6 +93,16 @@ class DummyDoctorScanner:
|
||||
raise self._rebuild_error
|
||||
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:
|
||||
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")
|
||||
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()
|
||||
cached_paths = {item["file_path"] for item in cache.raw_data}
|
||||
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();
|
||||
}
|
||||
|
||||
// Now do the re-render after drag is complete
|
||||
if (renderFunction) {
|
||||
renderFunction(widget.value, widget);
|
||||
// Commit final value through options.setValue so external observers are notified.
|
||||
// During drag, handleStrengthDrag mutates widgetValue in-place (updateWidget=false),
|
||||
// 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');
|
||||
|
||||
// Only re-render if we actually dragged
|
||||
if (wasDragging && renderFunction) {
|
||||
renderFunction(widget.value, widget);
|
||||
if (wasDragging) {
|
||||
// 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
|
||||
headerEl.addEventListener('pointerup', endDrag);
|
||||
|
||||
|
||||
@@ -413,7 +413,7 @@ app.registerExtension({
|
||||
const savedItem = consumeQueuedState(itemState, itemText);
|
||||
return {
|
||||
text: itemText,
|
||||
active: savedItem ? savedItem.active : defaultActive,
|
||||
active: savedItem ? savedItem.active : true,
|
||||
highlighted: false,
|
||||
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 |