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

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

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

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

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

|
|
||||||
|
|
||||||
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Port Configuration
|
|
||||||
|
|
||||||
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
|
|
||||||
|
|
||||||
After correctly setting and saving the port, you'll see in the extension's header area:
|
|
||||||
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Connecting to a Remote LoRA Manager
|
|
||||||
|
|
||||||
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
|
|
||||||
|
|
||||||
> **Why can't you set a remote IP directly?**
|
|
||||||
>
|
|
||||||
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
|
|
||||||
|
|
||||||
**Solution: Port Forwarding with `socat`**
|
|
||||||
|
|
||||||
On your browser computer, run:
|
|
||||||
|
|
||||||
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
|
|
||||||
|
|
||||||
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
|
|
||||||
- Adjust the port if needed.
|
|
||||||
|
|
||||||
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
|
|
||||||
|
|
||||||
_Thanks to user **Temikus** for sharing this solution!_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
|
|
||||||
|
|
||||||
- [x] Support for **additional model types** (e.g., embeddings)
|
|
||||||
- [x] One-click **Recipe Import**
|
|
||||||
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
|
|
||||||
- [x] One-click **Auto-organize Models**
|
|
||||||
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
|
|
||||||
|
|
||||||
**Stay tuned — and thank you for your support!**
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"close": "Schließen"
|
"close": "Schließen",
|
||||||
|
"menu": "Menü"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "Bei Hover anzeigen"
|
"hover": "Bei Hover anzeigen"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||||
|
"showVersionOnCard": "Version auf Karte anzeigen",
|
||||||
|
"showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden",
|
||||||
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Beispielbilder öffnen",
|
"exampleImages": "Beispielbilder öffnen",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Modelliste aktualisieren",
|
"title": "Modelliste aktualisieren",
|
||||||
"quick": "Änderungen synchronisieren",
|
|
||||||
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
|
||||||
"full": "Cache neu aufbauen",
|
"full": "Cache neu aufbauen",
|
||||||
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "Automatisch organisieren",
|
"autoOrganize": "Automatisch organisieren",
|
||||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||||
|
"setFavorite": "Als Favorit setzen",
|
||||||
|
"setFavoriteCount": "Als Favorit setzen ({favorited}/{total})",
|
||||||
|
"unfavorite": "Aus Favoriten entfernen",
|
||||||
"deleteAll": "Ausgewählte löschen",
|
"deleteAll": "Ausgewählte löschen",
|
||||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||||
|
"downloadExamples": "Beispielbilder herunterladen",
|
||||||
"clear": "Auswahl löschen",
|
"clear": "Auswahl löschen",
|
||||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||||
|
"sendToWorkflow": "An Workflow senden",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadaten",
|
||||||
|
"attributes": "Attribute",
|
||||||
|
"organize": "Organisieren",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Automatische Organisation wird initialisiert...",
|
"initializing": "Automatische Organisation wird initialisiert...",
|
||||||
"starting": "Automatische Organisation für {type} wird gestartet...",
|
"starting": "Automatische Organisation für {type} wird gestartet...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Rezeptliste aktualisieren",
|
"title": "Rezeptliste aktualisieren",
|
||||||
"quick": "Änderungen synchronisieren",
|
|
||||||
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
|
|
||||||
"full": "Cache neu aufbauen",
|
"full": "Cache neu aufbauen",
|
||||||
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "Früher Zugriff",
|
"earlyAccess": "Früher Zugriff",
|
||||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||||
"ignored": "Ignoriert",
|
"ignored": "Ignoriert",
|
||||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
|
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
||||||
|
"onSiteOnly": "Nur On-Site",
|
||||||
|
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloadTooltip": "Diese Version herunterladen",
|
"downloadTooltip": "Diese Version herunterladen",
|
||||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||||
|
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"deleteTooltip": "Diese lokale Version löschen",
|
"deleteTooltip": "Diese lokale Version löschen",
|
||||||
"ignore": "Ignorieren",
|
"ignore": "Ignorieren",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||||
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||||
|
"bulkFavoriteUpdating": "Füge {count} Modell(e) zu Favoriten hinzu...",
|
||||||
|
"bulkUnfavoriteUpdating": "Entferne {count} Modell(e) aus Favoriten...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} Modell(e) zu Favoriten hinzugefügt, {failed} fehlgeschlagen",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} Modell(e) aus Favoriten entfernt, {failed} fehlgeschlagen",
|
||||||
|
"bulkFavoriteFailed": "Fehler beim Aktualisieren des Favoritenstatus",
|
||||||
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
||||||
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||||
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
||||||
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
||||||
"exportSuccess": "Diagnosepaket exportiert.",
|
"exportSuccess": "Diagnosepaket exportiert.",
|
||||||
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
|
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}",
|
||||||
|
"conflictsResolved": "{count} Dateinamenskonflikt(e) gelöst.",
|
||||||
|
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"menu": "Menu"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "Reveal on Hover"
|
"hover": "Reveal on Hover"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
||||||
|
"showVersionOnCard": "Show Version on Card",
|
||||||
|
"showVersionOnCardHelp": "Show or hide the version name on model cards",
|
||||||
"modelCardFooterAction": "Model Card Button Action",
|
"modelCardFooterAction": "Model Card Button Action",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Open Example Images",
|
"exampleImages": "Open Example Images",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh model list",
|
"title": "Refresh model list",
|
||||||
"quick": "Sync Changes",
|
|
||||||
"quickTooltip": "Scan for new or missing model files so the list stays current.",
|
|
||||||
"full": "Rebuild Cache",
|
"full": "Rebuild Cache",
|
||||||
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "Auto-Organize Selected",
|
"autoOrganize": "Auto-Organize Selected",
|
||||||
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
||||||
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
||||||
|
"setFavorite": "Set as Favorite",
|
||||||
|
"setFavoriteCount": "Set as Favorite ({favorited}/{total})",
|
||||||
|
"unfavorite": "Remove from Favorites",
|
||||||
"deleteAll": "Delete Selected",
|
"deleteAll": "Delete Selected",
|
||||||
"downloadMissingLoras": "Download Missing LoRAs",
|
"downloadMissingLoras": "Download Missing LoRAs",
|
||||||
|
"downloadExamples": "Download Example Images",
|
||||||
"clear": "Clear Selection",
|
"clear": "Clear Selection",
|
||||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||||
|
"sendToWorkflow": "Send to Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"attributes": "Attributes",
|
||||||
|
"organize": "Organize",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Initializing auto-organize...",
|
"initializing": "Initializing auto-organize...",
|
||||||
"starting": "Starting auto-organize for {type}...",
|
"starting": "Starting auto-organize for {type}...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh recipe list",
|
"title": "Refresh recipe list",
|
||||||
"quick": "Sync Changes",
|
|
||||||
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
|
|
||||||
"full": "Rebuild Cache",
|
"full": "Rebuild Cache",
|
||||||
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
|
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "Early Access",
|
"earlyAccess": "Early Access",
|
||||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||||
"ignored": "Ignored",
|
"ignored": "Ignored",
|
||||||
"ignoredTooltip": "Update notifications are disabled for this version"
|
"ignoredTooltip": "Update notifications are disabled for this version",
|
||||||
|
"onSiteOnly": "On-Site Only",
|
||||||
|
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTooltip": "Download this version",
|
"downloadTooltip": "Download this version",
|
||||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteTooltip": "Delete this local version",
|
"deleteTooltip": "Delete this local version",
|
||||||
"ignore": "Ignore",
|
"ignore": "Ignore",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||||
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||||
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
||||||
|
"bulkFavoriteUpdating": "Adding {count} model(s) to favorites...",
|
||||||
|
"bulkUnfavoriteUpdating": "Removing {count} model(s) from favorites...",
|
||||||
|
"bulkFavoritePartialAdded": "Added {success} model(s) to favorites, {failed} failed",
|
||||||
|
"bulkFavoritePartialRemoved": "Removed {success} model(s) from favorites, {failed} failed",
|
||||||
|
"bulkFavoriteFailed": "Failed to update favorite status for selected models",
|
||||||
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||||
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||||
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "Cache rebuild completed.",
|
"repairSuccess": "Cache rebuild completed.",
|
||||||
"repairFailed": "Cache rebuild failed: {message}",
|
"repairFailed": "Cache rebuild failed: {message}",
|
||||||
"exportSuccess": "Diagnostics bundle exported.",
|
"exportSuccess": "Diagnostics bundle exported.",
|
||||||
"exportFailed": "Failed to export diagnostics bundle: {message}"
|
"exportFailed": "Failed to export diagnostics bundle: {message}",
|
||||||
|
"conflictsResolved": "{count} filename conflict(s) resolved.",
|
||||||
|
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"help": "Ayuda",
|
"help": "Ayuda",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"close": "Cerrar"
|
"close": "Cerrar",
|
||||||
|
"menu": "Menú"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "Mostrar al pasar el ratón"
|
"hover": "Mostrar al pasar el ratón"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||||
|
"showVersionOnCard": "Mostrar versión en la tarjeta",
|
||||||
|
"showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo",
|
||||||
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Abrir imágenes de ejemplo",
|
"exampleImages": "Abrir imágenes de ejemplo",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de modelos",
|
"title": "Actualizar lista de modelos",
|
||||||
"quick": "Sincronizar cambios",
|
|
||||||
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
|
|
||||||
"full": "Reconstruir caché",
|
"full": "Reconstruir caché",
|
||||||
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "Auto-organizar seleccionados",
|
"autoOrganize": "Auto-organizar seleccionados",
|
||||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||||
|
"setFavorite": "Marcar como favorito",
|
||||||
|
"setFavoriteCount": "Marcar como favorito ({favorited}/{total})",
|
||||||
|
"unfavorite": "Quitar de favoritos",
|
||||||
"deleteAll": "Eliminar seleccionados",
|
"deleteAll": "Eliminar seleccionados",
|
||||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||||
|
"downloadExamples": "Descargar imágenes de ejemplo",
|
||||||
"clear": "Limpiar selección",
|
"clear": "Limpiar selección",
|
||||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||||
|
"sendToWorkflow": "Enviar al workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Metadatos",
|
||||||
|
"attributes": "Atributos",
|
||||||
|
"organize": "Organizar",
|
||||||
|
"download": "Descargar"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Inicializando auto-organización...",
|
"initializing": "Inicializando auto-organización...",
|
||||||
"starting": "Iniciando auto-organización para {type}...",
|
"starting": "Iniciando auto-organización para {type}...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de recetas",
|
"title": "Actualizar lista de recetas",
|
||||||
"quick": "Sincronizar cambios",
|
|
||||||
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
|
|
||||||
"full": "Reconstruir caché",
|
"full": "Reconstruir caché",
|
||||||
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "Acceso temprano",
|
"earlyAccess": "Acceso temprano",
|
||||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||||
"ignored": "Ignorada",
|
"ignored": "Ignorada",
|
||||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
|
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
||||||
|
"onSiteOnly": "Solo en Sitio",
|
||||||
|
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"downloadTooltip": "Descargar esta versión",
|
"downloadTooltip": "Descargar esta versión",
|
||||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"deleteTooltip": "Eliminar esta versión local",
|
"deleteTooltip": "Eliminar esta versión local",
|
||||||
"ignore": "Ignorar",
|
"ignore": "Ignorar",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
||||||
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
||||||
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||||
|
"bulkFavoriteUpdating": "Añadiendo {count} modelo(s) a favoritos...",
|
||||||
|
"bulkUnfavoriteUpdating": "Eliminando {count} modelo(s) de favoritos...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} modelo(s) añadido(s) a favoritos, {failed} fallido(s)",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} modelo(s) eliminado(s) de favoritos, {failed} fallido(s)",
|
||||||
|
"bulkFavoriteFailed": "Error al actualizar el estado de favorito",
|
||||||
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
||||||
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||||
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "Reconstrucción de caché completada.",
|
"repairSuccess": "Reconstrucción de caché completada.",
|
||||||
"repairFailed": "Error al reconstruir la caché: {message}",
|
"repairFailed": "Error al reconstruir la caché: {message}",
|
||||||
"exportSuccess": "Paquete de diagnósticos exportado.",
|
"exportSuccess": "Paquete de diagnósticos exportado.",
|
||||||
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
|
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}",
|
||||||
|
"conflictsResolved": "{count} conflicto(s) de nombre de archivo resuelto(s).",
|
||||||
|
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"close": "Fermer"
|
"close": "Fermer",
|
||||||
|
"menu": "Menu"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "Révéler au survol"
|
"hover": "Révéler au survol"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||||
|
"showVersionOnCard": "Afficher la version sur la carte",
|
||||||
|
"showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle",
|
||||||
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Ouvrir les images d'exemple",
|
"exampleImages": "Ouvrir les images d'exemple",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des modèles",
|
"title": "Actualiser la liste des modèles",
|
||||||
"quick": "Synchroniser les changements",
|
|
||||||
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
|
|
||||||
"full": "Reconstruire le cache",
|
"full": "Reconstruire le cache",
|
||||||
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
|
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "Auto-organiser la sélection",
|
"autoOrganize": "Auto-organiser la sélection",
|
||||||
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
|
||||||
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
|
||||||
|
"setFavorite": "Définir comme favori",
|
||||||
|
"setFavoriteCount": "Définir comme favori ({favorited}/{total})",
|
||||||
|
"unfavorite": "Retirer des favoris",
|
||||||
"deleteAll": "Supprimer la sélection",
|
"deleteAll": "Supprimer la sélection",
|
||||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||||
|
"downloadExamples": "Télécharger les images d'exemple",
|
||||||
"clear": "Effacer la sélection",
|
"clear": "Effacer la sélection",
|
||||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||||
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
||||||
|
"sendToWorkflow": "Envoyer au workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Métadonnées",
|
||||||
|
"attributes": "Attributs",
|
||||||
|
"organize": "Organiser",
|
||||||
|
"download": "Télécharger"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Initialisation de l'auto-organisation...",
|
"initializing": "Initialisation de l'auto-organisation...",
|
||||||
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
"starting": "Démarrage de l'auto-organisation pour {type}...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des recipes",
|
"title": "Actualiser la liste des recipes",
|
||||||
"quick": "Synchroniser les changements",
|
|
||||||
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
|
|
||||||
"full": "Reconstruire le cache",
|
"full": "Reconstruire le cache",
|
||||||
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "Accès anticipé",
|
"earlyAccess": "Accès anticipé",
|
||||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||||
"ignored": "Ignorée",
|
"ignored": "Ignorée",
|
||||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
|
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
||||||
|
"onSiteOnly": "Uniquement sur Site",
|
||||||
|
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"downloadTooltip": "Télécharger cette version",
|
"downloadTooltip": "Télécharger cette version",
|
||||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"deleteTooltip": "Supprimer cette version locale",
|
"deleteTooltip": "Supprimer cette version locale",
|
||||||
"ignore": "Ignorer",
|
"ignore": "Ignorer",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
||||||
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
||||||
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||||
|
"bulkFavoriteUpdating": "Ajout de {count} modèle(s) aux favoris...",
|
||||||
|
"bulkUnfavoriteUpdating": "Suppression de {count} modèle(s) des favoris...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} modèle(s) ajouté(s) aux favoris, {failed} échec(s)",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} modèle(s) retiré(s) des favoris, {failed} échec(s)",
|
||||||
|
"bulkFavoriteFailed": "Échec de la mise à jour du statut de favori",
|
||||||
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
|
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
|
||||||
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||||
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "Reconstruction du cache terminée.",
|
"repairSuccess": "Reconstruction du cache terminée.",
|
||||||
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
||||||
"exportSuccess": "Lot de diagnostics exporté.",
|
"exportSuccess": "Lot de diagnostics exporté.",
|
||||||
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
|
"exportFailed": "Échec de l'export du lot de diagnostics : {message}",
|
||||||
|
"conflictsResolved": "{count} conflit(s) de nom de fichier résolu(s).",
|
||||||
|
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "הגדרות",
|
"settings": "הגדרות",
|
||||||
"help": "עזרה",
|
"help": "עזרה",
|
||||||
"add": "הוספה",
|
"add": "הוספה",
|
||||||
"close": "סגור"
|
"close": "סגור",
|
||||||
|
"menu": "תפריט"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "טוען...",
|
"loading": "טוען...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "חשוף בריחוף"
|
"hover": "חשוף בריחוף"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||||
|
"showVersionOnCard": "הצג גרסה בכרטיס",
|
||||||
|
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
|
||||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "פתח תמונות דוגמה",
|
"exampleImages": "פתח תמונות דוגמה",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מודלים",
|
"title": "רענן רשימת מודלים",
|
||||||
"quick": "סנכרון שינויים",
|
|
||||||
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
|
|
||||||
"full": "בניית מטמון מחדש",
|
"full": "בניית מטמון מחדש",
|
||||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||||
|
"setFavorite": "הגדר כמועדף",
|
||||||
|
"setFavoriteCount": "הגדר כמועדף ({favorited}/{total})",
|
||||||
|
"unfavorite": "הסר ממועדפים",
|
||||||
"deleteAll": "מחק נבחרים",
|
"deleteAll": "מחק נבחרים",
|
||||||
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||||
|
"downloadExamples": "הורד תמונות דוגמה",
|
||||||
"clear": "נקה בחירה",
|
"clear": "נקה בחירה",
|
||||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||||
|
"sendToWorkflow": "שלח ל-Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "מטא-נתונים",
|
||||||
|
"attributes": "מאפיינים",
|
||||||
|
"organize": "ארגן",
|
||||||
|
"download": "הורדה"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "מאתחל ארגון אוטומטי...",
|
"initializing": "מאתחל ארגון אוטומטי...",
|
||||||
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
"starting": "מתחיל ארגון אוטומטי עבור {type}...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מתכונים",
|
"title": "רענן רשימת מתכונים",
|
||||||
"quick": "סנכרן שינויים",
|
|
||||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
|
||||||
"full": "בנה מטמון מחדש",
|
"full": "בנה מטמון מחדש",
|
||||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "גישה מוקדמת",
|
"earlyAccess": "גישה מוקדמת",
|
||||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||||
"ignored": "התעלם",
|
"ignored": "התעלם",
|
||||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||||
|
"onSiteOnly": "רק באתר",
|
||||||
|
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "הורדה",
|
"download": "הורדה",
|
||||||
"downloadTooltip": "הורד את הגרסה הזו",
|
"downloadTooltip": "הורד את הגרסה הזו",
|
||||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||||
"delete": "מחיקה",
|
"delete": "מחיקה",
|
||||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||||
"ignore": "התעלם",
|
"ignore": "התעלם",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
|
||||||
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
|
||||||
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
|
||||||
|
"bulkFavoriteUpdating": "מוסיף {count} דגמים למועדפים...",
|
||||||
|
"bulkUnfavoriteUpdating": "מסיר {count} דגמים ממועדפים...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} דגמים נוספו למועדפים, {failed} נכשלו",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} דגמים הוסרו ממועדפים, {failed} נכשלו",
|
||||||
|
"bulkFavoriteFailed": "עדכון סטטוס מועדפים נכשל",
|
||||||
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
|
||||||
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
|
||||||
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||||
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||||
"exportSuccess": "חבילת האבחון יוצאה.",
|
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
|
||||||
|
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
|
||||||
|
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
"close": "閉じる"
|
"close": "閉じる",
|
||||||
|
"menu": "メニュー"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "ホバー時に表示"
|
"hover": "ホバー時に表示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||||
|
"showVersionOnCard": "カードにバージョンを表示",
|
||||||
|
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
|
||||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "例画像を開く",
|
"exampleImages": "例画像を開く",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "モデルリストを更新",
|
"title": "モデルリストを更新",
|
||||||
"quick": "変更を同期",
|
|
||||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
|
||||||
"full": "キャッシュを再構築",
|
"full": "キャッシュを再構築",
|
||||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "自動整理を実行",
|
"autoOrganize": "自動整理を実行",
|
||||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||||
|
"setFavorite": "お気に入りに設定",
|
||||||
|
"setFavoriteCount": "お気に入りに設定 ({favorited}/{total})",
|
||||||
|
"unfavorite": "お気に入りから削除",
|
||||||
"deleteAll": "選択したものを削除",
|
"deleteAll": "選択したものを削除",
|
||||||
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||||
|
"downloadExamples": "例画像をダウンロード",
|
||||||
"clear": "選択をクリア",
|
"clear": "選択をクリア",
|
||||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||||
|
"sendToWorkflow": "ワークフローに送信",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "ワークフロー",
|
||||||
|
"metadata": "メタデータ",
|
||||||
|
"attributes": "属性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "ダウンロード"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "自動整理を初期化中...",
|
"initializing": "自動整理を初期化中...",
|
||||||
"starting": "{type}の自動整理を開始中...",
|
"starting": "{type}の自動整理を開始中...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "レシピリストを更新",
|
"title": "レシピリストを更新",
|
||||||
"quick": "変更を同期",
|
|
||||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
|
||||||
"full": "キャッシュを再構築",
|
"full": "キャッシュを再構築",
|
||||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "早期アクセス",
|
"earlyAccess": "早期アクセス",
|
||||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||||
"ignored": "無視中",
|
"ignored": "無視中",
|
||||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||||
|
"onSiteOnly": "サイト内のみ",
|
||||||
|
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"downloadTooltip": "このバージョンをダウンロード",
|
"downloadTooltip": "このバージョンをダウンロード",
|
||||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||||
|
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"deleteTooltip": "このローカルバージョンを削除",
|
"deleteTooltip": "このローカルバージョンを削除",
|
||||||
"ignore": "無視",
|
"ignore": "無視",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||||
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||||
|
"bulkFavoriteUpdating": "{count} 個のモデルをお気に入りに追加中...",
|
||||||
|
"bulkUnfavoriteUpdating": "{count} 個のモデルをお気に入りから削除中...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} 個のモデルをお気に入りに追加、{failed} 個失敗",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} 個のモデルをお気に入りから削除、{failed} 個失敗",
|
||||||
|
"bulkFavoriteFailed": "お気に入り状態の更新に失敗しました",
|
||||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "キャッシュの再構築が完了しました。",
|
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||||
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||||
"exportSuccess": "診断パッケージをエクスポートしました。",
|
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
|
||||||
|
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
|
||||||
|
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "설정",
|
"settings": "설정",
|
||||||
"help": "도움말",
|
"help": "도움말",
|
||||||
"add": "추가",
|
"add": "추가",
|
||||||
"close": "닫기"
|
"close": "닫기",
|
||||||
|
"menu": "메뉴"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "호버 시 표시"
|
"hover": "호버 시 표시"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||||
|
"showVersionOnCard": "카드에 버전 표시",
|
||||||
|
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
|
||||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "예시 이미지 열기",
|
"exampleImages": "예시 이미지 열기",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "모델 목록 새로고침",
|
"title": "모델 목록 새로고침",
|
||||||
"quick": "변경 사항 동기화",
|
|
||||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
|
||||||
"full": "캐시 재구성",
|
"full": "캐시 재구성",
|
||||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "자동 정리 선택",
|
"autoOrganize": "자동 정리 선택",
|
||||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||||
|
"setFavorite": "즐겨찾기로 설정",
|
||||||
|
"setFavoriteCount": "즐겨찾기로 설정 ({favorited}/{total})",
|
||||||
|
"unfavorite": "즐겨찾기 해제",
|
||||||
"deleteAll": "선택된 항목 삭제",
|
"deleteAll": "선택된 항목 삭제",
|
||||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||||
|
"downloadExamples": "예시 이미지 다운로드",
|
||||||
"clear": "선택 지우기",
|
"clear": "선택 지우기",
|
||||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||||
|
"sendToWorkflow": "워크플로우로 보내기",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "워크플로우",
|
||||||
|
"metadata": "메타데이터",
|
||||||
|
"attributes": "속성",
|
||||||
|
"organize": "정리",
|
||||||
|
"download": "다운로드"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "자동 정리 초기화 중...",
|
"initializing": "자동 정리 초기화 중...",
|
||||||
"starting": "{type}에 대한 자동 정리 시작...",
|
"starting": "{type}에 대한 자동 정리 시작...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "레시피 목록 새로고침",
|
"title": "레시피 목록 새로고침",
|
||||||
"quick": "변경 사항 동기화",
|
|
||||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
|
||||||
"full": "캐시 재구성",
|
"full": "캐시 재구성",
|
||||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "얼리 액세스",
|
"earlyAccess": "얼리 액세스",
|
||||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||||
"ignored": "무시됨",
|
"ignored": "무시됨",
|
||||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||||
|
"onSiteOnly": "사이트 내 전용",
|
||||||
|
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"downloadTooltip": "이 버전 다운로드",
|
"downloadTooltip": "이 버전 다운로드",
|
||||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||||
|
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"deleteTooltip": "이 로컬 버전 삭제",
|
"deleteTooltip": "이 로컬 버전 삭제",
|
||||||
"ignore": "무시",
|
"ignore": "무시",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||||
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||||
|
"bulkFavoriteUpdating": "{count}개 모델을 즐겨찾기에 추가 중...",
|
||||||
|
"bulkUnfavoriteUpdating": "{count}개 모델을 즐겨찾기에서 제거 중...",
|
||||||
|
"bulkFavoritePartialAdded": "{success}개 모델을 즐겨찾기에 추가, {failed}개 실패",
|
||||||
|
"bulkFavoritePartialRemoved": "{success}개 모델을 즐겨찾기에서 제거, {failed}개 실패",
|
||||||
|
"bulkFavoriteFailed": "즐겨찾기 상태 업데이트 실패",
|
||||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||||
"repairFailed": "캐시 재구성 실패: {message}",
|
"repairFailed": "캐시 재구성 실패: {message}",
|
||||||
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||||
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
"exportFailed": "진단 번들 내보내기 실패: {message}",
|
||||||
|
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
|
||||||
|
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"help": "Справка",
|
"help": "Справка",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"close": "Закрыть"
|
"close": "Закрыть",
|
||||||
|
"menu": "Меню"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "Показать при наведении"
|
"hover": "Показать при наведении"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||||
|
"showVersionOnCard": "Показывать версию на карточке",
|
||||||
|
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
|
||||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "Открыть примеры изображений",
|
"exampleImages": "Открыть примеры изображений",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список моделей",
|
"title": "Обновить список моделей",
|
||||||
"quick": "Синхронизировать изменения",
|
|
||||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
|
||||||
"full": "Перестроить кэш",
|
"full": "Перестроить кэш",
|
||||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "Автоматически организовать выбранные",
|
"autoOrganize": "Автоматически организовать выбранные",
|
||||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||||
|
"setFavorite": "Добавить в избранное",
|
||||||
|
"setFavoriteCount": "Добавить в избранное ({favorited}/{total})",
|
||||||
|
"unfavorite": "Удалить из избранного",
|
||||||
"deleteAll": "Удалить выбранные",
|
"deleteAll": "Удалить выбранные",
|
||||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||||
|
"downloadExamples": "Загрузить примеры изображений",
|
||||||
"clear": "Очистить выбор",
|
"clear": "Очистить выбор",
|
||||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||||
|
"sendToWorkflow": "Отправить в Workflow",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "Workflow",
|
||||||
|
"metadata": "Метаданные",
|
||||||
|
"attributes": "Атрибуты",
|
||||||
|
"organize": "Организовать",
|
||||||
|
"download": "Скачать"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "Инициализация автоматической организации...",
|
"initializing": "Инициализация автоматической организации...",
|
||||||
"starting": "Запуск автоматической организации для {type}...",
|
"starting": "Запуск автоматической организации для {type}...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список рецептов",
|
"title": "Обновить список рецептов",
|
||||||
"quick": "Синхронизировать изменения",
|
|
||||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
|
||||||
"full": "Перестроить кэш",
|
"full": "Перестроить кэш",
|
||||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "Ранний доступ",
|
"earlyAccess": "Ранний доступ",
|
||||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||||
"ignored": "Игнорируется",
|
"ignored": "Игнорируется",
|
||||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||||
|
"onSiteOnly": "Только на Сайте",
|
||||||
|
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"downloadTooltip": "Скачать эту версию",
|
"downloadTooltip": "Скачать эту версию",
|
||||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||||
|
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"deleteTooltip": "Удалить эту локальную версию",
|
"deleteTooltip": "Удалить эту локальную версию",
|
||||||
"ignore": "Игнорировать",
|
"ignore": "Игнорировать",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||||
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||||
|
"bulkFavoriteUpdating": "Добавление {count} моделей в избранное...",
|
||||||
|
"bulkUnfavoriteUpdating": "Удаление {count} моделей из избранного...",
|
||||||
|
"bulkFavoritePartialAdded": "{success} моделей добавлено в избранное, {failed} не удалось",
|
||||||
|
"bulkFavoritePartialRemoved": "{success} моделей удалено из избранного, {failed} не удалось",
|
||||||
|
"bulkFavoriteFailed": "Не удалось обновить статус избранного",
|
||||||
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||||
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||||
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "Перестройка кэша завершена.",
|
"repairSuccess": "Перестройка кэша завершена.",
|
||||||
"repairFailed": "Не удалось перестроить кэш: {message}",
|
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||||
"exportSuccess": "Диагностический пакет экспортирован.",
|
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
|
||||||
|
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
|
||||||
|
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"help": "帮助",
|
"help": "帮助",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"menu": "菜单"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "悬停时显示"
|
"hover": "悬停时显示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||||
|
"showVersionOnCard": "在卡片上显示版本",
|
||||||
|
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
|
||||||
"modelCardFooterAction": "模型卡片按钮操作",
|
"modelCardFooterAction": "模型卡片按钮操作",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "打开示例图片",
|
"exampleImages": "打开示例图片",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新模型列表",
|
"title": "刷新模型列表",
|
||||||
"quick": "同步变更",
|
|
||||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
|
||||||
"full": "重建缓存",
|
"full": "重建缓存",
|
||||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "自动整理所选模型",
|
"autoOrganize": "自动整理所选模型",
|
||||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||||
|
"setFavorite": "设为收藏",
|
||||||
|
"setFavoriteCount": "设为收藏 ({favorited}/{total})",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
"deleteAll": "删除已选",
|
"deleteAll": "删除已选",
|
||||||
"downloadMissingLoras": "下载缺失的 LoRAs",
|
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||||
|
"downloadExamples": "下载示例图片",
|
||||||
"clear": "清除选择",
|
"clear": "清除选择",
|
||||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||||
|
"sendToWorkflow": "发送到工作流",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "工作流",
|
||||||
|
"metadata": "元数据",
|
||||||
|
"attributes": "属性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "下载"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "正在初始化自动整理...",
|
"initializing": "正在初始化自动整理...",
|
||||||
"starting": "正在为 {type} 启动自动整理...",
|
"starting": "正在为 {type} 启动自动整理...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新配方列表",
|
"title": "刷新配方列表",
|
||||||
"quick": "同步变更",
|
|
||||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
|
||||||
"full": "重建缓存",
|
"full": "重建缓存",
|
||||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "抢先体验",
|
"earlyAccess": "抢先体验",
|
||||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已关闭更新通知"
|
"ignoredTooltip": "此版本已关闭更新通知",
|
||||||
|
"onSiteOnly": "仅站内生成",
|
||||||
|
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTooltip": "下载此版本",
|
"downloadTooltip": "下载此版本",
|
||||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||||
|
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleteTooltip": "删除此本地版本",
|
"deleteTooltip": "删除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||||
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||||
|
"bulkFavoriteUpdating": "正在将 {count} 个模型添加到收藏...",
|
||||||
|
"bulkUnfavoriteUpdating": "正在将 {count} 个模型从收藏移除...",
|
||||||
|
"bulkFavoritePartialAdded": "已将 {success} 个模型添加到收藏,{failed} 个失败",
|
||||||
|
"bulkFavoritePartialRemoved": "已将 {success} 个模型从收藏移除,{failed} 个失败",
|
||||||
|
"bulkFavoriteFailed": "更新收藏状态失败",
|
||||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "缓存重建完成。",
|
"repairSuccess": "缓存重建完成。",
|
||||||
"repairFailed": "缓存重建失败:{message}",
|
"repairFailed": "缓存重建失败:{message}",
|
||||||
"exportSuccess": "诊断包已导出。",
|
"exportSuccess": "诊断包已导出。",
|
||||||
"exportFailed": "导出诊断包失败:{message}"
|
"exportFailed": "导出诊断包失败:{message}",
|
||||||
|
"conflictsResolved": "已解决 {count} 个文件名冲突。",
|
||||||
|
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"help": "說明",
|
"help": "說明",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
"close": "關閉"
|
"close": "關閉",
|
||||||
|
"menu": "選單"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "載入中...",
|
"loading": "載入中...",
|
||||||
@@ -428,6 +429,8 @@
|
|||||||
"hover": "滑鼠懸停顯示"
|
"hover": "滑鼠懸停顯示"
|
||||||
},
|
},
|
||||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||||
|
"showVersionOnCard": "在卡片上顯示版本",
|
||||||
|
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
|
||||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||||
"modelCardFooterActionOptions": {
|
"modelCardFooterActionOptions": {
|
||||||
"exampleImages": "開啟範例圖片",
|
"exampleImages": "開啟範例圖片",
|
||||||
@@ -637,8 +640,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理模型列表",
|
"title": "重新整理模型列表",
|
||||||
"quick": "同步變更",
|
|
||||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
|
||||||
"full": "重建快取",
|
"full": "重建快取",
|
||||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||||
},
|
},
|
||||||
@@ -684,11 +685,23 @@
|
|||||||
"autoOrganize": "自動整理所選模型",
|
"autoOrganize": "自動整理所選模型",
|
||||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||||
|
"setFavorite": "設為收藏",
|
||||||
|
"setFavoriteCount": "設為收藏 ({favorited}/{total})",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
"deleteAll": "刪除所選",
|
"deleteAll": "刪除所選",
|
||||||
"downloadMissingLoras": "下載缺失的 LoRAs",
|
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||||
|
"downloadExamples": "下載範例圖片",
|
||||||
"clear": "清除選取",
|
"clear": "清除選取",
|
||||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||||
|
"sendToWorkflow": "發送到工作流",
|
||||||
|
"sections": {
|
||||||
|
"workflow": "工作流",
|
||||||
|
"metadata": "元數據",
|
||||||
|
"attributes": "屬性",
|
||||||
|
"organize": "整理",
|
||||||
|
"download": "下載"
|
||||||
|
},
|
||||||
"autoOrganizeProgress": {
|
"autoOrganizeProgress": {
|
||||||
"initializing": "正在初始化自動整理...",
|
"initializing": "正在初始化自動整理...",
|
||||||
"starting": "正在開始自動整理 {type}...",
|
"starting": "正在開始自動整理 {type}...",
|
||||||
@@ -801,8 +814,6 @@
|
|||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理配方列表",
|
"title": "重新整理配方列表",
|
||||||
"quick": "同步變更",
|
|
||||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
|
||||||
"full": "重建快取",
|
"full": "重建快取",
|
||||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||||
},
|
},
|
||||||
@@ -1290,12 +1301,15 @@
|
|||||||
"earlyAccess": "搶先體驗",
|
"earlyAccess": "搶先體驗",
|
||||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已關閉更新通知"
|
"ignoredTooltip": "此版本已關閉更新通知",
|
||||||
|
"onSiteOnly": "僅站內生成",
|
||||||
|
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"downloadTooltip": "下載此版本",
|
"downloadTooltip": "下載此版本",
|
||||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||||
|
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"deleteTooltip": "刪除此本地版本",
|
"deleteTooltip": "刪除此本地版本",
|
||||||
"ignore": "忽略",
|
"ignore": "忽略",
|
||||||
@@ -1693,6 +1707,11 @@
|
|||||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||||
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||||
|
"bulkFavoriteUpdating": "正在將 {count} 個模型加入收藏...",
|
||||||
|
"bulkUnfavoriteUpdating": "正在將 {count} 個模型從收藏移除...",
|
||||||
|
"bulkFavoritePartialAdded": "已將 {success} 個模型加入收藏,{failed} 個失敗",
|
||||||
|
"bulkFavoritePartialRemoved": "已將 {success} 個模型從收藏移除,{failed} 個失敗",
|
||||||
|
"bulkFavoriteFailed": "更新收藏狀態失敗",
|
||||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||||
@@ -1904,7 +1923,9 @@
|
|||||||
"repairSuccess": "快取重建完成。",
|
"repairSuccess": "快取重建完成。",
|
||||||
"repairFailed": "快取重建失敗:{message}",
|
"repairFailed": "快取重建失敗:{message}",
|
||||||
"exportSuccess": "診斷套件已匯出。",
|
"exportSuccess": "診斷套件已匯出。",
|
||||||
"exportFailed": "匯出診斷套件失敗:{message}"
|
"exportFailed": "匯出診斷套件失敗:{message}",
|
||||||
|
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
|
||||||
|
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
96
py/config.py
96
py/config.py
@@ -172,6 +172,12 @@ class Config:
|
|||||||
self.extra_unet_roots: List[str] = []
|
self.extra_unet_roots: List[str] = []
|
||||||
self.extra_embeddings_roots: List[str] = []
|
self.extra_embeddings_roots: List[str] = []
|
||||||
self.recipes_path: str = ""
|
self.recipes_path: str = ""
|
||||||
|
|
||||||
|
# Load extra folder paths from active library settings before symlink scan
|
||||||
|
# so both primary and extra paths are discovered in a single pass.
|
||||||
|
if not standalone_mode:
|
||||||
|
self._load_extra_paths_from_settings()
|
||||||
|
|
||||||
# Scan symbolic links during initialization
|
# Scan symbolic links during initialization
|
||||||
self._initialize_symlink_mappings()
|
self._initialize_symlink_mappings()
|
||||||
|
|
||||||
@@ -179,6 +185,96 @@ class Config:
|
|||||||
# Save the paths to settings.json when running in ComfyUI mode
|
# Save the paths to settings.json when running in ComfyUI mode
|
||||||
self.save_folder_paths_to_settings()
|
self.save_folder_paths_to_settings()
|
||||||
|
|
||||||
|
def _load_extra_paths_from_settings(self) -> None:
|
||||||
|
"""Read extra folder paths from the active library and apply them.
|
||||||
|
|
||||||
|
Called during ``Config.__init__`` before the symlink scan so both primary and
|
||||||
|
extra paths are discovered in a single pass. Mirrors the extra-path
|
||||||
|
portion of ``_apply_library_paths`` without replacing the primary roots
|
||||||
|
that were already resolved from ComfyUI's ``folder_paths``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
library_name = settings_manager.get_active_library_name()
|
||||||
|
libraries = settings_manager.get_libraries()
|
||||||
|
|
||||||
|
if not library_name or library_name not in libraries:
|
||||||
|
return
|
||||||
|
|
||||||
|
library_config = libraries[library_name]
|
||||||
|
if not isinstance(library_config, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
extra_folder_paths = library_config.get("extra_folder_paths")
|
||||||
|
if not isinstance(extra_folder_paths, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
extra_lora = extra_folder_paths.get("loras", []) or []
|
||||||
|
extra_checkpoint = extra_folder_paths.get("checkpoints", []) or []
|
||||||
|
extra_unet = extra_folder_paths.get("unet", []) or []
|
||||||
|
extra_embedding = extra_folder_paths.get("embeddings", []) or []
|
||||||
|
|
||||||
|
if not any([extra_lora, extra_checkpoint, extra_unet, extra_embedding]):
|
||||||
|
return
|
||||||
|
|
||||||
|
filtered_extra_lora = self._filter_overlapping_extra_lora_paths(
|
||||||
|
self.loras_roots, extra_lora
|
||||||
|
)
|
||||||
|
self.extra_loras_roots = self._prepare_lora_paths(filtered_extra_lora)
|
||||||
|
(
|
||||||
|
_,
|
||||||
|
self.extra_checkpoints_roots,
|
||||||
|
self.extra_unet_roots,
|
||||||
|
) = self._prepare_checkpoint_paths(extra_checkpoint, extra_unet)
|
||||||
|
self.extra_embeddings_roots = self._prepare_embedding_paths(
|
||||||
|
extra_embedding
|
||||||
|
)
|
||||||
|
|
||||||
|
recipes_path = library_config.get("recipes_path", "")
|
||||||
|
if isinstance(recipes_path, str) and recipes_path:
|
||||||
|
self.recipes_path = recipes_path
|
||||||
|
|
||||||
|
if self.extra_loras_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra LoRA roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_loras_roots)
|
||||||
|
)
|
||||||
|
if self.extra_checkpoints_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra checkpoint roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_checkpoints_roots)
|
||||||
|
)
|
||||||
|
if self.extra_unet_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra diffusion model roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_unet_roots)
|
||||||
|
)
|
||||||
|
if self.extra_embeddings_roots:
|
||||||
|
logger.info(
|
||||||
|
"Found extra embedding roots:"
|
||||||
|
+ "\n - "
|
||||||
|
+ "\n - ".join(self.extra_embeddings_roots)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Applied library settings for '%s' with extra paths: loras=%s, "
|
||||||
|
"checkpoints=%s, embeddings=%s",
|
||||||
|
library_name,
|
||||||
|
extra_lora,
|
||||||
|
extra_checkpoint,
|
||||||
|
extra_embedding,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"Could not load extra paths from library settings: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
def save_folder_paths_to_settings(self):
|
def save_folder_paths_to_settings(self):
|
||||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -184,39 +184,6 @@ class LoraManager:
|
|||||||
async def _initialize_services(cls):
|
async def _initialize_services(cls):
|
||||||
"""Initialize all services using the ServiceRegistry"""
|
"""Initialize all services using the ServiceRegistry"""
|
||||||
try:
|
try:
|
||||||
# Apply library settings to load extra folder paths before scanning
|
|
||||||
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
|
|
||||||
try:
|
|
||||||
from .services.settings_manager import get_settings_manager
|
|
||||||
|
|
||||||
settings_manager = get_settings_manager()
|
|
||||||
library_name = settings_manager.get_active_library_name()
|
|
||||||
libraries = settings_manager.get_libraries()
|
|
||||||
if library_name and library_name in libraries:
|
|
||||||
library_config = libraries[library_name]
|
|
||||||
# Only apply settings if extra paths are not already configured
|
|
||||||
# This preserves values set by tests via monkeypatch
|
|
||||||
extra_paths = library_config.get("extra_folder_paths", {})
|
|
||||||
has_extra_paths = (
|
|
||||||
config.extra_loras_roots
|
|
||||||
or config.extra_checkpoints_roots
|
|
||||||
or config.extra_unet_roots
|
|
||||||
or config.extra_embeddings_roots
|
|
||||||
)
|
|
||||||
if not has_extra_paths and any(extra_paths.values()):
|
|
||||||
config.apply_library_settings(library_config)
|
|
||||||
logger.info(
|
|
||||||
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
|
|
||||||
library_name,
|
|
||||||
extra_paths.get("loras", []),
|
|
||||||
extra_paths.get("checkpoints", []),
|
|
||||||
extra_paths.get("embeddings", []),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to apply library settings during initialization: %s", exc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize CivitaiClient first to ensure it's ready for other services
|
# Initialize CivitaiClient first to ensure it's ready for other services
|
||||||
await ServiceRegistry.get_civitai_client()
|
await ServiceRegistry.get_civitai_client()
|
||||||
|
|
||||||
|
|||||||
@@ -560,8 +560,14 @@ class MetadataProcessor:
|
|||||||
|
|
||||||
params["loras"] = " ".join(lora_parts)
|
params["loras"] = " ".join(lora_parts)
|
||||||
|
|
||||||
# Set default clip_skip value
|
# Extract clip_skip from any SAMPLING node that provides it
|
||||||
params["clip_skip"] = "1" # Common default
|
for sampler_info in metadata.get(SAMPLING, {}).values():
|
||||||
|
clip_skip = sampler_info.get("parameters", {}).get("clip_skip")
|
||||||
|
if clip_skip is not None:
|
||||||
|
params["clip_skip"] = clip_skip
|
||||||
|
break
|
||||||
|
if params["clip_skip"] is None:
|
||||||
|
params["clip_skip"] = "1"
|
||||||
|
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
|||||||
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
||||||
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class EasyComfyLoaderExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "ckpt_name" in inputs:
|
||||||
|
_store_checkpoint_metadata(metadata, node_id, inputs["ckpt_name"])
|
||||||
|
|
||||||
|
# Only extract from optional_lora_stack — skip the single lora_name to
|
||||||
|
# avoid double-counting LoRAs that come through the LORA_STACK path.
|
||||||
|
active_loras = []
|
||||||
|
optional_lora_stack = inputs.get("optional_lora_stack")
|
||||||
|
if optional_lora_stack is not None and isinstance(optional_lora_stack, (list, tuple)):
|
||||||
|
for item in optional_lora_stack:
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
lora_path = item[0]
|
||||||
|
model_strength = item[1]
|
||||||
|
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": model_strength
|
||||||
|
})
|
||||||
|
|
||||||
|
if active_loras:
|
||||||
|
metadata[LORAS][node_id] = {
|
||||||
|
"lora_list": active_loras,
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
positive_text = inputs.get("positive", "")
|
||||||
|
negative_text = inputs.get("negative", "")
|
||||||
|
|
||||||
|
if positive_text or negative_text:
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
metadata[PROMPTS][node_id]["positive_text"] = positive_text
|
||||||
|
metadata[PROMPTS][node_id]["negative_text"] = negative_text
|
||||||
|
|
||||||
|
if "clip_skip" in inputs:
|
||||||
|
clip_skip = inputs["clip_skip"]
|
||||||
|
if node_id not in metadata[SAMPLING]:
|
||||||
|
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
|
||||||
|
metadata[SAMPLING][node_id]["parameters"]["clip_skip"] = clip_skip
|
||||||
|
|
||||||
|
width = inputs.get("empty_latent_width")
|
||||||
|
height = inputs.get("empty_latent_height")
|
||||||
|
if width is not None and height is not None:
|
||||||
|
if SIZE not in metadata:
|
||||||
|
metadata[SIZE] = {}
|
||||||
|
metadata[SIZE][node_id] = {
|
||||||
|
"width": int(width),
|
||||||
|
"height": int(height),
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(node_id, outputs, metadata):
|
||||||
|
# outputs: [(pipe_dict, model, vae), ...]
|
||||||
|
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||||
|
return
|
||||||
|
first_output = outputs[0]
|
||||||
|
if not isinstance(first_output, tuple) or len(first_output) < 1:
|
||||||
|
return
|
||||||
|
pipe = first_output[0]
|
||||||
|
if not isinstance(pipe, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
positive_conditioning = pipe.get("positive")
|
||||||
|
negative_conditioning = pipe.get("negative")
|
||||||
|
|
||||||
|
if positive_conditioning is not None or negative_conditioning is not None:
|
||||||
|
if node_id not in metadata[PROMPTS]:
|
||||||
|
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||||
|
if positive_conditioning is not None:
|
||||||
|
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
|
||||||
|
if negative_conditioning is not None:
|
||||||
|
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
|
||||||
|
|
||||||
|
|
||||||
|
class EasyPreSamplingExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
sampling_params = {}
|
||||||
|
for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
|
||||||
|
if key in inputs:
|
||||||
|
sampling_params[key] = inputs[key]
|
||||||
|
|
||||||
|
metadata[SAMPLING][node_id] = {
|
||||||
|
"parameters": sampling_params,
|
||||||
|
"node_id": node_id,
|
||||||
|
IS_SAMPLER: True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EasySeedExtractor(NodeMetadataExtractor):
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs or "seed" not in inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata[SAMPLING][node_id] = {
|
||||||
|
"parameters": {"seed": inputs["seed"]},
|
||||||
|
"node_id": node_id,
|
||||||
|
IS_SAMPLER: False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -1013,9 +1125,12 @@ NODE_EXTRACTORS = {
|
|||||||
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
|
||||||
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
|
||||||
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
|
||||||
|
# ComfyUI-Easy-Use pre-sampling / seed
|
||||||
|
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
|
||||||
|
"easySeed": EasySeedExtractor, # easy seed
|
||||||
# Loaders
|
# Loaders
|
||||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
"comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
|
||||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import folder_paths # type: ignore
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from ..config import config
|
||||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_within_loras(abs_path):
|
||||||
|
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
|
||||||
|
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
|
||||||
|
for root in all_roots:
|
||||||
|
try:
|
||||||
|
return os.path.relpath(abs_path, root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
class WanVideoLoraSelectLM:
|
class WanVideoLoraSelectLM:
|
||||||
NAME = "WanVideo Lora Select (LoraManager)"
|
NAME = "WanVideo Lora Select (LoraManager)"
|
||||||
CATEGORY = "Lora Manager/stackers"
|
CATEGORY = "Lora Manager/stackers"
|
||||||
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
|
|||||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||||
|
|
||||||
# Get lora path and trigger words
|
# Get lora path and trigger words
|
||||||
lora_path, trigger_words = get_lora_info(lora_name)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||||
|
|
||||||
# Create lora item for WanVideo format
|
# Create lora item for WanVideo format
|
||||||
lora_item = {
|
lora_item = {
|
||||||
"path": folder_paths.get_full_path("loras", lora_path),
|
"path": lora_path,
|
||||||
"strength": model_strength,
|
"strength": model_strength,
|
||||||
"name": lora_path.split(".")[0],
|
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import folder_paths # type: ignore
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info_absolute
|
||||||
|
from ..config import config
|
||||||
from .utils import any_type
|
from .utils import any_type
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# 初始化日志记录器
|
# 初始化日志记录器
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _relpath_within_loras(abs_path):
|
||||||
|
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
|
||||||
|
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
|
||||||
|
for root in all_roots:
|
||||||
|
try:
|
||||||
|
return os.path.relpath(abs_path, root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return os.path.basename(abs_path)
|
||||||
|
|
||||||
# 定义新节点的类
|
# 定义新节点的类
|
||||||
class WanVideoLoraTextSelectLM:
|
class WanVideoLoraTextSelectLM:
|
||||||
# 节点在UI中显示的名称
|
# 节点在UI中显示的名称
|
||||||
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
lora_path, trigger_words = get_lora_info(lora_name_raw)
|
lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
|
||||||
|
|
||||||
lora_item = {
|
lora_item = {
|
||||||
"path": folder_paths.get_full_path("loras", lora_path),
|
"path": lora_path,
|
||||||
"strength": model_strength,
|
"strength": model_strength,
|
||||||
"name": lora_path.split(".")[0],
|
"name": os.path.splitext(_relpath_within_loras(lora_path))[0],
|
||||||
"blocks": selected_blocks,
|
"blocks": selected_blocks,
|
||||||
"layer_filter": layer_filter,
|
"layer_filter": layer_filter,
|
||||||
"low_mem_load": low_mem_load,
|
"low_mem_load": low_mem_load,
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ class RecipeEnricher:
|
|||||||
async def enrich_recipe(
|
async def enrich_recipe(
|
||||||
recipe: Dict[str, Any],
|
recipe: Dict[str, Any],
|
||||||
civitai_client: Any,
|
civitai_client: Any,
|
||||||
request_params: Optional[Dict[str, Any]] = None
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
|
prefetched_civitai_meta_raw: Optional[Dict[str, Any]] = None,
|
||||||
|
prefetched_model_version_id: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
|
||||||
@@ -25,6 +27,9 @@ class RecipeEnricher:
|
|||||||
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
|
||||||
civitai_client: Authenticated Civitai client instance.
|
civitai_client: Authenticated Civitai client instance.
|
||||||
request_params: (Optional) Parameters from a user request (e.g. import).
|
request_params: (Optional) Parameters from a user request (e.g. import).
|
||||||
|
prefetched_civitai_meta_raw: (Optional) Pre-fetched raw meta from Civitai
|
||||||
|
get_image_info, avoiding a duplicate API call.
|
||||||
|
prefetched_model_version_id: (Optional) Pre-fetched model version ID.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the recipe was modified, False otherwise.
|
bool: True if the recipe was modified, False otherwise.
|
||||||
@@ -32,21 +37,27 @@ class RecipeEnricher:
|
|||||||
updated = False
|
updated = False
|
||||||
gen_params = recipe.get("gen_params", {})
|
gen_params = recipe.get("gen_params", {})
|
||||||
|
|
||||||
# 1. Fetch Civitai Info if available
|
# 1. Obtain Civitai metadata
|
||||||
civitai_meta = None
|
civitai_meta = None
|
||||||
model_version_id = None
|
model_version_id = prefetched_model_version_id
|
||||||
|
|
||||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
source_path = recipe.get("source_path", "")
|
||||||
|
|
||||||
# Check if it's a Civitai image URL
|
if prefetched_civitai_meta_raw is not None:
|
||||||
image_id = extract_civitai_image_id(str(source_url))
|
raw_meta = prefetched_civitai_meta_raw
|
||||||
|
if isinstance(raw_meta, dict):
|
||||||
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
|
civitai_meta = raw_meta["meta"]
|
||||||
|
else:
|
||||||
|
civitai_meta = raw_meta
|
||||||
|
else:
|
||||||
|
image_id = extract_civitai_image_id(str(source_path))
|
||||||
if image_id:
|
if image_id:
|
||||||
try:
|
try:
|
||||||
image_info = await civitai_client.get_image_info(
|
image_info = await civitai_client.get_image_info(
|
||||||
image_id, source_url=str(source_url)
|
image_id, source_url=str(source_path)
|
||||||
)
|
)
|
||||||
if image_info:
|
if image_info:
|
||||||
# Handle nested meta often found in Civitai API responses
|
|
||||||
raw_meta = image_info.get("meta")
|
raw_meta = image_info.get("meta")
|
||||||
if isinstance(raw_meta, dict):
|
if isinstance(raw_meta, dict):
|
||||||
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
|
||||||
@@ -55,16 +66,15 @@ class RecipeEnricher:
|
|||||||
civitai_meta = raw_meta
|
civitai_meta = raw_meta
|
||||||
|
|
||||||
model_version_id = image_info.get("modelVersionId")
|
model_version_id = image_info.get("modelVersionId")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
||||||
|
|
||||||
# If not at top level, check resources in meta
|
|
||||||
if not model_version_id and civitai_meta:
|
if not model_version_id and civitai_meta:
|
||||||
resources = civitai_meta.get("civitaiResources", [])
|
resources = civitai_meta.get("civitaiResources", [])
|
||||||
for res in resources:
|
for res in resources:
|
||||||
if res.get("type") == "checkpoint":
|
if res.get("type") == "checkpoint":
|
||||||
model_version_id = res.get("modelVersionId")
|
model_version_id = res.get("modelVersionId")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch Civitai image info: {e}")
|
|
||||||
|
|
||||||
# 2. Merge Parameters
|
# 2. Merge Parameters
|
||||||
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
# Priority: request_params > civitai_meta > embedded (existing gen_params)
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
|
|||||||
|
|
||||||
def _find_model_file(self, files):
|
def _find_model_file(self, files):
|
||||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
||||||
return next((file for file in files if file.get("type") == "Model" and file.get("primary") is True), None)
|
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
|
||||||
|
|
||||||
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
||||||
"""Expose handlers for subclasses or tests."""
|
"""Expose handlers for subclasses or tests."""
|
||||||
|
|||||||
@@ -33,15 +33,18 @@ from ...services.metadata_service import (
|
|||||||
update_metadata_providers,
|
update_metadata_providers,
|
||||||
)
|
)
|
||||||
from ...services.service_registry import ServiceRegistry
|
from ...services.service_registry import ServiceRegistry
|
||||||
|
from ...services.model_lifecycle_service import delete_model_artifacts
|
||||||
from ...services.settings_manager import get_settings_manager
|
from ...services.settings_manager import get_settings_manager
|
||||||
from ...services.websocket_manager import ws_manager
|
from ...services.websocket_manager import ws_manager
|
||||||
from ...services.downloader import get_downloader
|
from ...services.downloader import get_downloader
|
||||||
from ...services.errors import ResourceNotFoundError
|
from ...services.errors import ResourceNotFoundError
|
||||||
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||||
|
from ...utils.models import BaseModelMetadata
|
||||||
from ...utils.constants import (
|
from ...utils.constants import (
|
||||||
CIVITAI_USER_MODEL_TYPES,
|
CIVITAI_USER_MODEL_TYPES,
|
||||||
DEFAULT_NODE_COLOR,
|
DEFAULT_NODE_COLOR,
|
||||||
NODE_TYPES,
|
NODE_TYPES,
|
||||||
|
PREVIEW_EXTENSIONS,
|
||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
@@ -617,6 +620,7 @@ class DoctorHandler:
|
|||||||
diagnostics = [
|
diagnostics = [
|
||||||
await self._check_civitai_api_key(),
|
await self._check_civitai_api_key(),
|
||||||
await self._check_cache_health(),
|
await self._check_cache_health(),
|
||||||
|
await self._check_filename_conflicts(),
|
||||||
self._check_ui_version(client_version, app_version),
|
self._check_ui_version(client_version, app_version),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -681,6 +685,145 @@ class DoctorHandler:
|
|||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
|
renamed: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
try:
|
||||||
|
scanner = await factory()
|
||||||
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
|
if hash_index is None:
|
||||||
|
continue
|
||||||
|
duplicates = {
|
||||||
|
filename: list(paths)
|
||||||
|
for filename, paths in hash_index.get_duplicate_filenames().items()
|
||||||
|
}
|
||||||
|
if not duplicates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
path_to_model = {m["file_path"]: m for m in cache.raw_data}
|
||||||
|
|
||||||
|
used_basenames: set[str] = set()
|
||||||
|
for paths in duplicates.values():
|
||||||
|
if paths:
|
||||||
|
used_basenames.add(
|
||||||
|
os.path.splitext(os.path.basename(paths[0]))[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
for filename, paths in duplicates.items():
|
||||||
|
for idx, path in enumerate(paths):
|
||||||
|
if idx == 0:
|
||||||
|
continue
|
||||||
|
dirname = os.path.dirname(path)
|
||||||
|
base_name = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
ext = os.path.splitext(path)[1]
|
||||||
|
if not ext:
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_data = path_to_model.get(path)
|
||||||
|
sha256 = (
|
||||||
|
model_data.get("sha256", "") if model_data else ""
|
||||||
|
)
|
||||||
|
hash_provider = (
|
||||||
|
lambda s=sha256: s if s else "0000"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_filename = (
|
||||||
|
BaseModelMetadata.generate_unique_filename(
|
||||||
|
dirname,
|
||||||
|
base_name,
|
||||||
|
ext,
|
||||||
|
hash_provider=hash_provider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_base = os.path.splitext(new_filename)[0]
|
||||||
|
counter = 1
|
||||||
|
original_base = candidate_base
|
||||||
|
while candidate_base in used_basenames:
|
||||||
|
candidate_base = f"{original_base}-{counter}"
|
||||||
|
new_filename = f"{candidate_base}{ext}"
|
||||||
|
counter += 1
|
||||||
|
used_basenames.add(candidate_base)
|
||||||
|
|
||||||
|
new_path = os.path.join(dirname, new_filename)
|
||||||
|
|
||||||
|
if new_filename == os.path.basename(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_base_no_ext = os.path.splitext(path)[0]
|
||||||
|
new_base_no_ext = (
|
||||||
|
os.path.splitext(new_path)[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
os.rename(path, new_path)
|
||||||
|
|
||||||
|
for suffix in (".metadata.json", ".civitai.info"):
|
||||||
|
old_sidecar = old_base_no_ext + suffix
|
||||||
|
new_sidecar = new_base_no_ext + suffix
|
||||||
|
if os.path.exists(old_sidecar):
|
||||||
|
os.rename(old_sidecar, new_sidecar)
|
||||||
|
|
||||||
|
for preview_ext in PREVIEW_EXTENSIONS:
|
||||||
|
old_preview = old_base_no_ext + preview_ext
|
||||||
|
new_preview = new_base_no_ext + preview_ext
|
||||||
|
if os.path.exists(old_preview):
|
||||||
|
os.rename(old_preview, new_preview)
|
||||||
|
|
||||||
|
entry = path_to_model.get(path)
|
||||||
|
if entry:
|
||||||
|
entry = dict(entry)
|
||||||
|
entry["file_name"] = os.path.splitext(new_filename)[0]
|
||||||
|
if entry.get("preview_url"):
|
||||||
|
old_preview_url = entry["preview_url"].replace("\\", "/")
|
||||||
|
preview_ext = os.path.splitext(old_preview_url)[1]
|
||||||
|
if preview_ext:
|
||||||
|
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
|
||||||
|
await scanner.update_single_model_cache(
|
||||||
|
path, new_path, entry
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Resolved duplicate filename '%s': "
|
||||||
|
"renamed '%s' to '%s'",
|
||||||
|
filename,
|
||||||
|
path,
|
||||||
|
new_path,
|
||||||
|
)
|
||||||
|
renamed.append({
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"filename": filename,
|
||||||
|
"old_path": path,
|
||||||
|
"new_path": new_path,
|
||||||
|
"new_filename": new_filename,
|
||||||
|
})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error(
|
||||||
|
"Failed to resolve filename conflicts for %s: %s",
|
||||||
|
model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"renamed": renamed,
|
||||||
|
"count": len(renamed),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Error resolving filename conflicts: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
|
async def export_doctor_bundle(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
@@ -846,6 +989,79 @@ class DoctorHandler:
|
|||||||
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
|
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _check_filename_conflicts(self) -> dict[str, Any]:
|
||||||
|
all_conflicts: list[dict[str, Any]] = []
|
||||||
|
total_conflict_groups = 0
|
||||||
|
total_conflict_files = 0
|
||||||
|
|
||||||
|
for model_type, label, factory in self._scanner_factories:
|
||||||
|
try:
|
||||||
|
scanner = await factory()
|
||||||
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
|
if hash_index is None:
|
||||||
|
continue
|
||||||
|
duplicates = hash_index.get_duplicate_filenames()
|
||||||
|
if not duplicates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_conflict_groups += len(duplicates)
|
||||||
|
for filename, paths in duplicates.items():
|
||||||
|
total_conflict_files += len(paths)
|
||||||
|
all_conflicts.append({
|
||||||
|
"model_type": model_type,
|
||||||
|
"label": label,
|
||||||
|
"filename": filename,
|
||||||
|
"paths": paths,
|
||||||
|
})
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.error(
|
||||||
|
"Doctor filename conflict check failed for %s: %s",
|
||||||
|
model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all_conflicts:
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "ok",
|
||||||
|
"summary": "No duplicate filenames found across model directories.",
|
||||||
|
"details": [],
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"{total_conflict_groups} filename(s) shared by "
|
||||||
|
f"{total_conflict_files} files across your library. "
|
||||||
|
f"This causes ambiguity when loading LoRAs by name."
|
||||||
|
)
|
||||||
|
details: list[str | dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"conflict_groups": total_conflict_groups,
|
||||||
|
"total_conflict_files": total_conflict_files,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
for conflict in all_conflicts:
|
||||||
|
details.append(
|
||||||
|
f"[{conflict['label']}] '{conflict['filename']}' "
|
||||||
|
f"found in {len(conflict['paths'])} locations"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "filename_conflicts",
|
||||||
|
"title": "Duplicate Filename Conflicts",
|
||||||
|
"status": "warning",
|
||||||
|
"summary": summary,
|
||||||
|
"details": details,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "resolve-filename-conflicts",
|
||||||
|
"label": "Resolve Conflicts",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
|
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
|
||||||
if client_version and client_version != app_version:
|
if client_version and client_version != app_version:
|
||||||
return {
|
return {
|
||||||
@@ -1576,15 +1792,19 @@ class ModelLibraryHandler:
|
|||||||
exists = True
|
exists = True
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
|
|
||||||
|
if exists:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"exists": True,
|
||||||
|
"modelType": model_type,
|
||||||
|
"hasBeenDownloaded": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
has_been_downloaded = False
|
has_been_downloaded = False
|
||||||
history_type = model_type
|
history_type = None
|
||||||
if history_type:
|
|
||||||
has_been_downloaded = await history_service.has_been_downloaded(
|
|
||||||
history_type,
|
|
||||||
model_version_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
if await history_service.has_been_downloaded(
|
if await history_service.has_been_downloaded(
|
||||||
candidate_type,
|
candidate_type,
|
||||||
@@ -1597,8 +1817,8 @@ class ModelLibraryHandler:
|
|||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"exists": exists,
|
"exists": False,
|
||||||
"modelType": model_type if exists else history_type,
|
"modelType": history_type,
|
||||||
"hasBeenDownloaded": has_been_downloaded,
|
"hasBeenDownloaded": has_been_downloaded,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1618,29 +1838,35 @@ class ModelLibraryHandler:
|
|||||||
model_type = None
|
model_type = None
|
||||||
versions = []
|
versions = []
|
||||||
downloaded_version_ids = []
|
downloaded_version_ids = []
|
||||||
history_service = await self._get_download_history_service()
|
|
||||||
if lora_versions:
|
if lora_versions:
|
||||||
model_type = "lora"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(lora_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "lora",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(lora_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
elif checkpoint_versions:
|
if checkpoint_versions:
|
||||||
model_type = "checkpoint"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(checkpoint_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "checkpoint",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
elif embedding_versions:
|
if embedding_versions:
|
||||||
model_type = "embedding"
|
return web.json_response(
|
||||||
versions = self._with_downloaded_flag(embedding_versions)
|
{
|
||||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
"success": True,
|
||||||
model_type,
|
"modelType": "embedding",
|
||||||
model_id,
|
"versions": self._with_downloaded_flag(embedding_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||||
candidate_downloaded_version_ids = (
|
candidate_downloaded_version_ids = (
|
||||||
await history_service.get_downloaded_version_ids(
|
await history_service.get_downloaded_version_ids(
|
||||||
@@ -1665,6 +1891,86 @@ class ModelLibraryHandler:
|
|||||||
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def check_models_exist(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_ids_raw = request.query.get("modelIds", "")
|
||||||
|
if not model_ids_raw:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_ids = model_ids_raw.split(",")
|
||||||
|
seen: set[int] = set()
|
||||||
|
model_ids: list[int] = []
|
||||||
|
for raw in raw_ids:
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mid = int(stripped)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if mid not in seen:
|
||||||
|
seen.add(mid)
|
||||||
|
model_ids.append(mid)
|
||||||
|
|
||||||
|
if not model_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
for model_id in model_ids:
|
||||||
|
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if lora_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "lora",
|
||||||
|
"versions": self._with_downloaded_flag(lora_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if checkpoint_scanner:
|
||||||
|
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if checkpoint_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "checkpoint",
|
||||||
|
"versions": self._with_downloaded_flag(checkpoint_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if embedding_scanner:
|
||||||
|
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if embedding_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "embedding",
|
||||||
|
"versions": self._with_downloaded_flag(embedding_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": None,
|
||||||
|
"versions": [],
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "results": results}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to check models existence: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_version_download_status(
|
async def get_model_version_download_status(
|
||||||
self, request: web.Request
|
self, request: web.Request
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
@@ -1759,7 +2065,7 @@ class ModelLibraryHandler:
|
|||||||
file_path=file_path if isinstance(file_path, str) else None,
|
file_path=file_path if isinstance(file_path, str) else None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await history_service.mark_not_downloaded(model_type, model_version_id)
|
await history_service.mark_as_deleted(model_type, model_version_id)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1777,6 +2083,89 @@ class ModelLibraryHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def delete_model_version(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
model_version_id_str = request.query.get("modelVersionId")
|
||||||
|
if not model_version_id_str:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Missing required parameter: modelVersionId"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model_version_id = int(model_version_id_str)
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
|
found_type = None
|
||||||
|
file_path = None
|
||||||
|
found_cache = None
|
||||||
|
|
||||||
|
for model_type, scanner in (
|
||||||
|
("lora", lora_scanner),
|
||||||
|
("checkpoint", checkpoint_scanner),
|
||||||
|
("embedding", embedding_scanner),
|
||||||
|
):
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
if cache and model_version_id in cache.version_index:
|
||||||
|
found_type = model_type
|
||||||
|
found_cache = cache
|
||||||
|
entry = cache.version_index[model_version_id]
|
||||||
|
file_path = entry.get("file_path")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model version not found in any scanner cache"},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
target_dir = os.path.dirname(file_path)
|
||||||
|
base_name = os.path.basename(file_path)
|
||||||
|
file_name, extension = os.path.splitext(base_name)
|
||||||
|
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||||
|
|
||||||
|
if found_cache:
|
||||||
|
found_cache.raw_data = [
|
||||||
|
item
|
||||||
|
for item in found_cache.raw_data
|
||||||
|
if item.get("file_path") != file_path
|
||||||
|
]
|
||||||
|
await found_cache.resort()
|
||||||
|
|
||||||
|
scanner_map = {
|
||||||
|
"lora": lora_scanner,
|
||||||
|
"checkpoint": checkpoint_scanner,
|
||||||
|
"embedding": embedding_scanner,
|
||||||
|
}
|
||||||
|
scanner = scanner_map.get(found_type)
|
||||||
|
if scanner:
|
||||||
|
persist = getattr(scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist):
|
||||||
|
await persist()
|
||||||
|
|
||||||
|
history_service = await self._get_download_history_service()
|
||||||
|
await history_service.mark_as_deleted(found_type, model_version_id)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"modelType": found_type,
|
||||||
|
"modelVersionId": model_version_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete model version: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
model_id_str = request.query.get("modelId")
|
model_id_str = request.query.get("modelId")
|
||||||
@@ -2796,6 +3185,7 @@ class MiscHandlerSet:
|
|||||||
"update_settings": self.settings.update_settings,
|
"update_settings": self.settings.update_settings,
|
||||||
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
||||||
"repair_doctor_cache": self.doctor.repair_doctor_cache,
|
"repair_doctor_cache": self.doctor.repair_doctor_cache,
|
||||||
|
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
|
||||||
"export_doctor_bundle": self.doctor.export_doctor_bundle,
|
"export_doctor_bundle": self.doctor.export_doctor_bundle,
|
||||||
"get_priority_tags": self.settings.get_priority_tags,
|
"get_priority_tags": self.settings.get_priority_tags,
|
||||||
"get_settings_libraries": self.settings.get_libraries,
|
"get_settings_libraries": self.settings.get_libraries,
|
||||||
@@ -2809,8 +3199,10 @@ class MiscHandlerSet:
|
|||||||
"update_node_widget": self.node_registry.update_node_widget,
|
"update_node_widget": self.node_registry.update_node_widget,
|
||||||
"get_registry": self.node_registry.get_registry,
|
"get_registry": self.node_registry.get_registry,
|
||||||
"check_model_exists": self.model_library.check_model_exists,
|
"check_model_exists": self.model_library.check_model_exists,
|
||||||
|
"check_models_exist": self.model_library.check_models_exist,
|
||||||
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
||||||
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
||||||
|
"delete_model_version": self.model_library.delete_model_version,
|
||||||
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
||||||
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
||||||
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
||||||
|
|||||||
@@ -2423,6 +2423,7 @@ class ModelUpdateHandler:
|
|||||||
"shouldIgnore": version.should_ignore,
|
"shouldIgnore": version.should_ignore,
|
||||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||||
"isEarlyAccess": is_early_access,
|
"isEarlyAccess": is_early_access,
|
||||||
|
"usageControl": version.usage_control,
|
||||||
"filePath": context.get("file_path"),
|
"filePath": context.get("file_path"),
|
||||||
"fileName": context.get("file_name"),
|
"fileName": context.get("file_name"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ class RecipeHandlerSet:
|
|||||||
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
||||||
"start_directory_import": self.batch_import.start_directory_import,
|
"start_directory_import": self.batch_import.start_directory_import,
|
||||||
"browse_directory": self.batch_import.browse_directory,
|
"browse_directory": self.batch_import.browse_directory,
|
||||||
|
"check_image_exists": self.management.check_image_exists,
|
||||||
|
"import_from_url": self.management.import_from_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -541,7 +543,7 @@ class RecipeQueryHandler:
|
|||||||
)
|
)
|
||||||
response_data.append(
|
response_data.append(
|
||||||
{
|
{
|
||||||
"type": "source_url",
|
"type": "source_path",
|
||||||
"fingerprint": url,
|
"fingerprint": url,
|
||||||
"count": len(recipes),
|
"count": len(recipes),
|
||||||
"recipes": recipes,
|
"recipes": recipes,
|
||||||
@@ -607,6 +609,7 @@ class RecipeManagementHandler:
|
|||||||
self._downloader_factory = downloader_factory
|
self._downloader_factory = downloader_factory
|
||||||
self._civitai_client_getter = civitai_client_getter
|
self._civitai_client_getter = civitai_client_getter
|
||||||
self._ws_manager = ws_manager
|
self._ws_manager = ws_manager
|
||||||
|
self._import_semaphore = asyncio.Semaphore(2)
|
||||||
|
|
||||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -767,25 +770,53 @@ class RecipeManagementHandler:
|
|||||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Initial Metadata Construction
|
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
||||||
|
async with self._import_semaphore:
|
||||||
|
return await self._do_import_remote_recipe(
|
||||||
|
image_url=image_url,
|
||||||
|
name=name,
|
||||||
|
lora_entries=lora_entries,
|
||||||
|
checkpoint_entry=checkpoint_entry,
|
||||||
|
gen_params_request=gen_params_request,
|
||||||
|
tags=self._parse_tags(params.get("tags")),
|
||||||
|
base_model=params.get("base_model", "") or "",
|
||||||
|
source_path=params.get("source_path") or image_url,
|
||||||
|
)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except RecipeDownloadError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error importing recipe from remote source: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _do_import_remote_recipe(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
image_url: str,
|
||||||
|
name: str,
|
||||||
|
lora_entries: list,
|
||||||
|
checkpoint_entry: dict,
|
||||||
|
gen_params_request: dict,
|
||||||
|
tags: list,
|
||||||
|
base_model: str,
|
||||||
|
source_path: str,
|
||||||
|
) -> web.Response:
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"base_model": params.get("base_model", "") or "",
|
"base_model": base_model,
|
||||||
"loras": lora_entries,
|
"loras": lora_entries,
|
||||||
"gen_params": gen_params_request or {},
|
"gen_params": gen_params_request or {},
|
||||||
"source_url": image_url,
|
"source_path": source_path,
|
||||||
}
|
}
|
||||||
|
|
||||||
source_path = params.get("source_path")
|
|
||||||
if source_path:
|
|
||||||
metadata["source_path"] = source_path
|
|
||||||
|
|
||||||
# Checkpoint handling
|
|
||||||
if checkpoint_entry:
|
if checkpoint_entry:
|
||||||
metadata["checkpoint"] = checkpoint_entry
|
metadata["checkpoint"] = checkpoint_entry
|
||||||
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
|
||||||
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
|
||||||
|
|
||||||
# Try to resolve base model from checkpoint if not explicitly provided
|
|
||||||
if not metadata["base_model"]:
|
if not metadata["base_model"]:
|
||||||
base_model_from_metadata = (
|
base_model_from_metadata = (
|
||||||
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||||
@@ -793,30 +824,17 @@ class RecipeManagementHandler:
|
|||||||
if base_model_from_metadata:
|
if base_model_from_metadata:
|
||||||
metadata["base_model"] = base_model_from_metadata
|
metadata["base_model"] = base_model_from_metadata
|
||||||
|
|
||||||
tags = self._parse_tags(params.get("tags"))
|
# Download image
|
||||||
|
|
||||||
# 3. Download Image
|
|
||||||
(
|
(
|
||||||
image_bytes,
|
image_bytes,
|
||||||
extension,
|
extension,
|
||||||
civitai_meta_from_download,
|
civitai_meta_raw,
|
||||||
|
model_version_id,
|
||||||
) = await self._download_remote_media(image_url)
|
) = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
# 4. Extract Embedded Metadata
|
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
||||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
|
||||||
# with embedded data if we want it to merge it.
|
|
||||||
# However, logic in Enricher merges: request > civitai > embedded.
|
|
||||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
|
||||||
# OR pass them to enricher to handle?
|
|
||||||
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
|
||||||
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
|
||||||
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
|
||||||
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
|
||||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
|
||||||
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
|
||||||
|
|
||||||
# Let's extract embedded metadata first
|
|
||||||
embedded_gen_params = {}
|
embedded_gen_params = {}
|
||||||
|
parsed_embedded = None
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
suffix=extension, delete=False
|
suffix=extension, delete=False
|
||||||
@@ -825,7 +843,9 @@ class RecipeManagementHandler:
|
|||||||
temp_img_path = temp_img.name
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
raw_embedded = await asyncio.to_thread(
|
||||||
|
ExifUtils.extract_image_metadata, temp_img_path
|
||||||
|
)
|
||||||
if raw_embedded:
|
if raw_embedded:
|
||||||
parser = (
|
parser = (
|
||||||
self._analysis_service._recipe_parser_factory.create_parser(
|
self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
@@ -848,27 +868,44 @@ class RecipeManagementHandler:
|
|||||||
"Failed to extract embedded metadata during import: %s", exc
|
"Failed to extract embedded metadata during import: %s", exc
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
|
# Fallback: if EXIF extraction yielded nothing, parse Civitai API meta directly
|
||||||
|
# (same approach as analyze_remote_image — downloaded Civitai images often
|
||||||
|
# have no embedded EXIF but the API meta contains resources/hashes)
|
||||||
|
if parsed_embedded is None and civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw
|
||||||
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||||
|
if isinstance(civitai_inner_meta, dict):
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
civitai_inner_meta
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
|
||||||
if embedded_gen_params:
|
if embedded_gen_params:
|
||||||
# Merge embedded into existing gen_params (which currently only has request params if any)
|
|
||||||
# But wait, we want request params to override everything.
|
|
||||||
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
|
||||||
metadata["gen_params"] = embedded_gen_params
|
metadata["gen_params"] = embedded_gen_params
|
||||||
|
|
||||||
# 5. Enrich with unified logic
|
if parsed_embedded:
|
||||||
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
|
if parsed_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = parsed_loras
|
||||||
|
parsed_model = parsed_embedded.get("model")
|
||||||
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = parsed_model
|
||||||
|
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
await RecipeEnricher.enrich_recipe(
|
await RecipeEnricher.enrich_recipe(
|
||||||
recipe=metadata,
|
recipe=metadata,
|
||||||
civitai_client=civitai_client,
|
civitai_client=civitai_client,
|
||||||
request_params=gen_params_request, # Pass explicit request params here to override
|
request_params=gen_params_request,
|
||||||
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||||
|
prefetched_model_version_id=model_version_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
|
|
||||||
# we might want to manually merge it?
|
|
||||||
# But usually `import_remote_recipe` is used with Civitai URLs.
|
|
||||||
# For now, relying on Enricher's internal fetch is consistent with repair.
|
|
||||||
|
|
||||||
result = await self._persistence_service.save_recipe(
|
result = await self._persistence_service.save_recipe(
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_bytes=image_bytes,
|
image_bytes=image_bytes,
|
||||||
@@ -879,15 +916,6 @@ class RecipeManagementHandler:
|
|||||||
extension=extension,
|
extension=extension,
|
||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
return web.json_response(result.payload, status=result.status)
|
||||||
except RecipeValidationError as exc:
|
|
||||||
return web.json_response({"error": str(exc)}, status=400)
|
|
||||||
except RecipeDownloadError as exc:
|
|
||||||
return web.json_response({"error": str(exc)}, status=400)
|
|
||||||
except Exception as exc:
|
|
||||||
self._logger.error(
|
|
||||||
"Error importing recipe from remote source: %s", exc, exc_info=True
|
|
||||||
)
|
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
|
||||||
|
|
||||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -1190,7 +1218,7 @@ class RecipeManagementHandler:
|
|||||||
"exclude": False,
|
"exclude": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]:
|
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any, Any]:
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
downloader = await self._downloader_factory()
|
downloader = await self._downloader_factory()
|
||||||
temp_path = None
|
temp_path = None
|
||||||
@@ -1238,10 +1266,18 @@ class RecipeManagementHandler:
|
|||||||
extension = ".webp" # Default to webp if unknown
|
extension = ".webp" # Default to webp if unknown
|
||||||
|
|
||||||
with open(temp_path, "rb") as file_obj:
|
with open(temp_path, "rb") as file_obj:
|
||||||
|
model_ver_id = None
|
||||||
|
if civitai_image_id and image_info:
|
||||||
|
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]
|
||||||
return (
|
return (
|
||||||
file_obj.read(),
|
file_obj.read(),
|
||||||
extension,
|
extension,
|
||||||
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,
|
||||||
|
model_ver_id,
|
||||||
)
|
)
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
@@ -1289,6 +1325,205 @@ class RecipeManagementHandler:
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
async def check_image_exists(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
image_ids_raw = request.query.get("image_ids", "")
|
||||||
|
if not image_ids_raw:
|
||||||
|
return web.json_response({"success": True, "results": {}})
|
||||||
|
|
||||||
|
requested_ids = set()
|
||||||
|
for raw in image_ids_raw.split(","):
|
||||||
|
stripped = raw.strip()
|
||||||
|
if stripped and stripped.isdigit():
|
||||||
|
requested_ids.add(stripped)
|
||||||
|
|
||||||
|
if not requested_ids:
|
||||||
|
return web.json_response({"success": True, "results": {}})
|
||||||
|
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
|
# Build lookup: image_id -> recipe_id from stored source_path
|
||||||
|
image_to_recipe = {}
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
source = recipe.get("source_path")
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
image_id = extract_civitai_image_id(source)
|
||||||
|
if image_id and image_id not in image_to_recipe:
|
||||||
|
image_to_recipe[image_id] = recipe.get("id")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for img_id in requested_ids:
|
||||||
|
recipe_id = image_to_recipe.get(img_id)
|
||||||
|
results[img_id] = {
|
||||||
|
"in_library": recipe_id is not None,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "results": results})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error checking image existence: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def import_from_url(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
image_url = request.query.get("image_url")
|
||||||
|
if not image_url:
|
||||||
|
raise RecipeValidationError("Missing required field: image_url")
|
||||||
|
|
||||||
|
image_id = extract_civitai_image_id(image_url)
|
||||||
|
if not image_id:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Could not extract Civitai image ID from URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for duplicate (fast, before acquiring semaphore)
|
||||||
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
|
source = recipe.get("source_path")
|
||||||
|
if source:
|
||||||
|
existing_id = extract_civitai_image_id(source)
|
||||||
|
if existing_id == image_id:
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": recipe.get("id"),
|
||||||
|
"name": recipe.get("title", ""),
|
||||||
|
"already_exists": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
async with self._import_semaphore:
|
||||||
|
return await self._do_import_from_url(image_url, recipe_scanner)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except RecipeDownloadError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=400)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error importing recipe from URL: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def _do_import_from_url(
|
||||||
|
self,
|
||||||
|
image_url: str,
|
||||||
|
recipe_scanner: Any,
|
||||||
|
) -> web.Response:
|
||||||
|
image_id = extract_civitai_image_id(image_url)
|
||||||
|
if not image_id:
|
||||||
|
raise RecipeValidationError(
|
||||||
|
"Could not extract Civitai image ID from URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
image_bytes, extension, civitai_meta_raw, model_version_id = (
|
||||||
|
await self._download_remote_media(image_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract embedded EXIF metadata
|
||||||
|
embedded_gen_params = {}
|
||||||
|
parsed_embedded = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=extension, delete=False
|
||||||
|
) as temp_img:
|
||||||
|
temp_img.write(image_bytes)
|
||||||
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_embedded = await asyncio.to_thread(
|
||||||
|
ExifUtils.extract_image_metadata, temp_img_path
|
||||||
|
)
|
||||||
|
if raw_embedded:
|
||||||
|
parser = (
|
||||||
|
self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
raw_embedded
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_img_path):
|
||||||
|
os.unlink(temp_img_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Failed to extract embedded metadata: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
if parsed_embedded is None and civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw
|
||||||
|
if isinstance(civitai_meta_raw, dict) and "meta" in civitai_meta_raw:
|
||||||
|
civitai_inner_meta = civitai_meta_raw["meta"]
|
||||||
|
if isinstance(civitai_inner_meta, dict):
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
|
civitai_inner_meta
|
||||||
|
)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"base_model": "",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": embedded_gen_params or {},
|
||||||
|
"source_path": image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed_embedded:
|
||||||
|
parsed_loras = parsed_embedded.get("loras")
|
||||||
|
if parsed_loras and not metadata.get("loras"):
|
||||||
|
metadata["loras"] = parsed_loras
|
||||||
|
parsed_model = parsed_embedded.get("model")
|
||||||
|
if parsed_model and not metadata.get("checkpoint"):
|
||||||
|
metadata["checkpoint"] = parsed_model
|
||||||
|
|
||||||
|
civitai_client = self._civitai_client_getter()
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=metadata,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params={},
|
||||||
|
prefetched_civitai_meta_raw=civitai_meta_raw,
|
||||||
|
prefetched_model_version_id=model_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
metadata.get("gen_params", {}).get("prompt")
|
||||||
|
or metadata.get("gen_params", {}).get("positivePrompt")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if prompt:
|
||||||
|
name = " ".join(str(prompt).split()[:10])
|
||||||
|
else:
|
||||||
|
name = f"Civitai Image {image_id}"
|
||||||
|
|
||||||
|
result = await self._persistence_service.save_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
image_base64=None,
|
||||||
|
name=name,
|
||||||
|
tags=[],
|
||||||
|
metadata=metadata,
|
||||||
|
extension=extension,
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
|
|
||||||
class RecipeAnalysisHandler:
|
class RecipeAnalysisHandler:
|
||||||
"""Analyze images to extract recipe metadata."""
|
"""Analyze images to extract recipe metadata."""
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||||
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||||
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||||
|
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
|
||||||
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
||||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||||
@@ -42,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||||
|
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET",
|
"GET",
|
||||||
"/api/lm/model-version-download-status",
|
"/api/lm/model-version-download-status",
|
||||||
@@ -89,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
||||||
),
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
|
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
|
||||||
),
|
),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
|
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/recipes/check-image-exists", "check_image_exists"
|
||||||
|
),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/import-from-url", "import_from_url"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -908,6 +908,17 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
if should_skip or metadata is None:
|
if should_skip or metadata is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Prune stale example-image metadata entries whose files no longer
|
||||||
|
# exist on disk (e.g. a user deleted the files manually).
|
||||||
|
from ..utils.example_images_metadata import MetadataUpdater
|
||||||
|
|
||||||
|
was_modified = await MetadataUpdater.prune_stale_example_images(metadata)
|
||||||
|
if was_modified:
|
||||||
|
asyncio.create_task(
|
||||||
|
MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
)
|
||||||
|
|
||||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||||
|
|
||||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class BatchImportService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
source_path = recipe.get("source_path") or recipe.get("source_url")
|
source_path = recipe.get("source_path")
|
||||||
if source_path and source_path == source:
|
if source_path and source_path == source:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ class CivitaiBaseModelService:
|
|||||||
"zimageturbo": "ZIT",
|
"zimageturbo": "ZIT",
|
||||||
"zimagebase": "ZIB",
|
"zimagebase": "ZIB",
|
||||||
"anima": "ANI",
|
"anima": "ANI",
|
||||||
|
"ernie": "ERNI",
|
||||||
|
"ernie turbo": "ETRB",
|
||||||
|
"nucleus": "NUCL",
|
||||||
"svd": "SVD",
|
"svd": "SVD",
|
||||||
"ltxv": "LTXV",
|
"ltxv": "LTXV",
|
||||||
"ltxv2": "LTV2",
|
"ltxv2": "LTV2",
|
||||||
@@ -418,6 +421,9 @@ class CivitaiBaseModelService:
|
|||||||
"Kolors",
|
"Kolors",
|
||||||
"NoobAI",
|
"NoobAI",
|
||||||
"Anima",
|
"Anima",
|
||||||
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -577,6 +577,59 @@ class CivitaiClient:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
|
||||||
|
|
||||||
|
Uses POST /api/v1/model-versions/by-hash which returns full version
|
||||||
|
details including ``usageControl`` and ``earlyAccessEndsAt`` that are
|
||||||
|
not available from the model-level API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hashes: List of SHA256 hashes (max 100 per batch; auto-split).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of version dicts or None on failure.
|
||||||
|
"""
|
||||||
|
if not hashes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
BATCH_SIZE = 100
|
||||||
|
all_versions: List[Dict] = []
|
||||||
|
|
||||||
|
for start in range(0, len(hashes), BATCH_SIZE):
|
||||||
|
batch = hashes[start : start + BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
success, result = await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.base_url}/model-versions/by-hash",
|
||||||
|
use_auth=True,
|
||||||
|
json=batch,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.warning(
|
||||||
|
"Batch by-hash request failed for %d hashes: %s",
|
||||||
|
len(batch),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
all_versions.extend(result)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Unexpected by-hash response type: %s", type(result)
|
||||||
|
)
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Error fetching model versions by hashes: %s", exc
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_versions if all_versions else None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
"""Fetch all models for a specific Civitai user."""
|
"""Fetch all models for a specific Civitai user."""
|
||||||
if not username:
|
if not username:
|
||||||
|
|||||||
@@ -1364,7 +1364,7 @@ class DownloadManager:
|
|||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary")
|
if f.get("primary")
|
||||||
and f.get("type") in ("Model", "Negative")
|
and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1395,7 +1395,7 @@ class DownloadManager:
|
|||||||
(
|
(
|
||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary") and f.get("type") in ("Model", "Negative")
|
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
|
|||||||
self._db_path = db_path or _resolve_database_path()
|
self._db_path = db_path or _resolve_database_path()
|
||||||
self._settings = settings_manager or get_settings_manager()
|
self._settings = settings_manager or get_settings_manager()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._conn: sqlite3.Connection | None = None
|
||||||
self._schema_initialized = False
|
self._schema_initialized = False
|
||||||
self._ensure_directory()
|
self._ensure_directory()
|
||||||
self._initialize_schema()
|
self._initialize_schema()
|
||||||
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
def _get_conn(self) -> sqlite3.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
return self._conn
|
||||||
|
|
||||||
def _initialize_schema(self) -> None:
|
def _initialize_schema(self) -> None:
|
||||||
if self._schema_initialized:
|
if self._schema_initialized:
|
||||||
return
|
return
|
||||||
@@ -116,7 +123,7 @@ class DownloadedVersionHistoryService:
|
|||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
@@ -180,7 +187,7 @@ class DownloadedVersionHistoryService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
@@ -199,7 +206,7 @@ class DownloadedVersionHistoryService:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
|
||||||
normalized_type = _normalize_model_type(model_type)
|
normalized_type = _normalize_model_type(model_type)
|
||||||
normalized_version_id = _normalize_int(version_id)
|
normalized_version_id = _normalize_int(version_id)
|
||||||
if normalized_type is None or normalized_version_id is None:
|
if normalized_type is None or normalized_version_id is None:
|
||||||
@@ -208,7 +215,7 @@ class DownloadedVersionHistoryService:
|
|||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO downloaded_model_versions (
|
INSERT INTO downloaded_model_versions (
|
||||||
@@ -238,7 +245,7 @@ class DownloadedVersionHistoryService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT is_deleted_override
|
SELECT is_deleted_override
|
||||||
@@ -258,7 +265,7 @@ class DownloadedVersionHistoryService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT version_id
|
SELECT version_id
|
||||||
@@ -291,7 +298,7 @@ class DownloadedVersionHistoryService:
|
|||||||
params: list[object] = [normalized_type, *normalized_model_ids]
|
params: list[object] = [normalized_type, *normalized_model_ids]
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
with self._connect() as conn:
|
conn = self._get_conn()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT model_id, version_id
|
SELECT model_id, version_id
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ class ModelHashIndex:
|
|||||||
hash_val = h
|
hash_val = h
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if hash_val is None:
|
||||||
|
for h, paths in self._duplicate_hashes.items():
|
||||||
|
if file_path in paths:
|
||||||
|
hash_val = h
|
||||||
|
break
|
||||||
|
|
||||||
# If we didn't find a hash, nothing to do
|
# If we didn't find a hash, nothing to do
|
||||||
if not hash_val:
|
if not hash_val:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ class ModelLifecycleService:
|
|||||||
self._scanner._hash_index.remove_by_path(file_path)
|
self._scanner._hash_index.remove_by_path(file_path)
|
||||||
|
|
||||||
await self._sync_update_for_model(model_id)
|
await self._sync_update_for_model(model_id)
|
||||||
|
|
||||||
|
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist_current_cache):
|
||||||
|
await persist_current_cache()
|
||||||
|
|
||||||
return {"success": True, "deleted_files": deleted_files}
|
return {"success": True, "deleted_files": deleted_files}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ class ModelMetadataProvider(ABC):
|
|||||||
"""Fetch model versions for multiple model ids when supported."""
|
"""Fetch model versions for multiple model ids when supported."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
"""Fetch full version details for multiple SHA256 hashes.
|
||||||
|
|
||||||
|
Used specifically to retrieve ``usageControl`` which is only
|
||||||
|
available from the per-version / by-hash API, not from model-level
|
||||||
|
responses. Providers that cannot resolve hashes should let the
|
||||||
|
default ``NotImplementedError`` propagate.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
"""Get specific model version with additional metadata"""
|
"""Get specific model version with additional metadata"""
|
||||||
@@ -141,6 +153,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
|||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict]]:
|
||||||
return await self.client.get_model_versions_bulk(model_ids)
|
return await self.client.get_model_versions_bulk(model_ids)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
return await self.client.get_model_versions_by_hashes(hashes)
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
return await self.client.get_model_version(model_id, version_id)
|
return await self.client.get_model_version(model_id, version_id)
|
||||||
|
|
||||||
@@ -519,6 +536,32 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None, "No provider could retrieve the data"
|
return None, "No provider could retrieve the data"
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
for provider, label in self._iter_providers():
|
||||||
|
try:
|
||||||
|
result = await self._call_with_rate_limit(
|
||||||
|
label,
|
||||||
|
provider.get_model_versions_by_hashes,
|
||||||
|
hashes,
|
||||||
|
)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
except NotImplementedError:
|
||||||
|
continue
|
||||||
|
except RateLimitError as exc:
|
||||||
|
exc.provider = exc.provider or label
|
||||||
|
raise exc
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"Provider %s failed for get_model_versions_by_hashes: %s",
|
||||||
|
label,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
@@ -593,6 +636,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
|||||||
model_ids,
|
model_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self, hashes: List[str]
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
return await self._rate_limit_helper.run(
|
||||||
|
self._label,
|
||||||
|
self._provider.get_model_versions_by_hashes,
|
||||||
|
hashes,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
@@ -669,6 +721,17 @@ class ModelMetadataProviderManager:
|
|||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_model_version_info(version_id)
|
return await provider.get_model_version_info(version_id)
|
||||||
|
|
||||||
|
async def get_model_versions_by_hashes(
|
||||||
|
self,
|
||||||
|
hashes: List[str],
|
||||||
|
provider_name: str = None,
|
||||||
|
) -> Optional[List[Dict]]:
|
||||||
|
provider = self._get_provider(provider_name)
|
||||||
|
try:
|
||||||
|
return await provider.get_model_versions_by_hashes(hashes)
|
||||||
|
except NotImplementedError:
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
||||||
"""Fetch models owned by the specified user"""
|
"""Fetch models owned by the specified user"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
|
|||||||
@@ -1072,14 +1072,6 @@ class ModelScanner:
|
|||||||
excluded_models.append(model_data['file_path'])
|
excluded_models.append(model_data['file_path'])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check for duplicate filename before adding to hash index
|
|
||||||
# filename = os.path.splitext(os.path.basename(file_path))[0]
|
|
||||||
# existing_hash = hash_index.get_hash_by_filename(filename)
|
|
||||||
# if existing_hash and existing_hash != model_data.get('sha256', '').lower():
|
|
||||||
# existing_path = hash_index.get_path(existing_hash)
|
|
||||||
# if existing_path and existing_path != file_path:
|
|
||||||
# logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
|
|
||||||
|
|
||||||
return model_data
|
return model_data
|
||||||
|
|
||||||
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
|
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
|
||||||
@@ -1105,6 +1097,31 @@ class ModelScanner:
|
|||||||
|
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
|
self._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
def _log_duplicate_filename_summary(self) -> None:
|
||||||
|
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
||||||
|
if self._hash_index is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
duplicates = self._hash_index.get_duplicate_filenames()
|
||||||
|
if not duplicates:
|
||||||
|
return
|
||||||
|
|
||||||
|
total_files = sum(len(paths) for paths in duplicates.values())
|
||||||
|
conflict_count = len(duplicates)
|
||||||
|
model_type_label = self.model_type or "model"
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Duplicate filename conflict detected: %d %s filename(s) "
|
||||||
|
"are shared by %d files total, causing ambiguity in %s resolution. "
|
||||||
|
"Open the Doctor panel to resolve one-click.",
|
||||||
|
conflict_count,
|
||||||
|
model_type_label,
|
||||||
|
total_files,
|
||||||
|
model_type_label.capitalize(),
|
||||||
|
)
|
||||||
|
|
||||||
async def _sync_download_history(
|
async def _sync_download_history(
|
||||||
self,
|
self,
|
||||||
raw_data: List[Mapping[str, Any]],
|
raw_data: List[Mapping[str, Any]],
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ModelVersionRecord:
|
|||||||
early_access_ends_at: Optional[str] = None
|
early_access_ends_at: Optional[str] = None
|
||||||
sort_index: int = 0
|
sort_index: int = 0
|
||||||
is_early_access: bool = False
|
is_early_access: bool = False
|
||||||
|
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
|
|||||||
|
|
||||||
return [version.version_id for version in self.versions if version.is_in_library]
|
return [version.version_id for version in self.versions if version.is_in_library]
|
||||||
|
|
||||||
def has_update(self, hide_early_access: bool = False) -> bool:
|
def has_update(
|
||||||
|
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
|
||||||
|
) -> bool:
|
||||||
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
hide_early_access: If True, exclude early access versions from update check.
|
||||||
|
hide_non_downloadable: If True, exclude versions that don't allow downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.should_ignore_model:
|
if self.should_ignore_model:
|
||||||
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
|
|||||||
not version.is_in_library
|
not version.is_in_library
|
||||||
and not version.should_ignore
|
and not version.should_ignore
|
||||||
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
||||||
|
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
|
||||||
for version in self.versions
|
for version in self.versions
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
|
continue
|
||||||
if version.version_id > max_in_library:
|
if version.version_id > max_in_library:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
|
|||||||
# Phase 1: Basic EA flag from bulk API
|
# Phase 1: Basic EA flag from bulk API
|
||||||
return version.is_early_access
|
return version.is_early_access
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_downloadable(version: ModelVersionRecord) -> bool:
|
||||||
|
if version.usage_control is None:
|
||||||
|
return True
|
||||||
|
return version.usage_control == "Download"
|
||||||
|
|
||||||
def has_update_for_base(
|
def has_update_for_base(
|
||||||
self,
|
self,
|
||||||
local_version_id: Optional[int],
|
local_version_id: Optional[int],
|
||||||
local_base_model: Optional[str],
|
local_base_model: Optional[str],
|
||||||
hide_early_access: bool = False,
|
hide_early_access: bool = False,
|
||||||
|
hide_non_downloadable: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True when a newer remote version with the same base model exists.
|
"""Return True when a newer remote version with the same base model exists.
|
||||||
|
|
||||||
@@ -167,6 +181,7 @@ class ModelUpdateRecord:
|
|||||||
local_version_id: The current local version id.
|
local_version_id: The current local version id.
|
||||||
local_base_model: The base model to filter by.
|
local_base_model: The base model to filter by.
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
hide_early_access: If True, exclude early access versions from update check.
|
||||||
|
hide_non_downloadable: If True, exclude versions that don't allow downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.should_ignore_model:
|
if self.should_ignore_model:
|
||||||
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
|
continue
|
||||||
version_base = _normalize_base_model(version.base_model)
|
version_base = _normalize_base_model(version.base_model)
|
||||||
if version_base != normalized_base:
|
if version_base != normalized_base:
|
||||||
continue
|
continue
|
||||||
@@ -230,6 +247,7 @@ class ModelUpdateService:
|
|||||||
preview_url TEXT,
|
preview_url TEXT,
|
||||||
is_in_library INTEGER NOT NULL DEFAULT 0,
|
is_in_library INTEGER NOT NULL DEFAULT 0,
|
||||||
should_ignore INTEGER NOT NULL DEFAULT 0,
|
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||||
|
usage_control TEXT,
|
||||||
PRIMARY KEY (model_id, version_id),
|
PRIMARY KEY (model_id, version_id),
|
||||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -465,6 +483,10 @@ class ModelUpdateService:
|
|||||||
"ALTER TABLE model_update_versions "
|
"ALTER TABLE model_update_versions "
|
||||||
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
|
||||||
),
|
),
|
||||||
|
"usage_control": (
|
||||||
|
"ALTER TABLE model_update_versions "
|
||||||
|
"ADD COLUMN usage_control TEXT"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, statement in migrations.items():
|
for column, statement in migrations.items():
|
||||||
@@ -967,6 +989,11 @@ class ModelUpdateService:
|
|||||||
fallback_attempted = True
|
fallback_attempted = True
|
||||||
try:
|
try:
|
||||||
response = await metadata_provider.get_model_versions(model_id)
|
response = await metadata_provider.get_model_versions(model_id)
|
||||||
|
if response is not None:
|
||||||
|
await self._enrich_version_entries(
|
||||||
|
metadata_provider,
|
||||||
|
{model_id: response},
|
||||||
|
)
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
raise
|
raise
|
||||||
except ResourceNotFoundError as exc:
|
except ResourceNotFoundError as exc:
|
||||||
@@ -1061,6 +1088,136 @@ class ModelUpdateService:
|
|||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
async def _enrich_version_entries(
|
||||||
|
self,
|
||||||
|
metadata_provider,
|
||||||
|
responses_by_model_id: Dict[int, Mapping],
|
||||||
|
) -> None:
|
||||||
|
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
|
||||||
|
|
||||||
|
The model-level API does not include ``usageControl`` on version
|
||||||
|
entries. This method collects SHA256 hashes from every version's
|
||||||
|
primary model file, calls ``POST /api/v1/model-versions/by-hash``
|
||||||
|
(up to 100 hashes per request), and injects ``usageControl`` +
|
||||||
|
``earlyAccessEndsAt`` into each version entry dict in-place.
|
||||||
|
"""
|
||||||
|
if not metadata_provider or not responses_by_model_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
hashes_by_version: Dict[int, str] = {}
|
||||||
|
for response in responses_by_model_id.values():
|
||||||
|
hashes_by_version.update(
|
||||||
|
self._collect_hashes_from_response(response)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hashes_by_version:
|
||||||
|
return
|
||||||
|
|
||||||
|
version_ids_by_hash: Dict[str, List[int]] = {}
|
||||||
|
for version_id, sha256 in hashes_by_version.items():
|
||||||
|
version_ids_by_hash.setdefault(sha256, []).append(version_id)
|
||||||
|
|
||||||
|
all_hashes = list(version_ids_by_hash.keys())
|
||||||
|
BATCH_SIZE = 100
|
||||||
|
|
||||||
|
enrichment: Dict[int, Dict] = {}
|
||||||
|
try:
|
||||||
|
for start in range(0, len(all_hashes), BATCH_SIZE):
|
||||||
|
batch = all_hashes[start : start + BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
enriched = await metadata_provider.get_model_versions_by_hashes(
|
||||||
|
batch
|
||||||
|
)
|
||||||
|
except NotImplementedError:
|
||||||
|
return
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not enriched:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in enriched:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
version_id = entry.get("id")
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
enrichment[version_id] = {
|
||||||
|
"usageControl": _normalize_string(
|
||||||
|
entry.get("usageControl")
|
||||||
|
),
|
||||||
|
"earlyAccessEndsAt": _normalize_string(
|
||||||
|
entry.get("earlyAccessEndsAt")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except RateLimitError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not enrichment:
|
||||||
|
return
|
||||||
|
|
||||||
|
for response in responses_by_model_id.values():
|
||||||
|
versions = response.get("modelVersions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
continue
|
||||||
|
for version in versions:
|
||||||
|
if not isinstance(version, dict):
|
||||||
|
continue
|
||||||
|
version_id = version.get("id")
|
||||||
|
if version_id not in enrichment:
|
||||||
|
continue
|
||||||
|
extra = enrichment[version_id]
|
||||||
|
if extra.get("usageControl") and not version.get("usageControl"):
|
||||||
|
version["usageControl"] = extra["usageControl"]
|
||||||
|
if extra.get("earlyAccessEndsAt") and not version.get(
|
||||||
|
"earlyAccessEndsAt"
|
||||||
|
):
|
||||||
|
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_hashes_from_response(response: Mapping) -> Dict[int, str]:
|
||||||
|
"""Extract ``{version_id: sha256}`` from a model-level API response.
|
||||||
|
|
||||||
|
Returns an empty dict if the response structure is unexpected.
|
||||||
|
"""
|
||||||
|
result: Dict[int, str] = {}
|
||||||
|
versions = response.get("modelVersions")
|
||||||
|
if not isinstance(versions, list):
|
||||||
|
return result
|
||||||
|
for entry in versions:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
version_id = _normalize_int(entry.get("id"))
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
|
sha256 = ModelUpdateService._extract_sha256_from_version_entry(entry)
|
||||||
|
if sha256:
|
||||||
|
result[version_id] = sha256
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_sha256_from_version_entry(entry: Mapping) -> Optional[str]:
|
||||||
|
"""Return the SHA256 hash from the primary model file of a version entry."""
|
||||||
|
files = entry.get("files")
|
||||||
|
if not isinstance(files, list):
|
||||||
|
return None
|
||||||
|
for file_info in files:
|
||||||
|
if not isinstance(file_info, dict):
|
||||||
|
continue
|
||||||
|
if file_info.get("type") != "Model":
|
||||||
|
continue
|
||||||
|
primary = file_info.get("primary")
|
||||||
|
if primary is not True and str(primary).strip().lower() != "true":
|
||||||
|
continue
|
||||||
|
hashes = file_info.get("hashes")
|
||||||
|
if isinstance(hashes, dict):
|
||||||
|
sha256 = hashes.get("SHA256")
|
||||||
|
if sha256:
|
||||||
|
return sha256
|
||||||
|
return None
|
||||||
|
|
||||||
async def _fetch_model_versions_bulk(
|
async def _fetch_model_versions_bulk(
|
||||||
self,
|
self,
|
||||||
metadata_provider,
|
metadata_provider,
|
||||||
@@ -1112,6 +1269,7 @@ class ModelUpdateService:
|
|||||||
len(aggregated),
|
len(aggregated),
|
||||||
provider_name,
|
provider_name,
|
||||||
)
|
)
|
||||||
|
await self._enrich_version_entries(metadata_provider, aggregated)
|
||||||
return aggregated
|
return aggregated
|
||||||
|
|
||||||
async def _collect_local_versions(
|
async def _collect_local_versions(
|
||||||
@@ -1239,6 +1397,7 @@ class ModelUpdateService:
|
|||||||
sort_index=sort_map.get(version_id, index),
|
sort_index=sort_map.get(version_id, index),
|
||||||
early_access_ends_at=remote_version.early_access_ends_at,
|
early_access_ends_at=remote_version.early_access_ends_at,
|
||||||
is_early_access=remote_version.is_early_access,
|
is_early_access=remote_version.is_early_access,
|
||||||
|
usage_control=remote_version.usage_control,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1337,6 +1496,7 @@ class ModelUpdateService:
|
|||||||
# Check availability field from bulk API for basic EA detection
|
# Check availability field from bulk API for basic EA detection
|
||||||
availability = _normalize_string(entry.get("availability"))
|
availability = _normalize_string(entry.get("availability"))
|
||||||
is_early_access = availability == "EarlyAccess"
|
is_early_access = availability == "EarlyAccess"
|
||||||
|
usage_control = _normalize_string(entry.get("usageControl"))
|
||||||
|
|
||||||
return ModelVersionRecord(
|
return ModelVersionRecord(
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
@@ -1350,6 +1510,7 @@ class ModelUpdateService:
|
|||||||
early_access_ends_at=early_access_ends_at,
|
early_access_ends_at=early_access_ends_at,
|
||||||
sort_index=index,
|
sort_index=index,
|
||||||
is_early_access=is_early_access,
|
is_early_access=is_early_access,
|
||||||
|
usage_control=usage_control,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||||
@@ -1464,7 +1625,7 @@ class ModelUpdateService:
|
|||||||
f"""
|
f"""
|
||||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access
|
is_early_access, usage_control
|
||||||
FROM model_update_versions
|
FROM model_update_versions
|
||||||
WHERE model_id IN ({placeholders})
|
WHERE model_id IN ({placeholders})
|
||||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||||
@@ -1492,6 +1653,7 @@ class ModelUpdateService:
|
|||||||
early_access_ends_at=row["early_access_ends_at"],
|
early_access_ends_at=row["early_access_ends_at"],
|
||||||
sort_index=_normalize_int(row["sort_index"]) or 0,
|
sort_index=_normalize_int(row["sort_index"]) or 0,
|
||||||
is_early_access=bool(row["is_early_access"]),
|
is_early_access=bool(row["is_early_access"]),
|
||||||
|
usage_control=row["usage_control"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1548,8 +1710,8 @@ class ModelUpdateService:
|
|||||||
INSERT INTO model_update_versions (
|
INSERT INTO model_update_versions (
|
||||||
version_id, model_id, sort_index, name, base_model, released_at,
|
version_id, model_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access
|
is_early_access, usage_control
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
version.version_id,
|
version.version_id,
|
||||||
@@ -1564,6 +1726,7 @@ class ModelUpdateService:
|
|||||||
1 if version.should_ignore else 0,
|
1 if version.should_ignore else 0,
|
||||||
version.early_access_ends_at,
|
version.early_access_ends_at,
|
||||||
1 if version.is_early_access else 0,
|
1 if version.is_early_access else 0,
|
||||||
|
version.usage_control,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class PersistentRecipeCache:
|
|||||||
"json_path",
|
"json_path",
|
||||||
"title",
|
"title",
|
||||||
"folder",
|
"folder",
|
||||||
|
"source_path",
|
||||||
"base_model",
|
"base_model",
|
||||||
"fingerprint",
|
"fingerprint",
|
||||||
"created_date",
|
"created_date",
|
||||||
@@ -334,6 +335,7 @@ class PersistentRecipeCache:
|
|||||||
json_path TEXT,
|
json_path TEXT,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
folder TEXT,
|
folder TEXT,
|
||||||
|
source_path TEXT,
|
||||||
base_model TEXT,
|
base_model TEXT,
|
||||||
fingerprint TEXT,
|
fingerprint TEXT,
|
||||||
created_date REAL,
|
created_date REAL,
|
||||||
@@ -358,6 +360,13 @@ class PersistentRecipeCache:
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# Migration: add source_path column to existing databases
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE recipes ADD COLUMN source_path TEXT"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # column already exists
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._schema_initialized = True
|
self._schema_initialized = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -406,6 +415,7 @@ class PersistentRecipeCache:
|
|||||||
json_path,
|
json_path,
|
||||||
recipe.get("title"),
|
recipe.get("title"),
|
||||||
recipe.get("folder"),
|
recipe.get("folder"),
|
||||||
|
recipe.get("source_path"),
|
||||||
recipe.get("base_model"),
|
recipe.get("base_model"),
|
||||||
recipe.get("fingerprint"),
|
recipe.get("fingerprint"),
|
||||||
float(recipe.get("created_date") or 0.0),
|
float(recipe.get("created_date") or 0.0),
|
||||||
@@ -456,6 +466,7 @@ class PersistentRecipeCache:
|
|||||||
"file_path": row["file_path"] or "",
|
"file_path": row["file_path"] or "",
|
||||||
"title": row["title"] or "",
|
"title": row["title"] or "",
|
||||||
"folder": row["folder"] or "",
|
"folder": row["folder"] or "",
|
||||||
|
"source_path": row["source_path"] or "",
|
||||||
"base_model": row["base_model"] or "",
|
"base_model": row["base_model"] or "",
|
||||||
"fingerprint": row["fingerprint"] or "",
|
"fingerprint": row["fingerprint"] or "",
|
||||||
"created_date": row["created_date"] or 0.0,
|
"created_date": row["created_date"] or 0.0,
|
||||||
|
|||||||
@@ -504,6 +504,9 @@ class RecipeScanner:
|
|||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
self._update_folder_metadata(self._cache)
|
self._update_folder_metadata(self._cache)
|
||||||
self._sort_cache_sync()
|
self._sort_cache_sync()
|
||||||
|
# Backfill source_path from JSON files if missing (schema migration)
|
||||||
|
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||||
|
self._persistent_cache.save_cache(recipes, json_paths)
|
||||||
return self._cache
|
return self._cache
|
||||||
else:
|
else:
|
||||||
# Partial update: some files changed
|
# Partial update: some files changed
|
||||||
@@ -514,6 +517,8 @@ class RecipeScanner:
|
|||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
self._update_folder_metadata(self._cache)
|
self._update_folder_metadata(self._cache)
|
||||||
self._sort_cache_sync()
|
self._sort_cache_sync()
|
||||||
|
# Backfill source_path from JSON files if missing (schema migration)
|
||||||
|
self._backfill_source_path_if_needed(recipes, json_paths)
|
||||||
# Persist updated cache
|
# Persist updated cache
|
||||||
self._persistent_cache.save_cache(recipes, json_paths)
|
self._persistent_cache.save_cache(recipes, json_paths)
|
||||||
return self._cache
|
return self._cache
|
||||||
@@ -642,6 +647,34 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return recipes, changed, json_paths
|
return recipes, changed, json_paths
|
||||||
|
|
||||||
|
def _backfill_source_path_if_needed(
|
||||||
|
self,
|
||||||
|
recipes: List[Dict],
|
||||||
|
json_paths: Dict[str, str],
|
||||||
|
) -> bool:
|
||||||
|
"""Backfill source_path from recipe JSON files if missing from cache.
|
||||||
|
|
||||||
|
Returns True if any recipes were updated (caller should persist cache).
|
||||||
|
"""
|
||||||
|
updated = False
|
||||||
|
for recipe in recipes:
|
||||||
|
if recipe.get("source_path"):
|
||||||
|
continue
|
||||||
|
recipe_id = str(recipe.get("id", ""))
|
||||||
|
json_path = json_paths.get(recipe_id)
|
||||||
|
if not json_path or not os.path.exists(json_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
json_data = json.load(f)
|
||||||
|
file_source_path = json_data.get("source_path")
|
||||||
|
if file_source_path:
|
||||||
|
recipe["source_path"] = file_source_path
|
||||||
|
updated = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return updated
|
||||||
|
|
||||||
def _full_directory_scan_sync(
|
def _full_directory_scan_sync(
|
||||||
self, recipes_dir: str
|
self, recipes_dir: str
|
||||||
) -> Tuple[List[Dict], Dict[str, str]]:
|
) -> Tuple[List[Dict], Dict[str, str]]:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
@@ -14,6 +15,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||||
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
from .errors import (
|
from .errors import (
|
||||||
RecipeDownloadError,
|
RecipeDownloadError,
|
||||||
RecipeNotFoundError,
|
RecipeNotFoundError,
|
||||||
@@ -170,9 +172,11 @@ class RecipeAnalysisService:
|
|||||||
await self._download_image(url, temp_path)
|
await self._download_image(url, temp_path)
|
||||||
|
|
||||||
if metadata is None and not is_video:
|
if metadata is None and not is_video:
|
||||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata, temp_path
|
||||||
|
)
|
||||||
|
|
||||||
return await self._parse_metadata(
|
result = await self._parse_metadata(
|
||||||
metadata or {},
|
metadata or {},
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_path=temp_path,
|
image_path=temp_path,
|
||||||
@@ -180,6 +184,37 @@ class RecipeAnalysisService:
|
|||||||
is_video=is_video,
|
is_video=is_video,
|
||||||
extension=extension,
|
extension=extension,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||||
|
mvid = image_info.get("modelVersionId")
|
||||||
|
if not mvid:
|
||||||
|
mvids = image_info.get("modelVersionIds")
|
||||||
|
if isinstance(mvids, list) and mvids:
|
||||||
|
mvid = mvids[0]
|
||||||
|
|
||||||
|
recipe_for_enrich = {
|
||||||
|
"gen_params": result.payload.get("gen_params", {}),
|
||||||
|
"loras": result.payload.get("loras", []),
|
||||||
|
"base_model": result.payload.get("base_model", "") or "",
|
||||||
|
"checkpoint": result.payload.get("checkpoint") or result.payload.get("model"),
|
||||||
|
"source_path": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
await RecipeEnricher.enrich_recipe(
|
||||||
|
recipe=recipe_for_enrich,
|
||||||
|
civitai_client=civitai_client,
|
||||||
|
request_params=None,
|
||||||
|
prefetched_civitai_meta_raw=image_info.get("meta"),
|
||||||
|
prefetched_model_version_id=mvid,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||||
|
if recipe_for_enrich.get("checkpoint"):
|
||||||
|
result.payload["checkpoint"] = recipe_for_enrich["checkpoint"]
|
||||||
|
if recipe_for_enrich.get("base_model"):
|
||||||
|
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||||
|
|
||||||
|
return result
|
||||||
finally:
|
finally:
|
||||||
if temp_path:
|
if temp_path:
|
||||||
self._safe_cleanup(temp_path)
|
self._safe_cleanup(temp_path)
|
||||||
@@ -199,7 +234,9 @@ class RecipeAnalysisService:
|
|||||||
if not os.path.isfile(normalized_path):
|
if not os.path.isfile(normalized_path):
|
||||||
raise RecipeNotFoundError("File not found")
|
raise RecipeNotFoundError("File not found")
|
||||||
|
|
||||||
metadata = self._exif_utils.extract_image_metadata(normalized_path)
|
metadata = await asyncio.to_thread(
|
||||||
|
self._exif_utils.extract_image_metadata, normalized_path
|
||||||
|
)
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return self._metadata_not_found_response(normalized_path)
|
return self._metadata_not_found_response(normalized_path)
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||||
"model_name_display": "model_name",
|
"model_name_display": "model_name",
|
||||||
"model_card_footer_action": "replace_preview",
|
"model_card_footer_action": "replace_preview",
|
||||||
|
"show_version_on_card": True,
|
||||||
"update_flag_strategy": "same_base",
|
"update_flag_strategy": "same_base",
|
||||||
"auto_organize_exclusions": [],
|
"auto_organize_exclusions": [],
|
||||||
"metadata_refresh_skip_paths": [],
|
"metadata_refresh_skip_paths": [],
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, Mapping, Sequence
|
|||||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||||
|
|
||||||
|
|
||||||
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
|
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red", "civitai.green"})
|
||||||
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
||||||
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
||||||
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
||||||
|
|||||||
@@ -178,5 +178,8 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
|||||||
"Wan Video 2.5 I2V",
|
"Wan Video 2.5 I2V",
|
||||||
"Hunyuan Video",
|
"Hunyuan Video",
|
||||||
"Anima",
|
"Anima",
|
||||||
|
"Ernie",
|
||||||
|
"Ernie Turbo",
|
||||||
|
"Nucleus",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -452,3 +452,111 @@ class MetadataUpdater:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def prune_stale_example_images(metadata) -> bool:
|
||||||
|
"""Remove example-image metadata entries whose files no longer exist on disk.
|
||||||
|
|
||||||
|
Checks ``civitai.customImages`` (by ``id``) and ``civitai.images`` entries
|
||||||
|
that have an empty ``url`` (no remote fallback) against actual files in
|
||||||
|
the model's example-image folder. Stale entries are removed in-place so
|
||||||
|
the caller can persist the cleaned metadata afterwards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: A ``BaseModelMetadata`` instance (modified in place).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one entry was removed.
|
||||||
|
"""
|
||||||
|
from ..utils.example_images_paths import get_model_folder
|
||||||
|
|
||||||
|
model_hash = getattr(metadata, "sha256", None)
|
||||||
|
if not model_hash:
|
||||||
|
return False
|
||||||
|
|
||||||
|
model_folder = get_model_folder(model_hash)
|
||||||
|
if not model_folder:
|
||||||
|
return False
|
||||||
|
|
||||||
|
civitai = getattr(metadata, "civitai", None)
|
||||||
|
if not isinstance(civitai, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
has_changes = False
|
||||||
|
|
||||||
|
custom_images = civitai.get("customImages")
|
||||||
|
if isinstance(custom_images, list) and custom_images:
|
||||||
|
stale: list[int] = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(custom_images):
|
||||||
|
img_id = img.get("id", "")
|
||||||
|
if not img_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.isdir(model_folder):
|
||||||
|
stale.append(idx)
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
prefix = f"custom_{img_id}"
|
||||||
|
for fname in os.listdir(model_folder):
|
||||||
|
if fname.startswith(prefix) and os.path.isfile(
|
||||||
|
os.path.join(model_folder, fname)
|
||||||
|
):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
stale.append(idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
stale.append(idx)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
for idx in reversed(stale):
|
||||||
|
custom_images.pop(idx)
|
||||||
|
has_changes = True
|
||||||
|
logger.info(
|
||||||
|
"Pruned %d stale custom image(s) for %s",
|
||||||
|
len(stale),
|
||||||
|
getattr(metadata, "model_name", model_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
images = civitai.get("images")
|
||||||
|
if isinstance(images, list) and images:
|
||||||
|
stale: list[int] = []
|
||||||
|
|
||||||
|
for idx, img in enumerate(images):
|
||||||
|
if img.get("url", ""):
|
||||||
|
# Has a remote fallback – keep it even if the local copy
|
||||||
|
# is gone.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not os.path.isdir(model_folder):
|
||||||
|
stale.append(idx)
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
prefix = f"image_{idx}."
|
||||||
|
for fname in os.listdir(model_folder):
|
||||||
|
if fname.startswith(prefix):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
stale.append(idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
stale.append(idx)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
for idx in reversed(stale):
|
||||||
|
images.pop(idx)
|
||||||
|
has_changes = True
|
||||||
|
logger.info(
|
||||||
|
"Pruned %d stale image entry(ies) for %s",
|
||||||
|
len(stale),
|
||||||
|
getattr(metadata, "model_name", model_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
return has_changes
|
||||||
|
|||||||
@@ -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.5"
|
version = "1.0.6"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
354
scripts/migrate_legacy_metadata.py
Normal file
354
scripts/migrate_legacy_metadata.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.
|
||||||
|
|
||||||
|
This script automatically discovers model folders from LoRA Manager's settings.json,
|
||||||
|
finds JSON files with the same basename as model files (e.g., `model.json` for
|
||||||
|
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.
|
||||||
|
|
||||||
|
Fields migrated:
|
||||||
|
- "activation text" → civitai.trainedWords (array of trigger words)
|
||||||
|
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
|
||||||
|
- "notes" → notes (user-defined notes)
|
||||||
|
|
||||||
|
Supported model types: LoRA, Checkpoint
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. Read settings.json to find all configured model folders
|
||||||
|
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
|
||||||
|
3. Find corresponding legacy metadata JSON files
|
||||||
|
4. Migrate data to .metadata.json files
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||||
|
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
|
||||||
|
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_path() -> Path:
|
||||||
|
repo_root = Path(__file__).parent.parent.resolve()
|
||||||
|
portable = repo_root / "settings.json"
|
||||||
|
if portable.exists():
|
||||||
|
payload = load_json(portable)
|
||||||
|
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
|
||||||
|
return portable
|
||||||
|
|
||||||
|
config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if config_home:
|
||||||
|
return Path(config_home).expanduser() / APP_NAME / "settings.json"
|
||||||
|
return Path.home() / ".config" / APP_NAME / "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
logger.error(f"Invalid JSON in {path}: {exc}")
|
||||||
|
return {}
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(f"Cannot read {path}: {exc}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_path(value: str) -> str:
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [expand_path(value)] if value else []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [expand_path(item) for item in value if isinstance(item, str) and item]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
if value not in seen:
|
||||||
|
result.append(value)
|
||||||
|
seen.add(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
roots: dict[str, list[str]] = {}
|
||||||
|
active_library = settings.get("active_library") or "default"
|
||||||
|
sources = [settings]
|
||||||
|
library = settings.get("libraries", {}).get(active_library)
|
||||||
|
if isinstance(library, dict):
|
||||||
|
sources.insert(0, library)
|
||||||
|
for source in sources:
|
||||||
|
folder_paths = source.get("folder_paths")
|
||||||
|
if isinstance(folder_paths, dict):
|
||||||
|
for key, value in folder_paths.items():
|
||||||
|
roots.setdefault(key, []).extend(normalize_path_list(value))
|
||||||
|
for default_key, folder_key in (
|
||||||
|
("default_lora_root", "loras"),
|
||||||
|
("default_checkpoint_root", "checkpoints"),
|
||||||
|
("default_embedding_root", "embeddings"),
|
||||||
|
("default_unet_root", "unet"),
|
||||||
|
):
|
||||||
|
value = settings.get(default_key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
roots.setdefault(folder_key, []).append(expand_path(value))
|
||||||
|
return {key: dedupe(values) for key, values in roots.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def find_model_files(directory: Path) -> list[Path]:
|
||||||
|
model_files = []
|
||||||
|
for ext in MODEL_EXTENSIONS:
|
||||||
|
model_files.extend(directory.rglob(f"*{ext}"))
|
||||||
|
return model_files
|
||||||
|
|
||||||
|
|
||||||
|
def find_legacy_metadata(model_path: Path) -> Path | None:
|
||||||
|
base_name = model_path.stem
|
||||||
|
legacy_path = model_path.with_name(f"{base_name}.json")
|
||||||
|
if legacy_path.exists() and legacy_path.is_file():
|
||||||
|
return legacy_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
with open(legacy_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading legacy file {legacy_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_metadata(metadata_path: Path) -> dict[str, Any]:
|
||||||
|
if not metadata_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading metadata file {metadata_path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
|
||||||
|
if dry_run:
|
||||||
|
logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
|
||||||
|
return True
|
||||||
|
temp_path = metadata_path.with_suffix(".tmp")
|
||||||
|
try:
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
os.replace(temp_path, metadata_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving metadata to {metadata_path}: {e}")
|
||||||
|
if temp_path.exists():
|
||||||
|
try:
|
||||||
|
temp_path.unlink()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_metadata(
|
||||||
|
legacy_data: dict[str, Any],
|
||||||
|
existing_metadata: dict[str, Any],
|
||||||
|
model_type: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
metadata = existing_metadata.copy()
|
||||||
|
changes_made = False
|
||||||
|
if "civitai" not in metadata:
|
||||||
|
metadata["civitai"] = {}
|
||||||
|
activation_text = legacy_data.get("activation text")
|
||||||
|
if activation_text and isinstance(activation_text, str):
|
||||||
|
trigger_words = [
|
||||||
|
word.strip()
|
||||||
|
for word in activation_text.replace("\n", ",").split(",")
|
||||||
|
if word.strip()
|
||||||
|
]
|
||||||
|
if trigger_words:
|
||||||
|
existing_trained = metadata["civitai"].get("trainedWords", [])
|
||||||
|
if not isinstance(existing_trained, list):
|
||||||
|
existing_trained = []
|
||||||
|
merged = list(dict.fromkeys(existing_trained + trigger_words))
|
||||||
|
if merged != existing_trained:
|
||||||
|
metadata["civitai"]["trainedWords"] = merged
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(f" Migrated activation text: {trigger_words}")
|
||||||
|
if model_type == "lora":
|
||||||
|
preferred_weight = legacy_data.get("preferred weight")
|
||||||
|
if preferred_weight is not None:
|
||||||
|
try:
|
||||||
|
weight_value = float(preferred_weight)
|
||||||
|
usage_tips_str = metadata.get("usage_tips", "{}")
|
||||||
|
if isinstance(usage_tips_str, str):
|
||||||
|
try:
|
||||||
|
usage_tips = json.loads(usage_tips_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
usage_tips = {}
|
||||||
|
elif isinstance(usage_tips_str, dict):
|
||||||
|
usage_tips = usage_tips_str
|
||||||
|
else:
|
||||||
|
usage_tips = {}
|
||||||
|
if "strength" not in usage_tips:
|
||||||
|
usage_tips["strength"] = weight_value
|
||||||
|
metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(f" Migrated preferred weight: {weight_value}")
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning(f" Could not parse preferred weight '{preferred_weight}': {e}")
|
||||||
|
else:
|
||||||
|
if legacy_data.get("preferred weight") is not None:
|
||||||
|
logger.debug(" Skipping 'preferred weight' for non-LoRA model")
|
||||||
|
notes = legacy_data.get("notes")
|
||||||
|
if notes and isinstance(notes, str) and notes.strip():
|
||||||
|
existing_notes = metadata.get("notes", "")
|
||||||
|
if not existing_notes:
|
||||||
|
metadata["notes"] = notes.strip()
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(" Migrated notes")
|
||||||
|
elif notes.strip() not in existing_notes:
|
||||||
|
metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
|
||||||
|
changes_made = True
|
||||||
|
logger.debug(" Appended notes")
|
||||||
|
return metadata if changes_made else None
|
||||||
|
|
||||||
|
|
||||||
|
def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
|
||||||
|
legacy_path = find_legacy_metadata(model_path)
|
||||||
|
if not legacy_path:
|
||||||
|
return True
|
||||||
|
logger.info(f"Processing: {model_path.name} ({model_type})")
|
||||||
|
logger.info(f" Found legacy metadata: {legacy_path.name}")
|
||||||
|
legacy_data = load_legacy_metadata(legacy_path)
|
||||||
|
if legacy_data is None:
|
||||||
|
return False
|
||||||
|
metadata_path = model_path.with_suffix(".metadata.json")
|
||||||
|
existing_metadata = load_metadata(metadata_path)
|
||||||
|
migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
|
||||||
|
if migrated is None:
|
||||||
|
logger.info(" No changes needed (fields already exist or no migratable data)")
|
||||||
|
return True
|
||||||
|
if save_metadata(metadata_path, migrated, dry_run):
|
||||||
|
logger.info(f" ✓ Successfully migrated metadata to: {metadata_path.name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(" ✗ Failed to save metadata")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python scripts/migrate_legacy_metadata.py
|
||||||
|
python scripts/migrate_legacy_metadata.py --dry-run
|
||||||
|
python scripts/migrate_legacy_metadata.py --verbose
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Preview changes without modifying any files"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable verbose output"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.verbose:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
settings_path = resolve_settings_path()
|
||||||
|
logger.info(f"Using settings: {settings_path}")
|
||||||
|
settings = load_json(settings_path)
|
||||||
|
if not settings:
|
||||||
|
logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
|
||||||
|
return 1
|
||||||
|
roots = get_model_roots(settings)
|
||||||
|
if not roots:
|
||||||
|
logger.error("No model folders configured in settings.json.")
|
||||||
|
return 1
|
||||||
|
lora_roots = roots.get("loras", [])
|
||||||
|
checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
|
||||||
|
all_roots = []
|
||||||
|
for root_list in [lora_roots, checkpoint_roots]:
|
||||||
|
for root in root_list:
|
||||||
|
path = Path(root)
|
||||||
|
if path.exists() and path.is_dir():
|
||||||
|
all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
|
||||||
|
if not all_roots:
|
||||||
|
logger.error("No valid model folders found.")
|
||||||
|
return 1
|
||||||
|
logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
|
||||||
|
processed = 0
|
||||||
|
migrated = 0
|
||||||
|
errors = 0
|
||||||
|
skipped = 0
|
||||||
|
lora_count = 0
|
||||||
|
checkpoint_count = 0
|
||||||
|
for root_path, model_type in all_roots:
|
||||||
|
logger.info(f"Scanning: {root_path} ({model_type})")
|
||||||
|
model_files = find_model_files(root_path)
|
||||||
|
logger.debug(f" Found {len(model_files)} model files")
|
||||||
|
for model_path in model_files:
|
||||||
|
legacy_path = find_legacy_metadata(model_path)
|
||||||
|
if not legacy_path:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
processed += 1
|
||||||
|
if process_model(model_path, model_type, dry_run=args.dry_run):
|
||||||
|
migrated += 1
|
||||||
|
if model_type == "lora":
|
||||||
|
lora_count += 1
|
||||||
|
else:
|
||||||
|
checkpoint_count += 1
|
||||||
|
else:
|
||||||
|
errors += 1
|
||||||
|
logger.info("\n" + "=" * 50)
|
||||||
|
logger.info("Migration Summary:")
|
||||||
|
logger.info(f" Models with legacy metadata: {processed}")
|
||||||
|
logger.info(f" Successfully migrated: {migrated}")
|
||||||
|
logger.info(f" - LoRA models: {lora_count}")
|
||||||
|
logger.info(f" - Checkpoint models: {checkpoint_count}")
|
||||||
|
logger.info(f" Errors: {errors}")
|
||||||
|
logger.info(f" Skipped (no legacy file): {skipped}")
|
||||||
|
if args.dry_run:
|
||||||
|
logger.info("\n [DRY RUN MODE - No files were modified]")
|
||||||
|
return 0 if errors == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
||||||
content: '\f00c';
|
content: '\f00c';
|
||||||
font-family: 'Font Awesome 6 Free';
|
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--lora-text);
|
color: var(--lora-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||||
max-width: 260px; /* Base size */
|
max-width: 260px; /* Base size */
|
||||||
|
min-width: 200px; /* Prevent cards from becoming too narrow */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -328,7 +329,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-actions i {
|
.card-actions i {
|
||||||
margin-left: var(--space-1);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: white;
|
color: white;
|
||||||
transition: opacity 0.2s, transform 0.15s ease;
|
transition: opacity 0.2s, transform 0.15s ease;
|
||||||
@@ -370,7 +370,16 @@
|
|||||||
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
@media (max-width: 1200px) {
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-card {
|
||||||
|
max-width: 240px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
|
||||||
@@ -378,6 +387,7 @@
|
|||||||
|
|
||||||
.model-card {
|
.model-card {
|
||||||
max-width: 100%; /* Allow cards to fill available space on mobile */
|
max-width: 100%; /* Allow cards to fill available space on mobile */
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +517,11 @@
|
|||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide civitai version name when setting is disabled */
|
||||||
|
body.hide-card-version .civitai-version {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Prevent text selection on cards and interactive elements */
|
/* Prevent text selection on cards and interactive elements */
|
||||||
.model-card,
|
.model-card,
|
||||||
.model-card *,
|
.model-card *,
|
||||||
@@ -558,8 +573,13 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
|
margin: 0;
|
||||||
width: 100%; /* Allow width to be set by the VirtualScroller */
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow cards to grow beyond 260px in virtual scroll mode */
|
||||||
|
.virtual-scroll-item.model-card {
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroll-item:hover {
|
.virtual-scroll-item:hover {
|
||||||
@@ -571,11 +591,11 @@
|
|||||||
.card-grid.virtual-scroll {
|
.card-grid.virtual-scroll {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 0 auto;
|
margin: 0; /* Remove auto margins - positioning handled by VirtualScroller leftOffset */
|
||||||
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1400px; /* Keep the max-width from original grid */
|
max-width: none; /* Remove max-width constraint - handled by VirtualScroller */
|
||||||
box-sizing: border-box; /* Include padding in width calculation */
|
box-sizing: border-box; /* Include padding in width calculation */
|
||||||
overflow-x: hidden; /* Prevent horizontal overflow */
|
overflow-x: hidden; /* Prevent horizontal overflow */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,22 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left section: Logo + Navigation */
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right section: Controls */
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive header container for larger screens */
|
/* Responsive header container for larger screens */
|
||||||
@media (min-width: 2150px) {
|
@media (min-width: 2150px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -77,6 +93,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover,
|
.nav-item:hover,
|
||||||
@@ -97,13 +114,99 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header search */
|
/* Header search - Centered with VS Code command palette style */
|
||||||
.header-search {
|
.header-search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* VS Code command palette style search container */
|
||||||
|
.header-search .search-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--input-bg, var(--card-bg));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm, 6px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container:focus-within {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
padding-left: 2.25rem !important;
|
||||||
|
padding-right: 5rem !important;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle,
|
||||||
|
.header-search .search-filter-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle {
|
||||||
|
right: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-options-toggle:hover,
|
||||||
|
.header-search .search-filter-toggle:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .filter-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--lora-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Disabled state for header search */
|
/* Disabled state for header search */
|
||||||
.header-search.disabled {
|
.header-search.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -247,44 +350,207 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile adjustments */
|
/* Hamburger menu button - hidden by default */
|
||||||
@media (max-width: 768px) {
|
.hamburger-menu-btn {
|
||||||
.app-title {
|
|
||||||
display: none;
|
display: none;
|
||||||
/* Hide text title on mobile */
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger dropdown menu */
|
||||||
|
.hamburger-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm, 6px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: var(--z-dropdown, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item:hover {
|
||||||
|
background: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-item i {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown .dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.header-container {
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.header-controls {
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls > div {
|
.header-controls > div {
|
||||||
width: 28px;
|
width: 30px;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Hide nav icons at 1100px to save space */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.nav-item {
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
.app-title {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-container {
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-menu-btn {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-dropdown.active {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0 0.5rem;
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
margin-right: 0.5rem;
|
gap: 0.25rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For very small screens */
|
/* Responsive: Compact mode at 768px */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-search input {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
padding-left: 2rem !important;
|
||||||
|
padding-right: 4.5rem !important;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search .search-container {
|
||||||
|
border-radius: var(--border-radius-xs, 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For very small screens - switch nav to icons only */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: none;
|
display: flex;
|
||||||
/* Hide navigation on very small screens */
|
gap: 0.15rem;
|
||||||
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-search {
|
.nav-item {
|
||||||
flex: 1;
|
padding: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Position relative for hamburger menu positioning */
|
||||||
|
.header-right {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|||||||
@@ -374,11 +374,23 @@
|
|||||||
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-action-disabled {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.version-action:disabled {
|
.version-action:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-action-disabled-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.versions-loading-state,
|
.versions-loading-state,
|
||||||
.versions-empty,
|
.versions-empty,
|
||||||
.versions-error {
|
.versions-error {
|
||||||
|
|||||||
124
static/css/components/media-viewer.css
Normal file
124
static/css/components/media-viewer.css
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
.media-viewer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active {
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10001;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active .media-viewer-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-content-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 95vh;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-media {
|
||||||
|
display: block;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-video {
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-counter {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-title {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 90vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 48px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10001;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-overlay.active .media-viewer-nav {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-nav:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-prev {
|
||||||
|
left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-viewer-next {
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
@@ -41,6 +41,63 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section Headers */
|
||||||
|
.context-menu-section-header {
|
||||||
|
padding: 6px 12px 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu */
|
||||||
|
.context-menu-item.has-submenu {
|
||||||
|
position: relative;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% - 4px);
|
||||||
|
top: -1px;
|
||||||
|
display: none;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1001;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu .context-menu-item {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu .context-menu-item:first-child {
|
||||||
|
padding-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu .context-menu-item:last-child {
|
||||||
|
padding-bottom: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu.flip-left {
|
||||||
|
left: auto;
|
||||||
|
right: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* NSFW Level Selector */
|
/* NSFW Level Selector */
|
||||||
.nsfw-level-selector {
|
.nsfw-level-selector {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -396,14 +396,54 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-gen-params h3 {
|
.gen-params-header-row {
|
||||||
margin-top: 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
font-size: 1.2em;
|
|
||||||
color: var(--text-color);
|
|
||||||
padding-bottom: var(--space-1);
|
padding-bottom: var(--space-1);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gen-params-header-row h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline toggle for lora strip setting */
|
||||||
|
.lora-strip-toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .inline-toggle-label {
|
||||||
|
font-size: 0.78em;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle:hover .inline-toggle-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-switch {
|
||||||
|
width: 32px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-slider:before {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-strip-toggle .toggle-switch input:checked + .toggle-slider:before {
|
||||||
|
transform: translateX(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gen-params-container {
|
.gen-params-container {
|
||||||
|
|||||||
@@ -67,7 +67,6 @@
|
|||||||
|
|
||||||
.early-access-info {
|
.early-access-info {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
@@ -97,7 +96,6 @@
|
|||||||
|
|
||||||
.local-path {
|
.local-path {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|||||||
@@ -271,11 +271,16 @@
|
|||||||
|
|
||||||
/* Enhanced Sidebar Breadcrumb Styles */
|
/* Enhanced Sidebar Breadcrumb Styles */
|
||||||
.sidebar-breadcrumb-container {
|
.sidebar-breadcrumb-container {
|
||||||
margin-top: 8px;
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
|
/* Sticky positioning to stick below header when scrolling
|
||||||
|
top: 0 means stick at the top of the scroll container (page-content)
|
||||||
|
which is at header height (48px) from the viewport */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: calc(var(--z-header) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-breadcrumb-nav {
|
.sidebar-breadcrumb-nav {
|
||||||
@@ -284,7 +289,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
padding: 0 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-breadcrumb-item {
|
.sidebar-breadcrumb-item {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
top: -54px;
|
top: -54px;
|
||||||
z-index: calc(var(--z-header) - 1);
|
z-index: calc(var(--z-header) - 1);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
padding: var(--space-2) 0;
|
padding: var(--space-1) 0;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +371,14 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
|
||||||
|
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
|
||||||
|
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
|
||||||
|
the sticky breadcrumb. */
|
||||||
|
.controls:has(.dropdown-group.active) {
|
||||||
|
z-index: var(--z-header);
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 6px 15px;
|
padding: 6px 15px;
|
||||||
@@ -397,6 +405,33 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce button sizes to fit better */
|
||||||
|
.control-group button {
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.actions {
|
.actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||||
@import 'components/statistics.css'; /* Add statistics component */
|
@import 'components/statistics.css'; /* Add statistics component */
|
||||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||||
|
@import 'components/media-viewer.css';
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export class BaseContextMenu {
|
|||||||
this.menu = document.getElementById(menuId);
|
this.menu = document.getElementById(menuId);
|
||||||
this.cardSelector = cardSelector;
|
this.cardSelector = cardSelector;
|
||||||
this.currentCard = null;
|
this.currentCard = null;
|
||||||
|
this.submenuTimeout = null;
|
||||||
|
this.openSubmenu = null;
|
||||||
|
|
||||||
if (!this.menu) {
|
if (!this.menu) {
|
||||||
console.error(`Context menu element with ID ${menuId} not found`);
|
console.error(`Context menu element with ID ${menuId} not found`);
|
||||||
@@ -13,20 +15,99 @@ export class BaseContextMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Hide menu on regular clicks
|
// Hide menu when clicking outside
|
||||||
document.addEventListener('click', () => this.hideMenu());
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.menu.contains(e.target)) {
|
||||||
|
this.hideMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle menu item clicks
|
// Handle menu item clicks (including submenu items)
|
||||||
this.menu.addEventListener('click', (e) => {
|
this.menu.addEventListener('click', (e) => {
|
||||||
const menuItem = e.target.closest('.context-menu-item');
|
const menuItem = e.target.closest('.context-menu-item');
|
||||||
if (!menuItem || !this.currentCard) return;
|
if (!menuItem || !this.currentCard) return;
|
||||||
|
|
||||||
|
// Ignore clicks on submenu trigger (has-submenu parent)
|
||||||
|
if (menuItem.classList.contains('has-submenu')) return;
|
||||||
|
|
||||||
const action = menuItem.dataset.action;
|
const action = menuItem.dataset.action;
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
|
|
||||||
this.handleMenuAction(action, menuItem);
|
this.handleMenuAction(action, menuItem);
|
||||||
this.hideMenu();
|
this.hideMenu();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Submenu hover handling
|
||||||
|
// Use mouseover/mouseout (which bubble) with relatedTarget checks
|
||||||
|
// to reliably detect crossing the .has-submenu boundary
|
||||||
|
this.menu.addEventListener('mouseover', (e) => {
|
||||||
|
const trigger = e.target.closest('.has-submenu');
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
// Only act when entering from outside this trigger's tree
|
||||||
|
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
|
||||||
|
|
||||||
|
this._openSubmenu(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.menu.addEventListener('mouseout', (e) => {
|
||||||
|
const trigger = e.target.closest('.has-submenu');
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
// Only close when leaving the trigger's tree entirely
|
||||||
|
if (e.relatedTarget && trigger.contains(e.relatedTarget)) return;
|
||||||
|
|
||||||
|
this._scheduleSubmenuClose(trigger);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_openSubmenu(trigger) {
|
||||||
|
// Clear any pending close
|
||||||
|
if (this.submenuTimeout) {
|
||||||
|
clearTimeout(this.submenuTimeout);
|
||||||
|
this.submenuTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide any previously open submenu
|
||||||
|
if (this.openSubmenu && this.openSubmenu !== trigger) {
|
||||||
|
this._hideSubmenu(this.openSubmenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submenu = trigger.querySelector('.context-submenu');
|
||||||
|
if (!submenu) return;
|
||||||
|
|
||||||
|
submenu.style.display = 'block';
|
||||||
|
this.openSubmenu = trigger;
|
||||||
|
this._positionSubmenu(submenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleSubmenuClose(trigger) {
|
||||||
|
this.submenuTimeout = setTimeout(() => {
|
||||||
|
this._hideSubmenu(trigger);
|
||||||
|
this.submenuTimeout = null;
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
_hideSubmenu(trigger) {
|
||||||
|
const submenu = trigger.querySelector('.context-submenu');
|
||||||
|
if (submenu) {
|
||||||
|
submenu.style.display = 'none';
|
||||||
|
submenu.classList.remove('flip-left');
|
||||||
|
}
|
||||||
|
if (this.openSubmenu === trigger) {
|
||||||
|
this.openSubmenu = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_positionSubmenu(submenu) {
|
||||||
|
const submenuRect = submenu.getBoundingClientRect();
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
|
||||||
|
if (submenuRect.right > viewportWidth) {
|
||||||
|
submenu.classList.add('flip-left');
|
||||||
|
} else {
|
||||||
|
submenu.classList.remove('flip-left');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMenuAction(action, menuItem) {
|
handleMenuAction(action, menuItem) {
|
||||||
@@ -65,6 +146,13 @@ export class BaseContextMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hideMenu() {
|
hideMenu() {
|
||||||
|
if (this.submenuTimeout) {
|
||||||
|
clearTimeout(this.submenuTimeout);
|
||||||
|
this.submenuTimeout = null;
|
||||||
|
}
|
||||||
|
if (this.openSubmenu) {
|
||||||
|
this._hideSubmenu(this.openSubmenu);
|
||||||
|
}
|
||||||
if (this.menu) {
|
if (this.menu) {
|
||||||
this.menu.style.display = 'none';
|
this.menu.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
|
|||||||
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
||||||
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
|
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
export class BulkContextMenu extends BaseContextMenu {
|
export class BulkContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -50,6 +51,14 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
if (copyAllItem) {
|
if (copyAllItem) {
|
||||||
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
|
copyAllItem.style.display = config.copyAll ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Submenu parent visibility
|
||||||
|
const sendToWorkflowSubmenu = this.menu.querySelector('[data-has-submenu="send-to-workflow"]');
|
||||||
|
if (sendToWorkflowSubmenu) {
|
||||||
|
const hasWorkflowActions = config.sendToWorkflow || config.copyAll;
|
||||||
|
sendToWorkflowSubmenu.style.display = hasWorkflowActions ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (refreshAllItem) {
|
if (refreshAllItem) {
|
||||||
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
|
refreshAllItem.style.display = config.refreshAll ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
@@ -74,11 +83,46 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
if (setContentRatingItem) {
|
if (setContentRatingItem) {
|
||||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setFavoriteItem = this.menu.querySelector('[data-action="set-favorite"]');
|
||||||
|
|
||||||
|
if (setFavoriteItem && config.setFavorite) {
|
||||||
|
setFavoriteItem.style.display = 'flex';
|
||||||
|
|
||||||
|
const total = state.selectedModels.size;
|
||||||
|
const favoritedCount = this.countFavoritedInSelection();
|
||||||
|
const allFavorited = total > 0 && favoritedCount === total;
|
||||||
|
|
||||||
|
const icon = setFavoriteItem.querySelector('i');
|
||||||
|
const label = setFavoriteItem.querySelector('span');
|
||||||
|
|
||||||
|
if (allFavorited) {
|
||||||
|
if (icon) { icon.className = 'far fa-star'; }
|
||||||
|
if (label) { label.textContent = translate('loras.bulkOperations.unfavorite'); }
|
||||||
|
} else {
|
||||||
|
if (icon) { icon.className = 'fas fa-star'; }
|
||||||
|
if (label) {
|
||||||
|
label.textContent = favoritedCount > 0
|
||||||
|
? translate('loras.bulkOperations.setFavoriteCount', { favorited: favoritedCount, total })
|
||||||
|
: translate('loras.bulkOperations.setFavorite');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (setFavoriteItem) {
|
||||||
|
setFavoriteItem.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadMissingLorasItem) {
|
if (downloadMissingLorasItem) {
|
||||||
// Only show for recipes page
|
// Only show for recipes page
|
||||||
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||||
|
if (downloadExampleImagesItem) {
|
||||||
|
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
|
||||||
|
const modelPages = ['loras', 'checkpoints', 'embeddings'];
|
||||||
|
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||||
|
|
||||||
@@ -112,6 +156,14 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide empty sections
|
||||||
|
this.menu.querySelectorAll('.context-menu-section').forEach(section => {
|
||||||
|
const items = Array.from(section.querySelectorAll('.context-menu-item'))
|
||||||
|
.filter(item => !item.closest('.context-submenu'));
|
||||||
|
const allHidden = items.length > 0 && items.every(item => item.style.display === 'none');
|
||||||
|
section.style.display = allHidden ? 'none' : '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedCountHeader() {
|
updateSelectedCountHeader() {
|
||||||
@@ -138,6 +190,20 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countFavoritedInSelection() {
|
||||||
|
let count = 0;
|
||||||
|
for (const filePath of state.selectedModels) {
|
||||||
|
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||||
|
? window.CSS.escape(filePath)
|
||||||
|
: filePath.replace(/["\\]/g, '\\$&');
|
||||||
|
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||||
|
if (card && card.dataset.favorite === 'true') {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
this.updateMenuItemsForModelType();
|
this.updateMenuItemsForModelType();
|
||||||
this.updateSelectedCountHeader();
|
this.updateSelectedCountHeader();
|
||||||
@@ -185,9 +251,17 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
case 'delete-all':
|
case 'delete-all':
|
||||||
bulkManager.showBulkDeleteModal();
|
bulkManager.showBulkDeleteModal();
|
||||||
break;
|
break;
|
||||||
|
case 'set-favorite': {
|
||||||
|
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
|
||||||
|
bulkManager.setBulkFavorites(!allFavorited);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'download-missing-loras':
|
case 'download-missing-loras':
|
||||||
this.handleDownloadMissingLoras();
|
this.handleDownloadMissingLoras();
|
||||||
break;
|
break;
|
||||||
|
case 'download-example-images':
|
||||||
|
this.handleDownloadExampleImages();
|
||||||
|
break;
|
||||||
case 'clear':
|
case 'clear':
|
||||||
bulkManager.clearSelection();
|
bulkManager.clearSelection();
|
||||||
break;
|
break;
|
||||||
@@ -230,4 +304,31 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleDownloadExampleImages() {
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashes = new Set();
|
||||||
|
for (const filePath of state.selectedModels) {
|
||||||
|
const escapedPath = CSS.escape(filePath);
|
||||||
|
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||||
|
if (card?.dataset?.sha256) {
|
||||||
|
hashes.add(card.dataset.sha256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashes.size === 0) {
|
||||||
|
showToast('No valid model hashes found in selection', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
await apiClient.downloadExampleImages([...hashes]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Bulk download example images failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,126 @@ export class HeaderManager {
|
|||||||
|
|
||||||
// Hide search functionality on Statistics page
|
// Hide search functionality on Statistics page
|
||||||
this.updateHeaderForPage();
|
this.updateHeaderForPage();
|
||||||
|
|
||||||
|
// Initialize hamburger menu for mobile
|
||||||
|
this.initializeHamburgerMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeHamburgerMenu() {
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
|
||||||
|
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
|
||||||
|
|
||||||
|
if (!hamburgerBtn || !hamburgerDropdown) return;
|
||||||
|
|
||||||
|
// Toggle dropdown on hamburger button click
|
||||||
|
hamburgerBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
hamburgerDropdown.classList.toggle('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
if (hamburgerDropdown.classList.contains('active')) {
|
||||||
|
icon.classList.remove('fa-bars');
|
||||||
|
icon.classList.add('fa-times');
|
||||||
|
} else {
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle dropdown item clicks
|
||||||
|
const dropdownItems = hamburgerDropdown.querySelectorAll('.dropdown-item');
|
||||||
|
dropdownItems.forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
const action = item.dataset.action;
|
||||||
|
this.handleHamburgerAction(action);
|
||||||
|
hamburgerDropdown.classList.remove('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!hamburgerDropdown.contains(e.target) && !hamburgerBtn.contains(e.target)) {
|
||||||
|
hamburgerDropdown.classList.remove('active');
|
||||||
|
const icon = hamburgerBtn.querySelector('i');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('fa-times');
|
||||||
|
icon.classList.add('fa-bars');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update theme icon in hamburger menu based on current theme
|
||||||
|
this.updateHamburgerThemeIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHamburgerAction(action) {
|
||||||
|
switch (action) {
|
||||||
|
case 'theme':
|
||||||
|
if (typeof toggleTheme === 'function') {
|
||||||
|
const newTheme = toggleTheme();
|
||||||
|
// Update theme toggle in header if it exists
|
||||||
|
const themeToggle = document.querySelector('.theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
|
||||||
|
themeToggle.classList.add(`theme-${newTheme}`);
|
||||||
|
this.updateThemeTooltip(themeToggle, newTheme);
|
||||||
|
}
|
||||||
|
this.updateHamburgerThemeIcon();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
if (window.settingsManager) {
|
||||||
|
window.settingsManager.toggleSettings();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
const helpToggle = document.getElementById('helpToggleBtn');
|
||||||
|
if (helpToggle) {
|
||||||
|
helpToggle.click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'notifications':
|
||||||
|
updateService.toggleUpdateModal();
|
||||||
|
break;
|
||||||
|
case 'support':
|
||||||
|
if (window.modalManager) {
|
||||||
|
window.modalManager.toggleModal('supportModal');
|
||||||
|
renderSupporters().catch(error => {
|
||||||
|
console.error('Error loading supporters:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHamburgerThemeIcon() {
|
||||||
|
const themeItem = document.querySelector('.dropdown-item[data-action="theme"]');
|
||||||
|
if (!themeItem) return;
|
||||||
|
|
||||||
|
const currentTheme = getStorageItem('theme') || 'auto';
|
||||||
|
const icon = themeItem.querySelector('i');
|
||||||
|
const text = themeItem.querySelector('span');
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.remove('fa-moon', 'fa-sun', 'fa-adjust');
|
||||||
|
if (currentTheme === 'light') {
|
||||||
|
icon.classList.add('fa-sun');
|
||||||
|
} else if (currentTheme === 'dark') {
|
||||||
|
icon.classList.add('fa-moon');
|
||||||
|
} else {
|
||||||
|
icon.classList.add('fa-adjust');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update text based on current theme
|
||||||
|
if (text) {
|
||||||
|
const key = currentTheme === 'light' ? 'header.theme.switchToDark' :
|
||||||
|
currentTheme === 'dark' ? 'header.theme.switchToLight' :
|
||||||
|
'header.theme.toggle';
|
||||||
|
updateElementAttribute(themeItem, 'aria-label', key, {}, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHeaderForPage() {
|
updateHeaderForPage() {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { downloadManager } from '../managers/DownloadManager.js';
|
import { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
|
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||||
|
|
||||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||||
'prompt',
|
'prompt',
|
||||||
@@ -104,6 +105,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.setupCopyButtons();
|
this.setupCopyButtons();
|
||||||
|
this.setupStripLoraToggle();
|
||||||
this.setupPromptEditors();
|
this.setupPromptEditors();
|
||||||
// Set up tooltip positioning handlers after DOM is ready
|
// Set up tooltip positioning handlers after DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -112,6 +114,23 @@ class RecipeModal {
|
|||||||
|
|
||||||
// Set up document click handler to close edit fields
|
// Set up document click handler to close edit fields
|
||||||
document.addEventListener('click', (event) => {
|
document.addEventListener('click', (event) => {
|
||||||
|
const recipeModal = document.getElementById('recipeModal');
|
||||||
|
if (recipeModal && recipeModal.style.display !== 'none') {
|
||||||
|
const mediaEl = event.target.closest('.recipe-preview-media');
|
||||||
|
if (mediaEl && mediaEl.tagName) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const isVideo = mediaEl.tagName === 'VIDEO';
|
||||||
|
const url = mediaEl.src || mediaEl.currentSrc;
|
||||||
|
if (url) {
|
||||||
|
openMediaViewer(url, {
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
title: document.getElementById('recipeModalTitle')?.textContent || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle title edit
|
// Handle title edit
|
||||||
const titleEditor = document.getElementById('recipeTitleEditor');
|
const titleEditor = document.getElementById('recipeTitleEditor');
|
||||||
if (titleEditor && titleEditor.classList.contains('active') &&
|
if (titleEditor && titleEditor.classList.contains('active') &&
|
||||||
@@ -1332,14 +1351,20 @@ class RecipeModal {
|
|||||||
|
|
||||||
if (copyPromptBtn) {
|
if (copyPromptBtn) {
|
||||||
copyPromptBtn.addEventListener('click', () => {
|
copyPromptBtn.addEventListener('click', () => {
|
||||||
const promptText = this.currentRecipe?.gen_params?.prompt || '';
|
let promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||||
|
if (this.shouldStripLoraOnCopy()) {
|
||||||
|
promptText = RecipeModal.stripLoraTags(promptText);
|
||||||
|
}
|
||||||
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyNegativePromptBtn) {
|
if (copyNegativePromptBtn) {
|
||||||
copyNegativePromptBtn.addEventListener('click', () => {
|
copyNegativePromptBtn.addEventListener('click', () => {
|
||||||
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
let negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||||
|
if (this.shouldStripLoraOnCopy()) {
|
||||||
|
negativePromptText = RecipeModal.stripLoraTags(negativePromptText);
|
||||||
|
}
|
||||||
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1359,6 +1384,43 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip <lora:...> tags from prompt text and clean up residual punctuation/whitespace.
|
||||||
|
* Handles both unescaped (<lora:...>) and HTML-escaped (<lora:...>) variants.
|
||||||
|
* Cleans up artifacts like leading ", ", double commas, and extra whitespace.
|
||||||
|
*/
|
||||||
|
static stripLoraTags(text) {
|
||||||
|
return text
|
||||||
|
.replace(/<lora:[^>]*>/gi, '')
|
||||||
|
.replace(/<lora:[^&]*>/gi, '')
|
||||||
|
.replace(/,(\s*,)+/g, ',')
|
||||||
|
.replace(/^,\s*/, '')
|
||||||
|
.replace(/,\s*$/, '')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldStripLoraOnCopy() {
|
||||||
|
const toggle = document.getElementById('stripLoraOnCopyToggle');
|
||||||
|
return toggle ? toggle.checked : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupStripLoraToggle() {
|
||||||
|
const toggle = document.getElementById('stripLoraOnCopyToggle');
|
||||||
|
if (!toggle) return;
|
||||||
|
|
||||||
|
const stored = getStorageItem('strip_lora_on_copy');
|
||||||
|
if (stored !== null) {
|
||||||
|
toggle.checked = stored === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('change', () => {
|
||||||
|
const checked = toggle.checked;
|
||||||
|
setStorageItem('strip_lora_on_copy', checked);
|
||||||
|
state.global.settings.strip_lora_on_copy = checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch recipe syntax from backend and copy to clipboard
|
// Fetch recipe syntax from backend and copy to clipboard
|
||||||
async fetchAndCopyRecipeSyntax() {
|
async fetchAndCopyRecipeSyntax() {
|
||||||
if (!this.recipeId) {
|
if (!this.recipeId) {
|
||||||
|
|||||||
@@ -166,17 +166,6 @@ export class PageControls {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle quick refresh option
|
|
||||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
|
||||||
if (quickRefreshOption) {
|
|
||||||
quickRefreshOption.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.refreshModels(false);
|
|
||||||
// Close the dropdown
|
|
||||||
document.querySelector('.dropdown-group.active')?.classList.remove('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle full rebuild option
|
// Handle full rebuild option
|
||||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||||
if (fullRebuildOption) {
|
if (fullRebuildOption) {
|
||||||
|
|||||||
204
static/js/components/shared/MediaViewer.js
Normal file
204
static/js/components/shared/MediaViewer.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
let activeViewer = null;
|
||||||
|
|
||||||
|
function createMediaElement(item) {
|
||||||
|
const { url, type = 'image' } = item;
|
||||||
|
if (type === 'video') {
|
||||||
|
const el = document.createElement('video');
|
||||||
|
el.controls = true;
|
||||||
|
el.autoplay = true;
|
||||||
|
el.loop = true;
|
||||||
|
el.muted = true;
|
||||||
|
el.className = 'media-viewer-media media-viewer-video';
|
||||||
|
el.src = url;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
const el = document.createElement('img');
|
||||||
|
el.className = 'media-viewer-media media-viewer-image';
|
||||||
|
el.src = url;
|
||||||
|
el.alt = 'Full size preview';
|
||||||
|
el.draggable = false;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preloadAdjacent(items, index) {
|
||||||
|
[index - 1, index + 1].forEach(i => {
|
||||||
|
if (i >= 0 && i < items.length && items[i].type !== 'video') {
|
||||||
|
const preload = new Image();
|
||||||
|
preload.src = items[i].url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMediaViewer(arg1, arg2, arg3) {
|
||||||
|
closeMediaViewer();
|
||||||
|
|
||||||
|
let items, currentIndex, title = '';
|
||||||
|
|
||||||
|
if (Array.isArray(arg1)) {
|
||||||
|
items = arg1;
|
||||||
|
currentIndex = typeof arg2 === 'number' ? arg2 : 0;
|
||||||
|
title = (arg3 && arg3.title) || '';
|
||||||
|
} else {
|
||||||
|
items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }];
|
||||||
|
currentIndex = 0;
|
||||||
|
title = (arg2 && arg2.title) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'media-viewer-overlay';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-label', title || 'Media viewer');
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'media-viewer-close';
|
||||||
|
closeBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||||
|
closeBtn.title = 'Close (Esc)';
|
||||||
|
closeBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeMediaViewer();
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentContainer = document.createElement('div');
|
||||||
|
contentContainer.className = 'media-viewer-content-container';
|
||||||
|
|
||||||
|
let mediaElement = createMediaElement(items[currentIndex]);
|
||||||
|
contentContainer.appendChild(mediaElement);
|
||||||
|
|
||||||
|
const hasNavigation = items.length > 1;
|
||||||
|
|
||||||
|
const counter = document.createElement('div');
|
||||||
|
counter.className = 'media-viewer-counter';
|
||||||
|
counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : '';
|
||||||
|
contentContainer.appendChild(counter);
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
const titleBar = document.createElement('div');
|
||||||
|
titleBar.className = 'media-viewer-title';
|
||||||
|
titleBar.textContent = title;
|
||||||
|
contentContainer.appendChild(titleBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevBtn, nextBtn;
|
||||||
|
if (hasNavigation) {
|
||||||
|
prevBtn = document.createElement('button');
|
||||||
|
prevBtn.className = 'media-viewer-nav media-viewer-prev';
|
||||||
|
prevBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
|
||||||
|
prevBtn.title = 'Previous (←)';
|
||||||
|
nextBtn = document.createElement('button');
|
||||||
|
nextBtn.className = 'media-viewer-nav media-viewer-next';
|
||||||
|
nextBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||||||
|
nextBtn.title = 'Next (→)';
|
||||||
|
|
||||||
|
const navigate = (delta) => {
|
||||||
|
const newIndex = (currentIndex + delta + items.length) % items.length;
|
||||||
|
currentIndex = newIndex;
|
||||||
|
|
||||||
|
const oldMedia = contentContainer.querySelector('.media-viewer-media');
|
||||||
|
const newMedia = createMediaElement(items[currentIndex]);
|
||||||
|
|
||||||
|
if (oldMedia) {
|
||||||
|
if (oldMedia.tagName === 'VIDEO') {
|
||||||
|
oldMedia.pause();
|
||||||
|
oldMedia.src = '';
|
||||||
|
}
|
||||||
|
oldMedia.replaceWith(newMedia);
|
||||||
|
}
|
||||||
|
mediaElement = newMedia;
|
||||||
|
|
||||||
|
counter.textContent = `${currentIndex + 1} / ${items.length}`;
|
||||||
|
preloadAdjacent(items, currentIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||||
|
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); });
|
||||||
|
|
||||||
|
overlay.appendChild(prevBtn);
|
||||||
|
overlay.appendChild(nextBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.appendChild(closeBtn);
|
||||||
|
overlay.appendChild(contentContainer);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
closeMediaViewer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeMediaViewer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasNavigation) {
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
prevBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
nextBtn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', keyHandler, true);
|
||||||
|
|
||||||
|
activeViewer = { overlay, keyHandler };
|
||||||
|
preloadAdjacent(items, currentIndex);
|
||||||
|
|
||||||
|
if (items[currentIndex].type === 'video') {
|
||||||
|
const recipeVideo = document.getElementById('recipeModalVideo');
|
||||||
|
if (recipeVideo && !recipeVideo.paused) {
|
||||||
|
recipeVideo.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeMediaViewer() {
|
||||||
|
if (!activeViewer) return;
|
||||||
|
|
||||||
|
const { overlay, keyHandler } = activeViewer;
|
||||||
|
|
||||||
|
const video = overlay.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
video.pause();
|
||||||
|
video.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = overlay.querySelector('img');
|
||||||
|
if (img) {
|
||||||
|
img.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('keydown', keyHandler, true);
|
||||||
|
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
overlay.addEventListener('transitionend', () => {
|
||||||
|
if (overlay.parentNode) {
|
||||||
|
overlay.parentNode.removeChild(overlay);
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (overlay.parentNode) {
|
||||||
|
overlay.parentNode.removeChild(overlay);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
activeViewer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMediaViewerOpen() {
|
||||||
|
return activeViewer !== null;
|
||||||
|
}
|
||||||
@@ -645,7 +645,7 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '"')}">${getDisplayName(model)}</span>
|
||||||
<div>
|
<div>
|
||||||
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
|
${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''}
|
||||||
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ function isEarlyAccessActive(version) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDownloadAllowed(version) {
|
||||||
|
if (!version.usageControl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return version.usageControl === 'Download';
|
||||||
|
}
|
||||||
|
|
||||||
function buildMetaMarkup(version, options = {}) {
|
function buildMetaMarkup(version, options = {}) {
|
||||||
const segments = [];
|
const segments = [];
|
||||||
if (version.baseModel) {
|
if (version.baseModel) {
|
||||||
@@ -230,16 +237,25 @@ function buildBadge(label, tone, options = {}) {
|
|||||||
function buildActionButton(label, variant, action, options = {}) {
|
function buildActionButton(label, variant, action, options = {}) {
|
||||||
const attributes = [
|
const attributes = [
|
||||||
`class="version-action ${variant}"`,
|
`class="version-action ${variant}"`,
|
||||||
`data-version-action="${escapeHtml(action)}"`,
|
|
||||||
];
|
];
|
||||||
if (options.title) {
|
if (action) {
|
||||||
|
attributes.push(`data-version-action="${escapeHtml(action)}"`);
|
||||||
|
}
|
||||||
|
if (!options.disabled && options.title) {
|
||||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||||
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||||
}
|
}
|
||||||
|
if (options.disabled) {
|
||||||
|
attributes.push('disabled');
|
||||||
|
}
|
||||||
if (options.extraAttributes) {
|
if (options.extraAttributes) {
|
||||||
attributes.push(options.extraAttributes);
|
attributes.push(options.extraAttributes);
|
||||||
}
|
}
|
||||||
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
const buttonHtml = `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
||||||
|
if (options.disabled && options.title) {
|
||||||
|
return `<span class="version-action-disabled-wrapper" title="${escapeHtml(options.title)}" aria-label="${escapeHtml(options.title)}">${buttonHtml}</span>`;
|
||||||
|
}
|
||||||
|
return buttonHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||||
@@ -371,6 +387,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
|||||||
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!isDownloadAllowed(version)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const versionBase = normalizeBaseModelName(version.baseModel);
|
const versionBase = normalizeBaseModelName(version.baseModel);
|
||||||
if (versionBase !== normalizedBase) {
|
if (versionBase !== normalizedBase) {
|
||||||
return false;
|
return false;
|
||||||
@@ -502,6 +521,17 @@ function renderRow(version, options) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isDownloadAllowed(version)) {
|
||||||
|
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
|
||||||
|
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
|
||||||
|
title: translate(
|
||||||
|
'modals.model.versions.badges.onSiteOnlyTooltip',
|
||||||
|
{},
|
||||||
|
'This version is only available for on-site generation on Civitai'
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (version.shouldIgnore) {
|
if (version.shouldIgnore) {
|
||||||
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
||||||
title: translate(
|
title: translate(
|
||||||
@@ -524,25 +554,36 @@ function renderRow(version, options) {
|
|||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
if (!version.isInLibrary) {
|
if (!version.isInLibrary) {
|
||||||
// Download button with optional EA bolt icon
|
const canDownload = isDownloadAllowed(version);
|
||||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||||
actions.push(buildActionButton(
|
let downloadTitle;
|
||||||
downloadLabel,
|
if (!canDownload) {
|
||||||
'version-action-primary',
|
downloadTitle = translate(
|
||||||
'download',
|
'modals.model.versions.actions.downloadNotAllowedTooltip',
|
||||||
{
|
{},
|
||||||
title: isEarlyAccess
|
'This version is only available for on-site generation on Civitai'
|
||||||
? translate(
|
);
|
||||||
|
} else if (isEarlyAccess) {
|
||||||
|
downloadTitle = translate(
|
||||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||||
{},
|
{},
|
||||||
'Download this early access version from Civitai'
|
'Download this early access version from Civitai'
|
||||||
)
|
);
|
||||||
: translate(
|
} else {
|
||||||
|
downloadTitle = translate(
|
||||||
'modals.model.versions.actions.downloadTooltip',
|
'modals.model.versions.actions.downloadTooltip',
|
||||||
{},
|
{},
|
||||||
'Download this version'
|
'Download this version'
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
actions.push(buildActionButton(
|
||||||
|
downloadLabel,
|
||||||
|
canDownload ? 'version-action-primary' : 'version-action-disabled',
|
||||||
|
canDownload ? 'download' : '',
|
||||||
|
{
|
||||||
|
title: downloadTitle,
|
||||||
iconMarkup: downloadIcon,
|
iconMarkup: downloadIcon,
|
||||||
|
disabled: !canDownload,
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
} else if (version.filePath) {
|
} else if (version.filePath) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||||
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||||
|
import { openMediaViewer } from '../MediaViewer.js';
|
||||||
|
|
||||||
export const showcaseListenerMetrics = {
|
export const showcaseListenerMetrics = {
|
||||||
wheelListeners: 0,
|
wheelListeners: 0,
|
||||||
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
|
|||||||
initMediaControlHandlers(carousel);
|
initMediaControlHandlers(carousel);
|
||||||
positionAllMediaControls(carousel);
|
positionAllMediaControls(carousel);
|
||||||
|
|
||||||
|
// Click-to-view: open full-size media viewer when clicking showcase images/videos
|
||||||
|
const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
|
||||||
|
const allItems = [];
|
||||||
|
const elementIndexMap = new Map();
|
||||||
|
viewerElements.forEach((el) => {
|
||||||
|
const isVideo = el.tagName === 'VIDEO';
|
||||||
|
const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
|
||||||
|
if (url) {
|
||||||
|
elementIndexMap.set(el, allItems.length);
|
||||||
|
allItems.push({ url, type: isVideo ? 'video' : 'image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
viewerElements.forEach((mediaEl) => {
|
||||||
|
const idx = elementIndexMap.get(mediaEl);
|
||||||
|
if (idx === undefined) return;
|
||||||
|
mediaEl.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openMediaViewer(allItems, idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Bind scroll-indicator click events
|
// Bind scroll-indicator click events
|
||||||
bindScrollIndicatorEvents(carousel);
|
bindScrollIndicatorEvents(carousel);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
|||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
import { RecipeSidebarApiClient, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
@@ -41,7 +41,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: true,
|
setContentRating: true,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
@@ -53,7 +55,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: false,
|
setContentRating: false,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.CHECKPOINT]: {
|
[MODEL_TYPES.CHECKPOINT]: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
@@ -65,7 +69,9 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: true,
|
setContentRating: true,
|
||||||
skipMetadataRefresh: true
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
},
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
addTags: false,
|
addTags: false,
|
||||||
@@ -77,7 +83,9 @@ export class BulkManager {
|
|||||||
autoOrganize: false,
|
autoOrganize: false,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: false,
|
setContentRating: false,
|
||||||
skipMetadataRefresh: false
|
skipMetadataRefresh: false,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1090,6 +1098,60 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setBulkFavorites(value) {
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
|
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = state.selectedModels.size;
|
||||||
|
const isRecipesPage = state.currentPageType === 'recipes';
|
||||||
|
|
||||||
|
state.loadingManager.showSimpleLoading(
|
||||||
|
translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
|
||||||
|
);
|
||||||
|
let cancelled = false;
|
||||||
|
state.loadingManager.showCancelButton(() => {
|
||||||
|
cancelled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failureCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const filePath of state.selectedModels) {
|
||||||
|
if (cancelled) {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (isRecipesPage) {
|
||||||
|
await updateRecipeMetadata(filePath, { favorite: value });
|
||||||
|
} else {
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
await apiClient.saveModelMetadata(filePath, { favorite: value });
|
||||||
|
}
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
failureCount++;
|
||||||
|
console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.loadingManager?.hide?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount === totalCount) {
|
||||||
|
const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
|
||||||
|
showToast(toastKey, {}, 'success');
|
||||||
|
} else if (successCount > 0) {
|
||||||
|
const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
|
||||||
|
showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
|
||||||
|
} else {
|
||||||
|
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show bulk base model modal
|
* Show bulk base model modal
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { escapeHtml } from '../components/shared/utils.js';
|
import { escapeHtml } from '../components/shared/utils.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
const MAX_CONSOLE_ENTRIES = 200;
|
const MAX_CONSOLE_ENTRIES = 200;
|
||||||
|
|
||||||
@@ -258,6 +259,15 @@ export class DoctorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderInlineDetail(detail) {
|
renderInlineDetail(detail) {
|
||||||
|
if (detail.conflict_groups || detail.total_conflict_files) {
|
||||||
|
return `
|
||||||
|
<div class="doctor-inline-detail">
|
||||||
|
<strong>${escapeHtml(translate('doctor.status.warning', {}, 'Conflicts'))}</strong>
|
||||||
|
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
if (detail.client_version || detail.server_version) {
|
if (detail.client_version || detail.server_version) {
|
||||||
return `
|
return `
|
||||||
<div class="doctor-inline-detail">
|
<div class="doctor-inline-detail">
|
||||||
@@ -317,6 +327,9 @@ export class DoctorManager {
|
|||||||
case 'repair-cache':
|
case 'repair-cache':
|
||||||
await this.repairCache();
|
await this.repairCache();
|
||||||
break;
|
break;
|
||||||
|
case 'resolve-filename-conflicts':
|
||||||
|
await this.resolveFilenameConflicts();
|
||||||
|
break;
|
||||||
case 'reload-page':
|
case 'reload-page':
|
||||||
this.reloadUi();
|
this.reloadUi();
|
||||||
break;
|
break;
|
||||||
@@ -345,6 +358,47 @@ export class DoctorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resolveFilenameConflicts() {
|
||||||
|
try {
|
||||||
|
this.setLoading(true);
|
||||||
|
const response = await fetch('/api/lm/doctor/resolve-filename-conflicts', { method: 'POST' });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
throw new Error(payload.error || 'Failed to resolve filename conflicts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const renamedCount = payload.count || 0;
|
||||||
|
showToast(
|
||||||
|
'doctor.toast.conflictsResolved',
|
||||||
|
{ count: renamedCount },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update scroller items so model cards reflect new filenames immediately
|
||||||
|
if (state.virtualScroller && payload.renamed) {
|
||||||
|
for (const renamed of payload.renamed) {
|
||||||
|
const baseName = renamed.new_filename.replace(/\.[^.]+$/, '');
|
||||||
|
state.virtualScroller.updateSingleItem(renamed.old_path, {
|
||||||
|
file_name: baseName,
|
||||||
|
file_path: renamed.new_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshDiagnostics({ silent: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Doctor filename conflict resolution failed:', error);
|
||||||
|
showToast(
|
||||||
|
'doctor.toast.conflictsResolveFailed',
|
||||||
|
{ message: error.message },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exportBundle() {
|
async exportBundle() {
|
||||||
try {
|
try {
|
||||||
this.setLoading(true);
|
this.setLoading(true);
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
@@ -682,7 +682,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class SearchManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes(true); // true to reset pagination
|
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
|
|||||||
@@ -879,6 +879,12 @@ export class SettingsManager {
|
|||||||
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
|
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set show version on card
|
||||||
|
const showVersionOnCardCheckbox = document.getElementById('showVersionOnCard');
|
||||||
|
if (showVersionOnCardCheckbox) {
|
||||||
|
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
|
||||||
|
}
|
||||||
|
|
||||||
// Set model name display setting
|
// Set model name display setting
|
||||||
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
||||||
if (modelNameDisplaySelect) {
|
if (modelNameDisplaySelect) {
|
||||||
@@ -2857,7 +2863,7 @@ export class SettingsManager {
|
|||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} else if (this.currentPage === 'recipes') {
|
} else if (this.currentPage === 'recipes') {
|
||||||
// Reload the recipes without updating folders
|
// Reload the recipes without updating folders
|
||||||
await window.recipeManager.loadRecipes();
|
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
@@ -2890,6 +2896,10 @@ export class SettingsManager {
|
|||||||
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
||||||
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
||||||
|
|
||||||
|
// Apply show version on card setting
|
||||||
|
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
|
||||||
|
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
|
||||||
|
|
||||||
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
|
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
|
||||||
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
|
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
|
||||||
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class RecipePageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
refreshVirtualScroll();
|
await refreshVirtualScroll({ preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
@@ -286,16 +286,6 @@ class RecipeManager {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle quick refresh option (Sync Changes)
|
|
||||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
|
||||||
if (quickRefreshOption) {
|
|
||||||
quickRefreshOption.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
this.pageControls.refreshModels(false);
|
|
||||||
this.closeDropdowns();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle full rebuild option (Rebuild Cache)
|
// Handle full rebuild option (Rebuild Cache)
|
||||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||||
if (fullRebuildOption) {
|
if (fullRebuildOption) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
show_folder_sidebar: true,
|
show_folder_sidebar: true,
|
||||||
model_name_display: 'model_name',
|
model_name_display: 'model_name',
|
||||||
model_card_footer_action: 'example_images',
|
model_card_footer_action: 'example_images',
|
||||||
|
show_version_on_card: true,
|
||||||
include_trigger_words: false,
|
include_trigger_words: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||||
@@ -49,6 +50,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
download_skip_base_models: [],
|
download_skip_base_models: [],
|
||||||
backup_auto_enabled: true,
|
backup_auto_enabled: true,
|
||||||
backup_retention_count: 5,
|
backup_retention_count: 5,
|
||||||
|
strip_lora_on_copy: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createDefaultSettings() {
|
export function createDefaultSettings() {
|
||||||
|
|||||||
@@ -104,69 +104,74 @@ export class VirtualScroller {
|
|||||||
// Get display density setting
|
// Get display density setting
|
||||||
const displayDensity = state.global.settings?.display_density || 'default';
|
const displayDensity = state.global.settings?.display_density || 'default';
|
||||||
|
|
||||||
// Set exact column counts and grid widths to match CSS container widths
|
// Base gap between cards
|
||||||
let maxColumns, maxGridWidth;
|
const baseGap = 12;
|
||||||
|
this.columnGap = baseGap;
|
||||||
|
|
||||||
// Match exact column counts and CSS container width values based on density
|
// Define minimum card width based on density setting to ensure usability
|
||||||
|
// Cards smaller than this become hard to interact with and view
|
||||||
|
const minCardWidths = {
|
||||||
|
'default': 240, // Default: comfortable minimum
|
||||||
|
'medium': 200, // Medium: slightly smaller
|
||||||
|
'compact': 170 // Compact: smallest usable size
|
||||||
|
};
|
||||||
|
const minCardWidth = minCardWidths[displayDensity] || 240;
|
||||||
|
|
||||||
|
// Calculate maximum possible columns that fit in available width
|
||||||
|
// Formula: maxColumns = floor((availableWidth + gap) / (minCardWidth + gap))
|
||||||
|
const maxPossibleColumns = Math.floor((availableContentWidth + this.columnGap) / (minCardWidth + this.columnGap));
|
||||||
|
|
||||||
|
// Ensure at least 1 column
|
||||||
|
const maxColumns = Math.max(1, maxPossibleColumns);
|
||||||
|
|
||||||
|
// Define preferred maximum columns based on display density and screen size
|
||||||
|
// These are upper limits to prevent too many columns on ultra-wide screens
|
||||||
|
let preferredMaxColumns;
|
||||||
if (window.innerWidth >= 3000) { // 4K
|
if (window.innerWidth >= 3000) { // 4K
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 8;
|
preferredMaxColumns = 8;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 9;
|
preferredMaxColumns = 10;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 10;
|
preferredMaxColumns = 12;
|
||||||
}
|
}
|
||||||
maxGridWidth = 2400; // Match exact CSS container width for 4K
|
|
||||||
} else if (window.innerWidth >= 2150) { // 2K/1440p
|
} else if (window.innerWidth >= 2150) { // 2K/1440p
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 6;
|
preferredMaxColumns = 6;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 7;
|
preferredMaxColumns = 8;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 8;
|
preferredMaxColumns = 10;
|
||||||
}
|
}
|
||||||
maxGridWidth = 1800; // Match exact CSS container width for 2K
|
} else { // 1080p and smaller
|
||||||
} else {
|
|
||||||
// 1080p
|
|
||||||
if (displayDensity === 'default') {
|
if (displayDensity === 'default') {
|
||||||
maxColumns = 5;
|
preferredMaxColumns = 5;
|
||||||
} else if (displayDensity === 'medium') {
|
} else if (displayDensity === 'medium') {
|
||||||
maxColumns = 6;
|
preferredMaxColumns = 6;
|
||||||
} else { // compact
|
} else { // compact
|
||||||
maxColumns = 7;
|
preferredMaxColumns = 8;
|
||||||
}
|
}
|
||||||
maxGridWidth = 1400; // Match exact CSS container width for 1080p
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate baseCardWidth based on desired column count and available space
|
// Use the smaller of: max columns that fit, or preferred max
|
||||||
// Formula: (maxGridWidth - (columns-1)*gap) / columns
|
// This ensures cards are never smaller than minCardWidth
|
||||||
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
|
this.columnsCount = Math.min(maxColumns, preferredMaxColumns);
|
||||||
|
|
||||||
// Use the smaller of available content width or max grid width
|
// Calculate card width to perfectly fill available space
|
||||||
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
// Formula: (availableWidth - totalGap) / columns
|
||||||
|
const totalGap = (this.columnsCount - 1) * this.columnGap;
|
||||||
|
this.itemWidth = (availableContentWidth - totalGap) / this.columnsCount;
|
||||||
|
|
||||||
// Set exact column count based on screen size and mode
|
// Calculate height based on aspect ratio (896/1152)
|
||||||
this.columnsCount = maxColumns;
|
|
||||||
|
|
||||||
// When available width is smaller than maxGridWidth, recalculate columns
|
|
||||||
if (availableContentWidth < maxGridWidth) {
|
|
||||||
// Calculate how many columns can fit in the available space
|
|
||||||
this.columnsCount = Math.max(1, Math.floor(
|
|
||||||
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate actual item width
|
|
||||||
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
|
|
||||||
|
|
||||||
// Calculate height based on aspect ratio
|
|
||||||
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
||||||
|
|
||||||
// Calculate the left offset to center the grid within the content area
|
// Edge-to-edge layout: no offset, grid fills container
|
||||||
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
|
this.leftOffset = 0;
|
||||||
|
const actualGridWidth = this.itemWidth * this.columnsCount + totalGap;
|
||||||
|
|
||||||
// Update grid element max-width to match available width
|
// Update grid element to fill available width
|
||||||
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
||||||
|
this.gridElement.style.width = `${actualGridWidth}px`;
|
||||||
|
|
||||||
// Add or remove density classes for style adjustments
|
// Add or remove density classes for style adjustments
|
||||||
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
||||||
@@ -478,6 +483,12 @@ export class VirtualScroller {
|
|||||||
element.style.width = `${this.itemWidth}px`;
|
element.style.width = `${this.itemWidth}px`;
|
||||||
element.style.height = `${this.itemHeight}px`;
|
element.style.height = `${this.itemHeight}px`;
|
||||||
|
|
||||||
|
// Remove max-width constraint from model-card to allow dynamic sizing
|
||||||
|
const modelCard = element.querySelector('.model-card');
|
||||||
|
if (modelCard) {
|
||||||
|
modelCard.style.maxWidth = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export const BASE_MODELS = {
|
|||||||
HUNYUAN_VIDEO: "Hunyuan Video",
|
HUNYUAN_VIDEO: "Hunyuan Video",
|
||||||
// Other models
|
// Other models
|
||||||
ANIMA: "Anima",
|
ANIMA: "Anima",
|
||||||
|
ERNIE: "Ernie",
|
||||||
|
ERNIE_TURBO: "Ernie Turbo",
|
||||||
|
NUCLEUS: "Nucleus",
|
||||||
PONY_V7: "Pony V7",
|
PONY_V7: "Pony V7",
|
||||||
// Default
|
// Default
|
||||||
UNKNOWN: "Other"
|
UNKNOWN: "Other"
|
||||||
@@ -191,6 +194,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
|
|||||||
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
|
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
|
||||||
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
|
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
|
||||||
[BASE_MODELS.ANIMA]: 'ANI',
|
[BASE_MODELS.ANIMA]: 'ANI',
|
||||||
|
[BASE_MODELS.ERNIE]: 'ERNI',
|
||||||
|
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
|
||||||
|
[BASE_MODELS.NUCLEUS]: 'NUCL',
|
||||||
|
|
||||||
// Default
|
// Default
|
||||||
[BASE_MODELS.UNKNOWN]: 'OTH'
|
[BASE_MODELS.UNKNOWN]: 'OTH'
|
||||||
@@ -394,6 +400,7 @@ export const BASE_MODEL_CATEGORIES = {
|
|||||||
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
|
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
|
||||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
||||||
|
BASE_MODELS.ERNIE, BASE_MODELS.ERNIE_TURBO, BASE_MODELS.NUCLEUS,
|
||||||
BASE_MODELS.UNKNOWN
|
BASE_MODELS.UNKNOWN
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
5
templates/components/breadcrumb.html
Normal file
5
templates/components/breadcrumb.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
||||||
|
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
||||||
|
<!-- Breadcrumbs will be populated by JavaScript -->
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
@@ -53,32 +53,32 @@
|
|||||||
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
|
<span>{{ t('loras.bulkOperations.selected', {'count': 0}) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item" data-action="refresh-all">
|
<div class="context-menu-section" data-section="workflow">
|
||||||
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.workflow') }}</div>
|
||||||
</div>
|
<div class="context-menu-item has-submenu" data-has-submenu="send-to-workflow">
|
||||||
<div class="context-menu-item" data-action="check-updates">
|
<i class="fas fa-paper-plane"></i>
|
||||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
<span>{{ t('loras.bulkOperations.sendToWorkflow') }}</span>
|
||||||
</div>
|
<i class="fas fa-chevron-right submenu-arrow"></i>
|
||||||
<div class="context-menu-item" data-action="copy-all">
|
<div class="context-submenu">
|
||||||
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="send-to-workflow-append">
|
<div class="context-menu-item" data-action="send-to-workflow-append">
|
||||||
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
|
<i class="fas fa-paper-plane"></i> <span>{{ t('loras.contextMenu.sendToWorkflowAppend') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="send-to-workflow-replace">
|
<div class="context-menu-item" data-action="send-to-workflow-replace">
|
||||||
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
|
<i class="fas fa-exchange-alt"></i> <span>{{ t('loras.contextMenu.sendToWorkflowReplace') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="auto-organize">
|
<div class="context-menu-item" data-action="copy-all">
|
||||||
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
|
<i class="fas fa-copy"></i> <span>{{ t('loras.bulkOperations.copyAll') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="add-tags">
|
|
||||||
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="set-base-model">
|
|
||||||
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="set-content-rating">
|
</div>
|
||||||
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
<div class="context-menu-section" data-section="metadata">
|
||||||
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.metadata') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="refresh-all">
|
||||||
|
<i class="fas fa-sync-alt"></i> <span>{{ t('loras.bulkOperations.refreshAll') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="check-updates">
|
||||||
|
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
||||||
</div>
|
</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>
|
||||||
@@ -86,13 +86,41 @@
|
|||||||
<div class="context-menu-item" data-action="resume-metadata-refresh">
|
<div class="context-menu-item" data-action="resume-metadata-refresh">
|
||||||
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-separator"></div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="download-missing-loras">
|
<div class="context-menu-section" data-section="attributes">
|
||||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.attributes') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="add-tags">
|
||||||
|
<i class="fas fa-tags"></i> <span>{{ t('loras.bulkOperations.addTags') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="set-base-model">
|
||||||
|
<i class="fas fa-layer-group"></i> <span>{{ t('loras.bulkOperations.setBaseModel') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="set-favorite">
|
||||||
|
<i class="fas fa-star"></i> <span>{{ t('loras.bulkOperations.setFavorite') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="set-content-rating">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> <span>{{ t('loras.bulkOperations.setContentRating') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-section" data-section="organize">
|
||||||
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.organize') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="auto-organize">
|
||||||
|
<i class="fas fa-magic"></i> <span>{{ t('loras.bulkOperations.autoOrganize') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="move-all">
|
<div class="context-menu-item" data-action="move-all">
|
||||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-section" data-section="download">
|
||||||
|
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="download-example-images">
|
||||||
|
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="download-missing-loras">
|
||||||
|
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item delete-item" data-action="delete-all">
|
<div class="context-menu-item delete-item" data-action="delete-all">
|
||||||
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
|
<i class="fas fa-trash"></i> <span>{{ t('loras.bulkOperations.deleteAll') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,9 +41,6 @@
|
|||||||
<i class="fas fa-caret-down"></i>
|
<i class="fas fa-caret-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('loras.controls.refresh.quickTooltip') }}">
|
|
||||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
|
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('loras.controls.refresh.fullTooltip') }}">
|
||||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
|
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,11 +126,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Breadcrumb Navigation -->
|
|
||||||
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
|
|
||||||
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
|
|
||||||
<!-- Breadcrumbs will be populated by JavaScript -->
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-container">
|
<div class="header-container">
|
||||||
|
<!-- Left section: Logo + Navigation -->
|
||||||
|
<div class="header-left">
|
||||||
<div class="header-branding">
|
<div class="header-branding">
|
||||||
<a href="/loras" class="logo-link">
|
<a href="/loras" class="logo-link">
|
||||||
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
|
||||||
@@ -18,10 +20,6 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% set current_page = 'loras' %}
|
{% set current_page = 'loras' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set search_disabled = current_page == 'statistics' %}
|
|
||||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
|
||||||
current_page %}
|
|
||||||
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||||
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
||||||
@@ -43,8 +41,13 @@
|
|||||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Context-aware search container -->
|
<!-- Center section: Search -->
|
||||||
|
{% set search_disabled = current_page == 'statistics' %}
|
||||||
|
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
||||||
|
current_page %}
|
||||||
|
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
||||||
<div class="{{ header_search_class }}" id="headerSearch">
|
<div class="{{ header_search_class }}" id="headerSearch">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||||
@@ -62,9 +65,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<!-- Right section: Controls -->
|
||||||
<!-- Integrated corner controls -->
|
<div class="header-right">
|
||||||
<div class="header-controls">
|
<div class="header-controls" id="headerControls">
|
||||||
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
|
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
|
||||||
<i class="fas fa-moon dark-icon"></i>
|
<i class="fas fa-moon dark-icon"></i>
|
||||||
<i class="fas fa-sun light-icon"></i>
|
<i class="fas fa-sun light-icon"></i>
|
||||||
@@ -85,6 +88,34 @@
|
|||||||
<i class="fas fa-heart"></i>
|
<i class="fas fa-heart"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Hamburger menu button (visible on mobile) -->
|
||||||
|
<button class="hamburger-menu-btn" id="hamburgerMenuBtn" title="{{ t('common.actions.menu') }}">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<!-- Hamburger dropdown menu -->
|
||||||
|
<div class="hamburger-dropdown" id="hamburgerDropdown">
|
||||||
|
<div class="dropdown-item theme-toggle-item" data-action="theme">
|
||||||
|
<i class="fas fa-moon"></i>
|
||||||
|
<span>{{ t('header.theme.toggle') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="settings">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
<span>{{ t('common.actions.settings') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="help">
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
<span>{{ t('common.actions.help') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" data-action="notifications">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>{{ t('header.actions.notifications') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<div class="dropdown-item" data-action="support">
|
||||||
|
<i class="fas fa-heart"></i>
|
||||||
|
<span>{{ t('header.actions.support') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -554,6 +554,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="showVersionOnCard">
|
||||||
|
{{ t('settings.layoutSettings.showVersionOnCard') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.showVersionOnCardHelp') }}"></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="showVersionOnCard"
|
||||||
|
onchange="settingsManager.saveToggleSetting('showVersionOnCard', 'show_version_on_card')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
|
|||||||
@@ -22,7 +22,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-section recipe-gen-params">
|
<div class="info-section recipe-gen-params">
|
||||||
|
<div class="gen-params-header-row">
|
||||||
<h3>Generation Parameters</h3>
|
<h3>Generation Parameters</h3>
|
||||||
|
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, <lora:...> tags are removed from prompt text when copying">
|
||||||
|
<span class="inline-toggle-label">Strip <lora:></span>
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="stripLoraOnCopyToggle">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="gen-params-container">
|
<div class="gen-params-container">
|
||||||
<!-- Prompt -->
|
<!-- Prompt -->
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
{% include 'components/breadcrumb.html' %}
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,6 @@
|
|||||||
<i class="fas fa-caret-down"></i>
|
<i class="fas fa-caret-down"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('recipes.controls.refresh.quickTooltip', default='Sync changes - quick refresh without rebuilding cache') }}">
|
|
||||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick', default='Sync Changes') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ describe('LoRA widget drag interactions', () => {
|
|||||||
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
|
dragEl.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1 }));
|
||||||
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
|
expect(document.body.classList.contains('lm-lora-strength-dragging')).toBe(false);
|
||||||
expect(onDragEnd).toHaveBeenCalledTimes(1);
|
expect(onDragEnd).toHaveBeenCalledTimes(1);
|
||||||
expect(renderSpy).toHaveBeenCalledWith(widget.value, widget);
|
// 454210a4 replaced renderFunction() with widget.value setter + widget.callback()
|
||||||
|
expect(widget.callback).toHaveBeenCalledWith(widget.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {
|
it('deletes the selected LoRA when backspace is pressed outside of strength inputs', async () => {
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ function renderControlsDom(pageKey) {
|
|||||||
<button data-action="refresh" class="dropdown-main"></button>
|
<button data-action="refresh" class="dropdown-main"></button>
|
||||||
<button class="dropdown-toggle"></button>
|
<button class="dropdown-toggle"></button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<div class="dropdown-item" data-action="quick-refresh"></div>
|
|
||||||
<div class="dropdown-item" data-action="full-rebuild"></div>
|
<div class="dropdown-item" data-action="full-rebuild"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class FakeDownloadHistoryService:
|
|||||||
async def mark_downloaded(self, *_args, **_kwargs):
|
async def mark_downloaded(self, *_args, **_kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def mark_not_downloaded(self, *_args, **_kwargs):
|
async def mark_as_deleted(self, *_args, **_kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
from py.routes.handlers.misc_handlers import (
|
from py.routes.handlers.misc_handlers import (
|
||||||
BackupHandler,
|
BackupHandler,
|
||||||
DoctorHandler,
|
DoctorHandler,
|
||||||
@@ -78,10 +79,11 @@ async def dummy_downloader_factory():
|
|||||||
|
|
||||||
|
|
||||||
class DummyDoctorScanner:
|
class DummyDoctorScanner:
|
||||||
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None):
|
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None, hash_index=None):
|
||||||
self.model_type = model_type
|
self.model_type = model_type
|
||||||
self._raw_data = list(raw_data or [])
|
self._raw_data = list(raw_data or [])
|
||||||
self._rebuild_error = rebuild_error
|
self._rebuild_error = rebuild_error
|
||||||
|
self._hash_index = hash_index
|
||||||
self._persistent_cache = SimpleNamespace(
|
self._persistent_cache = SimpleNamespace(
|
||||||
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
|
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
|
||||||
)
|
)
|
||||||
@@ -91,6 +93,16 @@ class DummyDoctorScanner:
|
|||||||
raise self._rebuild_error
|
raise self._rebuild_error
|
||||||
return SimpleNamespace(raw_data=list(self._raw_data))
|
return SimpleNamespace(raw_data=list(self._raw_data))
|
||||||
|
|
||||||
|
async def update_single_model_cache(self, original_path, new_path, metadata):
|
||||||
|
for item in self._raw_data:
|
||||||
|
if item.get("file_path") == original_path:
|
||||||
|
item["file_path"] = new_path
|
||||||
|
item["file_name"] = metadata.get("file_name", item.get("file_name", ""))
|
||||||
|
if metadata.get("preview_url"):
|
||||||
|
item["preview_url"] = metadata["preview_url"]
|
||||||
|
break
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class DummyCivitaiClient:
|
class DummyCivitaiClient:
|
||||||
def __init__(self, *, success=True, result=None):
|
def __init__(self, *, success=True, result=None):
|
||||||
@@ -891,7 +903,7 @@ class FakeDownloadHistoryService:
|
|||||||
(model_type, version_id, model_id, source, file_path)
|
(model_type, version_id, model_id, source, file_path)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def mark_not_downloaded(self, model_type, version_id):
|
async def mark_as_deleted(self, model_type, version_id):
|
||||||
self.marked_not_downloaded.append((model_type, version_id))
|
self.marked_not_downloaded.append((model_type, version_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -1582,3 +1594,107 @@ def test_wsl_to_windows_path_returns_none_on_subprocess_error(tmp_path):
|
|||||||
):
|
):
|
||||||
result = _wsl_to_windows_path("/mnt/c/test")
|
result = _wsl_to_windows_path("/mnt/c/test")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── DoctorHandler filename conflict tests ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_filename_conflicts_returns_ok_when_no_duplicates():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora", raw_data=[], hash_index=hash_index
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
|
||||||
|
assert diagnostic_map["filename_conflicts"]["status"] == "ok"
|
||||||
|
assert "No duplicate filenames" in diagnostic_map["filename_conflicts"]["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_filename_conflicts_detects_duplicates():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
hash_index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
hash_index.add_entry("def456", "/b/lora.safetensors")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora",
|
||||||
|
raw_data=[
|
||||||
|
{"file_path": "/a/lora.safetensors"},
|
||||||
|
{"file_path": "/b/lora.safetensors"},
|
||||||
|
],
|
||||||
|
hash_index=hash_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
|
||||||
|
conflict_diag = diagnostic_map["filename_conflicts"]
|
||||||
|
assert conflict_diag["status"] == "warning"
|
||||||
|
assert "1 filename(s)" in conflict_diag["summary"]
|
||||||
|
assert any("resolve-filename-conflicts" in str(a) for a in conflict_diag.get("actions", []))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_filename_conflicts_returns_renamed_list():
|
||||||
|
hash_index = ModelHashIndex()
|
||||||
|
hash_index.add_entry("abc123", "lora.safetensors")
|
||||||
|
hash_index.add_entry("def456", "lora.safetensors")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return DummyDoctorScanner(
|
||||||
|
model_type="lora",
|
||||||
|
raw_data=[],
|
||||||
|
hash_index=hash_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
# Files don't exist on disk, so nothing gets renamed
|
||||||
|
assert payload["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_filename_conflicts_handles_scanner_error_gracefully():
|
||||||
|
class ErrorScanner:
|
||||||
|
model_type = "lora"
|
||||||
|
|
||||||
|
async def get_cached_data(self):
|
||||||
|
raise RuntimeError("scanner unavailable")
|
||||||
|
|
||||||
|
async def scanner_factory():
|
||||||
|
return ErrorScanner()
|
||||||
|
|
||||||
|
handler = DoctorHandler(
|
||||||
|
settings_service=DummySettings({"civitai_api_key": "token"}),
|
||||||
|
scanner_factories=(("lora", "LoRAs", scanner_factory),),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["count"] == 0
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async def test_download_history_roundtrip_and_manual_override(tmp_path: Path) ->
|
|||||||
assert await service.has_been_downloaded("lora", 101) is True
|
assert await service.has_been_downloaded("lora", 101) is True
|
||||||
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||||
|
|
||||||
await service.mark_not_downloaded("lora", 101)
|
await service.mark_as_deleted("lora", 101)
|
||||||
assert await service.has_been_downloaded("lora", 101) is False
|
assert await service.has_been_downloaded("lora", 101) is False
|
||||||
assert await service.get_downloaded_version_ids("lora", 11) == []
|
assert await service.get_downloaded_version_ids("lora", 11) == []
|
||||||
|
|
||||||
|
|||||||
113
tests/services/test_model_hash_index.py
Normal file
113
tests/services/test_model_hash_index.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import pytest
|
||||||
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelHashIndexRemoveByPath:
|
||||||
|
def test_remove_by_path_finds_hash_in_hash_to_path(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
index.remove_by_path("/models/lora.safetensors")
|
||||||
|
assert len(index) == 0
|
||||||
|
assert not index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
def test_remove_by_path_falls_back_to_duplicate_hashes(self):
|
||||||
|
"""When a path is only tracked in _duplicate_hashes, remove_by_path
|
||||||
|
should still find and remove it."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora_v1.safetensors")
|
||||||
|
index.add_entry("abc123", "/models/lora_v2.safetensors")
|
||||||
|
|
||||||
|
# lora_v1 is the primary (_hash_to_path), lora_v2 is in _duplicate_hashes
|
||||||
|
index.remove_by_path("/models/lora_v2.safetensors")
|
||||||
|
|
||||||
|
assert len(index) == 1
|
||||||
|
assert index._hash_to_path.get("abc123") == "/models/lora_v1.safetensors"
|
||||||
|
assert "abc123" not in index._duplicate_hashes
|
||||||
|
|
||||||
|
def test_remove_by_path_cleans_up_duplicate_filenames(self):
|
||||||
|
"""After removing a path, _duplicate_filenames should be updated."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/other/mylora.safetensors")
|
||||||
|
|
||||||
|
assert "mylora" in index.get_duplicate_filenames()
|
||||||
|
assert len(index.get_duplicate_filenames()["mylora"]) == 2
|
||||||
|
|
||||||
|
index.remove_by_path("/other/mylora.safetensors")
|
||||||
|
|
||||||
|
# After removing one duplicate, only one path remains — no longer a duplicate
|
||||||
|
assert "mylora" not in index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
def test_remove_by_path_keeps_duplicate_filenames_with_three_entries(self):
|
||||||
|
"""With 3 entries for the same filename, removing one should leave 2."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/other/mylora.safetensors")
|
||||||
|
index.add_entry("ghi789", "/third/mylora.safetensors")
|
||||||
|
|
||||||
|
index.remove_by_path("/other/mylora.safetensors")
|
||||||
|
|
||||||
|
assert "mylora" in index.get_duplicate_filenames()
|
||||||
|
paths = index.get_duplicate_filenames()["mylora"]
|
||||||
|
assert len(paths) == 2
|
||||||
|
assert "/other/mylora.safetensors" not in paths
|
||||||
|
|
||||||
|
def test_remove_by_path_noop_on_unknown_path(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
# Should not raise
|
||||||
|
index.remove_by_path("/nonexistent/lora.safetensors")
|
||||||
|
assert len(index) == 1
|
||||||
|
|
||||||
|
def test_remove_by_path_handles_hash_from_duplicate_hashes_only(self):
|
||||||
|
"""Remove a path whose hash exists ONLY in _duplicate_hashes,
|
||||||
|
not in _hash_to_path (edge case from index rebuilds)."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/model.safetensors")
|
||||||
|
index.add_entry("abc123", "/b/model.safetensors")
|
||||||
|
|
||||||
|
# Manually remove the primary entry to simulate edge case
|
||||||
|
del index._hash_to_path["abc123"]
|
||||||
|
# Now the path is only referenced in _duplicate_hashes
|
||||||
|
assert "abc123" in index._duplicate_hashes
|
||||||
|
|
||||||
|
index.remove_by_path("/b/model.safetensors")
|
||||||
|
# The remaining path is promoted to _hash_to_path, duplicates cleared
|
||||||
|
assert "abc123" not in index._duplicate_hashes
|
||||||
|
assert index._hash_to_path.get("abc123") == "/a/model.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelHashIndexGetDuplicateFilenames:
|
||||||
|
def test_empty_index_returns_empty_dict(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
|
|
||||||
|
def test_no_duplicates_returns_empty_dict(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/models/lora.safetensors")
|
||||||
|
index.add_entry("def456", "/models/other.safetensors")
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
|
|
||||||
|
def test_duplicate_filenames_detected(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/mylora.safetensors")
|
||||||
|
index.add_entry("def456", "/b/mylora.safetensors")
|
||||||
|
dupes = index.get_duplicate_filenames()
|
||||||
|
assert "mylora" in dupes
|
||||||
|
assert len(dupes["mylora"]) == 2
|
||||||
|
|
||||||
|
def test_same_hash_same_name_not_a_filename_duplicate(self):
|
||||||
|
"""Same hash with same filename = hash duplicate, not filename conflict."""
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
# Same hash, same filename — this is a true duplicate (hash collision)
|
||||||
|
# but the filename index only tracks different files with same name
|
||||||
|
# Currently add_entry for same hash+path would update, not create duplicate
|
||||||
|
# This is correct behavior — filename dupes are for different files
|
||||||
|
|
||||||
|
def test_add_entry_idempotent_for_same_path_and_hash(self):
|
||||||
|
index = ModelHashIndex()
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
index.add_entry("abc123", "/a/lora.safetensors")
|
||||||
|
assert len(index) == 1
|
||||||
|
assert index.get_duplicate_filenames() == {}
|
||||||
@@ -624,3 +624,42 @@ async def test_reconcile_cache_removes_duplicate_alias_when_same_real_file_seen_
|
|||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
cached_paths = {item["file_path"] for item in cache.raw_data}
|
cached_paths = {item["file_path"] for item in cache.raw_data}
|
||||||
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
|
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplog):
|
||||||
|
"""When duplicate filenames exist, _log_duplicate_filename_summary should emit
|
||||||
|
a single warning log with the conflict count and total file count."""
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
scanner = DummyScanner(root)
|
||||||
|
|
||||||
|
# Simulate duplicate filenames in the hash index
|
||||||
|
scanner._hash_index.add_entry("aaa111", str(root / "model.safetensors"))
|
||||||
|
scanner._hash_index.add_entry("bbb222", str(root / "dir" / "model.safetensors"))
|
||||||
|
|
||||||
|
scanner._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
assert len(caplog.records) >= 1
|
||||||
|
log_msg = caplog.records[-1].message
|
||||||
|
assert "Duplicate filename conflict detected" in log_msg
|
||||||
|
assert "1 dummy filename(s)" in log_msg
|
||||||
|
assert "2 files total" in log_msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path: Path, caplog):
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
|
||||||
|
root = tmp_path / "loras"
|
||||||
|
root.mkdir()
|
||||||
|
scanner = DummyScanner(root)
|
||||||
|
scanner._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
# No warning should be logged when there are no duplicates
|
||||||
|
for record in caplog.records:
|
||||||
|
assert "Duplicate filename conflict detected" not in record.message
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
|||||||
recipe = {
|
recipe = {
|
||||||
"id": "r1",
|
"id": "r1",
|
||||||
"title": "Old Recipe",
|
"title": "Old Recipe",
|
||||||
"source_url": "https://civitai.com/images/12345",
|
"source_path": "https://civitai.com/images/12345",
|
||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
"gen_params": {"prompt": ""}
|
"gen_params": {"prompt": ""}
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
|
|||||||
recipe = {
|
recipe = {
|
||||||
"id": "r1",
|
"id": "r1",
|
||||||
"title": "Red Recipe",
|
"title": "Red Recipe",
|
||||||
"source_url": "https://civitai.red/images/12345",
|
"source_path": "https://civitai.red/images/12345",
|
||||||
"checkpoint": None,
|
"checkpoint": None,
|
||||||
"gen_params": {"prompt": ""},
|
"gen_params": {"prompt": ""},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,8 +186,22 @@ onMounted(() => {
|
|||||||
(container as any).__widgetInputEl.inputEl = textareaRef.value
|
(container as any).__widgetInputEl.inputEl = textareaRef.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize hasText state
|
// Apply pending value from setValue if exists (workflow loading before Vue mount)
|
||||||
|
const pendingValue = (props.widget as any)._pendingValue
|
||||||
|
if (pendingValue !== undefined) {
|
||||||
|
textareaRef.value.value = pendingValue
|
||||||
|
hasText.value = pendingValue.length > 0
|
||||||
|
delete (props.widget as any)._pendingValue
|
||||||
|
// Dispatch event to notify autocomplete of value change
|
||||||
|
textareaRef.value.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
|
||||||
|
detail: { value: pendingValue }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize hasText state (already done if pendingValue was applied, but safe to re-check)
|
||||||
|
if (pendingValue === undefined) {
|
||||||
hasText.value = textareaRef.value.value.length > 0
|
hasText.value = textareaRef.value.value.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for external value change events from setValue
|
// Listen for external value change events from setValue
|
||||||
textareaRef.value.addEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
|
textareaRef.value.addEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ function shouldBypassAutocompleteWidgetMigration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) =>
|
const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) =>
|
||||||
widgetNames.has(input.name)
|
widgetNames.has(input.name) || input.forceInput
|
||||||
)
|
)
|
||||||
|
|
||||||
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) =>
|
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) =>
|
||||||
@@ -441,10 +441,12 @@ function shouldBypassAutocompleteWidgetMigration(
|
|||||||
: [!!input.forceInput]
|
: [!!input.forceInput]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const result = (
|
||||||
widgetIndexHasForceInput.some(Boolean) &&
|
widgetIndexHasForceInput.some(Boolean) &&
|
||||||
widgetIndexHasForceInput.length === widgetValues.length
|
widgetIndexHasForceInput.length === widgetValues.length
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function remapWidgetValuesByName(
|
function remapWidgetValuesByName(
|
||||||
@@ -459,6 +461,7 @@ function remapWidgetValuesByName(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const currentWidgetNameSet = new Set(currentWidgetNames)
|
||||||
const remappedValues: unknown[] = []
|
const remappedValues: unknown[] = []
|
||||||
for (const name of currentWidgetNames) {
|
for (const name of currentWidgetNames) {
|
||||||
if (valueByName.has(name)) {
|
if (valueByName.has(name)) {
|
||||||
@@ -466,6 +469,18 @@ function remapWidgetValuesByName(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append values for saved widget names that are NOT in the current widget
|
||||||
|
// list (e.g. forceInput widgets like "seed" that haven't been converted
|
||||||
|
// back to DOM widgets yet at configure time). Without these, the
|
||||||
|
// resulting array may accidentally match the length of ComfyUI's
|
||||||
|
// widgetIndexHasForceInput array, causing migrateWidgetsValues to
|
||||||
|
// incorrectly filter out the wrong values and drop real widget content.
|
||||||
|
for (const name of savedWidgetNames) {
|
||||||
|
if (!currentWidgetNameSet.has(name) && valueByName.has(name)) {
|
||||||
|
remappedValues.push(valueByName.get(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return remappedValues
|
return remappedValues
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +513,7 @@ function normalizeAutocompleteWidgetValues(node: any, info: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentWidgetNames = getSerializableWidgetNames(node)
|
const currentWidgetNames = getSerializableWidgetNames(node)
|
||||||
|
|
||||||
if (currentWidgetNames.length === 0) {
|
if (currentWidgetNames.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -615,6 +631,8 @@ function createAutocompleteTextWidgetFactory(
|
|||||||
inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
|
inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
|
||||||
detail: { value: v ?? '' }
|
detail: { value: v ?? '' }
|
||||||
}))
|
}))
|
||||||
|
} else {
|
||||||
|
;(widget as any)._pendingValue = v ?? ''
|
||||||
}
|
}
|
||||||
// Also call onSetValue if defined (for Vue component integration)
|
// Also call onSetValue if defined (for Vue component integration)
|
||||||
if (typeof widget.onSetValue === 'function') {
|
if (typeof widget.onSetValue === 'function') {
|
||||||
@@ -751,10 +769,16 @@ app.registerExtension({
|
|||||||
|
|
||||||
nodeType.prototype.configure = function (info: any) {
|
nodeType.prototype.configure = function (info: any) {
|
||||||
normalizeAutocompleteWidgetValues(this, info)
|
normalizeAutocompleteWidgetValues(this, info)
|
||||||
if (shouldBypassAutocompleteWidgetMigration(this, info?.widgets_values ?? [])) {
|
|
||||||
|
const bypassResult = shouldBypassAutocompleteWidgetMigration(this, info?.widgets_values ?? [])
|
||||||
|
|
||||||
|
if (bypassResult) {
|
||||||
info.widgets_values = [...(info.widgets_values ?? []), null]
|
info.widgets_values = [...(info.widgets_values ?? []), null]
|
||||||
}
|
}
|
||||||
return originalConfigure?.apply(this, arguments)
|
|
||||||
|
const result = originalConfigure?.apply(this, arguments)
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,9 +232,13 @@ export function initDrag(
|
|||||||
onDragEnd();
|
onDragEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now do the re-render after drag is complete
|
// Commit final value through options.setValue so external observers are notified.
|
||||||
if (renderFunction) {
|
// During drag, handleStrengthDrag mutates widgetValue in-place (updateWidget=false),
|
||||||
renderFunction(widget.value, widget);
|
// bypassing widget.value setter and options.setValue entirely. This assignment
|
||||||
|
// flushes the in-place mutation through the setter so any setValue wrappers fire.
|
||||||
|
widget.value = widget.value;
|
||||||
|
if (typeof widget.callback === 'function') {
|
||||||
|
widget.callback(widget.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -349,8 +353,12 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
|
|||||||
document.body.classList.remove('lm-lora-strength-dragging');
|
document.body.classList.remove('lm-lora-strength-dragging');
|
||||||
|
|
||||||
// Only re-render if we actually dragged
|
// Only re-render if we actually dragged
|
||||||
if (wasDragging && renderFunction) {
|
if (wasDragging) {
|
||||||
renderFunction(widget.value, widget);
|
// Commit final value through options.setValue so external observers are notified.
|
||||||
|
widget.value = widget.value;
|
||||||
|
if (typeof widget.callback === 'function') {
|
||||||
|
widget.callback(widget.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ app.registerExtension({
|
|||||||
const savedItem = consumeQueuedState(itemState, itemText);
|
const savedItem = consumeQueuedState(itemState, itemText);
|
||||||
return {
|
return {
|
||||||
text: itemText,
|
text: itemText,
|
||||||
active: savedItem ? savedItem.active : defaultActive,
|
active: savedItem ? savedItem.active : true,
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
strength: null,
|
strength: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
|
|||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-text-widget[data-v-76ce0f19] {
|
.autocomplete-text-widget[data-v-5514bf46] {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.input-wrapper[data-v-76ce0f19] {
|
.input-wrapper[data-v-5514bf46] {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
|
||||||
.text-input[data-v-76ce0f19] {
|
.text-input[data-v-5514bf46] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--comfy-input-bg, #222);
|
background-color: var(--comfy-input-bg, #222);
|
||||||
@@ -2150,7 +2150,7 @@ to { transform: rotate(360deg);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
|
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
|
||||||
.text-input.vue-dom-mode[data-v-76ce0f19] {
|
.text-input.vue-dom-mode[data-v-5514bf46] {
|
||||||
background-color: var(--color-charcoal-400, #313235);
|
background-color: var(--color-charcoal-400, #313235);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
|
||||||
@@ -2159,12 +2159,12 @@ to { transform: rotate(360deg);
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.text-input[data-v-76ce0f19]:focus {
|
.text-input[data-v-5514bf46]:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clear button styles */
|
/* Clear button styles */
|
||||||
.clear-button[data-v-76ce0f19] {
|
.clear-button[data-v-5514bf46] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
bottom: 6px; /* Changed from top to bottom */
|
bottom: 6px; /* Changed from top to bottom */
|
||||||
@@ -2187,31 +2187,31 @@ to { transform: rotate(360deg);
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Show clear button when hovering over input wrapper */
|
/* Show clear button when hovering over input wrapper */
|
||||||
.input-wrapper:hover .clear-button[data-v-76ce0f19] {
|
.input-wrapper:hover .clear-button[data-v-5514bf46] {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.clear-button[data-v-76ce0f19]:hover {
|
.clear-button[data-v-5514bf46]:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: rgba(255, 100, 100, 0.8);
|
background: rgba(255, 100, 100, 0.8);
|
||||||
}
|
}
|
||||||
.clear-button svg[data-v-76ce0f19] {
|
.clear-button svg[data-v-5514bf46] {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vue DOM mode adjustments for clear button */
|
/* Vue DOM mode adjustments for clear button */
|
||||||
.text-input.vue-dom-mode ~ .clear-button[data-v-76ce0f19] {
|
.text-input.vue-dom-mode ~ .clear-button[data-v-5514bf46] {
|
||||||
right: 8px;
|
right: 8px;
|
||||||
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
|
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
background: rgba(107, 114, 128, 0.6);
|
background: rgba(107, 114, 128, 0.6);
|
||||||
}
|
}
|
||||||
.text-input.vue-dom-mode ~ .clear-button[data-v-76ce0f19]:hover {
|
.text-input.vue-dom-mode ~ .clear-button[data-v-5514bf46]:hover {
|
||||||
background: oklch(62% 0.18 25);
|
background: oklch(62% 0.18 25);
|
||||||
}
|
}
|
||||||
.text-input.vue-dom-mode ~ .clear-button svg[data-v-76ce0f19] {
|
.text-input.vue-dom-mode ~ .clear-button svg[data-v-5514bf46] {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}`));
|
}`));
|
||||||
@@ -14864,7 +14864,18 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
if (container && container.__widgetInputEl) {
|
if (container && container.__widgetInputEl) {
|
||||||
container.__widgetInputEl.inputEl = textareaRef.value;
|
container.__widgetInputEl.inputEl = textareaRef.value;
|
||||||
}
|
}
|
||||||
|
const pendingValue = props.widget._pendingValue;
|
||||||
|
if (pendingValue !== void 0) {
|
||||||
|
textareaRef.value.value = pendingValue;
|
||||||
|
hasText.value = pendingValue.length > 0;
|
||||||
|
delete props.widget._pendingValue;
|
||||||
|
textareaRef.value.dispatchEvent(new CustomEvent("lora-manager:autocomplete-value-changed", {
|
||||||
|
detail: { value: pendingValue }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (pendingValue === void 0) {
|
||||||
hasText.value = textareaRef.value.value.length > 0;
|
hasText.value = textareaRef.value.value.length > 0;
|
||||||
|
}
|
||||||
textareaRef.value.addEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
|
textareaRef.value.addEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
|
||||||
}
|
}
|
||||||
if (textareaRef.value && typeof props.widget.callback === "function") {
|
if (textareaRef.value && typeof props.widget.callback === "function") {
|
||||||
@@ -14932,7 +14943,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-76ce0f19"]]);
|
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-5514bf46"]]);
|
||||||
function createVueWidgetCleanup(vueApp, onCleanup) {
|
function createVueWidgetCleanup(vueApp, onCleanup) {
|
||||||
let didUnmount = false;
|
let didUnmount = false;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -15573,12 +15584,13 @@ function shouldBypassAutocompleteWidgetMigration(node, widgetValues) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const originalWidgetsInputs = Object.values(inputDefs).filter(
|
const originalWidgetsInputs = Object.values(inputDefs).filter(
|
||||||
(input) => widgetNames.has(input.name)
|
(input) => widgetNames.has(input.name) || input.forceInput
|
||||||
);
|
);
|
||||||
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap(
|
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap(
|
||||||
(input) => input.control_after_generate ? [!!input.forceInput, false] : [!!input.forceInput]
|
(input) => input.control_after_generate ? [!!input.forceInput, false] : [!!input.forceInput]
|
||||||
);
|
);
|
||||||
return widgetIndexHasForceInput.some(Boolean) && widgetIndexHasForceInput.length === widgetValues.length;
|
const result = widgetIndexHasForceInput.some(Boolean) && widgetIndexHasForceInput.length === widgetValues.length;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
function remapWidgetValuesByName(widgetValues, savedWidgetNames, currentWidgetNames) {
|
function remapWidgetValuesByName(widgetValues, savedWidgetNames, currentWidgetNames) {
|
||||||
const valueByName = /* @__PURE__ */ new Map();
|
const valueByName = /* @__PURE__ */ new Map();
|
||||||
@@ -15587,12 +15599,18 @@ function remapWidgetValuesByName(widgetValues, savedWidgetNames, currentWidgetNa
|
|||||||
valueByName.set(name, widgetValues[index]);
|
valueByName.set(name, widgetValues[index]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const currentWidgetNameSet = new Set(currentWidgetNames);
|
||||||
const remappedValues = [];
|
const remappedValues = [];
|
||||||
for (const name of currentWidgetNames) {
|
for (const name of currentWidgetNames) {
|
||||||
if (valueByName.has(name)) {
|
if (valueByName.has(name)) {
|
||||||
remappedValues.push(valueByName.get(name));
|
remappedValues.push(valueByName.get(name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const name of savedWidgetNames) {
|
||||||
|
if (!currentWidgetNameSet.has(name) && valueByName.has(name)) {
|
||||||
|
remappedValues.push(valueByName.get(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
return remappedValues;
|
return remappedValues;
|
||||||
}
|
}
|
||||||
function injectDefaultAutocompleteMetadataValues(widgetValues, currentWidgetNames) {
|
function injectDefaultAutocompleteMetadataValues(widgetValues, currentWidgetNames) {
|
||||||
@@ -15707,6 +15725,8 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
|
|||||||
inputEl.dispatchEvent(new CustomEvent("lora-manager:autocomplete-value-changed", {
|
inputEl.dispatchEvent(new CustomEvent("lora-manager:autocomplete-value-changed", {
|
||||||
detail: { value: v2 ?? "" }
|
detail: { value: v2 ?? "" }
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
widget._pendingValue = v2 ?? "";
|
||||||
}
|
}
|
||||||
if (typeof widget.onSetValue === "function") {
|
if (typeof widget.onSetValue === "function") {
|
||||||
widget.onSetValue(v2 ?? "");
|
widget.onSetValue(v2 ?? "");
|
||||||
@@ -15823,10 +15843,12 @@ app$1.registerExtension({
|
|||||||
};
|
};
|
||||||
nodeType.prototype.configure = function(info) {
|
nodeType.prototype.configure = function(info) {
|
||||||
normalizeAutocompleteWidgetValues(this, info);
|
normalizeAutocompleteWidgetValues(this, info);
|
||||||
if (shouldBypassAutocompleteWidgetMigration(this, (info == null ? void 0 : info.widgets_values) ?? [])) {
|
const bypassResult = shouldBypassAutocompleteWidgetMigration(this, (info == null ? void 0 : info.widgets_values) ?? []);
|
||||||
|
if (bypassResult) {
|
||||||
info.widgets_values = [...info.widgets_values ?? [], null];
|
info.widgets_values = [...info.widgets_values ?? [], null];
|
||||||
}
|
}
|
||||||
return originalConfigure == null ? void 0 : originalConfigure.apply(this, arguments);
|
const result = originalConfigure == null ? void 0 : originalConfigure.apply(this, arguments);
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (LORA_CHAIN_NODE_TYPES$1.includes(comfyClass)) {
|
if (LORA_CHAIN_NODE_TYPES$1.includes(comfyClass)) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user