mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-27 05:11:15 -03:00
Compare commits
33 Commits
v1.0.8
...
54bcdfab38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54bcdfab38 | ||
|
|
2e7532eecc | ||
|
|
7e5e3b1ec7 | ||
|
|
df67bd396a | ||
|
|
dd5d9cfcb2 | ||
|
|
d9fd60bec1 | ||
|
|
b633b22779 | ||
|
|
1ffa543160 | ||
|
|
cdc940586e | ||
|
|
ccf1c6f2ae | ||
|
|
bfe7b5e1c7 | ||
|
|
85c020cd12 | ||
|
|
1b202f8ec7 | ||
|
|
d02a0611d3 | ||
|
|
92166a161a | ||
|
|
b509f27cb7 | ||
|
|
5c2ef48917 | ||
|
|
ad2bd82c67 | ||
|
|
17ba350153 | ||
|
|
60175334b5 | ||
|
|
f65a01df00 | ||
|
|
430e24d70b | ||
|
|
14f0c48fdd | ||
|
|
34791c2ad7 | ||
|
|
3f6824eef6 | ||
|
|
3919dfa3f4 | ||
|
|
7124b5293f | ||
|
|
d2a04f8993 | ||
|
|
7027a7c270 | ||
|
|
0a1d7dfd4c | ||
|
|
3962b1a96d | ||
|
|
8b856276bf | ||
|
|
c97c802956 |
@@ -21,7 +21,9 @@
|
|||||||
"stone9k",
|
"stone9k",
|
||||||
"Rosenthal",
|
"Rosenthal",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
|
"JongWon Han",
|
||||||
"runte3221",
|
"runte3221",
|
||||||
|
"FreelancerZ",
|
||||||
"Fraser Cross",
|
"Fraser Cross",
|
||||||
"Polymorphic Indeterminate",
|
"Polymorphic Indeterminate",
|
||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
@@ -43,11 +45,13 @@
|
|||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
"KD",
|
"KD",
|
||||||
"Omnidex",
|
"Omnidex",
|
||||||
|
"Tyler Trebuchon",
|
||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
"Tobi_Swagg",
|
"Tobi_Swagg",
|
||||||
"SG",
|
"SG",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
|
"Buzzard",
|
||||||
"jmack",
|
"jmack",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
@@ -57,7 +61,7 @@
|
|||||||
"Wolffen",
|
"Wolffen",
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
"James Todd",
|
"James Todd",
|
||||||
"JongWon Han",
|
"Steven Pfeiffer",
|
||||||
"VantAI",
|
"VantAI",
|
||||||
"Tim",
|
"Tim",
|
||||||
"Lisster",
|
"Lisster",
|
||||||
@@ -65,7 +69,6 @@
|
|||||||
"Illrigger",
|
"Illrigger",
|
||||||
"Tom Corrigan",
|
"Tom Corrigan",
|
||||||
"JackieWang",
|
"JackieWang",
|
||||||
"FreelancerZ",
|
|
||||||
"fnkylove",
|
"fnkylove",
|
||||||
"Yushio",
|
"Yushio",
|
||||||
"Vik71it",
|
"Vik71it",
|
||||||
@@ -73,6 +76,7 @@
|
|||||||
"Lilleman",
|
"Lilleman",
|
||||||
"Robert Stacey",
|
"Robert Stacey",
|
||||||
"PM",
|
"PM",
|
||||||
|
"Todd Keck",
|
||||||
"Edgar Tejeda",
|
"Edgar Tejeda",
|
||||||
"Jorge Hussni",
|
"Jorge Hussni",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
@@ -91,7 +95,6 @@
|
|||||||
"Melville Parrish",
|
"Melville Parrish",
|
||||||
"daniel dove",
|
"daniel dove",
|
||||||
"Lustre",
|
"Lustre",
|
||||||
"Tyler Trebuchon",
|
|
||||||
"JW Sin",
|
"JW Sin",
|
||||||
"contrite831",
|
"contrite831",
|
||||||
"Alex",
|
"Alex",
|
||||||
@@ -99,20 +102,19 @@
|
|||||||
"carozzz",
|
"carozzz",
|
||||||
"Marlon Daniels",
|
"Marlon Daniels",
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
"Buzzard",
|
|
||||||
"Aaron Bleuer",
|
"Aaron Bleuer",
|
||||||
"LacesOut!",
|
"LacesOut!",
|
||||||
"greebles",
|
"greebles",
|
||||||
"Adam Shaw",
|
"Adam Shaw",
|
||||||
"Anthony Rizzo",
|
"Anthony Rizzo",
|
||||||
|
"M Postkasse",
|
||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
"RedrockVP",
|
"RedrockVP",
|
||||||
"ASLPro3D",
|
"Wicked Choices by ASLPro3D",
|
||||||
"OldBones",
|
"OldBones",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
"FinalyFree",
|
||||||
"Weasyl",
|
"Weasyl",
|
||||||
"Steven Pfeiffer",
|
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"Johnny",
|
"Johnny",
|
||||||
"Cory Paza",
|
"Cory Paza",
|
||||||
@@ -126,7 +128,7 @@
|
|||||||
"corde",
|
"corde",
|
||||||
"Nick Walker",
|
"Nick Walker",
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
"Todd Keck",
|
"aai",
|
||||||
"Briton Heilbrun",
|
"Briton Heilbrun",
|
||||||
"Tori",
|
"Tori",
|
||||||
"wildnut",
|
"wildnut",
|
||||||
@@ -153,12 +155,13 @@
|
|||||||
"JaxMax",
|
"JaxMax",
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
"Jwk0205",
|
"Jwk0205",
|
||||||
|
"Bro Xie",
|
||||||
"batblue",
|
"batblue",
|
||||||
"carey6409",
|
"carey6409",
|
||||||
"Olive",
|
"Olive",
|
||||||
|
"太郎 ゲーム",
|
||||||
"Some Guy Named Barry",
|
"Some Guy Named Barry",
|
||||||
"Cosmosis",
|
"Cosmosis",
|
||||||
"M Postkasse",
|
|
||||||
"AELOX",
|
"AELOX",
|
||||||
"Nicfit23",
|
"Nicfit23",
|
||||||
"FloPro4Sho",
|
"FloPro4Sho",
|
||||||
@@ -172,13 +175,13 @@
|
|||||||
"Serge Bekenkamp",
|
"Serge Bekenkamp",
|
||||||
"Jimmy Ledbetter",
|
"Jimmy Ledbetter",
|
||||||
"LeoZero",
|
"LeoZero",
|
||||||
|
"Antonio Pontes",
|
||||||
"ApathyJones",
|
"ApathyJones",
|
||||||
"Julian V",
|
"Julian V",
|
||||||
"Steven Owens",
|
"Steven Owens",
|
||||||
"nahinahi9",
|
"nahinahi9",
|
||||||
"Dustin Chen",
|
"Dustin Chen",
|
||||||
"dan",
|
"dan",
|
||||||
"aai",
|
|
||||||
"Mouthlessman",
|
"Mouthlessman",
|
||||||
"otaku fra",
|
"otaku fra",
|
||||||
"ViperC",
|
"ViperC",
|
||||||
@@ -199,15 +202,15 @@
|
|||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
"Ubivis",
|
"Ubivis",
|
||||||
"CloudValley",
|
"CloudValley",
|
||||||
|
"linnfrey",
|
||||||
"IamAyam",
|
"IamAyam",
|
||||||
|
"skaterb949",
|
||||||
"Joboshy",
|
"Joboshy",
|
||||||
"Bohemian Corporal",
|
"Bohemian Corporal",
|
||||||
"Dan",
|
"Dan",
|
||||||
"confiscated Zyra",
|
"confiscated Zyra",
|
||||||
"Bro Xie",
|
|
||||||
"yer fey",
|
"yer fey",
|
||||||
"Error_Rule34_Not_found",
|
"Error_Rule34_Not_found",
|
||||||
"太郎 ゲーム",
|
|
||||||
"Roslynd",
|
"Roslynd",
|
||||||
"Tee Gee",
|
"Tee Gee",
|
||||||
"jinxedx",
|
"jinxedx",
|
||||||
@@ -221,7 +224,7 @@
|
|||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"Pronredn",
|
"Pronredn",
|
||||||
"DougPeterson",
|
"DougPeterson",
|
||||||
"Antonio Pontes",
|
"Jeff",
|
||||||
"Bruce",
|
"Bruce",
|
||||||
"lh qwe",
|
"lh qwe",
|
||||||
"Kevin John Duck",
|
"Kevin John Duck",
|
||||||
@@ -249,19 +252,21 @@
|
|||||||
"地獄の禄",
|
"地獄の禄",
|
||||||
"MJG",
|
"MJG",
|
||||||
"David LaVallee",
|
"David LaVallee",
|
||||||
"linnfrey",
|
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"capn",
|
"capn",
|
||||||
"Joseph",
|
"Joseph",
|
||||||
"Mirko Katzula",
|
"Mirko Katzula",
|
||||||
|
"dan",
|
||||||
|
"Piccio08",
|
||||||
|
"kumakichi",
|
||||||
|
"cppbel",
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"Brian M",
|
"Brian M",
|
||||||
"Josef Lanzl",
|
"Josef Lanzl",
|
||||||
"Nerezza",
|
"Nerezza",
|
||||||
"sanborondon",
|
"sanborondon",
|
||||||
"Griffin Dahlberg",
|
|
||||||
"준희 김",
|
"준희 김",
|
||||||
"Taylor Funk",
|
"Taylor Funk",
|
||||||
"aezin",
|
"aezin",
|
||||||
@@ -278,10 +283,11 @@
|
|||||||
"Noora",
|
"Noora",
|
||||||
"Pierce McBride",
|
"Pierce McBride",
|
||||||
"Mattssn",
|
"Mattssn",
|
||||||
|
"Mikko Hemilä",
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
"a _",
|
"a _",
|
||||||
"Jeff",
|
|
||||||
"James Coleman",
|
"James Coleman",
|
||||||
|
"Martial",
|
||||||
"Emil Andersson",
|
"Emil Andersson",
|
||||||
"Ouro Boros",
|
"Ouro Boros",
|
||||||
"Chad Idk",
|
"Chad Idk",
|
||||||
@@ -302,10 +308,6 @@
|
|||||||
"Nick “Loadstone” D",
|
"Nick “Loadstone” D",
|
||||||
"Gamalonia",
|
"Gamalonia",
|
||||||
"momokai",
|
"momokai",
|
||||||
"dan",
|
|
||||||
"Piccio08",
|
|
||||||
"kumakichi",
|
|
||||||
"cppbel",
|
|
||||||
"starbugx",
|
"starbugx",
|
||||||
"Moon Knight",
|
"Moon Knight",
|
||||||
"몽타주",
|
"몽타주",
|
||||||
@@ -337,6 +339,7 @@
|
|||||||
"Andrew",
|
"Andrew",
|
||||||
"Robert Wegemund",
|
"Robert Wegemund",
|
||||||
"Littlehuggy",
|
"Littlehuggy",
|
||||||
|
"Gregory Kozhemiak",
|
||||||
"Draven T",
|
"Draven T",
|
||||||
"mrjuan",
|
"mrjuan",
|
||||||
"Brian Buie",
|
"Brian Buie",
|
||||||
@@ -350,7 +353,6 @@
|
|||||||
"Joshua Gray",
|
"Joshua Gray",
|
||||||
"Morgandel",
|
"Morgandel",
|
||||||
"Focuschannel",
|
"Focuschannel",
|
||||||
"Mikko Hemilä",
|
|
||||||
"Noah",
|
"Noah",
|
||||||
"Jacob McDaniel",
|
"Jacob McDaniel",
|
||||||
"X",
|
"X",
|
||||||
@@ -359,7 +361,6 @@
|
|||||||
"Artokun",
|
"Artokun",
|
||||||
"Michael Taylor",
|
"Michael Taylor",
|
||||||
"Derek Baker",
|
"Derek Baker",
|
||||||
"Martial",
|
|
||||||
"Anthony Faxlandez",
|
"Anthony Faxlandez",
|
||||||
"battu",
|
"battu",
|
||||||
"Michael Anthony Scott",
|
"Michael Anthony Scott",
|
||||||
@@ -367,8 +368,6 @@
|
|||||||
"Decx _",
|
"Decx _",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
"Thesharingbrother",
|
|
||||||
"ResidentDeviant",
|
|
||||||
"四糸凜音",
|
"四糸凜音",
|
||||||
"Nihongasuki",
|
"Nihongasuki",
|
||||||
"JC",
|
"JC",
|
||||||
@@ -412,11 +411,11 @@
|
|||||||
"Wolfe7D1",
|
"Wolfe7D1",
|
||||||
"blikkies",
|
"blikkies",
|
||||||
"Chris",
|
"Chris",
|
||||||
"Gregory Kozhemiak",
|
|
||||||
"elleshar666",
|
"elleshar666",
|
||||||
"Shock Shockor",
|
"Shock Shockor",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
"Goldwaters",
|
"Goldwaters",
|
||||||
|
"Kauffy",
|
||||||
"Zude",
|
"Zude",
|
||||||
"John J Linehan",
|
"John J Linehan",
|
||||||
"Kyler",
|
"Kyler",
|
||||||
@@ -426,19 +425,21 @@
|
|||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"aRtFuL_DodGeR",
|
"aRtFuL_DodGeR",
|
||||||
"Vane Holzer",
|
"Vane Holzer",
|
||||||
|
"psytrax",
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"notedfakes",
|
"notedfakes",
|
||||||
"DarkSunset",
|
|
||||||
"Nathan",
|
"Nathan",
|
||||||
"Billy Gladky",
|
"Billy Gladky",
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
"Michael Scott",
|
"Michael Scott",
|
||||||
"Probis",
|
"Probis",
|
||||||
"Ed Wang",
|
"Ed Wang",
|
||||||
|
"Wes Sims",
|
||||||
"ItsGeneralButtNaked",
|
"ItsGeneralButtNaked",
|
||||||
"SRDB",
|
"SRDB",
|
||||||
"g unit",
|
"g unit",
|
||||||
"Distortik",
|
"Distortik",
|
||||||
|
"Filippo Ferrari",
|
||||||
"Youguang",
|
"Youguang",
|
||||||
"Saya",
|
"Saya",
|
||||||
"andrewzpong",
|
"andrewzpong",
|
||||||
@@ -456,6 +457,7 @@
|
|||||||
"emadsultan",
|
"emadsultan",
|
||||||
"Pkrsky",
|
"Pkrsky",
|
||||||
"nanana",
|
"nanana",
|
||||||
|
"FeralOpticsAI",
|
||||||
"Pavlaki",
|
"Pavlaki",
|
||||||
"Doug+Rintoul",
|
"Doug+Rintoul",
|
||||||
"Noor",
|
"Noor",
|
||||||
@@ -483,7 +485,6 @@
|
|||||||
"Time Valentine",
|
"Time Valentine",
|
||||||
"Михал Михалыч",
|
"Михал Михалыч",
|
||||||
"Matt",
|
"Matt",
|
||||||
"Kauffy",
|
|
||||||
"Frogmilk",
|
"Frogmilk",
|
||||||
"SPJ",
|
"SPJ",
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
@@ -491,11 +492,11 @@
|
|||||||
"Nick Kage",
|
"Nick Kage",
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
"psytrax",
|
|
||||||
"Cyrus Fett",
|
"Cyrus Fett",
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
"SendingRavens",
|
"SendingRavens",
|
||||||
"Xenon Xue",
|
"Xenon Xue",
|
||||||
|
"JackJohnnyJim",
|
||||||
"Edward Ten Eyck",
|
"Edward Ten Eyck",
|
||||||
"Michael Docherty",
|
"Michael Docherty",
|
||||||
"Paul Hartsuyker",
|
"Paul Hartsuyker",
|
||||||
@@ -504,15 +505,14 @@
|
|||||||
"Solixer",
|
"Solixer",
|
||||||
"Jacob Winter",
|
"Jacob Winter",
|
||||||
"Ryan Presley Ng",
|
"Ryan Presley Ng",
|
||||||
"Wes Sims",
|
|
||||||
"jinksta187",
|
"jinksta187",
|
||||||
"Donor4115",
|
"Donor4115",
|
||||||
"Manu Thetug",
|
"Manu Thetug",
|
||||||
|
"Karlanx",
|
||||||
"Lyavph",
|
"Lyavph",
|
||||||
"David",
|
"David",
|
||||||
"Meilo",
|
"Meilo",
|
||||||
"operationancut",
|
"operationancut",
|
||||||
"Filippo Ferrari",
|
|
||||||
"shinonomeiro",
|
"shinonomeiro",
|
||||||
"Snille",
|
"Snille",
|
||||||
"MaartenAlbers",
|
"MaartenAlbers",
|
||||||
@@ -531,6 +531,8 @@
|
|||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"D",
|
"D",
|
||||||
|
"YassineKhaled",
|
||||||
|
"Y",
|
||||||
"MatteKey",
|
"MatteKey",
|
||||||
"Flob",
|
"Flob",
|
||||||
"ShiroSenpai",
|
"ShiroSenpai",
|
||||||
@@ -552,7 +554,6 @@
|
|||||||
"rsamerica",
|
"rsamerica",
|
||||||
"sfasdfasfdsa",
|
"sfasdfasfdsa",
|
||||||
"Alan+Cano",
|
"Alan+Cano",
|
||||||
"FeralOpticsAI",
|
|
||||||
"generic404",
|
"generic404",
|
||||||
"abattoirblues",
|
"abattoirblues",
|
||||||
"zounik",
|
"zounik",
|
||||||
@@ -584,7 +585,6 @@
|
|||||||
"Sauv",
|
"Sauv",
|
||||||
"Steven",
|
"Steven",
|
||||||
"CptNeo",
|
"CptNeo",
|
||||||
"JackJohnnyJim",
|
|
||||||
"TenaciousD",
|
"TenaciousD",
|
||||||
"Dmitry Ryzhov",
|
"Dmitry Ryzhov",
|
||||||
"Khánh Đặng",
|
"Khánh Đặng",
|
||||||
@@ -599,7 +599,6 @@
|
|||||||
"Andrew Wilkinson",
|
"Andrew Wilkinson",
|
||||||
"Yavizu3d",
|
"Yavizu3d",
|
||||||
"Maxim",
|
"Maxim",
|
||||||
"Karlanx",
|
|
||||||
"Yves Poezevara",
|
"Yves Poezevara",
|
||||||
"Teriak47",
|
"Teriak47",
|
||||||
"Just me",
|
"Just me",
|
||||||
@@ -637,6 +636,7 @@
|
|||||||
"Captain_Swag",
|
"Captain_Swag",
|
||||||
"obkircher",
|
"obkircher",
|
||||||
"gwyar",
|
"gwyar",
|
||||||
|
"ResidentDeviant",
|
||||||
"D",
|
"D",
|
||||||
"edgecase",
|
"edgecase",
|
||||||
"Neoxena",
|
"Neoxena",
|
||||||
@@ -681,8 +681,6 @@
|
|||||||
"low9",
|
"low9",
|
||||||
"Winged",
|
"Winged",
|
||||||
"you+halo9",
|
"you+halo9",
|
||||||
"YassineKhaled",
|
|
||||||
"YK12",
|
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Somebody",
|
"Somebody",
|
||||||
"Crescent~San",
|
"Crescent~San",
|
||||||
@@ -697,6 +695,7 @@
|
|||||||
"Coeur+de+cochon",
|
"Coeur+de+cochon",
|
||||||
"Obsidian.Studios",
|
"Obsidian.Studios",
|
||||||
"han b",
|
"han b",
|
||||||
|
"Zomba Mann",
|
||||||
"Nico",
|
"Nico",
|
||||||
"Maximilian Krischan",
|
"Maximilian Krischan",
|
||||||
"Banana Joe",
|
"Banana Joe",
|
||||||
@@ -714,7 +713,6 @@
|
|||||||
"Ronan Delevacq",
|
"Ronan Delevacq",
|
||||||
"karim ben brik",
|
"karim ben brik",
|
||||||
"Vinarus",
|
"Vinarus",
|
||||||
"james",
|
|
||||||
"Michael Zhu",
|
"Michael Zhu",
|
||||||
"Nemisu",
|
"Nemisu",
|
||||||
"Seraphy",
|
"Seraphy",
|
||||||
@@ -743,9 +741,11 @@
|
|||||||
"dsffsdfsdfsdfsdfsdf",
|
"dsffsdfsdfsdfsdfsdf",
|
||||||
"somethingtosay8",
|
"somethingtosay8",
|
||||||
"Jean-françois SEMA",
|
"Jean-françois SEMA",
|
||||||
|
"3zS4QNQ4",
|
||||||
"Terminuz",
|
"Terminuz",
|
||||||
"Kurt",
|
"Kurt",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
|
"Ivan Imes",
|
||||||
"Faburizu",
|
"Faburizu",
|
||||||
"Jack Lawfield",
|
"Jack Lawfield",
|
||||||
"jimyjomson",
|
"jimyjomson",
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||||
"copyAll": "Alle Syntax kopieren",
|
"copyAll": "Alle Syntax kopieren",
|
||||||
"refreshAll": "Alle Metadaten aktualisieren",
|
"refreshAll": "Alle Metadaten aktualisieren",
|
||||||
|
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||||
"moveAll": "Alle in Ordner verschieben",
|
"moveAll": "Alle in Ordner verschieben",
|
||||||
"autoOrganize": "Automatisch organisieren",
|
"autoOrganize": "Automatisch organisieren",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Keine Ordner gefunden",
|
"noFolders": "Keine Ordner gefunden",
|
||||||
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Auf Updates in diesem Ordner prüfen",
|
||||||
|
"loading": "Prüfe {type}-Updates in diesem Ordner...",
|
||||||
|
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
|
||||||
|
"none": "Alle {type}s in diesem Ordner sind aktuell",
|
||||||
|
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Dateiformat auswählen",
|
||||||
|
"files": "Dateien",
|
||||||
|
"select": "Datei auswählen"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||||
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||||
|
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
||||||
|
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
||||||
|
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "Set Content Rating for Selected",
|
"setContentRating": "Set Content Rating for Selected",
|
||||||
"copyAll": "Copy Selected Syntax",
|
"copyAll": "Copy Selected Syntax",
|
||||||
"refreshAll": "Refresh Selected Metadata",
|
"refreshAll": "Refresh Selected Metadata",
|
||||||
|
"repairMetadata": "Repair Metadata for Selected",
|
||||||
"checkUpdates": "Check Updates for Selected",
|
"checkUpdates": "Check Updates for Selected",
|
||||||
"moveAll": "Move Selected to Folder",
|
"moveAll": "Move Selected to Folder",
|
||||||
"autoOrganize": "Auto-Organize Selected",
|
"autoOrganize": "Auto-Organize Selected",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "No folders found",
|
"noFolders": "No folders found",
|
||||||
"dragHint": "Drag items here to create folders"
|
"dragHint": "Drag items here to create folders"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Check for updates in this folder",
|
||||||
|
"loading": "Checking {type} updates for this folder...",
|
||||||
|
"success": "Found {count} update(s) for {type}s in this folder",
|
||||||
|
"none": "All {type}s in this folder are up to date",
|
||||||
|
"error": "Failed to check folder for {type} updates: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||||
"alreadyInLibrary": "Already in Library",
|
"alreadyInLibrary": "Already in Library",
|
||||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Select File Format",
|
||||||
|
"files": "files",
|
||||||
|
"select": "Select File"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Invalid Civitai URL format",
|
"invalidUrl": "Invalid Civitai URL format",
|
||||||
"noVersions": "No versions available for this model"
|
"noVersions": "No versions available for this model"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No recipes selected",
|
"noRecipesSelected": "No recipes selected",
|
||||||
|
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
||||||
|
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
||||||
|
"repairBulkFailed": "Failed to repair selected recipes: {message}",
|
||||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||||
"copyAll": "Copiar toda la sintaxis",
|
"copyAll": "Copiar toda la sintaxis",
|
||||||
"refreshAll": "Actualizar todos los metadatos",
|
"refreshAll": "Actualizar todos los metadatos",
|
||||||
|
"repairMetadata": "Reparar metadatos de la selección",
|
||||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||||
"moveAll": "Mover todos a carpeta",
|
"moveAll": "Mover todos a carpeta",
|
||||||
"autoOrganize": "Auto-organizar seleccionados",
|
"autoOrganize": "Auto-organizar seleccionados",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "No se encontraron carpetas",
|
"noFolders": "No se encontraron carpetas",
|
||||||
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
"dragHint": "Arrastra elementos aquí para crear carpetas"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Buscar actualizaciones en esta carpeta",
|
||||||
|
"loading": "Buscando actualizaciones de {type} en esta carpeta...",
|
||||||
|
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
|
||||||
|
"none": "Todos los {type}s en esta carpeta están actualizados",
|
||||||
|
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||||
"alreadyInLibrary": "Ya en la biblioteca",
|
"alreadyInLibrary": "Ya en la biblioteca",
|
||||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Seleccionar formato de archivo",
|
||||||
|
"files": "archivos",
|
||||||
|
"select": "Seleccionar archivo"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||||
"noVersions": "No hay versiones disponibles para este modelo"
|
"noVersions": "No hay versiones disponibles para este modelo"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No se han seleccionado recetas",
|
"noRecipesSelected": "No se han seleccionado recetas",
|
||||||
|
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
||||||
|
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
||||||
|
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
|
||||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "Définir la classification du contenu pour tous",
|
"setContentRating": "Définir la classification du contenu pour tous",
|
||||||
"copyAll": "Copier toute la syntaxe",
|
"copyAll": "Copier toute la syntaxe",
|
||||||
"refreshAll": "Actualiser toutes les métadonnées",
|
"refreshAll": "Actualiser toutes les métadonnées",
|
||||||
|
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||||
"moveAll": "Déplacer tout vers un dossier",
|
"moveAll": "Déplacer tout vers un dossier",
|
||||||
"autoOrganize": "Auto-organiser la sélection",
|
"autoOrganize": "Auto-organiser la sélection",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Aucun dossier trouvé",
|
"noFolders": "Aucun dossier trouvé",
|
||||||
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Vérifier les mises à jour dans ce dossier",
|
||||||
|
"loading": "Vérification des mises à jour {type} dans ce dossier...",
|
||||||
|
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
|
||||||
|
"none": "Tous les {type}s dans ce dossier sont à jour",
|
||||||
|
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Choisir le format de fichier",
|
||||||
|
"files": "fichiers",
|
||||||
|
"select": "Choisir le fichier"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Format d'URL Civitai invalide",
|
"invalidUrl": "Format d'URL Civitai invalide",
|
||||||
"noVersions": "Aucune version disponible pour ce modèle"
|
"noVersions": "Aucune version disponible pour ce modèle"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Aucune recette sélectionnée",
|
"noRecipesSelected": "Aucune recette sélectionnée",
|
||||||
|
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
|
||||||
|
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
|
||||||
|
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
|
||||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||||
"copyAll": "העתק את כל התחבירים",
|
"copyAll": "העתק את כל התחבירים",
|
||||||
"refreshAll": "רענן את כל המטא-דאטה",
|
"refreshAll": "רענן את כל המטא-דאטה",
|
||||||
|
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||||
"moveAll": "העבר הכל לתיקייה",
|
"moveAll": "העבר הכל לתיקייה",
|
||||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "לא נמצאו תיקיות",
|
"noFolders": "לא נמצאו תיקיות",
|
||||||
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "בדוק עדכונים בתיקייה זו",
|
||||||
|
"loading": "בודק עדכוני {type} בתיקייה זו...",
|
||||||
|
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
|
||||||
|
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
|
||||||
|
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||||
"alreadyInLibrary": "כבר בספרייה",
|
"alreadyInLibrary": "כבר בספרייה",
|
||||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "בחר פורמט קובץ",
|
||||||
|
"files": "קבצים",
|
||||||
|
"select": "בחר קובץ"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||||
"noVersions": "אין גרסאות זמינות למודל זה"
|
"noVersions": "אין גרסאות זמינות למודל זה"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||||
|
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||||
|
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||||
|
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||||
"copyAll": "すべての構文をコピー",
|
"copyAll": "すべての構文をコピー",
|
||||||
"refreshAll": "すべてのメタデータを更新",
|
"refreshAll": "すべてのメタデータを更新",
|
||||||
|
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||||
"checkUpdates": "選択項目の更新を確認",
|
"checkUpdates": "選択項目の更新を確認",
|
||||||
"moveAll": "すべてをフォルダに移動",
|
"moveAll": "すべてをフォルダに移動",
|
||||||
"autoOrganize": "自動整理を実行",
|
"autoOrganize": "自動整理を実行",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "フォルダが見つかりません",
|
"noFolders": "フォルダが見つかりません",
|
||||||
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "このフォルダのアップデートを確認",
|
||||||
|
"loading": "このフォルダの{type}アップデートを確認中...",
|
||||||
|
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
|
||||||
|
"none": "このフォルダのすべての{type}sは最新です",
|
||||||
|
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||||
"alreadyInLibrary": "既にライブラリ内",
|
"alreadyInLibrary": "既にライブラリ内",
|
||||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "ファイル形式を選択",
|
||||||
|
"files": "ファイル",
|
||||||
|
"select": "ファイルを選択"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "無効なCivitai URL形式",
|
"invalidUrl": "無効なCivitai URL形式",
|
||||||
"noVersions": "このモデルの利用可能なバージョンがありません"
|
"noVersions": "このモデルの利用可能なバージョンがありません"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "レシピが選択されていません",
|
"noRecipesSelected": "レシピが選択されていません",
|
||||||
|
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||||
|
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||||
|
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||||
"copyAll": "모든 문법 복사",
|
"copyAll": "모든 문법 복사",
|
||||||
"refreshAll": "모든 메타데이터 새로고침",
|
"refreshAll": "모든 메타데이터 새로고침",
|
||||||
|
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||||
"checkUpdates": "선택 항목 업데이트 확인",
|
"checkUpdates": "선택 항목 업데이트 확인",
|
||||||
"moveAll": "모두 폴더로 이동",
|
"moveAll": "모두 폴더로 이동",
|
||||||
"autoOrganize": "자동 정리 선택",
|
"autoOrganize": "자동 정리 선택",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "폴더를 찾을 수 없습니다",
|
"noFolders": "폴더를 찾을 수 없습니다",
|
||||||
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "이 폴더의 업데이트 확인",
|
||||||
|
"loading": "이 폴더의 {type} 업데이트를 확인하는 중...",
|
||||||
|
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
|
||||||
|
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
|
||||||
|
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "파일 형식 선택",
|
||||||
|
"files": "개 파일",
|
||||||
|
"select": "파일 선택"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||||
|
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||||
|
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||||
|
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "Установить рейтинг контента для всех",
|
"setContentRating": "Установить рейтинг контента для всех",
|
||||||
"copyAll": "Копировать весь синтаксис",
|
"copyAll": "Копировать весь синтаксис",
|
||||||
"refreshAll": "Обновить все метаданные",
|
"refreshAll": "Обновить все метаданные",
|
||||||
|
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||||
"checkUpdates": "Проверить обновления для выбранных",
|
"checkUpdates": "Проверить обновления для выбранных",
|
||||||
"moveAll": "Переместить все в папку",
|
"moveAll": "Переместить все в папку",
|
||||||
"autoOrganize": "Автоматически организовать выбранные",
|
"autoOrganize": "Автоматически организовать выбранные",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "Папки не найдены",
|
"noFolders": "Папки не найдены",
|
||||||
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "Проверить обновления в этой папке",
|
||||||
|
"loading": "Проверка обновлений {type} в этой папке...",
|
||||||
|
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
|
||||||
|
"none": "Все {type}s в этой папке актуальны",
|
||||||
|
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||||
"alreadyInLibrary": "Уже в библиотеке",
|
"alreadyInLibrary": "Уже в библиотеке",
|
||||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "Выбрать формат файла",
|
||||||
|
"files": "файлов",
|
||||||
|
"select": "Выбрать файл"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Неверный формат URL Civitai",
|
"invalidUrl": "Неверный формат URL Civitai",
|
||||||
"noVersions": "Нет доступных версий для этой модели"
|
"noVersions": "Нет доступных версий для этой модели"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "Рецепты не выбраны",
|
"noRecipesSelected": "Рецепты не выбраны",
|
||||||
|
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||||
|
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||||
|
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "为所选中设置内容评级",
|
"setContentRating": "为所选中设置内容评级",
|
||||||
"copyAll": "复制所选中语法",
|
"copyAll": "复制所选中语法",
|
||||||
"refreshAll": "刷新所选中元数据",
|
"refreshAll": "刷新所选中元数据",
|
||||||
|
"repairMetadata": "修复所选中元数据",
|
||||||
"checkUpdates": "检查所选更新",
|
"checkUpdates": "检查所选更新",
|
||||||
"moveAll": "移动所选中到文件夹",
|
"moveAll": "移动所选中到文件夹",
|
||||||
"autoOrganize": "自动整理所选模型",
|
"autoOrganize": "自动整理所选模型",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "未找到文件夹",
|
"noFolders": "未找到文件夹",
|
||||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "检查此文件夹的更新",
|
||||||
|
"loading": "正在检查此文件夹中的{type}更新...",
|
||||||
|
"success": "在此文件夹中找到 {count} 个{type}更新",
|
||||||
|
"none": "此文件夹中的所有{type}都是最新版本",
|
||||||
|
"error": "检查文件夹{type}更新失败: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||||
"alreadyInLibrary": "已存在于库中",
|
"alreadyInLibrary": "已存在于库中",
|
||||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "选择文件格式",
|
||||||
|
"files": "个文件",
|
||||||
|
"select": "选择文件"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "无效的 Civitai URL 格式",
|
"invalidUrl": "无效的 Civitai URL 格式",
|
||||||
"noVersions": "此模型没有可用版本"
|
"noVersions": "此模型没有可用版本"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||||
"noRecipesSelected": "未选择任何配方",
|
"noRecipesSelected": "未选择任何配方",
|
||||||
|
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||||
|
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||||
|
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -689,6 +689,7 @@
|
|||||||
"setContentRating": "為全部設定內容分級",
|
"setContentRating": "為全部設定內容分級",
|
||||||
"copyAll": "複製全部語法",
|
"copyAll": "複製全部語法",
|
||||||
"refreshAll": "刷新全部 metadata",
|
"refreshAll": "刷新全部 metadata",
|
||||||
|
"repairMetadata": "修復所選中元數據",
|
||||||
"checkUpdates": "檢查所選更新",
|
"checkUpdates": "檢查所選更新",
|
||||||
"moveAll": "全部移動到資料夾",
|
"moveAll": "全部移動到資料夾",
|
||||||
"autoOrganize": "自動整理所選模型",
|
"autoOrganize": "自動整理所選模型",
|
||||||
@@ -962,6 +963,13 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"noFolders": "未找到資料夾",
|
"noFolders": "未找到資料夾",
|
||||||
"dragHint": "將項目拖到此處以建立資料夾"
|
"dragHint": "將項目拖到此處以建立資料夾"
|
||||||
|
},
|
||||||
|
"folderUpdateCheck": {
|
||||||
|
"label": "檢查此資料夾的更新",
|
||||||
|
"loading": "正在檢查此資料夾中的{type}更新...",
|
||||||
|
"success": "在此資料夾中找到 {count} 個{type}更新",
|
||||||
|
"none": "此資料夾中的所有{type}都是最新版本",
|
||||||
|
"error": "檢查資料夾{type}更新失敗: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1030,6 +1038,11 @@
|
|||||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||||
"alreadyInLibrary": "已在庫存",
|
"alreadyInLibrary": "已在庫存",
|
||||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||||
|
"fileSelection": {
|
||||||
|
"title": "選擇檔案格式",
|
||||||
|
"files": "個檔案",
|
||||||
|
"select": "選擇檔案"
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Civitai 網址格式無效",
|
"invalidUrl": "Civitai 網址格式無效",
|
||||||
"noVersions": "此模型無可用版本"
|
"noVersions": "此模型無可用版本"
|
||||||
@@ -1693,6 +1706,9 @@
|
|||||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||||
"noRecipesSelected": "未選取任何食譜",
|
"noRecipesSelected": "未選取任何食譜",
|
||||||
|
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||||
|
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||||
|
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import re
|
|||||||
from typing import Dict, List, Any, Optional, Tuple
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.constants import VALID_LORA_TYPES
|
from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||||
from ..utils.civitai_utils import rewrite_preview_url
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -173,6 +173,20 @@ class RecipeMetadataParser(ABC):
|
|||||||
checkpoint['isDeleted'] = True
|
checkpoint['isDeleted'] = True
|
||||||
return checkpoint
|
return checkpoint
|
||||||
|
|
||||||
|
# Validate that the model type is actually a checkpoint.
|
||||||
|
# Unlike populate_lora_from_civitai which has this check,
|
||||||
|
# this function was missing type validation — allowing LoRA
|
||||||
|
# version data to be saved as the recipe's checkpoint when the
|
||||||
|
# wrong version ID was passed downstream (fixed in v2.7+).
|
||||||
|
model_type = civitai_data.get('model', {}).get('type', '').lower()
|
||||||
|
if model_type not in VALID_CHECKPOINT_SUB_TYPES:
|
||||||
|
logger.warning(
|
||||||
|
f"Cannot populate checkpoint: model version {civitai_data.get('id')} "
|
||||||
|
f"has type '{model_type}', expected one of {VALID_CHECKPOINT_SUB_TYPES}. "
|
||||||
|
f"Skipping checkpoint enrichment."
|
||||||
|
)
|
||||||
|
return checkpoint
|
||||||
|
|
||||||
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||||
checkpoint['name'] = civitai_data['model']['name']
|
checkpoint['name'] = civitai_data['model']['name']
|
||||||
|
|
||||||
|
|||||||
@@ -190,26 +190,41 @@ class RecipeEnricher:
|
|||||||
existing_cp = recipe.get("checkpoint")
|
existing_cp = recipe.get("checkpoint")
|
||||||
if existing_cp is None:
|
if existing_cp is None:
|
||||||
existing_cp = {}
|
existing_cp = {}
|
||||||
|
|
||||||
|
# Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
|
||||||
|
# (populate may reject non-checkpoint types and lose this data)
|
||||||
|
base_model_from_civitai: str = ""
|
||||||
|
if isinstance(civitai_info, dict):
|
||||||
|
base_model_from_civitai = civitai_info.get("baseModel", "") or ""
|
||||||
|
elif isinstance(civitai_info, tuple) and len(civitai_info) > 0 and isinstance(civitai_info[0], dict):
|
||||||
|
base_model_from_civitai = civitai_info[0].get("baseModel", "") or ""
|
||||||
|
|
||||||
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
|
||||||
# 1. First, resolve base_model using full data before we format it away
|
|
||||||
|
# 1. Resolve base_model from checkpoint_data first, then fall back to raw civitai_info
|
||||||
current_base_model = recipe.get("base_model")
|
current_base_model = recipe.get("base_model")
|
||||||
resolved_base_model = checkpoint_data.get("baseModel")
|
resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
|
||||||
if resolved_base_model:
|
if resolved_base_model:
|
||||||
# Update if empty OR if it matches our generic prefix but is less specific
|
|
||||||
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
|
||||||
if is_generic and resolved_base_model != current_base_model:
|
if is_generic and resolved_base_model != current_base_model:
|
||||||
recipe["base_model"] = resolved_base_model
|
recipe["base_model"] = resolved_base_model
|
||||||
|
|
||||||
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
|
# 2. Only format and save checkpoint if it has real data (not just type after type rejection)
|
||||||
formatted_checkpoint = {
|
has_checkpoint_data = any([
|
||||||
"type": "checkpoint",
|
checkpoint_data.get("modelId"),
|
||||||
"modelId": checkpoint_data.get("modelId"),
|
checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
checkpoint_data.get("name"),
|
||||||
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
|
checkpoint_data.get("version"),
|
||||||
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
|
])
|
||||||
}
|
if has_checkpoint_data:
|
||||||
# Remove None values
|
formatted_checkpoint = {
|
||||||
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
"type": "checkpoint",
|
||||||
|
"modelId": checkpoint_data.get("modelId"),
|
||||||
|
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
|
||||||
|
"modelName": checkpoint_data.get("name"),
|
||||||
|
"modelVersionName": checkpoint_data.get("version"),
|
||||||
|
}
|
||||||
|
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
|
||||||
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -185,8 +185,67 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Process standard resources array
|
# Process standard resources array
|
||||||
if "resources" in metadata and isinstance(metadata["resources"], list):
|
if "resources" in metadata and isinstance(metadata["resources"], list):
|
||||||
for resource in metadata["resources"]:
|
for resource in metadata["resources"]:
|
||||||
|
resource_type = resource.get("type", "lora")
|
||||||
|
|
||||||
|
# Track resources with type "model" — these are checkpoint models.
|
||||||
|
# The resources array is the most reliable source for checkpoint
|
||||||
|
# identification because it has an explicit type field and hash,
|
||||||
|
# unlike modelVersionIds which is a flat list with no type info.
|
||||||
|
if resource_type == "model":
|
||||||
|
checkpoint_entry = {
|
||||||
|
"id": 0,
|
||||||
|
"modelId": 0,
|
||||||
|
"name": resource.get("name", "Unknown Model"),
|
||||||
|
"version": "",
|
||||||
|
"type": resource.get("type", "model"),
|
||||||
|
"existsLocally": False,
|
||||||
|
"localPath": None,
|
||||||
|
"file_name": resource.get("name", ""),
|
||||||
|
"hash": resource.get("hash", "") or "",
|
||||||
|
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||||
|
"baseModel": "",
|
||||||
|
"size": 0,
|
||||||
|
"downloadUrl": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to look up base model from the checkpoint hash
|
||||||
|
if checkpoint_entry["hash"] and metadata_provider:
|
||||||
|
try:
|
||||||
|
civitai_info = (
|
||||||
|
await metadata_provider.get_model_by_hash(
|
||||||
|
checkpoint_entry["hash"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
civitai_data, error_msg = (
|
||||||
|
(civitai_info, None)
|
||||||
|
if not isinstance(civitai_info, tuple)
|
||||||
|
else civitai_info
|
||||||
|
)
|
||||||
|
if civitai_data and error_msg != "Model not found":
|
||||||
|
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||||
|
checkpoint_entry['name'] = civitai_data['model']['name']
|
||||||
|
checkpoint_entry['id'] = civitai_data.get('id', 0)
|
||||||
|
checkpoint_entry['modelId'] = civitai_data.get('modelId', 0)
|
||||||
|
if 'name' in civitai_data:
|
||||||
|
checkpoint_entry['version'] = civitai_data['name']
|
||||||
|
base_model = civitai_data.get('baseModel', '')
|
||||||
|
if base_model:
|
||||||
|
checkpoint_entry['baseModel'] = base_model
|
||||||
|
if not result['base_model']:
|
||||||
|
result['base_model'] = base_model
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching checkpoint info for hash "
|
||||||
|
f"{checkpoint_entry['hash']}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["model"] is None:
|
||||||
|
result["model"] = checkpoint_entry
|
||||||
|
continue
|
||||||
|
|
||||||
# Modified to process resources without a type field as potential LoRAs
|
# Modified to process resources without a type field as potential LoRAs
|
||||||
if resource.get("type", "lora") == "lora":
|
if resource_type == "lora":
|
||||||
lora_hash = resource.get("hash", "")
|
lora_hash = resource.get("hash", "")
|
||||||
|
|
||||||
# Try to get hash from the hashes field if not present in resource
|
# Try to get hash from the hashes field if not present in resource
|
||||||
|
|||||||
@@ -1960,6 +1960,10 @@ class ModelUpdateHandler:
|
|||||||
if target_model_ids:
|
if target_model_ids:
|
||||||
target_model_ids = sorted(set(target_model_ids))
|
target_model_ids = sorted(set(target_model_ids))
|
||||||
|
|
||||||
|
folder_path: Optional[str] = payload.get("folder_path")
|
||||||
|
if folder_path is not None and not isinstance(folder_path, str):
|
||||||
|
folder_path = None
|
||||||
|
|
||||||
provider = await self._get_civitai_provider()
|
provider = await self._get_civitai_provider()
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1974,6 +1978,7 @@ class ModelUpdateHandler:
|
|||||||
provider,
|
provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
target_model_ids=target_model_ids or None,
|
target_model_ids=target_model_ids or None,
|
||||||
|
folder_path=folder_path,
|
||||||
)
|
)
|
||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class RecipeHandlerSet:
|
|||||||
"repair_recipes": self.management.repair_recipes,
|
"repair_recipes": self.management.repair_recipes,
|
||||||
"cancel_repair": self.management.cancel_repair,
|
"cancel_repair": self.management.cancel_repair,
|
||||||
"repair_recipe": self.management.repair_recipe,
|
"repair_recipe": self.management.repair_recipe,
|
||||||
|
"repair_recipes_bulk": self.management.repair_recipes_bulk,
|
||||||
"get_repair_progress": self.management.get_repair_progress,
|
"get_repair_progress": self.management.get_repair_progress,
|
||||||
"start_batch_import": self.batch_import.start_batch_import,
|
"start_batch_import": self.batch_import.start_batch_import,
|
||||||
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
||||||
@@ -460,7 +461,11 @@ class RecipeQueryHandler:
|
|||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
self._logger.info("Manually triggering recipe cache rebuild")
|
full_rebuild = request.query.get("full_rebuild", "true").lower() == "true"
|
||||||
|
self._logger.info(
|
||||||
|
"Manually triggering recipe cache %s",
|
||||||
|
"full rebuild" if full_rebuild else "refresh",
|
||||||
|
)
|
||||||
await recipe_scanner.get_cached_data(force_refresh=True)
|
await recipe_scanner.get_cached_data(force_refresh=True)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "message": "Recipe cache refreshed successfully"}
|
{"success": True, "message": "Recipe cache refreshed successfully"}
|
||||||
@@ -706,6 +711,69 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def repair_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||||
|
"""Bulk repair metadata for multiple recipes by their IDs.
|
||||||
|
|
||||||
|
Accepts a JSON body with a "recipe_ids" array and iterates
|
||||||
|
repair_recipe_by_id over each entry, collecting statistics.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Recipe scanner unavailable"},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_ids = data.get("recipe_ids", [])
|
||||||
|
if not recipe_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_ids are required"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = len(recipe_ids)
|
||||||
|
repaired = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = 0
|
||||||
|
recipes = []
|
||||||
|
|
||||||
|
for recipe_id in recipe_ids:
|
||||||
|
try:
|
||||||
|
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||||
|
if result.get("success"):
|
||||||
|
repaired += result.get("repaired", 0)
|
||||||
|
skipped += result.get("skipped", 0)
|
||||||
|
if result.get("recipe"):
|
||||||
|
recipes.append(result["recipe"])
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
except RecipeNotFoundError:
|
||||||
|
skipped += 1
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error repairing recipe %s: %s", recipe_id, exc
|
||||||
|
)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"total": total,
|
||||||
|
"repaired": repaired,
|
||||||
|
"skipped": skipped,
|
||||||
|
"errors": errors,
|
||||||
|
"recipes": recipes,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error performing bulk repair: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
async def repair_recipe(self, request: web.Request) -> web.Response:
|
async def repair_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -911,6 +979,9 @@ class RecipeManagementHandler:
|
|||||||
civitai_model = civitai_parsed.get("model")
|
civitai_model = civitai_parsed.get("model")
|
||||||
if civitai_model and not metadata.get("checkpoint"):
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
metadata["checkpoint"] = civitai_model
|
metadata["checkpoint"] = civitai_model
|
||||||
|
civitai_base_model = civitai_parsed.get("base_model")
|
||||||
|
if civitai_base_model and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = civitai_base_model
|
||||||
elif parsed_embedded:
|
elif parsed_embedded:
|
||||||
parsed_loras = parsed_embedded.get("loras")
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
if parsed_loras and not metadata.get("loras"):
|
if parsed_loras and not metadata.get("loras"):
|
||||||
@@ -918,6 +989,8 @@ class RecipeManagementHandler:
|
|||||||
parsed_model = parsed_embedded.get("model")
|
parsed_model = parsed_embedded.get("model")
|
||||||
if parsed_model and not metadata.get("checkpoint"):
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
metadata["checkpoint"] = parsed_model
|
metadata["checkpoint"] = parsed_model
|
||||||
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = parsed_embedded["base_model"]
|
||||||
|
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
await RecipeEnricher.enrich_recipe(
|
await RecipeEnricher.enrich_recipe(
|
||||||
@@ -1293,11 +1366,18 @@ class RecipeManagementHandler:
|
|||||||
image_info.get("meta") if civitai_image_id and image_info else None
|
image_info.get("meta") if civitai_image_id and image_info else None
|
||||||
)
|
)
|
||||||
if civitai_image_id and image_info:
|
if civitai_image_id and image_info:
|
||||||
|
# modelVersionId (singular) — the primary version for this
|
||||||
|
# image on CivitAI. May be absent, or may *not* be the
|
||||||
|
# checkpoint (e.g. when the image was generated with a LoRA
|
||||||
|
# as the primary subject). When absent, DO NOT fall back to
|
||||||
|
# modelVersionIds[0] — that array mixes checkpoints, LoRAs,
|
||||||
|
# and other model version IDs without ordering guarantees.
|
||||||
|
# The downstream enrichment flow will find the real
|
||||||
|
# checkpoint via meta.resources (type:"model" hash) or
|
||||||
|
# meta.civitaiResources (type:"checkpoint" version ID), so
|
||||||
|
# leaving model_ver_id as None is safe and avoids the bug
|
||||||
|
# where a LoRA version ID was treated as the checkpoint.
|
||||||
model_ver_id = image_info.get("modelVersionId")
|
model_ver_id = image_info.get("modelVersionId")
|
||||||
if not model_ver_id:
|
|
||||||
ids = image_info.get("modelVersionIds")
|
|
||||||
if isinstance(ids, list) and ids:
|
|
||||||
model_ver_id = ids[0]
|
|
||||||
|
|
||||||
# Inject root-level modelVersionIds into meta so downstream
|
# Inject root-level modelVersionIds into meta so downstream
|
||||||
# parsers (CivitaiApiMetadataParser) can discover ALL resources
|
# parsers (CivitaiApiMetadataParser) can discover ALL resources
|
||||||
@@ -1418,25 +1498,28 @@ class RecipeManagementHandler:
|
|||||||
if not image_url:
|
if not image_url:
|
||||||
raise RecipeValidationError("Missing required field: image_url")
|
raise RecipeValidationError("Missing required field: image_url")
|
||||||
|
|
||||||
|
force = request.query.get("force", "false").lower() == "true"
|
||||||
|
|
||||||
image_id = extract_civitai_image_id(image_url)
|
image_id = extract_civitai_image_id(image_url)
|
||||||
if not image_id:
|
if not image_id:
|
||||||
raise RecipeValidationError(
|
raise RecipeValidationError(
|
||||||
"Could not extract Civitai image ID from URL"
|
"Could not extract Civitai image ID from URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for duplicate (fast, before acquiring semaphore)
|
# Check for duplicate (fast, before acquiring semaphore), unless force
|
||||||
cache = await recipe_scanner.get_cached_data()
|
if not force:
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
cache = await recipe_scanner.get_cached_data()
|
||||||
source = recipe.get("source_path")
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
if source:
|
source = recipe.get("source_path")
|
||||||
existing_id = extract_civitai_image_id(source)
|
if source:
|
||||||
if existing_id == image_id:
|
existing_id = extract_civitai_image_id(source)
|
||||||
return web.json_response({
|
if existing_id == image_id:
|
||||||
"success": True,
|
return web.json_response({
|
||||||
"recipe_id": recipe.get("id"),
|
"success": True,
|
||||||
"name": recipe.get("title", ""),
|
"recipe_id": recipe.get("id"),
|
||||||
"already_exists": True,
|
"name": recipe.get("title", ""),
|
||||||
})
|
"already_exists": True,
|
||||||
|
})
|
||||||
|
|
||||||
async with self._import_semaphore:
|
async with self._import_semaphore:
|
||||||
return await self._do_import_from_url(image_url, recipe_scanner)
|
return await self._do_import_from_url(image_url, recipe_scanner)
|
||||||
@@ -1542,6 +1625,9 @@ class RecipeManagementHandler:
|
|||||||
civitai_model = civitai_parsed.get("model")
|
civitai_model = civitai_parsed.get("model")
|
||||||
if civitai_model and not metadata.get("checkpoint"):
|
if civitai_model and not metadata.get("checkpoint"):
|
||||||
metadata["checkpoint"] = civitai_model
|
metadata["checkpoint"] = civitai_model
|
||||||
|
civitai_base_model = civitai_parsed.get("base_model")
|
||||||
|
if civitai_base_model and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = civitai_base_model
|
||||||
elif parsed_embedded:
|
elif parsed_embedded:
|
||||||
parsed_loras = parsed_embedded.get("loras")
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
if parsed_loras and not metadata.get("loras"):
|
if parsed_loras and not metadata.get("loras"):
|
||||||
@@ -1549,6 +1635,8 @@ class RecipeManagementHandler:
|
|||||||
parsed_model = parsed_embedded.get("model")
|
parsed_model = parsed_embedded.get("model")
|
||||||
if parsed_model and not metadata.get("checkpoint"):
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
metadata["checkpoint"] = parsed_model
|
metadata["checkpoint"] = parsed_model
|
||||||
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||||
|
metadata["base_model"] = parsed_embedded["base_model"]
|
||||||
|
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
await RecipeEnricher.enrich_recipe(
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Dict, List
|
|||||||
|
|
||||||
from ..utils.settings_paths import ensure_settings_file
|
from ..utils.settings_paths import ensure_settings_file
|
||||||
from ..services.downloader import get_downloader
|
from ..services.downloader import get_downloader
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -212,8 +213,19 @@ class UpdateRoutes:
|
|||||||
|
|
||||||
zip_path = tmp_zip_path
|
zip_path = tmp_zip_path
|
||||||
|
|
||||||
# Skip both settings.json, civitai and model cache folder
|
# Close the downloaded-versions SQLite connection before cleaning,
|
||||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
# so that shutil.rmtree() does not fail on Windows (the process
|
||||||
|
# cannot delete a file with an outstanding open handle).
|
||||||
|
try:
|
||||||
|
history_svc = ServiceRegistry._services.get("downloaded_version_history_service")
|
||||||
|
if history_svc is not None:
|
||||||
|
history_svc.close()
|
||||||
|
logger.info("Closed downloaded-version history database connection")
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not close downloaded-version history database", exc_info=True)
|
||||||
|
|
||||||
|
# Skip settings.json, civitai, model cache and runtime cache folders
|
||||||
|
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups'])
|
||||||
|
|
||||||
# Extract ZIP to temp dir
|
# Extract ZIP to temp dir
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -222,16 +234,17 @@ class UpdateRoutes:
|
|||||||
# Find extracted folder (GitHub ZIP contains a root folder)
|
# Find extracted folder (GitHub ZIP contains a root folder)
|
||||||
extracted_root = next(os.scandir(tmp_dir)).path
|
extracted_root = next(os.scandir(tmp_dir)).path
|
||||||
|
|
||||||
# Copy files, skipping settings.json and civitai folder
|
# Copy files, skipping user data that should be preserved
|
||||||
|
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
|
||||||
for item in os.listdir(extracted_root):
|
for item in os.listdir(extracted_root):
|
||||||
if item == 'settings.json' or item == 'civitai':
|
if item in skip_items:
|
||||||
continue
|
continue
|
||||||
src = os.path.join(extracted_root, item)
|
src = os.path.join(extracted_root, item)
|
||||||
dst = os.path.join(plugin_root, item)
|
dst = os.path.join(plugin_root, item)
|
||||||
if os.path.isdir(src):
|
if os.path.isdir(src):
|
||||||
if os.path.exists(dst):
|
if os.path.exists(dst):
|
||||||
shutil.rmtree(dst)
|
shutil.rmtree(dst)
|
||||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai'))
|
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(*skip_items))
|
||||||
else:
|
else:
|
||||||
shutil.copy2(src, dst)
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
@@ -239,15 +252,17 @@ class UpdateRoutes:
|
|||||||
# for ComfyUI Manager to work properly
|
# for ComfyUI Manager to work properly
|
||||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||||
tracking_files = []
|
tracking_files = []
|
||||||
|
skip_tracked = {'civitai', 'wildcards', 'backups'}
|
||||||
for root, dirs, files in os.walk(extracted_root):
|
for root, dirs, files in os.walk(extracted_root):
|
||||||
# Skip civitai folder and its contents
|
# Skip user data directories and their contents
|
||||||
rel_root = os.path.relpath(root, extracted_root)
|
rel_root = os.path.relpath(root, extracted_root)
|
||||||
if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep):
|
top_dir = rel_root.split(os.sep)[0] if rel_root != '.' else ''
|
||||||
|
if top_dir in skip_tracked:
|
||||||
continue
|
continue
|
||||||
for file in files:
|
for file in files:
|
||||||
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
||||||
# Skip settings.json and any file under civitai
|
# Skip settings.json and any file under user data dirs
|
||||||
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep):
|
if rel_path == 'settings.json' or rel_path.split(os.sep)[0] in skip_tracked:
|
||||||
continue
|
continue
|
||||||
tracking_files.append(rel_path.replace("\\", "/"))
|
tracking_files.append(rel_path.replace("\\", "/"))
|
||||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||||
|
|||||||
@@ -14,12 +14,30 @@ from typing import Any, Dict, Optional, Tuple
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from .downloader import DownloadProgress, get_downloader
|
from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
|
||||||
from .aria2_transfer_state import Aria2TransferStateStore
|
from .aria2_transfer_state import Aria2TransferStateStore
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _try_certifi_ca_path() -> str | None:
|
||||||
|
"""Return the certifi CA bundle path if available, else None."""
|
||||||
|
try:
|
||||||
|
import certifi # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
path = certifi.where()
|
||||||
|
if os.path.isfile(path):
|
||||||
|
logger.debug(
|
||||||
|
"aria2 --ca-certificate: using certifi CA bundle at %s", path
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.debug("aria2 --ca-certificate: certifi not available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||||
"https://civitai.com/api/download/",
|
"https://civitai.com/api/download/",
|
||||||
"https://civitai.red/api/download/",
|
"https://civitai.red/api/download/",
|
||||||
@@ -391,6 +409,15 @@ class Aria2Downloader:
|
|||||||
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
||||||
)
|
)
|
||||||
except aiohttp.ClientError as exc:
|
except aiohttp.ClientError as exc:
|
||||||
|
if is_ssl_cert_verify_error(exc):
|
||||||
|
logger.error(
|
||||||
|
"SSL certificate verification failed during Civitai redirect "
|
||||||
|
"resolution for %s. This is usually caused by an outdated CA "
|
||||||
|
"certificate bundle. Recommended fixes:\n"
|
||||||
|
" 1. pip install --upgrade certifi\n"
|
||||||
|
" 2. pip install pip-system-certs",
|
||||||
|
url,
|
||||||
|
)
|
||||||
raise Aria2Error(
|
raise Aria2Error(
|
||||||
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -414,6 +441,11 @@ class Aria2Downloader:
|
|||||||
f"--rpc-listen-port={self._rpc_port}",
|
f"--rpc-listen-port={self._rpc_port}",
|
||||||
f"--rpc-secret={self._rpc_secret}",
|
f"--rpc-secret={self._rpc_secret}",
|
||||||
"--check-certificate=true",
|
"--check-certificate=true",
|
||||||
|
# Point aria2 at certifi's CA bundle when available so it uses
|
||||||
|
# the same certificate store as Python downloads.
|
||||||
|
*((
|
||||||
|
f"--ca-certificate={ca_cert}",
|
||||||
|
) if (ca_cert := _try_certifi_ca_path()) else ()),
|
||||||
"--allow-overwrite=true",
|
"--allow-overwrite=true",
|
||||||
"--auto-file-renaming=false",
|
"--auto-file-renaming=false",
|
||||||
"--file-allocation=none",
|
"--file-allocation=none",
|
||||||
|
|||||||
@@ -186,6 +186,22 @@ class CivArchiveClient:
|
|||||||
if "metadata" in file_data:
|
if "metadata" in file_data:
|
||||||
transformed["metadata"] = file_data["metadata"]
|
transformed["metadata"] = file_data["metadata"]
|
||||||
|
|
||||||
|
# Infer metadata.format from filename extension
|
||||||
|
name = transformed.get("name")
|
||||||
|
if name and isinstance(name, str):
|
||||||
|
lower_name = name.lower()
|
||||||
|
if lower_name.endswith(".safetensors"):
|
||||||
|
inferred_format = "SafeTensor"
|
||||||
|
elif lower_name.endswith(".ckpt"):
|
||||||
|
inferred_format = "PickleTensor"
|
||||||
|
else:
|
||||||
|
inferred_format = None
|
||||||
|
if inferred_format:
|
||||||
|
if "metadata" not in transformed:
|
||||||
|
transformed["metadata"] = {}
|
||||||
|
if isinstance(transformed["metadata"], dict):
|
||||||
|
transformed["metadata"].setdefault("format", inferred_format)
|
||||||
|
|
||||||
if file_data.get("modelVersionId") is not None:
|
if file_data.get("modelVersionId") is not None:
|
||||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||||
elif file_data.get("model_version_id") is not None:
|
elif file_data.get("model_version_id") is not None:
|
||||||
@@ -213,6 +229,20 @@ class CivArchiveClient:
|
|||||||
for file_data in candidates:
|
for file_data in candidates:
|
||||||
if isinstance(file_data, dict):
|
if isinstance(file_data, dict):
|
||||||
transformed_files.append(self._transform_file_entry(file_data))
|
transformed_files.append(self._transform_file_entry(file_data))
|
||||||
|
|
||||||
|
# Sort: .safetensors first, .ckpt second, others last
|
||||||
|
# so the backend fallback (no file_params) prefers safetensors
|
||||||
|
def _sort_key(f: Dict) -> int:
|
||||||
|
fname = f.get("name") or ""
|
||||||
|
if isinstance(fname, str):
|
||||||
|
lower = fname.lower()
|
||||||
|
if lower.endswith(".safetensors"):
|
||||||
|
return 0
|
||||||
|
elif lower.endswith(".ckpt"):
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
transformed_files.sort(key=_sort_key)
|
||||||
return transformed_files
|
return transformed_files
|
||||||
|
|
||||||
def _transform_version(
|
def _transform_version(
|
||||||
|
|||||||
@@ -410,6 +410,25 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
target_version = self._select_target_version(model_data, model_id, version_id)
|
target_version = self._select_target_version(model_data, model_id, version_id)
|
||||||
|
|
||||||
|
# If modelVersions is empty (e.g. CivitAI cache lag for newly published
|
||||||
|
# models) but a specific version_id is known, fall back to fetching the
|
||||||
|
# version directly via the individual model-versions endpoint, then
|
||||||
|
# enrich it with the model-level data we already have.
|
||||||
|
if target_version is None and version_id is not None:
|
||||||
|
logger.info(
|
||||||
|
"modelVersions empty for model %s; falling back to direct "
|
||||||
|
"version lookup for %s",
|
||||||
|
model_id,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
version = await self._fetch_version_by_id(version_id)
|
||||||
|
if version:
|
||||||
|
self._enrich_version_with_model_data(version, model_data)
|
||||||
|
self._remove_comfy_metadata(version)
|
||||||
|
return version
|
||||||
|
return None
|
||||||
|
|
||||||
if target_version is None:
|
if target_version is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,21 @@ class DownloadedVersionHistoryService:
|
|||||||
def get_database_path(self) -> str:
|
def get_database_path(self) -> str:
|
||||||
return self._db_path
|
return self._db_path
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the persistent SQLite connection, if open.
|
||||||
|
|
||||||
|
This is called before plugin update operations to release the
|
||||||
|
database file lock on Windows, allowing ``shutil.rmtree()`` to
|
||||||
|
succeed when the cache resides inside the plugin directory.
|
||||||
|
"""
|
||||||
|
if self._conn is not None:
|
||||||
|
try:
|
||||||
|
self._conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
def _get_active_library_name(self) -> str | None:
|
def _get_active_library_name(self) -> str | None:
|
||||||
try:
|
try:
|
||||||
value = self._settings.get_active_library_name()
|
value = self._settings.get_active_library_name()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This module provides a centralized download service with:
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ssl
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -31,6 +32,20 @@ from .errors import RateLimitError
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_ssl_cert_verify_error(exc: BaseException) -> bool:
|
||||||
|
"""Check if an exception represents an SSL certificate verification failure.
|
||||||
|
|
||||||
|
Matches ``ssl.SSLCertVerificationError``, ``aiohttp.ClientConnectorCertificateError``
|
||||||
|
(which wraps the former), and falls back to the standard OpenSSL error text.
|
||||||
|
"""
|
||||||
|
if isinstance(exc, ssl.SSLCertVerificationError):
|
||||||
|
return True
|
||||||
|
cert_error = getattr(exc, "certificate_error", None)
|
||||||
|
if isinstance(cert_error, ssl.SSLCertVerificationError):
|
||||||
|
return True
|
||||||
|
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DownloadProgress:
|
class DownloadProgress:
|
||||||
"""Snapshot of a download transfer at a moment in time."""
|
"""Snapshot of a download transfer at a moment in time."""
|
||||||
@@ -265,9 +280,22 @@ class Downloader:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
|
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
|
||||||
)
|
)
|
||||||
|
# Build SSL context: prefer certifi's CA bundle for broader
|
||||||
|
# CA coverage across different Python environments (especially
|
||||||
|
# embedded/compatibility Python builds).
|
||||||
|
try:
|
||||||
|
import certifi # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
ca_path = certifi.where()
|
||||||
|
ssl_context = ssl.create_default_context(cafile=ca_path)
|
||||||
|
logger.debug("SSL: using certifi CA bundle at %s", ca_path)
|
||||||
|
except (ImportError, FileNotFoundError, ValueError, OSError):
|
||||||
|
ssl_context = ssl.create_default_context()
|
||||||
|
logger.debug("SSL: certifi unavailable; using system default CA bundle")
|
||||||
|
|
||||||
# Optimize TCP connection parameters
|
# Optimize TCP connection parameters
|
||||||
connector = aiohttp.TCPConnector(
|
connector = aiohttp.TCPConnector(
|
||||||
ssl=True,
|
ssl=ssl_context,
|
||||||
limit=8, # Concurrent connections
|
limit=8, # Concurrent connections
|
||||||
ttl_dns_cache=300, # DNS cache timeout
|
ttl_dns_cache=300, # DNS cache timeout
|
||||||
force_close=False, # Keep connections for reuse
|
force_close=False, # Keep connections for reuse
|
||||||
@@ -736,6 +764,17 @@ class Downloader:
|
|||||||
DownloadRestartRequested,
|
DownloadRestartRequested,
|
||||||
) as e:
|
) as e:
|
||||||
retry_count += 1
|
retry_count += 1
|
||||||
|
|
||||||
|
if is_ssl_cert_verify_error(e):
|
||||||
|
logger.error(
|
||||||
|
"SSL certificate verification failed when connecting to %s. "
|
||||||
|
"This is usually caused by an outdated CA certificate bundle "
|
||||||
|
"in the Python environment. Recommended fixes:\n"
|
||||||
|
" 1. pip install --upgrade certifi\n"
|
||||||
|
" 2. pip install pip-system-certs",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ModelHashIndex:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._hash_to_path: Dict[str, str] = {}
|
self._hash_to_path: Dict[str, str] = {}
|
||||||
self._filename_to_hash: Dict[str, str] = {}
|
self._filename_to_hash: Dict[str, str] = {}
|
||||||
|
self._autov2_to_path: Dict[str, str] = {}
|
||||||
# New data structures for tracking duplicates
|
# New data structures for tracking duplicates
|
||||||
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
||||||
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
||||||
@@ -63,6 +64,9 @@ class ModelHashIndex:
|
|||||||
# Add new mappings
|
# Add new mappings
|
||||||
self._hash_to_path[sha256] = file_path
|
self._hash_to_path[sha256] = file_path
|
||||||
self._filename_to_hash[filename] = sha256
|
self._filename_to_hash[filename] = sha256
|
||||||
|
# AutoV2 = first 10 chars of SHA256
|
||||||
|
if len(sha256) >= 10:
|
||||||
|
self._autov2_to_path[sha256[:10]] = file_path
|
||||||
|
|
||||||
def _get_filename_from_path(self, file_path: str) -> str:
|
def _get_filename_from_path(self, file_path: str) -> str:
|
||||||
"""Extract filename without extension from path"""
|
"""Extract filename without extension from path"""
|
||||||
@@ -158,6 +162,11 @@ class ModelHashIndex:
|
|||||||
if filename in self._filename_to_hash:
|
if filename in self._filename_to_hash:
|
||||||
del self._filename_to_hash[filename]
|
del self._filename_to_hash[filename]
|
||||||
|
|
||||||
|
# Remove from AutoV2 index
|
||||||
|
autov2_keys_to_remove = [k for k, v in self._autov2_to_path.items() if v == file_path]
|
||||||
|
for k in autov2_keys_to_remove:
|
||||||
|
del self._autov2_to_path[k]
|
||||||
|
|
||||||
def remove_by_hash(self, sha256: str) -> None:
|
def remove_by_hash(self, sha256: str) -> None:
|
||||||
"""Remove entry by hash"""
|
"""Remove entry by hash"""
|
||||||
sha256 = sha256.lower()
|
sha256 = sha256.lower()
|
||||||
@@ -177,6 +186,10 @@ class ModelHashIndex:
|
|||||||
# Remove hash-to-path mapping
|
# Remove hash-to-path mapping
|
||||||
del self._hash_to_path[sha256]
|
del self._hash_to_path[sha256]
|
||||||
|
|
||||||
|
autov2_key = sha256[:10]
|
||||||
|
if autov2_key in self._autov2_to_path:
|
||||||
|
del self._autov2_to_path[autov2_key]
|
||||||
|
|
||||||
# Update filename-to-hash and duplicate filenames for all paths
|
# Update filename-to-hash and duplicate filenames for all paths
|
||||||
for path_to_remove in paths_to_remove:
|
for path_to_remove in paths_to_remove:
|
||||||
fname = self._get_filename_from_path(path_to_remove)
|
fname = self._get_filename_from_path(path_to_remove)
|
||||||
@@ -195,13 +208,24 @@ class ModelHashIndex:
|
|||||||
# If only one entry remains, it's no longer a duplicate
|
# If only one entry remains, it's no longer a duplicate
|
||||||
del self._duplicate_filenames[fname]
|
del self._duplicate_filenames[fname]
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, hash_value: str) -> bool:
|
||||||
"""Check if hash exists in index"""
|
"""Check if hash exists in index (SHA256 or AutoV2)"""
|
||||||
return sha256.lower() in self._hash_to_path
|
normalized = hash_value.lower()
|
||||||
|
if normalized in self._hash_to_path:
|
||||||
|
return True
|
||||||
|
if len(normalized) == 10:
|
||||||
|
return normalized in self._autov2_to_path
|
||||||
|
return False
|
||||||
|
|
||||||
def get_path(self, sha256: str) -> Optional[str]:
|
def get_path(self, hash_value: str) -> Optional[str]:
|
||||||
"""Get file path for a hash"""
|
"""Get file path for a hash (SHA256 or AutoV2)"""
|
||||||
return self._hash_to_path.get(sha256.lower())
|
normalized = hash_value.lower()
|
||||||
|
path = self._hash_to_path.get(normalized)
|
||||||
|
if path is not None:
|
||||||
|
return path
|
||||||
|
if len(normalized) == 10:
|
||||||
|
return self._autov2_to_path.get(normalized)
|
||||||
|
return None
|
||||||
|
|
||||||
def get_hash(self, file_path: str) -> Optional[str]:
|
def get_hash(self, file_path: str) -> Optional[str]:
|
||||||
"""Get hash for a file path"""
|
"""Get hash for a file path"""
|
||||||
@@ -218,6 +242,7 @@ class ModelHashIndex:
|
|||||||
"""Clear all entries"""
|
"""Clear all entries"""
|
||||||
self._hash_to_path.clear()
|
self._hash_to_path.clear()
|
||||||
self._filename_to_hash.clear()
|
self._filename_to_hash.clear()
|
||||||
|
self._autov2_to_path.clear()
|
||||||
self._duplicate_hashes.clear()
|
self._duplicate_hashes.clear()
|
||||||
self._duplicate_filenames.clear()
|
self._duplicate_filenames.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
import random
|
import random
|
||||||
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
||||||
from .downloader import get_downloader
|
from .downloader import get_downloader
|
||||||
from .errors import RateLimitError
|
from .errors import RateLimitError, ResourceNotFoundError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
@@ -482,6 +482,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
return None, "Model not found"
|
return None, "Model not found"
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||||
|
not_found_confirmed = False
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result = await self._call_with_rate_limit(
|
result = await self._call_with_rate_limit(
|
||||||
@@ -492,8 +493,24 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
except RateLimitError as exc:
|
except RateLimitError as exc:
|
||||||
|
if not_found_confirmed:
|
||||||
|
logger.debug(
|
||||||
|
"Suppressing rate limit from %s for model %s: "
|
||||||
|
"already confirmed as not found by another provider",
|
||||||
|
label,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
exc.provider = exc.provider or label
|
exc.provider = exc.provider or label
|
||||||
raise exc
|
raise exc
|
||||||
|
except ResourceNotFoundError:
|
||||||
|
not_found_confirmed = True
|
||||||
|
logger.debug(
|
||||||
|
"Provider %s reports model %s as not found",
|
||||||
|
label,
|
||||||
|
model_id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
|
folder_path: Optional[str] = None,
|
||||||
) -> Dict[int, ModelUpdateRecord]:
|
) -> Dict[int, ModelUpdateRecord]:
|
||||||
"""Refresh update information for every model present in the cache."""
|
"""Refresh update information for every model present in the cache."""
|
||||||
scanner.reset_cancellation()
|
scanner.reset_cancellation()
|
||||||
@@ -703,6 +704,7 @@ class ModelUpdateService:
|
|||||||
local_versions = await self._collect_local_versions(
|
local_versions = await self._collect_local_versions(
|
||||||
scanner,
|
scanner,
|
||||||
target_model_ids=target_filter,
|
target_model_ids=target_filter,
|
||||||
|
folder_path=folder_path,
|
||||||
)
|
)
|
||||||
total_models = len(local_versions)
|
total_models = len(local_versions)
|
||||||
if total_models == 0:
|
if total_models == 0:
|
||||||
@@ -1276,6 +1278,7 @@ class ModelUpdateService:
|
|||||||
scanner,
|
scanner,
|
||||||
*,
|
*,
|
||||||
target_model_ids: Optional[Sequence[int]] = None,
|
target_model_ids: Optional[Sequence[int]] = None,
|
||||||
|
folder_path: Optional[str] = None,
|
||||||
) -> Dict[int, List[int]]:
|
) -> Dict[int, List[int]]:
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
mapping: Dict[int, set[int]] = {}
|
mapping: Dict[int, set[int]] = {}
|
||||||
@@ -1288,7 +1291,19 @@ class ModelUpdateService:
|
|||||||
if not target_set:
|
if not target_set:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
normalized_folder = None
|
||||||
|
if folder_path is not None:
|
||||||
|
normalized_folder = folder_path.replace("\\", "/").strip("/")
|
||||||
|
|
||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
|
# Apply folder filter first (cheapest check)
|
||||||
|
if normalized_folder is not None:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_folder = (item.get("folder") or "").replace("\\", "/").strip("/")
|
||||||
|
if item_folder != normalized_folder and not item_folder.startswith(normalized_folder + "/"):
|
||||||
|
continue
|
||||||
|
|
||||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class RecipeScanner:
|
|||||||
cls._instance._civitai_client = None # Will be lazily initialized
|
cls._instance._civitai_client = None # Will be lazily initialized
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
REPAIR_VERSION = 3
|
REPAIR_VERSION = 4
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -292,6 +292,32 @@ class RecipeScanner:
|
|||||||
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
|
||||||
|
# A checkpoint whose modelVersionId also appears in a LoRA entry is
|
||||||
|
# definitely wrong — the CivitAI import code used to pick
|
||||||
|
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
|
||||||
|
# Clearing it lets the enrichment flow re-resolve the correct
|
||||||
|
# checkpoint from CivitAI image metadata.
|
||||||
|
cp = recipe.get("checkpoint")
|
||||||
|
lora_mvids = {
|
||||||
|
l.get("modelVersionId")
|
||||||
|
for l in recipe.get("loras", [])
|
||||||
|
if l.get("modelVersionId")
|
||||||
|
}
|
||||||
|
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
|
||||||
|
cp_mvid = cp["modelVersionId"]
|
||||||
|
logger.info(
|
||||||
|
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
|
||||||
|
"clearing corrupted checkpoint and removing matching LoRA entry",
|
||||||
|
recipe.get("id"),
|
||||||
|
cp_mvid,
|
||||||
|
)
|
||||||
|
recipe["checkpoint"] = None
|
||||||
|
recipe["loras"] = [
|
||||||
|
l for l in recipe.get("loras", [])
|
||||||
|
if l.get("modelVersionId") != cp_mvid
|
||||||
|
]
|
||||||
|
|
||||||
# 2. Identification: Is repair needed?
|
# 2. Identification: Is repair needed?
|
||||||
has_checkpoint = (
|
has_checkpoint = (
|
||||||
"checkpoint" in recipe
|
"checkpoint" in recipe
|
||||||
|
|||||||
@@ -101,8 +101,34 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
|||||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||||
[
|
[
|
||||||
"Anima",
|
"Anima",
|
||||||
"ZImageTurbo",
|
# Flux series — DiT architecture, loaded via UNETLoader in ComfyUI
|
||||||
"ZImageBase",
|
"Flux.1 D",
|
||||||
|
"Flux.1 S",
|
||||||
|
"Flux.1 Krea",
|
||||||
|
"Flux.1 Kontext",
|
||||||
|
"Flux.2 D",
|
||||||
|
"Flux.2 Klein 9B",
|
||||||
|
"Flux.2 Klein 9B-base",
|
||||||
|
"Flux.2 Klein 4B",
|
||||||
|
"Flux.2 Klein 4B-base",
|
||||||
|
# Non-UNet / DiT image diffusion models
|
||||||
|
"AuraFlow",
|
||||||
|
"Chroma",
|
||||||
|
"HiDream",
|
||||||
|
"Hunyuan 1",
|
||||||
|
"Kolors",
|
||||||
|
"Lumina",
|
||||||
|
"PixArt a",
|
||||||
|
"PixArt E",
|
||||||
|
# Video diffusion models
|
||||||
|
"CogVideoX",
|
||||||
|
"Hunyuan Video",
|
||||||
|
"LTXV",
|
||||||
|
"LTXV2",
|
||||||
|
"LTXV 2.3",
|
||||||
|
"Mochi",
|
||||||
|
"SVD",
|
||||||
|
"Wan Video",
|
||||||
"Wan Video 1.3B t2v",
|
"Wan Video 1.3B t2v",
|
||||||
"Wan Video 14B t2v",
|
"Wan Video 14B t2v",
|
||||||
"Wan Video 14B i2v 480p",
|
"Wan Video 14B i2v 480p",
|
||||||
@@ -112,9 +138,13 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
|||||||
"Wan Video 2.2 T2V-A14B",
|
"Wan Video 2.2 T2V-A14B",
|
||||||
"Wan Video 2.5 T2V",
|
"Wan Video 2.5 T2V",
|
||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
"CogVideoX",
|
# Other diffusion models
|
||||||
"Mochi",
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
"Qwen",
|
"Qwen",
|
||||||
|
"ZImageBase",
|
||||||
|
"ZImageTurbo",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -397,13 +397,12 @@ class DownloadManager:
|
|||||||
|
|
||||||
models_with_hash = len(all_models_with_hash)
|
models_with_hash = len(all_models_with_hash)
|
||||||
|
|
||||||
# Calculate pending count: check which models actually need processing
|
# Calculate pending count: check which models actually need processing.
|
||||||
# A model is pending if it has a hash, is not in processed_models,
|
# A model is pending if it has a hash, is not already processed or known-failed,
|
||||||
# and its folder doesn't exist or is empty
|
# and its folder doesn't exist or is empty.
|
||||||
pending_hashes = set()
|
pending_hashes = set()
|
||||||
for model_hash, model_name in all_models_with_hash:
|
for model_hash, model_name in all_models_with_hash:
|
||||||
if model_hash not in processed_models:
|
if model_hash not in processed_models and model_hash not in failed_models:
|
||||||
# Check if model folder exists with files
|
|
||||||
model_dir = ExampleImagePathResolver.get_model_folder(
|
model_dir = ExampleImagePathResolver.get_model_folder(
|
||||||
model_hash, active_library
|
model_hash, active_library
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str
|
|||||||
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
||||||
|
|
||||||
|
|
||||||
|
_KEEP_LOG_COUNT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_old_logs(log_dir: str) -> None:
|
||||||
|
"""Remove older session log files, keeping only the ``_KEEP_LOG_COUNT`` newest."""
|
||||||
|
try:
|
||||||
|
files = [
|
||||||
|
os.path.join(log_dir, name)
|
||||||
|
for name in os.listdir(log_dir)
|
||||||
|
if name.startswith("standalone-session-") and name.endswith(".log")
|
||||||
|
]
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
files.sort(key=os.path.getmtime, reverse=True)
|
||||||
|
for path in files[_KEEP_LOG_COUNT:]:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
||||||
global _session_state
|
global _session_state
|
||||||
|
|
||||||
@@ -90,6 +111,7 @@ def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSes
|
|||||||
file_handler.set_name(_FILE_HANDLER_NAME)
|
file_handler.set_name(_FILE_HANDLER_NAME)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
root_logger.addHandler(file_handler)
|
root_logger.addHandler(file_handler)
|
||||||
|
_prune_old_logs(os.path.dirname(log_file_path))
|
||||||
|
|
||||||
_session_state = StandaloneSessionLogState(
|
_session_state = StandaloneSessionLogState(
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.0.8"
|
version = "1.0.10"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
"C:/path/to/your/checkpoints_folder",
|
"C:/path/to/your/checkpoints_folder",
|
||||||
"C:/path/to/another/checkpoints_folder"
|
"C:/path/to/another/checkpoints_folder"
|
||||||
],
|
],
|
||||||
|
"unet": [
|
||||||
|
"C:/path/to/your/diffusion_models_folder",
|
||||||
|
"C:/path/to/another/diffusion_models_folder"
|
||||||
|
],
|
||||||
"embeddings": [
|
"embeddings": [
|
||||||
"C:/path/to/your/embeddings_folder",
|
"C:/path/to/your/embeddings_folder",
|
||||||
"C:/path/to/another/embeddings_folder"
|
"C:/path/to/another/embeddings_folder"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"example_images_open_mode": "system",
|
|
||||||
"example_images_local_root": "",
|
|
||||||
"example_images_open_uri_template": "",
|
|
||||||
"auto_organize_exclusions": []
|
"auto_organize_exclusions": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -503,3 +503,169 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* File Count Badge on Version Items */
|
||||||
|
.file-select-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: oklch(var(--lora-accent) / 0.18);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid oklch(var(--lora-accent) / 0.35);
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: 0 1px 2px oklch(var(--lora-accent) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge:hover {
|
||||||
|
background: oklch(var(--lora-accent) / 0.3);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 6px oklch(var(--lora-accent) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge i {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-select-badge .badge-arrow {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-size: 0.65em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Selection Step */
|
||||||
|
.file-selection-header {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-header h3 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-version-name {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selection-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option.selected {
|
||||||
|
border: 2px solid var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-radio {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-radio input[type="radio"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.format {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.fp {
|
||||||
|
background: oklch(0.6 0.15 250 / 0.1);
|
||||||
|
color: oklch(0.55 0.15 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tag.size {
|
||||||
|
background: oklch(0.55 0.1 160 / 0.1);
|
||||||
|
color: oklch(0.5 0.12 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-name {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-option-size {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] .file-option {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .file-tag.fp {
|
||||||
|
background: oklch(0.55 0.12 250 / 0.15);
|
||||||
|
color: oklch(0.7 0.12 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .file-tag.size {
|
||||||
|
background: oklch(0.5 0.08 160 / 0.15);
|
||||||
|
color: oklch(0.65 0.08 160);
|
||||||
|
}
|
||||||
@@ -745,3 +745,8 @@
|
|||||||
.sidebar-tree-container {
|
.sidebar-tree-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folder context menu - positioned relative to sidebar */
|
||||||
|
#sidebarFolderContextMenu {
|
||||||
|
z-index: var(--z-modal, 1002);
|
||||||
|
}
|
||||||
|
|||||||
@@ -766,6 +766,49 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshUpdatesForFolder(folderPath, { force = false } = {}) {
|
||||||
|
if (!folderPath) {
|
||||||
|
throw new Error('No folder path provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.loadingManager.show('Checking for updates...', 0);
|
||||||
|
state.loadingManager.showCancelButton(() => this.cancelTask());
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
folder_path: folderPath,
|
||||||
|
force
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Unable to parse refresh updates response as JSON', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || payload?.success !== true) {
|
||||||
|
if (payload?.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const message = payload?.error || response.statusText || 'Failed to refresh updates';
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing updates for folder:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fetchCivitaiVersions(modelId, source = null) {
|
async fetchCivitaiVersions(modelId, source = null) {
|
||||||
try {
|
try {
|
||||||
let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`;
|
let requestUrl = `${this.apiConfig.endpoints.civitaiVersions}/${modelId}`;
|
||||||
@@ -909,7 +952,7 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null) {
|
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -921,7 +964,8 @@ export class BaseModelApiClient {
|
|||||||
relative_path: relativePath,
|
relative_path: relativePath,
|
||||||
use_default_paths: useDefaultPaths,
|
use_default_paths: useDefaultPaths,
|
||||||
download_id: downloadId,
|
download_id: downloadId,
|
||||||
...(source ? { source } : {})
|
...(source ? { source } : {}),
|
||||||
|
...(fileParams ? { file_params: fileParams } : {})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const RECIPE_ENDPOINTS = {
|
|||||||
move: '/api/lm/recipe/move',
|
move: '/api/lm/recipe/move',
|
||||||
moveBulk: '/api/lm/recipes/move-bulk',
|
moveBulk: '/api/lm/recipes/move-bulk',
|
||||||
bulkDelete: '/api/lm/recipes/bulk-delete',
|
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||||
|
repairBulk: '/api/lm/recipes/repair-bulk',
|
||||||
};
|
};
|
||||||
|
|
||||||
const RECIPE_SIDEBAR_CONFIG = {
|
const RECIPE_SIDEBAR_CONFIG = {
|
||||||
@@ -196,8 +197,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
// Reset page counter
|
// Reset page counter
|
||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
|
|
||||||
// Fetch the first page
|
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
|
||||||
const result = await fetchPageFunction(1, pageState.pageSize || 50);
|
const result = await fetchPageFunction(1, pageSize);
|
||||||
|
|
||||||
// Update the virtual scroller
|
// Update the virtual scroller
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
@@ -250,8 +251,8 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the first page of data
|
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
|
||||||
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
|
const result = await fetchPageFunction(pageState.currentPage, pageSize);
|
||||||
|
|
||||||
// Update virtual scroller with the new data
|
// Update virtual scroller with the new data
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
@@ -293,47 +294,41 @@ export async function resetAndReload(updateFolders = false, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync changes - quick refresh without rebuilding cache (similar to models page)
|
* Refreshes the recipe list by triggering a backend scan, then reloading.
|
||||||
|
* @param {boolean} fullRebuild - If true, fully rebuild the cache; if false, incremental scan
|
||||||
*/
|
*/
|
||||||
export async function syncChanges() {
|
export async function syncChanges() {
|
||||||
try {
|
return refreshRecipes(false);
|
||||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
|
||||||
|
|
||||||
// Simply reload the recipes without rebuilding cache
|
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
|
||||||
|
|
||||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing recipes:', error);
|
|
||||||
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
|
|
||||||
} finally {
|
|
||||||
state.loadingManager.hide();
|
|
||||||
state.loadingManager.restoreProgressBar();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function refreshRecipes(fullRebuild = true) {
|
||||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
|
||||||
*/
|
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
|
||||||
export async function refreshRecipes() {
|
|
||||||
try {
|
|
||||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
|
||||||
|
|
||||||
// Call the API endpoint to rebuild the recipe cache
|
try {
|
||||||
const response = await fetch(RECIPE_ENDPOINTS.scan);
|
state.loadingManager.show(`${actionLabel}...`, 0);
|
||||||
|
|
||||||
|
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
|
||||||
|
url.searchParams.append('full_rebuild', fullRebuild);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error(`Failed to refresh recipe cache: ${response.status} ${response.statusText}`);
|
||||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// After successful cache rebuild, reload the recipes
|
const data = await response.json();
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
if (data.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
await resetAndReload(false);
|
||||||
|
|
||||||
|
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing recipes:', error);
|
console.error('Error refreshing recipes:', error);
|
||||||
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error');
|
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
state.loadingManager.restoreProgressBar();
|
state.loadingManager.restoreProgressBar();
|
||||||
@@ -557,6 +552,38 @@ export class RecipeSidebarApiClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async repairBulkModels(filePaths) {
|
||||||
|
if (!filePaths || filePaths.length === 0) {
|
||||||
|
throw new Error('No file paths provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeIds = filePaths
|
||||||
|
.map((path) => extractRecipeId(path))
|
||||||
|
.filter((id) => !!id);
|
||||||
|
|
||||||
|
if (recipeIds.length === 0) {
|
||||||
|
throw new Error('No recipe IDs could be derived from file paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.repairBulk, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipe_ids: recipeIds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to repair recipes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async bulkDeleteModels(filePaths) {
|
async bulkDeleteModels(filePaths) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||||
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
||||||
|
const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]');
|
||||||
|
|
||||||
|
if (repairMetadataItem) {
|
||||||
|
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (sendToWorkflowAppendItem) {
|
if (sendToWorkflowAppendItem) {
|
||||||
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
||||||
@@ -127,33 +132,38 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||||
|
|
||||||
if (skipMetadataRefreshItem && resumeMetadataRefreshItem) {
|
if (skipMetadataRefreshItem && resumeMetadataRefreshItem) {
|
||||||
const skipCount = this.countSkipStatus(true);
|
if (!config.skipMetadataRefresh) {
|
||||||
const resumeCount = this.countSkipStatus(false);
|
|
||||||
const totalCount = skipCount + resumeCount;
|
|
||||||
|
|
||||||
if (skipCount === totalCount) {
|
|
||||||
skipMetadataRefreshItem.style.display = 'none';
|
skipMetadataRefreshItem.style.display = 'none';
|
||||||
resumeMetadataRefreshItem.style.display = 'flex';
|
|
||||||
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
|
||||||
'loras.bulkOperations.resumeMetadataRefresh'
|
|
||||||
);
|
|
||||||
} else if (resumeCount === totalCount) {
|
|
||||||
skipMetadataRefreshItem.style.display = 'flex';
|
|
||||||
resumeMetadataRefreshItem.style.display = 'none';
|
resumeMetadataRefreshItem.style.display = 'none';
|
||||||
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
|
||||||
'loras.bulkOperations.skipMetadataRefresh'
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
skipMetadataRefreshItem.style.display = 'flex';
|
const skipCount = this.countSkipStatus(true);
|
||||||
resumeMetadataRefreshItem.style.display = 'flex';
|
const resumeCount = this.countSkipStatus(false);
|
||||||
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
const totalCount = skipCount + resumeCount;
|
||||||
'loras.bulkOperations.skipMetadataRefreshCount',
|
|
||||||
{ count: resumeCount }
|
if (skipCount === totalCount) {
|
||||||
);
|
skipMetadataRefreshItem.style.display = 'none';
|
||||||
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
resumeMetadataRefreshItem.style.display = 'flex';
|
||||||
'loras.bulkOperations.resumeMetadataRefreshCount',
|
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||||
{ count: skipCount }
|
'loras.bulkOperations.resumeMetadataRefresh'
|
||||||
);
|
);
|
||||||
|
} else if (resumeCount === totalCount) {
|
||||||
|
skipMetadataRefreshItem.style.display = 'flex';
|
||||||
|
resumeMetadataRefreshItem.style.display = 'none';
|
||||||
|
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||||
|
'loras.bulkOperations.skipMetadataRefresh'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
skipMetadataRefreshItem.style.display = 'flex';
|
||||||
|
resumeMetadataRefreshItem.style.display = 'flex';
|
||||||
|
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||||
|
'loras.bulkOperations.skipMetadataRefreshCount',
|
||||||
|
{ count: resumeCount }
|
||||||
|
);
|
||||||
|
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||||
|
'loras.bulkOperations.resumeMetadataRefreshCount',
|
||||||
|
{ count: skipCount }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +261,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
case 'delete-all':
|
case 'delete-all':
|
||||||
bulkManager.showBulkDeleteModal();
|
bulkManager.showBulkDeleteModal();
|
||||||
break;
|
break;
|
||||||
|
case 'repair-metadata':
|
||||||
|
bulkManager.repairSelectedRecipes();
|
||||||
|
break;
|
||||||
case 'set-favorite': {
|
case 'set-favorite': {
|
||||||
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
|
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
|
||||||
bulkManager.setBulkFavorites(!allFavorited);
|
bulkManager.setBulkFavorites(!allFavorited);
|
||||||
|
|||||||
@@ -306,8 +306,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.repaired > 0) {
|
if (result.repaired > 0) {
|
||||||
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
||||||
// Refresh the current card or reload
|
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
this.resetAndReload();
|
if (detailResponse.ok) {
|
||||||
|
const updatedRecipe = await detailResponse.json();
|
||||||
|
const filePath = this.currentCard?.dataset?.filepath;
|
||||||
|
if (filePath && state.virtualScroller) {
|
||||||
|
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class RecipeCard {
|
|||||||
card.dataset.created = this.recipe.created_date;
|
card.dataset.created = this.recipe.created_date;
|
||||||
card.dataset.id = this.recipe.id || '';
|
card.dataset.id = this.recipe.id || '';
|
||||||
card.dataset.folder = this.recipe.folder || '';
|
card.dataset.folder = this.recipe.folder || '';
|
||||||
|
card.dataset.favorite = this.recipe.favorite ? 'true' : 'false';
|
||||||
|
|
||||||
// Get base model with fallback
|
// Get base model with fallback
|
||||||
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
||||||
@@ -161,6 +162,7 @@ class RecipeCard {
|
|||||||
|
|
||||||
// Update early to provide instant feedback and avoid race conditions with re-renders
|
// Update early to provide instant feedback and avoid race conditions with re-renders
|
||||||
this.recipe.favorite = newFavoriteState;
|
this.recipe.favorite = newFavoriteState;
|
||||||
|
card.dataset.favorite = newFavoriteState ? 'true' : 'false';
|
||||||
|
|
||||||
// Function to update icon state
|
// Function to update icon state
|
||||||
const updateIconUI = (icon, state) => {
|
const updateIconUI = (icon, state) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
|
|||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.js';
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||||
|
|
||||||
export class SidebarManager {
|
export class SidebarManager {
|
||||||
@@ -41,6 +42,7 @@ export class SidebarManager {
|
|||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.handleTreeClick = this.handleTreeClick.bind(this);
|
this.handleTreeClick = this.handleTreeClick.bind(this);
|
||||||
|
this.handleTreeContextMenu = this.handleTreeContextMenu.bind(this);
|
||||||
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
|
||||||
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
this.handleDocumentClick = this.handleDocumentClick.bind(this);
|
||||||
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
this.handleSidebarHeaderClick = this.handleSidebarHeaderClick.bind(this);
|
||||||
@@ -185,6 +187,8 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
if (folderTree) {
|
if (folderTree) {
|
||||||
folderTree.removeEventListener('click', this.handleTreeClick);
|
folderTree.removeEventListener('click', this.handleTreeClick);
|
||||||
|
folderTree.removeEventListener('contextmenu', this.handleTreeContextMenu);
|
||||||
|
folderTree.removeEventListener('dragover', this.handleFolderDragOver);
|
||||||
}
|
}
|
||||||
if (sidebarBreadcrumbNav) {
|
if (sidebarBreadcrumbNav) {
|
||||||
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
|
sidebarBreadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
|
||||||
@@ -977,6 +981,7 @@ export class SidebarManager {
|
|||||||
const folderTree = document.getElementById('sidebarFolderTree');
|
const folderTree = document.getElementById('sidebarFolderTree');
|
||||||
if (folderTree) {
|
if (folderTree) {
|
||||||
folderTree.addEventListener('click', this.handleTreeClick);
|
folderTree.addEventListener('click', this.handleTreeClick);
|
||||||
|
folderTree.addEventListener('contextmenu', this.handleTreeContextMenu);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breadcrumb click handler
|
// Breadcrumb click handler
|
||||||
@@ -1027,6 +1032,19 @@ export class SidebarManager {
|
|||||||
if (displayModeToggleBtn) {
|
if (displayModeToggleBtn) {
|
||||||
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
|
displayModeToggleBtn.addEventListener('click', this.handleDisplayModeToggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar folder context menu click handler
|
||||||
|
const sidebarFolderMenu = document.getElementById('sidebarFolderContextMenu');
|
||||||
|
if (sidebarFolderMenu) {
|
||||||
|
sidebarFolderMenu.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.context-menu-item');
|
||||||
|
if (!item) return;
|
||||||
|
const action = item.dataset.action;
|
||||||
|
if (action) {
|
||||||
|
this.handleFolderContextMenuAction(action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDocumentClick(event) {
|
handleDocumentClick(event) {
|
||||||
@@ -1398,6 +1416,82 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleTreeContextMenu(event) {
|
||||||
|
const nodeContent = event.target.closest('.sidebar-tree-node, .sidebar-folder-item');
|
||||||
|
if (!nodeContent) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const path = nodeContent.dataset.path;
|
||||||
|
if (path === undefined || path === null || path === '') return;
|
||||||
|
|
||||||
|
this._showFolderContextMenu(event.clientX, event.clientY, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showFolderContextMenu(x, y, path) {
|
||||||
|
this._closeFolderContextMenu();
|
||||||
|
|
||||||
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
menu.style.left = `${x}px`;
|
||||||
|
menu.style.top = `${y}px`;
|
||||||
|
menu.style.display = 'block';
|
||||||
|
menu.dataset.folderPath = path;
|
||||||
|
|
||||||
|
this._folderContextOpen = true;
|
||||||
|
|
||||||
|
// Close on next click outside
|
||||||
|
this._folderContextCloseHandler = (e) => {
|
||||||
|
if (!menu.contains(e.target)) {
|
||||||
|
this._closeFolderContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', this._folderContextCloseHandler);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeFolderContextMenu() {
|
||||||
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
||||||
|
if (menu) {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
delete menu.dataset.folderPath;
|
||||||
|
}
|
||||||
|
if (this._folderContextCloseHandler) {
|
||||||
|
document.removeEventListener('click', this._folderContextCloseHandler);
|
||||||
|
this._folderContextCloseHandler = null;
|
||||||
|
}
|
||||||
|
this._folderContextOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFolderContextMenuAction(action) {
|
||||||
|
const menu = document.getElementById('sidebarFolderContextMenu');
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
const path = menu.dataset.folderPath;
|
||||||
|
this._closeFolderContextMenu();
|
||||||
|
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
this._performFolderAction(action, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _performFolderAction(action, path) {
|
||||||
|
switch (action) {
|
||||||
|
case 'check-folder-updates':
|
||||||
|
try {
|
||||||
|
await performFolderUpdateCheck(path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Folder update check failed:', error);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn('Unknown folder action:', action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleBreadcrumbClick(event) {
|
handleBreadcrumbClick(event) {
|
||||||
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
const breadcrumbItem = event.target.closest('.sidebar-breadcrumb-item');
|
||||||
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
const dropdownItem = event.target.closest('.breadcrumb-dropdown-item');
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
|||||||
|
|
||||||
// Refresh recipes list to show newly imported recipes
|
// Refresh recipes list to show newly imported recipes
|
||||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show results step
|
// Show results step
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ export class BulkManager {
|
|||||||
setContentRating: false,
|
setContentRating: false,
|
||||||
skipMetadataRefresh: false,
|
skipMetadataRefresh: false,
|
||||||
setFavorite: true,
|
setFavorite: true,
|
||||||
unfavorite: true
|
unfavorite: true,
|
||||||
|
repairMetadata: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -656,6 +657,76 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async repairSelectedRecipes() {
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
|
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.currentPageType !== 'recipes') {
|
||||||
|
showToast('This operation is only available for recipes', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiClient = this.getActiveApiClient();
|
||||||
|
const filePaths = Array.from(state.selectedModels);
|
||||||
|
|
||||||
|
if (typeof apiClient.repairBulkModels !== 'function') {
|
||||||
|
showToast('Bulk repair is not supported for this model type', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loadingManager.showSimpleLoading('Repairing recipe metadata...');
|
||||||
|
|
||||||
|
const result = await apiClient.repairBulkModels(filePaths);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const total = result.total || filePaths.length;
|
||||||
|
const repaired = result.repaired || 0;
|
||||||
|
const skipped = result.skipped || 0;
|
||||||
|
|
||||||
|
const recipes = result.recipes || [];
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
if (recipe.file_path) {
|
||||||
|
state.virtualScroller.updateSingleItem(
|
||||||
|
recipe.file_path,
|
||||||
|
recipe
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repaired > 0) {
|
||||||
|
showToast(
|
||||||
|
'toast.recipes.repairBulkComplete',
|
||||||
|
{ repaired, skipped, total },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
'toast.recipes.repairBulkSkipped',
|
||||||
|
{ total },
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearSelection();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Bulk repair failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during bulk recipe repair:', error);
|
||||||
|
showToast('toast.recipes.repairBulkFailed', { message: error.message }, 'error');
|
||||||
|
} finally {
|
||||||
|
if (state.loadingManager?.hide) {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
|
||||||
|
state.loadingManager.restoreProgressBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refreshAllMetadata() {
|
async refreshAllMetadata() {
|
||||||
if (state.selectedModels.size === 0) {
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||||
|
|||||||
@@ -309,9 +309,22 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
}, 'warning');
|
}, 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the recipes list to update LoRA status
|
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
||||||
if (window.recipeManager) {
|
if (state.virtualScroller) {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
const { extractRecipeId } = await import('../api/recipeApi.js');
|
||||||
|
for (const recipe of this.pendingRecipes) {
|
||||||
|
const recipeId = extractRecipeId(recipe.file_path);
|
||||||
|
if (!recipeId) continue;
|
||||||
|
try {
|
||||||
|
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
|
||||||
|
if (detailRes.ok) {
|
||||||
|
const updated = await detailRes.json();
|
||||||
|
state.virtualScroller.updateSingleItem(recipe.file_path, updated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to update recipe card after LoRA download:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export class DownloadManager {
|
|||||||
this.handleStartDownload = this.startDownload.bind(this);
|
this.handleStartDownload = this.startDownload.bind(this);
|
||||||
this.handleBackToUrl = this.backToUrl.bind(this);
|
this.handleBackToUrl = this.backToUrl.bind(this);
|
||||||
this.handleBackToVersions = this.backToVersions.bind(this);
|
this.handleBackToVersions = this.backToVersions.bind(this);
|
||||||
|
this.handleBackToVersionFromFiles = this.backToVersionFromFiles.bind(this);
|
||||||
|
this.handleConfirmFileSelection = this.confirmFileSelection.bind(this);
|
||||||
this.handleCloseModal = this.closeModal.bind(this);
|
this.handleCloseModal = this.closeModal.bind(this);
|
||||||
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
||||||
}
|
}
|
||||||
@@ -80,6 +82,10 @@ export class DownloadManager {
|
|||||||
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
|
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
|
||||||
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
|
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
|
||||||
|
|
||||||
|
// File selection step buttons
|
||||||
|
document.getElementById('backToVersionFromFilesBtn').addEventListener('click', this.handleBackToVersionFromFiles);
|
||||||
|
document.getElementById('confirmFileSelection').addEventListener('click', this.handleConfirmFileSelection);
|
||||||
|
|
||||||
// Default path toggle handler
|
// Default path toggle handler
|
||||||
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
||||||
}
|
}
|
||||||
@@ -129,6 +135,7 @@ export class DownloadManager {
|
|||||||
this.modelId = null;
|
this.modelId = null;
|
||||||
this.modelVersionId = null;
|
this.modelVersionId = null;
|
||||||
this.source = null;
|
this.source = null;
|
||||||
|
this.selectedFile = null;
|
||||||
|
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
|
|
||||||
@@ -247,9 +254,12 @@ export class DownloadManager {
|
|||||||
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
||||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||||
|
|
||||||
|
// Count model-type files per version
|
||||||
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||||
|
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
|
||||||
const fileSize = version.modelSizeKB ?
|
const fileSize = version.modelSizeKB ?
|
||||||
(version.modelSizeKB / 1024).toFixed(2) :
|
(version.modelSizeKB / 1024).toFixed(2) :
|
||||||
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
((primaryFile.sizeKB || 0) / 1024).toFixed(2);
|
||||||
|
|
||||||
const existsLocally = version.existsLocally;
|
const existsLocally = version.existsLocally;
|
||||||
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
|
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
|
||||||
@@ -282,6 +292,12 @@ export class DownloadManager {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileBadge = modelFiles.length > 1 && !existsLocally
|
||||||
|
? `<span class="file-select-badge" data-version-id="${version.id}">
|
||||||
|
<i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
|
||||||
|
</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
||||||
${existsLocally ? 'exists-locally' : ''}
|
${existsLocally ? 'exists-locally' : ''}
|
||||||
@@ -302,14 +318,23 @@ export class DownloadManager {
|
|||||||
<div class="version-meta">
|
<div class="version-meta">
|
||||||
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
|
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
|
||||||
<span><i class="fas fa-file-archive"></i> ${fileSize} MB</span>
|
<span><i class="fas fa-file-archive"></i> ${fileSize} MB</span>
|
||||||
|
${fileBadge}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add click handlers for version selection
|
// Add click handlers for version selection and file badge
|
||||||
versionList.addEventListener('click', (event) => {
|
versionList.addEventListener('click', (event) => {
|
||||||
|
const badge = event.target.closest('.file-select-badge');
|
||||||
|
if (badge) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const versionId = badge.dataset.versionId;
|
||||||
|
this.selectVersion(versionId);
|
||||||
|
this.showFileSelectionStep(versionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const versionItem = event.target.closest('.version-item');
|
const versionItem = event.target.closest('.version-item');
|
||||||
if (versionItem) {
|
if (versionItem) {
|
||||||
this.selectVersion(versionItem.dataset.versionId);
|
this.selectVersion(versionItem.dataset.versionId);
|
||||||
@@ -352,6 +377,80 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showFileSelectionStep(versionId) {
|
||||||
|
const version = this.versions.find(v => v.id.toString() === versionId.toString());
|
||||||
|
if (!version) return;
|
||||||
|
|
||||||
|
this.currentVersion = version;
|
||||||
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||||
|
|
||||||
|
document.getElementById('versionStep').style.display = 'none';
|
||||||
|
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||||
|
|
||||||
|
const nameEl = document.getElementById('fileSelectionVersionName');
|
||||||
|
if (nameEl) {
|
||||||
|
nameEl.textContent = `${version.name} · ${version.baseModel || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('fileSelectionList');
|
||||||
|
container.innerHTML = modelFiles.map(file => {
|
||||||
|
const meta = file.metadata || {};
|
||||||
|
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
|
||||||
|
const isSelected = this.selectedFile?.id === file.id;
|
||||||
|
|
||||||
|
const tags = [];
|
||||||
|
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
|
||||||
|
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
|
||||||
|
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
|
||||||
|
|
||||||
|
const fileName = file.name || '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
|
||||||
|
<div class="file-option-radio">
|
||||||
|
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}>
|
||||||
|
</div>
|
||||||
|
<div class="file-option-info">
|
||||||
|
<div class="file-option-tags">
|
||||||
|
${tags.join(' ')}
|
||||||
|
</div>
|
||||||
|
<div class="file-option-name">${fileName}</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-option-size">${sizeGB} GB</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.file-option').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
const radio = el.querySelector('input[type="radio"]');
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmFileSelection() {
|
||||||
|
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
||||||
|
if (!selectedRadio) return;
|
||||||
|
|
||||||
|
const version = this.currentVersion;
|
||||||
|
if (!version) return;
|
||||||
|
|
||||||
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
||||||
|
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||||
|
|
||||||
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
|
document.getElementById('locationStep').style.display = 'block';
|
||||||
|
this.proceedToLocationContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
backToVersionFromFiles() {
|
||||||
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
|
document.getElementById('versionStep').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
async proceedToLocation() {
|
async proceedToLocation() {
|
||||||
if (!this.currentVersion) {
|
if (!this.currentVersion) {
|
||||||
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
||||||
@@ -366,6 +465,10 @@ export class DownloadManager {
|
|||||||
|
|
||||||
document.getElementById('versionStep').style.display = 'none';
|
document.getElementById('versionStep').style.display = 'none';
|
||||||
document.getElementById('locationStep').style.display = 'block';
|
document.getElementById('locationStep').style.display = 'block';
|
||||||
|
await this.proceedToLocationContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async proceedToLocationContent() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch model roots
|
// Fetch model roots
|
||||||
@@ -450,6 +553,7 @@ export class DownloadManager {
|
|||||||
targetFolder = '',
|
targetFolder = '',
|
||||||
useDefaultPaths = false,
|
useDefaultPaths = false,
|
||||||
source = null,
|
source = null,
|
||||||
|
fileParams = null,
|
||||||
closeModal = false,
|
closeModal = false,
|
||||||
}) {
|
}) {
|
||||||
const config = this.apiClient?.apiConfig?.config;
|
const config = this.apiClient?.apiConfig?.config;
|
||||||
@@ -513,7 +617,8 @@ export class DownloadManager {
|
|||||||
targetFolder,
|
targetFolder,
|
||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
downloadId,
|
downloadId,
|
||||||
source
|
source,
|
||||||
|
fileParams
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response?.skipped) {
|
if (response?.skipped) {
|
||||||
@@ -632,6 +737,13 @@ export class DownloadManager {
|
|||||||
} else {
|
} else {
|
||||||
targetFolder = this.folderTreeManager.getSelectedPath();
|
targetFolder = this.folderTreeManager.getSelectedPath();
|
||||||
}
|
}
|
||||||
|
const fileParams = this.selectedFile ? {
|
||||||
|
type: 'Model',
|
||||||
|
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
||||||
|
size: this.selectedFile.metadata?.size || 'full',
|
||||||
|
fp: this.selectedFile.metadata?.fp,
|
||||||
|
} : null;
|
||||||
|
|
||||||
return this.executeDownloadWithProgress({
|
return this.executeDownloadWithProgress({
|
||||||
modelId: this.modelId,
|
modelId: this.modelId,
|
||||||
versionId: this.currentVersion.id,
|
versionId: this.currentVersion.id,
|
||||||
@@ -640,6 +752,7 @@ export class DownloadManager {
|
|||||||
targetFolder,
|
targetFolder,
|
||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
source: this.source,
|
source: this.source,
|
||||||
|
fileParams,
|
||||||
closeModal: true,
|
closeModal: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
@@ -746,7 +746,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class SearchManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
|
|||||||
@@ -2876,7 +2876,7 @@ export class SettingsManager {
|
|||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} else if (this.currentPage === 'recipes') {
|
} else if (this.currentPage === 'recipes') {
|
||||||
// Reload the recipes without updating folders
|
// Reload the recipes without updating folders
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
|||||||
@@ -731,9 +731,16 @@ export class UpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Simple markdown parser for changelog items
|
// Simple markdown parser for changelog items
|
||||||
|
// Simple markdown parser for changelog items
|
||||||
|
// Escape HTML entities first so angle brackets in content (e.g. `<lora:x>`)
|
||||||
|
// aren't swallowed by innerHTML's HTML parser as invalid tags
|
||||||
parseMarkdown(text) {
|
parseMarkdown(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
|
text = text.replace(/&/g, '&');
|
||||||
|
text = text.replace(/</g, '<');
|
||||||
|
text = text.replace(/>/g, '>');
|
||||||
|
|
||||||
// Handle bold text (**text**)
|
// Handle bold text (**text**)
|
||||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class DownloadManager {
|
|||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
// Refresh the recipe
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
|||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.js';
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
|
||||||
class RecipePageControls {
|
class RecipePageControls {
|
||||||
@@ -19,16 +19,13 @@ class RecipePageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
await refreshVirtualScroll({ preserveScroll: true });
|
await refreshVirtualScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
if (fullRebuild) {
|
await refreshRecipes(fullRebuild);
|
||||||
await refreshRecipes();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncChanges();
|
await sidebarManager.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSidebarApiClient() {
|
getSidebarApiClient() {
|
||||||
|
|||||||
@@ -100,6 +100,90 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
|
|||||||
return { status, displayName, records, error };
|
return { status, displayName, records, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a model update check scoped to a specific folder.
|
||||||
|
* @param {string} folderPath - The relative folder path to check.
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {Function} [options.onComplete] - Callback invoked after the request settles.
|
||||||
|
* @returns {Promise<{status: string, records: Array, error: Error | null}>}
|
||||||
|
*/
|
||||||
|
export async function performFolderUpdateCheck(folderPath, { onComplete } = {}) {
|
||||||
|
const modelType = getCurrentModelType();
|
||||||
|
const apiConfig = getCompleteApiConfig(modelType);
|
||||||
|
const apiClient = getModelApiClient(modelType);
|
||||||
|
const displayName = apiConfig?.config?.displayName ?? 'Model';
|
||||||
|
|
||||||
|
if (!apiConfig?.endpoints?.refreshUpdates) {
|
||||||
|
console.warn('Refresh updates endpoint not configured for model type:', modelType);
|
||||||
|
onComplete?.({ status: 'unsupported', records: [], error: null });
|
||||||
|
return { status: 'unsupported', records: [], error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingMessage = translate(
|
||||||
|
'sidebar.folderUpdateCheck.loading',
|
||||||
|
{ type: displayName },
|
||||||
|
`Checking ${displayName} updates for this folder...`
|
||||||
|
);
|
||||||
|
|
||||||
|
state.loadingManager?.showSimpleLoading?.(loadingMessage);
|
||||||
|
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
|
||||||
|
|
||||||
|
let status = 'success';
|
||||||
|
let records = [];
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiConfig.endpoints.refreshUpdates, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ folder_path: folderPath, force: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || payload.success !== true) {
|
||||||
|
if (payload?.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return { status: 'cancelled', records: [], error: null };
|
||||||
|
}
|
||||||
|
const errorMessage = payload?.error || response.statusText || 'Unknown error';
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
records = Array.isArray(payload.records) ? payload.records : [];
|
||||||
|
|
||||||
|
if (records.length > 0) {
|
||||||
|
showToast('sidebar.folderUpdateCheck.success', { count: records.length, type: displayName }, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('sidebar.folderUpdateCheck.none', { type: displayName }, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetAndReload(false);
|
||||||
|
} catch (err) {
|
||||||
|
status = 'error';
|
||||||
|
error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
console.error('Error checking folder model updates:', error);
|
||||||
|
showToast(
|
||||||
|
'sidebar.folderUpdateCheck.error',
|
||||||
|
{ message: error?.message ?? 'Unknown error', type: displayName },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
state.loadingManager?.hide?.();
|
||||||
|
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
|
||||||
|
state.loadingManager.restoreProgressBar();
|
||||||
|
}
|
||||||
|
onComplete?.({ status, records, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, records, error };
|
||||||
|
}
|
||||||
|
|
||||||
function getTypePlural(displayName) {
|
function getTypePlural(displayName) {
|
||||||
if (!displayName) {
|
if (!displayName) {
|
||||||
return 'models';
|
return 'models';
|
||||||
|
|||||||
@@ -80,6 +80,9 @@
|
|||||||
<div class="context-menu-item" data-action="check-updates">
|
<div class="context-menu-item" data-action="check-updates">
|
||||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="repair-metadata">
|
||||||
|
<i class="fas fa-tools"></i> <span>{{ t('loras.bulkOperations.repairMetadata') }}</span>
|
||||||
|
</div>
|
||||||
<div class="context-menu-item" data-action="skip-metadata-refresh">
|
<div class="context-menu-item" data-action="skip-metadata-refresh">
|
||||||
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
|
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,6 +150,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Folder Context Menu -->
|
||||||
|
<div id="sidebarFolderContextMenu" class="context-menu">
|
||||||
|
<div class="context-menu-item" data-action="check-folder-updates">
|
||||||
|
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="nsfwLevelSelector" class="nsfw-level-selector">
|
<div id="nsfwLevelSelector" class="nsfw-level-selector">
|
||||||
<div class="nsfw-level-header">
|
<div class="nsfw-level-header">
|
||||||
<h3>{{ t('modals.contentRating.title') }}</h3>
|
<h3>{{ t('modals.contentRating.title') }}</h3>
|
||||||
|
|||||||
@@ -29,6 +29,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2.5: File Selection (optional - only when version has multiple model files) -->
|
||||||
|
<div class="download-step" id="fileSelectionStep" style="display: none;">
|
||||||
|
<div class="file-selection-header">
|
||||||
|
<h3 id="fileSelectionTitle">{{ t('modals.download.fileSelection.title') }}</h3>
|
||||||
|
<div class="file-selection-version-name" id="fileSelectionVersionName"></div>
|
||||||
|
</div>
|
||||||
|
<div class="file-selection-list" id="fileSelectionList">
|
||||||
|
<!-- File options will be rendered here dynamically -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="secondary-btn" id="backToVersionFromFilesBtn">{{ t('common.actions.back') }}</button>
|
||||||
|
<button class="primary-btn" id="confirmFileSelection">{{ t('modals.download.fileSelection.select') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Step 3: Location Selection -->
|
<!-- Step 3: Location Selection -->
|
||||||
<div class="download-step" id="locationStep" style="display: none;">
|
<div class="download-step" id="locationStep" style="display: none;">
|
||||||
<div class="location-selection">
|
<div class="location-selection">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|||||||
const showToastMock = vi.hoisted(() => vi.fn());
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
const loadingManagerMock = vi.hoisted(() => ({
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
showSimpleLoading: vi.fn(),
|
showSimpleLoading: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
restoreProgressBar: vi.fn(),
|
restoreProgressBar: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -177,9 +178,7 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves scroll position for recipe reloads when requested', async () => {
|
it('reloads recipes without preserving scroll', async () => {
|
||||||
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
|
|
||||||
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
|
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -189,18 +188,18 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
await resetAndReload(false);
|
||||||
|
|
||||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(captureScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
||||||
[{ id: 'recipe-1' }],
|
[{ id: 'recipe-1' }],
|
||||||
1,
|
1,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot);
|
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses scroll-preserving reloads for syncChanges', async () => {
|
it('uses scroll-free reloads for syncChanges', async () => {
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -212,8 +211,8 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
|
|
||||||
await syncChanges();
|
await syncChanges();
|
||||||
|
|
||||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(captureScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class DummyUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh=False,
|
force_refresh=False,
|
||||||
target_model_ids=None,
|
target_model_ids=None,
|
||||||
|
folder_path=None,
|
||||||
):
|
):
|
||||||
self.calls.append(
|
self.calls.append(
|
||||||
{
|
{
|
||||||
@@ -54,6 +55,7 @@ class DummyUpdateService:
|
|||||||
"provider": provider,
|
"provider": provider,
|
||||||
"force_refresh": force_refresh,
|
"force_refresh": force_refresh,
|
||||||
"target_model_ids": target_model_ids,
|
"target_model_ids": target_model_ids,
|
||||||
|
"folder_path": folder_path,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.records
|
return self.records
|
||||||
|
|||||||
@@ -467,7 +467,10 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
|||||||
class Provider:
|
class Provider:
|
||||||
async def get_model_version_info(self, model_version_id):
|
async def get_model_version_info(self, model_version_id):
|
||||||
provider_calls.append(model_version_id)
|
provider_calls.append(model_version_id)
|
||||||
return {"baseModel": "Flux Provider"}, None
|
return {
|
||||||
|
"baseModel": "Flux Provider",
|
||||||
|
"model": {"type": "Checkpoint", "name": "Flux"},
|
||||||
|
}, None
|
||||||
|
|
||||||
async def fake_get_default_metadata_provider():
|
async def fake_get_default_metadata_provider():
|
||||||
return Provider()
|
return Provider()
|
||||||
|
|||||||
@@ -298,3 +298,113 @@ async def test_parse_metadata_handles_modelVersionIds(monkeypatch):
|
|||||||
assert lora2["type"] == "lora"
|
assert lora2["type"] == "lora"
|
||||||
assert lora2["hash"] == "aabbccdd0022"
|
assert lora2["hash"] == "aabbccdd0022"
|
||||||
assert lora2["baseModel"] == "SDXL"
|
assert lora2["baseModel"] == "SDXL"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_extracts_checkpoint_from_resources_model_type(monkeypatch):
|
||||||
|
"""resources entries with type:"model" should be captured as the checkpoint,
|
||||||
|
not skipped (which was the old buggy behavior), and not mixed into loras."""
|
||||||
|
captured_hashes = []
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
captured_hashes.append(model_hash)
|
||||||
|
if model_hash == "a1b2c3d4e5":
|
||||||
|
return ({
|
||||||
|
"id": 999,
|
||||||
|
"modelId": 888,
|
||||||
|
"name": "v1.0",
|
||||||
|
"model": {"name": "Real Checkpoint", "type": "Checkpoint"},
|
||||||
|
"baseModel": "SDXL 1.0",
|
||||||
|
"images": [{"url": "https://image.civitai.com/cp/original=true"}],
|
||||||
|
"files": [{"type": "Model", "primary": True, "sizeKB": 1024, "name": "cp.safetensors"}]
|
||||||
|
}, None)
|
||||||
|
return None, "Model not found"
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = CivitaiApiMetadataParser()
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"prompt": "test",
|
||||||
|
"resources": [
|
||||||
|
{"hash": "a1b2c3d4e5", "name": "Real Checkpoint", "type": "model"},
|
||||||
|
{"hash": "f6g7h8i9j0", "name": "Some LoRA", "type": "lora", "weight": 0.8},
|
||||||
|
],
|
||||||
|
"Model hash": "a1b2c3d4e5",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(metadata)
|
||||||
|
|
||||||
|
# The type:"model" resource should be in result["model"], not in result["loras"]
|
||||||
|
assert result["model"] is not None, "checkpoint model should be extracted"
|
||||||
|
assert result["model"]["name"] == "Real Checkpoint"
|
||||||
|
assert result["model"]["hash"] == "a1b2c3d4e5"
|
||||||
|
assert result["model"]["type"] == "model"
|
||||||
|
|
||||||
|
# The LoRA resource should be in result["loras"]
|
||||||
|
assert len(result["loras"]) == 1
|
||||||
|
assert result["loras"][0]["name"] == "Some LoRA"
|
||||||
|
|
||||||
|
# The checkpoint hash should have triggered a lookup
|
||||||
|
assert "a1b2c3d4e5" in captured_hashes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_resources_model_type_does_not_duplicate_checkpoint_in_loras(monkeypatch):
|
||||||
|
"""When a resources entry has type:"model", it should NOT also appear in loras.
|
||||||
|
Regression test for the bug where the checkpoint model appeared in both places."""
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
if model_hash == "cp123hash":
|
||||||
|
return ({
|
||||||
|
"id": 100,
|
||||||
|
"modelId": 200,
|
||||||
|
"name": "v2",
|
||||||
|
"model": {"name": "My Checkpoint", "type": "Checkpoint"},
|
||||||
|
"baseModel": "SDXL",
|
||||||
|
"files": [{"type": "Model", "primary": True, "sizeKB": 1024, "name": "cp.safetensors"}]
|
||||||
|
}, None)
|
||||||
|
if model_hash == "lora1hash":
|
||||||
|
return ({
|
||||||
|
"id": 300,
|
||||||
|
"modelId": 400,
|
||||||
|
"name": "v1",
|
||||||
|
"model": {"name": "Style LoRA", "type": "LORA"},
|
||||||
|
"baseModel": "SDXL",
|
||||||
|
"files": [{"type": "Model", "primary": True, "sizeKB": 512, "name": "style.safetensors"}]
|
||||||
|
}, None)
|
||||||
|
return None, "Model not found"
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = CivitaiApiMetadataParser()
|
||||||
|
metadata = {
|
||||||
|
"resources": [
|
||||||
|
{"hash": "cp123hash", "name": "My Checkpoint", "type": "model"},
|
||||||
|
{"hash": "lora1hash", "name": "Style LoRA", "type": "lora", "weight": 0.5},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(metadata)
|
||||||
|
|
||||||
|
# Checkpoint must NOT appear in loras
|
||||||
|
lora_names = {l["name"] for l in result["loras"]}
|
||||||
|
assert "My Checkpoint" not in lora_names
|
||||||
|
assert "Style LoRA" in lora_names
|
||||||
|
|
||||||
|
# Checkpoint must be in result["model"]
|
||||||
|
assert result["model"] is not None
|
||||||
|
assert result["model"]["name"] == "My Checkpoint"
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
|||||||
"id": 5678,
|
"id": 5678,
|
||||||
"modelId": 1234,
|
"modelId": 1234,
|
||||||
"name": "v1.0",
|
"name": "v1.0",
|
||||||
"model": {"name": "Full Model Name"},
|
"model": {"name": "Full Model Name", "type": "Checkpoint"},
|
||||||
"baseModel": "SDXL 1.0",
|
"baseModel": "SDXL 1.0",
|
||||||
"images": [{"url": "https://image.url/thumb.jpg"}],
|
"images": [{"url": "https://image.url/thumb.jpg"}],
|
||||||
"files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}]
|
"files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}]
|
||||||
@@ -142,7 +142,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
|
|||||||
"id": 5678,
|
"id": 5678,
|
||||||
"modelId": 1234,
|
"modelId": 1234,
|
||||||
"name": "v1.0",
|
"name": "v1.0",
|
||||||
"model": {"name": "Full Model Name"},
|
"model": {"name": "Full Model Name", "type": "Checkpoint"},
|
||||||
"baseModel": "SDXL 1.0",
|
"baseModel": "SDXL 1.0",
|
||||||
"images": [{"url": "https://image.url/thumb.jpg"}],
|
"images": [{"url": "https://image.url/thumb.jpg"}],
|
||||||
"files": [
|
"files": [
|
||||||
@@ -183,7 +183,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
|
|||||||
"id": 999,
|
"id": 999,
|
||||||
"modelId": 888,
|
"modelId": 888,
|
||||||
"name": "v2.0",
|
"name": "v2.0",
|
||||||
"model": {"name": "Hashed Model"},
|
"model": {"name": "Hashed Model", "type": "Checkpoint"},
|
||||||
"baseModel": "SD 1.5",
|
"baseModel": "SD 1.5",
|
||||||
"files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}]
|
"files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}]
|
||||||
}, None)
|
}, None)
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ function parseSearchTokens(term = '') {
|
|||||||
return { include, exclude };
|
return { include, exclude };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapePromptParentheses(text) {
|
||||||
|
// In ComfyUI's CLIP text encoder, bare parentheses are weight adjustment syntax.
|
||||||
|
// Tags containing literal parentheses must be escaped with backslash to prevent
|
||||||
|
// them from being interpreted as weight modifiers. e.g. "foo (bar)" → "foo \(bar\)"
|
||||||
|
return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||||
|
}
|
||||||
|
|
||||||
function formatAutocompleteInsertion(text = '') {
|
function formatAutocompleteInsertion(text = '') {
|
||||||
const trimmed = typeof text === 'string' ? text.trim() : '';
|
const trimmed = typeof text === 'string' ? text.trim() : '';
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -253,7 +260,7 @@ function createDefaultBehavior(modelType) {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return formatAutocompleteInsertion(trimmed);
|
return formatAutocompleteInsertion(escapePromptParentheses(trimmed));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -352,7 +359,7 @@ const MODEL_BEHAVIORS = {
|
|||||||
custom_words: {
|
custom_words: {
|
||||||
enablePreview: false,
|
enablePreview: false,
|
||||||
async getInsertText(_instance, relativePath) {
|
async getInsertText(_instance, relativePath) {
|
||||||
return formatAutocompleteInsertion(relativePath);
|
return formatAutocompleteInsertion(escapePromptParentheses(relativePath));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
prompt: {
|
prompt: {
|
||||||
@@ -399,6 +406,8 @@ const MODEL_BEHAVIORS = {
|
|||||||
tagText = tagText.replace(/_/g, ' ');
|
tagText = tagText.replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tagText = escapePromptParentheses(tagText);
|
||||||
|
|
||||||
return formatAutocompleteInsertion(tagText);
|
return formatAutocompleteInsertion(tagText);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user