mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-23 11:41:17 -03:00
Compare commits
7 Commits
519bafebc8
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
237a015cde | ||
|
|
1ae2778baa | ||
|
|
84fcdb5f20 | ||
|
|
8a0b368b44 | ||
|
|
3990535505 | ||
|
|
3e961a9860 | ||
|
|
d6669f1d04 |
@@ -6,22 +6,29 @@
|
||||
"Scott R"
|
||||
],
|
||||
"allSupporters": [
|
||||
"Brennok",
|
||||
"Insomnia Art Designs",
|
||||
"2018cfh",
|
||||
"megakirbs",
|
||||
"Brennok",
|
||||
"Arlecchino Shion",
|
||||
"Rob Williams",
|
||||
"W+K+White",
|
||||
"wackop",
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"Arlecchino Shion",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"stone9k",
|
||||
"itismyelement",
|
||||
"$MetaSamsara",
|
||||
"Gingko Biloba",
|
||||
"Kiba",
|
||||
"onesecondinosaur",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"Rosenthal",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"Andrew Wilson",
|
||||
@@ -30,32 +37,38 @@
|
||||
"JongWon Han",
|
||||
"VantAI",
|
||||
"runte3221",
|
||||
"Illrigger",
|
||||
"Tom Corrigan",
|
||||
"FreelancerZ",
|
||||
"Echo",
|
||||
"Robert Stacey",
|
||||
"Edgar Tejeda",
|
||||
"Fraser Cross",
|
||||
"Liam MacDougal",
|
||||
"Polymorphic Indeterminate",
|
||||
"Sterilized",
|
||||
"JORGE+LUIZ+HUSSNI+MESSIAS",
|
||||
"Marc Whiffen",
|
||||
"Skalabananen",
|
||||
"Birdy",
|
||||
"Kiba",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Reno Lam",
|
||||
"JSST",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"Takkan",
|
||||
"Matt+J",
|
||||
"ClockDaemon",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Nazono_hito",
|
||||
"daniel dove",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"Alex",
|
||||
"SG",
|
||||
"carozzz",
|
||||
"James Dooley",
|
||||
@@ -70,77 +83,71 @@
|
||||
"RedrockVP",
|
||||
"Wolffen",
|
||||
"James Todd",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"Steven Pfeiffer",
|
||||
"Tim",
|
||||
"レプサイ",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Tom Corrigan",
|
||||
"dl0901dm",
|
||||
"JackieWang",
|
||||
"fnkylove",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
"Bishoujoker",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"Jorge Hussni",
|
||||
"Sterilized",
|
||||
"wildnut",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"quarz",
|
||||
"Greg",
|
||||
"JSST",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"Jack B Nimble",
|
||||
"Nazono_hito",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"JW Sin",
|
||||
"JaxMax",
|
||||
"contrite831",
|
||||
"Alex",
|
||||
"bh",
|
||||
"Marlon Daniels",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Some Guy Named Barry",
|
||||
"M Postkasse",
|
||||
"Gooohokrbe",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"OldBones",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Zach Gonser",
|
||||
"Big Red",
|
||||
"Jimmy Ledbetter",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Nick Walker",
|
||||
"Bishoujoker",
|
||||
"dan",
|
||||
"aai",
|
||||
"Tori",
|
||||
"wildnut",
|
||||
"otaku fra",
|
||||
"jean jahren",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"MiraiKuriyamaSy",
|
||||
"Ran C",
|
||||
"ViperC",
|
||||
"Penfore",
|
||||
@@ -149,17 +156,22 @@
|
||||
"Karl P.",
|
||||
"Akira_HentAI",
|
||||
"Gordon Cole",
|
||||
"Adam Taylor",
|
||||
"AbstractAss",
|
||||
"andrew.tappan",
|
||||
"Weird_With_A_Beard",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"Greenmoustache",
|
||||
"Jackthemind",
|
||||
"fancypants",
|
||||
"Eldithor",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
@@ -170,42 +182,37 @@
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Some Guy Named Barry",
|
||||
"Roslynd",
|
||||
"jinxedx",
|
||||
"Cosmosis",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"FloPro4Sho",
|
||||
"Cristian Vazquez",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Christopher Michel",
|
||||
"Serge Bekenkamp",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"nahinahi9",
|
||||
"Kevin John Duck",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"Blackfish95",
|
||||
"Mouthlessman",
|
||||
"Paul Kroll",
|
||||
"otaku fra",
|
||||
"MiraiKuriyamaSy",
|
||||
"Bas Imagineer",
|
||||
"yuxz69",
|
||||
"Adam Taylor",
|
||||
"Weird_With_A_Beard",
|
||||
"esthe",
|
||||
"Pozadine1",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"decoy",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"elu3199",
|
||||
@@ -217,46 +224,50 @@
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
"Tyrswood",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"MJG",
|
||||
"linnfrey",
|
||||
"IamAyam",
|
||||
"skaterb949",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"confiscated Zyra",
|
||||
"Error_Rule34_Not_found",
|
||||
"aezin",
|
||||
"jcay015",
|
||||
"Gerald Welly",
|
||||
"Roslynd",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Tee Gee",
|
||||
"Geolog",
|
||||
"tarek helmi",
|
||||
"Neco28",
|
||||
"Eris3D",
|
||||
"Max Marklund",
|
||||
"David Ortega",
|
||||
"Cristian Vazquez",
|
||||
"Magic Noob",
|
||||
"Pronredn",
|
||||
"DougPeterson",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"lh qwe",
|
||||
"Kevin John Duck",
|
||||
"James Coleman",
|
||||
"conner",
|
||||
"Kevin Christopher",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"Princess Bright Eyes",
|
||||
"Dušan Ryban",
|
||||
"Felipe dos Santos",
|
||||
"sjon kreutz",
|
||||
"John Statham",
|
||||
"Douglas Gaspar",
|
||||
"Metryman55",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"dw",
|
||||
"decoy",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"地獄の禄",
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
@@ -273,19 +284,20 @@
|
||||
"몽타주",
|
||||
"Kland",
|
||||
"Hailshem",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"epicgamer0020690",
|
||||
"Richard",
|
||||
"奚明 刘",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"Nerezza",
|
||||
"Robert Wegemund",
|
||||
"sanborondon",
|
||||
"준희 김",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Eris3D",
|
||||
"Sadlip",
|
||||
"Tomohiro Baba",
|
||||
"m",
|
||||
"Noora",
|
||||
@@ -294,32 +306,30 @@
|
||||
"Mattssn",
|
||||
"Mikko Hemilä",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"James Coleman",
|
||||
"Michael Taylor",
|
||||
"Martial",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Davaitamin",
|
||||
"Rops Alot",
|
||||
"tedcor",
|
||||
"Sam",
|
||||
"Fotek Design",
|
||||
"sjon kreutz",
|
||||
"Ace Ventura",
|
||||
"LarsesFPC",
|
||||
"MadSpin",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"momokai",
|
||||
"starbugx",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Crocket",
|
||||
"Joshua Porrata",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
@@ -339,22 +349,24 @@
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"OrganicArtifact",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"emadsultan",
|
||||
"Vir",
|
||||
"gzmzmvp",
|
||||
"Richard",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Gregory Kozhemiak",
|
||||
"Draven T",
|
||||
"mrjuan",
|
||||
"Brian Buie",
|
||||
"Sadlip",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Aquatic Coffee",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"John J Linehan",
|
||||
"ethanfel",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
@@ -366,34 +378,30 @@
|
||||
"Sloan Steddy",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"hexxish",
|
||||
"Derek Baker",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"Michael Anthony Scott",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Nathan",
|
||||
"Decx _",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Pat Hen",
|
||||
"Xeeosat",
|
||||
"Ed Wang",
|
||||
"Jordan Shaw",
|
||||
"g unit",
|
||||
"Srdb",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"LarsesFPC",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"FrxzenSnxw",
|
||||
"zenobeus",
|
||||
"Crocket",
|
||||
"Jackthemind",
|
||||
"ryoma",
|
||||
"Stryker",
|
||||
"ResidentDeviant",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"InformedViewz",
|
||||
@@ -415,6 +423,15 @@
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"DarkRoast",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"Sora+Yori",
|
||||
"lrdchs2",
|
||||
"Duk3+Rand0m",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"cocona",
|
||||
"ElitaSSJ4",
|
||||
"David Schenck",
|
||||
"Wolfe7D1",
|
||||
@@ -426,7 +443,6 @@
|
||||
"Goldwaters",
|
||||
"Kauffy",
|
||||
"Zude",
|
||||
"John J Linehan",
|
||||
"Kyler",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
@@ -435,17 +451,14 @@
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"hexxish",
|
||||
"Xenon Xue",
|
||||
"notedfakes",
|
||||
"Billy Gladky",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Michael Scott",
|
||||
"Probis",
|
||||
"Ed Wang",
|
||||
"Wes Sims",
|
||||
"ItsGeneralButtNaked",
|
||||
"Donor4115",
|
||||
"g unit",
|
||||
"Distortik",
|
||||
"Filippo Ferrari",
|
||||
"Youguang",
|
||||
@@ -460,9 +473,14 @@
|
||||
"Mitchell Robson",
|
||||
"Whitepinetrader",
|
||||
"POPPIN",
|
||||
"Ginnie",
|
||||
"emadsultan",
|
||||
"nanana",
|
||||
"ChaChanoKo",
|
||||
"ghoulars",
|
||||
"null",
|
||||
"Beau",
|
||||
"redcarrot",
|
||||
"powerbot99",
|
||||
"Fthehappy",
|
||||
"g",
|
||||
"J",
|
||||
"Alan+Cano",
|
||||
@@ -474,15 +492,6 @@
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"BillyBoy84",
|
||||
"DarkRoast",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"Sora+Yori",
|
||||
"lrdchs2",
|
||||
"Duk3+Rand0m",
|
||||
"Nathen+Choi",
|
||||
"T",
|
||||
"cocona",
|
||||
"Buecyb99",
|
||||
"Welkor",
|
||||
"John Martin",
|
||||
@@ -491,6 +500,8 @@
|
||||
"moranqianlong",
|
||||
"Kalli Core",
|
||||
"Time Valentine",
|
||||
"Christian Schäfer",
|
||||
"りん あめ",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Frogmilk",
|
||||
@@ -501,21 +512,26 @@
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"Xenon Xue",
|
||||
"JackJohnnyJim",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Khánh Đặng",
|
||||
"Edward Ten Eyck",
|
||||
"Michael Docherty",
|
||||
"Jimmy Borup",
|
||||
"Paul Hartsuyker",
|
||||
"Henrique Faiolli",
|
||||
"elitassj",
|
||||
"Solixer",
|
||||
"Pete Pain",
|
||||
"Jacob Winter",
|
||||
"Ryan Presley Ng",
|
||||
"jinksta187",
|
||||
"RHopkirk",
|
||||
"Andrew Wilkinson",
|
||||
"Manu Thetug",
|
||||
"Karlanx",
|
||||
"Lyavph",
|
||||
"Maxim",
|
||||
"David",
|
||||
"Meilo",
|
||||
"operationancut",
|
||||
@@ -537,6 +553,17 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"2turbo",
|
||||
"Somebody",
|
||||
"Balut+Omelette",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"tanjin90",
|
||||
"sternenkrieger",
|
||||
"eriick",
|
||||
"Patrick+Bryan",
|
||||
"Pascalou",
|
||||
"lighthawke",
|
||||
"Lev+Lanevskiy",
|
||||
"low9",
|
||||
"Winged",
|
||||
"YassineKhaled",
|
||||
@@ -552,13 +579,6 @@
|
||||
"Alex",
|
||||
"Jacky+Ho",
|
||||
"Karru",
|
||||
"ghoulars",
|
||||
"ChaChanoKo",
|
||||
"null",
|
||||
"Beau",
|
||||
"redcarrot",
|
||||
"powerbot99",
|
||||
"Fthehappy",
|
||||
"generic404",
|
||||
"abattoirblues",
|
||||
"zounik",
|
||||
@@ -568,9 +588,10 @@
|
||||
"Bob Barker",
|
||||
"edk",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Ben D",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"Christian Schäfer",
|
||||
"りん あめ",
|
||||
"ja s",
|
||||
"Doug Mason",
|
||||
"Jeremy Townsend",
|
||||
@@ -580,38 +601,41 @@
|
||||
"Sean voets",
|
||||
"Owen Gwosdz",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"John Rednoulf",
|
||||
"Spire",
|
||||
"Boba Smith",
|
||||
"Devil Lude",
|
||||
"David Murcko",
|
||||
"MR.Bear",
|
||||
"Jack Dole",
|
||||
"somethingtosay8",
|
||||
"ivistorm",
|
||||
"max blo",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"CptNeo",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Khánh Đặng",
|
||||
"Maso",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"Eric Ketchum",
|
||||
"Kevin Wallace",
|
||||
"Jimmy Borup",
|
||||
"David Spearing",
|
||||
"ChicRic",
|
||||
"Tigon",
|
||||
"BastardSama",
|
||||
"mercur",
|
||||
"Pete Pain",
|
||||
"RHopkirk",
|
||||
"Tania Nayelli Fernandez",
|
||||
"Draconach",
|
||||
"Yavizu3d",
|
||||
"Maxim",
|
||||
"Yves Poezevara",
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
"Вячеслав Маринин",
|
||||
"Dkommander22",
|
||||
"Cola Matthew",
|
||||
"OniNoKen",
|
||||
"Iain Wisely",
|
||||
@@ -655,6 +679,17 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"Monix",
|
||||
"Trolinka",
|
||||
"IshouI;_;",
|
||||
"PredragR",
|
||||
"Clauzmak",
|
||||
"Nerick",
|
||||
"JoL",
|
||||
"Gold_miner_ego",
|
||||
"SundayRage",
|
||||
"YoruHime",
|
||||
"matter",
|
||||
"SRCRCOSS",
|
||||
"imer",
|
||||
"Akkas+Haque",
|
||||
@@ -675,18 +710,8 @@
|
||||
"Sildoren",
|
||||
"Darvidous",
|
||||
"Seon+Song",
|
||||
"2turbo",
|
||||
"balut+omelette",
|
||||
"Nebuleux",
|
||||
"Dmitry+Viznesenskiy",
|
||||
"Tanjin90",
|
||||
"Somebody",
|
||||
"sternenkrieger",
|
||||
"eriick",
|
||||
"Join+Chun",
|
||||
"Pascalou",
|
||||
"lighthawke",
|
||||
"Terraformer",
|
||||
"GDS+DEV",
|
||||
"4rt+r3d",
|
||||
"you+halo9",
|
||||
@@ -712,17 +737,16 @@
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"Hans Meier",
|
||||
"shira1011",
|
||||
"sicarius",
|
||||
"Michael Eid",
|
||||
"Wolf and Fox Legends",
|
||||
"beersandbacon",
|
||||
"Neko Desco",
|
||||
"Bob barker",
|
||||
"Ben D",
|
||||
"Ninja Tom",
|
||||
"G",
|
||||
"karim ben brik",
|
||||
"Vinarus",
|
||||
"Josh Snyder",
|
||||
"Michael Zhu",
|
||||
"Nemisu",
|
||||
"Seraphy",
|
||||
@@ -732,41 +756,42 @@
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"Room Light",
|
||||
"Jairus Knudsen",
|
||||
"Poophead27 Blyat",
|
||||
"Xan Dionysus",
|
||||
"Patryk Serious",
|
||||
"Nathan lee",
|
||||
"Lyle Liston",
|
||||
"lylepaul",
|
||||
"Middo",
|
||||
"Forbidden Atelier",
|
||||
"Thomas Sankowski",
|
||||
"Spire",
|
||||
"DrB",
|
||||
"AZ Party Oasis",
|
||||
"Adictedtohumping",
|
||||
"Snorklebort",
|
||||
"Towelie",
|
||||
"TheFusion",
|
||||
"matt",
|
||||
"dsffsdfsdfsdfsdfsdf",
|
||||
"somethingtosay8",
|
||||
"Jean-françois SEMA",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"Kurt",
|
||||
"ivistorm",
|
||||
"Matt M.",
|
||||
"Ivan Imes",
|
||||
"J M",
|
||||
"Bouya shaka",
|
||||
"Faburizu",
|
||||
"Jack Lawfield",
|
||||
"jimyjomson",
|
||||
"Borte",
|
||||
"JaeHyun Jang",
|
||||
"Chase Kwon",
|
||||
"Ted Cart",
|
||||
"Sage Himeros",
|
||||
"yyuvuvu",
|
||||
"Inyoshu",
|
||||
"Chad Barnes",
|
||||
"Person Y",
|
||||
"David Spearing",
|
||||
"Nomki",
|
||||
"James Ming",
|
||||
"vanditking",
|
||||
"kripitonga",
|
||||
@@ -787,5 +812,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 784
|
||||
"totalCount": 809
|
||||
}
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||
"reimportMetadata": "Metadaten der Auswahl neu importieren",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"moreOptions": "Weitere Optionen",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
"pinSidebar": "Sidebar anheften",
|
||||
"unpinSidebar": "Sidebar lösen",
|
||||
"hideOnThisPage": "Seitenleiste auf dieser Seite ausblenden",
|
||||
"showSidebar": "Seitenleiste anzeigen",
|
||||
"sidebarHiddenNotification": "Seitenleiste auf der Seite {page} ausgeblendet",
|
||||
"switchToListView": "Zur Listenansicht wechseln",
|
||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||
"recursiveOn": "Unterordner einbeziehen",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "Copy Selected Syntax",
|
||||
"refreshAll": "Refresh Selected Metadata",
|
||||
"repairMetadata": "Repair Metadata for Selected",
|
||||
"reimportMetadata": "Re-import Metadata for Selected",
|
||||
"reimportMetadata": "Re-import from Source",
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
"moveAll": "Move Selected to Folder",
|
||||
"autoOrganize": "Auto-Organize Selected",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Root",
|
||||
"moreOptions": "More options",
|
||||
"collapseAll": "Collapse All Folders",
|
||||
"pinSidebar": "Pin Sidebar",
|
||||
"unpinSidebar": "Unpin Sidebar",
|
||||
"hideOnThisPage": "Hide sidebar on this page",
|
||||
"showSidebar": "Show sidebar",
|
||||
"sidebarHiddenNotification": "Folder sidebar hidden on {page} page",
|
||||
"switchToListView": "Switch to List View",
|
||||
"switchToTreeView": "Switch to Tree View",
|
||||
"recursiveOn": "Include subfolders",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"repairMetadata": "Reparar metadatos de la selección",
|
||||
"reimportMetadata": "Reimportar metadatos de la selección",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz",
|
||||
"moreOptions": "Más opciones",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
"pinSidebar": "Fijar barra lateral",
|
||||
"unpinSidebar": "Desfijar barra lateral",
|
||||
"hideOnThisPage": "Ocultar barra lateral en esta página",
|
||||
"showSidebar": "Mostrar barra lateral",
|
||||
"sidebarHiddenNotification": "Barra lateral oculta en la página {page}",
|
||||
"switchToListView": "Cambiar a vista de lista",
|
||||
"switchToTreeView": "Cambiar a vista de árbol",
|
||||
"recursiveOn": "Incluir subcarpetas",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||
"reimportMetadata": "Ré-importer les métadonnées de la sélection",
|
||||
"reimportMetadata": "Ré-importer depuis la source",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
"autoOrganize": "Auto-organiser la sélection",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine",
|
||||
"moreOptions": "Plus d'options",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
"pinSidebar": "Épingler la barre latérale",
|
||||
"unpinSidebar": "Désépingler la barre latérale",
|
||||
"hideOnThisPage": "Masquer la barre latérale sur cette page",
|
||||
"showSidebar": "Afficher la barre latérale",
|
||||
"sidebarHiddenNotification": "Barre latérale masquée sur la page {page}",
|
||||
"switchToListView": "Passer en vue liste",
|
||||
"switchToTreeView": "Passer en vue arborescence",
|
||||
"recursiveOn": "Inclure les sous-dossiers",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "העתק את כל התחבירים",
|
||||
"refreshAll": "רענן את כל המטא-דאטה",
|
||||
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||
"reimportMetadata": "ייבא מחדש מטא-דאטה עבור הנבחרים",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
"moveAll": "העבר הכל לתיקייה",
|
||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש",
|
||||
"moreOptions": "אפשרויות נוספות",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
"pinSidebar": "נעל סרגל צד",
|
||||
"unpinSidebar": "שחרר סרגל צד",
|
||||
"hideOnThisPage": "הסתר סרגל צד בדף זה",
|
||||
"showSidebar": "הצג סרגל צד",
|
||||
"sidebarHiddenNotification": "סרגל הצד מוסתר בדף {page}",
|
||||
"switchToListView": "עבור לתצוגת רשימה",
|
||||
"switchToTreeView": "תצוגת עץ",
|
||||
"recursiveOn": "כלול תיקיות משנה",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||
"reimportMetadata": "選択したレシピを再インポート",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
"autoOrganize": "自動整理を実行",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "ルート",
|
||||
"moreOptions": "その他のオプション",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"pinSidebar": "サイドバーを固定",
|
||||
"unpinSidebar": "サイドバーの固定を解除",
|
||||
"hideOnThisPage": "このページでサイドバーを非表示",
|
||||
"showSidebar": "サイドバーを表示",
|
||||
"sidebarHiddenNotification": "{page}ページでサイドバーが非表示になっています",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"recursiveOn": "サブフォルダーを含める",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||
"reimportMetadata": "선택한 레시피 다시 가져오기",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "루트",
|
||||
"moreOptions": "더 많은 옵션",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"pinSidebar": "사이드바 고정",
|
||||
"unpinSidebar": "사이드바 고정 해제",
|
||||
"hideOnThisPage": "이 페이지에서 사이드바 숨기기",
|
||||
"showSidebar": "사이드바 표시",
|
||||
"sidebarHiddenNotification": "{page} 페이지에서 사이드바가 숨겨져 있습니다",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"recursiveOn": "하위 폴더 포함",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||
"reimportMetadata": "Переимпортировать метаданные для выбранных",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень",
|
||||
"moreOptions": "Дополнительные параметры",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"pinSidebar": "Закрепить боковую панель",
|
||||
"unpinSidebar": "Открепить боковую панель",
|
||||
"hideOnThisPage": "Скрыть боковую панель на этой странице",
|
||||
"showSidebar": "Показать боковую панель",
|
||||
"sidebarHiddenNotification": "Боковая панель скрыта на странице {page}",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"recursiveOn": "Включать вложенные папки",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"repairMetadata": "修复所选中元数据",
|
||||
"reimportMetadata": "重新导入所选配方元数据",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "根目录",
|
||||
"moreOptions": "更多选项",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"pinSidebar": "固定侧边栏",
|
||||
"unpinSidebar": "取消固定侧边栏",
|
||||
"hideOnThisPage": "隐藏此页面侧边栏",
|
||||
"showSidebar": "显示侧边栏",
|
||||
"sidebarHiddenNotification": "{page}页面的文件夹侧边栏已隐藏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"recursiveOn": "包含子文件夹",
|
||||
|
||||
@@ -693,7 +693,7 @@
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"repairMetadata": "修復所選中元數據",
|
||||
"reimportMetadata": "重新匯入所選配方元數據",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
@@ -953,9 +953,13 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "根目錄",
|
||||
"moreOptions": "更多選項",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"pinSidebar": "固定側邊欄",
|
||||
"unpinSidebar": "取消固定側邊欄",
|
||||
"hideOnThisPage": "隱藏此頁面側邊欄",
|
||||
"showSidebar": "顯示側邊欄",
|
||||
"sidebarHiddenNotification": "{page}頁面的資料夾側邊欄已隱藏",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"recursiveOn": "包含子資料夾",
|
||||
|
||||
@@ -189,6 +189,10 @@ class LoraManager:
|
||||
|
||||
# Register DownloadManager with ServiceRegistry
|
||||
await ServiceRegistry.get_download_manager()
|
||||
|
||||
# Initialize DownloadQueueService for persistent queue/history
|
||||
await ServiceRegistry.get_download_queue_service()
|
||||
|
||||
await ServiceRegistry.get_backup_service()
|
||||
|
||||
from .services.metadata_service import initialize_metadata_providers
|
||||
|
||||
@@ -37,6 +37,7 @@ from ...services.use_cases import (
|
||||
)
|
||||
from ...services.websocket_manager import WebSocketManager
|
||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
||||
from ...services.download_queue_service import DownloadQueueService
|
||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||
from ...utils.civitai_utils import resolve_license_payload
|
||||
from ...utils.file_utils import calculate_sha256
|
||||
@@ -1567,6 +1568,255 @@ class ModelDownloadHandler:
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Download queue / history handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
queue = await service.get_queue()
|
||||
stats = await service.get_stats()
|
||||
return web.json_response({"success": True, "queue": queue, "stats": stats})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def add_to_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
import uuid
|
||||
|
||||
download_id = request.query.get("download_id") or str(uuid.uuid4())
|
||||
model_id_str = request.query.get("model_id")
|
||||
model_version_id_str = request.query.get("model_version_id")
|
||||
model_name = request.query.get("model_name", "")
|
||||
version_name = request.query.get("version_name", "")
|
||||
thumbnail_url = request.query.get("thumbnail_url", "")
|
||||
source = request.query.get("source")
|
||||
file_params_json = request.query.get("file_params")
|
||||
|
||||
model_id = int(model_id_str) if model_id_str else None
|
||||
model_version_id = int(model_version_id_str) if model_version_id_str else None
|
||||
file_params = json.loads(file_params_json) if file_params_json else None
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.add_to_queue(
|
||||
download_id=download_id,
|
||||
model_id=model_id,
|
||||
model_version_id=model_version_id,
|
||||
model_name=model_name,
|
||||
version_name=version_name,
|
||||
thumbnail_url=thumbnail_url,
|
||||
source=source,
|
||||
file_params=file_params,
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error adding to download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def remove_from_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "download_id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
removed = await service.remove_from_queue(download_id)
|
||||
return web.json_response({"success": removed})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error removing from download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_queue_item_to_top(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "download_id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
moved = await service.move_to_top(download_id)
|
||||
return web.json_response({"success": moved})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error moving queue item to top: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def move_queue_item_to_end(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "download_id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
moved = await service.move_to_end(download_id)
|
||||
return web.json_response({"success": moved})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error moving queue item to end: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def clear_download_queue(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cleared = await service.clear_queue(status_filter=status_filter)
|
||||
return web.json_response({"success": True, "cleared": cleared})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error clearing download queue: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_download_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = min(int(request.query.get("limit", "50")), 500)
|
||||
offset = int(request.query.get("offset", "0"))
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
result = await service.get_history(
|
||||
limit=limit, offset=offset, status_filter=status_filter
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"items": result["items"],
|
||||
"total": result["total"],
|
||||
"limit": result["limit"],
|
||||
"offset": result["offset"],
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def clear_download_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cleared = await service.clear_history(status_filter=status_filter)
|
||||
return web.json_response({"success": True, "cleared": cleared})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error clearing download history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def delete_download_history_item(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
item_id = int(request.query.get("id", "0"))
|
||||
if not item_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
deleted = await service.delete_history_item(item_id)
|
||||
return web.json_response({"success": deleted})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error deleting download history item: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def retry_download_from_history(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
item_id = int(request.query.get("id", "0"))
|
||||
if not item_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "id is required"}, status=400
|
||||
)
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.retry_from_history(item_id)
|
||||
if item is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "History item not found or not retryable"},
|
||||
status=404,
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrying download from history: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def retry_all_failed_downloads(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
retry_count = await service.retry_all_failed()
|
||||
return web.json_response({"success": True, "retry_count": retry_count})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrying all failed downloads: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def complete_download_in_queue(self, request: web.Request) -> web.Response:
|
||||
"""Atomically move a download from queue to history with terminal status."""
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "download_id is required"}, status=400
|
||||
)
|
||||
status = request.query.get("status", "completed")
|
||||
error = request.query.get("error")
|
||||
file_path = request.query.get("file_path")
|
||||
try:
|
||||
bytes_downloaded = int(request.query.get("bytes_downloaded", "0"))
|
||||
except (TypeError, ValueError):
|
||||
bytes_downloaded = 0
|
||||
total_bytes_raw = request.query.get("total_bytes")
|
||||
total_bytes = int(total_bytes_raw) if total_bytes_raw else None
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.complete_download(
|
||||
download_id=download_id,
|
||||
status=status,
|
||||
error=error,
|
||||
file_path=file_path,
|
||||
bytes_downloaded=bytes_downloaded,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
if item is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"}, status=404
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error completing download: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_download_stats(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
stats = await service.get_stats()
|
||||
return web.json_response({"success": True, "stats": stats})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error getting download stats: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelCivitaiHandler:
|
||||
"""CivitAI integration endpoints."""
|
||||
@@ -2596,6 +2846,19 @@ class ModelHandlerSet:
|
||||
"pause_download_get": self.download.pause_download_get,
|
||||
"resume_download_get": self.download.resume_download_get,
|
||||
"get_download_progress": self.download.get_download_progress,
|
||||
"get_download_queue": self.download.get_download_queue,
|
||||
"add_to_download_queue": self.download.add_to_download_queue,
|
||||
"remove_from_download_queue": self.download.remove_from_download_queue,
|
||||
"move_queue_item_to_top": self.download.move_queue_item_to_top,
|
||||
"move_queue_item_to_end": self.download.move_queue_item_to_end,
|
||||
"clear_download_queue": self.download.clear_download_queue,
|
||||
"get_download_history": self.download.get_download_history,
|
||||
"clear_download_history": self.download.clear_download_history,
|
||||
"delete_download_history_item": self.download.delete_download_history_item,
|
||||
"retry_download_from_history": self.download.retry_download_from_history,
|
||||
"retry_all_failed_downloads": self.download.retry_all_failed_downloads,
|
||||
"complete_download_in_queue": self.download.complete_download_in_queue,
|
||||
"get_download_stats": self.download.get_download_stats,
|
||||
"get_civitai_versions": self.civitai.get_civitai_versions,
|
||||
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
|
||||
"get_civitai_model_by_hash": self.civitai.get_civitai_model_by_hash,
|
||||
|
||||
@@ -107,6 +107,37 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/download-progress/{download_id}", "get_download_progress"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/queue", "get_download_queue"),
|
||||
RouteDefinition("GET", "/api/lm/downloads/queue/add", "add_to_download_queue"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/remove", "remove_from_download_queue"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/move-to-top", "move_queue_item_to_top"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/move-to-end", "move_queue_item_to_end"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/clear", "clear_download_queue"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/history", "get_download_history"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/clear", "clear_download_history"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/delete", "delete_download_history_item"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/retry", "retry_download_from_history"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/history/retry-all", "retry_all_failed_downloads"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/downloads/stats", "get_download_stats"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/downloads/queue/complete", "complete_download_in_queue"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
|
||||
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||
)
|
||||
|
||||
730
py/services/download_queue_service.py
Normal file
730
py/services/download_queue_service.py
Normal file
@@ -0,0 +1,730 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_database_path() -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
history_dir = os.path.join(base_dir, "download_history")
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
return os.path.join(history_dir, "download_queue.sqlite")
|
||||
|
||||
|
||||
class DownloadQueueService:
|
||||
"""Persistent download queue and history manager backed by SQLite.
|
||||
|
||||
Provides a singleton interface for managing a download queue and
|
||||
corresponding history table, both stored in a single SQLite database
|
||||
under the cache directory.
|
||||
"""
|
||||
|
||||
_instance: Optional[DownloadQueueService] = None
|
||||
_class_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS download_queue (
|
||||
download_id TEXT PRIMARY KEY,
|
||||
model_id INTEGER,
|
||||
model_version_id INTEGER,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
source TEXT,
|
||||
file_params TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
priority INTEGER DEFAULT 0,
|
||||
progress INTEGER DEFAULT 0,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
bytes_per_second REAL DEFAULT 0.0,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
added_at REAL NOT NULL,
|
||||
started_at REAL,
|
||||
completed_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dq_status ON download_queue(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_dq_added ON download_queue(added_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_id TEXT,
|
||||
model_id INTEGER,
|
||||
model_version_id INTEGER,
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
completed_at REAL NOT NULL,
|
||||
is_already_exists INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dh_completed ON download_history(completed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_dh_status ON download_history(status);
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> DownloadQueueService:
|
||||
"""Return the singleton instance, creating it if necessary."""
|
||||
async with cls._class_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None) -> None:
|
||||
self._db_path = db_path or _resolve_database_path()
|
||||
self._lock = asyncio.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._schema_initialized = False
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
|
||||
def _ensure_directory(self) -> None:
|
||||
directory = os.path.dirname(self._db_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
return self._conn
|
||||
|
||||
def _initialize_schema(self) -> None:
|
||||
if self._schema_initialized:
|
||||
return
|
||||
with self._connect() as conn:
|
||||
conn.executescript(self._SCHEMA)
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
|
||||
def get_database_path(self) -> str:
|
||||
"""Return the resolved database file path."""
|
||||
return self._db_path
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the persistent SQLite connection, if open.
|
||||
|
||||
This is called before plugin update operations to release the
|
||||
database file lock on Windows, allowing ``shutil.rmtree()`` to
|
||||
succeed when the cache resides inside the plugin directory.
|
||||
"""
|
||||
if self._conn is not None:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._conn = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queue methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def add_to_queue(
|
||||
self,
|
||||
download_id: str,
|
||||
model_id: Optional[int] = None,
|
||||
model_version_id: Optional[int] = None,
|
||||
model_name: str = "",
|
||||
version_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
source: Optional[str] = None,
|
||||
file_params: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a new download into the queue.
|
||||
|
||||
Returns the inserted row as a dict (or an empty dict if the
|
||||
download_id already exists).
|
||||
"""
|
||||
now = time.time()
|
||||
file_params_json = json.dumps(file_params) if file_params is not None else None
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
download_id,
|
||||
model_id,
|
||||
model_version_id,
|
||||
model_name,
|
||||
version_name,
|
||||
thumbnail_url,
|
||||
source,
|
||||
file_params_json,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(row) if row else {}
|
||||
|
||||
async def get_queue(self) -> list[dict[str, Any]]:
|
||||
"""Return all items in the queue ordered by priority then added time."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_queue ORDER BY priority DESC, added_at ASC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def get_queued_count(self) -> int:
|
||||
"""Return the number of items with status ``'queued'``."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_queue WHERE status = 'queued'"
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
download_id: str,
|
||||
status: str,
|
||||
**extra: Any,
|
||||
) -> bool:
|
||||
"""Update the status and/or extra fields of a queue item.
|
||||
|
||||
Accepted extra keyword arguments:
|
||||
``progress``, ``error``, ``file_path``, ``bytes_downloaded``,
|
||||
``total_bytes``, ``bytes_per_second``.
|
||||
|
||||
Returns ``True`` if a row was updated.
|
||||
"""
|
||||
allowed_extra = {
|
||||
"progress",
|
||||
"error",
|
||||
"file_path",
|
||||
"bytes_downloaded",
|
||||
"total_bytes",
|
||||
"bytes_per_second",
|
||||
}
|
||||
|
||||
set_clauses: list[str] = ["status = ?"]
|
||||
params: list[Any] = [status]
|
||||
now = time.time()
|
||||
|
||||
if status in ("downloading",):
|
||||
set_clauses.append("started_at = COALESCE(started_at, ?)")
|
||||
params.append(now)
|
||||
if status in ("completed", "failed", "canceled"):
|
||||
set_clauses.append("completed_at = ?")
|
||||
params.append(now)
|
||||
|
||||
for key, value in extra.items():
|
||||
if key in allowed_extra:
|
||||
set_clauses.append(f"{key} = ?")
|
||||
params.append(value)
|
||||
|
||||
params.append(download_id)
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
f"UPDATE download_queue SET {', '.join(set_clauses)} "
|
||||
"WHERE download_id = ?",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def remove_from_queue(self, download_id: str) -> bool:
|
||||
"""Remove a single item from the queue by download_id.
|
||||
|
||||
Returns ``True`` if a row was deleted.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def move_to_top(self, download_id: str) -> bool:
|
||||
"""Move an item to the front of the queue (highest priority).
|
||||
|
||||
Returns ``True`` if the item was found and updated.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT priority FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
max_row = conn.execute(
|
||||
"SELECT MAX(priority) AS mx FROM download_queue"
|
||||
).fetchone()
|
||||
max_priority: int = max_row["mx"] if max_row["mx"] is not None else 0
|
||||
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
|
||||
(max_priority + 1, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
async def move_to_end(self, download_id: str) -> bool:
|
||||
"""Move an item to the end of the queue (lowest priority).
|
||||
|
||||
Returns ``True`` if the item was found and updated.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT priority FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
|
||||
min_row = conn.execute(
|
||||
"SELECT MIN(priority) AS mn FROM download_queue"
|
||||
).fetchone()
|
||||
min_priority: int = min_row["mn"] if min_row["mn"] is not None else 0
|
||||
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET priority = ? WHERE download_id = ?",
|
||||
(min_priority - 1, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
async def clear_queue(self, status_filter: Optional[str] = None) -> int:
|
||||
"""Remove items from the queue.
|
||||
|
||||
When *status_filter* is provided only items with that status are
|
||||
deleted. Returns the number of deleted rows.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if status_filter is not None:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_queue WHERE status = ?",
|
||||
(status_filter,),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute("DELETE FROM download_queue")
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def complete_download(
|
||||
self,
|
||||
download_id: str,
|
||||
status: str = "completed",
|
||||
error: Optional[str] = None,
|
||||
file_path: Optional[str] = None,
|
||||
bytes_downloaded: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Atomically move a download from the queue into the history table.
|
||||
|
||||
Looks up the queue record by ``download_id``, deletes it from the
|
||||
queue, and inserts a corresponding history entry with the given
|
||||
terminal status (``completed``, ``failed``, or ``canceled``).
|
||||
|
||||
Returns the original queue record (before deletion) on success,
|
||||
or ``None`` if the download was not found in the queue.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["download_id"],
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
|
||||
async def pop_next_download(self) -> Optional[dict[str, Any]]:
|
||||
"""Atomically fetch and mark the next queued item as ``downloading``.
|
||||
|
||||
The item with the highest priority (and earliest ``added_at``
|
||||
among ties) whose status is ``'queued'`` is selected, set to
|
||||
``'downloading'``, and returned as a dict. Returns ``None`` if
|
||||
the queue is empty.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM download_queue
|
||||
WHERE status = 'queued'
|
||||
ORDER BY priority DESC, added_at ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
download_id = row["download_id"]
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"UPDATE download_queue SET status = 'downloading', "
|
||||
"started_at = COALESCE(started_at, ?) "
|
||||
"WHERE download_id = ?",
|
||||
(now, download_id),
|
||||
)
|
||||
conn.commit()
|
||||
updated = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(updated) if updated else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# History methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def add_to_history(
|
||||
self,
|
||||
download_id: Optional[str] = None,
|
||||
model_id: Optional[int] = None,
|
||||
model_version_id: Optional[int] = None,
|
||||
model_name: str = "",
|
||||
version_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
status: str = "completed",
|
||||
error: Optional[str] = None,
|
||||
file_path: Optional[str] = None,
|
||||
bytes_downloaded: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
is_already_exists: int = 0,
|
||||
) -> int:
|
||||
"""Insert a record into the download history.
|
||||
|
||||
Returns the ``id`` (AUTOINCREMENT primary key) of the newly
|
||||
inserted row.
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at, is_already_exists
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
download_id,
|
||||
model_id,
|
||||
model_version_id,
|
||||
model_name,
|
||||
version_name,
|
||||
thumbnail_url,
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
now,
|
||||
is_already_exists,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid or 0
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status_filter: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a page of download history entries.
|
||||
|
||||
Returns a dict with keys ``items``, ``total``, ``limit``, and
|
||||
``offset``.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
if status_filter is not None:
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
|
||||
(status_filter,),
|
||||
).fetchone()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history WHERE status = ? "
|
||||
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
|
||||
(status_filter, limit, offset),
|
||||
).fetchall()
|
||||
else:
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history"
|
||||
).fetchone()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history "
|
||||
"ORDER BY completed_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [dict(row) for row in rows],
|
||||
"total": count_row["cnt"] if count_row else 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
async def delete_history_item(self, id: int) -> bool:
|
||||
"""Delete a single history entry by its *id*.
|
||||
|
||||
Returns ``True`` if a row was deleted.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM download_history WHERE id = ?",
|
||||
(id,),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def clear_history(
|
||||
self,
|
||||
status_filter: Optional[str] = None,
|
||||
before_timestamp: Optional[float] = None,
|
||||
) -> int:
|
||||
"""Remove history entries matching the optional filters.
|
||||
|
||||
Both ``status_filter`` and ``before_timestamp`` can be combined
|
||||
(AND logic). Returns the number of deleted rows.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if status_filter is not None:
|
||||
clauses.append("status = ?")
|
||||
params.append(status_filter)
|
||||
if before_timestamp is not None:
|
||||
clauses.append("completed_at < ?")
|
||||
params.append(before_timestamp)
|
||||
|
||||
where = ""
|
||||
if clauses:
|
||||
where = " WHERE " + " AND ".join(clauses)
|
||||
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM download_history{where}",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
async def get_history_count(self, status_filter: Optional[str] = None) -> int:
|
||||
"""Return the number of history entries, optionally filtered by status."""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if status_filter is not None:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history WHERE status = ?",
|
||||
(status_filter,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM download_history"
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Retry
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def retry_from_history(self, item_id: int) -> Optional[dict[str, Any]]:
|
||||
"""Re-queue a failed or canceled download from history.
|
||||
|
||||
Looks up the history record by its primary key. If the status is
|
||||
``failed`` or ``canceled`` a new queue entry is created with the
|
||||
same model metadata and a fresh download id.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM download_history WHERE id = ?",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
status = str(row["status"])
|
||||
if status not in ("failed", "canceled"):
|
||||
return None
|
||||
|
||||
import uuid
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
queued = conn.execute(
|
||||
"SELECT * FROM download_queue WHERE download_id = ?",
|
||||
(new_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(queued) if queued else None
|
||||
|
||||
async def retry_all_failed(self) -> int:
|
||||
"""Re-queue all failed and canceled downloads from history.
|
||||
|
||||
Returns the number of items that were re-queued.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM download_history WHERE status IN ('failed', 'canceled')"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
import uuid
|
||||
|
||||
now = time.time()
|
||||
count = 0
|
||||
for row in rows:
|
||||
new_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_queue (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
row["model_id"],
|
||||
row["model_version_id"],
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
now,
|
||||
),
|
||||
)
|
||||
count += 1
|
||||
conn.commit()
|
||||
|
||||
return count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_stats(self) -> dict[str, int]:
|
||||
"""Return aggregate counts across both tables.
|
||||
|
||||
Returns a dict with keys ``queued``, ``downloading``, ``paused``
|
||||
(all from the queue table) and ``completed``, ``failed``,
|
||||
``canceled`` (all from the history table).
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
queue_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS cnt FROM download_queue GROUP BY status"
|
||||
).fetchall()
|
||||
queue_stats: dict[str, int] = {}
|
||||
for row in queue_rows:
|
||||
queue_stats[str(row["status"])] = row["cnt"]
|
||||
|
||||
history_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS cnt FROM download_history GROUP BY status"
|
||||
).fetchall()
|
||||
history_stats: dict[str, int] = {}
|
||||
for row in history_rows:
|
||||
history_stats[str(row["status"])] = row["cnt"]
|
||||
|
||||
return {
|
||||
"queued": queue_stats.get("queued", 0),
|
||||
"downloading": queue_stats.get("downloading", 0),
|
||||
"paused": queue_stats.get("paused", 0),
|
||||
"completed": history_stats.get("completed", 0),
|
||||
"failed": history_stats.get("failed", 0),
|
||||
"canceled": history_stats.get("canceled", 0),
|
||||
}
|
||||
@@ -129,6 +129,18 @@ class RecipePersistenceService:
|
||||
if nsfw_level is not None and isinstance(nsfw_level, int):
|
||||
recipe_data["preview_nsfw_level"] = nsfw_level
|
||||
|
||||
# Compute recipe folder relative to recipes root, mirroring
|
||||
# RecipeScanner._calculate_folder() which is only called during scan/load.
|
||||
if recipe_scanner.recipes_dir:
|
||||
recipe_file_dir = os.path.dirname(normalized_image_path)
|
||||
try:
|
||||
relative_folder = os.path.relpath(recipe_file_dir, recipe_scanner.recipes_dir)
|
||||
if relative_folder in (".", ""):
|
||||
relative_folder = ""
|
||||
recipe_data["folder"] = relative_folder.replace(os.path.sep, "/")
|
||||
except Exception:
|
||||
recipe_data["folder"] = ""
|
||||
|
||||
json_filename = f"{recipe_id}.recipe.json"
|
||||
json_path = os.path.join(recipes_dir, json_filename)
|
||||
json_path = os.path.normpath(json_path)
|
||||
|
||||
@@ -188,6 +188,25 @@ class ServiceRegistry:
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_download_queue_service(cls):
|
||||
"""Get or create the download queue service."""
|
||||
service_name = "download_queue_service"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
from .download_queue_service import DownloadQueueService
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cls._services[service_name] = service
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_backup_service(cls):
|
||||
"""Get or create the backup service."""
|
||||
|
||||
@@ -221,6 +221,9 @@ class UsageStats:
|
||||
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
|
||||
self.stats["loras"] = loaded_stats["loras"]
|
||||
|
||||
if "embeddings" in loaded_stats and isinstance(loaded_stats["embeddings"], dict):
|
||||
self.stats["embeddings"] = loaded_stats["embeddings"]
|
||||
|
||||
if "total_executions" in loaded_stats:
|
||||
self.stats["total_executions"] = loaded_stats["total_executions"]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.0.11"
|
||||
version = "1.1.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-header:hover {
|
||||
@@ -150,6 +151,120 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== Sidebar More Options Dropdown ===== */
|
||||
.sidebar-more-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 8px;
|
||||
min-width: 190px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: calc(var(--z-overlay) + 20);
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-more-dropdown.open {
|
||||
display: block;
|
||||
animation: dropdownFadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.sidebar-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
transition: var(--transition-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-dropdown-item:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.sidebar-dropdown-item i {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-dropdown-item:hover i {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-dropdown-item.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== Sidebar Hidden Indicator (left edge) ===== */
|
||||
.sidebar-hidden-indicator {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: var(--z-overlay);
|
||||
width: 14px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--border-color);
|
||||
opacity: 0.3;
|
||||
border-radius: 0 4px 4px 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator:hover {
|
||||
opacity: 0.7;
|
||||
background: var(--lora-accent);
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator i {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator:hover i {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator-tooltip {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-left: 8px;
|
||||
padding: 4px 10px;
|
||||
background: var(--text-color);
|
||||
color: var(--bg-color);
|
||||
font-size: 0.8em;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-hidden-indicator:hover .sidebar-hidden-indicator-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -36,6 +36,8 @@ export class SidebarManager {
|
||||
this.currentDropTarget = null;
|
||||
this.lastPageControls = null;
|
||||
this.isDisabledBySetting = false;
|
||||
this.isDisabledByPage = false;
|
||||
this.isMoreDropdownOpen = false;
|
||||
this.initializationPromise = null;
|
||||
this.isCreatingFolder = false;
|
||||
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
|
||||
@@ -68,6 +70,10 @@ export class SidebarManager {
|
||||
this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
|
||||
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
|
||||
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
|
||||
this.handleMoreToggle = this.handleMoreToggle.bind(this);
|
||||
this.handleMoreDropdownItemClick = this.handleMoreDropdownItemClick.bind(this);
|
||||
this.handleDocumentClickForMore = this.handleDocumentClickForMore.bind(this);
|
||||
this.getPageDisplayName = this.getPageDisplayName.bind(this);
|
||||
}
|
||||
|
||||
setHostPageControls(pageControls) {
|
||||
@@ -100,6 +106,8 @@ export class SidebarManager {
|
||||
this.initializeDragAndDrop();
|
||||
this.updateSidebarTitle();
|
||||
this.restoreSidebarState();
|
||||
// Re-apply DOM visibility now that per-page state is known
|
||||
this.updateDomVisibility(!this.isDisabledBySetting);
|
||||
await this.loadFolderTree();
|
||||
if (this.isDisabledBySetting && !forceInitialize) {
|
||||
this.cleanup();
|
||||
@@ -143,6 +151,13 @@ export class SidebarManager {
|
||||
this.sidebarDragHandlersInitialized = false;
|
||||
}
|
||||
|
||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
||||
if (moreDropdown) {
|
||||
moreDropdown.classList.remove('open');
|
||||
}
|
||||
this.isMoreDropdownOpen = false;
|
||||
this.hideSidebarHiddenIndicator();
|
||||
|
||||
// Reset state
|
||||
this.pageControls = null;
|
||||
this.pageType = null;
|
||||
@@ -151,6 +166,7 @@ export class SidebarManager {
|
||||
this.expandedNodes = new Set();
|
||||
this.openDropdown = null;
|
||||
this.isHovering = false;
|
||||
this.isDisabledByPage = false;
|
||||
this.apiClient = null;
|
||||
this.isInitialized = false;
|
||||
this.recursiveSearchEnabled = true;
|
||||
@@ -217,6 +233,18 @@ export class SidebarManager {
|
||||
if (recursiveToggleBtn) {
|
||||
recursiveToggleBtn.removeEventListener('click', this.handleRecursiveToggle);
|
||||
}
|
||||
|
||||
const moreToggle = document.getElementById('sidebarMoreToggle');
|
||||
if (moreToggle) {
|
||||
moreToggle.removeEventListener('click', this.handleMoreToggle);
|
||||
}
|
||||
|
||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
||||
if (moreDropdown) {
|
||||
moreDropdown.removeEventListener('click', this.handleMoreDropdownItemClick);
|
||||
}
|
||||
|
||||
document.removeEventListener('click', this.handleDocumentClickForMore);
|
||||
}
|
||||
|
||||
initializeDragAndDrop() {
|
||||
@@ -1045,6 +1073,19 @@ export class SidebarManager {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// More options dropdown
|
||||
const moreToggle = document.getElementById('sidebarMoreToggle');
|
||||
if (moreToggle) {
|
||||
moreToggle.addEventListener('click', this.handleMoreToggle);
|
||||
}
|
||||
|
||||
const moreDropdown = document.getElementById('sidebarMoreDropdown');
|
||||
if (moreDropdown) {
|
||||
moreDropdown.addEventListener('click', this.handleMoreDropdownItemClick);
|
||||
}
|
||||
|
||||
document.addEventListener('click', this.handleDocumentClickForMore);
|
||||
}
|
||||
|
||||
handleDocumentClick(event) {
|
||||
@@ -1066,6 +1107,7 @@ export class SidebarManager {
|
||||
this.isPinned = !this.isPinned;
|
||||
this.updateAutoHideState();
|
||||
this.updatePinButton();
|
||||
this.updateMoreDropdownLabels();
|
||||
this.saveSidebarState();
|
||||
this.updateContainerMargin();
|
||||
}
|
||||
@@ -1129,7 +1171,7 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
updateAutoHideState() {
|
||||
if (this.isDisabledBySetting) return;
|
||||
if (this.isDisabledBySetting || this.isDisabledByPage) return;
|
||||
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
@@ -1174,9 +1216,12 @@ export class SidebarManager {
|
||||
|
||||
if (!container || !sidebar || this.isDisabledBySetting) return;
|
||||
|
||||
// Reset margin to default
|
||||
// Always reset margin first — needed when transitioning from visible to hidden
|
||||
container.style.marginLeft = '';
|
||||
|
||||
// When per-page disabled, skip adjustment but margin is already reset
|
||||
if (this.isDisabledByPage) return;
|
||||
|
||||
// Only adjust margin if sidebar is visible and pinned
|
||||
if ((this.isPinned || this.isHovering) && this.isVisible) {
|
||||
const sidebarWidth = sidebar.offsetWidth;
|
||||
@@ -1193,20 +1238,29 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
updateDomVisibility(enabled) {
|
||||
// Per-page disable adds on top of global setting
|
||||
const isVisible = enabled && !this.isDisabledByPage;
|
||||
const sidebar = document.getElementById('folderSidebar');
|
||||
const hoverArea = document.getElementById('sidebarHoverArea');
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('hidden-by-setting', !enabled);
|
||||
sidebar.setAttribute('aria-hidden', (!enabled).toString());
|
||||
sidebar.classList.toggle('hidden-by-setting', !isVisible);
|
||||
sidebar.setAttribute('aria-hidden', (!isVisible).toString());
|
||||
}
|
||||
|
||||
if (hoverArea) {
|
||||
hoverArea.classList.toggle('hidden-by-setting', !enabled);
|
||||
if (!enabled) {
|
||||
hoverArea.classList.toggle('hidden-by-setting', !isVisible);
|
||||
if (!isVisible) {
|
||||
hoverArea.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Show or hide the "sidebar hidden" notification
|
||||
if (enabled && this.isDisabledByPage) {
|
||||
this.showSidebarHiddenIndicator();
|
||||
} else {
|
||||
this.hideSidebarHiddenIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
async setSidebarEnabled(enabled) {
|
||||
@@ -1266,6 +1320,133 @@ export class SidebarManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== More Options Dropdown =====
|
||||
|
||||
handleMoreToggle(event) {
|
||||
event.stopPropagation();
|
||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
this.isMoreDropdownOpen = !dropdown.classList.contains('open');
|
||||
dropdown.classList.toggle('open', this.isMoreDropdownOpen);
|
||||
this.updateMoreDropdownLabels();
|
||||
}
|
||||
|
||||
handleMoreDropdownItemClick(event) {
|
||||
const item = event.target.closest('.sidebar-dropdown-item');
|
||||
if (!item) return;
|
||||
|
||||
const action = item.dataset.action;
|
||||
if (!action) return;
|
||||
|
||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('open');
|
||||
this.isMoreDropdownOpen = false;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'toggle-pin':
|
||||
this.handlePinToggle(event);
|
||||
break;
|
||||
case 'toggle-hide':
|
||||
this.toggleHideOnThisPage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleDocumentClickForMore(event) {
|
||||
const dropdown = document.getElementById('sidebarMoreDropdown');
|
||||
const toggle = document.getElementById('sidebarMoreToggle');
|
||||
if (!dropdown || !toggle) return;
|
||||
|
||||
if (!dropdown.contains(event.target) && !toggle.contains(event.target)) {
|
||||
dropdown.classList.remove('open');
|
||||
this.isMoreDropdownOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateMoreDropdownLabels() {
|
||||
const pinLabel = document.getElementById('sidebarMorePinLabel');
|
||||
if (pinLabel) {
|
||||
pinLabel.textContent = this.isPinned
|
||||
? translate('sidebar.unpinSidebar')
|
||||
: translate('sidebar.pinSidebar');
|
||||
}
|
||||
|
||||
const hideItem = document.querySelector('.sidebar-dropdown-item[data-action="toggle-hide"]');
|
||||
if (hideItem) {
|
||||
const hideIcon = hideItem.querySelector('i');
|
||||
const hideLabel = hideItem.querySelector('span');
|
||||
if (this.isDisabledByPage) {
|
||||
hideLabel.textContent = translate('sidebar.showSidebar');
|
||||
if (hideIcon) {
|
||||
hideIcon.className = 'fas fa-eye';
|
||||
}
|
||||
} else {
|
||||
hideLabel.textContent = translate('sidebar.hideOnThisPage');
|
||||
if (hideIcon) {
|
||||
hideIcon.className = 'fas fa-eye-slash';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleHideOnThisPage() {
|
||||
this.isDisabledByPage = !this.isDisabledByPage;
|
||||
setStorageItem(`${this.pageType}_sidebarDisabled`, this.isDisabledByPage);
|
||||
this.updateDomVisibility(!this.isDisabledBySetting);
|
||||
this.updateAutoHideState();
|
||||
this.updateContainerMargin();
|
||||
this.updateMoreDropdownLabels();
|
||||
|
||||
if (!this.isDisabledByPage) {
|
||||
this.hideSidebarHiddenIndicator();
|
||||
} else {
|
||||
showToast(
|
||||
'sidebar.sidebarHiddenNotification',
|
||||
{ page: this.getPageDisplayName() },
|
||||
'info',
|
||||
`Sidebar hidden on ${this.getPageDisplayName()} page`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getPageDisplayName() {
|
||||
const names = {
|
||||
loras: 'LoRAs',
|
||||
recipes: 'Recipes',
|
||||
checkpoints: 'Checkpoints',
|
||||
embeddings: 'Embeddings',
|
||||
};
|
||||
return names[this.pageType] || this.pageType;
|
||||
}
|
||||
|
||||
showSidebarHiddenIndicator() {
|
||||
if (document.getElementById('sidebarHiddenIndicator')) return;
|
||||
|
||||
const indicator = document.createElement('div');
|
||||
indicator.id = 'sidebarHiddenIndicator';
|
||||
indicator.className = 'sidebar-hidden-indicator';
|
||||
indicator.innerHTML = `
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
<span class="sidebar-hidden-indicator-tooltip">${translate('sidebar.showSidebar')}</span>
|
||||
`;
|
||||
|
||||
indicator.addEventListener('click', () => {
|
||||
this.toggleHideOnThisPage();
|
||||
});
|
||||
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
|
||||
hideSidebarHiddenIndicator() {
|
||||
const indicator = document.getElementById('sidebarHiddenIndicator');
|
||||
if (indicator) {
|
||||
indicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async loadFolderTree() {
|
||||
try {
|
||||
if (this.displayMode === 'tree') {
|
||||
@@ -1911,6 +2092,7 @@ export class SidebarManager {
|
||||
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
||||
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
|
||||
|
||||
this.isPinned = isPinned;
|
||||
this.expandedNodes = new Set(expandedPaths);
|
||||
|
||||
@@ -998,7 +998,9 @@ function showNodeSelector(nodes, options = {}) {
|
||||
nodeSelectorState.enableSendAll = options.enableSendAll !== false;
|
||||
|
||||
// Generate node list HTML with icons and proper colors
|
||||
const nodeItems = Object.entries(safeNodes).map(([nodeKey, node]) => {
|
||||
const nodeItems = Object.entries(safeNodes)
|
||||
.sort(([, a], [, b]) => a.type - b.type || a.id - b.id)
|
||||
.map(([nodeKey, node]) => {
|
||||
const iconClass = NODE_TYPE_ICONS[node.type] || 'fas fa-question-circle';
|
||||
const bgColor = node.bgcolor || DEFAULT_NODE_COLOR;
|
||||
const graphLabel = node.graph_name ? ` (${node.graph_name})` : '';
|
||||
|
||||
@@ -18,6 +18,20 @@
|
||||
<button class="sidebar-action-btn" id="sidebarPinToggle" title="{{ t('sidebar.unpinSidebar') }}">
|
||||
<i class="fas fa-thumbtack"></i>
|
||||
</button>
|
||||
<button class="sidebar-action-btn" id="sidebarMoreToggle" title="{{ t('sidebar.moreOptions') }}">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dropdown menu for more options -->
|
||||
<div class="sidebar-more-dropdown" id="sidebarMoreDropdown">
|
||||
<div class="sidebar-dropdown-item" data-action="toggle-pin">
|
||||
<i class="fas fa-thumbtack"></i>
|
||||
<span id="sidebarMorePinLabel">{{ t('sidebar.pinSidebar') }}</span>
|
||||
</div>
|
||||
<div class="sidebar-dropdown-item" data-action="toggle-hide">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
<span>{{ t('sidebar.hideOnThisPage') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
|
||||
Reference in New Issue
Block a user