mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 17:53:21 -03:00
Compare commits
36 Commits
v1.1.5
...
fe90f7f9b1
| Author | SHA1 | Date | |
|---|---|---|---|
| fe90f7f9b1 | |||
| 8b344ea39f | |||
| 8348a0cef8 | |||
| 7cf785b72f | |||
| e8913f4481 | |||
| f9c3d8dc97 | |||
| 09ca91fc0e | |||
| 16f5222efd | |||
| 28e7c04b37 | |||
| 28f99c46d3 | |||
| 205194f4e6 | |||
| 402d8b07cf | |||
| 3e303ab316 | |||
| e9e8c31ad1 | |||
| 703a6a4ea0 | |||
| 283730cf38 | |||
| 20417797e8 | |||
| 004c69b9ef | |||
| 47fe2d3783 | |||
| 36ef840a22 | |||
| 09c2445ac9 | |||
| 8a6d23f9c7 | |||
| 3d207b6744 | |||
| b3edda62ad | |||
| a429e6b1c3 | |||
| c1bf9c6221 | |||
| 75fffc1e25 | |||
| f264bab65c | |||
| 154fcd803b | |||
| 4ef32d3a96 | |||
| d2d109a69c | |||
| 3a2941d751 | |||
| 0ac10dfd42 | |||
| 9c95856b2f | |||
| 5ce4667d32 | |||
| be53fda6df |
@@ -7,6 +7,10 @@ py/run_test.py
|
|||||||
.vscode/
|
.vscode/
|
||||||
cache/
|
cache/
|
||||||
civitai/
|
civitai/
|
||||||
|
stats/
|
||||||
|
wildcards/
|
||||||
|
backups/
|
||||||
|
logs/
|
||||||
node_modules/
|
node_modules/
|
||||||
coverage/
|
coverage/
|
||||||
.coverage
|
.coverage
|
||||||
|
|||||||
+94
-89
@@ -11,8 +11,8 @@
|
|||||||
"Insomnia Art Designs",
|
"Insomnia Art Designs",
|
||||||
"2018cfh",
|
"2018cfh",
|
||||||
"Arlecchino Shion",
|
"Arlecchino Shion",
|
||||||
"Rob Williams",
|
|
||||||
"Charles Blakemore",
|
"Charles Blakemore",
|
||||||
|
"Rob Williams",
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
"$MetaSamsara",
|
"$MetaSamsara",
|
||||||
"wackop",
|
"wackop",
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
"stone9k",
|
"stone9k",
|
||||||
"Rosenthal",
|
"Rosenthal",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
|
"JongWon Han",
|
||||||
|
"FreelancerZ",
|
||||||
"Polymorphic Indeterminate",
|
"Polymorphic Indeterminate",
|
||||||
"Skalabananen",
|
"Skalabananen",
|
||||||
"Marc Whiffen",
|
"Marc Whiffen",
|
||||||
@@ -38,24 +40,32 @@
|
|||||||
"Estragon",
|
"Estragon",
|
||||||
"J\\B/ 8r0wns0n",
|
"J\\B/ 8r0wns0n",
|
||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
|
"KD",
|
||||||
|
"Omnidex",
|
||||||
|
"Tyler Trebuchon",
|
||||||
|
"Release Cabrakan",
|
||||||
"Tobi_Swagg",
|
"Tobi_Swagg",
|
||||||
"SG",
|
"SG",
|
||||||
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
|
"Buzzard",
|
||||||
"jmack",
|
"jmack",
|
||||||
"Andrew Wilson",
|
"Andrew Wilson",
|
||||||
"Greybush",
|
"Greybush",
|
||||||
|
"Mark Corneglio",
|
||||||
"Ricky Carter",
|
"Ricky Carter",
|
||||||
"James Todd",
|
"James Todd",
|
||||||
"JongWon Han",
|
"Steven Pfeiffer",
|
||||||
"VantAI",
|
"VantAI",
|
||||||
"レプサイ",
|
"レプサイ",
|
||||||
|
"Lisster",
|
||||||
"Michael Wong",
|
"Michael Wong",
|
||||||
"runte3221",
|
"runte3221",
|
||||||
"Illrigger",
|
"Illrigger",
|
||||||
"Tom Corrigan",
|
"Tom Corrigan",
|
||||||
"JackieWang",
|
"JackieWang",
|
||||||
"FreelancerZ",
|
|
||||||
"fnkylove",
|
"fnkylove",
|
||||||
|
"Yushio",
|
||||||
"Vik71it",
|
"Vik71it",
|
||||||
"Echo",
|
"Echo",
|
||||||
"Lilleman",
|
"Lilleman",
|
||||||
@@ -65,50 +75,48 @@
|
|||||||
"Fraser Cross",
|
"Fraser Cross",
|
||||||
"Liam MacDougal",
|
"Liam MacDougal",
|
||||||
"Sterilized",
|
"Sterilized",
|
||||||
|
"BadassArabianMofo",
|
||||||
"JORGE+LUIZ+HUSSNI+MESSIAS",
|
"JORGE+LUIZ+HUSSNI+MESSIAS",
|
||||||
"quarz",
|
"quarz",
|
||||||
"Greg",
|
"Greg",
|
||||||
|
"jean jahren",
|
||||||
"JSST",
|
"JSST",
|
||||||
"Snaggwort",
|
"Snaggwort",
|
||||||
|
"lmsupporter",
|
||||||
"Takkan",
|
"Takkan",
|
||||||
"wfpearl",
|
"wfpearl",
|
||||||
"Matt+J",
|
"Matt+J",
|
||||||
"Baekdoosixt",
|
"Baekdoosixt",
|
||||||
"Jonathan Ross",
|
"Jonathan Ross",
|
||||||
"KD",
|
"Jack B Nimble",
|
||||||
"Omnidex",
|
|
||||||
"Nazono_hito",
|
"Nazono_hito",
|
||||||
"Melville Parrish",
|
"Melville Parrish",
|
||||||
"daniel dove",
|
"daniel dove",
|
||||||
"Lustre",
|
"Lustre",
|
||||||
"Tyler Trebuchon",
|
|
||||||
"Release Cabrakan",
|
|
||||||
"JW Sin",
|
"JW Sin",
|
||||||
"Alex",
|
"Alex",
|
||||||
"bh",
|
"bh",
|
||||||
"carozzz",
|
"carozzz",
|
||||||
"Marlon Daniels",
|
"Marlon Daniels",
|
||||||
"James Dooley",
|
"Starkselle",
|
||||||
"Buzzard",
|
|
||||||
"Aaron Bleuer",
|
"Aaron Bleuer",
|
||||||
"LacesOut!",
|
"LacesOut!",
|
||||||
|
"greebles",
|
||||||
"Adam Shaw",
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
"Anthony Rizzo",
|
"Anthony Rizzo",
|
||||||
"iamresist",
|
"iamresist",
|
||||||
|
"M Postkasse",
|
||||||
"RedrockVP",
|
"RedrockVP",
|
||||||
"Wolffen",
|
"Wolffen",
|
||||||
"Wicked Choices by ASLPro3D",
|
"Wicked Choices by ASLPro3D",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
"FinalyFree",
|
"FinalyFree",
|
||||||
"Weasyl",
|
"Weasyl",
|
||||||
"Steven Pfeiffer",
|
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"Johnny",
|
"Johnny",
|
||||||
"Cory Paza",
|
"Cory Paza",
|
||||||
"Tak",
|
"Tak",
|
||||||
"Lisster",
|
|
||||||
"Big Red",
|
"Big Red",
|
||||||
"whudunit",
|
"whudunit",
|
||||||
"Luc Job",
|
"Luc Job",
|
||||||
@@ -116,37 +124,36 @@
|
|||||||
"Philip Hempel",
|
"Philip Hempel",
|
||||||
"corde",
|
"corde",
|
||||||
"nwalker94",
|
"nwalker94",
|
||||||
"Yushio",
|
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
|
"aai",
|
||||||
"Todd Keck",
|
"Todd Keck",
|
||||||
"Briton Heilbrun",
|
"Briton Heilbrun",
|
||||||
"Tori",
|
"Tori",
|
||||||
"wildnut",
|
"wildnut",
|
||||||
"Aleksander Wujczyk",
|
"Aleksander Wujczyk",
|
||||||
"AM Kuro",
|
"AM Kuro",
|
||||||
"BadassArabianMofo",
|
|
||||||
"Pascal Dahle",
|
"Pascal Dahle",
|
||||||
"Sangheili460",
|
"Sangheili460",
|
||||||
"MagnaInsomnia",
|
"MagnaInsomnia",
|
||||||
"Akira_HentAI",
|
"Akira_HentAI",
|
||||||
"Karl P.",
|
"Karl P.",
|
||||||
"lmsupporter",
|
|
||||||
"andrew.tappan",
|
"andrew.tappan",
|
||||||
"N/A",
|
"N/A",
|
||||||
|
"The Spawn",
|
||||||
"graysock",
|
"graysock",
|
||||||
"Greenmoustache",
|
"Greenmoustache",
|
||||||
"zounic",
|
"zounic",
|
||||||
"fancypants",
|
"fancypants",
|
||||||
"Eldithor",
|
"Digital",
|
||||||
"Jack B Nimble",
|
|
||||||
"JaxMax",
|
"JaxMax",
|
||||||
"contrite831",
|
"contrite831",
|
||||||
"Jwk0205",
|
"Jwk0205",
|
||||||
"Starkselle",
|
"Bro Xie",
|
||||||
|
"batblue",
|
||||||
|
"carey6409",
|
||||||
"Olive",
|
"Olive",
|
||||||
"greebles",
|
"太郎 ゲーム",
|
||||||
"Some Guy Named Barry",
|
"Some Guy Named Barry",
|
||||||
"M Postkasse",
|
|
||||||
"AELOX",
|
"AELOX",
|
||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
"Nicfit23",
|
"Nicfit23",
|
||||||
@@ -162,12 +169,12 @@
|
|||||||
"Serge Bekenkamp",
|
"Serge Bekenkamp",
|
||||||
"Jimmy Ledbetter",
|
"Jimmy Ledbetter",
|
||||||
"LeoZero",
|
"LeoZero",
|
||||||
|
"Antonio Pontes",
|
||||||
|
"nahinahi9",
|
||||||
"Dustin Chen",
|
"Dustin Chen",
|
||||||
"dan",
|
"dan",
|
||||||
"aai",
|
|
||||||
"Mouthlessman",
|
"Mouthlessman",
|
||||||
"otaku fra",
|
"otaku fra",
|
||||||
"jean jahren",
|
|
||||||
"MiraiKuriyamaSy",
|
"MiraiKuriyamaSy",
|
||||||
"Ran C",
|
"Ran C",
|
||||||
"ViperC",
|
"ViperC",
|
||||||
@@ -176,7 +183,6 @@
|
|||||||
"Adam Taylor",
|
"Adam Taylor",
|
||||||
"AbstractAss",
|
"AbstractAss",
|
||||||
"Weird_With_A_Beard",
|
"Weird_With_A_Beard",
|
||||||
"The Spawn",
|
|
||||||
"Pozadine1",
|
"Pozadine1",
|
||||||
"Qarob",
|
"Qarob",
|
||||||
"AIGooner",
|
"AIGooner",
|
||||||
@@ -187,18 +193,16 @@
|
|||||||
"Hasturkun",
|
"Hasturkun",
|
||||||
"Jon Sandman",
|
"Jon Sandman",
|
||||||
"Ubivis",
|
"Ubivis",
|
||||||
|
"CloudValley",
|
||||||
|
"linnfrey",
|
||||||
"Jackthemind",
|
"Jackthemind",
|
||||||
|
"griffin+dahlberg",
|
||||||
"Joboshy",
|
"Joboshy",
|
||||||
"Digital",
|
|
||||||
"takyamtom",
|
"takyamtom",
|
||||||
"Bohemian Corporal",
|
"Bohemian Corporal",
|
||||||
"Dan",
|
"Dan",
|
||||||
"Bro Xie",
|
|
||||||
"yer fey",
|
"yer fey",
|
||||||
"batblue",
|
|
||||||
"Error_Rule34_Not_found",
|
"Error_Rule34_Not_found",
|
||||||
"carey6409",
|
|
||||||
"太郎 ゲーム",
|
|
||||||
"Roslynd",
|
"Roslynd",
|
||||||
"jinxedx",
|
"jinxedx",
|
||||||
"Neco28",
|
"Neco28",
|
||||||
@@ -210,22 +214,21 @@
|
|||||||
"Frank Nitty",
|
"Frank Nitty",
|
||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"DougPeterson",
|
"DougPeterson",
|
||||||
"Antonio Pontes",
|
|
||||||
"ApathyJones",
|
"ApathyJones",
|
||||||
|
"Jeff",
|
||||||
"Bruce",
|
"Bruce",
|
||||||
"Julian V",
|
|
||||||
"Steven Owens",
|
"Steven Owens",
|
||||||
"nahinahi9",
|
|
||||||
"Kevin John Duck",
|
"Kevin John Duck",
|
||||||
"Kevin Christopher",
|
"Kevin Christopher",
|
||||||
"Blackfish95",
|
"Blackfish95",
|
||||||
|
"dd",
|
||||||
"Paul Kroll",
|
"Paul Kroll",
|
||||||
"Bas Imagineer",
|
"Bas Imagineer",
|
||||||
"John Statham",
|
"John Statham",
|
||||||
"yuxz69",
|
"yuxz69",
|
||||||
"esthe",
|
"esthe",
|
||||||
|
"AlexDuKaNa",
|
||||||
"decoy",
|
"decoy",
|
||||||
"CloudValley",
|
|
||||||
"thesoftwaredruid",
|
"thesoftwaredruid",
|
||||||
"wundershark",
|
"wundershark",
|
||||||
"mr_dinosaur",
|
"mr_dinosaur",
|
||||||
@@ -233,15 +236,20 @@
|
|||||||
"Ray Wing",
|
"Ray Wing",
|
||||||
"Ranzitho",
|
"Ranzitho",
|
||||||
"Gus",
|
"Gus",
|
||||||
|
"地獄の禄",
|
||||||
"MJG",
|
"MJG",
|
||||||
"David LaVallee",
|
"David LaVallee",
|
||||||
"linnfrey",
|
|
||||||
"ae",
|
"ae",
|
||||||
"Tr4shP4nda",
|
"Tr4shP4nda",
|
||||||
"capn",
|
"capn",
|
||||||
"Joseph",
|
"Joseph",
|
||||||
|
"Mirko Katzula",
|
||||||
|
"dan",
|
||||||
|
"Piccio08",
|
||||||
|
"kumakichi",
|
||||||
|
"cppbel",
|
||||||
"IamAyam",
|
"IamAyam",
|
||||||
"skaterb949",
|
"jeaness",
|
||||||
"Brian M",
|
"Brian M",
|
||||||
"Josef Lanzl",
|
"Josef Lanzl",
|
||||||
"Nerezza",
|
"Nerezza",
|
||||||
@@ -261,37 +269,32 @@
|
|||||||
"Eris3D",
|
"Eris3D",
|
||||||
"Max Marklund",
|
"Max Marklund",
|
||||||
"m",
|
"m",
|
||||||
|
"Pierce McBride",
|
||||||
"Pronredn",
|
"Pronredn",
|
||||||
|
"Mikko Hemilä",
|
||||||
"Jamie Ogletree",
|
"Jamie Ogletree",
|
||||||
"a _",
|
"a _",
|
||||||
"Jeff",
|
|
||||||
"lh qwe",
|
"lh qwe",
|
||||||
"James Coleman",
|
"James Coleman",
|
||||||
|
"Martial",
|
||||||
"conner",
|
"conner",
|
||||||
"Ouro Boros",
|
"Ouro Boros",
|
||||||
"Chad Idk",
|
"Chad Idk",
|
||||||
"dd",
|
|
||||||
"Princess Bright Eyes",
|
"Princess Bright Eyes",
|
||||||
"Yuji Kaneko",
|
"Yuji Kaneko",
|
||||||
"Dušan Ryban",
|
"Dušan Ryban",
|
||||||
"Felipe dos Santos",
|
"Felipe dos Santos",
|
||||||
|
"Rops Alot",
|
||||||
"Sam",
|
"Sam",
|
||||||
"sjon kreutz",
|
"sjon kreutz",
|
||||||
"Ace Ventura",
|
"Ace Ventura",
|
||||||
"Douglas Gaspar",
|
"Douglas Gaspar",
|
||||||
"Metryman55",
|
"Metryman55",
|
||||||
"AlexDuKaNa",
|
|
||||||
"George",
|
"George",
|
||||||
"dw",
|
"dw",
|
||||||
"地獄の禄",
|
|
||||||
"Gamalonia",
|
"Gamalonia",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"momokai",
|
"momokai",
|
||||||
"Mirko Katzula",
|
|
||||||
"dan",
|
|
||||||
"Piccio08",
|
|
||||||
"kumakichi",
|
|
||||||
"cppbel",
|
|
||||||
"Moon Knight",
|
"Moon Knight",
|
||||||
"몽타주",
|
"몽타주",
|
||||||
"Kland",
|
"Kland",
|
||||||
@@ -313,12 +316,15 @@
|
|||||||
"kyoumei",
|
"kyoumei",
|
||||||
"RadStorm04",
|
"RadStorm04",
|
||||||
"JohnDoe42054",
|
"JohnDoe42054",
|
||||||
|
"BillyHill",
|
||||||
|
"emyth",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"Richard",
|
"Richard",
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"Andrew",
|
"Andrew",
|
||||||
"Robert Wegemund",
|
"Robert Wegemund",
|
||||||
"Littlehuggy",
|
"Littlehuggy",
|
||||||
|
"Gregory Kozhemiak",
|
||||||
"준희 김",
|
"준희 김",
|
||||||
"Brian Buie",
|
"Brian Buie",
|
||||||
"Sadlip",
|
"Sadlip",
|
||||||
@@ -328,16 +334,16 @@
|
|||||||
"Tomohiro Baba",
|
"Tomohiro Baba",
|
||||||
"Mike Simone",
|
"Mike Simone",
|
||||||
"Noora",
|
"Noora",
|
||||||
"Pierce McBride",
|
|
||||||
"Joshua Gray",
|
"Joshua Gray",
|
||||||
"Mattssn",
|
"Mattssn",
|
||||||
"Mikko Hemilä",
|
"Morgandel",
|
||||||
"Jacob McDaniel",
|
"Jacob McDaniel",
|
||||||
|
"X",
|
||||||
|
"Sloan Steddy",
|
||||||
"Temikus",
|
"Temikus",
|
||||||
"Artokun",
|
"Artokun",
|
||||||
"Michael Taylor",
|
"Michael Taylor",
|
||||||
"Derek Baker",
|
"Derek Baker",
|
||||||
"Martial",
|
|
||||||
"Michael Anthony Scott",
|
"Michael Anthony Scott",
|
||||||
"Emil Andersson",
|
"Emil Andersson",
|
||||||
"Atilla Berke Pekduyar",
|
"Atilla Berke Pekduyar",
|
||||||
@@ -345,7 +351,6 @@
|
|||||||
"CryptoTraderJK",
|
"CryptoTraderJK",
|
||||||
"Decx _",
|
"Decx _",
|
||||||
"Davaitamin",
|
"Davaitamin",
|
||||||
"Rops Alot",
|
|
||||||
"tedcor",
|
"tedcor",
|
||||||
"Fotek Design",
|
"Fotek Design",
|
||||||
"四糸凜音",
|
"四糸凜音",
|
||||||
@@ -358,8 +363,6 @@
|
|||||||
"starbugx",
|
"starbugx",
|
||||||
"dc7431",
|
"dc7431",
|
||||||
"Crocket",
|
"Crocket",
|
||||||
"BillyHill",
|
|
||||||
"emyth",
|
|
||||||
"chriphost",
|
"chriphost",
|
||||||
"KitKatM",
|
"KitKatM",
|
||||||
"socrasteeze",
|
"socrasteeze",
|
||||||
@@ -383,35 +386,40 @@
|
|||||||
"g9p0o",
|
"g9p0o",
|
||||||
"TheHolySheep",
|
"TheHolySheep",
|
||||||
"Monte Won",
|
"Monte Won",
|
||||||
"Gregory Kozhemiak",
|
"SpringBootisTrash",
|
||||||
|
"carsten",
|
||||||
|
"ikok",
|
||||||
|
"Wolfe7D1",
|
||||||
"Draven T",
|
"Draven T",
|
||||||
"mrjuan",
|
|
||||||
"elleshar666",
|
"elleshar666",
|
||||||
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
"Aquatic Coffee",
|
"Aquatic Coffee",
|
||||||
|
"Kauffy",
|
||||||
"John J Linehan",
|
"John J Linehan",
|
||||||
"ethanfel",
|
"ethanfel",
|
||||||
"Elliot E",
|
"Elliot E",
|
||||||
"Morgandel",
|
|
||||||
"Theerat Jiramate",
|
"Theerat Jiramate",
|
||||||
"Focuschannel",
|
"Focuschannel",
|
||||||
"Edward Kennedy",
|
"Edward Kennedy",
|
||||||
"Noah",
|
"Noah",
|
||||||
"X",
|
|
||||||
"Sloan Steddy",
|
|
||||||
"Vane Holzer",
|
"Vane Holzer",
|
||||||
|
"psytrax",
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"Anthony Faxlandez",
|
"Anthony Faxlandez",
|
||||||
"battu",
|
"battu",
|
||||||
"notedfakes",
|
"notedfakes",
|
||||||
"Nathan",
|
"Nathan",
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
|
"Michael Scott",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
"Xeeosat",
|
"Xeeosat",
|
||||||
"Saya",
|
"Saya",
|
||||||
"Ed Wang",
|
"Ed Wang",
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
|
"Wes Sims",
|
||||||
"g unit",
|
"g unit",
|
||||||
"Srdb",
|
"Srdb",
|
||||||
|
"Filippo Ferrari",
|
||||||
"JC",
|
"JC",
|
||||||
"Prompt Pirate",
|
"Prompt Pirate",
|
||||||
"uwutismxd",
|
"uwutismxd",
|
||||||
@@ -427,9 +435,8 @@
|
|||||||
"Pkrsky",
|
"Pkrsky",
|
||||||
"nanana",
|
"nanana",
|
||||||
"raf8osz",
|
"raf8osz",
|
||||||
"SpringBootisTrash",
|
"FeralOpticsAI",
|
||||||
"carsten",
|
"Pavlaki",
|
||||||
"ikok",
|
|
||||||
"Doug+Rintoul",
|
"Doug+Rintoul",
|
||||||
"Noor",
|
"Noor",
|
||||||
"Yorunai",
|
"Yorunai",
|
||||||
@@ -446,36 +453,34 @@
|
|||||||
"cocona",
|
"cocona",
|
||||||
"ElitaSSJ4",
|
"ElitaSSJ4",
|
||||||
"David Schenck",
|
"David Schenck",
|
||||||
"Wolfe7D1",
|
|
||||||
"blikkies",
|
"blikkies",
|
||||||
"Chris",
|
"Chris",
|
||||||
"Time Valentine",
|
"Time Valentine",
|
||||||
"Shock Shockor",
|
"Shock Shockor",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
|
||||||
"Михал Михалыч",
|
"Михал Михалыч",
|
||||||
"Matt",
|
"Matt",
|
||||||
"Goldwaters",
|
"Goldwaters",
|
||||||
"Kauffy",
|
|
||||||
"Zude",
|
"Zude",
|
||||||
|
"Frogmilk",
|
||||||
"SPJ",
|
"SPJ",
|
||||||
"Kyler",
|
"Kyler",
|
||||||
|
"Bryan Rutkowski",
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
"aRtFuL_DodGeR",
|
"aRtFuL_DodGeR",
|
||||||
"Nick Kage",
|
"Nick Kage",
|
||||||
"psytrax",
|
|
||||||
"Cyrus Fett",
|
"Cyrus Fett",
|
||||||
"Xenon Xue",
|
"Xenon Xue",
|
||||||
"Edward Ten Eyck",
|
"Edward Ten Eyck",
|
||||||
"Billy Gladky",
|
"Billy Gladky",
|
||||||
"Michael Scott",
|
|
||||||
"Probis",
|
"Probis",
|
||||||
"Solixer",
|
"Solixer",
|
||||||
"Wes Sims",
|
|
||||||
"ItsGeneralButtNaked",
|
"ItsGeneralButtNaked",
|
||||||
"Donor4115",
|
"Donor4115",
|
||||||
|
"jinksta187",
|
||||||
"Distortik",
|
"Distortik",
|
||||||
"Manu Thetug",
|
"Manu Thetug",
|
||||||
"Filippo Ferrari",
|
"Karlanx",
|
||||||
|
"operationancut",
|
||||||
"Youguang",
|
"Youguang",
|
||||||
"andrewzpong",
|
"andrewzpong",
|
||||||
"BossGame",
|
"BossGame",
|
||||||
@@ -486,11 +491,18 @@
|
|||||||
"Kevinj",
|
"Kevinj",
|
||||||
"Mitchell Robson",
|
"Mitchell Robson",
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
|
"YassineKhaled",
|
||||||
|
"Y",
|
||||||
|
"MatteKey",
|
||||||
|
"Flob",
|
||||||
|
"ShiroSenpai",
|
||||||
|
"Inkognito",
|
||||||
"G",
|
"G",
|
||||||
"Tan+Huynh",
|
"Tan+Huynh",
|
||||||
"Bob+Barker",
|
"Bob+Barker",
|
||||||
"D",
|
"D",
|
||||||
"Dark_Pest",
|
"Dark_Pest",
|
||||||
|
"Eldithor",
|
||||||
"Alex",
|
"Alex",
|
||||||
"Karru",
|
"Karru",
|
||||||
"ChaChanoKo",
|
"ChaChanoKo",
|
||||||
@@ -503,13 +515,10 @@
|
|||||||
"g",
|
"g",
|
||||||
"J",
|
"J",
|
||||||
"Alan+Cano",
|
"Alan+Cano",
|
||||||
"FeralOpticsAI",
|
|
||||||
"Pavlaki",
|
|
||||||
"BillyBoy84",
|
"BillyBoy84",
|
||||||
"Buecyb99",
|
"Buecyb99",
|
||||||
"Welkor",
|
"Welkor",
|
||||||
"John Martin",
|
"John Martin",
|
||||||
"Ink Temptation",
|
|
||||||
"JBsuede",
|
"JBsuede",
|
||||||
"moranqianlong",
|
"moranqianlong",
|
||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
@@ -519,13 +528,12 @@
|
|||||||
"Dave Abraham",
|
"Dave Abraham",
|
||||||
"Joaquin Hierrezuelo",
|
"Joaquin Hierrezuelo",
|
||||||
"Locrospiel",
|
"Locrospiel",
|
||||||
"Frogmilk",
|
|
||||||
"Sean voets",
|
"Sean voets",
|
||||||
|
"Jarrid Lee",
|
||||||
"Kor",
|
"Kor",
|
||||||
"Joseph Hanson",
|
"Joseph Hanson",
|
||||||
"John Rednoulf",
|
"John Rednoulf",
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
"Bryan Rutkowski",
|
|
||||||
"Boba Smith",
|
"Boba Smith",
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
@@ -544,15 +552,12 @@
|
|||||||
"Pete Pain",
|
"Pete Pain",
|
||||||
"Jacob Winter",
|
"Jacob Winter",
|
||||||
"Ryan Presley Ng",
|
"Ryan Presley Ng",
|
||||||
"jinksta187",
|
|
||||||
"RHopkirk",
|
"RHopkirk",
|
||||||
"Andrew Wilkinson",
|
"Andrew Wilkinson",
|
||||||
"Karlanx",
|
|
||||||
"Lyavph",
|
"Lyavph",
|
||||||
"Maxim",
|
"Maxim",
|
||||||
"David",
|
"David",
|
||||||
"Meilo",
|
"Meilo",
|
||||||
"operationancut",
|
|
||||||
"shinonomeiro",
|
"shinonomeiro",
|
||||||
"Snille",
|
"Snille",
|
||||||
"MaartenAlbers",
|
"MaartenAlbers",
|
||||||
@@ -571,6 +576,8 @@
|
|||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"D",
|
"D",
|
||||||
|
"Akkas+Haque",
|
||||||
|
"Kachac",
|
||||||
"SAVEagleBasement",
|
"SAVEagleBasement",
|
||||||
"Kevin+Isom",
|
"Kevin+Isom",
|
||||||
"Rune+Osnes",
|
"Rune+Osnes",
|
||||||
@@ -600,12 +607,6 @@
|
|||||||
"Lev+Lanevskiy",
|
"Lev+Lanevskiy",
|
||||||
"low9",
|
"low9",
|
||||||
"Winged",
|
"Winged",
|
||||||
"YassineKhaled",
|
|
||||||
"Y",
|
|
||||||
"MatteKey",
|
|
||||||
"Flob",
|
|
||||||
"ShiroSenpai",
|
|
||||||
"Inkognito",
|
|
||||||
"Jacky+Ho",
|
"Jacky+Ho",
|
||||||
"generic404",
|
"generic404",
|
||||||
"abattoirblues",
|
"abattoirblues",
|
||||||
@@ -614,29 +615,34 @@
|
|||||||
"hayden",
|
"hayden",
|
||||||
"Obsidian.Studios",
|
"Obsidian.Studios",
|
||||||
"ahoystan",
|
"ahoystan",
|
||||||
|
"Zomba Mann",
|
||||||
"edk",
|
"edk",
|
||||||
"Tú Nguyễn Lý Hoàng",
|
"Tú Nguyễn Lý Hoàng",
|
||||||
"shira1011",
|
"shira1011",
|
||||||
|
"Neko Desco",
|
||||||
"Ben D",
|
"Ben D",
|
||||||
"G",
|
"G",
|
||||||
|
"Vinarus",
|
||||||
"ja s",
|
"ja s",
|
||||||
"Leslie Andrew Ridings",
|
"Leslie Andrew Ridings",
|
||||||
"Doug Mason",
|
"Doug Mason",
|
||||||
"scoreswazey",
|
"scoreswazey",
|
||||||
"Owen Gwosdz",
|
"Owen Gwosdz",
|
||||||
"Jarrid Lee",
|
|
||||||
"Poophead27 Blyat",
|
"Poophead27 Blyat",
|
||||||
"Mythspire",
|
"Mythspire",
|
||||||
"AZ Party Oasis",
|
"AZ Party Oasis",
|
||||||
"Devil Lude",
|
"Devil Lude",
|
||||||
"David Murcko",
|
"David Murcko",
|
||||||
|
"TheFusion",
|
||||||
"MR.Bear",
|
"MR.Bear",
|
||||||
"Jack Dole",
|
"Jack Dole",
|
||||||
"matt",
|
"matt",
|
||||||
"somethingtosay8",
|
"somethingtosay8",
|
||||||
|
"3zS4QNQ4",
|
||||||
"Terminuz",
|
"Terminuz",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
"max blo",
|
"max blo",
|
||||||
|
"Ivan Imes",
|
||||||
"CptNeo",
|
"CptNeo",
|
||||||
"Jack Lawfield",
|
"Jack Lawfield",
|
||||||
"Borte",
|
"Borte",
|
||||||
@@ -645,7 +651,7 @@
|
|||||||
"Sage Himeros",
|
"Sage Himeros",
|
||||||
"Eric Ketchum",
|
"Eric Ketchum",
|
||||||
"Kevin Wallace",
|
"Kevin Wallace",
|
||||||
"David Spearing",
|
"Zeeble",
|
||||||
"ChicRic",
|
"ChicRic",
|
||||||
"Tigon",
|
"Tigon",
|
||||||
"BastardSama",
|
"BastardSama",
|
||||||
@@ -706,6 +712,9 @@
|
|||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
|
"Somebody",
|
||||||
|
"Jasper",
|
||||||
|
"megameganck",
|
||||||
"thomasand01",
|
"thomasand01",
|
||||||
"Shiba+Sama",
|
"Shiba+Sama",
|
||||||
"miduzza",
|
"miduzza",
|
||||||
@@ -730,8 +739,6 @@
|
|||||||
"matter",
|
"matter",
|
||||||
"SRCRCOSS",
|
"SRCRCOSS",
|
||||||
"imer",
|
"imer",
|
||||||
"Akkas+Haque",
|
|
||||||
"Kachac",
|
|
||||||
"jcx29",
|
"jcx29",
|
||||||
"Drizzly",
|
"Drizzly",
|
||||||
"Nebuleux",
|
"Nebuleux",
|
||||||
@@ -751,24 +758,23 @@
|
|||||||
"KUJYAKU",
|
"KUJYAKU",
|
||||||
"Coeur+de+cochon",
|
"Coeur+de+cochon",
|
||||||
"han b",
|
"han b",
|
||||||
"Zomba Mann",
|
|
||||||
"Aquaneo",
|
"Aquaneo",
|
||||||
"Nico",
|
"Nico",
|
||||||
"Maximilian Krischan",
|
"Maximilian Krischan",
|
||||||
"Banana Joe",
|
"Banana Joe",
|
||||||
"proto merp",
|
"proto merp",
|
||||||
"_ G3n",
|
"_ G3n",
|
||||||
|
"Brandon Thomas",
|
||||||
"Donovan Jenkins",
|
"Donovan Jenkins",
|
||||||
"Hans Meier",
|
"Hans Meier",
|
||||||
"sicarius",
|
"sicarius",
|
||||||
"Michael Eid",
|
"Michael Eid",
|
||||||
"Wolf and Fox Legends",
|
"Wolf and Fox Legends",
|
||||||
"beersandbacon",
|
"beersandbacon",
|
||||||
"Neko Desco",
|
|
||||||
"Bob barker",
|
"Bob barker",
|
||||||
"Ninja Tom",
|
"Ninja Tom",
|
||||||
"karim ben brik",
|
"karim ben brik",
|
||||||
"Vinarus",
|
"Elemnt",
|
||||||
"Josh Snyder",
|
"Josh Snyder",
|
||||||
"Michael Zhu",
|
"Michael Zhu",
|
||||||
"Nemisu",
|
"Nemisu",
|
||||||
@@ -792,16 +798,15 @@
|
|||||||
"Forbidden Atelier",
|
"Forbidden Atelier",
|
||||||
"Thomas Sankowski",
|
"Thomas Sankowski",
|
||||||
"DrB",
|
"DrB",
|
||||||
|
"Nimhloth",
|
||||||
"Adictedtohumping",
|
"Adictedtohumping",
|
||||||
"Snorklebort",
|
"Snorklebort",
|
||||||
"vinter",
|
"vinter",
|
||||||
"Towelie",
|
"Towelie",
|
||||||
"TheFusion",
|
|
||||||
"Jean-françois SEMA",
|
"Jean-françois SEMA",
|
||||||
"3zS4QNQ4",
|
|
||||||
"Kurt",
|
"Kurt",
|
||||||
|
"Andrew Ly",
|
||||||
"Matt M.",
|
"Matt M.",
|
||||||
"Ivan Imes",
|
|
||||||
"J M",
|
"J M",
|
||||||
"Slacks",
|
"Slacks",
|
||||||
"Bouya shaka",
|
"Bouya shaka",
|
||||||
@@ -837,5 +842,5 @@
|
|||||||
"Somebody",
|
"Somebody",
|
||||||
"CK"
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 834
|
"totalCount": 839
|
||||||
}
|
}
|
||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Aus Favoriten entfernen",
|
"removeFromFavorites": "Aus Favoriten entfernen",
|
||||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||||
"notAvailableFromCivitai": "Nicht auf Civitai verfügbar",
|
"notAvailableFromCivitai": "Nicht auf Civitai verfügbar",
|
||||||
|
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||||
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
|
"sendToWorkflow": "An ComfyUI senden (Klick: Anhängen, Shift+Klick: Ersetzen)",
|
||||||
"copyLoRASyntax": "LoRA-Syntax kopieren",
|
"copyLoRASyntax": "LoRA-Syntax kopieren",
|
||||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "{type} von URL herunterladen",
|
"titleWithType": "{type} von URL herunterladen",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "Geben Sie eine CivitAI- oder CivArchive-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
|
"urlHint": "Geben Sie eine CivitAI-, CivArchive- oder Hugging Face-URL pro Zeile ein. Unterstützt mehrere URLs für den Batch-Download.",
|
||||||
|
"selectHfFiles": "Datei(en) zum Herunterladen aus diesem Repository auswählen:",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"fetchingRepoFiles": "Repository-Dateien werden abgerufen...",
|
||||||
"locationPreview": "Download-Speicherort Vorschau",
|
"locationPreview": "Download-Speicherort Vorschau",
|
||||||
"useDefaultPath": "Standardpfad verwenden",
|
"useDefaultPath": "Standardpfad verwenden",
|
||||||
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
"useDefaultPathTooltip": "Wenn aktiviert, werden Dateien automatisch mit konfigurierten Pfadvorlagen organisiert",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||||
"noVersions": "Keine Versionen für dieses Modell verfügbar"
|
"noVersions": "Keine Versionen für dieses Modell verfügbar",
|
||||||
|
"mixedSources": "CivitAI- und Hugging Face-URLs können nicht in derselben Charge gemischt werden.",
|
||||||
|
"noModelFiles": "In diesem Repository wurden keine Modelldateien gefunden."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "Download wird vorbereitet...",
|
"preparing": "Download wird vorbereitet...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "Versionsname bearbeiten",
|
"editVersionName": "Versionsname bearbeiten",
|
||||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||||
|
"viewOnHuggingFace": "Auf Hugging Face ansehen",
|
||||||
|
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
|
||||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||||
"openFileLocation": "Dateispeicherort öffnen",
|
"openFileLocation": "Dateispeicherort öffnen",
|
||||||
"sendToWorkflow": "An ComfyUI senden",
|
"sendToWorkflow": "An ComfyUI senden",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "Zusätzliche Notizen",
|
"additionalNotes": "Zusätzliche Notizen",
|
||||||
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
|
"notesHint": "Enter zum Speichern, Shift+Enter für neue Zeile",
|
||||||
"addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...",
|
"addNotesPlaceholder": "Fügen Sie hier Ihre Notizen hinzu...",
|
||||||
"aboutThisVersion": "Über diese Version"
|
"aboutThisVersion": "Über diese Version",
|
||||||
|
"baseModelSearchPlaceholder": "Basismodell suchen…",
|
||||||
|
"baseModelSuggested": "Vorschlag",
|
||||||
|
"baseModelNoMatch": "Keine passenden Basismodelle"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notizen erfolgreich gespeichert",
|
"saved": "Notizen erfolgreich gespeichert",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Remove from favorites",
|
"removeFromFavorites": "Remove from favorites",
|
||||||
"viewOnCivitai": "View on Civitai",
|
"viewOnCivitai": "View on Civitai",
|
||||||
"notAvailableFromCivitai": "Not available from Civitai",
|
"notAvailableFromCivitai": "Not available from Civitai",
|
||||||
|
"viewOnHuggingFace": "View on Hugging Face",
|
||||||
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
|
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
|
||||||
"copyLoRASyntax": "Copy LoRA Syntax",
|
"copyLoRASyntax": "Copy LoRA Syntax",
|
||||||
"checkpointNameCopied": "Checkpoint name copied",
|
"checkpointNameCopied": "Checkpoint name copied",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "Download {type} from URL",
|
"titleWithType": "Download {type} from URL",
|
||||||
"civitaiUrl": "Civitai URL(s):",
|
"civitaiUrl": "Civitai URL(s):",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "Enter one CivitAI or CivArchive URL per line. Supports multiple URLs for batch download.",
|
"urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.",
|
||||||
|
"selectHfFiles": "Select file(s) to download from this repository:",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"fetchingRepoFiles": "Fetching repository files...",
|
||||||
"locationPreview": "Download Location Preview",
|
"locationPreview": "Download Location Preview",
|
||||||
"useDefaultPath": "Use Default Path",
|
"useDefaultPath": "Use Default Path",
|
||||||
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
"useDefaultPathTooltip": "When enabled, files are automatically organized using configured path templates",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Invalid Civitai URL format",
|
"invalidUrl": "Invalid Civitai URL format",
|
||||||
"noVersions": "No versions available for this model"
|
"noVersions": "No versions available for this model",
|
||||||
|
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
|
||||||
|
"noModelFiles": "No model files found in this repository."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "Preparing download...",
|
"preparing": "Preparing download...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "Edit version name",
|
"editVersionName": "Edit version name",
|
||||||
"viewOnCivitai": "View on Civitai",
|
"viewOnCivitai": "View on Civitai",
|
||||||
"viewOnCivitaiText": "View on Civitai",
|
"viewOnCivitaiText": "View on Civitai",
|
||||||
|
"viewOnHuggingFace": "View on Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "View on Hugging Face",
|
||||||
"viewCreatorProfile": "View Creator Profile",
|
"viewCreatorProfile": "View Creator Profile",
|
||||||
"openFileLocation": "Open File Location",
|
"openFileLocation": "Open File Location",
|
||||||
"sendToWorkflow": "Send to ComfyUI",
|
"sendToWorkflow": "Send to ComfyUI",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "Additional Notes",
|
"additionalNotes": "Additional Notes",
|
||||||
"notesHint": "Press Enter to save, Shift+Enter for new line",
|
"notesHint": "Press Enter to save, Shift+Enter for new line",
|
||||||
"addNotesPlaceholder": "Add your notes here...",
|
"addNotesPlaceholder": "Add your notes here...",
|
||||||
"aboutThisVersion": "About this version"
|
"aboutThisVersion": "About this version",
|
||||||
|
"baseModelSearchPlaceholder": "Search base model…",
|
||||||
|
"baseModelSuggested": "Suggested",
|
||||||
|
"baseModelNoMatch": "No matching base models"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notes saved successfully",
|
"saved": "Notes saved successfully",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Eliminar de favoritos",
|
"removeFromFavorites": "Eliminar de favoritos",
|
||||||
"viewOnCivitai": "Ver en Civitai",
|
"viewOnCivitai": "Ver en Civitai",
|
||||||
"notAvailableFromCivitai": "No disponible en Civitai",
|
"notAvailableFromCivitai": "No disponible en Civitai",
|
||||||
|
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||||
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
|
"sendToWorkflow": "Enviar a ComfyUI (Clic: Añadir, Shift+Clic: Reemplazar)",
|
||||||
"copyLoRASyntax": "Copiar sintaxis de LoRA",
|
"copyLoRASyntax": "Copiar sintaxis de LoRA",
|
||||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "Descargar {type} desde URL",
|
"titleWithType": "Descargar {type} desde URL",
|
||||||
"civitaiUrl": "URL de Civitai:",
|
"civitaiUrl": "URL de Civitai:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "Ingrese una URL de CivitAI o CivArchive por línea. Admite múltiples URLs para descarga por lotes.",
|
"urlHint": "Ingrese una URL de CivitAI, CivArchive o Hugging Face por línea. Admite múltiples URLs para descarga por lotes.",
|
||||||
|
"selectHfFiles": "Seleccione el/los archivo(s) para descargar de este repositorio:",
|
||||||
|
"selectAll": "Seleccionar todo",
|
||||||
|
"fetchingRepoFiles": "Obteniendo archivos del repositorio...",
|
||||||
"locationPreview": "Vista previa de ubicación de descarga",
|
"locationPreview": "Vista previa de ubicación de descarga",
|
||||||
"useDefaultPath": "Usar ruta predeterminada",
|
"useDefaultPath": "Usar ruta predeterminada",
|
||||||
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
"useDefaultPathTooltip": "Cuando está habilitado, los archivos se organizan automáticamente usando plantillas de rutas configuradas",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||||
"noVersions": "No hay versiones disponibles para este modelo"
|
"noVersions": "No hay versiones disponibles para este modelo",
|
||||||
|
"mixedSources": "No se pueden mezclar URL de CivitAI y Hugging Face en el mismo lote.",
|
||||||
|
"noModelFiles": "No se encontraron archivos de modelo en este repositorio."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "Preparando descarga...",
|
"preparing": "Preparando descarga...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "Editar nombre de versión",
|
"editVersionName": "Editar nombre de versión",
|
||||||
"viewOnCivitai": "Ver en Civitai",
|
"viewOnCivitai": "Ver en Civitai",
|
||||||
"viewOnCivitaiText": "Ver en Civitai",
|
"viewOnCivitaiText": "Ver en Civitai",
|
||||||
|
"viewOnHuggingFace": "Ver en Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "Ver en Hugging Face",
|
||||||
"viewCreatorProfile": "Ver perfil del creador",
|
"viewCreatorProfile": "Ver perfil del creador",
|
||||||
"openFileLocation": "Abrir ubicación del archivo",
|
"openFileLocation": "Abrir ubicación del archivo",
|
||||||
"sendToWorkflow": "Enviar a ComfyUI",
|
"sendToWorkflow": "Enviar a ComfyUI",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "Notas adicionales",
|
"additionalNotes": "Notas adicionales",
|
||||||
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
|
"notesHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
|
||||||
"addNotesPlaceholder": "Añade tus notas aquí...",
|
"addNotesPlaceholder": "Añade tus notas aquí...",
|
||||||
"aboutThisVersion": "Acerca de esta versión"
|
"aboutThisVersion": "Acerca de esta versión",
|
||||||
|
"baseModelSearchPlaceholder": "Buscar modelo base…",
|
||||||
|
"baseModelSuggested": "Sugerido",
|
||||||
|
"baseModelNoMatch": "No hay modelos base que coincidan"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notas guardadas exitosamente",
|
"saved": "Notas guardadas exitosamente",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Retirer des favoris",
|
"removeFromFavorites": "Retirer des favoris",
|
||||||
"viewOnCivitai": "Voir sur Civitai",
|
"viewOnCivitai": "Voir sur Civitai",
|
||||||
"notAvailableFromCivitai": "Non disponible sur Civitai",
|
"notAvailableFromCivitai": "Non disponible sur Civitai",
|
||||||
|
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||||
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
|
"sendToWorkflow": "Envoyer vers ComfyUI (Clic: Ajouter, Maj+Clic: Remplacer)",
|
||||||
"copyLoRASyntax": "Copier la syntaxe LoRA",
|
"copyLoRASyntax": "Copier la syntaxe LoRA",
|
||||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "Télécharger {type} depuis une URL",
|
"titleWithType": "Télécharger {type} depuis une URL",
|
||||||
"civitaiUrl": "URL Civitai :",
|
"civitaiUrl": "URL Civitai :",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "Entrez une URL CivitAI ou CivArchive par ligne. Prend en charge plusieurs URLs pour le téléchargement par lot.",
|
"urlHint": "Entrez une URL CivitAI, CivArchive ou Hugging Face par ligne. Prend en charge plusieurs URL pour le téléchargement par lot.",
|
||||||
|
"selectHfFiles": "Sélectionnez le(s) fichier(s) à télécharger depuis ce dépôt :",
|
||||||
|
"selectAll": "Tout sélectionner",
|
||||||
|
"fetchingRepoFiles": "Récupération des fichiers du dépôt...",
|
||||||
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
"locationPreview": "Aperçu de l'emplacement de téléchargement",
|
||||||
"useDefaultPath": "Utiliser le chemin par défaut",
|
"useDefaultPath": "Utiliser le chemin par défaut",
|
||||||
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
"useDefaultPathTooltip": "Lorsque activé, les fichiers sont automatiquement organisés selon les modèles de chemin configurés",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Format d'URL Civitai invalide",
|
"invalidUrl": "Format d'URL Civitai invalide",
|
||||||
"noVersions": "Aucune version disponible pour ce modèle"
|
"noVersions": "Aucune version disponible pour ce modèle",
|
||||||
|
"mixedSources": "Impossible de mélanger les URL CivitAI et Hugging Face dans le même lot.",
|
||||||
|
"noModelFiles": "Aucun fichier de modèle trouvé dans ce dépôt."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "Préparation du téléchargement...",
|
"preparing": "Préparation du téléchargement...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "Modifier le nom de la version",
|
"editVersionName": "Modifier le nom de la version",
|
||||||
"viewOnCivitai": "Voir sur Civitai",
|
"viewOnCivitai": "Voir sur Civitai",
|
||||||
"viewOnCivitaiText": "Voir sur Civitai",
|
"viewOnCivitaiText": "Voir sur Civitai",
|
||||||
|
"viewOnHuggingFace": "Voir sur Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "Voir sur Hugging Face",
|
||||||
"viewCreatorProfile": "Voir le profil du créateur",
|
"viewCreatorProfile": "Voir le profil du créateur",
|
||||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "Notes supplémentaires",
|
"additionalNotes": "Notes supplémentaires",
|
||||||
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
|
"notesHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
|
||||||
"addNotesPlaceholder": "Ajoutez vos notes ici...",
|
"addNotesPlaceholder": "Ajoutez vos notes ici...",
|
||||||
"aboutThisVersion": "À propos de cette version"
|
"aboutThisVersion": "À propos de cette version",
|
||||||
|
"baseModelSearchPlaceholder": "Rechercher un modèle de base…",
|
||||||
|
"baseModelSuggested": "Suggéré",
|
||||||
|
"baseModelNoMatch": "Aucun modèle de base correspondant"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Notes sauvegardées avec succès",
|
"saved": "Notes sauvegardées avec succès",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "הסר מהמועדפים",
|
"removeFromFavorites": "הסר מהמועדפים",
|
||||||
"viewOnCivitai": "הצג ב-Civitai",
|
"viewOnCivitai": "הצג ב-Civitai",
|
||||||
"notAvailableFromCivitai": "לא זמין מ-Civitai",
|
"notAvailableFromCivitai": "לא זמין מ-Civitai",
|
||||||
|
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||||
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
|
"sendToWorkflow": "שלח ל-ComfyUI (לחיצה: הוסף, Shift+לחיצה: החלף)",
|
||||||
"copyLoRASyntax": "העתק תחביר LoRA",
|
"copyLoRASyntax": "העתק תחביר LoRA",
|
||||||
"checkpointNameCopied": "שם Checkpoint הועתק",
|
"checkpointNameCopied": "שם Checkpoint הועתק",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "הורד {type} מכתובת URL",
|
"titleWithType": "הורד {type} מכתובת URL",
|
||||||
"civitaiUrl": "כתובת URL של Civitai:",
|
"civitaiUrl": "כתובת URL של Civitai:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "יש להזין כתובת URL אחת של CivitAI או CivArchive בכל שורה. תומך במספר כתובות URL להורדה בבת אחת.",
|
"urlHint": "יש להזין כתובת URL אחת של CivitAI, CivArchive או Hugging Face בכל שורה. תומך במספר כתובות URL להורדה בקבוצה.",
|
||||||
|
"selectHfFiles": "בחר קבצים להורדה ממאגר זה:",
|
||||||
|
"selectAll": "בחר הכל",
|
||||||
|
"fetchingRepoFiles": "מביא קבצים מהמאגר...",
|
||||||
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
"locationPreview": "תצוגה מקדימה של מיקום ההורדה",
|
||||||
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
"useDefaultPath": "השתמש בנתיב ברירת מחדל",
|
||||||
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
"useDefaultPathTooltip": "כאשר מופעל, קבצים מאורגנים אוטומטית באמצעות תבניות נתיב מוגדרות",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||||
"noVersions": "אין גרסאות זמינות למודל זה"
|
"noVersions": "אין גרסאות זמינות למודל זה",
|
||||||
|
"mixedSources": "לא ניתן לערבב כתובות URL של CivitAI ו-Hugging Face באותה קבוצה.",
|
||||||
|
"noModelFiles": "לא נמצאו קבצי מודל במאגר זה."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "מכין הורדה...",
|
"preparing": "מכין הורדה...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "ערוך שם גרסה",
|
"editVersionName": "ערוך שם גרסה",
|
||||||
"viewOnCivitai": "הצג ב-Civitai",
|
"viewOnCivitai": "הצג ב-Civitai",
|
||||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||||
|
"viewOnHuggingFace": "צפייה ב-Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
|
||||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||||
"openFileLocation": "פתח מיקום קובץ",
|
"openFileLocation": "פתח מיקום קובץ",
|
||||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "הערות נוספות",
|
"additionalNotes": "הערות נוספות",
|
||||||
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
"notesHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
||||||
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
|
"addNotesPlaceholder": "הוסף את ההערות שלך כאן...",
|
||||||
"aboutThisVersion": "אודות גרסה זו"
|
"aboutThisVersion": "אודות גרסה זו",
|
||||||
|
"baseModelSearchPlaceholder": "חפש מודל בסיס…",
|
||||||
|
"baseModelSuggested": "מוצע",
|
||||||
|
"baseModelNoMatch": "אין מודלי בסיס תואמים"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "הערות נשמרו בהצלחה",
|
"saved": "הערות נשמרו בהצלחה",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "お気に入りから削除",
|
"removeFromFavorites": "お気に入りから削除",
|
||||||
"viewOnCivitai": "Civitaiで表示",
|
"viewOnCivitai": "Civitaiで表示",
|
||||||
"notAvailableFromCivitai": "Civitaiでは利用できません",
|
"notAvailableFromCivitai": "Civitaiでは利用できません",
|
||||||
|
"viewOnHuggingFace": "Hugging Face で見る",
|
||||||
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
|
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
|
||||||
"copyLoRASyntax": "LoRA構文をコピー",
|
"copyLoRASyntax": "LoRA構文をコピー",
|
||||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "URLから{type}をダウンロード",
|
"titleWithType": "URLから{type}をダウンロード",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "1行に1つのCivitAIまたはCivArchive URLを入力してください。複数のURLを一括ダウンロードできます。",
|
"urlHint": "1行に1つのCivitAI、CivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||||
|
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
|
||||||
|
"selectAll": "すべて選択",
|
||||||
|
"fetchingRepoFiles": "リポジトリのファイルを取得中...",
|
||||||
"locationPreview": "ダウンロード場所プレビュー",
|
"locationPreview": "ダウンロード場所プレビュー",
|
||||||
"useDefaultPath": "デフォルトパスを使用",
|
"useDefaultPath": "デフォルトパスを使用",
|
||||||
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
"useDefaultPathTooltip": "有効にすると、設定されたパステンプレートを使用してファイルが自動的に整理されます",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "無効なCivitai URL形式",
|
"invalidUrl": "無効なCivitai URL形式",
|
||||||
"noVersions": "このモデルの利用可能なバージョンがありません"
|
"noVersions": "このモデルの利用可能なバージョンがありません",
|
||||||
|
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
|
||||||
|
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "ダウンロードを準備中...",
|
"preparing": "ダウンロードを準備中...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "バージョン名を編集",
|
"editVersionName": "バージョン名を編集",
|
||||||
"viewOnCivitai": "Civitaiで表示",
|
"viewOnCivitai": "Civitaiで表示",
|
||||||
"viewOnCivitaiText": "Civitaiで表示",
|
"viewOnCivitaiText": "Civitaiで表示",
|
||||||
|
"viewOnHuggingFace": "Hugging Face で見る",
|
||||||
|
"viewOnHuggingFaceText": "Hugging Face で見る",
|
||||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||||
"openFileLocation": "ファイルの場所を開く",
|
"openFileLocation": "ファイルの場所を開く",
|
||||||
"sendToWorkflow": "ComfyUI に送信",
|
"sendToWorkflow": "ComfyUI に送信",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "追加メモ",
|
"additionalNotes": "追加メモ",
|
||||||
"notesHint": "Enterで保存、Shift+Enterで改行",
|
"notesHint": "Enterで保存、Shift+Enterで改行",
|
||||||
"addNotesPlaceholder": "メモをここに追加...",
|
"addNotesPlaceholder": "メモをここに追加...",
|
||||||
"aboutThisVersion": "このバージョンについて"
|
"aboutThisVersion": "このバージョンについて",
|
||||||
|
"baseModelSearchPlaceholder": "ベースモデルを検索…",
|
||||||
|
"baseModelSuggested": "おすすめ",
|
||||||
|
"baseModelNoMatch": "該当するベースモデルがありません"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "メモが正常に保存されました",
|
"saved": "メモが正常に保存されました",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "즐겨찾기에서 제거",
|
"removeFromFavorites": "즐겨찾기에서 제거",
|
||||||
"viewOnCivitai": "Civitai에서 보기",
|
"viewOnCivitai": "Civitai에서 보기",
|
||||||
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
|
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
|
||||||
|
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||||
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
|
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
|
||||||
"copyLoRASyntax": "LoRA 문법 복사",
|
"copyLoRASyntax": "LoRA 문법 복사",
|
||||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "URL에서 {type} 다운로드",
|
"titleWithType": "URL에서 {type} 다운로드",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "한 줄에 하나의 CivitAI 또는 CivArchive URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||||
|
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
|
||||||
|
"selectAll": "모두 선택",
|
||||||
|
"fetchingRepoFiles": "저장소 파일을 가져오는 중...",
|
||||||
"locationPreview": "다운로드 위치 미리보기",
|
"locationPreview": "다운로드 위치 미리보기",
|
||||||
"useDefaultPath": "기본 경로 사용",
|
"useDefaultPath": "기본 경로 사용",
|
||||||
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
"useDefaultPathTooltip": "활성화하면 구성된 경로 템플릿을 사용하여 파일이 자동으로 정리됩니다",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다"
|
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
|
||||||
|
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
|
||||||
|
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "다운로드 준비 중...",
|
"preparing": "다운로드 준비 중...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "버전명 편집",
|
"editVersionName": "버전명 편집",
|
||||||
"viewOnCivitai": "Civitai에서 보기",
|
"viewOnCivitai": "Civitai에서 보기",
|
||||||
"viewOnCivitaiText": "Civitai에서 보기",
|
"viewOnCivitaiText": "Civitai에서 보기",
|
||||||
|
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||||
|
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
||||||
"viewCreatorProfile": "제작자 프로필 보기",
|
"viewCreatorProfile": "제작자 프로필 보기",
|
||||||
"openFileLocation": "파일 위치 열기",
|
"openFileLocation": "파일 위치 열기",
|
||||||
"sendToWorkflow": "ComfyUI로 보내기",
|
"sendToWorkflow": "ComfyUI로 보내기",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "추가 메모",
|
"additionalNotes": "추가 메모",
|
||||||
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
|
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
|
||||||
"addNotesPlaceholder": "메모를 여기에 추가하세요...",
|
"addNotesPlaceholder": "메모를 여기에 추가하세요...",
|
||||||
"aboutThisVersion": "이 버전에 대해"
|
"aboutThisVersion": "이 버전에 대해",
|
||||||
|
"baseModelSearchPlaceholder": "베이스 모델 검색…",
|
||||||
|
"baseModelSuggested": "추천",
|
||||||
|
"baseModelNoMatch": "일치하는 베이스 모델 없음"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "메모가 성공적으로 저장됨",
|
"saved": "메모가 성공적으로 저장됨",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "Удалить из избранного",
|
"removeFromFavorites": "Удалить из избранного",
|
||||||
"viewOnCivitai": "Посмотреть на Civitai",
|
"viewOnCivitai": "Посмотреть на Civitai",
|
||||||
"notAvailableFromCivitai": "Недоступно на Civitai",
|
"notAvailableFromCivitai": "Недоступно на Civitai",
|
||||||
|
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||||
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
|
"sendToWorkflow": "Отправить в ComfyUI (Клик: Добавить, Shift+Клик: Заменить)",
|
||||||
"copyLoRASyntax": "Копировать синтаксис LoRA",
|
"copyLoRASyntax": "Копировать синтаксис LoRA",
|
||||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "Скачать {type} по URL",
|
"titleWithType": "Скачать {type} по URL",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "Введите один URL CivitAI или CivArchive в каждой строке. Поддерживается пакетная загрузка нескольких URL.",
|
"urlHint": "Введите один URL CivitAI, CivArchive или Hugging Face в каждой строке. Поддерживает несколько URL для пакетной загрузки.",
|
||||||
|
"selectHfFiles": "Выберите файл(ы) для загрузки из этого репозитория:",
|
||||||
|
"selectAll": "Выбрать все",
|
||||||
|
"fetchingRepoFiles": "Получение файлов репозитория...",
|
||||||
"locationPreview": "Предпросмотр места загрузки",
|
"locationPreview": "Предпросмотр места загрузки",
|
||||||
"useDefaultPath": "Использовать путь по умолчанию",
|
"useDefaultPath": "Использовать путь по умолчанию",
|
||||||
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
"useDefaultPathTooltip": "При включении файлы автоматически организуются с использованием настроенных шаблонов путей",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Неверный формат URL Civitai",
|
"invalidUrl": "Неверный формат URL Civitai",
|
||||||
"noVersions": "Нет доступных версий для этой модели"
|
"noVersions": "Нет доступных версий для этой модели",
|
||||||
|
"mixedSources": "Нельзя смешивать URL-адреса CivitAI и Hugging Face в одном пакете.",
|
||||||
|
"noModelFiles": "В этом репозитории не найдено файлов моделей."
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "Подготовка загрузки...",
|
"preparing": "Подготовка загрузки...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "Редактировать название версии",
|
"editVersionName": "Редактировать название версии",
|
||||||
"viewOnCivitai": "Посмотреть на Civitai",
|
"viewOnCivitai": "Посмотреть на Civitai",
|
||||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||||
|
"viewOnHuggingFace": "Открыть Hugging Face",
|
||||||
|
"viewOnHuggingFaceText": "Открыть Hugging Face",
|
||||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||||
"openFileLocation": "Открыть расположение файла",
|
"openFileLocation": "Открыть расположение файла",
|
||||||
"sendToWorkflow": "Отправить в ComfyUI",
|
"sendToWorkflow": "Отправить в ComfyUI",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "Дополнительные заметки",
|
"additionalNotes": "Дополнительные заметки",
|
||||||
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
"notesHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
||||||
"addNotesPlaceholder": "Добавьте ваши заметки здесь...",
|
"addNotesPlaceholder": "Добавьте ваши заметки здесь...",
|
||||||
"aboutThisVersion": "Об этой версии"
|
"aboutThisVersion": "Об этой версии",
|
||||||
|
"baseModelSearchPlaceholder": "Поиск базовой модели…",
|
||||||
|
"baseModelSuggested": "Предполагаемые",
|
||||||
|
"baseModelNoMatch": "Нет подходящих базовых моделей"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "Заметки успешно сохранены",
|
"saved": "Заметки успешно сохранены",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "从收藏移除",
|
"removeFromFavorites": "从收藏移除",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"notAvailableFromCivitai": "Civitai 上不可用",
|
"notAvailableFromCivitai": "Civitai 上不可用",
|
||||||
|
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||||
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
|
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
|
||||||
"copyLoRASyntax": "复制 LoRA 语法",
|
"copyLoRASyntax": "复制 LoRA 语法",
|
||||||
"checkpointNameCopied": "检查点名称已复制",
|
"checkpointNameCopied": "检查点名称已复制",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "从 URL 下载 {type}",
|
"titleWithType": "从 URL 下载 {type}",
|
||||||
"civitaiUrl": "Civitai URL:",
|
"civitaiUrl": "Civitai URL:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "每行输入一个 CivitAI 或 CivArchive URL。支持批量下载多个 URL。",
|
"urlHint": "每行输入一个 CivitAI、CivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
|
||||||
|
"selectHfFiles": "选择从此仓库下载的文件:",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"fetchingRepoFiles": "正在获取仓库文件...",
|
||||||
"locationPreview": "下载位置预览",
|
"locationPreview": "下载位置预览",
|
||||||
"useDefaultPath": "使用默认路径",
|
"useDefaultPath": "使用默认路径",
|
||||||
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
"useDefaultPathTooltip": "启用后,文件将自动按配置的路径模板进行整理",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "无效的 Civitai URL 格式",
|
"invalidUrl": "无效的 Civitai URL 格式",
|
||||||
"noVersions": "此模型没有可用版本"
|
"noVersions": "此模型没有可用版本",
|
||||||
|
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||||
|
"noModelFiles": "在此仓库中未找到模型文件。"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "正在准备下载...",
|
"preparing": "正在准备下载...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "编辑版本名称",
|
"editVersionName": "编辑版本名称",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
|
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||||
|
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||||
"viewCreatorProfile": "查看创作者主页",
|
"viewCreatorProfile": "查看创作者主页",
|
||||||
"openFileLocation": "打开文件位置",
|
"openFileLocation": "打开文件位置",
|
||||||
"sendToWorkflow": "发送到 ComfyUI",
|
"sendToWorkflow": "发送到 ComfyUI",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "附加备注",
|
"additionalNotes": "附加备注",
|
||||||
"notesHint": "回车保存,Shift+回车换行",
|
"notesHint": "回车保存,Shift+回车换行",
|
||||||
"addNotesPlaceholder": "在此添加你的备注...",
|
"addNotesPlaceholder": "在此添加你的备注...",
|
||||||
"aboutThisVersion": "关于此版本"
|
"aboutThisVersion": "关于此版本",
|
||||||
|
"baseModelSearchPlaceholder": "搜索基础模型…",
|
||||||
|
"baseModelSuggested": "推荐",
|
||||||
|
"baseModelNoMatch": "没有匹配的基础模型"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "备注保存成功",
|
"saved": "备注保存成功",
|
||||||
|
|||||||
+14
-3
@@ -105,6 +105,7 @@
|
|||||||
"removeFromFavorites": "移除收藏",
|
"removeFromFavorites": "移除收藏",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"notAvailableFromCivitai": "Civitai 不提供",
|
"notAvailableFromCivitai": "Civitai 不提供",
|
||||||
|
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||||
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
|
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
|
||||||
"copyLoRASyntax": "複製 LoRA 語法",
|
"copyLoRASyntax": "複製 LoRA 語法",
|
||||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||||
@@ -1134,7 +1135,10 @@
|
|||||||
"titleWithType": "從網址下載 {type}",
|
"titleWithType": "從網址下載 {type}",
|
||||||
"civitaiUrl": "Civitai 網址:",
|
"civitaiUrl": "Civitai 網址:",
|
||||||
"placeholder": "https://civitai.com/models/...",
|
"placeholder": "https://civitai.com/models/...",
|
||||||
"urlHint": "每行輸入一個 CivitAI 或 CivArchive URL。支援批量下載多個 URL。",
|
"urlHint": "每行輸入一個 CivitAI、CivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
|
||||||
|
"selectHfFiles": "選擇從此倉庫下載的檔案:",
|
||||||
|
"selectAll": "全選",
|
||||||
|
"fetchingRepoFiles": "正在獲取倉庫檔案...",
|
||||||
"locationPreview": "下載位置預覽",
|
"locationPreview": "下載位置預覽",
|
||||||
"useDefaultPath": "使用預設路徑",
|
"useDefaultPath": "使用預設路徑",
|
||||||
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
"useDefaultPathTooltip": "啟用後,檔案將依照設定的路徑範本自動整理",
|
||||||
@@ -1163,7 +1167,9 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"invalidUrl": "Civitai 網址格式無效",
|
"invalidUrl": "Civitai 網址格式無效",
|
||||||
"noVersions": "此模型無可用版本"
|
"noVersions": "此模型無可用版本",
|
||||||
|
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||||
|
"noModelFiles": "在此倉庫中未找到模型檔案。"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"preparing": "準備下載中...",
|
"preparing": "準備下載中...",
|
||||||
@@ -1314,6 +1320,8 @@
|
|||||||
"editVersionName": "編輯版本名稱",
|
"editVersionName": "編輯版本名稱",
|
||||||
"viewOnCivitai": "在 Civitai 查看",
|
"viewOnCivitai": "在 Civitai 查看",
|
||||||
"viewOnCivitaiText": "在 Civitai 查看",
|
"viewOnCivitaiText": "在 Civitai 查看",
|
||||||
|
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||||
|
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||||
"viewCreatorProfile": "查看創作者個人檔案",
|
"viewCreatorProfile": "查看創作者個人檔案",
|
||||||
"openFileLocation": "開啟檔案位置",
|
"openFileLocation": "開啟檔案位置",
|
||||||
"sendToWorkflow": "傳送到 ComfyUI",
|
"sendToWorkflow": "傳送到 ComfyUI",
|
||||||
@@ -1339,7 +1347,10 @@
|
|||||||
"additionalNotes": "附加備註",
|
"additionalNotes": "附加備註",
|
||||||
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
|
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
|
||||||
"addNotesPlaceholder": "在此新增備註...",
|
"addNotesPlaceholder": "在此新增備註...",
|
||||||
"aboutThisVersion": "關於此版本"
|
"aboutThisVersion": "關於此版本",
|
||||||
|
"baseModelSearchPlaceholder": "搜尋基礎模型…",
|
||||||
|
"baseModelSuggested": "推薦",
|
||||||
|
"baseModelNoMatch": "沒有符合的基礎模型"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"saved": "備註已儲存",
|
"saved": "備註已儲存",
|
||||||
|
|||||||
@@ -123,24 +123,39 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
if model_hash_from_hashes:
|
if model_hash_from_hashes:
|
||||||
metadata["model_hash"] = model_hash_from_hashes
|
metadata["model_hash"] = model_hash_from_hashes
|
||||||
|
|
||||||
# Extract Lora hashes in alternative format
|
# Extract Lora hashes in alternative format.
|
||||||
|
# Run unconditionally (not just as fallback) so that
|
||||||
|
# non-empty hashes from Lora hashes fill in the gaps left
|
||||||
|
# by empty values in the Hashes JSON dict. Some WebUI
|
||||||
|
# builds write real hash values only to Lora hashes and
|
||||||
|
# leave the Hashes JSON values empty.
|
||||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||||
if not hashes_match and lora_hashes_match:
|
if lora_hashes_match:
|
||||||
try:
|
try:
|
||||||
lora_hashes_str = lora_hashes_match.group(1)
|
lora_hashes_str = lora_hashes_match.group(1)
|
||||||
lora_hash_entries = lora_hashes_str.split(', ')
|
lora_hash_entries = lora_hashes_str.split(', ')
|
||||||
|
|
||||||
# Initialize hashes dict if it doesn't exist
|
|
||||||
if "hashes" not in metadata:
|
|
||||||
metadata["hashes"] = {}
|
|
||||||
|
|
||||||
# Parse each lora hash entry (format: "name: hash")
|
# Parse each lora hash entry (format: "name: hash")
|
||||||
for entry in lora_hash_entries:
|
for entry in lora_hash_entries:
|
||||||
if ': ' in entry:
|
if ': ' in entry:
|
||||||
lora_name, lora_hash = entry.split(': ', 1)
|
lora_name, lora_hash = entry.split(': ', 1)
|
||||||
# Add as lora type in the same format as regular hashes
|
lora_hash = lora_hash.strip()
|
||||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
if not lora_hash:
|
||||||
|
# Skip entries without a hash value
|
||||||
|
continue
|
||||||
|
# Initialize hashes dict if it doesn't exist
|
||||||
|
if "hashes" not in metadata:
|
||||||
|
metadata["hashes"] = {}
|
||||||
|
# Add as lora type in the same format as
|
||||||
|
# regular hashes. Only override an
|
||||||
|
# existing entry if its value is empty
|
||||||
|
# (Lora hashes is the more reliable
|
||||||
|
# source when Hashes JSON has blanks).
|
||||||
|
key = f"lora:{lora_name}"
|
||||||
|
existing = metadata["hashes"].get(key, "")
|
||||||
|
if not existing:
|
||||||
|
metadata["hashes"][key] = lora_hash
|
||||||
|
|
||||||
# Remove lora hashes from params section
|
# Remove lora hashes from params section
|
||||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -362,6 +377,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
# Only process lora or hypernet types
|
# Only process lora or hypernet types
|
||||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip entries without a hash value — they can't be
|
||||||
|
# resolved via CivitAI and would only produce a
|
||||||
|
# useless "Deleted" entry in the recipe.
|
||||||
|
if not lora_hash:
|
||||||
|
continue
|
||||||
|
|
||||||
lora_type, lora_name = hash_key.split(':', 1)
|
lora_type, lora_name = hash_key.split(':', 1)
|
||||||
|
|
||||||
@@ -387,11 +408,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
# Try to get info from Civitai
|
# Try to get info from Civitai
|
||||||
if metadata_provider:
|
if metadata_provider:
|
||||||
try:
|
try:
|
||||||
if lora_hash:
|
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||||
# If we have hash, use it for lookup
|
|
||||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
|
||||||
else:
|
|
||||||
civitai_info = None
|
|
||||||
|
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
lora_entry,
|
lora_entry,
|
||||||
|
|||||||
@@ -514,11 +514,21 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
result["loras"].append(lora_entry)
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
# Process modelVersionIds from Civitai image API
|
# Process modelVersionIds from Civitai image API.
|
||||||
# These are model version IDs returned at root level when meta doesn't contain resources
|
# These are version IDs returned at root level of the API response.
|
||||||
if "modelVersionIds" in metadata and isinstance(
|
# When resources or civitaiResources are already present in metadata
|
||||||
metadata["modelVersionIds"], list
|
# (which they are when ?withMeta=true is passed), those sections have
|
||||||
|
# complete hash/type information — modelVersionIds is a fallback for
|
||||||
|
# when meta is null and only the flat ID list is available. Skipping
|
||||||
|
# it here avoids duplicates: the same file hash often resolves to
|
||||||
|
# different version IDs via hash lookup (resources) vs the original
|
||||||
|
# version ID in modelVersionIds, and both paths would create entries.
|
||||||
|
if (
|
||||||
|
"modelVersionIds" in metadata
|
||||||
|
and isinstance(metadata["modelVersionIds"], list)
|
||||||
|
and not result.get("loras")
|
||||||
):
|
):
|
||||||
|
|
||||||
for version_id in metadata["modelVersionIds"]:
|
for version_id in metadata["modelVersionIds"]:
|
||||||
version_id_str = str(version_id)
|
version_id_str = str(version_id)
|
||||||
|
|
||||||
@@ -526,6 +536,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
if version_id_str in added_loras:
|
if version_id_str in added_loras:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip if this version ID is already the recipe's checkpoint
|
||||||
|
# (resolved earlier from embedded resources/Model hash,
|
||||||
|
# avoiding a duplicate CivitAI API call).
|
||||||
|
existing_model = result.get("model")
|
||||||
|
if existing_model and str(existing_model.get("id")) == version_id_str:
|
||||||
|
continue
|
||||||
|
|
||||||
# Initialize lora entry with version ID
|
# Initialize lora entry with version ID
|
||||||
lora_entry = {
|
lora_entry = {
|
||||||
"id": version_id,
|
"id": version_id,
|
||||||
@@ -559,9 +576,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if populated_entry is None:
|
if populated_entry is None:
|
||||||
continue # Skip invalid LoRA types
|
# Not a LoRA — try as checkpoint (only if we
|
||||||
|
# don't already have one). Reuses the same
|
||||||
|
# civitai_info from the API call above so no
|
||||||
|
# extra query is made.
|
||||||
|
if result["model"] is None:
|
||||||
|
checkpoint_entry = {
|
||||||
|
"id": version_id,
|
||||||
|
"modelId": 0,
|
||||||
|
"name": "Unknown Model",
|
||||||
|
"version": "",
|
||||||
|
"type": "checkpoint",
|
||||||
|
"existsLocally": False,
|
||||||
|
"localPath": None,
|
||||||
|
"file_name": "",
|
||||||
|
"hash": "",
|
||||||
|
"thumbnailUrl": (
|
||||||
|
"/loras_static/images/no-preview.png"
|
||||||
|
),
|
||||||
|
"baseModel": "",
|
||||||
|
"size": 0,
|
||||||
|
"downloadUrl": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
}
|
||||||
|
cp_populated = await (
|
||||||
|
self.populate_checkpoint_from_civitai(
|
||||||
|
checkpoint_entry, civitai_info
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if cp_populated.get("modelId"):
|
||||||
|
result["model"] = cp_populated
|
||||||
|
continue # Not a LoRA, don't add to loras
|
||||||
|
|
||||||
lora_entry = populated_entry
|
lora_entry = populated_entry
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error fetching Civitai info for model version {version_id}: {e}"
|
f"Error fetching Civitai info for model version {version_id}: {e}"
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""Handlers for Hugging Face model listing and download.
|
||||||
|
|
||||||
|
Minimal MVP implementation — uses direct HTTP to the HF API for file
|
||||||
|
listing and the project's existing aiohttp-based Downloader for
|
||||||
|
downloading. No huggingface_hub dependency required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ...config import config
|
||||||
|
from ...services.downloader import (
|
||||||
|
DownloadProgress,
|
||||||
|
get_downloader,
|
||||||
|
)
|
||||||
|
from ...services.aria2_downloader import Aria2Downloader
|
||||||
|
from ...services.settings_manager import get_settings_manager
|
||||||
|
from ...services.service_registry import ServiceRegistry
|
||||||
|
from ...services.websocket_manager import ws_manager
|
||||||
|
from ...utils.constants import MODEL_FILE_EXTENSIONS
|
||||||
|
from ...utils.metadata_manager import MetadataManager
|
||||||
|
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DEFAULT_MODEL_CLASS = LoraMetadata
|
||||||
|
_DEFAULT_SCANNER_GETTER = "get_lora_scanner"
|
||||||
|
|
||||||
|
# Shared aiohttp session for HF API calls (created on first use)
|
||||||
|
_hf_api_session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_hf_api_session() -> aiohttp.ClientSession:
|
||||||
|
"""Get or create the shared aiohttp session for HF API calls."""
|
||||||
|
global _hf_api_session # needed because we reassign the module-level name
|
||||||
|
if _hf_api_session is None or _hf_api_session.closed:
|
||||||
|
_hf_api_session = aiohttp.ClientSession(
|
||||||
|
headers={"User-Agent": "ComfyUI-LoRA-Manager/1.0"},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=30),
|
||||||
|
)
|
||||||
|
return _hf_api_session
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_model_type(model_root: str) -> tuple[Any, str]:
|
||||||
|
"""Determine model class and scanner by matching ``model_root`` against the
|
||||||
|
configured root paths for each model type (from ``Config``).
|
||||||
|
|
||||||
|
The ``model_root`` value comes from the frontend's model-root dropdown,
|
||||||
|
which is populated from the current page's scanner roots. By checking
|
||||||
|
which scanner's root list it belongs to, we avoid fragile heuristics
|
||||||
|
like substring-matching path names.
|
||||||
|
"""
|
||||||
|
norm = os.path.normpath(model_root).replace(os.sep, "/")
|
||||||
|
|
||||||
|
# LoRA roots
|
||||||
|
for p in (config.loras_roots or []) + (config.extra_loras_roots or []):
|
||||||
|
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||||
|
return LoraMetadata, "get_lora_scanner"
|
||||||
|
|
||||||
|
# Checkpoint / UNet roots
|
||||||
|
for p in (
|
||||||
|
(config.checkpoints_roots or [])
|
||||||
|
+ (config.extra_checkpoints_roots or [])
|
||||||
|
+ (config.unet_roots or [])
|
||||||
|
+ (config.extra_unet_roots or [])
|
||||||
|
):
|
||||||
|
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||||
|
return CheckpointMetadata, "get_checkpoint_scanner"
|
||||||
|
|
||||||
|
# Embedding roots
|
||||||
|
for p in (config.embeddings_roots or []) + (config.extra_embeddings_roots or []):
|
||||||
|
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||||
|
return EmbeddingMetadata, "get_embedding_scanner"
|
||||||
|
|
||||||
|
# Fallback — should not happen in normal use
|
||||||
|
logger.warning(
|
||||||
|
"Could not determine model type for root '%s'; defaulting to LoRA",
|
||||||
|
model_root,
|
||||||
|
)
|
||||||
|
return _DEFAULT_MODEL_CLASS, _DEFAULT_SCANNER_GETTER
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
|
||||||
|
"""Create a proper .metadata.json and add the model to the scanner cache.
|
||||||
|
|
||||||
|
Uses ``MetadataManager.create_default_metadata()`` which computes the
|
||||||
|
SHA256 hash, extracts safetensors header metadata (base_model), and
|
||||||
|
produces a fully-populated ``LoraMetadata`` (or ``CheckpointMetadata`` /
|
||||||
|
``EmbeddingMetadata``) object. We then overlay HF-specific fields and
|
||||||
|
register the model in the in-memory scanner cache so it appears
|
||||||
|
immediately without a full filesystem walk.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hf_url = f"https://huggingface.co/{repo}"
|
||||||
|
model_class, scanner_getter_name = _infer_model_type(model_root)
|
||||||
|
|
||||||
|
# 1. Create proper metadata (computes SHA256, reads safetensors headers)
|
||||||
|
metadata = await MetadataManager.create_default_metadata(
|
||||||
|
dest_path, model_class=model_class
|
||||||
|
)
|
||||||
|
if metadata is None:
|
||||||
|
logger.warning("create_default_metadata returned None for %s", dest_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Overlay HF-specific fields
|
||||||
|
metadata._unknown_fields["hf_url"] = hf_url
|
||||||
|
metadata.from_civitai = False # HF models are not from CivitAI
|
||||||
|
|
||||||
|
# 3. Save metadata atomically
|
||||||
|
await MetadataManager.save_metadata(dest_path, metadata)
|
||||||
|
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||||
|
|
||||||
|
# 4. Determine relative folder path for cache
|
||||||
|
# model_root is an absolute path; dest_path is under it
|
||||||
|
folder = ""
|
||||||
|
if os.path.isabs(model_root) and dest_path.startswith(model_root):
|
||||||
|
rel = os.path.relpath(os.path.dirname(dest_path), model_root)
|
||||||
|
folder = rel.replace(os.sep, "/") if rel != "." else ""
|
||||||
|
|
||||||
|
# 5. Add to scanner cache (same as CivitAI's _execute_download does)
|
||||||
|
scanner_getter = getattr(ServiceRegistry, scanner_getter_name, None)
|
||||||
|
if scanner_getter is not None:
|
||||||
|
scanner = await scanner_getter()
|
||||||
|
if scanner is not None:
|
||||||
|
metadata_dict = metadata.to_dict()
|
||||||
|
metadata_dict["hf_url"] = hf_url
|
||||||
|
await scanner.add_model_to_cache(metadata_dict, folder)
|
||||||
|
logger.info("Added %s to scanner cache (folder=%s)", dest_path, folder)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to save HF metadata for %s: %s", dest_path, exc)
|
||||||
|
|
||||||
|
|
||||||
|
class HfHandler:
|
||||||
|
"""Handle Hugging Face model browsing and download."""
|
||||||
|
|
||||||
|
async def get_hf_repo_files(self, request: web.Request) -> web.Response:
|
||||||
|
"""List model-weight files from a HF repo with real file sizes.
|
||||||
|
|
||||||
|
Uses the HF tree API endpoint which returns accurate file sizes
|
||||||
|
(including LFS-tracked files), unlike the model info endpoint.
|
||||||
|
"""
|
||||||
|
repo = request.query.get("repo", "").strip()
|
||||||
|
if not repo or "/" not in repo:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Missing or invalid 'repo' parameter (expected user/repo)"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
url = f"https://huggingface.co/api/models/{repo}/tree/main"
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = await _get_hf_api_session()
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status == 404:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"Repo '{repo}' not found"}, status=404
|
||||||
|
)
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"HF API error {resp.status}: {text[:200]}"},
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
tree: list[dict[str, Any]] = await resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to fetch HF repo files: %s", exc)
|
||||||
|
return web.json_response({"error": str(exc)}, status=502)
|
||||||
|
|
||||||
|
files: list[dict[str, Any]] = []
|
||||||
|
for entry in tree:
|
||||||
|
path: str = entry.get("path", "")
|
||||||
|
ext = os.path.splitext(path)[1].lower()
|
||||||
|
if ext not in MODEL_FILE_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
size = entry.get("size", 0) or 0
|
||||||
|
if size == 0 and "lfs" in entry:
|
||||||
|
size = entry["lfs"].get("size", 0) or 0
|
||||||
|
files.append({
|
||||||
|
"filename": path,
|
||||||
|
"size": size,
|
||||||
|
})
|
||||||
|
|
||||||
|
files.sort(key=lambda f: f["size"], reverse=True)
|
||||||
|
return web.json_response(files)
|
||||||
|
|
||||||
|
async def download_hf_model(self, request: web.Request) -> web.Response:
|
||||||
|
"""Download a single file from Hugging Face into the model directory.
|
||||||
|
|
||||||
|
POST JSON body::
|
||||||
|
|
||||||
|
{
|
||||||
|
"repo": "dx8152/Flux2-Klein-9B-Consistency",
|
||||||
|
"filename": "Flux2-Klein-9B-consistency-V2.safetensors",
|
||||||
|
"revision": "main",
|
||||||
|
"model_root": "loras",
|
||||||
|
"relative_path": "",
|
||||||
|
"use_default_paths": false,
|
||||||
|
"download_id": "optional-batch-id"
|
||||||
|
}
|
||||||
|
|
||||||
|
If ``download_id`` is provided, real-time progress (bytes, speed,
|
||||||
|
percentage) is broadcast via the WebSocket progress system, matching
|
||||||
|
the CivitAI download experience.
|
||||||
|
|
||||||
|
Respects the ``download_backend`` setting (``aria2`` or ``default``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||||
|
|
||||||
|
repo = (payload.get("repo") or "").strip()
|
||||||
|
filename = (payload.get("filename") or "").strip()
|
||||||
|
revision = (payload.get("revision") or "main").strip()
|
||||||
|
model_root = (payload.get("model_root") or "").strip()
|
||||||
|
relative_path = (payload.get("relative_path") or "").strip()
|
||||||
|
use_default_paths = bool(payload.get("use_default_paths", False))
|
||||||
|
download_id: str | None = payload.get("download_id")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"download_hf_model: repo=%s file=%s root=%s download_id=%s",
|
||||||
|
repo, filename, model_root, download_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not repo or not filename:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate repo format — must be user/repo_name
|
||||||
|
if repo.count("/") != 1 or not re.match(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", repo):
|
||||||
|
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||||
|
author, repo_name = repo.split("/", 1)
|
||||||
|
if ".." in (author, repo_name) or "." in (author, repo_name):
|
||||||
|
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||||
|
|
||||||
|
# Validate filename — must not contain path separators or ..
|
||||||
|
if "/" in filename or "\\" in filename or ".." in filename:
|
||||||
|
return web.json_response({"error": "Invalid filename"}, status=400)
|
||||||
|
|
||||||
|
# Validate relative_path — must not be absolute or escape base directory
|
||||||
|
if relative_path:
|
||||||
|
if os.path.isabs(relative_path):
|
||||||
|
return web.json_response({"error": "relative_path must not be absolute"}, status=400)
|
||||||
|
if ".." in relative_path.split("/") or "\\" in relative_path:
|
||||||
|
return web.json_response({"error": "Invalid relative_path"}, status=400)
|
||||||
|
|
||||||
|
# Validate model_root — must not contain path traversal
|
||||||
|
if not os.path.isabs(model_root):
|
||||||
|
# For relative model_root, check it doesn't escape
|
||||||
|
resolved_model_root = os.path.realpath(
|
||||||
|
os.path.join(os.getcwd(), "models", model_root)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resolved_model_root = os.path.realpath(model_root)
|
||||||
|
|
||||||
|
# Verify model_root is within a configured scanner root
|
||||||
|
allowed_roots = set()
|
||||||
|
for root_list in (
|
||||||
|
config.loras_roots or [],
|
||||||
|
config.extra_loras_roots or [],
|
||||||
|
config.checkpoints_roots or [],
|
||||||
|
config.extra_checkpoints_roots or [],
|
||||||
|
config.unet_roots or [],
|
||||||
|
config.extra_unet_roots or [],
|
||||||
|
config.embeddings_roots or [],
|
||||||
|
config.extra_embeddings_roots or [],
|
||||||
|
):
|
||||||
|
for r in root_list:
|
||||||
|
allowed_roots.add(os.path.realpath(r))
|
||||||
|
|
||||||
|
if not any(resolved_model_root == root or resolved_model_root.startswith(root + os.sep) for root in allowed_roots):
|
||||||
|
logger.warning("Invalid model_root rejected: %s", model_root)
|
||||||
|
return web.json_response({"error": f"Invalid model_root: {model_root}"}, status=400)
|
||||||
|
|
||||||
|
base_dir = resolved_model_root
|
||||||
|
|
||||||
|
if use_default_paths:
|
||||||
|
target_dir = os.path.join(base_dir, "huggingface", author, repo_name)
|
||||||
|
elif relative_path:
|
||||||
|
target_dir = os.path.join(base_dir, relative_path)
|
||||||
|
else:
|
||||||
|
target_dir = base_dir
|
||||||
|
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
dest_path = os.path.join(target_dir, filename)
|
||||||
|
|
||||||
|
# Resolve symlinks and check for path traversal escape
|
||||||
|
real_dest = os.path.realpath(dest_path)
|
||||||
|
real_base = os.path.realpath(target_dir)
|
||||||
|
if not real_dest.startswith(real_base + os.sep):
|
||||||
|
logger.warning("Path traversal blocked: %s -> %s", dest_path, real_dest)
|
||||||
|
return web.json_response({"error": "Path traversal detected"}, status=400)
|
||||||
|
|
||||||
|
# Check if already exists (simple skip)
|
||||||
|
if os.path.exists(dest_path) and os.path.getsize(dest_path) > 0:
|
||||||
|
logger.info("download_hf_model: file already exists, skipping — %s", dest_path)
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"message": f"File already exists: {dest_path}",
|
||||||
|
"path": dest_path,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build HF resolve URL
|
||||||
|
resolve_url = (
|
||||||
|
f"https://huggingface.co/{repo}/resolve/{revision}/{filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up progress callback if download_id is provided
|
||||||
|
progress_callback = None
|
||||||
|
if download_id:
|
||||||
|
|
||||||
|
async def _progress_callback(
|
||||||
|
progress: float | DownloadProgress,
|
||||||
|
snapshot: DownloadProgress | None = None,
|
||||||
|
) -> None:
|
||||||
|
percent = 0.0
|
||||||
|
metrics = snapshot if isinstance(snapshot, DownloadProgress) else None
|
||||||
|
|
||||||
|
if isinstance(progress, DownloadProgress):
|
||||||
|
percent = progress.percent_complete
|
||||||
|
metrics = progress
|
||||||
|
elif isinstance(snapshot, DownloadProgress):
|
||||||
|
percent = snapshot.percent_complete
|
||||||
|
else:
|
||||||
|
percent = float(progress)
|
||||||
|
|
||||||
|
broadcast: dict[str, Any] = {
|
||||||
|
"status": "progress",
|
||||||
|
"progress": round(percent),
|
||||||
|
}
|
||||||
|
if metrics:
|
||||||
|
broadcast["bytes_downloaded"] = metrics.bytes_downloaded
|
||||||
|
broadcast["total_bytes"] = metrics.total_bytes
|
||||||
|
broadcast["bytes_per_second"] = metrics.bytes_per_second
|
||||||
|
|
||||||
|
await ws_manager.broadcast_download_progress(download_id, broadcast)
|
||||||
|
|
||||||
|
progress_callback = _progress_callback
|
||||||
|
|
||||||
|
# Respect download backend setting (aria2 vs default)
|
||||||
|
download_backend = (
|
||||||
|
get_settings_manager().get("download_backend", "default")
|
||||||
|
)
|
||||||
|
|
||||||
|
if download_backend == "aria2":
|
||||||
|
aria2 = await Aria2Downloader.get_instance()
|
||||||
|
aid = download_id or f"hf_{repo}_{filename}"
|
||||||
|
try:
|
||||||
|
hf_success, hf_result = await aria2.download_file(
|
||||||
|
url=resolve_url,
|
||||||
|
save_path=dest_path,
|
||||||
|
download_id=aid,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
if hf_success:
|
||||||
|
await _save_hf_metadata(dest_path, repo, model_root)
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Downloaded to {dest_path}",
|
||||||
|
"path": dest_path,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": hf_result or "aria2 download failed"},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("HF download (aria2) failed: %s", exc)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default: use built-in aiohttp Downloader
|
||||||
|
downloader = await get_downloader()
|
||||||
|
try:
|
||||||
|
success, result = await downloader.download_file(
|
||||||
|
url=resolve_url,
|
||||||
|
save_path=dest_path,
|
||||||
|
use_auth=False,
|
||||||
|
allow_resume=True,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
await _save_hf_metadata(dest_path, repo, model_root)
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Downloaded to {result}",
|
||||||
|
"path": result,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": result or "Download failed"},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("HF download failed: %s", exc)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
@@ -48,6 +48,7 @@ from ...utils.constants import (
|
|||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
|
from .hf_handlers import HfHandler
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from ...utils.example_images_paths import (
|
from ...utils.example_images_paths import (
|
||||||
find_non_compliant_items_in_example_images_root,
|
find_non_compliant_items_in_example_images_root,
|
||||||
@@ -414,9 +415,10 @@ class PromptServerProtocol(Protocol):
|
|||||||
"""Subset of PromptServer used by the handlers."""
|
"""Subset of PromptServer used by the handlers."""
|
||||||
|
|
||||||
instance: "PromptServerProtocol"
|
instance: "PromptServerProtocol"
|
||||||
|
sockets: dict # maps clientId (sid) → WebSocketResponse
|
||||||
|
|
||||||
def send_sync(
|
def send_sync(
|
||||||
self, event: str, payload: dict
|
self, event: str, payload: dict | None = None, sid: str | None = None
|
||||||
) -> None: # pragma: no cover - protocol
|
) -> None: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -471,90 +473,154 @@ class BackupServiceProtocol(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class NodeRegistry:
|
class NodeRegistry:
|
||||||
"""Thread-safe registry for tracking LoRA nodes in active workflows."""
|
"""Thread-safe registry for tracking LoRA nodes across ComfyUI tabs.
|
||||||
|
|
||||||
|
Each connected ComfyUI browser tab (identified by its ``sid`` / ``clientId``)
|
||||||
|
registers its own set of workflow nodes. Queries merge all known tabs into
|
||||||
|
a single result so that the calling LM panel always sees *every* available
|
||||||
|
target node, regardless of which tab responded fastest.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._nodes: Dict[str, dict] = {}
|
# sid → {unique_id → node_info}
|
||||||
self._registry_updated = asyncio.Event()
|
self._tab_nodes: Dict[str, Dict[str, dict]] = {}
|
||||||
|
self._ready = asyncio.Event()
|
||||||
|
self._waiting_clients: set[str] = set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_client_count(self) -> int:
|
||||||
|
"""Number of clients that have not yet responded in the current refresh cycle."""
|
||||||
|
return len(self._waiting_clients)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers to build one node dict (extracted so it's reused for each tab)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _build_node_dict(node: dict) -> dict:
|
||||||
|
node_id = node["node_id"]
|
||||||
|
graph_id = str(node["graph_id"])
|
||||||
|
unique_id = f"{graph_id}:{node_id}"
|
||||||
|
node_type = node.get("type", "")
|
||||||
|
type_id = NODE_TYPES.get(node_type, 0)
|
||||||
|
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
||||||
|
|
||||||
|
raw_capabilities = node.get("capabilities")
|
||||||
|
capabilities: dict = {}
|
||||||
|
if isinstance(raw_capabilities, dict):
|
||||||
|
capabilities = dict(raw_capabilities)
|
||||||
|
|
||||||
|
raw_widget_names: list | None = node.get("widget_names")
|
||||||
|
if not isinstance(raw_widget_names, list):
|
||||||
|
capability_widget_names = capabilities.get("widget_names")
|
||||||
|
raw_widget_names = (
|
||||||
|
capability_widget_names
|
||||||
|
if isinstance(capability_widget_names, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
widget_names: list[str] = []
|
||||||
|
if isinstance(raw_widget_names, list):
|
||||||
|
widget_names = [
|
||||||
|
str(widget_name)
|
||||||
|
for widget_name in raw_widget_names
|
||||||
|
if isinstance(widget_name, str) and widget_name
|
||||||
|
]
|
||||||
|
|
||||||
|
if widget_names:
|
||||||
|
capabilities["widget_names"] = widget_names
|
||||||
|
else:
|
||||||
|
capabilities.pop("widget_names", None)
|
||||||
|
|
||||||
|
if "supports_lora" in capabilities:
|
||||||
|
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
||||||
|
|
||||||
|
comfy_class = node.get("comfy_class")
|
||||||
|
if not isinstance(comfy_class, str) or not comfy_class:
|
||||||
|
comfy_class = node_type if isinstance(node_type, str) else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": node_id,
|
||||||
|
"graph_id": graph_id,
|
||||||
|
"graph_name": node.get("graph_name"),
|
||||||
|
"unique_id": unique_id,
|
||||||
|
"bgcolor": bgcolor,
|
||||||
|
"title": node.get("title"),
|
||||||
|
"type": type_id,
|
||||||
|
"type_name": node_type,
|
||||||
|
"comfy_class": comfy_class,
|
||||||
|
"capabilities": capabilities,
|
||||||
|
"widget_names": widget_names,
|
||||||
|
"mode": node.get("mode"),
|
||||||
|
"marker_role": node.get("marker_role"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def register_nodes(self, sid: str, nodes: list[dict]) -> None:
|
||||||
|
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
|
||||||
|
tab_nodes: dict[str, dict] = {}
|
||||||
|
for node in nodes:
|
||||||
|
nd = self._build_node_dict(node)
|
||||||
|
tab_nodes[nd["unique_id"]] = nd
|
||||||
|
|
||||||
async def register_nodes(self, nodes: list[dict]) -> None:
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._nodes.clear()
|
self._tab_nodes[sid] = tab_nodes
|
||||||
for node in nodes:
|
self._waiting_clients.discard(sid)
|
||||||
node_id = node["node_id"]
|
if not self._waiting_clients:
|
||||||
graph_id = str(node["graph_id"])
|
self._ready.set()
|
||||||
unique_id = f"{graph_id}:{node_id}"
|
|
||||||
node_type = node.get("type", "")
|
|
||||||
type_id = NODE_TYPES.get(node_type, 0)
|
|
||||||
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
|
||||||
raw_capabilities = node.get("capabilities")
|
|
||||||
capabilities: dict = {}
|
|
||||||
if isinstance(raw_capabilities, dict):
|
|
||||||
capabilities = dict(raw_capabilities)
|
|
||||||
|
|
||||||
raw_widget_names: list | None = node.get("widget_names")
|
logger.debug("Registered %s nodes from client %s", len(nodes), sid)
|
||||||
if not isinstance(raw_widget_names, list):
|
|
||||||
capability_widget_names = capabilities.get("widget_names")
|
|
||||||
raw_widget_names = (
|
|
||||||
capability_widget_names
|
|
||||||
if isinstance(capability_widget_names, list)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
widget_names: list[str] = []
|
def prepare_for_refresh(self, active_sids: list[str]) -> None:
|
||||||
if isinstance(raw_widget_names, list):
|
"""Set the list of client IDs we expect to hear from during the next refresh cycle."""
|
||||||
widget_names = [
|
self._ready.clear()
|
||||||
str(widget_name)
|
self._waiting_clients = set(active_sids)
|
||||||
for widget_name in raw_widget_names
|
|
||||||
if isinstance(widget_name, str) and widget_name
|
|
||||||
]
|
|
||||||
|
|
||||||
if widget_names:
|
async def wait_for_all(self, timeout: float = 2.0) -> bool:
|
||||||
capabilities["widget_names"] = widget_names
|
"""Block until every client in the current waiting set has responded
|
||||||
else:
|
(or *timeout* seconds elapse). Returns ``True`` if all responded."""
|
||||||
capabilities.pop("widget_names", None)
|
if not self._waiting_clients:
|
||||||
|
return True
|
||||||
if "supports_lora" in capabilities:
|
|
||||||
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
|
|
||||||
|
|
||||||
comfy_class = node.get("comfy_class")
|
|
||||||
if not isinstance(comfy_class, str) or not comfy_class:
|
|
||||||
comfy_class = node_type if isinstance(node_type, str) else None
|
|
||||||
|
|
||||||
self._nodes[unique_id] = {
|
|
||||||
"id": node_id,
|
|
||||||
"graph_id": graph_id,
|
|
||||||
"graph_name": node.get("graph_name"),
|
|
||||||
"unique_id": unique_id,
|
|
||||||
"bgcolor": bgcolor,
|
|
||||||
"title": node.get("title"),
|
|
||||||
"type": type_id,
|
|
||||||
"type_name": node_type,
|
|
||||||
"comfy_class": comfy_class,
|
|
||||||
"capabilities": capabilities,
|
|
||||||
"widget_names": widget_names,
|
|
||||||
"mode": node.get("mode"),
|
|
||||||
"marker_role": node.get("marker_role"),
|
|
||||||
}
|
|
||||||
logger.debug("Registered %s nodes in registry", len(nodes))
|
|
||||||
self._registry_updated.set()
|
|
||||||
|
|
||||||
async def get_registry(self) -> dict:
|
|
||||||
async with self._lock:
|
|
||||||
return {
|
|
||||||
"nodes": dict(self._nodes),
|
|
||||||
"node_count": len(self._nodes),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def wait_for_update(self, timeout: float = 1.0) -> bool:
|
|
||||||
self._registry_updated.clear()
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._registry_updated.wait(), timeout=timeout)
|
await asyncio.wait_for(self._ready.wait(), timeout=timeout)
|
||||||
return True
|
return True
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict:
|
||||||
|
"""Return the union of all known tab nodes, pruning any tab that is no
|
||||||
|
longer connected."""
|
||||||
|
async with self._lock:
|
||||||
|
# Garbage-collect stale entries (disconnected tabs)
|
||||||
|
if active_sids is not None:
|
||||||
|
for sid in list(self._tab_nodes):
|
||||||
|
if sid not in active_sids:
|
||||||
|
del self._tab_nodes[sid]
|
||||||
|
|
||||||
|
merged: dict[str, dict] = {}
|
||||||
|
tab_info: dict[str, dict] = {}
|
||||||
|
for sid, nodes in self._tab_nodes.items():
|
||||||
|
tab_info[sid] = {
|
||||||
|
"node_count": len(nodes),
|
||||||
|
"graph_names": list(
|
||||||
|
{
|
||||||
|
n.get("graph_name")
|
||||||
|
for n in nodes.values()
|
||||||
|
if n.get("graph_name")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
merged.update(nodes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodes": merged,
|
||||||
|
"node_count": len(merged),
|
||||||
|
"tab_count": len(self._tab_nodes),
|
||||||
|
"tabs": tab_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HealthCheckHandler:
|
class HealthCheckHandler:
|
||||||
async def health_check(self, request: web.Request) -> web.Response:
|
async def health_check(self, request: web.Request) -> web.Response:
|
||||||
@@ -2995,10 +3061,21 @@ class NodeRegistryHandler:
|
|||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
nodes = data.get("nodes", [])
|
nodes = data.get("nodes", [])
|
||||||
|
client_id = data.get("client_id")
|
||||||
if not isinstance(nodes, list):
|
if not isinstance(nodes, list):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "nodes must be a list"}, status=400
|
{"success": False, "error": "nodes must be a list"}, status=400
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not isinstance(client_id, str) or not client_id:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Missing client_id parameter",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
for index, node in enumerate(nodes):
|
for index, node in enumerate(nodes):
|
||||||
if not isinstance(node, dict):
|
if not isinstance(node, dict):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3042,7 +3119,7 @@ class NodeRegistryHandler:
|
|||||||
else:
|
else:
|
||||||
node["graph_name"] = str(graph_name)
|
node["graph_name"] = str(graph_name)
|
||||||
|
|
||||||
await self._node_registry.register_nodes(nodes)
|
await self._node_registry.register_nodes(client_id, nodes)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -3066,9 +3143,15 @@ class NodeRegistryHandler:
|
|||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Snapshot of currently-connected ComfyUI tabs
|
||||||
|
active_sids = list(self._prompt_server.instance.sockets.keys())
|
||||||
|
self._node_registry.prepare_for_refresh(active_sids)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
self._prompt_server.instance.send_sync("lora_registry_refresh", {})
|
||||||
logger.debug("Sent registry refresh request to frontend")
|
logger.debug(
|
||||||
|
"Sent registry refresh request (expecting %s clients)", len(active_sids)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to send registry refresh message: %s", exc)
|
logger.error("Failed to send registry refresh message: %s", exc)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3080,19 +3163,31 @@ class NodeRegistryHandler:
|
|||||||
status=500,
|
status=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
registry_updated = await self._node_registry.wait_for_update(timeout=1.0)
|
if not await self._node_registry.wait_for_all(timeout=2.0):
|
||||||
if not registry_updated:
|
logger.warning(
|
||||||
logger.warning("Registry refresh timeout after 1 second")
|
"Registry refresh timeout after 2s (%s/%s clients responded)",
|
||||||
|
len(active_sids) - self._node_registry.pending_client_count,
|
||||||
|
len(active_sids),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-read current sockets after the wait: a tab may have connected
|
||||||
|
# while we were waiting, and we don't want to garbage-collect it.
|
||||||
|
current_sids = set(self._prompt_server.instance.sockets.keys())
|
||||||
|
registry_info = await self._node_registry.get_merged_registry(
|
||||||
|
active_sids=current_sids
|
||||||
|
)
|
||||||
|
|
||||||
|
if registry_info["node_count"] == 0:
|
||||||
|
logger.warning("No nodes registered after refresh")
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "Timeout Error",
|
"error": "Empty Registry",
|
||||||
"message": "Registry refresh timeout - ComfyUI frontend may not be responsive",
|
"message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
|
||||||
},
|
},
|
||||||
status=408,
|
status=408,
|
||||||
)
|
)
|
||||||
|
|
||||||
registry_info = await self._node_registry.get_registry()
|
|
||||||
return web.json_response({"success": True, "data": registry_info})
|
return web.json_response({"success": True, "data": registry_info})
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error("Failed to get registry: %s", exc, exc_info=True)
|
logger.error("Failed to get registry: %s", exc, exc_info=True)
|
||||||
@@ -3221,6 +3316,7 @@ class MiscHandlerSet:
|
|||||||
doctor: DoctorHandler,
|
doctor: DoctorHandler,
|
||||||
example_workflows: ExampleWorkflowsHandler,
|
example_workflows: ExampleWorkflowsHandler,
|
||||||
base_model: BaseModelHandlerSet,
|
base_model: BaseModelHandlerSet,
|
||||||
|
hf_handler: HfHandler | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.health = health
|
self.health = health
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -3239,6 +3335,7 @@ class MiscHandlerSet:
|
|||||||
self.doctor = doctor
|
self.doctor = doctor
|
||||||
self.example_workflows = example_workflows
|
self.example_workflows = example_workflows
|
||||||
self.base_model = base_model
|
self.base_model = base_model
|
||||||
|
self.hf_handler = hf_handler
|
||||||
|
|
||||||
def to_route_mapping(
|
def to_route_mapping(
|
||||||
self,
|
self,
|
||||||
@@ -3284,6 +3381,9 @@ class MiscHandlerSet:
|
|||||||
"get_supporters": self.supporters.get_supporters,
|
"get_supporters": self.supporters.get_supporters,
|
||||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
"get_example_workflow": self.example_workflows.get_example_workflow,
|
||||||
|
# Hugging Face handlers
|
||||||
|
"get_hf_repo_files": self.hf_handler.get_hf_repo_files,
|
||||||
|
"download_hf_model": self.hf_handler.download_hf_model,
|
||||||
# Base model handlers
|
# Base model handlers
|
||||||
"get_base_models": self.base_model.get_base_models,
|
"get_base_models": self.base_model.get_base_models,
|
||||||
"refresh_base_models": self.base_model.refresh_base_models,
|
"refresh_base_models": self.base_model.refresh_base_models,
|
||||||
|
|||||||
@@ -203,11 +203,17 @@ class ModelListingHandler:
|
|||||||
result = await self._service.get_paginated_data(**params)
|
result = await self._service.get_paginated_data(**params)
|
||||||
|
|
||||||
format_start = time.perf_counter()
|
format_start = time.perf_counter()
|
||||||
|
formatted_raw = [
|
||||||
|
await self._service.format_response(entry)
|
||||||
|
for entry in result["items"]
|
||||||
|
]
|
||||||
|
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||||
|
# Note: "total" intentionally remains the pre-filter count to reflect
|
||||||
|
# the true number of models in the cache; corrupted entries are rare
|
||||||
|
# and adjusting total would cause pagination drift on every page.
|
||||||
|
formatted_items = [item for item in formatted_raw if item is not None]
|
||||||
formatted_result = {
|
formatted_result = {
|
||||||
"items": [
|
"items": formatted_items,
|
||||||
await self._service.format_response(item)
|
|
||||||
for item in result["items"]
|
|
||||||
],
|
|
||||||
"total": result["total"],
|
"total": result["total"],
|
||||||
"page": result["page"],
|
"page": result["page"],
|
||||||
"page_size": result["page_size"],
|
"page_size": result["page_size"],
|
||||||
@@ -238,11 +244,15 @@ class ModelListingHandler:
|
|||||||
result = await self._service.get_excluded_paginated_data(**params)
|
result = await self._service.get_excluded_paginated_data(**params)
|
||||||
|
|
||||||
format_start = time.perf_counter()
|
format_start = time.perf_counter()
|
||||||
|
formatted_raw = [
|
||||||
|
await self._service.format_response(entry)
|
||||||
|
for entry in result["items"]
|
||||||
|
]
|
||||||
|
# Filter out None entries returned for corrupted cache rows (issue #730).
|
||||||
|
# "total" stays at the pre-filter count; see get_models for rationale.
|
||||||
|
formatted_items = [item for item in formatted_raw if item is not None]
|
||||||
formatted_result = {
|
formatted_result = {
|
||||||
"items": [
|
"items": formatted_items,
|
||||||
await self._service.format_response(item)
|
|
||||||
for item in result["items"]
|
|
||||||
],
|
|
||||||
"total": result["total"],
|
"total": result["total"],
|
||||||
"page": result["page"],
|
"page": result["page"],
|
||||||
"page_size": result["page_size"],
|
"page_size": result["page_size"],
|
||||||
@@ -533,8 +543,13 @@ class ModelManagementHandler:
|
|||||||
if not success:
|
if not success:
|
||||||
return web.json_response({"success": False, "error": error})
|
return web.json_response({"success": False, "error": error})
|
||||||
|
|
||||||
formatted_metadata = await self._service.format_response(model_data)
|
formatted = await self._service.format_response(model_data)
|
||||||
return web.json_response({"success": True, "metadata": formatted_metadata})
|
if formatted is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Model entry is corrupted (missing file_path)"},
|
||||||
|
status=500,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": True, "metadata": formatted})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if is_expected_offline_error(str(exc)):
|
if is_expected_offline_error(str(exc)):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -1091,10 +1106,12 @@ class ModelQueryHandler:
|
|||||||
# Sort: originals first, copies last
|
# Sort: originals first, copies last
|
||||||
sorted_models = self._sort_duplicate_group(filtered)
|
sorted_models = self._sort_duplicate_group(filtered)
|
||||||
|
|
||||||
# Format response
|
# Format response, filtering out corrupted entries (issue #730)
|
||||||
group = {"hash": sha256, "models": []}
|
group = {"hash": sha256, "models": []}
|
||||||
for model in sorted_models:
|
for model in sorted_models:
|
||||||
group["models"].append(await self._service.format_response(model))
|
formatted = await self._service.format_response(model)
|
||||||
|
if formatted is not None:
|
||||||
|
group["models"].append(formatted)
|
||||||
|
|
||||||
# Only include groups with 2+ models after filtering
|
# Only include groups with 2+ models after filtering
|
||||||
if len(group["models"]) > 1:
|
if len(group["models"]) > 1:
|
||||||
@@ -1211,9 +1228,9 @@ class ModelQueryHandler:
|
|||||||
(m for m in cache.raw_data if m["file_path"] == path), None
|
(m for m in cache.raw_data if m["file_path"] == path), None
|
||||||
)
|
)
|
||||||
if model:
|
if model:
|
||||||
group["models"].append(
|
formatted = await self._service.format_response(model)
|
||||||
await self._service.format_response(model)
|
if formatted is not None:
|
||||||
)
|
group["models"].append(formatted)
|
||||||
hash_val = self._service.scanner.get_hash_by_filename(filename)
|
hash_val = self._service.scanner.get_hash_by_filename(filename)
|
||||||
if hash_val:
|
if hash_val:
|
||||||
main_path = self._service.get_path_by_hash(hash_val)
|
main_path = self._service.get_path_by_hash(hash_val)
|
||||||
@@ -1223,9 +1240,9 @@ class ModelQueryHandler:
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
if main_model:
|
if main_model:
|
||||||
group["models"].insert(
|
formatted = await self._service.format_response(main_model)
|
||||||
0, await self._service.format_response(main_model)
|
if formatted is not None:
|
||||||
)
|
group["models"].insert(0, formatted)
|
||||||
if group["models"]:
|
if group["models"]:
|
||||||
result.append(group)
|
result.append(group)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from ...utils.civitai_utils import (
|
|||||||
extract_civitai_image_id_from_cdn_url,
|
extract_civitai_image_id_from_cdn_url,
|
||||||
rewrite_preview_url,
|
rewrite_preview_url,
|
||||||
)
|
)
|
||||||
|
from ...utils.constants import NSFW_LEVELS
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
from ...recipes.enrichment import RecipeEnricher
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
@@ -1120,6 +1121,13 @@ class RecipeManagementHandler:
|
|||||||
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||||
metadata["base_model"] = parsed_embedded["base_model"]
|
metadata["base_model"] = parsed_embedded["base_model"]
|
||||||
|
|
||||||
|
# Extract preview_nsfw_level from the CivitAI API response
|
||||||
|
# (injected into civitai_meta_raw by _download_remote_media).
|
||||||
|
if isinstance(civitai_meta_raw, dict):
|
||||||
|
bl = civitai_meta_raw.get("browsingLevel")
|
||||||
|
if isinstance(bl, int) and bl > 0:
|
||||||
|
metadata["preview_nsfw_level"] = bl
|
||||||
|
|
||||||
civitai_client = self._civitai_client_getter()
|
civitai_client = self._civitai_client_getter()
|
||||||
await RecipeEnricher.enrich_recipe(
|
await RecipeEnricher.enrich_recipe(
|
||||||
recipe=metadata,
|
recipe=metadata,
|
||||||
@@ -1515,8 +1523,31 @@ class RecipeManagementHandler:
|
|||||||
# CivitAI API returns modelVersionIds at the root level of
|
# CivitAI API returns modelVersionIds at the root level of
|
||||||
# the image response, NOT inside the meta object.
|
# the image response, NOT inside the meta object.
|
||||||
mvids = image_info.get("modelVersionIds")
|
mvids = image_info.get("modelVersionIds")
|
||||||
if mvids and isinstance(civitai_meta_raw, dict):
|
if mvids:
|
||||||
civitai_meta_raw["modelVersionIds"] = mvids
|
if isinstance(civitai_meta_raw, dict):
|
||||||
|
civitai_meta_raw["modelVersionIds"] = mvids
|
||||||
|
else:
|
||||||
|
# meta is null but modelVersionIds exists — create a
|
||||||
|
# minimal dict so downstream parsers can discover
|
||||||
|
# LoRAs and checkpoints from the API response.
|
||||||
|
civitai_meta_raw = {"modelVersionIds": mvids}
|
||||||
|
|
||||||
|
# Inject browsingLevel (canonical integer) so the recipe's
|
||||||
|
# preview_nsfw_level can be set, enabling proper NSFW blur
|
||||||
|
# of the preview image. Fall back to nsfwLevel (string)
|
||||||
|
# when browsingLevel is absent.
|
||||||
|
if isinstance(civitai_meta_raw, dict):
|
||||||
|
browsing_level = image_info.get("browsingLevel")
|
||||||
|
nsfw_level_str = image_info.get("nsfwLevel")
|
||||||
|
if isinstance(browsing_level, int) and browsing_level > 0:
|
||||||
|
civitai_meta_raw["browsingLevel"] = browsing_level
|
||||||
|
elif (
|
||||||
|
isinstance(nsfw_level_str, str)
|
||||||
|
and nsfw_level_str in NSFW_LEVELS
|
||||||
|
):
|
||||||
|
civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[
|
||||||
|
nsfw_level_str
|
||||||
|
]
|
||||||
|
|
||||||
original_url = (
|
original_url = (
|
||||||
image_info.get("url") if civitai_image_id and image_info else None
|
image_info.get("url") if civitai_image_id and image_info else None
|
||||||
@@ -1796,6 +1827,13 @@ class RecipeManagementHandler:
|
|||||||
"source_path": image_url,
|
"source_path": image_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Extract preview_nsfw_level from the CivitAI API response
|
||||||
|
# (injected into civitai_meta_raw by _download_remote_media).
|
||||||
|
if isinstance(civitai_meta_raw, dict):
|
||||||
|
bl = civitai_meta_raw.get("browsingLevel")
|
||||||
|
if isinstance(bl, int) and bl > 0:
|
||||||
|
metadata["preview_nsfw_level"] = bl
|
||||||
|
|
||||||
if civitai_parsed:
|
if civitai_parsed:
|
||||||
civitai_loras = civitai_parsed.get("loras", [])
|
civitai_loras = civitai_parsed.get("loras", [])
|
||||||
if civitai_loras and not metadata.get("loras"):
|
if civitai_loras and not metadata.get("loras"):
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
"GET", "/api/lm/delete-model-version", "delete_model_version"
|
||||||
),
|
),
|
||||||
|
# Hugging Face model endpoints
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||||
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from .handlers.misc_handlers import (
|
|||||||
build_service_registry_adapter,
|
build_service_registry_adapter,
|
||||||
)
|
)
|
||||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||||
|
from .handlers.hf_handlers import HfHandler
|
||||||
from .misc_route_registrar import MiscRouteRegistrar
|
from .misc_route_registrar import MiscRouteRegistrar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -136,6 +137,7 @@ class MiscRoutes:
|
|||||||
doctor = DoctorHandler(settings_service=self._settings)
|
doctor = DoctorHandler(settings_service=self._settings)
|
||||||
example_workflows = ExampleWorkflowsHandler()
|
example_workflows = ExampleWorkflowsHandler()
|
||||||
base_model = BaseModelHandlerSet()
|
base_model = BaseModelHandlerSet()
|
||||||
|
hf_handler = HfHandler()
|
||||||
|
|
||||||
return self._handler_set_factory(
|
return self._handler_set_factory(
|
||||||
health=health,
|
health=health,
|
||||||
@@ -155,6 +157,7 @@ class MiscRoutes:
|
|||||||
doctor=doctor,
|
doctor=doctor,
|
||||||
example_workflows=example_workflows,
|
example_workflows=example_workflows,
|
||||||
base_model=base_model,
|
base_model=base_model,
|
||||||
|
hf_handler=hf_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||||
|
|
||||||
|
# User-managed directories that live inside the plugin folder (portable
|
||||||
|
# mode) and must survive a Git-based update. ``git clean -fd`` would
|
||||||
|
# otherwise delete them because they are untracked and, in released tags,
|
||||||
|
# not listed in ``.gitignore``. ``-e`` excludes a path from cleaning
|
||||||
|
# regardless of whether it is ignored.
|
||||||
|
_PRESERVE_DIRS = ('settings.json', 'civitai', 'wildcards', 'backups', 'stats', 'logs', 'cache', 'model_cache')
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_excludes() -> List[str]:
|
||||||
|
"""Build the ``-e`` arguments for ``git clean`` from :data:`_PRESERVE_DIRS`."""
|
||||||
|
excludes: List[str] = []
|
||||||
|
for name in _PRESERVE_DIRS:
|
||||||
|
excludes.append('-e')
|
||||||
|
excludes.append(name)
|
||||||
|
# For directories, also exclude nested matches explicitly
|
||||||
|
# (``-e dir`` alone matches the dir entry; ``-e dir/**`` guards
|
||||||
|
# contents under all git versions as defense-in-depth).
|
||||||
|
excludes.append('-e')
|
||||||
|
excludes.append(f'{name}/**')
|
||||||
|
return excludes
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoutes:
|
class UpdateRoutes:
|
||||||
"""Routes for handling plugin update checks"""
|
"""Routes for handling plugin update checks"""
|
||||||
@@ -365,6 +386,8 @@ class UpdateRoutes:
|
|||||||
)
|
)
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
|
clean_excludes = _clean_excludes()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Open the Git repository
|
# Open the Git repository
|
||||||
repo = git.Repo(plugin_root)
|
repo = git.Repo(plugin_root)
|
||||||
@@ -376,8 +399,9 @@ class UpdateRoutes:
|
|||||||
if nightly:
|
if nightly:
|
||||||
# Reset to discard any local changes
|
# Reset to discard any local changes
|
||||||
repo.git.reset('--hard')
|
repo.git.reset('--hard')
|
||||||
# Clean untracked files
|
# Clean untracked files, but preserve user-managed directories
|
||||||
repo.git.clean('-fd')
|
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
# Switch to main branch and pull latest
|
# Switch to main branch and pull latest
|
||||||
main_branch = 'main'
|
main_branch = 'main'
|
||||||
@@ -394,8 +418,9 @@ class UpdateRoutes:
|
|||||||
else:
|
else:
|
||||||
# Reset to discard any local changes
|
# Reset to discard any local changes
|
||||||
repo.git.reset('--hard')
|
repo.git.reset('--hard')
|
||||||
# Clean untracked files
|
# Clean untracked files, but preserve user-managed directories
|
||||||
repo.git.clean('-fd')
|
# (wildcards, backups, stats, civitai, caches, settings.json).
|
||||||
|
repo.git.clean('-fd', *clean_excludes)
|
||||||
|
|
||||||
# Get latest release tag
|
# Get latest release tag
|
||||||
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
tags = sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class Aria2Downloader:
|
|||||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||||
self._poll_interval = 0.5
|
self._poll_interval = 0.5
|
||||||
self._state_store = Aria2TransferStateStore()
|
self._state_store = Aria2TransferStateStore()
|
||||||
|
self._stderr_reader_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@@ -115,7 +116,7 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
status = await self.get_status(download_id)
|
status = await self._get_status_with_retry(download_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
return False, "aria2 download not found"
|
return False, "aria2 download not found"
|
||||||
|
|
||||||
@@ -136,6 +137,35 @@ class Aria2Downloader:
|
|||||||
finally:
|
finally:
|
||||||
self._transfers.pop(download_id, None)
|
self._transfers.pop(download_id, None)
|
||||||
|
|
||||||
|
async def _get_status_with_retry(
|
||||||
|
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Call get_status with retry for transient RPC failures.
|
||||||
|
|
||||||
|
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
|
||||||
|
``None`` immediately when the download_id is not tracked (a missing
|
||||||
|
transfer is not a transient condition, so retrying is pointless).
|
||||||
|
|
||||||
|
A single failed RPC call should not immediately fail the download,
|
||||||
|
because aria2 may be temporarily busy (e.g. finalizing multiple
|
||||||
|
concurrent downloads) and a retry will often succeed.
|
||||||
|
"""
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return await self.get_status(download_id)
|
||||||
|
except Aria2Error as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.warning(
|
||||||
|
"aria2 get_status transient failure (attempt %d/%d) for %s: %s",
|
||||||
|
attempt + 1, max_retries, download_id, exc,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_delay)
|
||||||
|
raise Aria2Error(
|
||||||
|
f"Failed to query aria2 download status after {max_retries} attempts: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
async def _schedule_download(
|
async def _schedule_download(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -312,6 +342,16 @@ class Aria2Downloader:
|
|||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Shut down the RPC process and session."""
|
"""Shut down the RPC process and session."""
|
||||||
|
|
||||||
|
# Cancel the background stderr reader first so it stops reading
|
||||||
|
# from the pipe before the subprocess is terminated.
|
||||||
|
if self._stderr_reader_task is not None:
|
||||||
|
self._stderr_reader_task.cancel()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._stderr_reader_task, timeout=2.0)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
|
self._stderr_reader_task = None
|
||||||
|
|
||||||
if self._rpc_session is not None:
|
if self._rpc_session is not None:
|
||||||
await self._rpc_session.close()
|
await self._rpc_session.close()
|
||||||
self._rpc_session = None
|
self._rpc_session = None
|
||||||
@@ -331,6 +371,23 @@ class Aria2Downloader:
|
|||||||
process.kill()
|
process.kill()
|
||||||
await process.wait()
|
await process.wait()
|
||||||
|
|
||||||
|
async def _drain_stderr(self) -> None:
|
||||||
|
"""Continuously drain aria2's stderr pipe so it never blocks.
|
||||||
|
|
||||||
|
When the 64 KB pipe buffer fills up, aria2's ``write()`` to stderr
|
||||||
|
blocks, which freezes the entire ``aria2c`` process — including its
|
||||||
|
RPC handler. This background task reads lines from stderr as they
|
||||||
|
arrive and forwards them to Python's logger.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
assert self._process is not None and self._process.stderr is not None
|
||||||
|
async for line in self._process.stderr:
|
||||||
|
text = line.decode("utf-8", errors="replace").rstrip()
|
||||||
|
if text:
|
||||||
|
logger.debug("aria2 stderr: %s", text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
||||||
try:
|
try:
|
||||||
result = callback(snapshot, snapshot)
|
result = callback(snapshot, snapshot)
|
||||||
@@ -465,6 +522,17 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
await self._wait_until_ready()
|
await self._wait_until_ready()
|
||||||
|
|
||||||
|
# Drain aria2's stderr in a background task so the pipe buffer
|
||||||
|
# never fills up. If the pipe blocks, aria2 itself freezes and
|
||||||
|
# cannot respond to RPC — this was the root cause of the
|
||||||
|
# "Failed to query aria2 download status" timeout bug.
|
||||||
|
# Must start AFTER _wait_until_ready to avoid a race where the
|
||||||
|
# drain task consumes aria2's early-exit error message before
|
||||||
|
# _wait_until_ready can read it.
|
||||||
|
self._stderr_reader_task = asyncio.create_task(
|
||||||
|
self._drain_stderr()
|
||||||
|
)
|
||||||
|
|
||||||
def _resolve_executable(self) -> str:
|
def _resolve_executable(self) -> str:
|
||||||
settings = get_settings_manager()
|
settings = get_settings_manager()
|
||||||
configured_path = (settings.get("aria2c_path") or "").strip()
|
configured_path = (settings.get("aria2c_path") or "").strip()
|
||||||
@@ -584,7 +652,9 @@ class Aria2Downloader:
|
|||||||
if self._rpc_session is None or self._rpc_session.closed:
|
if self._rpc_session is None or self._rpc_session.closed:
|
||||||
async with self._rpc_session_lock:
|
async with self._rpc_session_lock:
|
||||||
if self._rpc_session is None or self._rpc_session.closed:
|
if self._rpc_session is None or self._rpc_session.closed:
|
||||||
timeout = aiohttp.ClientTimeout(total=30)
|
timeout = aiohttp.ClientTimeout(
|
||||||
|
total=None, sock_connect=10, sock_read=60
|
||||||
|
)
|
||||||
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
|
||||||
return self._rpc_session
|
return self._rpc_session
|
||||||
|
|
||||||
|
|||||||
@@ -791,8 +791,12 @@ class BaseModelService(ABC):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def format_response(self, model_data: Dict) -> Dict:
|
async def format_response(self, model_data: Dict) -> Optional[Dict]:
|
||||||
"""Format model data for API response - must be implemented by subclasses"""
|
"""Format model data for API response - must be implemented by subclasses.
|
||||||
|
|
||||||
|
Subclasses should return None for corrupted entries so the handler
|
||||||
|
layer can filter them out. See issue #730.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Common service methods that delegate to scanner
|
# Common service methods that delegate to scanner
|
||||||
|
|||||||
@@ -523,6 +523,10 @@ class BatchImportService:
|
|||||||
if payload.get("checkpoint"):
|
if payload.get("checkpoint"):
|
||||||
metadata["checkpoint"] = payload["checkpoint"]
|
metadata["checkpoint"] = payload["checkpoint"]
|
||||||
|
|
||||||
|
nsfw = payload.get("preview_nsfw_level")
|
||||||
|
if isinstance(nsfw, int) and nsfw > 0:
|
||||||
|
metadata["preview_nsfw_level"] = nsfw
|
||||||
|
|
||||||
image_bytes = None
|
image_bytes = None
|
||||||
image_base64 = payload.get("image_base64")
|
image_base64 = payload.get("image_base64")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .auto_tag_service import extract_auto_tags
|
from .auto_tag_service import extract_auto_tags
|
||||||
@@ -21,20 +21,37 @@ class CheckpointService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
|
||||||
"""Format Checkpoint data for API response"""
|
"""Format Checkpoint data for API response.
|
||||||
|
|
||||||
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
|
row), so the handler layer can filter it out. See issue #730.
|
||||||
|
"""
|
||||||
|
# Guard against corrupted cache entries missing critical fields
|
||||||
|
file_path = checkpoint_data.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping corrupted checkpoint entry (missing file_path): %s",
|
||||||
|
checkpoint_data.get("file_name", "<unknown>"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# Get sub_type from cache entry (new canonical field)
|
# Get sub_type from cache entry (new canonical field)
|
||||||
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
||||||
|
|
||||||
|
file_name = checkpoint_data.get("file_name") or ""
|
||||||
|
model_name = checkpoint_data.get("model_name") or file_name
|
||||||
|
folder = checkpoint_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": checkpoint_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": checkpoint_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
||||||
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": checkpoint_data.get("base_model", ""),
|
"base_model": checkpoint_data.get("base_model", ""),
|
||||||
"folder": checkpoint_data["folder"],
|
"folder": folder,
|
||||||
"sha256": checkpoint_data.get("sha256", ""),
|
"sha256": checkpoint_data.get("sha256", ""),
|
||||||
"file_path": checkpoint_data["file_path"].replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": checkpoint_data.get("size", 0),
|
"file_size": checkpoint_data.get("size", 0),
|
||||||
"modified": checkpoint_data.get("modified", ""),
|
"modified": checkpoint_data.get("modified", ""),
|
||||||
"tags": checkpoint_data.get("tags", []),
|
"tags": checkpoint_data.get("tags", []),
|
||||||
@@ -49,6 +66,7 @@ class CheckpointService(BaseModelService):
|
|||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
||||||
"version_count": checkpoint_data.get("version_count"),
|
"version_count": checkpoint_data.get("version_count"),
|
||||||
|
"hf_url": checkpoint_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ class CivArchiveClient:
|
|||||||
if resolved:
|
if resolved:
|
||||||
return resolved, None
|
return resolved, None
|
||||||
|
|
||||||
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
logger.debug("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||||
return None, "No version data found"
|
return None, "No version data found"
|
||||||
|
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
@@ -417,7 +417,7 @@ class CivArchiveClient:
|
|||||||
|
|
||||||
if version_id is not None:
|
if version_id is not None:
|
||||||
raw_id = version_data.get("id")
|
raw_id = version_data.get("id")
|
||||||
if raw_id != version_id:
|
if raw_id is not None and str(raw_id) != str(version_id):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Requested version %s doesn't match default version %s for model %s",
|
"Requested version %s doesn't match default version %s for model %s",
|
||||||
version_id,
|
version_id,
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ class CivitaiBaseModelService:
|
|||||||
"ernie": "ERNI",
|
"ernie": "ERNI",
|
||||||
"ernie turbo": "ETRB",
|
"ernie turbo": "ETRB",
|
||||||
"nucleus": "NUCL",
|
"nucleus": "NUCL",
|
||||||
|
"krea 2": "KR2",
|
||||||
"svd": "SVD",
|
"svd": "SVD",
|
||||||
"ltxv": "LTXV",
|
"ltxv": "LTXV",
|
||||||
"ltxv2": "LTV2",
|
"ltxv2": "LTV2",
|
||||||
@@ -424,6 +425,7 @@ class CivitaiBaseModelService:
|
|||||||
"Ernie",
|
"Ernie",
|
||||||
"Ernie Turbo",
|
"Ernie Turbo",
|
||||||
"Nucleus",
|
"Nucleus",
|
||||||
|
"Krea 2",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class CivitaiClient:
|
|||||||
self._MAX_CACHE_ENTRIES = 500
|
self._MAX_CACHE_ENTRIES = 500
|
||||||
|
|
||||||
def _build_image_info_url(self, image_id: str) -> str:
|
def _build_image_info_url(self, image_id: str) -> str:
|
||||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
|
||||||
|
|
||||||
async def _make_request(
|
async def _make_request(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1288,10 +1288,24 @@ class DownloadManager:
|
|||||||
"download_id": download_id,
|
"download_id": download_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if this checkpoint should be treated as a diffusion model based on baseModel
|
# Check if this checkpoint should be treated as a diffusion model
|
||||||
|
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
||||||
|
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
||||||
is_diffusion_model = False
|
is_diffusion_model = False
|
||||||
if model_type == "checkpoint":
|
if model_type == "checkpoint":
|
||||||
if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
# Check file types first (more direct signal from CivitAI)
|
||||||
|
version_files = version_info.get("files", [])
|
||||||
|
for f in version_files:
|
||||||
|
f_type = f.get("type", "")
|
||||||
|
if f_type in ("UNet", "Diffusion Model"):
|
||||||
|
is_diffusion_model = True
|
||||||
|
logger.info(
|
||||||
|
f"File type '{f_type}' detected, routing checkpoint to unet folder"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback to baseModel name check
|
||||||
|
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||||
is_diffusion_model = True
|
is_diffusion_model = True
|
||||||
logger.info(
|
logger.info(
|
||||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||||
@@ -1420,7 +1434,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", "Diffusion Model")
|
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1451,7 +1465,7 @@ class DownloadManager:
|
|||||||
(
|
(
|
||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
|
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -2029,7 +2043,21 @@ class DownloadManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
last_error = result
|
last_error = result
|
||||||
if os.path.exists(save_path):
|
# For aria2: if the .aria2 control file is missing, aria2 considers
|
||||||
|
# the download complete. A transient RPC failure may have made us
|
||||||
|
# think the download failed even though the file is fully on disk.
|
||||||
|
# Keep the file so a retry can find it already complete.
|
||||||
|
if (
|
||||||
|
transfer_backend == "aria2"
|
||||||
|
and os.path.exists(save_path)
|
||||||
|
and not os.path.exists(f"{save_path}.aria2")
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"aria2 download reported failure but .aria2 file is absent "
|
||||||
|
"for %s — the file is likely complete. Preserving it for retry.",
|
||||||
|
save_path,
|
||||||
|
)
|
||||||
|
elif os.path.exists(save_path):
|
||||||
try:
|
try:
|
||||||
os.remove(save_path)
|
os.remove(save_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .auto_tag_service import extract_auto_tags
|
from .auto_tag_service import extract_auto_tags
|
||||||
@@ -21,20 +21,37 @@ class EmbeddingService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
|
||||||
"""Format Embedding data for API response"""
|
"""Format Embedding data for API response.
|
||||||
|
|
||||||
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
|
row), so the handler layer can filter it out. See issue #730.
|
||||||
|
"""
|
||||||
|
# Guard against corrupted cache entries missing critical fields
|
||||||
|
file_path = embedding_data.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping corrupted embedding entry (missing file_path): %s",
|
||||||
|
embedding_data.get("file_name", "<unknown>"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# Get sub_type from cache entry (new canonical field)
|
# Get sub_type from cache entry (new canonical field)
|
||||||
sub_type = embedding_data.get("sub_type", "embedding")
|
sub_type = embedding_data.get("sub_type", "embedding")
|
||||||
|
|
||||||
|
file_name = embedding_data.get("file_name") or ""
|
||||||
|
model_name = embedding_data.get("model_name") or file_name
|
||||||
|
folder = embedding_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": embedding_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": embedding_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
||||||
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": embedding_data.get("base_model", ""),
|
"base_model": embedding_data.get("base_model", ""),
|
||||||
"folder": embedding_data["folder"],
|
"folder": folder,
|
||||||
"sha256": embedding_data.get("sha256", ""),
|
"sha256": embedding_data.get("sha256", ""),
|
||||||
"file_path": embedding_data["file_path"].replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": embedding_data.get("size", 0),
|
"file_size": embedding_data.get("size", 0),
|
||||||
"modified": embedding_data.get("modified", ""),
|
"modified": embedding_data.get("modified", ""),
|
||||||
"tags": embedding_data.get("tags", []),
|
"tags": embedding_data.get("tags", []),
|
||||||
@@ -49,6 +66,7 @@ class EmbeddingService(BaseModelService):
|
|||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
||||||
"version_count": embedding_data.get("version_count"),
|
"version_count": embedding_data.get("version_count"),
|
||||||
|
"hf_url": embedding_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict:
|
||||||
|
|||||||
@@ -24,23 +24,41 @@ class LoraService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, lora_data: Dict) -> Dict:
|
async def format_response(self, lora_data: Dict) -> Optional[Dict]:
|
||||||
"""Format LoRA data for API response"""
|
"""Format LoRA data for API response.
|
||||||
|
|
||||||
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
|
row), so the handler layer can filter it out instead of crashing the
|
||||||
|
whole listing request. See issue #730.
|
||||||
|
"""
|
||||||
|
# Guard against corrupted cache entries missing critical fields
|
||||||
|
file_path = lora_data.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping corrupted LoRA entry (missing file_path): %s",
|
||||||
|
lora_data.get("file_name", "<unknown>"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||||
# Normalize to lowercase for consistent API responses
|
# Normalize to lowercase for consistent API responses
|
||||||
sub_type = resolve_sub_type(lora_data).lower()
|
sub_type = resolve_sub_type(lora_data).lower()
|
||||||
|
|
||||||
|
file_name = lora_data.get("file_name") or ""
|
||||||
|
model_name = lora_data.get("model_name") or file_name
|
||||||
|
folder = lora_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": lora_data["model_name"],
|
"model_name": model_name,
|
||||||
"file_name": lora_data["file_name"],
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(
|
"preview_url": config.get_preview_static_url(
|
||||||
lora_data.get("preview_url", "")
|
lora_data.get("preview_url", "")
|
||||||
),
|
),
|
||||||
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": lora_data.get("base_model", ""),
|
"base_model": lora_data.get("base_model", ""),
|
||||||
"folder": lora_data["folder"],
|
"folder": folder,
|
||||||
"sha256": lora_data.get("sha256", ""),
|
"sha256": lora_data.get("sha256", ""),
|
||||||
"file_path": lora_data["file_path"].replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": lora_data.get("size", 0),
|
"file_size": lora_data.get("size", 0),
|
||||||
"modified": lora_data.get("modified", ""),
|
"modified": lora_data.get("modified", ""),
|
||||||
"tags": lora_data.get("tags", []),
|
"tags": lora_data.get("tags", []),
|
||||||
@@ -60,6 +78,7 @@ class LoraService(BaseModelService):
|
|||||||
),
|
),
|
||||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
||||||
"version_count": lora_data.get("version_count"),
|
"version_count": lora_data.get("version_count"),
|
||||||
|
"hf_url": lora_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ class ModelScanner:
|
|||||||
'civitai': civitai_slim,
|
'civitai': civitai_slim,
|
||||||
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
'civitai_deleted': bool(get_value('civitai_deleted', False)),
|
||||||
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
|
||||||
|
'hf_url': get_value('hf_url', '') or '',
|
||||||
}
|
}
|
||||||
|
|
||||||
license_source: Dict[str, Any] = {}
|
license_source: Dict[str, Any] = {}
|
||||||
@@ -476,11 +477,20 @@ class ModelScanner:
|
|||||||
for tag in adjusted_item.get('tags') or []:
|
for tag in adjusted_item.get('tags') or []:
|
||||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
# Validate cache entries and check health
|
# Validate cache entries and check health.
|
||||||
|
# Always use the validated/repaired entries — even when there are no
|
||||||
|
# invalid entries, auto_repair may have filled in missing optional
|
||||||
|
# fields (model_name, file_name, folder) with safe defaults on a copied
|
||||||
|
# working_entry. Without this unconditional replacement the repaired
|
||||||
|
# copies are discarded and None values propagate to format_response.
|
||||||
|
# See issue #730.
|
||||||
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||||
adjusted_raw_data, auto_repair=True
|
adjusted_raw_data, auto_repair=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Always use the validated entries (repaired copies)
|
||||||
|
adjusted_raw_data = valid_entries
|
||||||
|
|
||||||
if invalid_entries:
|
if invalid_entries:
|
||||||
monitor = CacheHealthMonitor()
|
monitor = CacheHealthMonitor()
|
||||||
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
||||||
|
|||||||
@@ -724,6 +724,16 @@ class ModelUpdateService:
|
|||||||
"Refreshing update metadata for %d %s models", total_models, model_type
|
"Refreshing update metadata for %d %s models", total_models, model_type
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# When filtering by folder, also collect the cross-folder version set
|
||||||
|
# so that versions already present in other folders are not reported
|
||||||
|
# as available updates. See issue #997.
|
||||||
|
all_local_versions: Optional[Dict[int, List[int]]] = None
|
||||||
|
if folder_path is not None:
|
||||||
|
all_local_versions = await self._collect_local_versions(
|
||||||
|
scanner,
|
||||||
|
target_model_ids=target_filter,
|
||||||
|
)
|
||||||
|
|
||||||
results: Dict[int, ModelUpdateRecord] = {}
|
results: Dict[int, ModelUpdateRecord] = {}
|
||||||
prefetched: Dict[int, Mapping] = {}
|
prefetched: Dict[int, Mapping] = {}
|
||||||
|
|
||||||
@@ -762,6 +772,12 @@ class ModelUpdateService:
|
|||||||
for index, (model_id, version_ids) in enumerate(
|
for index, (model_id, version_ids) in enumerate(
|
||||||
local_versions.items(), start=1
|
local_versions.items(), start=1
|
||||||
):
|
):
|
||||||
|
# Use cross-folder version IDs for is_in_library if available
|
||||||
|
all_vids: Sequence[int] = (
|
||||||
|
all_local_versions.get(model_id, [])
|
||||||
|
if all_local_versions is not None
|
||||||
|
else version_ids
|
||||||
|
)
|
||||||
record = await self._refresh_single_model(
|
record = await self._refresh_single_model(
|
||||||
model_type,
|
model_type,
|
||||||
model_id,
|
model_id,
|
||||||
@@ -769,6 +785,7 @@ class ModelUpdateService:
|
|||||||
metadata_provider,
|
metadata_provider,
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
prefetched_response=prefetched.get(model_id),
|
prefetched_response=prefetched.get(model_id),
|
||||||
|
all_local_version_ids=all_vids,
|
||||||
)
|
)
|
||||||
if scanner.is_cancelled():
|
if scanner.is_cancelled():
|
||||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||||
@@ -964,8 +981,16 @@ class ModelUpdateService:
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
prefetched_response: Optional[Mapping] = None,
|
prefetched_response: Optional[Mapping] = None,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> Optional[ModelUpdateRecord]:
|
) -> Optional[ModelUpdateRecord]:
|
||||||
normalized_local = self._normalize_sequence(local_versions)
|
normalized_local = self._normalize_sequence(local_versions)
|
||||||
|
# When folder-filtering, this carries the cross-folder version set
|
||||||
|
# for is_in_library; otherwise it falls back to normalized_local.
|
||||||
|
normalized_all = (
|
||||||
|
self._normalize_sequence(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else normalized_local
|
||||||
|
)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
existing = self._get_record(model_type, model_id)
|
existing = self._get_record(model_type, model_id)
|
||||||
@@ -973,6 +998,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1048,6 +1074,7 @@ class ModelUpdateService:
|
|||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
existing,
|
existing,
|
||||||
normalized_local,
|
normalized_local,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1059,6 +1086,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=now,
|
last_checked_at=now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
record = replace(record, should_ignore_model=True)
|
record = replace(record, should_ignore_model=True)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
@@ -1077,6 +1105,7 @@ class ModelUpdateService:
|
|||||||
fetched_versions,
|
fetched_versions,
|
||||||
existing,
|
existing,
|
||||||
now,
|
now,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
record = self._merge_with_local_versions(
|
record = self._merge_with_local_versions(
|
||||||
@@ -1085,6 +1114,7 @@ class ModelUpdateService:
|
|||||||
model_type=model_type,
|
model_type=model_type,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
last_checked_at=existing.last_checked_at if existing else None,
|
last_checked_at=existing.last_checked_at if existing else None,
|
||||||
|
all_local_version_ids=normalized_all,
|
||||||
)
|
)
|
||||||
self._upsert_record(record)
|
self._upsert_record(record)
|
||||||
return record
|
return record
|
||||||
@@ -1322,12 +1352,20 @@ class ModelUpdateService:
|
|||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
normalized_local: Sequence[int],
|
normalized_local: Sequence[int],
|
||||||
*,
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
model_type: Optional[str] = None,
|
model_type: Optional[str] = None,
|
||||||
model_id: Optional[int] = None,
|
model_id: Optional[int] = None,
|
||||||
last_checked_at: Optional[float] = None,
|
last_checked_at: Optional[float] = None,
|
||||||
version_info: Optional[Mapping] = None,
|
version_info: Optional[Mapping] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(normalized_local)
|
local_set = set(normalized_local)
|
||||||
|
# When folder-filtering, also consider versions in other folders
|
||||||
|
# as in-library so they are not reported as available updates.
|
||||||
|
effective_local_set: set[int] = (
|
||||||
|
local_set | set(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else local_set
|
||||||
|
)
|
||||||
versions: List[ModelVersionRecord] = []
|
versions: List[ModelVersionRecord] = []
|
||||||
ignore_map: Dict[int, bool] = {}
|
ignore_map: Dict[int, bool] = {}
|
||||||
if existing:
|
if existing:
|
||||||
@@ -1339,7 +1377,7 @@ class ModelUpdateService:
|
|||||||
versions.append(
|
versions.append(
|
||||||
replace(
|
replace(
|
||||||
version,
|
version,
|
||||||
is_in_library=version.version_id in local_set,
|
is_in_library=version.version_id in effective_local_set,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif model_type is None or model_id is None:
|
elif model_type is None or model_id is None:
|
||||||
@@ -1386,8 +1424,17 @@ class ModelUpdateService:
|
|||||||
remote_versions: Sequence[ModelVersionRecord],
|
remote_versions: Sequence[ModelVersionRecord],
|
||||||
existing: Optional[ModelUpdateRecord],
|
existing: Optional[ModelUpdateRecord],
|
||||||
timestamp: float,
|
timestamp: float,
|
||||||
|
*,
|
||||||
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(local_versions)
|
local_set = set(local_versions)
|
||||||
|
# When folder-filtering, also consider versions in other folders
|
||||||
|
# as in-library so they are not reported as available updates.
|
||||||
|
effective_local_set: set[int] = (
|
||||||
|
local_set | set(all_local_version_ids)
|
||||||
|
if all_local_version_ids is not None
|
||||||
|
else local_set
|
||||||
|
)
|
||||||
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
||||||
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
||||||
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
||||||
@@ -1406,7 +1453,7 @@ class ModelUpdateService:
|
|||||||
released_at=remote_version.released_at,
|
released_at=remote_version.released_at,
|
||||||
size_bytes=remote_version.size_bytes,
|
size_bytes=remote_version.size_bytes,
|
||||||
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
preview_url=remote_version.preview_url or preview_map.get(version_id),
|
||||||
is_in_library=version_id in local_set,
|
is_in_library=version_id in effective_local_set,
|
||||||
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
|
||||||
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,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class PersistentModelCache:
|
|||||||
"db_checked",
|
"db_checked",
|
||||||
"last_checked_at",
|
"last_checked_at",
|
||||||
"hash_status",
|
"hash_status",
|
||||||
|
"hf_url",
|
||||||
)
|
)
|
||||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||||
@@ -165,8 +166,8 @@ class PersistentModelCache:
|
|||||||
|
|
||||||
item = {
|
item = {
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
"file_name": row["file_name"],
|
"file_name": row["file_name"] or "",
|
||||||
"model_name": row["model_name"],
|
"model_name": row["model_name"] or "",
|
||||||
"folder": row["folder"] or "",
|
"folder": row["folder"] or "",
|
||||||
"size": row["size"] or 0,
|
"size": row["size"] or 0,
|
||||||
"modified": row["modified"] or 0.0,
|
"modified": row["modified"] or 0.0,
|
||||||
@@ -188,6 +189,7 @@ class PersistentModelCache:
|
|||||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||||
"license_flags": int(license_value),
|
"license_flags": int(license_value),
|
||||||
"hash_status": row["hash_status"] or "completed",
|
"hash_status": row["hash_status"] or "completed",
|
||||||
|
"hf_url": row["hf_url"] or "",
|
||||||
}
|
}
|
||||||
raw_data.append(item)
|
raw_data.append(item)
|
||||||
|
|
||||||
@@ -452,6 +454,7 @@ class PersistentModelCache:
|
|||||||
db_checked INTEGER,
|
db_checked INTEGER,
|
||||||
last_checked_at REAL,
|
last_checked_at REAL,
|
||||||
hash_status TEXT,
|
hash_status TEXT,
|
||||||
|
hf_url TEXT DEFAULT '',
|
||||||
PRIMARY KEY (model_type, file_path)
|
PRIMARY KEY (model_type, file_path)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -500,6 +503,7 @@ class PersistentModelCache:
|
|||||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||||
"hash_status": "TEXT DEFAULT 'completed'",
|
"hash_status": "TEXT DEFAULT 'completed'",
|
||||||
|
"hf_url": "TEXT DEFAULT ''",
|
||||||
}
|
}
|
||||||
|
|
||||||
for column, definition in required_columns.items():
|
for column, definition in required_columns.items():
|
||||||
@@ -548,19 +552,19 @@ class PersistentModelCache:
|
|||||||
return (
|
return (
|
||||||
model_type,
|
model_type,
|
||||||
item.get("file_path"),
|
item.get("file_path"),
|
||||||
item.get("file_name"),
|
item.get("file_name") or "",
|
||||||
item.get("model_name"),
|
item.get("model_name") or "",
|
||||||
item.get("folder"),
|
item.get("folder") or "",
|
||||||
int(item.get("size") or 0),
|
int(item.get("size") or 0),
|
||||||
float(item.get("modified") or 0.0),
|
float(item.get("modified") or 0.0),
|
||||||
(item.get("sha256") or "").lower() or None,
|
(item.get("sha256") or "").lower() or None,
|
||||||
item.get("base_model"),
|
item.get("base_model") or "",
|
||||||
item.get("preview_url"),
|
item.get("preview_url") or "",
|
||||||
int(item.get("preview_nsfw_level") or 0),
|
int(item.get("preview_nsfw_level") or 0),
|
||||||
1 if item.get("from_civitai", True) else 0,
|
1 if item.get("from_civitai", True) else 0,
|
||||||
1 if item.get("favorite") else 0,
|
1 if item.get("favorite") else 0,
|
||||||
item.get("notes"),
|
item.get("notes") or "",
|
||||||
item.get("usage_tips"),
|
item.get("usage_tips") or "",
|
||||||
metadata_source,
|
metadata_source,
|
||||||
civitai.get("id"),
|
civitai.get("id"),
|
||||||
civitai.get("modelId"),
|
civitai.get("modelId"),
|
||||||
@@ -575,6 +579,7 @@ class PersistentModelCache:
|
|||||||
1 if item.get("db_checked") else 0,
|
1 if item.get("db_checked") else 0,
|
||||||
float(item.get("last_checked_at") or 0.0),
|
float(item.get("last_checked_at") or 0.0),
|
||||||
item.get("hash_status", "completed"),
|
item.get("hash_status", "completed"),
|
||||||
|
item.get("hf_url") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _insert_model_sql(self) -> str:
|
def _insert_model_sql(self) -> str:
|
||||||
|
|||||||
@@ -146,11 +146,38 @@ class RecipeAnalysisService:
|
|||||||
):
|
):
|
||||||
metadata = metadata["meta"]
|
metadata = metadata["meta"]
|
||||||
|
|
||||||
# Include modelVersionIds from root level if available
|
# Include modelVersionIds from root level if available.
|
||||||
# Civitai API returns modelVersionIds at root level, not in meta
|
# CivitAI API returns modelVersionIds at root level, not in meta.
|
||||||
|
# When meta is null (None), create a minimal dict so downstream
|
||||||
|
# parsers can still discover LoRAs and checkpoints.
|
||||||
model_version_ids = image_info.get("modelVersionIds")
|
model_version_ids = image_info.get("modelVersionIds")
|
||||||
if model_version_ids and isinstance(metadata, dict):
|
if model_version_ids:
|
||||||
metadata["modelVersionIds"] = model_version_ids
|
if isinstance(metadata, dict):
|
||||||
|
metadata["modelVersionIds"] = model_version_ids
|
||||||
|
else:
|
||||||
|
metadata = {"modelVersionIds": model_version_ids}
|
||||||
|
|
||||||
|
# Inject browsingLevel (canonical integer) so the recipe's
|
||||||
|
# preview_nsfw_level can be set, enabling proper NSFW blur
|
||||||
|
# of the preview image. Fall back to nsfwLevel (string)
|
||||||
|
# when browsingLevel is absent.
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
browsing_level = image_info.get("browsingLevel")
|
||||||
|
nsfw_level_str = image_info.get("nsfwLevel")
|
||||||
|
if isinstance(browsing_level, int) and browsing_level > 0:
|
||||||
|
metadata["browsingLevel"] = browsing_level
|
||||||
|
elif (
|
||||||
|
isinstance(nsfw_level_str, str)
|
||||||
|
and nsfw_level_str
|
||||||
|
in (
|
||||||
|
"PG", "PG13", "R", "X", "XXX", "Blocked",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
from ...utils.constants import NSFW_LEVELS
|
||||||
|
|
||||||
|
metadata["browsingLevel"] = NSFW_LEVELS.get(
|
||||||
|
nsfw_level_str, 0
|
||||||
|
)
|
||||||
|
|
||||||
# Validate that metadata contains meaningful recipe fields
|
# Validate that metadata contains meaningful recipe fields
|
||||||
# If not, treat as None to trigger EXIF extraction from downloaded image
|
# If not, treat as None to trigger EXIF extraction from downloaded image
|
||||||
@@ -171,12 +198,19 @@ class RecipeAnalysisService:
|
|||||||
temp_path = self._create_temp_path(suffix=extension)
|
temp_path = self._create_temp_path(suffix=extension)
|
||||||
await self._download_image(url, temp_path)
|
await self._download_image(url, temp_path)
|
||||||
|
|
||||||
if metadata is None and not is_video:
|
# Always extract EXIF from the downloaded image for generation
|
||||||
metadata = await asyncio.to_thread(
|
# params (prompt, negative prompt, sampler, steps, etc.).
|
||||||
|
# Previously this was gated on ``metadata is None``, but that
|
||||||
|
# skipped EXIF entirely when API metadata (modelVersionIds,
|
||||||
|
# browsingLevel) is present, losing all generation parameters.
|
||||||
|
exif_metadata = None
|
||||||
|
if not is_video:
|
||||||
|
exif_metadata = await asyncio.to_thread(
|
||||||
self._exif_utils.extract_image_metadata, temp_path
|
self._exif_utils.extract_image_metadata, temp_path
|
||||||
)
|
)
|
||||||
|
|
||||||
if not metadata and civitai_image_id and image_info:
|
# Fallback: try the original (non-optimized) image for EXIF data
|
||||||
|
if not exif_metadata and civitai_image_id and image_info:
|
||||||
original_url = image_info.get("url")
|
original_url = image_info.get("url")
|
||||||
if original_url:
|
if original_url:
|
||||||
self._logger.debug(
|
self._logger.debug(
|
||||||
@@ -187,15 +221,38 @@ class RecipeAnalysisService:
|
|||||||
orig_temp_path = self._create_temp_path(suffix=".png")
|
orig_temp_path = self._create_temp_path(suffix=".png")
|
||||||
try:
|
try:
|
||||||
await self._download_image(original_url, orig_temp_path)
|
await self._download_image(original_url, orig_temp_path)
|
||||||
metadata = await asyncio.to_thread(
|
exif_metadata = await asyncio.to_thread(
|
||||||
self._exif_utils.extract_image_metadata,
|
self._exif_utils.extract_image_metadata,
|
||||||
orig_temp_path,
|
orig_temp_path,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._safe_cleanup(orig_temp_path)
|
self._safe_cleanup(orig_temp_path)
|
||||||
|
|
||||||
|
# Parse EXIF data (typically a string like parameters/prompt/workflow)
|
||||||
|
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
|
||||||
|
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
|
||||||
|
# This mirrors the two-pass approach in _do_import_from_url.
|
||||||
|
exif_parsed_result = None
|
||||||
|
if isinstance(exif_metadata, str):
|
||||||
|
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
|
||||||
|
if exif_parser:
|
||||||
|
exif_data = await exif_parser.parse_metadata(
|
||||||
|
exif_metadata, recipe_scanner=recipe_scanner,
|
||||||
|
)
|
||||||
|
if exif_data and not exif_data.get("error"):
|
||||||
|
exif_parsed_result = exif_data
|
||||||
|
|
||||||
|
# Merge API metadata (dict) with EXIF data (if dict) for the
|
||||||
|
# CivitaiApiMetadataParser. If EXIF data is a string it was
|
||||||
|
# parsed above — don't try to merge a string into a dict.
|
||||||
|
merged = {}
|
||||||
|
if isinstance(exif_metadata, dict):
|
||||||
|
merged.update(exif_metadata)
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
merged.update(metadata)
|
||||||
|
|
||||||
result = await self._parse_metadata(
|
result = await self._parse_metadata(
|
||||||
metadata or {},
|
merged,
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
image_path=temp_path,
|
image_path=temp_path,
|
||||||
include_image_base64=True,
|
include_image_base64=True,
|
||||||
@@ -203,13 +260,23 @@ class RecipeAnalysisService:
|
|||||||
extension=extension,
|
extension=extension,
|
||||||
)
|
)
|
||||||
|
|
||||||
if civitai_image_id and image_info and not result.payload.get("error"):
|
# Merge EXIF string-parsed gen_params into the API result.
|
||||||
mvid = image_info.get("modelVersionId")
|
# API gen_params take priority (they come later via update).
|
||||||
if not mvid:
|
if exif_parsed_result and not result.payload.get("error"):
|
||||||
mvids = image_info.get("modelVersionIds")
|
exif_gp = exif_parsed_result.get("gen_params") or {}
|
||||||
if isinstance(mvids, list) and mvids:
|
result_gp = result.payload.get("gen_params") or {}
|
||||||
mvid = mvids[0]
|
merged_gp = {**exif_gp, **result_gp}
|
||||||
|
if merged_gp:
|
||||||
|
result.payload["gen_params"] = merged_gp
|
||||||
|
|
||||||
|
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||||
|
# Use the metadata dict we built (may contain modelVersionIds
|
||||||
|
# and browsingLevel from the API root level). Do NOT pass
|
||||||
|
# image_info.get("meta") — it is null for images whose meta
|
||||||
|
# lives at the root level only. Also do NOT derive
|
||||||
|
# model_version_id from modelVersionIds[0] — that array mixes
|
||||||
|
# checkpoints, LoRAs, and other types without ordering
|
||||||
|
# guarantees; the parser already resolved them correctly.
|
||||||
recipe_for_enrich = {
|
recipe_for_enrich = {
|
||||||
"gen_params": result.payload.get("gen_params", {}),
|
"gen_params": result.payload.get("gen_params", {}),
|
||||||
"loras": result.payload.get("loras", []),
|
"loras": result.payload.get("loras", []),
|
||||||
@@ -222,8 +289,10 @@ class RecipeAnalysisService:
|
|||||||
recipe=recipe_for_enrich,
|
recipe=recipe_for_enrich,
|
||||||
civitai_client=civitai_client,
|
civitai_client=civitai_client,
|
||||||
request_params=None,
|
request_params=None,
|
||||||
prefetched_civitai_meta_raw=image_info.get("meta"),
|
prefetched_civitai_meta_raw=(
|
||||||
prefetched_model_version_id=mvid,
|
metadata if isinstance(metadata, dict) else None
|
||||||
|
),
|
||||||
|
prefetched_model_version_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
result.payload["gen_params"] = recipe_for_enrich["gen_params"]
|
||||||
@@ -232,6 +301,12 @@ class RecipeAnalysisService:
|
|||||||
if recipe_for_enrich.get("base_model"):
|
if recipe_for_enrich.get("base_model"):
|
||||||
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
result.payload["base_model"] = recipe_for_enrich["base_model"]
|
||||||
|
|
||||||
|
# Extract browsingLevel from our constructed metadata for NSFW blur
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
bl = metadata.get("browsingLevel")
|
||||||
|
if isinstance(bl, int) and bl > 0:
|
||||||
|
result.payload["preview_nsfw_level"] = bl
|
||||||
|
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
if temp_path:
|
if temp_path:
|
||||||
@@ -314,6 +389,10 @@ class RecipeAnalysisService:
|
|||||||
"prompt_type",
|
"prompt_type",
|
||||||
"positive",
|
"positive",
|
||||||
"negative",
|
"negative",
|
||||||
|
# modelVersionIds is injected at the root level by CivitAI's image
|
||||||
|
# API when meta is null. It carries the version IDs of ALL models
|
||||||
|
# (checkpoint + LoRAs) used to generate the image.
|
||||||
|
"modelVersionIds",
|
||||||
}
|
}
|
||||||
return any(field in metadata for field in recipe_fields)
|
return any(field in metadata for field in recipe_fields)
|
||||||
|
|
||||||
|
|||||||
@@ -1568,7 +1568,7 @@ class SettingsManager:
|
|||||||
previous_dir = os.path.dirname(previous_path) or target_dir
|
previous_dir = os.path.dirname(previous_path) or target_dir
|
||||||
|
|
||||||
if os.path.abspath(previous_path) != os.path.abspath(target_path):
|
if os.path.abspath(previous_path) != os.path.abspath(target_path):
|
||||||
self._copy_model_cache_directory(previous_dir, target_dir)
|
self._migrate_settings_directory_content(previous_dir, target_dir)
|
||||||
logger.info("Switching settings file to: %s", target_path)
|
logger.info("Switching settings file to: %s", target_path)
|
||||||
|
|
||||||
self._pending_portable_switch = {"other_path": other_path}
|
self._pending_portable_switch = {"other_path": other_path}
|
||||||
@@ -1603,46 +1603,52 @@ class SettingsManager:
|
|||||||
finally:
|
finally:
|
||||||
self._pending_portable_switch = None
|
self._pending_portable_switch = None
|
||||||
|
|
||||||
def _copy_model_cache_directory(self, source_dir: str, target_dir: str) -> None:
|
def _migrate_settings_directory_content(
|
||||||
"""Copy model_cache artifacts when switching storage locations."""
|
self, source_dir: str, target_dir: str
|
||||||
|
) -> None:
|
||||||
|
"""Migrate settings directory subdirectories when switching storage locations.
|
||||||
|
|
||||||
|
Copies the canonical subdirectories (cache, backups, logs, stats, wildcards)
|
||||||
|
from the old settings directory to the new one. Legacy cache artifacts
|
||||||
|
(model_cache, recipe_cache, etc.) are migrated lazily by
|
||||||
|
``resolve_cache_path_with_migration`` on first access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_dir: The previous settings directory path.
|
||||||
|
target_dir: The new settings directory path.
|
||||||
|
"""
|
||||||
|
|
||||||
if not source_dir or not target_dir:
|
if not source_dir or not target_dir:
|
||||||
return
|
return
|
||||||
|
|
||||||
source_cache_dir = os.path.join(source_dir, "model_cache")
|
def _copy_dir(name: str) -> None:
|
||||||
target_cache_dir = os.path.join(target_dir, "model_cache")
|
source = os.path.join(source_dir, name)
|
||||||
if os.path.isdir(source_cache_dir) and os.path.abspath(
|
target = os.path.join(target_dir, name)
|
||||||
source_cache_dir
|
if os.path.isdir(source) and os.path.abspath(source) != os.path.abspath(
|
||||||
) != os.path.abspath(target_cache_dir):
|
target
|
||||||
try:
|
):
|
||||||
shutil.copytree(
|
try:
|
||||||
source_cache_dir,
|
shutil.copytree(
|
||||||
target_cache_dir,
|
source,
|
||||||
dirs_exist_ok=True,
|
target,
|
||||||
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
dirs_exist_ok=True,
|
||||||
)
|
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
|
||||||
except Exception as exc:
|
)
|
||||||
logger.warning(
|
except Exception as exc:
|
||||||
"Failed to copy model_cache directory from %s to %s: %s",
|
logger.warning(
|
||||||
source_cache_dir,
|
"Failed to copy directory %s from %s to %s: %s",
|
||||||
target_cache_dir,
|
name,
|
||||||
exc,
|
source,
|
||||||
)
|
target,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
|
# Managed subdirectories under settings_dir
|
||||||
target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
|
_copy_dir("cache")
|
||||||
if os.path.isfile(source_cache_file) and os.path.abspath(
|
_copy_dir("backups")
|
||||||
source_cache_file
|
_copy_dir("logs")
|
||||||
) != os.path.abspath(target_cache_file):
|
_copy_dir("stats")
|
||||||
try:
|
_copy_dir("wildcards")
|
||||||
shutil.copy2(source_cache_file, target_cache_file)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to copy model_cache.sqlite from %s to %s: %s",
|
|
||||||
source_cache_file,
|
|
||||||
target_cache_file,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_user_config_directory(self) -> str:
|
def _get_user_config_directory(self) -> str:
|
||||||
"""Return the user configuration directory, falling back to ~/.config."""
|
"""Return the user configuration directory, falling back to ~/.config."""
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ SUPPORTED_MEDIA_EXTENSIONS = {
|
|||||||
"videos": [".mp4", ".webm"],
|
"videos": [".mp4", ".webm"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Model weight file extensions recognised by scanners.
|
||||||
|
# This is the union of all scanner extensions (lora, checkpoint, embedding).
|
||||||
|
MODEL_FILE_EXTENSIONS = {
|
||||||
|
".safetensors",
|
||||||
|
".ckpt",
|
||||||
|
".pt",
|
||||||
|
".pt2",
|
||||||
|
".bin",
|
||||||
|
".pth",
|
||||||
|
".pkl",
|
||||||
|
".sft",
|
||||||
|
".gguf",
|
||||||
|
}
|
||||||
|
|
||||||
# Valid sub-types for each scanner type
|
# Valid sub-types for each scanner type
|
||||||
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||||
@@ -147,6 +161,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
|||||||
"Qwen",
|
"Qwen",
|
||||||
"ZImageBase",
|
"ZImageBase",
|
||||||
"ZImageTurbo",
|
"ZImageTurbo",
|
||||||
|
# Krea 2 — loaded via UNETLoader in ComfyUI
|
||||||
|
"Krea 2",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -213,5 +229,6 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
|||||||
"Ernie",
|
"Ernie",
|
||||||
"Ernie Turbo",
|
"Ernie Turbo",
|
||||||
"Nucleus",
|
"Nucleus",
|
||||||
|
"Krea 2",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.1.5"
|
version = "1.1.6"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 1746460,
|
|
||||||
"name": "Mixplin Style [Illustrious]",
|
|
||||||
"type": "LORA",
|
|
||||||
"description": "description",
|
|
||||||
"username": "Ty_Lee",
|
|
||||||
"downloadCount": 4207,
|
|
||||||
"favoriteCount": 0,
|
|
||||||
"commentCount": 8,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"is_nsfw": true,
|
|
||||||
"nsfw_level": 31,
|
|
||||||
"createdAt": "2025-07-06T01:51:42.859Z",
|
|
||||||
"updatedAt": "2025-10-10T23:15:26.714Z",
|
|
||||||
"deletedAt": null,
|
|
||||||
"tags": [
|
|
||||||
"art",
|
|
||||||
"style",
|
|
||||||
"artist style",
|
|
||||||
"styles",
|
|
||||||
"mixplin",
|
|
||||||
"artiststyle"
|
|
||||||
],
|
|
||||||
"creator_id": "Ty_Lee",
|
|
||||||
"creator_username": "Ty_Lee",
|
|
||||||
"creator_name": "Ty_Lee",
|
|
||||||
"creator_url": "/users/Ty_Lee",
|
|
||||||
"versions": [
|
|
||||||
{
|
|
||||||
"id": 2042594,
|
|
||||||
"name": "v2.0",
|
|
||||||
"href": "/models/1746460?modelVersionId=2042594"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1976567,
|
|
||||||
"name": "v1.0",
|
|
||||||
"href": "/models/1746460?modelVersionId=1976567"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": {
|
|
||||||
"id": 1976567,
|
|
||||||
"modelId": 1746460,
|
|
||||||
"name": "v1.0",
|
|
||||||
"baseModel": "Illustrious",
|
|
||||||
"baseModelType": "Standard",
|
|
||||||
"description": null,
|
|
||||||
"downloadCount": 437,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"is_nsfw": true,
|
|
||||||
"nsfw_level": 31,
|
|
||||||
"createdAt": "2025-07-05T10:17:28.716Z",
|
|
||||||
"updatedAt": "2025-10-10T23:15:26.756Z",
|
|
||||||
"deletedAt": null,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1874043,
|
|
||||||
"name": "mxpln-illustrious-ty_lee.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"sizeKB": 223124.37109375,
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1976567",
|
|
||||||
"modelId": 1746460,
|
|
||||||
"modelName": "Mixplin Style [Illustrious]",
|
|
||||||
"modelVersionId": 1976567,
|
|
||||||
"is_nsfw": true,
|
|
||||||
"nsfw_level": 31,
|
|
||||||
"sha256": "e2b7a280d6539556f23f380b3f71e4e22bc4524445c4c96526e117c6005c6ad3",
|
|
||||||
"createdAt": "2025-07-05T10:17:28.716Z",
|
|
||||||
"updatedAt": "2025-10-10T23:15:26.766Z",
|
|
||||||
"is_primary": false,
|
|
||||||
"mirrors": [
|
|
||||||
{
|
|
||||||
"filename": "mxpln-illustrious-ty_lee.safetensors",
|
|
||||||
"url": "https://civitai.com/api/download/models/1976567",
|
|
||||||
"source": "civitai",
|
|
||||||
"model_id": 1746460,
|
|
||||||
"model_version_id": 1976567,
|
|
||||||
"deletedAt": null,
|
|
||||||
"is_gated": false,
|
|
||||||
"is_paid": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"id": 86403595,
|
|
||||||
"url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"width": 1560,
|
|
||||||
"height": 2280,
|
|
||||||
"hash": "U7G8Zp0w02%IA6%N00-;D]-W~VNG0nMw-.IV",
|
|
||||||
"type": "image",
|
|
||||||
"minor": false,
|
|
||||||
"poi": false,
|
|
||||||
"hasMeta": true,
|
|
||||||
"hasPositivePrompt": true,
|
|
||||||
"onSite": false,
|
|
||||||
"remixOfId": null,
|
|
||||||
"image_url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
|
||||||
"link": "https://genur.art/posts/86403595"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"trigger": [
|
|
||||||
"mxpln"
|
|
||||||
],
|
|
||||||
"allow_download": true,
|
|
||||||
"download_url": "/api/download/models/1976567",
|
|
||||||
"platform_url": "https://civitai.com/models/1746460?modelVersionId=1976567",
|
|
||||||
"civitai_model_id": 1746460,
|
|
||||||
"civitai_model_version_id": 1976567,
|
|
||||||
"href": "/models/1746460?modelVersionId=1976567",
|
|
||||||
"mirrors": [
|
|
||||||
{
|
|
||||||
"platform": "tensorart",
|
|
||||||
"href": "/tensorart/models/904473536033245448/versions/904473536033245448",
|
|
||||||
"platform_url": "https://tensor.art/models/904473536033245448",
|
|
||||||
"name": "Mixplin Style MXP",
|
|
||||||
"version_name": "Mixplin",
|
|
||||||
"id": "904473536033245448",
|
|
||||||
"version_id": "904473536033245448"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"platform": "civitai",
|
|
||||||
"platform_name": "CivitAI",
|
|
||||||
"meta": {
|
|
||||||
"title": "Mixplin Style [Illustrious] - v1.0 - CivitAI Archive",
|
|
||||||
"description": "Mixplin Style [Illustrious] v1.0 is a Illustrious LORA AI model created by Ty_Lee for generating images of art, style, artist style, styles, mixplin, artiststyle",
|
|
||||||
"image": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
|
|
||||||
"canonical": "https://civarchive.com/models/1746460?modelVersionId=1976567"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
CREATE TABLE models (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
username TEXT,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
) STRICT;
|
|
||||||
CREATE TABLE model_versions (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
model_id INTEGER NOT NULL,
|
|
||||||
position INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
base_model TEXT NOT NULL,
|
|
||||||
published_at INTEGER,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
) STRICT;
|
|
||||||
CREATE INDEX model_versions_model_id_idx ON model_versions (model_id);
|
|
||||||
CREATE TABLE model_files (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
model_id INTEGER NOT NULL,
|
|
||||||
version_id INTEGER NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
sha256 TEXT,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
) STRICT;
|
|
||||||
CREATE INDEX model_files_model_id_idx ON model_files (model_id);
|
|
||||||
CREATE INDEX model_files_version_id_idx ON model_files (version_id);
|
|
||||||
CREATE TABLE archived_model_files (
|
|
||||||
file_id INTEGER PRIMARY KEY,
|
|
||||||
model_id INTEGER NOT NULL,
|
|
||||||
version_id INTEGER NOT NULL
|
|
||||||
) STRICT;
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 1231067,
|
|
||||||
"name": "Vivid Impressions Storybook Style",
|
|
||||||
"description": "<h3 id=\"if-you'd-like-to-support-me-feel-free-to-visit-my-ko-fi-page.-please-share-your-images-using-the-"+add-post"-button-below.-it-supports-the-creators.-thanks!-nnfwkvfly\">If you'd like to support me, feel free to visit my <a target=\"_blank\" rel=\"ugc\" href=\"https://ko-fi.com/pixelpawsai\">Ko-Fi</a> page. ❤️<br /><br />Please share your images using the \"<span style=\"color:rgb(250, 82, 82)\">+add post</span>\" button below. It supports the creators. Thanks! 💕</h3><h3 id=\"if-you-like-my-lora-please-like-comment-or-donate-some-buzz.-much-appreciated!-vyeqok3go\">If you like my LoRA, please<span style=\"color:rgb(230, 73, 128)\"> </span><span style=\"color:rgb(250, 82, 82)\">like</span>, <span style=\"color:rgb(250, 82, 82)\">comment</span>, or <span style=\"color:#fa5252\">donate some Buzz</span>. Much appreciated! ❤️</h3><h3 id=\"-lo912t8rj\"></h3><h3 id=\"trigger-word:-ppstorybook-wlggllim2\"><strong><span style=\"color:rgb(253, 126, 20)\">Trigger word: </span></strong>ppstorybook</h3><h3 id=\"strength:-0.8-experiment-as-you-like-luvhks6za\"><strong><span style=\"color:rgb(253, 126, 20)\">Strength: </span></strong>0.8, experiment as you like</h3>",
|
|
||||||
"allowNoCredit": true,
|
|
||||||
"allowCommercialUse": [
|
|
||||||
"Image",
|
|
||||||
"RentCivit",
|
|
||||||
"Rent",
|
|
||||||
"Sell"
|
|
||||||
],
|
|
||||||
"allowDerivatives": true,
|
|
||||||
"allowDifferentLicense": true,
|
|
||||||
"type": "LORA",
|
|
||||||
"minor": false,
|
|
||||||
"sfwOnly": false,
|
|
||||||
"poi": false,
|
|
||||||
"nsfw": false,
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"availability": "Public",
|
|
||||||
"cosmetic": null,
|
|
||||||
"supportsGeneration": true,
|
|
||||||
"stats": {
|
|
||||||
"downloadCount": 2183,
|
|
||||||
"favoriteCount": 0,
|
|
||||||
"thumbsUpCount": 416,
|
|
||||||
"thumbsDownCount": 0,
|
|
||||||
"commentCount": 12,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"tippedAmountCount": 360
|
|
||||||
},
|
|
||||||
"creator": {
|
|
||||||
"username": "PixelPawsAI",
|
|
||||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"style",
|
|
||||||
"illustration",
|
|
||||||
"storybook"
|
|
||||||
],
|
|
||||||
"modelVersions": [
|
|
||||||
{
|
|
||||||
"id": 1387174,
|
|
||||||
"index": 0,
|
|
||||||
"name": "v1.0",
|
|
||||||
"baseModel": "Flux.1 D",
|
|
||||||
"baseModelType": "Standard",
|
|
||||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
|
||||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
|
||||||
"status": "Published",
|
|
||||||
"availability": "Public",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"trainedWords": [
|
|
||||||
"ppstorybook"
|
|
||||||
],
|
|
||||||
"covered": true,
|
|
||||||
"stats": {
|
|
||||||
"downloadCount": 2183,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"thumbsUpCount": 416,
|
|
||||||
"thumbsDownCount": 0
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1289799,
|
|
||||||
"sizeKB": 18829.1484375,
|
|
||||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"pickleScanResult": "Success",
|
|
||||||
"pickleScanMessage": "No Pickle imports",
|
|
||||||
"virusScanResult": "Success",
|
|
||||||
"virusScanMessage": null,
|
|
||||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
|
||||||
"metadata": {
|
|
||||||
"format": "SafeTensor"
|
|
||||||
},
|
|
||||||
"hashes": {
|
|
||||||
"AutoV1": "F414C813",
|
|
||||||
"AutoV2": "9753338AB6",
|
|
||||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
|
||||||
"CRC32": "A65AE7B3",
|
|
||||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
|
||||||
"AutoV3": "34A22376739D"
|
|
||||||
},
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1387174",
|
|
||||||
"primary": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/original=true/56547310.jpeg",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216,
|
|
||||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
|
||||||
"type": "image",
|
|
||||||
"minor": false,
|
|
||||||
"poi": false,
|
|
||||||
"hasMeta": true,
|
|
||||||
"hasPositivePrompt": true,
|
|
||||||
"onSite": false,
|
|
||||||
"remixOfId": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 1387174,
|
|
||||||
"modelId": 1231067,
|
|
||||||
"name": "v1.0",
|
|
||||||
"createdAt": "2025-02-08T11:15:47.197Z",
|
|
||||||
"updatedAt": "2025-02-08T11:29:04.526Z",
|
|
||||||
"status": "Published",
|
|
||||||
"publishedAt": "2025-02-08T11:29:04.487Z",
|
|
||||||
"trainedWords": [
|
|
||||||
"ppstorybook"
|
|
||||||
],
|
|
||||||
"trainingStatus": null,
|
|
||||||
"trainingDetails": null,
|
|
||||||
"baseModel": "Flux.1 D",
|
|
||||||
"baseModelType": null,
|
|
||||||
"earlyAccessEndsAt": null,
|
|
||||||
"earlyAccessConfig": null,
|
|
||||||
"description": null,
|
|
||||||
"uploadType": "Created",
|
|
||||||
"usageControl": "Download",
|
|
||||||
"air": "urn:air:flux1:lora:civitai:1231067@1387174",
|
|
||||||
"stats": {
|
|
||||||
"downloadCount": 1436,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"thumbsUpCount": 316
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"name": "Vivid Impressions Storybook Style",
|
|
||||||
"type": "LORA",
|
|
||||||
"nsfw": false,
|
|
||||||
"poi": false
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1289799,
|
|
||||||
"sizeKB": 18829.1484375,
|
|
||||||
"name": "pp-storybook_rank2_bf16.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"pickleScanResult": "Success",
|
|
||||||
"pickleScanMessage": "No Pickle imports",
|
|
||||||
"virusScanResult": "Success",
|
|
||||||
"virusScanMessage": null,
|
|
||||||
"scannedAt": "2025-02-08T11:21:04.247Z",
|
|
||||||
"metadata": {
|
|
||||||
"format": "SafeTensor",
|
|
||||||
"size": null,
|
|
||||||
"fp": null
|
|
||||||
},
|
|
||||||
"hashes": {
|
|
||||||
"AutoV1": "F414C813",
|
|
||||||
"AutoV2": "9753338AB6",
|
|
||||||
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
|
|
||||||
"CRC32": "A65AE7B3",
|
|
||||||
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
|
|
||||||
"AutoV3": "34A22376739D"
|
|
||||||
},
|
|
||||||
"primary": true,
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216,
|
|
||||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
|
||||||
"type": "image",
|
|
||||||
"metadata": {
|
|
||||||
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
|
|
||||||
"size": 1361590,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"Size": "832x1216",
|
|
||||||
"seed": 1116375220995209,
|
|
||||||
"Model": "flux_dev_fp8",
|
|
||||||
"steps": 23,
|
|
||||||
"hashes": {
|
|
||||||
"model": ""
|
|
||||||
},
|
|
||||||
"prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.",
|
|
||||||
"Version": "ComfyUI",
|
|
||||||
"sampler": "DPM++ 2M",
|
|
||||||
"cfgScale": 3.5,
|
|
||||||
"clipSkip": 1,
|
|
||||||
"resources": [],
|
|
||||||
"Model hash": ""
|
|
||||||
},
|
|
||||||
"availability": "Public",
|
|
||||||
"hasMeta": true,
|
|
||||||
"hasPositivePrompt": true,
|
|
||||||
"onSite": false,
|
|
||||||
"remixOfId": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1387174"
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
{
|
|
||||||
"resource-stack": {
|
|
||||||
"class_type": "CheckpointLoaderSimple",
|
|
||||||
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
|
|
||||||
},
|
|
||||||
"resource-stack-1": {
|
|
||||||
"class_type": "LoraLoader",
|
|
||||||
"inputs": {
|
|
||||||
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
|
|
||||||
"strength_model": 1,
|
|
||||||
"strength_clip": 1,
|
|
||||||
"model": ["resource-stack", 0],
|
|
||||||
"clip": ["resource-stack", 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"resource-stack-2": {
|
|
||||||
"class_type": "LoraLoader",
|
|
||||||
"inputs": {
|
|
||||||
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
|
|
||||||
"strength_model": 1,
|
|
||||||
"strength_clip": 1,
|
|
||||||
"model": ["resource-stack-1", 0],
|
|
||||||
"clip": ["resource-stack-1", 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"resource-stack-3": {
|
|
||||||
"class_type": "LoraLoader",
|
|
||||||
"inputs": {
|
|
||||||
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
|
|
||||||
"strength_model": 1.55,
|
|
||||||
"strength_clip": 1,
|
|
||||||
"model": ["resource-stack-2", 0],
|
|
||||||
"clip": ["resource-stack-2", 1]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"6": {
|
|
||||||
"class_type": "smZ CLIPTextEncode",
|
|
||||||
"inputs": {
|
|
||||||
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
|
|
||||||
"parser": "comfy",
|
|
||||||
"text_g": "",
|
|
||||||
"text_l": "",
|
|
||||||
"ascore": 2.5,
|
|
||||||
"width": 0,
|
|
||||||
"height": 0,
|
|
||||||
"crop_w": 0,
|
|
||||||
"crop_h": 0,
|
|
||||||
"target_width": 0,
|
|
||||||
"target_height": 0,
|
|
||||||
"smZ_steps": 1,
|
|
||||||
"mean_normalization": true,
|
|
||||||
"multi_conditioning": true,
|
|
||||||
"use_old_emphasis_implementation": false,
|
|
||||||
"with_SDXL": false,
|
|
||||||
"clip": ["resource-stack-3", 1]
|
|
||||||
},
|
|
||||||
"_meta": { "title": "Positive" }
|
|
||||||
},
|
|
||||||
"7": {
|
|
||||||
"class_type": "smZ CLIPTextEncode",
|
|
||||||
"inputs": {
|
|
||||||
"text": "bad quality,worst quality,worst detail,sketch,censor",
|
|
||||||
"parser": "comfy",
|
|
||||||
"text_g": "",
|
|
||||||
"text_l": "",
|
|
||||||
"ascore": 2.5,
|
|
||||||
"width": 0,
|
|
||||||
"height": 0,
|
|
||||||
"crop_w": 0,
|
|
||||||
"crop_h": 0,
|
|
||||||
"target_width": 0,
|
|
||||||
"target_height": 0,
|
|
||||||
"smZ_steps": 1,
|
|
||||||
"mean_normalization": true,
|
|
||||||
"multi_conditioning": true,
|
|
||||||
"use_old_emphasis_implementation": false,
|
|
||||||
"with_SDXL": false,
|
|
||||||
"clip": ["resource-stack-3", 1]
|
|
||||||
},
|
|
||||||
"_meta": { "title": "Negative" }
|
|
||||||
},
|
|
||||||
"20": {
|
|
||||||
"class_type": "UpscaleModelLoader",
|
|
||||||
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
|
|
||||||
"_meta": { "title": "Load Upscale Model" }
|
|
||||||
},
|
|
||||||
"17": {
|
|
||||||
"class_type": "LoadImage",
|
|
||||||
"inputs": {
|
|
||||||
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
|
|
||||||
"upload": "image"
|
|
||||||
},
|
|
||||||
"_meta": { "title": "Image Load" }
|
|
||||||
},
|
|
||||||
"19": {
|
|
||||||
"class_type": "ImageUpscaleWithModel",
|
|
||||||
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
|
|
||||||
"_meta": { "title": "Upscale Image (using Model)" }
|
|
||||||
},
|
|
||||||
"23": {
|
|
||||||
"class_type": "ImageScale",
|
|
||||||
"inputs": {
|
|
||||||
"upscale_method": "nearest-exact",
|
|
||||||
"crop": "disabled",
|
|
||||||
"width": 1280,
|
|
||||||
"height": 1856,
|
|
||||||
"image": ["19", 0]
|
|
||||||
},
|
|
||||||
"_meta": { "title": "Upscale Image" }
|
|
||||||
},
|
|
||||||
"21": {
|
|
||||||
"class_type": "VAEEncode",
|
|
||||||
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
|
|
||||||
"_meta": { "title": "VAE Encode" }
|
|
||||||
},
|
|
||||||
"11": {
|
|
||||||
"class_type": "KSampler",
|
|
||||||
"inputs": {
|
|
||||||
"sampler_name": "euler_ancestral",
|
|
||||||
"scheduler": "normal",
|
|
||||||
"seed": 2088370631,
|
|
||||||
"steps": 47,
|
|
||||||
"cfg": 6.5,
|
|
||||||
"denoise": 0.3,
|
|
||||||
"model": ["resource-stack-3", 0],
|
|
||||||
"positive": ["6", 0],
|
|
||||||
"negative": ["7", 0],
|
|
||||||
"latent_image": ["21", 0]
|
|
||||||
},
|
|
||||||
"_meta": { "title": "KSampler" }
|
|
||||||
},
|
|
||||||
"13": {
|
|
||||||
"class_type": "VAEDecode",
|
|
||||||
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
|
|
||||||
"_meta": { "title": "VAE Decode" }
|
|
||||||
},
|
|
||||||
"12": {
|
|
||||||
"class_type": "SaveImage",
|
|
||||||
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
|
|
||||||
"_meta": { "title": "Save Image" }
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"airs": [
|
|
||||||
"urn:air:other:upscaler:civitai:147759@164821",
|
|
||||||
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
|
|
||||||
"urn:air:sdxl:lora:civitai:1107767@1253442",
|
|
||||||
"urn:air:sdxl:lora:civitai:1342708@1516344",
|
|
||||||
"urn:air:sdxl:lora:civitai:122359@135867"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting.
|
|
||||||
Negative prompt:
|
|
||||||
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
|
|
||||||
|
|
||||||
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
|
|
||||||
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
|
|
||||||
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
|
|
||||||
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
|
|
||||||
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
|
|
||||||
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)
|
|
||||||
|
|
||||||
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1girl, solo, futuristic warrior, sleek exosuit with glowing energy cores, long braided hair flowing behind, gripping a high-tech bow with an energy arrow drawn, standing on a floating platform overlooking a massive space station, planets and nebulae in the distance, soft glow from distant stars, cinematic depth, foreshortening, dynamic pose, dramatic sci-fi lighting.
|
|
||||||
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
|
|
||||||
Steps: 20, Sampler: euler_ancestral_karras, CFG scale: 8.0, Seed: 691121152183439, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 1.0, Lora_0 Strength clip: 1.0, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
|
|
||||||
|
|
||||||
Immerse yourself in the enchanting journey, where harmonious transmutation of Bauhaus art unites photographic precision and contemporary illustration, capturing an enthralling blend between vivid abstract nature and urban landscapes. Let your eyes be captivated by a kaleidoscope of rich, deep reds and yellows, entwined with intriguing shades that beckon a somber atmosphere. As your spirit ventures along this haunting path, witness the mysterious, high-angle perspective dominated by scattered clouds – granting you a mesmerizing glimpse into the ever-transforming realm of metamorphosing environments. ,<lora:flux/fav/ck-charcoal-drawing-000014.safetensors:1.0:1.0>
|
|
||||||
Negative prompt:
|
|
||||||
Steps: 20, Sampler: Euler, CFG scale: 3.5, Seed: 885491426361006, Size: 832x1216, Model hash: 4610115bb0, Model: flux_dev, Hashes: {"LORA:flux/fav/ck-charcoal-drawing-000014.safetensors": "34d36c17c1", "model": "4610115bb0"}, Version: ComfyUI
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
In this ethereal masterpiece, metallic sculptures juxtapose effortlessly against a subtle backdrop of misty neutral hues. Exquisite curvatures and geometric shapes converge harmoniously, creating an illuminating realm of polished metallic surfaces. Shimmering copper, gleaming silver, and lustrous gold hues dance in perfect balance, highlighting the intricate play of light and shadow cast upon these celestial forms. A halo of diffused radiance envelops each piece, enhancing their textured depths and metallic brilliance while allowing delicate details to emerge from obscurity. The composition conveys a serene yet mesmerizing atmosphere, as if suspended in a dreamlike limbo between reality and fantasy. The tantalizing interplay of colors within this transcendent realm creates a profound sense of depth and grandeur that invites the viewer into an enchanting voyage through abstract metallic beauty. This captivating artwork evokes emotions of boundless curiosity and reverence reminiscent of the timeless works by artists such as Giorgio de Chirico or Paul Klee, while asserting a unique, modern artistic sensibility. With every observation, a new nuance unfolds, as if a never-ending story waiting to be discovered through the lens of metallic artistry.
|
|
||||||
Negative prompt:
|
|
||||||
Steps: 25, Sampler: dpmpp_2m_sgm_uniform, Seed: 471889513588087, Model: Fluxmania V5P.safetensors, Model hash: 8ae0583b06, VAE: ae.sft, VAE hash: afc8e28272, Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, Lora_0 Strength model: 0.65, Lora_0 Strength clip: 0.65, Lora_1 Model name: Kaoru Yamada.safetensors, Lora_1 Model hash: d4893f7202, Lora_1 Strength model: 0.75, Lora_1 Strength clip: 0.75, Hashes: {"model": "8ae0583b06", "vae": "afc8e28272", "lora:ArtVador I": "08f7133a58", "lora:Kaoru Yamada": "d4893f7202"}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
|
|
||||||
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
|
|
||||||
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
|
|
||||||
"modified": 1754897325.0507245,
|
|
||||||
"created_date": 1754897325.0507245,
|
|
||||||
"base_model": "Illustrious",
|
|
||||||
"loras": [
|
|
||||||
{
|
|
||||||
"file_name": "",
|
|
||||||
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
|
|
||||||
"strength": 1.0,
|
|
||||||
"modelVersionId": 2007092,
|
|
||||||
"modelName": "Pony: People's Works +",
|
|
||||||
"modelVersionName": "v8_Illusv1.0",
|
|
||||||
"isDeleted": false,
|
|
||||||
"exclude": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"gen_params": {
|
|
||||||
"prompt": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect eyes, realistic eyes,\n(flat colors:1.5), (anime:1.5), (lineart:1.5),\nclose-up, solo, tongue, 1girl, food, (saliva:0.1), open mouth, candy, simple background, blue background, large lollipop, tongue out, fade background, lips, hand up, holding, looking at viewer, licking, seductive, half-closed eyes,",
|
|
||||||
"negative_prompt": "shiny skin,",
|
|
||||||
"steps": 19,
|
|
||||||
"sampler": "Euler a",
|
|
||||||
"cfg_scale": 5,
|
|
||||||
"seed": 1765271748,
|
|
||||||
"size": "832x1216",
|
|
||||||
"clip_skip": 2
|
|
||||||
},
|
|
||||||
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
|
|
||||||
"source_path": "https://civitai.com/images/92427432",
|
|
||||||
"folder": "test"
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 2269146,
|
|
||||||
"modelId": 2004760,
|
|
||||||
"name": "v1.0 Illustrious",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"trainedWords": ["PencilSketchDaal"],
|
|
||||||
"baseModel": "Illustrious",
|
|
||||||
"description": "<p>Illustrious. Your pencil may vary with your checkpoint. </p>",
|
|
||||||
"model": {
|
|
||||||
"name": "Pencil Sketch Anime",
|
|
||||||
"type": "LORA",
|
|
||||||
"nsfw": false,
|
|
||||||
"description": "description",
|
|
||||||
"tags": ["style"],
|
|
||||||
"allowNoCredit": true,
|
|
||||||
"allowCommercialUse": ["Sell"],
|
|
||||||
"allowDerivatives": true,
|
|
||||||
"allowDifferentLicense": true
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 2161260,
|
|
||||||
"sizeKB": 223106.37890625,
|
|
||||||
"name": "Pencil-Sketch-Illustrious.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"hashes": {
|
|
||||||
"SHA256": "2C70479CD673B0FE056EAF4FD97C7F33A39F14853805431AC9AB84226ECE3B82"
|
|
||||||
},
|
|
||||||
"primary": true,
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/2269146",
|
|
||||||
"mirrors": {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{},
|
|
||||||
{}
|
|
||||||
],
|
|
||||||
"creator": {
|
|
||||||
"username": "Daalis",
|
|
||||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/eb245b49-edc8-4ed6-ad7b-6d61eb8c51de/width=96/Daalis.jpeg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 1255556,
|
|
||||||
"modelId": 1117241,
|
|
||||||
"name": "v1.0",
|
|
||||||
"createdAt": "2025-01-08T06:13:08.839Z",
|
|
||||||
"updatedAt": "2025-01-08T06:28:54.156Z",
|
|
||||||
"status": "Published",
|
|
||||||
"publishedAt": "2025-01-08T06:28:54.155Z",
|
|
||||||
"trainedWords": ["in the style of ppWhimsy"],
|
|
||||||
"trainingStatus": null,
|
|
||||||
"trainingDetails": null,
|
|
||||||
"baseModel": "Flux.1 D",
|
|
||||||
"baseModelType": "Standard",
|
|
||||||
"earlyAccessEndsAt": null,
|
|
||||||
"earlyAccessConfig": null,
|
|
||||||
"description": null,
|
|
||||||
"uploadType": "Created",
|
|
||||||
"usageControl": "Download",
|
|
||||||
"air": "urn:air:flux1:lora:civitai:1117241@1255556",
|
|
||||||
"stats": {
|
|
||||||
"downloadCount": 210,
|
|
||||||
"ratingCount": 0,
|
|
||||||
"rating": 0,
|
|
||||||
"thumbsUpCount": 26
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"name": "Enchanted Whimsy style (Flux)",
|
|
||||||
"type": "LORA",
|
|
||||||
"nsfw": false,
|
|
||||||
"poi": false
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"id": 1160774,
|
|
||||||
"sizeKB": 38828.8125,
|
|
||||||
"name": "pp-enchanted-whimsy.safetensors",
|
|
||||||
"type": "Model",
|
|
||||||
"pickleScanResult": "Success",
|
|
||||||
"pickleScanMessage": "No Pickle imports",
|
|
||||||
"virusScanResult": "Success",
|
|
||||||
"virusScanMessage": null,
|
|
||||||
"scannedAt": "2025-01-08T06:16:27.731Z",
|
|
||||||
"metadata": {
|
|
||||||
"format": "SafeTensor",
|
|
||||||
"size": null,
|
|
||||||
"fp": null
|
|
||||||
},
|
|
||||||
"hashes": {
|
|
||||||
"AutoV1": "40CAF049",
|
|
||||||
"AutoV2": "3202778C3E",
|
|
||||||
"SHA256": "3202778C3EBE5CF7EBE5FC51561DEAE8611F4362036EB7C02EFA033C705E6240",
|
|
||||||
"CRC32": "69DCD953",
|
|
||||||
"BLAKE3": "ED04580DDB1AD36D8B87F4B0800F5930C7E5D4A7269BDC2BE26ED77EA1A34697",
|
|
||||||
"AutoV3": "BF82986F8597"
|
|
||||||
},
|
|
||||||
"primary": true,
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1255556"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/707aef9b-36fb-46c2-ac41-adcab539d3a6/width=832/50270101.jpeg",
|
|
||||||
"nsfwLevel": 1,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216,
|
|
||||||
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
|
|
||||||
"type": "image",
|
|
||||||
"metadata": {
|
|
||||||
"hash": "U7Am@@$^J3%100R;pLR.M]tQ-ps+?wRiVrof",
|
|
||||||
"size": 702313,
|
|
||||||
"width": 832,
|
|
||||||
"height": 1216
|
|
||||||
},
|
|
||||||
"minor": false,
|
|
||||||
"poi": false,
|
|
||||||
"meta": {
|
|
||||||
"prompt": "in the style of ppWhimsy, a close-up of a boy with a crown of ferns and tiny horns, his eyes wide with wonder as a family of glowing hedgehogs nestle in his hands, their spines shimmering with soft pastel colors"
|
|
||||||
},
|
|
||||||
"availability": "Public",
|
|
||||||
"hasMeta": true,
|
|
||||||
"hasPositivePrompt": true,
|
|
||||||
"onSite": false,
|
|
||||||
"remixOfId": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"downloadUrl": "https://civitai.com/api/download/models/1255556",
|
|
||||||
"creator": {
|
|
||||||
"username": "PixelPawsAI",
|
|
||||||
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/f3a1aa7c-0159-4dd8-884a-1e7ceb350f96/width=96/PixelPawsAI.jpeg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Style for selected cards */
|
/* Style for selected cards */
|
||||||
.model-card.selected {
|
.model-card.selected {
|
||||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
outline: 2px solid var(--lora-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -444,16 +444,161 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-model-selector {
|
/* ── Base Model Search Dropdown ─────────────────────────────────────────── */
|
||||||
width: 100%;
|
|
||||||
padding: 3px 5px;
|
.base-model-search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-search-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border: 1px solid var(--lora-accent);
|
border: 1px solid var(--lora-accent);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 0 6px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-search-input-wrapper .search-icon {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.45;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Reset global .search-icon rules from search-filter.css */
|
||||||
|
position: static;
|
||||||
|
right: auto;
|
||||||
|
top: auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
outline: none;
|
padding: 3px 0;
|
||||||
margin-right: var(--space-1);
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-search-input::placeholder {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: -1px;
|
||||||
|
right: -1px;
|
||||||
|
max-height: 270px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22);
|
||||||
|
z-index: 101;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .base-model-dropdown {
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown scrollbar styling */
|
||||||
|
.base-model-dropdown::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--lora-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.base-model-dropdown-section {
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header */
|
||||||
|
.base-model-dropdown-header {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-header.suggested-header {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
background: oklch(from var(--lora-accent) l c h / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-header.suggested-header i {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown items */
|
||||||
|
.base-model-dropdown-item {
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: background 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-item:hover {
|
||||||
|
background: oklch(from var(--lora-accent) l c h / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-item.active {
|
||||||
|
background: oklch(from var(--lora-accent) l c h / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-item.selected {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-model-dropdown-item.selected::after {
|
||||||
|
content: '✓';
|
||||||
|
float: right;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.base-model-dropdown-empty {
|
||||||
|
padding: 18px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 0.88em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.size-wrapper {
|
.size-wrapper {
|
||||||
|
|||||||
@@ -821,4 +821,66 @@
|
|||||||
|
|
||||||
[data-theme="dark"] .batch-preview-item {
|
[data-theme="dark"] .batch-preview-item {
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hf-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: oklch(0.55 0.12 250 / 0.15);
|
||||||
|
color: oklch(0.7 0.12 250);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Checkbox inside HF batch preview items */
|
||||||
|
.batch-preview-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select All toolbar in batch preview */
|
||||||
|
.batch-preview-select-all {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-select-all input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--lora-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-preview-select-all label {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .batch-preview-select-all {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|||||||
@@ -281,6 +281,157 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Sort dropdown — decoupled trigger width ===========================
|
||||||
|
The native <select> sizes its trigger to the widest <option>, wasting
|
||||||
|
horizontal space when a short option is selected. This custom trigger
|
||||||
|
sizes to the currently selected text only; the dropdown menu sizes to
|
||||||
|
its content independently. The native <select> is kept in the DOM
|
||||||
|
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
|
||||||
|
and dynamically adds/removes <option>s keeps working. */
|
||||||
|
|
||||||
|
.sort-dropdown-group {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 240px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger:hover,
|
||||||
|
.sort-trigger:focus-visible {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: var(--bg-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger__caret {
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: transform var(--transition-base);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group.active .sort-trigger__caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group.active .sort-trigger {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state — mirrors the native :disabled look (used when VLM is active) */
|
||||||
|
.sort-dropdown-group.is-disabled .sort-trigger {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown menu — sizes to its content, independent of trigger width.
|
||||||
|
Inherits base .dropdown-menu styling; capped for very long i18n text. */
|
||||||
|
.sort-dropdown-menu {
|
||||||
|
min-width: max-content;
|
||||||
|
max-width: 320px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optgroup label rendered as a section header */
|
||||||
|
.sort-dropdown-group .sort-optgroup-label {
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-optgroup-label:first-child {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option items */
|
||||||
|
.sort-dropdown-group .sort-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option::before {
|
||||||
|
content: '';
|
||||||
|
width: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option:hover {
|
||||||
|
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option.is-selected {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-group .sort-option.is-selected::before {
|
||||||
|
content: '\2713';
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visually hidden native <select> — kept in the DOM for programmatic access.
|
||||||
|
High-specificity selector overrides .control-group select { min-width: 100px }. */
|
||||||
|
.control-group .sort-select-native {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ensure hidden class works properly */
|
/* Ensure hidden class works properly */
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ export const DOWNLOAD_ENDPOINTS = {
|
|||||||
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
|
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hugging Face API endpoints
|
||||||
|
export const HF_ENDPOINTS = {
|
||||||
|
repoFiles: '/api/lm/hf-repo-files',
|
||||||
|
download: '/api/lm/download-hf-model',
|
||||||
|
};
|
||||||
|
|
||||||
// WebSocket endpoints
|
// WebSocket endpoints
|
||||||
export const WS_ENDPOINTS = {
|
export const WS_ENDPOINTS = {
|
||||||
fetchProgress: '/ws/fetch-progress'
|
fetchProgress: '/ws/fetch-progress'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getCurrentModelType,
|
getCurrentModelType,
|
||||||
isValidModelType,
|
isValidModelType,
|
||||||
DOWNLOAD_ENDPOINTS,
|
DOWNLOAD_ENDPOINTS,
|
||||||
|
HF_ENDPOINTS,
|
||||||
WS_ENDPOINTS
|
WS_ENDPOINTS
|
||||||
} from './apiConfig.js';
|
} from './apiConfig.js';
|
||||||
import { resetAndReload } from './modelApiFactory.js';
|
import { resetAndReload } from './modelApiFactory.js';
|
||||||
@@ -1243,6 +1244,48 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchHfRepoFiles(repo, revision = 'main') {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ repo, revision });
|
||||||
|
const response = await fetch(`${HF_ENDPOINTS.repoFiles}?${params}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Failed to fetch HF repo files');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching HF repo files:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadHfModel({ repo, filename, revision, modelRoot, relativePath, useDefaultPaths, download_id }) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(HF_ENDPOINTS.download, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
repo,
|
||||||
|
filename,
|
||||||
|
revision: revision || 'main',
|
||||||
|
model_root: modelRoot,
|
||||||
|
relative_path: relativePath || '',
|
||||||
|
use_default_paths: useDefaultPaths || false,
|
||||||
|
...(download_id ? { download_id } : {}),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading HF model:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_buildQueryParams(baseParams, pageState) {
|
_buildQueryParams(baseParams, pageState) {
|
||||||
const params = new URLSearchParams(baseParams);
|
const params = new URLSearchParams(baseParams);
|
||||||
const isExcludedView = pageState.viewMode === 'excluded';
|
const isExcludedView = pageState.viewMode === 'excluded';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
|
|||||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
import { sidebarManager } from '../SidebarManager.js';
|
import { sidebarManager } from '../SidebarManager.js';
|
||||||
|
import { initSortDropdown } from './SortDropdown.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PageControls class - Unified control management for model pages
|
* PageControls class - Unified control management for model pages
|
||||||
@@ -106,6 +107,7 @@ export class PageControls {
|
|||||||
// Sort select handler
|
// Sort select handler
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy;
|
sortSelect.value = this.pageState.sortBy;
|
||||||
sortSelect.addEventListener('change', async (e) => {
|
sortSelect.addEventListener('change', async (e) => {
|
||||||
this.pageState.sortBy = e.target.value;
|
this.pageState.sortBy = e.target.value;
|
||||||
@@ -314,7 +316,12 @@ export class PageControls {
|
|||||||
* Load sort preference from storage
|
* Load sort preference from storage
|
||||||
*/
|
*/
|
||||||
loadSortPreference() {
|
loadSortPreference() {
|
||||||
const savedSort = getStorageItem(`${this.pageType}_sort`);
|
// Use separate keys for grouped vs non-grouped sort so each mode
|
||||||
|
// remembers its own preference independently
|
||||||
|
const key = state.global.settings.group_by_model
|
||||||
|
? `${this.pageType}_sort_grouped`
|
||||||
|
: `${this.pageType}_sort`;
|
||||||
|
const savedSort = getStorageItem(key);
|
||||||
if (savedSort) {
|
if (savedSort) {
|
||||||
// Handle legacy format conversion
|
// Handle legacy format conversion
|
||||||
const convertedSort = this.convertLegacySortFormat(savedSort);
|
const convertedSort = this.convertLegacySortFormat(savedSort);
|
||||||
@@ -358,7 +365,11 @@ export class PageControls {
|
|||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
// Separate storage for grouped vs non-grouped sort
|
||||||
|
const key = state.global.settings.group_by_model
|
||||||
|
? `${this.pageType}_sort_grouped`
|
||||||
|
: `${this.pageType}_sort`;
|
||||||
|
setStorageItem(key, sortValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -553,37 +564,28 @@ export class PageControls {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when group_by_model is toggled.
|
* Called when group_by_model is toggled.
|
||||||
* Saves current sort when entering grouped mode, restores normal sort
|
* Swaps between {pageType}_sort (non-group) and {pageType}_sort_grouped,
|
||||||
* when leaving — prevents "Most versions first" persisting after exit.
|
* so each mode remembers its own sort preference independently.
|
||||||
*/
|
*/
|
||||||
onGroupByModelToggled(isEnabled) {
|
onGroupByModelToggled(isEnabled) {
|
||||||
const normalKey = `${this.pageType}_sort_normal`;
|
|
||||||
const groupedKey = `${this.pageType}_sort_grouped`;
|
const groupedKey = `${this.pageType}_sort_grouped`;
|
||||||
|
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
// Entering group mode: save current sort for later restoration
|
// Entering group mode: restore last-used grouped sort, if any
|
||||||
setStorageItem(normalKey, this.pageState.sortBy);
|
|
||||||
// Restore previously saved grouped sort, if any
|
|
||||||
const savedGroupedSort = getStorageItem(groupedKey);
|
const savedGroupedSort = getStorageItem(groupedKey);
|
||||||
if (savedGroupedSort) {
|
if (savedGroupedSort) {
|
||||||
this.pageState.sortBy = savedGroupedSort;
|
this.pageState.sortBy = savedGroupedSort;
|
||||||
this.saveSortPreference(savedGroupedSort);
|
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.value = savedGroupedSort;
|
sortSelect.value = savedGroupedSort;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Leaving group mode: save current grouped sort aside, restore normal
|
// Leaving group mode: persist current sort for next time, restore non-group sort
|
||||||
const currentSort = this.pageState.sortBy;
|
setStorageItem(groupedKey, this.pageState.sortBy);
|
||||||
if (currentSort && currentSort.startsWith('versions_count')) {
|
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
|
||||||
setStorageItem(groupedKey, currentSort);
|
|
||||||
}
|
|
||||||
const savedNormalSort = getStorageItem(normalKey);
|
|
||||||
if (savedNormalSort) {
|
if (savedNormalSort) {
|
||||||
removeStorageItem(normalKey);
|
|
||||||
this.pageState.sortBy = savedNormalSort;
|
this.pageState.sortBy = savedNormalSort;
|
||||||
this.saveSortPreference(savedNormalSort);
|
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.value = savedNormalSort;
|
sortSelect.value = savedNormalSort;
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
// SortDropdown.js — Decoupled sort trigger.
|
||||||
|
//
|
||||||
|
// The native <select> sizes its trigger to the widest <option>, so long
|
||||||
|
// options (e.g. "Fewest versions first") or long i18n translations force the
|
||||||
|
// control to be far wider than the selected text needs. This module wraps the
|
||||||
|
// existing <select> with a custom trigger + menu that mirror its state, so the
|
||||||
|
// trigger sizes to the selected text while the menu sizes to its content.
|
||||||
|
//
|
||||||
|
// The native <select> stays in the DOM (visually hidden) so existing code that
|
||||||
|
// reads/writes `.value` / `.disabled` and dynamically adds/removes <option>s
|
||||||
|
// (e.g. the VLM temporary option) keeps working unchanged. The `value` and
|
||||||
|
// `disabled` setters are overridden on the instance to keep the trigger label
|
||||||
|
// and disabled styling in sync with programmatic changes.
|
||||||
|
//
|
||||||
|
// Keyboard navigation (arrows, Home/End, type-to-select) mirrors native
|
||||||
|
// <select> behavior so the control remains fully accessible.
|
||||||
|
|
||||||
|
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
|
||||||
|
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a decoupled sort dropdown around a native <select>.
|
||||||
|
* Idempotent: safe to call more than once on the same element.
|
||||||
|
* @param {HTMLSelectElement|null} select
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function initSortDropdown(select) {
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const group = select.closest(SORT_GROUP_SELECTOR);
|
||||||
|
if (!group || group.dataset.sortReady === '1') return;
|
||||||
|
|
||||||
|
const trigger = group.querySelector('.sort-trigger');
|
||||||
|
const menu = group.querySelector('.sort-dropdown-menu');
|
||||||
|
const label = group.querySelector('.sort-trigger__label');
|
||||||
|
if (!trigger || !menu || !label) return;
|
||||||
|
|
||||||
|
const getOptions = () => menu.querySelectorAll('.sort-option');
|
||||||
|
|
||||||
|
const buildItem = (opt) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'sort-option';
|
||||||
|
item.setAttribute('role', 'option');
|
||||||
|
item.tabIndex = -1;
|
||||||
|
item.dataset.value = opt.value;
|
||||||
|
item.textContent = opt.textContent;
|
||||||
|
item.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (select.disabled) return;
|
||||||
|
choose(opt.value);
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMenu = () => {
|
||||||
|
menu.innerHTML = '';
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const child of Array.from(select.children)) {
|
||||||
|
if (child.tagName === 'OPTGROUP') {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'sort-optgroup-label';
|
||||||
|
header.textContent = child.label || '';
|
||||||
|
fragment.appendChild(header);
|
||||||
|
for (const opt of Array.from(child.children)) {
|
||||||
|
fragment.appendChild(buildItem(opt));
|
||||||
|
}
|
||||||
|
} else if (child.tagName === 'OPTION') {
|
||||||
|
fragment.appendChild(buildItem(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu.appendChild(fragment);
|
||||||
|
syncSelected();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSelected = () => {
|
||||||
|
const value = select.value;
|
||||||
|
let labelText = '';
|
||||||
|
let matched = false;
|
||||||
|
getOptions().forEach((el) => {
|
||||||
|
const selected = el.dataset.value === value;
|
||||||
|
el.classList.toggle('is-selected', selected);
|
||||||
|
el.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||||
|
if (selected) {
|
||||||
|
labelText = el.textContent;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!matched) {
|
||||||
|
const opt = select.querySelector(`option[value="${cssEscape(value)}"]`);
|
||||||
|
labelText = opt
|
||||||
|
? opt.textContent
|
||||||
|
: (select.options[select.selectedIndex]?.textContent ?? '');
|
||||||
|
}
|
||||||
|
label.textContent = labelText;
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = (value) => {
|
||||||
|
if (select.value === value) return;
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => {
|
||||||
|
if (g !== group) g.classList.remove('active');
|
||||||
|
});
|
||||||
|
group.classList.add('active');
|
||||||
|
trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
// Focus the currently selected option (or the first option) so
|
||||||
|
// keyboard navigation starts from a sensible position.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const selected = menu.querySelector('.sort-option.is-selected');
|
||||||
|
(selected || getOptions()[0])?.focus();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
group.classList.remove('active');
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (group.classList.contains('active')) close();
|
||||||
|
else open();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- keyboard navigation ----
|
||||||
|
|
||||||
|
// Type-to-select buffer: accumulate characters and reset after a pause.
|
||||||
|
// Shared between trigger and menu keydown handlers.
|
||||||
|
let typeBuffer = '';
|
||||||
|
let typeTimer = null;
|
||||||
|
|
||||||
|
const focusOptionByText = (prefix) => {
|
||||||
|
const options = getOptions();
|
||||||
|
const lower = prefix.toLowerCase();
|
||||||
|
for (let i = 0; i < options.length; i++) {
|
||||||
|
if (options[i].textContent.toLowerCase().startsWith(lower)) {
|
||||||
|
options[i].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFocus = (options, direction) => {
|
||||||
|
const focused = menu.querySelector('.sort-option:focus');
|
||||||
|
let idx = focused ? Array.from(options).indexOf(focused) : -1;
|
||||||
|
idx = Math.max(0, Math.min(options.length - 1, idx + direction));
|
||||||
|
options[idx]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeToSelect = (event) => {
|
||||||
|
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
clearTimeout(typeTimer);
|
||||||
|
typeBuffer += event.key;
|
||||||
|
focusOptionByText(typeBuffer);
|
||||||
|
typeTimer = setTimeout(() => { typeBuffer = ''; }, 800);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (select.disabled) return;
|
||||||
|
toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
trigger.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!select.disabled) toggle();
|
||||||
|
} else if (!group.classList.contains('active')) {
|
||||||
|
// Type-to-select on closed dropdown: open and highlight match
|
||||||
|
if (handleTypeToSelect(event)) {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.addEventListener('keydown', (event) => {
|
||||||
|
const options = getOptions();
|
||||||
|
if (options.length === 0) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
trigger.focus();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
moveFocus(options, 1);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
moveFocus(options, -1);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Home':
|
||||||
|
event.preventDefault();
|
||||||
|
options[0]?.focus();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'End':
|
||||||
|
event.preventDefault();
|
||||||
|
options[options.length - 1]?.focus();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
event.preventDefault();
|
||||||
|
if (select.disabled) return;
|
||||||
|
const focused = menu.querySelector('.sort-option:focus');
|
||||||
|
if (focused) {
|
||||||
|
choose(focused.dataset.value);
|
||||||
|
close();
|
||||||
|
trigger.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTypeToSelect(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
if (!group.contains(event.target)) {
|
||||||
|
const wasOpen = group.classList.contains('active');
|
||||||
|
close();
|
||||||
|
// Only return focus to the trigger when the dropdown was actually
|
||||||
|
// open — avoids forcing scrollIntoView on every page click (which
|
||||||
|
// causes the scroll container to jump when clicking a model card).
|
||||||
|
if (wasOpen) trigger.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- property overrides ----
|
||||||
|
|
||||||
|
// Override `value` and `disabled` on this instance so programmatic
|
||||||
|
// changes (loadSortPreference, VLM toggle, excluded-view sync, ...) keep
|
||||||
|
// the trigger label and disabled styling in sync without touching callers.
|
||||||
|
const proto = Object.getPrototypeOf(select);
|
||||||
|
const valueDescriptor =
|
||||||
|
Object.getOwnPropertyDescriptor(proto, 'value') ||
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
|
||||||
|
const disabledDescriptor =
|
||||||
|
Object.getOwnPropertyDescriptor(proto, 'disabled') ||
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'disabled');
|
||||||
|
|
||||||
|
if (valueDescriptor) {
|
||||||
|
Object.defineProperty(select, 'value', {
|
||||||
|
get() { return valueDescriptor.get.call(this); },
|
||||||
|
set(v) {
|
||||||
|
valueDescriptor.set.call(this, v);
|
||||||
|
syncSelected();
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disabledDescriptor) {
|
||||||
|
Object.defineProperty(select, 'disabled', {
|
||||||
|
get() { return disabledDescriptor.get.call(this); },
|
||||||
|
set(v) {
|
||||||
|
disabledDescriptor.set.call(this, v);
|
||||||
|
group.classList.toggle('is-disabled', Boolean(v));
|
||||||
|
trigger.disabled = Boolean(v);
|
||||||
|
if (v) close();
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the menu when <option>s change (VLM adds/removes a temporary
|
||||||
|
// option at runtime).
|
||||||
|
const observer = new MutationObserver(() => buildMenu());
|
||||||
|
observer.observe(select, { childList: true });
|
||||||
|
|
||||||
|
buildMenu();
|
||||||
|
group.dataset.sortReady = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscape(value) {
|
||||||
|
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||||
|
return CSS.escape(value);
|
||||||
|
}
|
||||||
|
// Fallback for environments without CSS.escape
|
||||||
|
return String(value).replace(/[!"#$%&'()*+,./:;<=>?@[\]^`{|}~\\ -]/g, '\\$&');
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../../state/index.js';
|
import { state, getCurrentPageState } from '../../state/index.js';
|
||||||
import { showModelModal } from './ModelModal.js';
|
import { showModelModal } from './ModelModal.js';
|
||||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||||
@@ -66,6 +66,8 @@ function handleModelCardEvent_internal(event, modelType) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (card.dataset.from_civitai === 'true') {
|
if (card.dataset.from_civitai === 'true') {
|
||||||
openCivitai(card.dataset.filepath);
|
openCivitai(card.dataset.filepath);
|
||||||
|
} else if (card.dataset.hf_url) {
|
||||||
|
openHuggingFace(card.dataset.hf_url);
|
||||||
}
|
}
|
||||||
return true; // Stop propagation
|
return true; // Stop propagation
|
||||||
}
|
}
|
||||||
@@ -313,6 +315,7 @@ async function showModelModalFromCard(card, modelType) {
|
|||||||
modified: card.dataset.modified,
|
modified: card.dataset.modified,
|
||||||
file_size: parseInt(card.dataset.file_size || '0'),
|
file_size: parseInt(card.dataset.file_size || '0'),
|
||||||
from_civitai: card.dataset.from_civitai === 'true',
|
from_civitai: card.dataset.from_civitai === 'true',
|
||||||
|
hf_url: card.dataset.hf_url || '',
|
||||||
base_model: card.dataset.base_model,
|
base_model: card.dataset.base_model,
|
||||||
notes: card.dataset.notes || '',
|
notes: card.dataset.notes || '',
|
||||||
favorite: card.dataset.favorite === 'true',
|
favorite: card.dataset.favorite === 'true',
|
||||||
@@ -401,6 +404,7 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
modified: card.dataset.modified,
|
modified: card.dataset.modified,
|
||||||
file_size: card.dataset.file_size,
|
file_size: card.dataset.file_size,
|
||||||
from_civitai: card.dataset.from_civitai === 'true',
|
from_civitai: card.dataset.from_civitai === 'true',
|
||||||
|
hf_url: card.dataset.hf_url || '',
|
||||||
base_model: card.dataset.base_model,
|
base_model: card.dataset.base_model,
|
||||||
notes: card.dataset.notes,
|
notes: card.dataset.notes,
|
||||||
favorite: card.dataset.favorite === 'true',
|
favorite: card.dataset.favorite === 'true',
|
||||||
@@ -467,6 +471,7 @@ export function createModelCard(model, modelType) {
|
|||||||
card.dataset.base_model = model.base_model || 'Unknown';
|
card.dataset.base_model = model.base_model || 'Unknown';
|
||||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||||
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||||||
|
card.dataset.hf_url = model.hf_url || '';
|
||||||
const hasUpdateAvailable = Boolean(model.update_available);
|
const hasUpdateAvailable = Boolean(model.update_available);
|
||||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||||
@@ -578,7 +583,10 @@ export function createModelCard(model, modelType) {
|
|||||||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||||
const globeTitle = model.from_civitai ?
|
const globeTitle = model.from_civitai ?
|
||||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
model.hf_url ?
|
||||||
|
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
|
||||||
|
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||||
|
const globeEnabled = model.from_civitai || !!model.hf_url;
|
||||||
let sendTitle;
|
let sendTitle;
|
||||||
let copyTitle;
|
let copyTitle;
|
||||||
if (modelType === MODEL_TYPES.LORA) {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
@@ -603,7 +611,7 @@ export function createModelCard(model, modelType) {
|
|||||||
</i>
|
</i>
|
||||||
<i class="fas fa-globe"
|
<i class="fas fa-globe"
|
||||||
title="${globeTitle}"
|
title="${globeTitle}"
|
||||||
${!model.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
${!globeEnabled ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||||
</i>
|
</i>
|
||||||
<i class="fas fa-paper-plane"
|
<i class="fas fa-paper-plane"
|
||||||
title="${sendTitle}">
|
title="${sendTitle}">
|
||||||
|
|||||||
@@ -3,9 +3,75 @@
|
|||||||
* Handles model metadata editing functionality - General version
|
* Handles model metadata editing functionality - General version
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BASE_MODEL_CATEGORIES } from '../../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
import { translate } from '../../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
// ── Filename-based base model inference ──────────────────────────────────────
|
||||||
|
// Rules are ordered by specificity — first match wins for dedup.
|
||||||
|
// Each rule checks the filename (lowercased) for a regex pattern and suggests
|
||||||
|
// the associated base model values.
|
||||||
|
|
||||||
|
const BASE_MODEL_FILENAME_RULES = [
|
||||||
|
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
|
||||||
|
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
|
||||||
|
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
|
||||||
|
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
|
||||||
|
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
|
||||||
|
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
|
||||||
|
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
|
||||||
|
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
|
||||||
|
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
|
||||||
|
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
|
||||||
|
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
|
||||||
|
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
|
||||||
|
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
|
||||||
|
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
|
||||||
|
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
|
||||||
|
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
|
||||||
|
{ pattern: /illustrious/i, models: ['Illustrious'] },
|
||||||
|
{ pattern: /noobai/i, models: ['NoobAI'] },
|
||||||
|
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
|
||||||
|
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
|
||||||
|
{ pattern: /kolors/i, models: ['Kolors'] },
|
||||||
|
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
|
||||||
|
{ pattern: /lumina/i, models: ['Lumina'] },
|
||||||
|
{ pattern: /hidream/i, models: ['HiDream'] },
|
||||||
|
{ pattern: /qwen/i, models: ['Qwen'] },
|
||||||
|
{ pattern: /chroma/i, models: ['Chroma'] },
|
||||||
|
{ pattern: /anima/i, models: ['Anima'] },
|
||||||
|
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
|
||||||
|
{ pattern: /mochi/i, models: ['Mochi'] },
|
||||||
|
{ pattern: /svd/i, models: ['SVD'] },
|
||||||
|
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
|
||||||
|
{ pattern: /nucleus/i, models: ['Nucleus'] },
|
||||||
|
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
|
||||||
|
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer likely base model(s) from a filename + model name string.
|
||||||
|
* Returns a deduplicated array in match-priority order.
|
||||||
|
* @param {string} filename
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function inferBaseModelsFromFilename(filename) {
|
||||||
|
if (!filename || typeof filename !== 'string') return [];
|
||||||
|
const seen = new Set();
|
||||||
|
const results = [];
|
||||||
|
for (const rule of BASE_MODEL_FILENAME_RULES) {
|
||||||
|
if (rule.pattern.test(filename)) {
|
||||||
|
for (const model of rule.models) {
|
||||||
|
if (!seen.has(model)) {
|
||||||
|
seen.add(model);
|
||||||
|
results.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the active file path for the currently open model modal.
|
* Resolve the active file path for the currently open model modal.
|
||||||
@@ -226,7 +292,9 @@ export function setupModelNameEditing(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up base model editing functionality
|
* Set up base model editing functionality with searchable dropdown
|
||||||
|
* Shows filename-inferred suggestions at the top, supports keyboard navigation,
|
||||||
|
* and allows typing custom values.
|
||||||
* @param {string} filePath - File path
|
* @param {string} filePath - File path
|
||||||
*/
|
*/
|
||||||
export function setupBaseModelEditing(filePath) {
|
export function setupBaseModelEditing(filePath) {
|
||||||
@@ -257,98 +325,251 @@ export function setupBaseModelEditing(filePath) {
|
|||||||
// Store the original value to check for changes later
|
// Store the original value to check for changes later
|
||||||
const originalValue = baseModelContent.textContent.trim();
|
const originalValue = baseModelContent.textContent.trim();
|
||||||
|
|
||||||
// Create dropdown selector to replace the base model content
|
// ── Build the full option list ────────────────────────────────────────
|
||||||
const currentValue = originalValue;
|
const allModels = []; // { value, label, category }
|
||||||
const dropdown = document.createElement('select');
|
const categorizedModels = new Set();
|
||||||
dropdown.className = 'base-model-selector';
|
|
||||||
|
|
||||||
// Flag to track if a change was made
|
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
|
||||||
let valueChanged = false;
|
|
||||||
|
|
||||||
// Add options from BASE_MODEL_CATEGORIES constants
|
|
||||||
const baseModelCategories = BASE_MODEL_CATEGORIES;
|
|
||||||
|
|
||||||
// Create option groups for better organization
|
|
||||||
Object.entries(baseModelCategories).forEach(([category, models]) => {
|
|
||||||
const group = document.createElement('optgroup');
|
|
||||||
group.label = category;
|
|
||||||
|
|
||||||
models.forEach(model => {
|
models.forEach(model => {
|
||||||
const option = document.createElement('option');
|
allModels.push({ value: model, label: model, category });
|
||||||
option.value = model;
|
categorizedModels.add(model);
|
||||||
option.textContent = model;
|
});
|
||||||
option.selected = model === currentValue;
|
});
|
||||||
group.appendChild(option);
|
|
||||||
|
const mergedModels = getMergedBaseModels();
|
||||||
|
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
|
||||||
|
if (uncategorizedModels.length > 0) {
|
||||||
|
uncategorizedModels.forEach(model => {
|
||||||
|
allModels.push({ value: model, label: model, category: 'Other (API)' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filename-based inference ──────────────────────────────────────────
|
||||||
|
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
|
||||||
|
(document.querySelector('.model-name-content')?.textContent || '');
|
||||||
|
const inferredModels = inferBaseModelsFromFilename(fileName);
|
||||||
|
const inferredSet = new Set(inferredModels);
|
||||||
|
|
||||||
|
// ── Build search widget DOM ───────────────────────────────────────────
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'base-model-search-wrapper';
|
||||||
|
|
||||||
|
// Search input row
|
||||||
|
const inputWrapper = document.createElement('div');
|
||||||
|
inputWrapper.className = 'base-model-search-input-wrapper';
|
||||||
|
const searchIcon = document.createElement('i');
|
||||||
|
searchIcon.className = 'fas fa-search search-icon';
|
||||||
|
searchIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
inputWrapper.appendChild(searchIcon);
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.type = 'text';
|
||||||
|
searchInput.className = 'base-model-search-input';
|
||||||
|
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
|
||||||
|
searchInput.autocomplete = 'off';
|
||||||
|
searchInput.spellcheck = false;
|
||||||
|
inputWrapper.appendChild(searchInput);
|
||||||
|
wrapper.appendChild(inputWrapper);
|
||||||
|
|
||||||
|
// Dropdown list
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'base-model-dropdown';
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
function renderDropdown(filterText) {
|
||||||
|
const lowerFilter = (filterText || '').toLowerCase().trim();
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
let hasVisibleItems = false;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
// 1. Suggested section (filename-inferred, filtered by search)
|
||||||
|
let suggestedToShow = inferredModels;
|
||||||
|
if (lowerFilter) {
|
||||||
|
suggestedToShow = inferredModels.filter(m =>
|
||||||
|
m.toLowerCase().includes(lowerFilter)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestedToShow.length > 0) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'base-model-dropdown-section';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'base-model-dropdown-header suggested-header';
|
||||||
|
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
|
||||||
|
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
suggestedToShow.forEach(model => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'base-model-dropdown-item';
|
||||||
|
if (model === originalValue) item.classList.add('selected');
|
||||||
|
item.dataset.value = model;
|
||||||
|
item.textContent = model;
|
||||||
|
section.appendChild(item);
|
||||||
|
hasVisibleItems = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Categorized options (deduplicated against suggestions)
|
||||||
|
const categoryMap = {};
|
||||||
|
allModels.forEach(m => {
|
||||||
|
if (inferredSet.has(m.value)) return; // already shown in Suggested
|
||||||
|
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
|
||||||
|
if (!categoryMap[m.category]) categoryMap[m.category] = [];
|
||||||
|
categoryMap[m.category].push(m);
|
||||||
});
|
});
|
||||||
|
|
||||||
dropdown.appendChild(group);
|
Object.entries(categoryMap).forEach(([category, items]) => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'base-model-dropdown-section';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'base-model-dropdown-header';
|
||||||
|
header.textContent = category;
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
items.forEach(m => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'base-model-dropdown-item';
|
||||||
|
if (m.value === originalValue) item.classList.add('selected');
|
||||||
|
item.dataset.value = m.value;
|
||||||
|
item.textContent = m.label;
|
||||||
|
section.appendChild(item);
|
||||||
|
hasVisibleItems = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
fragment.appendChild(section);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Empty state
|
||||||
|
if (!hasVisibleItems) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'base-model-dropdown-empty';
|
||||||
|
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
|
||||||
|
fragment.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.appendChild(fragment);
|
||||||
|
|
||||||
|
// Scroll the selected item into view
|
||||||
|
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
|
||||||
|
if (selected) {
|
||||||
|
selected.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial render — show everything
|
||||||
|
renderDropdown('');
|
||||||
|
|
||||||
|
// ── Events ────────────────────────────────────────────────────────────
|
||||||
|
let filterTimeout;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(filterTimeout);
|
||||||
|
filterTimeout = setTimeout(() => renderDropdown(searchInput.value), 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace content with dropdown
|
// Click to select
|
||||||
|
dropdown.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.base-model-dropdown-item');
|
||||||
|
if (!item) return;
|
||||||
|
baseModelContent.textContent = item.dataset.value;
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace content with search widget
|
||||||
baseModelContent.style.display = 'none';
|
baseModelContent.style.display = 'none';
|
||||||
baseModelDisplay.insertBefore(dropdown, editBtn);
|
|
||||||
|
|
||||||
// Hide edit button during editing
|
|
||||||
editBtn.style.display = 'none';
|
editBtn.style.display = 'none';
|
||||||
|
baseModelDisplay.insertBefore(wrapper, editBtn);
|
||||||
|
searchInput.focus();
|
||||||
|
|
||||||
// Focus the dropdown
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||||
dropdown.focus();
|
function cleanup() {
|
||||||
|
if (wrapper.parentNode === baseModelDisplay) {
|
||||||
// Handle dropdown change
|
baseModelDisplay.removeChild(wrapper);
|
||||||
dropdown.addEventListener('change', function() {
|
|
||||||
const selectedModel = this.value;
|
|
||||||
baseModelContent.textContent = selectedModel;
|
|
||||||
|
|
||||||
// Mark that a change was made if the value differs from original
|
|
||||||
if (selectedModel !== originalValue) {
|
|
||||||
valueChanged = true;
|
|
||||||
} else {
|
|
||||||
valueChanged = false;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Function to save changes and exit edit mode
|
|
||||||
const saveAndExit = function() {
|
|
||||||
// Check if dropdown still exists and remove it
|
|
||||||
if (dropdown && dropdown.parentNode === baseModelDisplay) {
|
|
||||||
baseModelDisplay.removeChild(dropdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the content and edit button
|
|
||||||
baseModelContent.style.display = '';
|
baseModelContent.style.display = '';
|
||||||
editBtn.style.display = '';
|
editBtn.style.display = '';
|
||||||
|
|
||||||
// Remove editing class
|
|
||||||
baseModelDisplay.classList.remove('editing');
|
baseModelDisplay.classList.remove('editing');
|
||||||
|
|
||||||
// Only save if the value has actually changed
|
|
||||||
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
|
|
||||||
const resolvedPath = getActiveModalFilePath(baseModelContent.dataset.filePath);
|
|
||||||
saveBaseModel(resolvedPath, originalValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove this event listener
|
|
||||||
document.removeEventListener('click', outsideClickHandler);
|
document.removeEventListener('click', outsideClickHandler);
|
||||||
};
|
}
|
||||||
|
|
||||||
// Handle outside clicks to save and exit
|
// Outside click → save typed/custom value if any
|
||||||
const outsideClickHandler = function(e) {
|
const outsideClickHandler = function(e) {
|
||||||
// If click is outside the dropdown and base model display
|
if (wrapper.contains(e.target)) return;
|
||||||
if (!baseModelDisplay.contains(e.target)) {
|
|
||||||
saveAndExit();
|
// If user typed a custom value (not just empty), apply it
|
||||||
|
const typedValue = searchInput.value.trim();
|
||||||
|
if (typedValue) {
|
||||||
|
baseModelContent.textContent = typedValue;
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add delayed event listener for outside clicks
|
// Defer listener to avoid the opening click itself
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', outsideClickHandler);
|
document.addEventListener('click', outsideClickHandler);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Also handle dropdown blur event
|
// Keyboard navigation
|
||||||
dropdown.addEventListener('blur', function(e) {
|
searchInput.addEventListener('keydown', function onKeydown(e) {
|
||||||
// Only save if the related target is not the edit button or inside the baseModelDisplay
|
const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
|
||||||
if (!baseModelDisplay.contains(e.relatedTarget)) {
|
const activeIdx = items.findIndex(el => el.classList.contains('active'));
|
||||||
saveAndExit();
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
items.forEach(el => el.classList.remove('active'));
|
||||||
|
const next = Math.min(activeIdx + 1, items.length - 1);
|
||||||
|
if (items[next]) {
|
||||||
|
items[next].classList.add('active');
|
||||||
|
items[next].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
items.forEach(el => el.classList.remove('active'));
|
||||||
|
const prev = Math.max(activeIdx - 1, 0);
|
||||||
|
if (items[prev]) {
|
||||||
|
items[prev].classList.add('active');
|
||||||
|
items[prev].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const activeItem = items.find(el => el.classList.contains('active'));
|
||||||
|
if (activeItem) {
|
||||||
|
activeItem.click();
|
||||||
|
} else if (searchInput.value.trim()) {
|
||||||
|
// Custom value typed
|
||||||
|
baseModelContent.textContent = searchInput.value.trim();
|
||||||
|
cleanup();
|
||||||
|
const finalValue = baseModelContent.textContent.trim();
|
||||||
|
if (finalValue !== originalValue) {
|
||||||
|
saveBaseModel(
|
||||||
|
getActiveModalFilePath(baseModelContent.dataset.filePath),
|
||||||
|
originalValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
baseModelContent.textContent = originalValue;
|
||||||
|
cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -360,6 +360,11 @@ export async function showModelModal(model, modelType) {
|
|||||||
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
||||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
||||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||||
|
</div>`.trim() : '';
|
||||||
|
const escapedHfUrl = modelWithFullData.hf_url ? escapeAttribute(modelWithFullData.hf_url) : '';
|
||||||
|
const viewOnHuggingFaceAction = escapedHfUrl ? `
|
||||||
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnHuggingFace', {}, 'View on Hugging Face')}" data-action="view-huggingface" data-hf-url="${escapedHfUrl}">
|
||||||
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnHuggingFaceText', {}, 'View on Hugging Face')}
|
||||||
</div>`.trim() : '';
|
</div>`.trim() : '';
|
||||||
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
|
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
|
||||||
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
|
||||||
@@ -377,6 +382,9 @@ export async function showModelModal(model, modelType) {
|
|||||||
if (viewOnCivitaiAction) {
|
if (viewOnCivitaiAction) {
|
||||||
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
|
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
|
||||||
}
|
}
|
||||||
|
if (viewOnHuggingFaceAction) {
|
||||||
|
creatorActionItems.push(indentMarkup(viewOnHuggingFaceAction, 24));
|
||||||
|
}
|
||||||
if (creatorInfoAction) {
|
if (creatorInfoAction) {
|
||||||
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
|
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
|
||||||
}
|
}
|
||||||
@@ -869,6 +877,11 @@ function setupEventHandlers(filePath, modelType) {
|
|||||||
case 'view-civitai':
|
case 'view-civitai':
|
||||||
openCivitai(target.dataset.filepath);
|
openCivitai(target.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'view-huggingface':
|
||||||
|
if (target.dataset.hfUrl) {
|
||||||
|
window.open(target.dataset.hfUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'view-creator':
|
case 'view-creator':
|
||||||
const username = target.dataset.username;
|
const username = target.dataset.username;
|
||||||
if (username) {
|
if (username) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class BulkManager {
|
|||||||
this.isMarqueeActive = false;
|
this.isMarqueeActive = false;
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
this.marqueeStart = { x: 0, y: 0 };
|
this.marqueeStart = { x: 0, y: 0 };
|
||||||
|
this.marqueeStartDoc = { x: 0, y: 0 }; // Marquee start in document coordinates
|
||||||
this.marqueeElement = null;
|
this.marqueeElement = null;
|
||||||
this.initialSelectedModels = new Set();
|
this.initialSelectedModels = new Set();
|
||||||
|
|
||||||
@@ -29,6 +30,11 @@ export class BulkManager {
|
|||||||
this.mouseDownTime = 0;
|
this.mouseDownTime = 0;
|
||||||
this.mouseDownPosition = { x: 0, y: 0 };
|
this.mouseDownPosition = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
// Auto-scroll properties for marquee
|
||||||
|
this.lastClientX = 0;
|
||||||
|
this.lastClientY = 0;
|
||||||
|
this.autoScrollRaf = null;
|
||||||
|
|
||||||
// Model type specific action configurations
|
// Model type specific action configurations
|
||||||
this.actionConfig = {
|
this.actionConfig = {
|
||||||
[MODEL_TYPES.LORA]: {
|
[MODEL_TYPES.LORA]: {
|
||||||
@@ -168,7 +174,10 @@ export class BulkManager {
|
|||||||
|
|
||||||
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
|
||||||
if (this.isMarqueeActive) {
|
if (this.isMarqueeActive) {
|
||||||
|
this.lastClientX = e.clientX;
|
||||||
|
this.lastClientY = e.clientY;
|
||||||
this.updateMarqueeSelection(e);
|
this.updateMarqueeSelection(e);
|
||||||
|
this.startAutoScroll();
|
||||||
} else if (this.mouseDownTime && !this.isDragging) {
|
} else if (this.mouseDownTime && !this.isDragging) {
|
||||||
// Check if we've moved enough to consider it a drag
|
// Check if we've moved enough to consider it a drag
|
||||||
const dx = e.clientX - this.mouseDownPosition.x;
|
const dx = e.clientX - this.mouseDownPosition.x;
|
||||||
@@ -237,6 +246,7 @@ export class BulkManager {
|
|||||||
* Clean up event handlers
|
* Clean up event handlers
|
||||||
*/
|
*/
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
this.stopAutoScroll();
|
||||||
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
|
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
|
||||||
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
|
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
|
||||||
eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
|
eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
|
||||||
@@ -1727,10 +1737,15 @@ export class BulkManager {
|
|||||||
* @param {boolean} isDragging - Whether this is triggered from a drag operation
|
* @param {boolean} isDragging - Whether this is triggered from a drag operation
|
||||||
*/
|
*/
|
||||||
startMarqueeSelection(e, isDragging = false) {
|
startMarqueeSelection(e, isDragging = false) {
|
||||||
// Store initial mouse position
|
// Store initial mouse position (viewport coordinates for visual element)
|
||||||
this.marqueeStart.x = this.mouseDownPosition.x;
|
this.marqueeStart.x = this.mouseDownPosition.x;
|
||||||
this.marqueeStart.y = this.mouseDownPosition.y;
|
this.marqueeStart.y = this.mouseDownPosition.y;
|
||||||
|
|
||||||
|
// Store initial mouse position in document coordinates (for logical selection)
|
||||||
|
const container = document.querySelector('.page-content');
|
||||||
|
this.marqueeStartDoc.x = this.mouseDownPosition.x + (container?.scrollLeft || 0);
|
||||||
|
this.marqueeStartDoc.y = this.mouseDownPosition.y + (container?.scrollTop || 0);
|
||||||
|
|
||||||
// Store initial selection state
|
// Store initial selection state
|
||||||
this.initialSelectedModels = new Set(state.selectedModels);
|
this.initialSelectedModels = new Set(state.selectedModels);
|
||||||
|
|
||||||
@@ -1776,46 +1791,67 @@ export class BulkManager {
|
|||||||
*/
|
*/
|
||||||
updateMarqueeSelection(e) {
|
updateMarqueeSelection(e) {
|
||||||
if (!this.marqueeElement) return;
|
if (!this.marqueeElement) return;
|
||||||
|
this.updateMarqueeSelectionFromPosition(e.clientX, e.clientY);
|
||||||
const currentX = e.clientX;
|
|
||||||
const currentY = e.clientY;
|
|
||||||
|
|
||||||
// Calculate rectangle bounds
|
|
||||||
const left = Math.min(this.marqueeStart.x, currentX);
|
|
||||||
const top = Math.min(this.marqueeStart.y, currentY);
|
|
||||||
const width = Math.abs(currentX - this.marqueeStart.x);
|
|
||||||
const height = Math.abs(currentY - this.marqueeStart.y);
|
|
||||||
|
|
||||||
// Update marquee element position and size
|
|
||||||
this.marqueeElement.style.left = left + 'px';
|
|
||||||
this.marqueeElement.style.top = top + 'px';
|
|
||||||
this.marqueeElement.style.width = width + 'px';
|
|
||||||
this.marqueeElement.style.height = height + 'px';
|
|
||||||
|
|
||||||
// Check which cards intersect with marquee
|
|
||||||
this.updateCardSelection(left, top, left + width, top + height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update card selection based on marquee bounds
|
* Update marquee from raw client coordinates (used by both mousemove and auto-scroll loop)
|
||||||
*/
|
*/
|
||||||
updateCardSelection(left, top, right, bottom) {
|
updateMarqueeSelectionFromPosition(clientX, clientY) {
|
||||||
const cards = document.querySelectorAll('.model-card');
|
if (!this.marqueeElement) return;
|
||||||
|
|
||||||
|
const container = document.querySelector('.page-content');
|
||||||
|
const scrollX = container?.scrollLeft || 0;
|
||||||
|
const scrollY = container?.scrollTop || 0;
|
||||||
|
|
||||||
|
// Current position in document coordinates
|
||||||
|
const currentDocX = clientX + scrollX;
|
||||||
|
const currentDocY = clientY + scrollY;
|
||||||
|
|
||||||
|
// Calculate marquee rectangle in document coordinates
|
||||||
|
const docLeft = Math.min(this.marqueeStartDoc.x, currentDocX);
|
||||||
|
const docTop = Math.min(this.marqueeStartDoc.y, currentDocY);
|
||||||
|
const docRight = Math.max(this.marqueeStartDoc.x, currentDocX);
|
||||||
|
const docBottom = Math.max(this.marqueeStartDoc.y, currentDocY);
|
||||||
|
|
||||||
|
// Update visual marquee element (position: fixed, so subtract scroll offset)
|
||||||
|
this.marqueeElement.style.left = (docLeft - scrollX) + 'px';
|
||||||
|
this.marqueeElement.style.top = (docTop - scrollY) + 'px';
|
||||||
|
this.marqueeElement.style.width = (docRight - docLeft) + 'px';
|
||||||
|
this.marqueeElement.style.height = (docBottom - docTop) + 'px';
|
||||||
|
|
||||||
|
// Check which cards intersect with marquee
|
||||||
|
this.updateCardSelection(docLeft, docTop, docRight, docBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update card selection based on marquee bounds (document coordinates).
|
||||||
|
* Uses dual detection: DOM cards for visible ones + VirtualScroller layout for off-screen cards.
|
||||||
|
*/
|
||||||
|
updateCardSelection(docLeft, docTop, docRight, docBottom) {
|
||||||
|
const vs = state.virtualScroller;
|
||||||
|
const container = document.querySelector('.page-content');
|
||||||
|
const scrollX = container?.scrollLeft || 0;
|
||||||
|
const scrollY = container?.scrollTop || 0;
|
||||||
const newSelection = new Set(this.initialSelectedModels);
|
const newSelection = new Set(this.initialSelectedModels);
|
||||||
|
const visibleFilepaths = new Set();
|
||||||
|
|
||||||
cards.forEach(card => {
|
// Step 1: Process visible DOM cards using getBoundingClientRect + scroll offset
|
||||||
const rect = card.getBoundingClientRect();
|
document.querySelectorAll('.model-card').forEach(card => {
|
||||||
|
|
||||||
// Check if card intersects with marquee rectangle
|
|
||||||
const intersects = !(rect.right < left ||
|
|
||||||
rect.left > right ||
|
|
||||||
rect.bottom < top ||
|
|
||||||
rect.top > bottom);
|
|
||||||
|
|
||||||
const filepath = card.dataset.filepath;
|
const filepath = card.dataset.filepath;
|
||||||
|
if (!filepath) return;
|
||||||
|
visibleFilepaths.add(filepath);
|
||||||
|
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
const cardLeft = rect.left + scrollX;
|
||||||
|
const cardTop = rect.top + scrollY;
|
||||||
|
const cardRight = rect.right + scrollX;
|
||||||
|
const cardBottom = rect.bottom + scrollY;
|
||||||
|
|
||||||
|
const intersects = !(cardRight < docLeft || cardLeft > docRight ||
|
||||||
|
cardBottom < docTop || cardTop > docBottom);
|
||||||
|
|
||||||
if (intersects) {
|
if (intersects) {
|
||||||
// Add to selection if intersecting
|
|
||||||
newSelection.add(filepath);
|
newSelection.add(filepath);
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
|
|
||||||
@@ -1825,12 +1861,43 @@ export class BulkManager {
|
|||||||
this.updateMetadataCacheFromCard(filepath, card);
|
this.updateMetadataCacheFromCard(filepath, card);
|
||||||
}
|
}
|
||||||
} else if (!this.initialSelectedModels.has(filepath)) {
|
} else if (!this.initialSelectedModels.has(filepath)) {
|
||||||
// Remove from selection if not intersecting and wasn't initially selected
|
|
||||||
newSelection.delete(filepath);
|
newSelection.delete(filepath);
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Step 2: Process off-screen cards via VirtualScroller layout calculation.
|
||||||
|
// Since VirtualScroller removes off-screen DOM elements, we compute
|
||||||
|
// each card's position from its index and the VS layout parameters.
|
||||||
|
if (vs?.gridElement && vs.items && vs.columnsCount > 0) {
|
||||||
|
const gridRect = vs.gridElement.getBoundingClientRect();
|
||||||
|
// Grid origin in scroll-container content coordinates
|
||||||
|
const originX = gridRect.left + scrollX;
|
||||||
|
const originY = gridRect.top + scrollY;
|
||||||
|
|
||||||
|
for (let i = 0; i < vs.items.length; i++) {
|
||||||
|
const filepath = vs.items[i]?.file_path;
|
||||||
|
if (!filepath || visibleFilepaths.has(filepath)) continue;
|
||||||
|
|
||||||
|
const row = Math.floor(i / vs.columnsCount);
|
||||||
|
const col = i % vs.columnsCount;
|
||||||
|
|
||||||
|
const cLeft = originX + col * (vs.itemWidth + vs.columnGap);
|
||||||
|
const cTop = originY + (vs.containerPaddingTop || 0) + row * (vs.itemHeight + (vs.rowGap || 0));
|
||||||
|
const cRight = cLeft + vs.itemWidth;
|
||||||
|
const cBottom = cTop + vs.itemHeight;
|
||||||
|
|
||||||
|
const intersects = !(cRight < docLeft || cLeft > docRight ||
|
||||||
|
cBottom < docTop || cTop > docBottom);
|
||||||
|
|
||||||
|
if (intersects) {
|
||||||
|
newSelection.add(filepath);
|
||||||
|
} else if (!this.initialSelectedModels.has(filepath)) {
|
||||||
|
newSelection.delete(filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update global selection state
|
// Update global selection state
|
||||||
state.selectedModels = newSelection;
|
state.selectedModels = newSelection;
|
||||||
|
|
||||||
@@ -1849,6 +1916,9 @@ export class BulkManager {
|
|||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
this.mouseDownTime = 0;
|
this.mouseDownTime = 0;
|
||||||
|
|
||||||
|
// Stop any active auto-scroll
|
||||||
|
this.stopAutoScroll();
|
||||||
|
|
||||||
// Update event manager state
|
// Update event manager state
|
||||||
eventManager.setState('marqueeActive', false);
|
eventManager.setState('marqueeActive', false);
|
||||||
|
|
||||||
@@ -1874,6 +1944,79 @@ export class BulkManager {
|
|||||||
// Clear initial selection state
|
// Clear initial selection state
|
||||||
this.initialSelectedModels.clear();
|
this.initialSelectedModels.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start auto-scroll loop when mouse approaches viewport edge during marquee
|
||||||
|
*/
|
||||||
|
startAutoScroll() {
|
||||||
|
if (this.autoScrollRaf) return;
|
||||||
|
this.autoScrollLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop auto-scroll loop
|
||||||
|
*/
|
||||||
|
stopAutoScroll() {
|
||||||
|
if (this.autoScrollRaf) {
|
||||||
|
cancelAnimationFrame(this.autoScrollRaf);
|
||||||
|
this.autoScrollRaf = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-scroll loop: scrolls the page when mouse is near viewport edges
|
||||||
|
* and re-evaluates marquee selection after each scroll.
|
||||||
|
*/
|
||||||
|
autoScrollLoop() {
|
||||||
|
if (!this.isMarqueeActive) {
|
||||||
|
this.autoScrollRaf = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.querySelector('.page-content');
|
||||||
|
if (!container) {
|
||||||
|
this.autoScrollRaf = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARGIN = 30; // Px from edge to trigger scroll
|
||||||
|
const BASE_SPEED = 12; // Pixels per frame at edge boundary
|
||||||
|
const MAX_SPEED = 40; // Maximum scroll speed
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
let dx = 0;
|
||||||
|
let dy = 0;
|
||||||
|
|
||||||
|
// Vertical auto-scroll - speed increases the further the cursor is past the edge
|
||||||
|
if (this.lastClientY !== undefined) {
|
||||||
|
if (this.lastClientY < rect.top + MARGIN) {
|
||||||
|
const dist = Math.max(0, (rect.top + MARGIN) - this.lastClientY);
|
||||||
|
dy = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
|
||||||
|
} else if (this.lastClientY > rect.bottom - MARGIN) {
|
||||||
|
const dist = Math.max(0, this.lastClientY - (rect.bottom - MARGIN));
|
||||||
|
dy = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal auto-scroll
|
||||||
|
if (this.lastClientX !== undefined) {
|
||||||
|
if (this.lastClientX < rect.left + MARGIN) {
|
||||||
|
const dist = Math.max(0, (rect.left + MARGIN) - this.lastClientX);
|
||||||
|
dx = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
|
||||||
|
} else if (this.lastClientX > rect.right - MARGIN) {
|
||||||
|
const dist = Math.max(0, this.lastClientX - (rect.right - MARGIN));
|
||||||
|
dx = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dx !== 0 || dy !== 0) {
|
||||||
|
container.scrollBy(dx, dy);
|
||||||
|
// Re-evaluate marquee selection with the new scroll position
|
||||||
|
this.updateMarqueeSelectionFromPosition(this.lastClientX, this.lastClientY);
|
||||||
|
this.autoScrollRaf = requestAnimationFrame(() => this.autoScrollLoop());
|
||||||
|
} else {
|
||||||
|
this.autoScrollRaf = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bulkManager = new BulkManager();
|
export const bulkManager = new BulkManager();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
|||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||||
|
import { formatFileSize } from '../utils/formatters.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -27,6 +28,10 @@ export class DownloadManager {
|
|||||||
this.isBatchMode = false;
|
this.isBatchMode = false;
|
||||||
this.editingBatchIndex = -1;
|
this.editingBatchIndex = -1;
|
||||||
|
|
||||||
|
// HF download state
|
||||||
|
this.hfRepoId = null;
|
||||||
|
this.hfSelectedFiles = [];
|
||||||
|
|
||||||
this.loadingManager = new LoadingManager();
|
this.loadingManager = new LoadingManager();
|
||||||
this.folderTreeManager = new FolderTreeManager();
|
this.folderTreeManager = new FolderTreeManager();
|
||||||
this.folderClickHandler = null;
|
this.folderClickHandler = null;
|
||||||
@@ -44,6 +49,8 @@ export class DownloadManager {
|
|||||||
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
|
||||||
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
|
this.handleBackToUrlFromBatch = this.backToUrlFromBatch.bind(this);
|
||||||
this.handleNextFromBatch = this.nextFromBatch.bind(this);
|
this.handleNextFromBatch = this.nextFromBatch.bind(this);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showDownloadModal() {
|
showDownloadModal() {
|
||||||
@@ -99,6 +106,8 @@ export class DownloadManager {
|
|||||||
|
|
||||||
// Default path toggle handler
|
// Default path toggle handler
|
||||||
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateModalLabels() {
|
updateModalLabels() {
|
||||||
@@ -160,6 +169,10 @@ export class DownloadManager {
|
|||||||
|
|
||||||
// Reset default path toggle
|
// Reset default path toggle
|
||||||
this.loadDefaultPathSetting();
|
this.loadDefaultPathSetting();
|
||||||
|
|
||||||
|
// Reset HF state
|
||||||
|
this.hfRepoId = null;
|
||||||
|
this.hfSelectedFiles = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveVersionsForModel(modelId, source = null) {
|
async retrieveVersionsForModel(modelId, source = null) {
|
||||||
@@ -180,6 +193,29 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect URL types — all URLs must share the same source type
|
||||||
|
const urlTypes = urls.map(u => DownloadManager.detectUrlType(u));
|
||||||
|
const isHf = urlTypes.every(t => t && (t.type === 'hf-resolve' || t.type === 'hf-repo'));
|
||||||
|
const isCivitai = urlTypes.every(t => t && t.type === 'civitai');
|
||||||
|
|
||||||
|
if (!isHf && !isCivitai) {
|
||||||
|
const allValid = urlTypes.every(t => t !== null);
|
||||||
|
if (!allValid) {
|
||||||
|
errorElement.textContent = translate('modals.download.errors.invalidUrl');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Mixed sources not supported in one batch
|
||||||
|
if (urls.length > 1) {
|
||||||
|
errorElement.textContent = translate('modals.download.errors.mixedSources');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHf) {
|
||||||
|
return this._validateAndFetchHf(urls, errorElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Original CivitAI flow below ---
|
||||||
if (urls.length === 1) {
|
if (urls.length === 1) {
|
||||||
this.isBatchMode = false;
|
this.isBatchMode = false;
|
||||||
try {
|
try {
|
||||||
@@ -271,6 +307,112 @@ export class DownloadManager {
|
|||||||
this.showBatchPreviewStep();
|
this.showBatchPreviewStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Hugging Face download flow ----
|
||||||
|
|
||||||
|
async _validateAndFetchHf(urls, errorElement) {
|
||||||
|
if (urls.length === 1) {
|
||||||
|
const info = DownloadManager.detectUrlType(urls[0]);
|
||||||
|
// Direct file resolve URL → skip file selection, go to location
|
||||||
|
if (info.type === 'hf-resolve') {
|
||||||
|
this.isBatchMode = false;
|
||||||
|
this.hfRepoId = info.repo;
|
||||||
|
this.hfSelectedFiles = [info.filename];
|
||||||
|
this.source = 'huggingface';
|
||||||
|
this.proceedToLocation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Repo URL → fetch file list and convert to batch items
|
||||||
|
try {
|
||||||
|
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
|
||||||
|
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
throw new Error(translate('modals.download.errors.noModelFiles'));
|
||||||
|
}
|
||||||
|
this.isBatchMode = true;
|
||||||
|
this.batchModels = [];
|
||||||
|
this.source = 'huggingface';
|
||||||
|
for (const file of files) {
|
||||||
|
this.batchModels.push({
|
||||||
|
url: urls[0],
|
||||||
|
source: 'huggingface',
|
||||||
|
repo: info.repo,
|
||||||
|
filename: file.filename,
|
||||||
|
revision: 'main',
|
||||||
|
displayName: file.filename,
|
||||||
|
fileSizeBytes: file.size,
|
||||||
|
selectedVersion: true,
|
||||||
|
versions: [],
|
||||||
|
checked: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.showBatchPreviewStep();
|
||||||
|
} catch (err) {
|
||||||
|
errorElement.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
this.loadingManager.hide();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple HF URLs → batch mode: flatten all files from all repos
|
||||||
|
this.isBatchMode = true;
|
||||||
|
this.batchModels = [];
|
||||||
|
this.source = 'huggingface';
|
||||||
|
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingRepoFiles'));
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const info = DownloadManager.detectUrlType(url);
|
||||||
|
if (!info) {
|
||||||
|
this.batchModels.push({ url, error: 'Invalid URL', versions: [], selectedVersion: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (info.type === 'hf-resolve') {
|
||||||
|
this.batchModels.push({
|
||||||
|
url,
|
||||||
|
source: 'huggingface',
|
||||||
|
repo: info.repo,
|
||||||
|
filename: info.filename,
|
||||||
|
revision: info.revision || 'main',
|
||||||
|
displayName: info.filename,
|
||||||
|
selectedVersion: true,
|
||||||
|
versions: [],
|
||||||
|
checked: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else if (info.type === 'hf-repo') {
|
||||||
|
try {
|
||||||
|
const files = await this.apiClient.fetchHfRepoFiles(info.repo);
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
this.batchModels.push({ url, error: 'No model files found', versions: [], selectedVersion: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Flatten: create one batch item per file, all checked by default
|
||||||
|
for (const file of files) {
|
||||||
|
this.batchModels.push({
|
||||||
|
url,
|
||||||
|
source: 'huggingface',
|
||||||
|
repo: info.repo,
|
||||||
|
filename: file.filename,
|
||||||
|
revision: 'main',
|
||||||
|
displayName: file.filename,
|
||||||
|
fileSizeBytes: file.size,
|
||||||
|
selectedVersion: true,
|
||||||
|
versions: [],
|
||||||
|
checked: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.batchModels.push({ url, error: err.message, versions: [], selectedVersion: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingManager.hide();
|
||||||
|
this.showBatchPreviewStep();
|
||||||
|
}
|
||||||
|
|
||||||
async fetchVersionsForCurrentModel() {
|
async fetchVersionsForCurrentModel() {
|
||||||
const errorElement = document.getElementById('urlError');
|
const errorElement = document.getElementById('urlError');
|
||||||
if (errorElement) {
|
if (errorElement) {
|
||||||
@@ -311,6 +453,60 @@ export class DownloadManager {
|
|||||||
return { modelId: null, modelVersionId: null, source: null };
|
return { modelId: null, modelVersionId: null, source: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the source type of a download URL.
|
||||||
|
* @param {string} url
|
||||||
|
* @returns {{ type: string, repo?: string, filename?: string, revision?: string } | null}
|
||||||
|
* type: 'civitai' | 'civarchive' | 'hf-resolve' | 'hf-repo' | 'direct-http'
|
||||||
|
*/
|
||||||
|
static detectUrlType(url) {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// CivitAI
|
||||||
|
if (/civitai\.com\/models\//i.test(trimmed) || /civitaiarchive|civarchive/i.test(trimmed)) {
|
||||||
|
// Will be parsed by existing CivitAI logic
|
||||||
|
return { type: 'civitai' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hugging Face resolve URL → direct file
|
||||||
|
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
|
||||||
|
if (hfResolveMatch) {
|
||||||
|
return {
|
||||||
|
type: 'hf-resolve',
|
||||||
|
repo: hfResolveMatch[1],
|
||||||
|
revision: hfResolveMatch[2],
|
||||||
|
filename: hfResolveMatch[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hugging Face repo URL (huggingface.co/user/repo or bare user/repo path)
|
||||||
|
// Require huggingface.co prefix for full URLs; bare user/repo only without ://
|
||||||
|
const hfRepoMatch = trimmed.match(
|
||||||
|
trimmed.includes('://')
|
||||||
|
? /^https?:\/\/huggingface\.co\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\/?$|$)/
|
||||||
|
: /^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)$/
|
||||||
|
);
|
||||||
|
if (hfRepoMatch) {
|
||||||
|
// Reject path-traversal patterns like "../.." or "user/.."
|
||||||
|
const parts = hfRepoMatch[1].split('/');
|
||||||
|
if (parts.some(p => p === '.' || p === '..')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'hf-repo',
|
||||||
|
repo: hfRepoMatch[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct HTTP(S) URL (non-HF)
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return { type: 'direct-http' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
extractModelId(url) {
|
extractModelId(url) {
|
||||||
const result = DownloadManager.parseModelUrl(url);
|
const result = DownloadManager.parseModelUrl(url);
|
||||||
this.modelVersionId = result.modelVersionId;
|
this.modelVersionId = result.modelVersionId;
|
||||||
@@ -351,7 +547,7 @@ export class DownloadManager {
|
|||||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||||
|
|
||||||
// Count model-type files per version
|
// Count model-type files per version
|
||||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||||
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
|
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
|
||||||
const fileSize = version.modelSizeKB ?
|
const fileSize = version.modelSizeKB ?
|
||||||
(version.modelSizeKB / 1024).toFixed(2) :
|
(version.modelSizeKB / 1024).toFixed(2) :
|
||||||
@@ -478,7 +674,7 @@ export class DownloadManager {
|
|||||||
if (!version) return;
|
if (!version) return;
|
||||||
|
|
||||||
this.currentVersion = version;
|
this.currentVersion = version;
|
||||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||||
|
|
||||||
document.getElementById('versionStep').style.display = 'none';
|
document.getElementById('versionStep').style.display = 'none';
|
||||||
document.getElementById('fileSelectionStep').style.display = 'block';
|
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||||
@@ -534,7 +730,7 @@ export class DownloadManager {
|
|||||||
const version = this.currentVersion;
|
const version = this.currentVersion;
|
||||||
if (!version) return;
|
if (!version) return;
|
||||||
|
|
||||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model');
|
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
||||||
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||||
|
|
||||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||||
@@ -559,8 +755,8 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In single-URL mode, validate version selection
|
// In single-URL mode, validate version selection (skip for HF)
|
||||||
if (!this.isBatchMode) {
|
if (!this.isBatchMode && this.source !== 'huggingface') {
|
||||||
if (!this.currentVersion) {
|
if (!this.currentVersion) {
|
||||||
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
showToast('toast.loras.pleaseSelectVersion', {}, 'error');
|
||||||
return;
|
return;
|
||||||
@@ -784,6 +980,77 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) {
|
||||||
|
modalManager.closeModal('downloadModal');
|
||||||
|
this.loadingManager.restoreProgressBar();
|
||||||
|
const totalFiles = this.hfSelectedFiles.length;
|
||||||
|
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let completedDownloads = 0;
|
||||||
|
for (let i = 0; i < totalFiles; i++) {
|
||||||
|
const filename = this.hfSelectedFiles[i];
|
||||||
|
updateProgress(0, completedDownloads, filename);
|
||||||
|
this.loadingManager.setStatus(`Downloading ${filename}...`);
|
||||||
|
|
||||||
|
const downloadId = Date.now().toString() + '_' + i;
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
ws.onopen = resolve;
|
||||||
|
ws.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture completed count at WS creation time so progress
|
||||||
|
// updates arriving after completedDownloads increments still
|
||||||
|
// show the correct "N / total" position.
|
||||||
|
const snapshotCompleted = completedDownloads;
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.status === 'progress') {
|
||||||
|
const metrics = {
|
||||||
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
|
totalBytes: data.total_bytes,
|
||||||
|
bytesPerSecond: data.bytes_per_second,
|
||||||
|
};
|
||||||
|
updateProgress(data.progress, snapshotCompleted, filename, metrics);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.apiClient.downloadHfModel({
|
||||||
|
repo: this.hfRepoId,
|
||||||
|
filename,
|
||||||
|
revision: 'main',
|
||||||
|
modelRoot,
|
||||||
|
relativePath: targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
download_id: downloadId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
completedDownloads++;
|
||||||
|
updateProgress(100, completedDownloads, filename);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||||
|
// Reload page data — model is already in scanner cache via backend
|
||||||
|
await resetAndReload(true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download HF model:', error);
|
||||||
|
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.loadingManager.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updatePathSelectionUI() {
|
updatePathSelectionUI() {
|
||||||
const manualSelection = document.getElementById('manualPathSelection');
|
const manualSelection = document.getElementById('manualPathSelection');
|
||||||
|
|
||||||
@@ -812,13 +1079,19 @@ export class DownloadManager {
|
|||||||
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
|
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
|
||||||
document.getElementById('batchPreviewStep').style.display = 'block';
|
document.getElementById('batchPreviewStep').style.display = 'block';
|
||||||
|
|
||||||
const validCount = this.batchModels.filter(m => !m.error && m.selectedVersion).length;
|
const validCount = this.batchModels.filter(m => {
|
||||||
|
if (m.error) return false;
|
||||||
|
if (m.source === 'huggingface') return m.checked !== false;
|
||||||
|
return m.selectedVersion;
|
||||||
|
}).length;
|
||||||
document.getElementById('downloadModalTitle').textContent =
|
document.getElementById('downloadModalTitle').textContent =
|
||||||
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
||||||
` (${validCount})`;
|
` (${validCount})`;
|
||||||
|
|
||||||
const list = document.getElementById('batchPreviewList');
|
const list = document.getElementById('batchPreviewList');
|
||||||
list.innerHTML = this.batchModels.map((item, index) => {
|
const hasHfItems = this.batchModels.some(m => m.source === 'huggingface' && !m.error);
|
||||||
|
|
||||||
|
let itemsHtml = this.batchModels.map((item, index) => {
|
||||||
if (item.error) {
|
if (item.error) {
|
||||||
return `
|
return `
|
||||||
<div class="batch-preview-item batch-preview-error" data-index="${index}">
|
<div class="batch-preview-item batch-preview-error" data-index="${index}">
|
||||||
@@ -837,6 +1110,30 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ver = item.selectedVersion;
|
const ver = item.selectedVersion;
|
||||||
|
|
||||||
|
// HF batch item rendering with checkbox
|
||||||
|
if (item.source === 'huggingface') {
|
||||||
|
const hfSize = item.fileSizeBytes
|
||||||
|
? formatFileSize(item.fileSizeBytes)
|
||||||
|
: '?';
|
||||||
|
return `
|
||||||
|
<div class="batch-preview-item" data-index="${index}">
|
||||||
|
<input type="checkbox" class="batch-preview-checkbox"
|
||||||
|
data-index="${index}" ${item.checked !== false ? 'checked' : ''} />
|
||||||
|
<div class="batch-preview-info">
|
||||||
|
<div class="batch-preview-name">${item.displayName || item.filename || `HF #${index}`} <span class="hf-badge">HF</span></div>
|
||||||
|
<div class="batch-preview-meta">
|
||||||
|
<span>${hfSize}</span>
|
||||||
|
<span>${item.repo || ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="batch-preview-remove" data-index="${index}" title="${translate('common.actions.remove', {}, 'Remove')}">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
|
const firstImage = ver?.images?.find(img => !img.url.endsWith('.mp4'));
|
||||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||||
const fileSize = ver?.modelSizeKB
|
const fileSize = ver?.modelSizeKB
|
||||||
@@ -866,6 +1163,21 @@ export class DownloadManager {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Prepend select-all toolbar if there are HF items with checkboxes
|
||||||
|
if (hasHfItems) {
|
||||||
|
const allChecked = this.batchModels
|
||||||
|
.filter(m => m.source === 'huggingface' && !m.error)
|
||||||
|
.every(m => m.checked !== false);
|
||||||
|
itemsHtml = `
|
||||||
|
<div class="batch-preview-select-all">
|
||||||
|
<input type="checkbox" id="batchSelectAll" ${allChecked ? 'checked' : ''} />
|
||||||
|
<label for="batchSelectAll">${translate('modals.download.selectAll', {}, 'Select All')}</label>
|
||||||
|
</div>
|
||||||
|
` + itemsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = itemsHtml;
|
||||||
|
|
||||||
list.onclick = (e) => {
|
list.onclick = (e) => {
|
||||||
const removeBtn = e.target.closest('.batch-preview-remove');
|
const removeBtn = e.target.closest('.batch-preview-remove');
|
||||||
if (removeBtn) {
|
if (removeBtn) {
|
||||||
@@ -881,6 +1193,59 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Checkbox handler for HF batch items
|
||||||
|
const checkboxes = list.querySelectorAll('.batch-preview-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.addEventListener('change', (e) => {
|
||||||
|
const idx = parseInt(e.target.dataset.index);
|
||||||
|
if (this.batchModels[idx]) {
|
||||||
|
this.batchModels[idx].checked = e.target.checked;
|
||||||
|
}
|
||||||
|
// Update valid count in title and Next button
|
||||||
|
const checkedCount = this.batchModels.filter(
|
||||||
|
m => !m.error && m.checked !== false
|
||||||
|
).length;
|
||||||
|
document.getElementById('downloadModalTitle').textContent =
|
||||||
|
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
||||||
|
` (${checkedCount})`;
|
||||||
|
const nextBtn = document.getElementById('nextFromBatchBtn');
|
||||||
|
nextBtn.disabled = checkedCount === 0;
|
||||||
|
nextBtn.classList.toggle('disabled', checkedCount === 0);
|
||||||
|
// Update select-all checkbox state
|
||||||
|
const selectAll = document.getElementById('batchSelectAll');
|
||||||
|
if (selectAll) {
|
||||||
|
const hfItems = this.batchModels.filter(m => m.source === 'huggingface' && !m.error);
|
||||||
|
selectAll.checked = hfItems.length > 0 && hfItems.every(m => m.checked !== false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select-all handler
|
||||||
|
const selectAll = document.getElementById('batchSelectAll');
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener('change', (e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
const hfCheckboxes = list.querySelectorAll('.batch-preview-checkbox');
|
||||||
|
hfCheckboxes.forEach(cb => {
|
||||||
|
cb.checked = checked;
|
||||||
|
const idx = parseInt(cb.dataset.index);
|
||||||
|
if (this.batchModels[idx]) {
|
||||||
|
this.batchModels[idx].checked = checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Update valid count in title and Next button
|
||||||
|
const checkedCount = this.batchModels.filter(
|
||||||
|
m => !m.error && m.checked !== false
|
||||||
|
).length;
|
||||||
|
document.getElementById('downloadModalTitle').textContent =
|
||||||
|
translate('modals.download.titleWithType', { type: this.apiClient.apiConfig.config.displayName }) +
|
||||||
|
` (${checkedCount})`;
|
||||||
|
const nextBtn = document.getElementById('nextFromBatchBtn');
|
||||||
|
nextBtn.disabled = checkedCount === 0;
|
||||||
|
nextBtn.classList.toggle('disabled', checkedCount === 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const nextBtn = document.getElementById('nextFromBatchBtn');
|
const nextBtn = document.getElementById('nextFromBatchBtn');
|
||||||
nextBtn.disabled = validCount === 0;
|
nextBtn.disabled = validCount === 0;
|
||||||
nextBtn.classList.toggle('disabled', validCount === 0);
|
nextBtn.classList.toggle('disabled', validCount === 0);
|
||||||
@@ -903,7 +1268,12 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextFromBatch() {
|
nextFromBatch() {
|
||||||
const validModels = this.batchModels.filter(m => !m.error && m.selectedVersion);
|
// For HF items, respect the checked flag; for CivitAI items, use selectedVersion
|
||||||
|
const validModels = this.batchModels.filter(m => {
|
||||||
|
if (m.error) return false;
|
||||||
|
if (m.source === 'huggingface') return m.checked !== false;
|
||||||
|
return m.selectedVersion;
|
||||||
|
});
|
||||||
if (validModels.length === 0) return;
|
if (validModels.length === 0) return;
|
||||||
this.proceedToLocation();
|
this.proceedToLocation();
|
||||||
}
|
}
|
||||||
@@ -953,8 +1323,17 @@ export class DownloadManager {
|
|||||||
targetFolder = this.folderTreeManager.getSelectedPath();
|
targetFolder = this.folderTreeManager.getSelectedPath();
|
||||||
}
|
}
|
||||||
if (!this.isBatchMode) {
|
if (!this.isBatchMode) {
|
||||||
|
// Single-item download
|
||||||
|
if (this.source === 'huggingface') {
|
||||||
|
return this._downloadHfSingle({
|
||||||
|
modelRoot,
|
||||||
|
targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const fileParams = this.selectedFile ? {
|
const fileParams = this.selectedFile ? {
|
||||||
type: 'Model',
|
type: this.selectedFile.type || 'Model',
|
||||||
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
format: this.selectedFile.metadata?.format || 'SafeTensor',
|
||||||
size: this.selectedFile.metadata?.size || 'full',
|
size: this.selectedFile.metadata?.size || 'full',
|
||||||
fp: this.selectedFile.metadata?.fp,
|
fp: this.selectedFile.metadata?.fp,
|
||||||
@@ -974,7 +1353,13 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Batch download mode
|
// Batch download mode
|
||||||
const downloadItems = this.batchModels.filter(m => !m.error && m.selectedVersion && !m.selectedVersion.existsLocally);
|
const downloadItems = this.batchModels.filter(m => {
|
||||||
|
if (m.error) return false;
|
||||||
|
if (!m.selectedVersion) return false;
|
||||||
|
// HF items have selectedVersion as a boolean marker + checked flag
|
||||||
|
if (m.source === 'huggingface') return m.checked !== false;
|
||||||
|
return !m.selectedVersion.existsLocally;
|
||||||
|
});
|
||||||
if (downloadItems.length === 0) {
|
if (downloadItems.length === 0) {
|
||||||
showToast('toast.loras.downloadCompleted', {}, 'info');
|
showToast('toast.loras.downloadCompleted', {}, 'info');
|
||||||
modalManager.closeModal('downloadModal');
|
modalManager.closeModal('downloadModal');
|
||||||
@@ -999,7 +1384,7 @@ export class DownloadManager {
|
|||||||
|
|
||||||
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
|
if (data.status === 'progress' && data.download_id?.startsWith(batchDownloadId)) {
|
||||||
const current = downloadItems[completedDownloads + failedDownloads];
|
const current = downloadItems[completedDownloads + failedDownloads];
|
||||||
const name = current?.selectedVersion?.name || `#${completedDownloads + failedDownloads + 1}`;
|
const name = current?.selectedVersion?.name || current?.displayName || current?.filename || `#${completedDownloads + failedDownloads + 1}`;
|
||||||
const metrics = {
|
const metrics = {
|
||||||
bytesDownloaded: data.bytes_downloaded,
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
totalBytes: data.total_bytes,
|
totalBytes: data.total_bytes,
|
||||||
@@ -1016,22 +1401,59 @@ export class DownloadManager {
|
|||||||
|
|
||||||
for (let i = 0; i < downloadItems.length; i++) {
|
for (let i = 0; i < downloadItems.length; i++) {
|
||||||
const item = downloadItems[i];
|
const item = downloadItems[i];
|
||||||
const ver = item.selectedVersion;
|
const name = item.displayName || item.filename || (item.selectedVersion?.name || `Model #${item.modelId}`);
|
||||||
const name = ver?.name || `Model #${item.modelId}`;
|
const isHf = item.source === 'huggingface';
|
||||||
|
|
||||||
updateProgress(0, completedDownloads, name);
|
updateProgress(0, completedDownloads, name);
|
||||||
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
|
loadingManager.setStatus(`${i + 1}/${downloadItems.length}: ${name}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.apiClient.downloadModel(
|
let response;
|
||||||
item.modelId,
|
if (isHf) {
|
||||||
ver.id,
|
// Per-file WebSocket for real-time progress
|
||||||
modelRoot,
|
const downloadId = Date.now().toString() + '_hf_' + i;
|
||||||
targetFolder,
|
const wsHf = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||||
useDefaultPaths,
|
try {
|
||||||
batchDownloadId,
|
await new Promise((resolve, reject) => {
|
||||||
item.source
|
wsHf.onopen = resolve;
|
||||||
);
|
wsHf.onerror = reject;
|
||||||
|
});
|
||||||
|
const snapshotCompleted = completedDownloads;
|
||||||
|
wsHf.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.status === 'progress') {
|
||||||
|
const metrics = {
|
||||||
|
bytesDownloaded: data.bytes_downloaded,
|
||||||
|
totalBytes: data.total_bytes,
|
||||||
|
bytesPerSecond: data.bytes_per_second,
|
||||||
|
};
|
||||||
|
updateProgress(data.progress, snapshotCompleted, name, metrics);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
response = await this.apiClient.downloadHfModel({
|
||||||
|
repo: item.repo,
|
||||||
|
filename: item.filename,
|
||||||
|
revision: item.revision || 'main',
|
||||||
|
modelRoot,
|
||||||
|
relativePath: targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
download_id: downloadId,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
wsHf.close();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response = await this.apiClient.downloadModel(
|
||||||
|
item.modelId,
|
||||||
|
item.selectedVersion.id,
|
||||||
|
modelRoot,
|
||||||
|
targetFolder,
|
||||||
|
useDefaultPaths,
|
||||||
|
batchDownloadId,
|
||||||
|
item.source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
|
|||||||
@@ -57,9 +57,16 @@ export class DownloadManager {
|
|||||||
base_model: this.importManager.recipeData.base_model || "",
|
base_model: this.importManager.recipeData.base_model || "",
|
||||||
loras: this.importManager.recipeData.loras || [],
|
loras: this.importManager.recipeData.loras || [],
|
||||||
gen_params: this.importManager.recipeData.gen_params || {},
|
gen_params: this.importManager.recipeData.gen_params || {},
|
||||||
raw_metadata: this.importManager.recipeData.raw_metadata || {}
|
raw_metadata: this.importManager.recipeData.raw_metadata || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Preserve preview_nsfw_level from analysis so the saved
|
||||||
|
// recipe applies the correct NSFW blur on the preview image.
|
||||||
|
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
|
||||||
|
if (nsfwLevel !== undefined && nsfwLevel !== null) {
|
||||||
|
completeMetadata.preview_nsfw_level = nsfwLevel;
|
||||||
|
}
|
||||||
|
|
||||||
const checkpointMetadata =
|
const checkpointMetadata =
|
||||||
this.importManager.recipeData.checkpoint ||
|
this.importManager.recipeData.checkpoint ||
|
||||||
this.importManager.recipeData.model ||
|
this.importManager.recipeData.model ||
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { ImportManager } from './managers/ImportManager.js';
|
|||||||
import { BatchImportManager } from './managers/BatchImportManager.js';
|
import { BatchImportManager } from './managers/BatchImportManager.js';
|
||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { state, getCurrentPageState } from './state/index.js';
|
import { state, getCurrentPageState } from './state/index.js';
|
||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.js';
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
import { initSortDropdown } from './components/controls/SortDropdown.js';
|
||||||
|
|
||||||
class RecipePageControls {
|
class RecipePageControls {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -236,12 +237,18 @@ class RecipeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initEventListeners() {
|
initEventListeners() {
|
||||||
// Sort select
|
// Sort select — load saved preference, persist on change
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
const savedSort = getStorageItem('recipes_sort');
|
||||||
|
if (savedSort) {
|
||||||
|
this.pageState.sortBy = savedSort;
|
||||||
|
}
|
||||||
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
this.pageState.sortBy = sortSelect.value;
|
||||||
|
setStorageItem('recipes_sort', sortSelect.value);
|
||||||
refreshVirtualScroll();
|
refreshVirtualScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const BASE_MODELS = {
|
|||||||
ERNIE_TURBO: "Ernie Turbo",
|
ERNIE_TURBO: "Ernie Turbo",
|
||||||
NUCLEUS: "Nucleus",
|
NUCLEUS: "Nucleus",
|
||||||
PONY_V7: "Pony V7",
|
PONY_V7: "Pony V7",
|
||||||
|
KREA_2: "Krea 2",
|
||||||
// Default
|
// Default
|
||||||
UNKNOWN: "Other"
|
UNKNOWN: "Other"
|
||||||
};
|
};
|
||||||
@@ -197,6 +198,7 @@ export const BASE_MODEL_ABBREVIATIONS = {
|
|||||||
[BASE_MODELS.ERNIE]: 'ERNI',
|
[BASE_MODELS.ERNIE]: 'ERNI',
|
||||||
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
|
[BASE_MODELS.ERNIE_TURBO]: 'ETRB',
|
||||||
[BASE_MODELS.NUCLEUS]: 'NUCL',
|
[BASE_MODELS.NUCLEUS]: 'NUCL',
|
||||||
|
[BASE_MODELS.KREA_2]: 'KR2',
|
||||||
|
|
||||||
// Default
|
// Default
|
||||||
[BASE_MODELS.UNKNOWN]: 'OTH'
|
[BASE_MODELS.UNKNOWN]: 'OTH'
|
||||||
@@ -401,6 +403,7 @@ export const BASE_MODEL_CATEGORIES = {
|
|||||||
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.ERNIE, BASE_MODELS.ERNIE_TURBO, BASE_MODELS.NUCLEUS,
|
||||||
|
BASE_MODELS.KREA_2,
|
||||||
BASE_MODELS.UNKNOWN
|
BASE_MODELS.UNKNOWN
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -319,6 +319,15 @@ export function openCivitai(filePath) {
|
|||||||
openCivitaiByMetadata(civitaiId, versionId, modelName);
|
openCivitaiByMetadata(civitaiId, versionId, modelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a Hugging Face model page in a new tab
|
||||||
|
* @param {string} hfUrl - The Hugging Face URL
|
||||||
|
*/
|
||||||
|
export function openHuggingFace(hfUrl) {
|
||||||
|
if (!hfUrl) return;
|
||||||
|
window.open(hfUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamically positions the search options panel and filter panel
|
* Dynamically positions the search options panel and filter panel
|
||||||
* based on the current layout and folder tags container height
|
* based on the current layout and folder tags container height
|
||||||
|
|||||||
@@ -15,8 +15,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group">
|
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.sort.title') }}{% else %}{{ t('loras.controls.sort.title') }}{% endif %}" class="control-group sort-dropdown-group dropdown-group" data-sort-dropdown>
|
||||||
<select id="sortSelect">
|
<button type="button" class="sort-trigger" aria-haspopup="listbox" aria-expanded="false">
|
||||||
|
<span class="sort-trigger__label"></span>
|
||||||
|
<i class="fas fa-caret-down sort-trigger__caret" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-menu sort-dropdown-menu" role="listbox"></div>
|
||||||
|
<select id="sortSelect" class="sort-select-native" tabindex="-1" aria-hidden="true">
|
||||||
<optgroup label="{{ t('loras.controls.sort.name') }}">
|
<optgroup label="{{ t('loras.controls.sort.name') }}">
|
||||||
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
<option value="name:asc">{{ t('loras.controls.sort.nameAsc') }}</option>
|
||||||
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
<option value="name:desc">{{ t('loras.controls.sort.nameDesc') }}</option>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="error-message" id="urlError"></div>
|
<div class="error-message" id="urlError"></div>
|
||||||
<div class="input-hint">
|
<div class="input-hint">
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<span>{{ t('modals.download.urlHint') }}</span>
|
<span id="urlHint">{{ t('modals.download.urlHint') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DownloadManager } from '../../../static/js/managers/DownloadManager.js';
|
||||||
|
|
||||||
|
describe('DownloadManager.detectUrlType — HF URL detection', () => {
|
||||||
|
|
||||||
|
it('detects HF resolve URL with file', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://huggingface.co/dx8152/Flux2-Klein-9B-Consistency/resolve/main/Flux2-Klein-9B-consistency-V2.safetensors'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'hf-resolve',
|
||||||
|
repo: 'dx8152/Flux2-Klein-9B-Consistency',
|
||||||
|
revision: 'main',
|
||||||
|
filename: 'Flux2-Klein-9B-consistency-V2.safetensors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects HF resolve URL with subdirectory file', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://huggingface.co/user/repo/resolve/main/subdir/model.safetensors'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'hf-resolve',
|
||||||
|
repo: 'user/repo',
|
||||||
|
revision: 'main',
|
||||||
|
filename: 'subdir/model.safetensors',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects HF repo URL (full URL)', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://huggingface.co/dx8152/Flux2-Klein-9B-Consistency'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'hf-repo',
|
||||||
|
repo: 'dx8152/Flux2-Klein-9B-Consistency',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects HF repo URL (bare user/repo)', () => {
|
||||||
|
const result = DownloadManager.detectUrlType('dx8152/Flux2-Klein-9B-Consistency');
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'hf-repo',
|
||||||
|
repo: 'dx8152/Flux2-Klein-9B-Consistency',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects HF repo URL with trailing slash', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://huggingface.co/user/repo/'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'hf-repo',
|
||||||
|
repo: 'user/repo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects CivitAI URL', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://civitai.com/models/123/some-model'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ type: 'civitai' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects CivArchive URL', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://civarchive.com/models/456'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ type: 'civitai' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects direct HTTP URL', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://example.com/file.zip'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ type: 'direct-http' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid input', () => {
|
||||||
|
expect(DownloadManager.detectUrlType('')).toBeNull();
|
||||||
|
expect(DownloadManager.detectUrlType(' ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unrecognized path', () => {
|
||||||
|
expect(DownloadManager.detectUrlType('justrandomtext')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers HF resolve over repo when both match', () => {
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://huggingface.co/user/repo/resolve/main/file.safetensors'
|
||||||
|
);
|
||||||
|
expect(result?.type).toBe('hf-resolve');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers CivitAI over HF when both match', () => {
|
||||||
|
// CivitAI check comes first in detectUrlType
|
||||||
|
// This URL should be detected as CivitAI, not HF
|
||||||
|
const result = DownloadManager.detectUrlType(
|
||||||
|
'https://civitai.com/models/123?huggingface.co/test/repo'
|
||||||
|
);
|
||||||
|
expect(result?.type).toBe('civitai');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,7 +60,9 @@ class FakePromptServer:
|
|||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
class Instance:
|
class Instance:
|
||||||
def send_sync(self, event, payload):
|
sockets: dict = {}
|
||||||
|
|
||||||
|
def send_sync(self, event, payload, sid=None):
|
||||||
FakePromptServer.sent.append((event, payload))
|
FakePromptServer.sent.append((event, payload))
|
||||||
|
|
||||||
instance = Instance()
|
instance = Instance()
|
||||||
@@ -148,7 +150,8 @@ class TestNodeRegistryHandlerSnapshots:
|
|||||||
"type": "Lora Loader (LoraManager)",
|
"type": "Lora Loader (LoraManager)",
|
||||||
"title": "Test Loader",
|
"title": "Test Loader",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"client_id": "test-client-1",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -167,7 +170,7 @@ class TestNodeRegistryHandlerSnapshots:
|
|||||||
standalone_mode=False,
|
standalone_mode=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
request = FakeRequest(json_data={"nodes": []})
|
request = FakeRequest(json_data={"nodes": [], "client_id": "test-client-1"})
|
||||||
response = await handler.register_nodes(request)
|
response = await handler.register_nodes(request)
|
||||||
payload = json.loads(response.text)
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,45 @@ def test_list_models_returns_formatted_items(mock_service, mock_scanner):
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_models_filters_out_corrupted_entries(mock_service, mock_scanner):
|
||||||
|
"""Corrupted cache entries (format_response returns None) must not appear
|
||||||
|
in the response items nor cause a 500. See issue #730.
|
||||||
|
"""
|
||||||
|
mock_service.paginated_items = [
|
||||||
|
{"file_path": "/tmp/good.safetensors", "name": "Good"},
|
||||||
|
{"file_path": None, "name": "Corrupted"}, # triggers None from format_response
|
||||||
|
{"file_path": "/tmp/also_good.safetensors", "name": "AlsoGood"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Override format_response to return None for corrupted entries
|
||||||
|
original_format = mock_service.format_response
|
||||||
|
|
||||||
|
async def conditional_format(item):
|
||||||
|
if item.get("file_path") is None:
|
||||||
|
return None
|
||||||
|
return await original_format(item)
|
||||||
|
|
||||||
|
mock_service.format_response = conditional_format
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
client = await create_test_client(mock_service)
|
||||||
|
try:
|
||||||
|
response = await client.get("/api/lm/test-models/list")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
# Only the 2 non-corrupted entries should appear
|
||||||
|
assert len(payload["items"]) == 2
|
||||||
|
assert payload["items"][0]["name"] == "Good"
|
||||||
|
assert payload["items"][1]["name"] == "AlsoGood"
|
||||||
|
# None should never appear in the items list
|
||||||
|
assert None not in payload["items"]
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_model_types_endpoint_returns_counts(mock_service, mock_scanner):
|
def test_model_types_endpoint_returns_counts(mock_service, mock_scanner):
|
||||||
mock_service.model_types = [
|
mock_service.model_types = [
|
||||||
{"type": "LoRa", "count": 3},
|
{"type": "LoRa", "count": 3},
|
||||||
|
|||||||
@@ -586,7 +586,9 @@ class FakePromptServer:
|
|||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
class Instance:
|
class Instance:
|
||||||
def send_sync(self, event, payload):
|
sockets: dict = {}
|
||||||
|
|
||||||
|
def send_sync(self, event, payload, sid=None):
|
||||||
FakePromptServer.sent.append((event, payload))
|
FakePromptServer.sent.append((event, payload))
|
||||||
|
|
||||||
instance = Instance()
|
instance = Instance()
|
||||||
@@ -601,7 +603,12 @@ async def test_register_nodes_requires_graph_id():
|
|||||||
standalone_mode=False,
|
standalone_mode=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
request = FakeRequest(json_data={"nodes": [{"node_id": 1}]})
|
request = FakeRequest(
|
||||||
|
json_data={
|
||||||
|
"nodes": [{"node_id": 1}],
|
||||||
|
"client_id": "test-client-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
response = await handler.register_nodes(request)
|
response = await handler.register_nodes(request)
|
||||||
payload = json.loads(response.text)
|
payload = json.loads(response.text)
|
||||||
|
|
||||||
@@ -629,7 +636,8 @@ async def test_register_nodes_stores_graph_identifier():
|
|||||||
"type": "Lora Loader (LoraManager)",
|
"type": "Lora Loader (LoraManager)",
|
||||||
"title": "Loader",
|
"title": "Loader",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"client_id": "test-client-1",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -638,7 +646,7 @@ async def test_register_nodes_stores_graph_identifier():
|
|||||||
|
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
|
|
||||||
registry = await node_registry.get_registry()
|
registry = await node_registry.get_merged_registry()
|
||||||
assert registry["node_count"] == 1
|
assert registry["node_count"] == 1
|
||||||
stored_node = next(iter(registry["nodes"].values()))
|
stored_node = next(iter(registry["nodes"].values()))
|
||||||
assert stored_node["graph_id"] == "graph-123"
|
assert stored_node["graph_id"] == "graph-123"
|
||||||
@@ -664,7 +672,8 @@ async def test_register_nodes_defaults_graph_name_to_none():
|
|||||||
"type": "Lora Loader (LoraManager)",
|
"type": "Lora Loader (LoraManager)",
|
||||||
"title": "Root Loader",
|
"title": "Root Loader",
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"client_id": "test-client-1",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -673,7 +682,7 @@ async def test_register_nodes_defaults_graph_name_to_none():
|
|||||||
|
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
|
|
||||||
registry = await node_registry.get_registry()
|
registry = await node_registry.get_merged_registry()
|
||||||
stored_node = next(iter(registry["nodes"].values()))
|
stored_node = next(iter(registry["nodes"].values()))
|
||||||
assert stored_node["graph_name"] is None
|
assert stored_node["graph_name"] is None
|
||||||
|
|
||||||
@@ -700,7 +709,8 @@ async def test_register_nodes_includes_capabilities():
|
|||||||
"widget_names": ["ckpt_name", "", 42],
|
"widget_names": ["ckpt_name", "", 42],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"client_id": "test-client-1",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -709,7 +719,7 @@ async def test_register_nodes_includes_capabilities():
|
|||||||
|
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
|
|
||||||
registry = await node_registry.get_registry()
|
registry = await node_registry.get_merged_registry()
|
||||||
stored_node = next(iter(registry["nodes"].values()))
|
stored_node = next(iter(registry["nodes"].values()))
|
||||||
assert stored_node["capabilities"] == {
|
assert stored_node["capabilities"] == {
|
||||||
"supports_lora": False,
|
"supports_lora": False,
|
||||||
@@ -724,7 +734,9 @@ async def test_update_node_widget_sends_payload():
|
|||||||
|
|
||||||
class RecordingPromptServer:
|
class RecordingPromptServer:
|
||||||
class Instance:
|
class Instance:
|
||||||
def send_sync(self, event, payload):
|
sockets: dict = {}
|
||||||
|
|
||||||
|
def send_sync(self, event, payload, sid=None):
|
||||||
send_calls.append((event, payload))
|
send_calls.append((event, payload))
|
||||||
|
|
||||||
instance = Instance()
|
instance = Instance()
|
||||||
@@ -768,7 +780,9 @@ async def test_update_lora_code_includes_graph_identifier():
|
|||||||
|
|
||||||
class RecordingPromptServer:
|
class RecordingPromptServer:
|
||||||
class Instance:
|
class Instance:
|
||||||
def send_sync(self, event, payload):
|
sockets: dict = {}
|
||||||
|
|
||||||
|
def send_sync(self, event, payload, sid=None):
|
||||||
send_calls.append((event, payload))
|
send_calls.append((event, payload))
|
||||||
|
|
||||||
instance = Instance()
|
instance = Instance()
|
||||||
|
|||||||
@@ -59,3 +59,180 @@ async def test_get_nightly_version_network_error_logs_warning(monkeypatch, caplo
|
|||||||
assert changelog == []
|
assert changelog == []
|
||||||
assert "Unable to reach GitHub for nightly version" in caplog.text
|
assert "Unable to reach GitHub for nightly version" in caplog.text
|
||||||
assert "Traceback" not in caplog.text
|
assert "Traceback" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_excludes_covers_user_data_dirs():
|
||||||
|
"""git clean must receive -e excludes for every user-managed dir."""
|
||||||
|
excludes = update_routes._clean_excludes()
|
||||||
|
assert "-e" in excludes # at least one exclude flag present
|
||||||
|
for name in update_routes._PRESERVE_DIRS:
|
||||||
|
assert name in excludes
|
||||||
|
assert f"{name}/**" in excludes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_perform_git_update_preserves_user_dirs(monkeypatch, tmp_path):
|
||||||
|
"""``git clean`` must be called with -e excludes for user data dirs.
|
||||||
|
|
||||||
|
Regression test for portable-mode updates wiping wildcards/, stats/,
|
||||||
|
backups/, etc. because ``git clean -fd`` removed untracked, non-ignored
|
||||||
|
directories.
|
||||||
|
"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeGit:
|
||||||
|
def reset(self, *args, **kwargs):
|
||||||
|
calls.append(("reset", args))
|
||||||
|
|
||||||
|
def clean(self, *args, **kwargs):
|
||||||
|
calls.append(("clean", args))
|
||||||
|
|
||||||
|
def checkout(self, *args, **kwargs):
|
||||||
|
calls.append(("checkout", args))
|
||||||
|
|
||||||
|
class FakeRemote:
|
||||||
|
def fetch(self):
|
||||||
|
calls.append(("fetch", ()))
|
||||||
|
|
||||||
|
def pull(self, *args, **kwargs):
|
||||||
|
calls.append(("pull", args))
|
||||||
|
|
||||||
|
class FakeRemotes:
|
||||||
|
origin = FakeRemote()
|
||||||
|
|
||||||
|
class FakeCommit:
|
||||||
|
hexsha = "abcdef123456"
|
||||||
|
|
||||||
|
class FakeHeads:
|
||||||
|
def __getitem__(self, name):
|
||||||
|
class Head:
|
||||||
|
def checkout(self_inner):
|
||||||
|
calls.append(("head-checkout", (name,)))
|
||||||
|
return Head()
|
||||||
|
|
||||||
|
class FakeBranches:
|
||||||
|
names = ["main"]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
class B:
|
||||||
|
name = "main"
|
||||||
|
return iter([B()])
|
||||||
|
|
||||||
|
class FakeRepo:
|
||||||
|
def __init__(self, path):
|
||||||
|
calls.append(("repo", (path,)))
|
||||||
|
|
||||||
|
git = FakeGit()
|
||||||
|
remotes = FakeRemotes()
|
||||||
|
head = type("H", (), {"commit": FakeCommit()})()
|
||||||
|
branches = FakeBranches()
|
||||||
|
heads = FakeHeads()
|
||||||
|
|
||||||
|
def create_head(self, name, ref):
|
||||||
|
calls.append(("create_head", (name, ref)))
|
||||||
|
|
||||||
|
class FakeGitModule:
|
||||||
|
class Repo:
|
||||||
|
def __new__(cls, path):
|
||||||
|
return FakeRepo(path)
|
||||||
|
|
||||||
|
class exc:
|
||||||
|
class GitError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == "git":
|
||||||
|
return FakeGitModule
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
|
success, version = await update_routes.UpdateRoutes._perform_git_update(
|
||||||
|
str(tmp_path), nightly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
clean_calls = [c for c in calls if c[0] == "clean"]
|
||||||
|
assert len(clean_calls) == 1
|
||||||
|
clean_args = clean_calls[0][1]
|
||||||
|
# Every preserved dir must be excluded via -e
|
||||||
|
for name in update_routes._PRESERVE_DIRS:
|
||||||
|
assert name in clean_args, f"{name} missing from git clean excludes"
|
||||||
|
assert f"{name}/**" in clean_args, f"{name}/** missing from git clean excludes"
|
||||||
|
# Ensure there's an -e before each name occurrence
|
||||||
|
idx = clean_args.index(name)
|
||||||
|
assert clean_args[idx - 1] == "-e"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_perform_git_update_stable_preserves_user_dirs(monkeypatch, tmp_path):
|
||||||
|
"""Stable (tag) update path must also pass -e excludes to git clean."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeGit:
|
||||||
|
def reset(self, *args, **kwargs):
|
||||||
|
calls.append(("reset", args))
|
||||||
|
|
||||||
|
def clean(self, *args, **kwargs):
|
||||||
|
calls.append(("clean", args))
|
||||||
|
|
||||||
|
def checkout(self, *args, **kwargs):
|
||||||
|
calls.append(("checkout", args))
|
||||||
|
|
||||||
|
class FakeRemote:
|
||||||
|
def fetch(self):
|
||||||
|
calls.append(("fetch", ()))
|
||||||
|
|
||||||
|
class FakeRemotes:
|
||||||
|
origin = FakeRemote()
|
||||||
|
|
||||||
|
class FakeCommit:
|
||||||
|
committed_datetime = "2026-01-01"
|
||||||
|
|
||||||
|
class FakeTag:
|
||||||
|
name = "v9.9.9"
|
||||||
|
commit = FakeCommit()
|
||||||
|
|
||||||
|
class FakeRepo:
|
||||||
|
def __init__(self, path):
|
||||||
|
calls.append(("repo", (path,)))
|
||||||
|
|
||||||
|
git = FakeGit()
|
||||||
|
remotes = FakeRemotes()
|
||||||
|
tags = [FakeTag()]
|
||||||
|
|
||||||
|
class FakeGitModule:
|
||||||
|
class Repo:
|
||||||
|
def __new__(cls, path):
|
||||||
|
return FakeRepo(path)
|
||||||
|
|
||||||
|
class exc:
|
||||||
|
class GitError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
import builtins
|
||||||
|
|
||||||
|
real_import = builtins.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == "git":
|
||||||
|
return FakeGitModule
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
|
success, version = await update_routes.UpdateRoutes._perform_git_update(
|
||||||
|
str(tmp_path), nightly=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert version == "v9.9.9"
|
||||||
|
clean_calls = [c for c in calls if c[0] == "clean"]
|
||||||
|
assert len(clean_calls) == 1
|
||||||
|
clean_args = clean_calls[0][1]
|
||||||
|
for name in update_routes._PRESERVE_DIRS:
|
||||||
|
assert name in clean_args, f"{name} missing from git clean excludes (stable)"
|
||||||
|
|||||||
@@ -352,3 +352,104 @@ async def test_resolve_authenticated_redirect_url_returns_location(monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == "https://signed.example.com/file.safetensors"
|
assert result == "https://signed.example.com/file.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_passes_through_success(monkeypatch):
|
||||||
|
"""A successful first call returns immediately, no retries."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return {"status": "active", "completedLength": "50", "totalLength": "100"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
|
||||||
|
result = await downloader._get_status_with_retry("dummy")
|
||||||
|
assert result is not None
|
||||||
|
assert result["status"] == "active"
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_succeeds_after_transient_failure(monkeypatch):
|
||||||
|
"""A transient Aria2Error on the first call is retried and succeeds."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise Aria2Error("timeout")
|
||||||
|
return {"status": "complete", "completedLength": "100", "totalLength": "100"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||||
|
|
||||||
|
result = await downloader._get_status_with_retry("dummy")
|
||||||
|
assert result is not None
|
||||||
|
assert result["status"] == "complete"
|
||||||
|
assert call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_raises_after_all_retries_exhausted(monkeypatch):
|
||||||
|
"""All retry attempts fail → Aria2Error with a descriptive message."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
async def fake_get_status(_id):
|
||||||
|
raise Aria2Error("connection reset")
|
||||||
|
|
||||||
|
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||||
|
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||||
|
|
||||||
|
with pytest.raises(Aria2Error) as exc_info:
|
||||||
|
await downloader._get_status_with_retry("dummy")
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "after 4 attempts" in msg
|
||||||
|
assert "connection reset" in msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status_with_retry_returns_none_when_not_tracked(monkeypatch):
|
||||||
|
"""No transfer in _transfers → get_status returns None → no retry needed."""
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
# get_status returns None when the download_id has no transfer;
|
||||||
|
# _get_status_with_retry should propagate that without raising.
|
||||||
|
result = await downloader._get_status_with_retry("nonexistent")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait_until_ready_includes_stderr_in_error():
|
||||||
|
"""When the subprocess exits early, its stderr output must be in Aria2Error."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
downloader = Aria2Downloader()
|
||||||
|
|
||||||
|
# Start a subprocess that writes a message to stderr and exits with code 28.
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable, "-c",
|
||||||
|
"import sys; print('ERROR: unknown option --fsync', file=sys.stderr); sys.exit(28)",
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Let the process exit
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
# Point the downloader at this dead process and let _wait_until_ready
|
||||||
|
# discover the exit and read stderr.
|
||||||
|
downloader._process = proc
|
||||||
|
|
||||||
|
with pytest.raises(Aria2Error) as exc_info:
|
||||||
|
await downloader._wait_until_ready()
|
||||||
|
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "code 28" in msg
|
||||||
|
assert "ERROR: unknown option --fsync" in msg
|
||||||
|
|||||||
@@ -64,6 +64,74 @@ async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeyp
|
|||||||
assert result["loras"] == []
|
assert result["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypatch):
|
||||||
|
"""When Hashes JSON has empty lora hashes but Lora hashes text field has
|
||||||
|
real ones, the real hashes should be used and those LoRAs resolved
|
||||||
|
correctly; entries with empty hashes in both sources should be skipped."""
|
||||||
|
lora_version_info = {
|
||||||
|
"id": 947620,
|
||||||
|
"modelId": 98765,
|
||||||
|
"model": {"name": "cfg_scale_boost", "type": "LORA"},
|
||||||
|
"name": "v1",
|
||||||
|
"images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}],
|
||||||
|
"baseModel": "illustrious",
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/models/947620",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"sizeKB": 1024,
|
||||||
|
"name": "cfg_scale_boost.safetensors",
|
||||||
|
"hashes": {"SHA256": "4605b2de07"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_by_hash(self, model_hash):
|
||||||
|
assert model_hash == "4605b2de07"
|
||||||
|
return lora_version_info, None
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
raise AssertionError("get_model_version_info should not be called")
|
||||||
|
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.automatic.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = AutomaticMetadataParser()
|
||||||
|
|
||||||
|
metadata_text = (
|
||||||
|
"a cyberpunk portrait <lora:cfg_scale_boost:0.6>\n"
|
||||||
|
"Negative prompt: low quality\n"
|
||||||
|
"Steps: 20, Sampler: Euler a, CFG scale: 7, Seed: 123456, Size: 512x768, "
|
||||||
|
"Model hash: abc123, Model: test.safetensors, "
|
||||||
|
'Lora hashes: "cfg_scale_boost: 4605b2de07, EmptyLora: ", '
|
||||||
|
'Hashes: {"model": "abc123", "lora:cfg_scale_boost": "", "lora:EmptyLora": "", "lora:UnusedLora": ""}'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(metadata_text)
|
||||||
|
|
||||||
|
# cfg_scale_boost should be resolved (hash from Lora hashes overrode empty Hashes JSON)
|
||||||
|
loras = result.get("loras", [])
|
||||||
|
assert len(loras) == 1, f"Expected 1 LoRA, got {len(loras)}"
|
||||||
|
lora = loras[0]
|
||||||
|
assert lora["name"] == "cfg_scale_boost", f"Expected cfg_scale_boost, got {lora['name']}"
|
||||||
|
assert lora["hash"] == "4605b2de07", f"Expected hash 4605b2de07, got {lora['hash']}"
|
||||||
|
assert lora.get("isDeleted") in (None, False), f"LoRA should not be deleted"
|
||||||
|
assert lora["weight"] == 0.6, f"Expected weight 0.6, got {lora['weight']}"
|
||||||
|
|
||||||
|
# EmptyLora and UnusedLora should be skipped (no hash in either source)
|
||||||
|
lora_names = [l["name"] for l in loras]
|
||||||
|
assert "EmptyLora" not in lora_names, "EmptyLora should have been skipped"
|
||||||
|
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
|
||||||
checkpoint_info = {
|
checkpoint_info = {
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ async def test_get_image_info_prefers_red_host_for_red_source(monkeypatch, downl
|
|||||||
|
|
||||||
assert result == {"id": 124950237, "name": "target"}
|
assert result == {"id": 124950237, "name": "target"}
|
||||||
assert requested_urls == [
|
assert requested_urls == [
|
||||||
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X"
|
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X&withMeta=true"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -589,7 +589,7 @@ async def test_get_image_info_uses_red_host_even_for_red_source(monkeypatch, dow
|
|||||||
|
|
||||||
assert result == {"id": 124950237, "name": "target"}
|
assert result == {"id": 124950237, "name": "target"}
|
||||||
assert requested_urls == [
|
assert requested_urls == [
|
||||||
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
|
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X&withMeta=true",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -610,7 +610,7 @@ async def test_get_image_info_does_not_fall_back_after_request_failure(monkeypat
|
|||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
assert requested_urls == [
|
assert requested_urls == [
|
||||||
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
|
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X&withMeta=true",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -579,3 +579,45 @@ async def test_update_in_library_versions_populates_metadata(tmp_path):
|
|||||||
assert version.preview_url == "https://example.com/preview.png"
|
assert version.preview_url == "https://example.com/preview.png"
|
||||||
assert version.is_in_library is True
|
assert version.is_in_library is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_folder_filter_considers_cross_folder_versions(tmp_path):
|
||||||
|
"""When refreshing by folder, versions in other folders must still be
|
||||||
|
considered in-library so they aren't reported as available updates."""
|
||||||
|
db_path = tmp_path / "updates.sqlite"
|
||||||
|
service = ModelUpdateService(str(db_path), ttl_seconds=0)
|
||||||
|
# Same model (modelId=1) in two folders with different versions
|
||||||
|
raw_data = [
|
||||||
|
{"civitai": {"modelId": 1, "id": 11}, "folder": "folder_a"},
|
||||||
|
{"civitai": {"modelId": 1, "id": 15}, "folder": "folder_b"},
|
||||||
|
]
|
||||||
|
scanner = DummyScanner(raw_data)
|
||||||
|
# Remote offers: 11 (in folder_a), 15 (in folder_b), 20 (truly new)
|
||||||
|
provider = DummyProvider(
|
||||||
|
{
|
||||||
|
"modelVersions": [
|
||||||
|
{"id": 11, "files": [], "images": []},
|
||||||
|
{"id": 15, "files": [], "images": []},
|
||||||
|
{"id": 20, "files": [], "images": []},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.refresh_for_model_type(
|
||||||
|
"lora", scanner, provider, folder_path="folder_a",
|
||||||
|
)
|
||||||
|
record = await service.get_record("lora", 1)
|
||||||
|
|
||||||
|
assert record is not None
|
||||||
|
|
||||||
|
# Version 15 is in folder_b — must be in_library even when filtering by folder_a
|
||||||
|
v15 = next(v for v in record.versions if v.version_id == 15)
|
||||||
|
assert v15.is_in_library is True
|
||||||
|
|
||||||
|
# Version 20 is truly new — should not be in_library
|
||||||
|
v20 = next(v for v in record.versions if v.version_id == 20)
|
||||||
|
assert v20.is_in_library is False
|
||||||
|
|
||||||
|
# has_update must be True (version 20 > max_in_library=15)
|
||||||
|
assert record.has_update() is True
|
||||||
|
|
||||||
|
|||||||
@@ -199,8 +199,107 @@ class TestEmbeddingServiceFormatResponse:
|
|||||||
"from_civitai": True,
|
"from_civitai": True,
|
||||||
"civitai": {},
|
"civitai": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await embedding_service.format_response(embedding_data)
|
result = await embedding_service.format_response(embedding_data)
|
||||||
|
|
||||||
assert result["sub_type"] == "embedding"
|
assert result["sub_type"] == "embedding"
|
||||||
assert "model_type" not in result # Removed in refactoring
|
assert "model_type" not in result # Removed in refactoring
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatResponseCorruptedEntries:
|
||||||
|
"""Test format_response handles corrupted cache entries gracefully (issue #730).
|
||||||
|
|
||||||
|
When cache rows have None/missing critical fields (e.g. from a partially
|
||||||
|
written or legacy DB), format_response must NOT raise KeyError/AttributeError.
|
||||||
|
Instead it returns None so the handler layer can filter the bad entry out
|
||||||
|
instead of failing the entire listing request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_scanner(self):
|
||||||
|
scanner = MagicMock()
|
||||||
|
scanner._hash_index = MagicMock()
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lora_service(self, mock_scanner):
|
||||||
|
return LoraService(mock_scanner)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def checkpoint_service(self, mock_scanner):
|
||||||
|
return CheckpointService(mock_scanner)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def embedding_service(self, mock_scanner):
|
||||||
|
return EmbeddingService(mock_scanner)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lora_returns_none_on_missing_file_path(self, lora_service):
|
||||||
|
"""format_response returns None when file_path is missing (corrupted row)."""
|
||||||
|
lora_data = {
|
||||||
|
"model_name": "Test LoRA",
|
||||||
|
"file_name": "test_lora",
|
||||||
|
"file_path": None, # corrupted: missing file_path
|
||||||
|
"folder": "",
|
||||||
|
"sha256": "abc123",
|
||||||
|
"tags": [],
|
||||||
|
"from_civitai": True,
|
||||||
|
"civitai": {},
|
||||||
|
}
|
||||||
|
result = await lora_service.format_response(lora_data)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lora_handles_none_model_name_gracefully(self, lora_service):
|
||||||
|
"""format_response should not crash when model_name is None (legacy DB row)."""
|
||||||
|
lora_data = {
|
||||||
|
"model_name": None, # NULL from old DB row
|
||||||
|
"file_name": "test_lora",
|
||||||
|
"file_path": "/models/test_lora.safetensors",
|
||||||
|
"folder": "",
|
||||||
|
"sha256": "abc123",
|
||||||
|
"tags": [],
|
||||||
|
"from_civitai": True,
|
||||||
|
"civitai": {},
|
||||||
|
}
|
||||||
|
result = await lora_service.format_response(lora_data)
|
||||||
|
# Should not raise; model_name falls back to file_name
|
||||||
|
assert result is not None
|
||||||
|
assert result["model_name"] == "test_lora"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_checkpoint_returns_none_on_missing_file_path(self, checkpoint_service):
|
||||||
|
"""format_response returns None when file_path is missing (corrupted row)."""
|
||||||
|
checkpoint_data = {
|
||||||
|
"model_name": "Test",
|
||||||
|
"file_name": "test",
|
||||||
|
"file_path": "", # empty string == corrupted
|
||||||
|
"folder": "",
|
||||||
|
"sha256": "abc",
|
||||||
|
"tags": [],
|
||||||
|
"from_civitai": True,
|
||||||
|
"civitai": {},
|
||||||
|
"sub_type": "checkpoint",
|
||||||
|
}
|
||||||
|
result = await checkpoint_service.format_response(checkpoint_data)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embedding_handles_none_fields_gracefully(self, embedding_service):
|
||||||
|
"""format_response should not crash when optional fields are None."""
|
||||||
|
embedding_data = {
|
||||||
|
"model_name": None,
|
||||||
|
"file_name": None,
|
||||||
|
"file_path": "/models/test.pt",
|
||||||
|
"folder": None,
|
||||||
|
"sha256": "abc",
|
||||||
|
"tags": [],
|
||||||
|
"from_civitai": True,
|
||||||
|
"civitai": {},
|
||||||
|
"sub_type": "embedding",
|
||||||
|
}
|
||||||
|
result = await embedding_service.format_response(embedding_data)
|
||||||
|
assert result is not None
|
||||||
|
assert result["file_path"] == "/models/test.pt"
|
||||||
|
# model_name falls back to file_name which falls back to ""
|
||||||
|
assert result["model_name"] == ""
|
||||||
|
|||||||
@@ -200,52 +200,97 @@ def _setup_storage_paths(tmp_path, monkeypatch):
|
|||||||
return project_root, user_dir, user_settings_path
|
return project_root, user_dir, user_settings_path
|
||||||
|
|
||||||
|
|
||||||
def _populate_cache(root_dir, marker_name, db_text):
|
def _populate_settings_dir(root_dir):
|
||||||
cache_dir = root_dir / "model_cache"
|
"""Create test data for all managed subdirectories under a settings directory."""
|
||||||
cache_dir.mkdir(exist_ok=True)
|
(root_dir / "cache" / "symlink").mkdir(parents=True, exist_ok=True)
|
||||||
marker_file = cache_dir / marker_name
|
(root_dir / "cache" / "symlink" / "symlink_map.json").write_text(
|
||||||
marker_file.write_text(marker_name, encoding="utf-8")
|
'{"migrated": true}', encoding="utf-8"
|
||||||
(root_dir / "model_cache.sqlite").write_text(db_text, encoding="utf-8")
|
)
|
||||||
|
(root_dir / "backups").mkdir(parents=True, exist_ok=True)
|
||||||
|
(root_dir / "backups" / "backup_test.zip").write_text(
|
||||||
|
"backup", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(root_dir / "logs").mkdir(parents=True, exist_ok=True)
|
||||||
|
(root_dir / "logs" / "session.log").write_text("log", encoding="utf-8")
|
||||||
|
(root_dir / "stats").mkdir(parents=True, exist_ok=True)
|
||||||
|
(root_dir / "stats" / "stats.json").write_text(
|
||||||
|
'{"stats": true}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
(root_dir / "wildcards").mkdir(parents=True, exist_ok=True)
|
||||||
|
(root_dir / "wildcards" / "test.txt").write_text("wildcard", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_switch_to_portable_mode_copies_cache(tmp_path, monkeypatch):
|
def test_switch_to_portable_mode_copies_subdirectories(tmp_path, monkeypatch):
|
||||||
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
|
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
|
||||||
_populate_cache(user_dir, "user_marker.txt", "user_db")
|
_populate_settings_dir(user_dir)
|
||||||
|
|
||||||
manager = SettingsManager()
|
manager = SettingsManager()
|
||||||
|
|
||||||
manager.set("use_portable_settings", True)
|
manager.set("use_portable_settings", True)
|
||||||
|
|
||||||
assert manager.settings_file == str(project_root / "settings.json")
|
assert manager.settings_file == str(project_root / "settings.json")
|
||||||
marker_copy = project_root / "model_cache" / "user_marker.txt"
|
# Managed subdirectories should all be migrated
|
||||||
assert marker_copy.read_text(encoding="utf-8") == "user_marker.txt"
|
assert (
|
||||||
assert (project_root / "model_cache.sqlite").read_text(
|
project_root / "cache" / "symlink" / "symlink_map.json"
|
||||||
|
).read_text(encoding="utf-8") == '{"migrated": true}'
|
||||||
|
assert (
|
||||||
|
project_root / "backups" / "backup_test.zip"
|
||||||
|
).read_text(encoding="utf-8") == "backup"
|
||||||
|
assert (project_root / "logs" / "session.log").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
) == "user_db"
|
) == "log"
|
||||||
|
assert (project_root / "stats" / "stats.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == '{"stats": true}'
|
||||||
|
assert (project_root / "wildcards" / "test.txt").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == "wildcard"
|
||||||
assert user_settings.exists()
|
assert user_settings.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_switching_back_to_user_config_moves_cache(tmp_path, monkeypatch):
|
def test_switching_back_to_user_config_moves_subdirectories(tmp_path, monkeypatch):
|
||||||
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
|
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
|
||||||
_populate_cache(user_dir, "user_marker.txt", "user_db")
|
_populate_settings_dir(user_dir)
|
||||||
|
|
||||||
manager = SettingsManager()
|
manager = SettingsManager()
|
||||||
manager.set("use_portable_settings", True)
|
manager.set("use_portable_settings", True)
|
||||||
|
|
||||||
project_cache_dir = project_root / "model_cache"
|
# Populate project-root managed subdirectories
|
||||||
project_cache_dir.mkdir(exist_ok=True)
|
(project_root / "cache" / "model").mkdir(parents=True, exist_ok=True)
|
||||||
(project_cache_dir / "project_marker.txt").write_text(
|
(project_root / "cache" / "model" / "default.sqlite").write_text(
|
||||||
"project_marker", encoding="utf-8"
|
"project_db", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(project_root / "backups" / "project_backup.zip").write_text(
|
||||||
|
"project_backup", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(project_root / "logs" / "project.log").write_text(
|
||||||
|
"project_log", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(project_root / "stats" / "project_stats.json").write_text(
|
||||||
|
'{"project": true}', encoding="utf-8"
|
||||||
|
)
|
||||||
|
(project_root / "wildcards" / "project.txt").write_text(
|
||||||
|
"project_wildcard", encoding="utf-8"
|
||||||
)
|
)
|
||||||
(project_root / "model_cache.sqlite").write_text("project_db", encoding="utf-8")
|
|
||||||
|
|
||||||
manager.set("use_portable_settings", False)
|
manager.set("use_portable_settings", False)
|
||||||
|
|
||||||
assert manager.settings_file == str(user_settings)
|
assert manager.settings_file == str(user_settings)
|
||||||
assert (user_dir / "model_cache" / "project_marker.txt").read_text(
|
assert (user_dir / "cache" / "model" / "default.sqlite").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
) == "project_marker"
|
) == "project_db"
|
||||||
assert (user_dir / "model_cache.sqlite").read_text(encoding="utf-8") == "project_db"
|
assert (user_dir / "backups" / "project_backup.zip").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == "project_backup"
|
||||||
|
assert (user_dir / "logs" / "project.log").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == "project_log"
|
||||||
|
assert (user_dir / "stats" / "project_stats.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == '{"project": true}'
|
||||||
|
assert (user_dir / "wildcards" / "project.txt").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == "project_wildcard"
|
||||||
|
|
||||||
|
|
||||||
def test_download_path_template_parses_json_string(manager):
|
def test_download_path_template_parses_json_string(manager):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Node Marker – right-click node marking (no dedicated node required)
|
// Node Marker – right-click node marking (no dedicated node required)
|
||||||
//
|
//
|
||||||
// Adds a "Mark as →" submenu with role options to any node's context menu.
|
// Adds a "🎯 Mark as →" submenu with role options to any node's context menu.
|
||||||
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
|
// Roles are stored in ``node.properties.lm_marker_role`` and automatically
|
||||||
// persist with the workflow JSON.
|
// persist with the workflow JSON.
|
||||||
//
|
//
|
||||||
@@ -107,7 +107,7 @@ function buildMenuItems(node) {
|
|||||||
return [
|
return [
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
content: "Mark as",
|
content: "\uD83C\uDFAF Mark as",
|
||||||
has_submenu: true,
|
has_submenu: true,
|
||||||
submenu: {
|
submenu: {
|
||||||
options: buildSubmenuOptions(node),
|
options: buildSubmenuOptions(node),
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ function createTagElement({
|
|||||||
}) {
|
}) {
|
||||||
const tagEl = document.createElement("div");
|
const tagEl = document.createElement("div");
|
||||||
tagEl.className = "comfy-tag";
|
tagEl.className = "comfy-tag";
|
||||||
tagEl.dataset.captureWheel = "true";
|
|
||||||
|
|
||||||
const baseStyles = {
|
const baseStyles = {
|
||||||
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
|
padding: `${roundScaled(group ? 5 : 3, styleScale)}px ${roundScaled(group ? 8 : 10, styleScale)}px`,
|
||||||
@@ -619,6 +618,36 @@ function showTagContextMenu(event, tagData, index, widget, anchorEl) {
|
|||||||
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Singleton window capture-phase wheel hook: focuses the tags container when a
|
||||||
|
// wheel event occurs inside it, so that ComfyUI's wheelCapturedByFocusedElement
|
||||||
|
// recognises this zone and does NOT forward the event to canvas (which would
|
||||||
|
// trigger zoom and stopPropagation, preventing the strength-adjustment handler).
|
||||||
|
/** @type {boolean} */
|
||||||
|
let tagWheelCaptureHookInstalled = false;
|
||||||
|
function installTagWheelCaptureHook() {
|
||||||
|
if (tagWheelCaptureHookInstalled) return;
|
||||||
|
tagWheelCaptureHookInstalled = true;
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
"wheel",
|
||||||
|
(event) => {
|
||||||
|
// Only handle vertical mouse wheel (not pinch-zoom or horizontal swipe)
|
||||||
|
if (event.ctrlKey || event.metaKey) return;
|
||||||
|
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
|
||||||
|
|
||||||
|
const target = /** @type {Element} */ (event.target);
|
||||||
|
if (!target?.closest) return;
|
||||||
|
const targetContainer = target.closest(
|
||||||
|
'.comfy-tags-container[data-capture-wheel="true"]'
|
||||||
|
);
|
||||||
|
if (!targetContainer) return;
|
||||||
|
|
||||||
|
targetContainer.focus({ preventScroll: true });
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
|
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.className = "comfy-tags-container";
|
container.className = "comfy-tags-container";
|
||||||
@@ -628,6 +657,29 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
forwardMiddleMouseToCanvas(container);
|
forwardMiddleMouseToCanvas(container);
|
||||||
forwardWheelToCanvas(container);
|
forwardWheelToCanvas(container);
|
||||||
|
|
||||||
|
// Vue render mode: ComfyUI's TransformPane uses a capture-phase wheel handler
|
||||||
|
// (TransformPane @wheel.capture) that checks wheelCapturedByFocusedElement.
|
||||||
|
// For that check to return true (preventing canvas zoom and allowing our
|
||||||
|
// strength-adjustment wheel handler to fire), the container needs both
|
||||||
|
// data-capture-wheel AND document.activeElement inside it.
|
||||||
|
// We make the container focusable and auto-focus it on wheel events via a
|
||||||
|
// window capture-phase hook.
|
||||||
|
container.dataset.captureWheel = "true";
|
||||||
|
container.tabIndex = -1;
|
||||||
|
|
||||||
|
// Blur on mouseleave to avoid lingering focus side effects.
|
||||||
|
container.addEventListener("mouseleave", () => {
|
||||||
|
if (document.activeElement === container) {
|
||||||
|
container.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Singleton window capture-phase wheel handler: focuses our container when
|
||||||
|
// a wheel event occurs inside it, so that wheelCapturedByFocusedElement
|
||||||
|
// recognises this zone and does NOT forward the event to canvas (which would
|
||||||
|
// trigger zoom and stopPropagation, preventing our strength handler).
|
||||||
|
installTagWheelCaptureHook();
|
||||||
|
|
||||||
Object.assign(container.style, {
|
Object.assign(container.style, {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
@@ -641,6 +693,7 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
|||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
alignContent: "flex-start",
|
alignContent: "flex-start",
|
||||||
|
outline: "none",
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialTagsData = opts?.defaultVal || [];
|
const initialTagsData = opts?.defaultVal || [];
|
||||||
|
|||||||
@@ -186,32 +186,59 @@ const createExtensionObject = (useActionBar) => {
|
|||||||
};
|
};
|
||||||
injectStyles();
|
injectStyles();
|
||||||
|
|
||||||
const replaceButtonIcon = () => {
|
const applyIconToButton = (button) => {
|
||||||
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
// Skip if the SVG icon is already in place
|
||||||
buttons.forEach(button => {
|
if (button.querySelector('svg')) return;
|
||||||
button.classList.add('lm-top-menu-button');
|
button.classList.add('lm-top-menu-button');
|
||||||
button.innerHTML = getLoraManagerIcon();
|
button.innerHTML = getLoraManagerIcon();
|
||||||
button.style.borderRadius = '4px';
|
button.style.borderRadius = '4px';
|
||||||
button.style.padding = '6px';
|
button.style.padding = '6px';
|
||||||
button.style.backgroundColor = 'var(--primary-bg)';
|
button.style.backgroundColor = 'var(--primary-bg)';
|
||||||
const svg = button.querySelector('svg');
|
const svg = button.querySelector('svg');
|
||||||
if (svg) {
|
if (svg) {
|
||||||
svg.style.width = '20px';
|
svg.style.width = '20px';
|
||||||
svg.style.height = '20px';
|
svg.style.height = '20px';
|
||||||
}
|
|
||||||
});
|
|
||||||
if (buttons.length === 0) {
|
|
||||||
requestAnimationFrame(replaceButtonIcon);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
requestAnimationFrame(replaceButtonIcon);
|
|
||||||
|
// Initial application — retry until the button is rendered by Vue
|
||||||
|
const pollUntilFound = () => {
|
||||||
|
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
||||||
|
if (buttons.length > 0) {
|
||||||
|
buttons.forEach(applyIconToButton);
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(pollUntilFound);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(pollUntilFound);
|
||||||
|
|
||||||
|
// MutationObserver: keep the SVG icon in place after Vue re-renders
|
||||||
|
// (e.g. when the properties panel is toggled inside a subgraph)
|
||||||
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const buttons = document.querySelectorAll('button[aria-label="Launch LoRA Manager (Shift+Click opens in new window)"]');
|
||||||
|
buttons.forEach(button => {
|
||||||
|
// Only re-apply when Vue has reset innerHTML back to <i>
|
||||||
|
if (button.querySelector('i')) {
|
||||||
|
applyIconToButton(button);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Watch the action bar and a broad ancestor so we cover re-mounts
|
||||||
|
const watchNode = document.querySelector('[data-testid="action-bar-buttons"]')
|
||||||
|
|| document.querySelector('.actionbar-container')
|
||||||
|
|| document.body;
|
||||||
|
observer.observe(watchNode, { childList: true, subtree: true });
|
||||||
|
// Store reference for potential cleanup
|
||||||
|
window.__lmIconObserver = observer;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (useActionBar) {
|
if (useActionBar) {
|
||||||
extensionObj.actionBarButtons = [
|
extensionObj.actionBarButtons = [
|
||||||
{
|
{
|
||||||
icon: "icon-[mdi--alpha-l-box] size-4",
|
icon: "icon-[lucide--layers] size-4",
|
||||||
tooltip: BUTTON_TOOLTIP,
|
tooltip: BUTTON_TOOLTIP,
|
||||||
onClick: openLoraManager
|
onClick: openLoraManager
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,10 @@ app.registerExtension({
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ nodes: workflowNodes }),
|
body: JSON.stringify({
|
||||||
|
nodes: workflowNodes,
|
||||||
|
client_id: api.clientId ?? api.initialClientId ?? "",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user