mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-22 11:21:15 -03:00
Compare commits
10 Commits
faf64f8986
...
v1.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
999814ca87 | ||
|
|
3c2760a803 | ||
|
|
0edbd7bcca | ||
|
|
21e89fa7de | ||
|
|
968d6d1d1f | ||
|
|
cf0fd0e0ad | ||
|
|
16e5dcf7b2 | ||
|
|
ab6bb25d46 | ||
|
|
07f49559be | ||
|
|
b24b1a7e57 |
@@ -11,14 +11,15 @@
|
|||||||
"Insomnia Art Designs",
|
"Insomnia Art Designs",
|
||||||
"2018cfh",
|
"2018cfh",
|
||||||
"Arlecchino Shion",
|
"Arlecchino Shion",
|
||||||
|
"Charles Blakemore",
|
||||||
"Rob Williams",
|
"Rob Williams",
|
||||||
"W+K+White",
|
"W+K+White",
|
||||||
"$MetaSamsara",
|
"$MetaSamsara",
|
||||||
"wackop",
|
"wackop",
|
||||||
"Phil",
|
"Phil",
|
||||||
"Carl G.",
|
"Carl G.",
|
||||||
"Charles Blakemore",
|
|
||||||
"stone9k",
|
"stone9k",
|
||||||
|
"Rosenthal",
|
||||||
"itismyelement",
|
"itismyelement",
|
||||||
"Mozzel",
|
"Mozzel",
|
||||||
"Gingko Biloba",
|
"Gingko Biloba",
|
||||||
@@ -28,7 +29,6 @@
|
|||||||
"DM",
|
"DM",
|
||||||
"Sen314",
|
"Sen314",
|
||||||
"Estragon",
|
"Estragon",
|
||||||
"Rosenthal",
|
|
||||||
"ClockDaemon",
|
"ClockDaemon",
|
||||||
"Francisco Tatis",
|
"Francisco Tatis",
|
||||||
"Tobi_Swagg",
|
"Tobi_Swagg",
|
||||||
@@ -80,11 +80,13 @@
|
|||||||
"Release Cabrakan",
|
"Release Cabrakan",
|
||||||
"JW Sin",
|
"JW Sin",
|
||||||
"Alex",
|
"Alex",
|
||||||
|
"bh",
|
||||||
"carozzz",
|
"carozzz",
|
||||||
"Marlon Daniels",
|
"Marlon Daniels",
|
||||||
"James Dooley",
|
"James Dooley",
|
||||||
"zenbound",
|
"zenbound",
|
||||||
"Buzzard",
|
"Buzzard",
|
||||||
|
"Aaron Bleuer",
|
||||||
"Adam Shaw",
|
"Adam Shaw",
|
||||||
"Mark Corneglio",
|
"Mark Corneglio",
|
||||||
"SarcasticHashtag",
|
"SarcasticHashtag",
|
||||||
@@ -95,6 +97,7 @@
|
|||||||
"James Todd",
|
"James Todd",
|
||||||
"Wicked Choices by ASLPro3D",
|
"Wicked Choices by ASLPro3D",
|
||||||
"FinalyFree",
|
"FinalyFree",
|
||||||
|
"Weasyl",
|
||||||
"Steven Pfeiffer",
|
"Steven Pfeiffer",
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"Johnny",
|
"Johnny",
|
||||||
@@ -105,7 +108,7 @@
|
|||||||
"Luc Job",
|
"Luc Job",
|
||||||
"dl0901dm",
|
"dl0901dm",
|
||||||
"corde",
|
"corde",
|
||||||
"Nick Walker",
|
"nwalker94",
|
||||||
"Yushio",
|
"Yushio",
|
||||||
"Vik71it",
|
"Vik71it",
|
||||||
"Bishoujoker",
|
"Bishoujoker",
|
||||||
@@ -118,9 +121,12 @@
|
|||||||
"BadassArabianMofo",
|
"BadassArabianMofo",
|
||||||
"Pascal Dahle",
|
"Pascal Dahle",
|
||||||
"Greg",
|
"Greg",
|
||||||
|
"Sangheili460",
|
||||||
|
"MagnaInsomnia",
|
||||||
"Akira_HentAI",
|
"Akira_HentAI",
|
||||||
"lmsupporter",
|
"lmsupporter",
|
||||||
"andrew.tappan",
|
"andrew.tappan",
|
||||||
|
"N/A",
|
||||||
"Greenmoustache",
|
"Greenmoustache",
|
||||||
"zounic",
|
"zounic",
|
||||||
"wfpearl",
|
"wfpearl",
|
||||||
@@ -128,20 +134,19 @@
|
|||||||
"Jack B Nimble",
|
"Jack B Nimble",
|
||||||
"JaxMax",
|
"JaxMax",
|
||||||
"contrite831",
|
"contrite831",
|
||||||
"bh",
|
|
||||||
"Jwk0205",
|
"Jwk0205",
|
||||||
"Starkselle",
|
"Starkselle",
|
||||||
"Olive",
|
"Olive",
|
||||||
"Aaron Bleuer",
|
|
||||||
"LacesOut!",
|
"LacesOut!",
|
||||||
"greebles",
|
"greebles",
|
||||||
"Some Guy Named Barry",
|
"Some Guy Named Barry",
|
||||||
"M Postkasse",
|
"M Postkasse",
|
||||||
"Gooohokrbe",
|
"Gooohokrbe",
|
||||||
|
"wamekukyouzin",
|
||||||
"OldBones",
|
"OldBones",
|
||||||
"Jacob Hoehler",
|
"Jacob Hoehler",
|
||||||
|
"Dogmaster",
|
||||||
"Matt Wenzel",
|
"Matt Wenzel",
|
||||||
"Weasyl",
|
|
||||||
"Lex Song",
|
"Lex Song",
|
||||||
"Cory Paza",
|
"Cory Paza",
|
||||||
"Gonzalo Andre Allendes Lopez",
|
"Gonzalo Andre Allendes Lopez",
|
||||||
@@ -151,20 +156,18 @@
|
|||||||
"Philip Hempel",
|
"Philip Hempel",
|
||||||
"dan",
|
"dan",
|
||||||
"aai",
|
"aai",
|
||||||
|
"Mouthlessman",
|
||||||
"otaku fra",
|
"otaku fra",
|
||||||
"jean jahren",
|
"jean jahren",
|
||||||
"MiraiKuriyamaSy",
|
"MiraiKuriyamaSy",
|
||||||
"Ran C",
|
"Ran C",
|
||||||
"ViperC",
|
"ViperC",
|
||||||
"Penfore",
|
"Penfore",
|
||||||
"Sangheili460",
|
|
||||||
"MagnaInsomnia",
|
|
||||||
"Karl P.",
|
"Karl P.",
|
||||||
"Gordon Cole",
|
"Gordon Cole",
|
||||||
"Adam Taylor",
|
"Adam Taylor",
|
||||||
"AbstractAss",
|
"AbstractAss",
|
||||||
"Weird_With_A_Beard",
|
"Weird_With_A_Beard",
|
||||||
"N/A",
|
|
||||||
"The Spawn",
|
"The Spawn",
|
||||||
"graysock",
|
"graysock",
|
||||||
"Pozadine1",
|
"Pozadine1",
|
||||||
@@ -187,15 +190,15 @@
|
|||||||
"太郎 ゲーム",
|
"太郎 ゲーム",
|
||||||
"Roslynd",
|
"Roslynd",
|
||||||
"jinxedx",
|
"jinxedx",
|
||||||
|
"Neco28",
|
||||||
"Cosmosis",
|
"Cosmosis",
|
||||||
|
"David Ortega",
|
||||||
"AELOX",
|
"AELOX",
|
||||||
"Dankin",
|
"Dankin",
|
||||||
"Nicfit23",
|
"Nicfit23",
|
||||||
"FloPro4Sho",
|
"FloPro4Sho",
|
||||||
"Cristian Vazquez",
|
"Cristian Vazquez",
|
||||||
"wamekukyouzin",
|
|
||||||
"drum matthieu",
|
"drum matthieu",
|
||||||
"Dogmaster",
|
|
||||||
"Frank Nitty",
|
"Frank Nitty",
|
||||||
"Magic Noob",
|
"Magic Noob",
|
||||||
"Christopher Michel",
|
"Christopher Michel",
|
||||||
@@ -210,7 +213,6 @@
|
|||||||
"Kevin John Duck",
|
"Kevin John Duck",
|
||||||
"Dustin Chen",
|
"Dustin Chen",
|
||||||
"Blackfish95",
|
"Blackfish95",
|
||||||
"Mouthlessman",
|
|
||||||
"Paul Kroll",
|
"Paul Kroll",
|
||||||
"Bas Imagineer",
|
"Bas Imagineer",
|
||||||
"John Statham",
|
"John Statham",
|
||||||
@@ -232,8 +234,11 @@
|
|||||||
"MJG",
|
"MJG",
|
||||||
"David LaVallee",
|
"David LaVallee",
|
||||||
"linnfrey",
|
"linnfrey",
|
||||||
|
"ae",
|
||||||
|
"Tr4shP4nda",
|
||||||
"IamAyam",
|
"IamAyam",
|
||||||
"skaterb949",
|
"skaterb949",
|
||||||
|
"Brian M",
|
||||||
"Josef Lanzl",
|
"Josef Lanzl",
|
||||||
"Nerezza",
|
"Nerezza",
|
||||||
"sanborondon",
|
"sanborondon",
|
||||||
@@ -248,11 +253,10 @@
|
|||||||
"Tee Gee",
|
"Tee Gee",
|
||||||
"Geolog",
|
"Geolog",
|
||||||
"tarek helmi",
|
"tarek helmi",
|
||||||
"Neco28",
|
|
||||||
"Eris3D",
|
"Eris3D",
|
||||||
"Max Marklund",
|
"Max Marklund",
|
||||||
"David Ortega",
|
|
||||||
"Pronredn",
|
"Pronredn",
|
||||||
|
"Jamie Ogletree",
|
||||||
"a _",
|
"a _",
|
||||||
"Jeff",
|
"Jeff",
|
||||||
"lh qwe",
|
"lh qwe",
|
||||||
@@ -272,8 +276,6 @@
|
|||||||
"George",
|
"George",
|
||||||
"dw",
|
"dw",
|
||||||
"地獄の禄",
|
"地獄の禄",
|
||||||
"ae",
|
|
||||||
"Tr4shP4nda",
|
|
||||||
"Gamalonia",
|
"Gamalonia",
|
||||||
"WRL_SPR",
|
"WRL_SPR",
|
||||||
"capn",
|
"capn",
|
||||||
@@ -289,13 +291,14 @@
|
|||||||
"Hailshem",
|
"Hailshem",
|
||||||
"kudari",
|
"kudari",
|
||||||
"Naomi Hale Danchi",
|
"Naomi Hale Danchi",
|
||||||
|
"ken",
|
||||||
"epicgamer0020690",
|
"epicgamer0020690",
|
||||||
"Joshua Porrata",
|
"Joshua Porrata",
|
||||||
"SuBu",
|
"SuBu",
|
||||||
|
"RedPIXel",
|
||||||
"Richard",
|
"Richard",
|
||||||
"奚明 刘",
|
"奚明 刘",
|
||||||
"Andrew",
|
"Andrew",
|
||||||
"Brian M",
|
|
||||||
"Robert Wegemund",
|
"Robert Wegemund",
|
||||||
"Littlehuggy",
|
"Littlehuggy",
|
||||||
"준희 김",
|
"준희 김",
|
||||||
@@ -303,6 +306,7 @@
|
|||||||
"Thought2Form",
|
"Thought2Form",
|
||||||
"Kevin Picco",
|
"Kevin Picco",
|
||||||
"Sadlip",
|
"Sadlip",
|
||||||
|
"Joey Callahan",
|
||||||
"Tomohiro Baba",
|
"Tomohiro Baba",
|
||||||
"m",
|
"m",
|
||||||
"Noora",
|
"Noora",
|
||||||
@@ -311,10 +315,10 @@
|
|||||||
"Mattssn",
|
"Mattssn",
|
||||||
"Mikko Hemilä",
|
"Mikko Hemilä",
|
||||||
"Jacob McDaniel",
|
"Jacob McDaniel",
|
||||||
"Jamie Ogletree",
|
|
||||||
"Temikus",
|
"Temikus",
|
||||||
"Artokun",
|
"Artokun",
|
||||||
"Michael Taylor",
|
"Michael Taylor",
|
||||||
|
"Derek Baker",
|
||||||
"Martial",
|
"Martial",
|
||||||
"Michael Anthony Scott",
|
"Michael Anthony Scott",
|
||||||
"Emil Andersson",
|
"Emil Andersson",
|
||||||
@@ -338,10 +342,8 @@
|
|||||||
"momokai",
|
"momokai",
|
||||||
"starbugx",
|
"starbugx",
|
||||||
"dc7431",
|
"dc7431",
|
||||||
"ken",
|
|
||||||
"Crocket",
|
"Crocket",
|
||||||
"keemun",
|
"keemun",
|
||||||
"RedPIXel",
|
|
||||||
"Wind",
|
"Wind",
|
||||||
"Nexus",
|
"Nexus",
|
||||||
"Ramneek“Guy”Ashok",
|
"Ramneek“Guy”Ashok",
|
||||||
@@ -370,12 +372,13 @@
|
|||||||
"Vir",
|
"Vir",
|
||||||
"Skyfire83",
|
"Skyfire83",
|
||||||
"Adam Rinehart",
|
"Adam Rinehart",
|
||||||
|
"Pitpe11",
|
||||||
|
"TheD1rtyD03",
|
||||||
"gzmzmvp",
|
"gzmzmvp",
|
||||||
"Gregory Kozhemiak",
|
"Gregory Kozhemiak",
|
||||||
"Draven T",
|
"Draven T",
|
||||||
"mrjuan",
|
"mrjuan",
|
||||||
"Eric Whitney",
|
"Eric Whitney",
|
||||||
"Joey Callahan",
|
|
||||||
"Aquatic Coffee",
|
"Aquatic Coffee",
|
||||||
"Ivan Tadic",
|
"Ivan Tadic",
|
||||||
"Mike Simone",
|
"Mike Simone",
|
||||||
@@ -389,13 +392,13 @@
|
|||||||
"X",
|
"X",
|
||||||
"Sloan Steddy",
|
"Sloan Steddy",
|
||||||
"hexxish",
|
"hexxish",
|
||||||
"Derek Baker",
|
|
||||||
"Anthony Faxlandez",
|
"Anthony Faxlandez",
|
||||||
"battu",
|
"battu",
|
||||||
"Nathan",
|
"Nathan",
|
||||||
"NICHOLAS BAXLEY",
|
"NICHOLAS BAXLEY",
|
||||||
"Pat Hen",
|
"Pat Hen",
|
||||||
"Xeeosat",
|
"Xeeosat",
|
||||||
|
"Saya",
|
||||||
"Ed Wang",
|
"Ed Wang",
|
||||||
"Jordan Shaw",
|
"Jordan Shaw",
|
||||||
"g unit",
|
"g unit",
|
||||||
@@ -411,8 +414,6 @@
|
|||||||
"Raku",
|
"Raku",
|
||||||
"smart.edge5178",
|
"smart.edge5178",
|
||||||
"Menard",
|
"Menard",
|
||||||
"Pitpe11",
|
|
||||||
"TheD1rtyD03",
|
|
||||||
"moonpetal",
|
"moonpetal",
|
||||||
"SomeDude",
|
"SomeDude",
|
||||||
"g9p0o",
|
"g9p0o",
|
||||||
@@ -444,9 +445,11 @@
|
|||||||
"Shock Shockor",
|
"Shock Shockor",
|
||||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||||
"Михал Михалыч",
|
"Михал Михалыч",
|
||||||
|
"Matt",
|
||||||
"Goldwaters",
|
"Goldwaters",
|
||||||
"Kauffy",
|
"Kauffy",
|
||||||
"Zude",
|
"Zude",
|
||||||
|
"SPJ",
|
||||||
"Kyler",
|
"Kyler",
|
||||||
"Edward Kennedy",
|
"Edward Kennedy",
|
||||||
"Justin Blaylock",
|
"Justin Blaylock",
|
||||||
@@ -467,7 +470,6 @@
|
|||||||
"Distortik",
|
"Distortik",
|
||||||
"Filippo Ferrari",
|
"Filippo Ferrari",
|
||||||
"Youguang",
|
"Youguang",
|
||||||
"Saya",
|
|
||||||
"andrewzpong",
|
"andrewzpong",
|
||||||
"BossGame",
|
"BossGame",
|
||||||
"lrdchs",
|
"lrdchs",
|
||||||
@@ -479,6 +481,8 @@
|
|||||||
"Whitepinetrader",
|
"Whitepinetrader",
|
||||||
"POPPIN",
|
"POPPIN",
|
||||||
"nanana",
|
"nanana",
|
||||||
|
"D",
|
||||||
|
"Dark_Pest",
|
||||||
"Alex",
|
"Alex",
|
||||||
"Karru",
|
"Karru",
|
||||||
"ChaChanoKo",
|
"ChaChanoKo",
|
||||||
@@ -506,18 +510,20 @@
|
|||||||
"Kalli Core",
|
"Kalli Core",
|
||||||
"Christian Schäfer",
|
"Christian Schäfer",
|
||||||
"りん あめ",
|
"りん あめ",
|
||||||
"Matt",
|
"Joaquin Hierrezuelo",
|
||||||
"Locrospiel",
|
"Locrospiel",
|
||||||
"Frogmilk",
|
"Frogmilk",
|
||||||
"SPJ",
|
"Sean voets",
|
||||||
"Kor",
|
"Kor",
|
||||||
"Joseph Hanson",
|
"Joseph Hanson",
|
||||||
|
"John Rednoulf",
|
||||||
"Kyron Mahan",
|
"Kyron Mahan",
|
||||||
"Bryan Rutkowski",
|
"Bryan Rutkowski",
|
||||||
"TBitz33",
|
"TBitz33",
|
||||||
"Anonym dkjglfleeoeldldldlkf",
|
"Anonym dkjglfleeoeldldldlkf",
|
||||||
"Ezokewn",
|
"Ezokewn",
|
||||||
"SendingRavens",
|
"SendingRavens",
|
||||||
|
"Steven",
|
||||||
"JackJohnnyJim",
|
"JackJohnnyJim",
|
||||||
"TenaciousD",
|
"TenaciousD",
|
||||||
"Dmitry Ryzhov",
|
"Dmitry Ryzhov",
|
||||||
@@ -558,6 +564,9 @@
|
|||||||
"Scott",
|
"Scott",
|
||||||
"Muratoraccio",
|
"Muratoraccio",
|
||||||
"D",
|
"D",
|
||||||
|
"Mobius2020",
|
||||||
|
"ExLightSaber",
|
||||||
|
"YaboiRay",
|
||||||
"nickname",
|
"nickname",
|
||||||
"Sildoren",
|
"Sildoren",
|
||||||
"Darv",
|
"Darv",
|
||||||
@@ -583,8 +592,6 @@
|
|||||||
"Inkognito",
|
"Inkognito",
|
||||||
"G",
|
"G",
|
||||||
"Tan+Huynh",
|
"Tan+Huynh",
|
||||||
"D",
|
|
||||||
"Dark_Pest",
|
|
||||||
"Jacky+Ho",
|
"Jacky+Ho",
|
||||||
"generic404",
|
"generic404",
|
||||||
"abattoirblues",
|
"abattoirblues",
|
||||||
@@ -604,12 +611,9 @@
|
|||||||
"Doug Mason",
|
"Doug Mason",
|
||||||
"Jeremy Townsend",
|
"Jeremy Townsend",
|
||||||
"Dave Abraham",
|
"Dave Abraham",
|
||||||
"Joaquin Hierrezuelo",
|
|
||||||
"Sean voets",
|
|
||||||
"Owen Gwosdz",
|
"Owen Gwosdz",
|
||||||
"Jarrid Lee",
|
"Jarrid Lee",
|
||||||
"Poophead27 Blyat",
|
"Poophead27 Blyat",
|
||||||
"John Rednoulf",
|
|
||||||
"Spire",
|
"Spire",
|
||||||
"AZ Party Oasis",
|
"AZ Party Oasis",
|
||||||
"Boba Smith",
|
"Boba Smith",
|
||||||
@@ -619,11 +623,12 @@
|
|||||||
"Jack Dole",
|
"Jack Dole",
|
||||||
"matt",
|
"matt",
|
||||||
"somethingtosay8",
|
"somethingtosay8",
|
||||||
|
"Terminuz",
|
||||||
"ivistorm",
|
"ivistorm",
|
||||||
"max blo",
|
"max blo",
|
||||||
"Sauv",
|
"Sauv",
|
||||||
"Steven",
|
|
||||||
"CptNeo",
|
"CptNeo",
|
||||||
|
"Borte",
|
||||||
"Maso",
|
"Maso",
|
||||||
"Ted Cart",
|
"Ted Cart",
|
||||||
"Sage Himeros",
|
"Sage Himeros",
|
||||||
@@ -642,6 +647,7 @@
|
|||||||
"Teriak47",
|
"Teriak47",
|
||||||
"Just me",
|
"Just me",
|
||||||
"Raf Stahelin",
|
"Raf Stahelin",
|
||||||
|
"Nacho Ferrando",
|
||||||
"Вячеслав Маринин",
|
"Вячеслав Маринин",
|
||||||
"Marcos Tortosa Carmona",
|
"Marcos Tortosa Carmona",
|
||||||
"Dkommander22",
|
"Dkommander22",
|
||||||
@@ -688,6 +694,8 @@
|
|||||||
"SelfishMedic",
|
"SelfishMedic",
|
||||||
"adderleighn",
|
"adderleighn",
|
||||||
"EnragedAntelope",
|
"EnragedAntelope",
|
||||||
|
"shw",
|
||||||
|
"Celestial+Kitten",
|
||||||
"bakeliteboy",
|
"bakeliteboy",
|
||||||
"TequiTequi",
|
"TequiTequi",
|
||||||
"Homero+Banda",
|
"Homero+Banda",
|
||||||
@@ -717,9 +725,6 @@
|
|||||||
"PoorStudent",
|
"PoorStudent",
|
||||||
"lucites",
|
"lucites",
|
||||||
"Alex+Zaw",
|
"Alex+Zaw",
|
||||||
"Mobius2020",
|
|
||||||
"ExLightSaber",
|
|
||||||
"YaboiRay",
|
|
||||||
"Drizzly",
|
"Drizzly",
|
||||||
"Nebuleux",
|
"Nebuleux",
|
||||||
"Join+Chun",
|
"Join+Chun",
|
||||||
@@ -745,6 +750,7 @@
|
|||||||
"Nico",
|
"Nico",
|
||||||
"Maximilian Krischan",
|
"Maximilian Krischan",
|
||||||
"Banana Joe",
|
"Banana Joe",
|
||||||
|
"proto merp",
|
||||||
"_ G3n",
|
"_ G3n",
|
||||||
"Donovan Jenkins",
|
"Donovan Jenkins",
|
||||||
"Hans Meier",
|
"Hans Meier",
|
||||||
@@ -766,6 +772,7 @@
|
|||||||
"jumpd",
|
"jumpd",
|
||||||
"John C",
|
"John C",
|
||||||
"Rim",
|
"Rim",
|
||||||
|
"yfx507",
|
||||||
"Room Light",
|
"Room Light",
|
||||||
"Jairus Knudsen",
|
"Jairus Knudsen",
|
||||||
"Xan Dionysus",
|
"Xan Dionysus",
|
||||||
@@ -783,19 +790,20 @@
|
|||||||
"TheFusion",
|
"TheFusion",
|
||||||
"Jean-françois SEMA",
|
"Jean-françois SEMA",
|
||||||
"3zS4QNQ4",
|
"3zS4QNQ4",
|
||||||
"Terminuz",
|
|
||||||
"Kurt",
|
"Kurt",
|
||||||
"Matt M.",
|
"Matt M.",
|
||||||
"Ivan Imes",
|
"Ivan Imes",
|
||||||
"J M",
|
"J M",
|
||||||
|
"Slacks",
|
||||||
"Bouya shaka",
|
"Bouya shaka",
|
||||||
|
"john Greene",
|
||||||
"Faburizu",
|
"Faburizu",
|
||||||
"Jack Lawfield",
|
"Jack Lawfield",
|
||||||
"jimyjomson",
|
"jimyjomson",
|
||||||
"Borte",
|
|
||||||
"JaeHyun Jang",
|
"JaeHyun Jang",
|
||||||
"Homero Banda",
|
"Homero Banda",
|
||||||
"Chase Kwon",
|
"Chase Kwon",
|
||||||
|
"Bob Ling",
|
||||||
"yyuvuvu",
|
"yyuvuvu",
|
||||||
"Inyoshu",
|
"Inyoshu",
|
||||||
"Chad Barnes",
|
"Chad Barnes",
|
||||||
@@ -821,5 +829,5 @@
|
|||||||
"Somebody",
|
"Somebody",
|
||||||
"CK"
|
"CK"
|
||||||
],
|
],
|
||||||
"totalCount": 818
|
"totalCount": 826
|
||||||
}
|
}
|
||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Civitai API Key",
|
"civitaiApiKey": "Civitai API Key",
|
||||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||||
|
"civitaiApiKeyConfigured": "Konfiguriert",
|
||||||
|
"civitaiApiKeyNotConfigured": "Nicht konfiguriert",
|
||||||
|
"civitaiApiKeySet": "Einrichten",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Civitai-Host",
|
"label": "Civitai-Host",
|
||||||
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "Speicher",
|
"storage": "Speicher",
|
||||||
"insights": "Erkenntnisse"
|
"insights": "Erkenntnisse"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "Modelle gesamt",
|
||||||
|
"totalStorage": "Speicher gesamt",
|
||||||
|
"totalGenerations": "Generationen gesamt",
|
||||||
|
"usageRate": "Nutzungsrate",
|
||||||
|
"loras": "LoRAs",
|
||||||
|
"checkpoints": "Checkpoints",
|
||||||
|
"embeddings": "Embeddings",
|
||||||
|
"uniqueTags": "Einzigartige Tags",
|
||||||
|
"unusedModels": "Ungenutzte Modelle",
|
||||||
|
"avgUsesPerModel": "Ø Nutzungen/Modell"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "Meistgenutzte LoRAs",
|
"mostUsedLoras": "Meistgenutzte LoRAs",
|
||||||
"mostUsedCheckpoints": "Meistgenutzte Checkpoints",
|
"mostUsedCheckpoints": "Meistgenutzte Checkpoints",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "Intelligente Erkenntnisse",
|
"smartInsights": "Intelligente Erkenntnisse",
|
||||||
"recommendations": "Empfehlungen"
|
"recommendations": "Empfehlungen",
|
||||||
|
"noInsights": "Keine Erkenntnisse verfügbar",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "Hohe Anzahl ungenutzter LoRAs",
|
||||||
|
"description": "{percent}% Ihrer LoRAs ({count}/{total}) wurden noch nie verwendet.",
|
||||||
|
"suggestion": "Erwägen Sie, ungenutzte Modelle zu organisieren oder zu archivieren, um Speicherplatz freizugeben."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "Ungenutzte Checkpoints erkannt",
|
||||||
|
"description": "{percent}% Ihrer Checkpoints ({count}/{total}) wurden noch nie verwendet.",
|
||||||
|
"suggestion": "Überprüfen Sie nicht mehr benötigte Checkpoints und erwägen Sie deren Entfernung."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "Hohe Anzahl ungenutzter Embeddings",
|
||||||
|
"description": "{percent}% Ihrer Embeddings ({count}/{total}) wurden noch nie verwendet.",
|
||||||
|
"suggestion": "Organisieren oder archivieren Sie ungenutzte Embeddings, um Ihre Sammlung zu optimieren."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "Große Sammlung erkannt",
|
||||||
|
"description": "Ihre Modellsammlung verwendet {size} Speicher.",
|
||||||
|
"suggestion": "Erwägen Sie externe Speicher- oder Cloud-Lösungen für eine bessere Organisation."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "Aktiver Benutzer",
|
||||||
|
"description": "Sie haben {count} Generationen abgeschlossen!",
|
||||||
|
"suggestion": "Entdecken und erstellen Sie weiterhin großartige Inhalte mit Ihren Modellen."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "Sammlungsübersicht",
|
"collectionOverview": "Sammlungsübersicht",
|
||||||
"baseModelDistribution": "Basis-Modell-Verteilung",
|
"baseModelDistribution": "Basis-Modell-Verteilung",
|
||||||
"usageTrends": "Nutzungstrends (Letzte 30 Tage)",
|
"usageTrends": "Nutzungstrends (Letzte 30 Tage)",
|
||||||
"usageDistribution": "Nutzungsverteilung"
|
"usageDistribution": "Nutzungsverteilung",
|
||||||
|
"date": "Datum",
|
||||||
|
"usageCount": "Nutzungsanzahl",
|
||||||
|
"fileSizeBytes": "Dateigröße (Bytes)",
|
||||||
|
"models": "Modelle",
|
||||||
|
"loraUsage": "LoRA-Nutzung",
|
||||||
|
"checkpointUsage": "Checkpoint-Nutzung",
|
||||||
|
"embeddingUsage": "Embedding-Nutzung"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusionsmodell",
|
||||||
|
"embedding": "Embeddings"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "Lädt...",
|
||||||
|
"noModels": "Keine Modelle gefunden",
|
||||||
|
"errorLoading": "Fehler beim Laden der Daten",
|
||||||
|
"noStorageData": "Keine Speicherdaten verfügbar",
|
||||||
|
"rootFolder": "Root",
|
||||||
|
"chartLibraryMissing": "Diagramm benötigt Chart.js-Bibliothek"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count} Modelle",
|
||||||
|
"chartUsage": "{name}: {size}, {count} Nutzungen",
|
||||||
|
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Civitai API Key",
|
"civitaiApiKey": "Civitai API Key",
|
||||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||||
|
"civitaiApiKeyConfigured": "Configured",
|
||||||
|
"civitaiApiKeyNotConfigured": "Not configured",
|
||||||
|
"civitaiApiKeySet": "Set up",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Civitai host",
|
"label": "Civitai host",
|
||||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
"insights": "Insights"
|
"insights": "Insights"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "Total Models",
|
||||||
|
"totalStorage": "Total Storage",
|
||||||
|
"totalGenerations": "Total Generations",
|
||||||
|
"usageRate": "Usage Rate",
|
||||||
|
"loras": "LoRAs",
|
||||||
|
"checkpoints": "Checkpoints",
|
||||||
|
"embeddings": "Embeddings",
|
||||||
|
"uniqueTags": "Unique Tags",
|
||||||
|
"unusedModels": "Unused Models",
|
||||||
|
"avgUsesPerModel": "Avg. Uses/Model"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "Most Used LoRAs",
|
"mostUsedLoras": "Most Used LoRAs",
|
||||||
"mostUsedCheckpoints": "Most Used Checkpoints",
|
"mostUsedCheckpoints": "Most Used Checkpoints",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "Smart Insights",
|
"smartInsights": "Smart Insights",
|
||||||
"recommendations": "Recommendations"
|
"recommendations": "Recommendations",
|
||||||
|
"noInsights": "No insights available",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "High Number of Unused LoRAs",
|
||||||
|
"description": "{percent}% of your LoRAs ({count}/{total}) have never been used.",
|
||||||
|
"suggestion": "Consider organizing or archiving unused models to free up storage space."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "Unused Checkpoints Detected",
|
||||||
|
"description": "{percent}% of your checkpoints ({count}/{total}) have never been used.",
|
||||||
|
"suggestion": "Review and consider removing checkpoints you no longer need."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "High Number of Unused Embeddings",
|
||||||
|
"description": "{percent}% of your embeddings ({count}/{total}) have never been used.",
|
||||||
|
"suggestion": "Consider organizing or archiving unused embeddings to optimize your collection."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "Large Collection Detected",
|
||||||
|
"description": "Your model collection is using {size} of storage.",
|
||||||
|
"suggestion": "Consider using external storage or cloud solutions for better organization."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "Active User",
|
||||||
|
"description": "You've completed {count} generations so far!",
|
||||||
|
"suggestion": "Keep exploring and creating amazing content with your models."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "Collection Overview",
|
"collectionOverview": "Collection Overview",
|
||||||
"baseModelDistribution": "Base Model Distribution",
|
"baseModelDistribution": "Base Model Distribution",
|
||||||
"usageTrends": "Usage Trends (Last 30 Days)",
|
"usageTrends": "Usage Trends (Last 30 Days)",
|
||||||
"usageDistribution": "Usage Distribution"
|
"usageDistribution": "Usage Distribution",
|
||||||
|
"date": "Date",
|
||||||
|
"usageCount": "Usage Count",
|
||||||
|
"fileSizeBytes": "File Size (bytes)",
|
||||||
|
"models": "Models",
|
||||||
|
"loraUsage": "LoRA Usage",
|
||||||
|
"checkpointUsage": "Checkpoint Usage",
|
||||||
|
"embeddingUsage": "Embedding Usage"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "Diffusion Model",
|
||||||
|
"embedding": "Embeddings"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noModels": "No models found",
|
||||||
|
"errorLoading": "Error loading data",
|
||||||
|
"noStorageData": "No storage data available",
|
||||||
|
"rootFolder": "Root",
|
||||||
|
"chartLibraryMissing": "Chart requires Chart.js library"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count} models",
|
||||||
|
"chartUsage": "{name}: {size}, {count} uses",
|
||||||
|
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Clave API de Civitai",
|
"civitaiApiKey": "Clave API de Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||||
|
"civitaiApiKeyConfigured": "Configurado",
|
||||||
|
"civitaiApiKeyNotConfigured": "No configurado",
|
||||||
|
"civitaiApiKeySet": "Configurar",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Host de Civitai",
|
"label": "Host de Civitai",
|
||||||
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "Almacenamiento",
|
"storage": "Almacenamiento",
|
||||||
"insights": "Perspectivas"
|
"insights": "Perspectivas"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "Total de modelos",
|
||||||
|
"totalStorage": "Almacenamiento total",
|
||||||
|
"totalGenerations": "Generaciones totales",
|
||||||
|
"usageRate": "Tasa de uso",
|
||||||
|
"loras": "LoRAs",
|
||||||
|
"checkpoints": "Puntos de control",
|
||||||
|
"embeddings": "Embeddings",
|
||||||
|
"uniqueTags": "Etiquetas únicas",
|
||||||
|
"unusedModels": "Modelos no usados",
|
||||||
|
"avgUsesPerModel": "Prom. usos/modelo"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "LoRAs más utilizados",
|
"mostUsedLoras": "LoRAs más utilizados",
|
||||||
"mostUsedCheckpoints": "Checkpoints más utilizados",
|
"mostUsedCheckpoints": "Checkpoints más utilizados",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "Perspectivas inteligentes",
|
"smartInsights": "Perspectivas inteligentes",
|
||||||
"recommendations": "Recomendaciones"
|
"recommendations": "Recomendaciones",
|
||||||
|
"noInsights": "No hay información disponible",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "Alta cantidad de LoRAs no utilizadas",
|
||||||
|
"description": "El {percent}% de tus LoRAs ({count}/{total}) nunca se han utilizado.",
|
||||||
|
"suggestion": "Considera organizar o archivar modelos no utilizados para liberar espacio."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "Puntos de control no utilizados detectados",
|
||||||
|
"description": "El {percent}% de tus puntos de control ({count}/{total}) nunca se han utilizado.",
|
||||||
|
"suggestion": "Revisa y considera eliminar los puntos de control que ya no necesites."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "Alta cantidad de Embeddings no utilizados",
|
||||||
|
"description": "El {percent}% de tus embeddings ({count}/{total}) nunca se han utilizado.",
|
||||||
|
"suggestion": "Considera organizar o archivar embeddings no utilizados para optimizar tu colección."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "Colección grande detectada",
|
||||||
|
"description": "Tu colección de modelos está usando {size} de almacenamiento.",
|
||||||
|
"suggestion": "Considera usar almacenamiento externo o soluciones en la nube para una mejor organización."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "Usuario activo",
|
||||||
|
"description": "¡Has completado {count} generaciones hasta ahora!",
|
||||||
|
"suggestion": "Sigue explorando y creando contenido increíble con tus modelos."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "Resumen de colección",
|
"collectionOverview": "Resumen de colección",
|
||||||
"baseModelDistribution": "Distribución de modelo base",
|
"baseModelDistribution": "Distribución de modelo base",
|
||||||
"usageTrends": "Tendencias de uso (Últimos 30 días)",
|
"usageTrends": "Tendencias de uso (Últimos 30 días)",
|
||||||
"usageDistribution": "Distribución de uso"
|
"usageDistribution": "Distribución de uso",
|
||||||
|
"date": "Fecha",
|
||||||
|
"usageCount": "Conteo de uso",
|
||||||
|
"fileSizeBytes": "Tamaño del archivo (bytes)",
|
||||||
|
"models": "Modelos",
|
||||||
|
"loraUsage": "Uso de LoRA",
|
||||||
|
"checkpointUsage": "Uso de Checkpoint",
|
||||||
|
"embeddingUsage": "Uso de Embedding"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Punto de control",
|
||||||
|
"diffusion_model": "Modelo de difusión",
|
||||||
|
"embedding": "Embeddings"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"noModels": "No se encontraron modelos",
|
||||||
|
"errorLoading": "Error al cargar datos",
|
||||||
|
"noStorageData": "No hay datos de almacenamiento disponibles",
|
||||||
|
"rootFolder": "Raíz",
|
||||||
|
"chartLibraryMissing": "El gráfico requiere la librería Chart.js"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count} modelos",
|
||||||
|
"chartUsage": "{name}: {size}, {count} usos",
|
||||||
|
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Clé API Civitai",
|
"civitaiApiKey": "Clé API Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||||
|
"civitaiApiKeyConfigured": "Configuré",
|
||||||
|
"civitaiApiKeyNotConfigured": "Non configuré",
|
||||||
|
"civitaiApiKeySet": "Configurer",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Hôte Civitai",
|
"label": "Hôte Civitai",
|
||||||
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "Stockage",
|
"storage": "Stockage",
|
||||||
"insights": "Aperçus"
|
"insights": "Aperçus"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "Total des modèles",
|
||||||
|
"totalStorage": "Stockage total",
|
||||||
|
"totalGenerations": "Générations totales",
|
||||||
|
"usageRate": "Taux d'utilisation",
|
||||||
|
"loras": "LoRAs",
|
||||||
|
"checkpoints": "Points de contrôle",
|
||||||
|
"embeddings": "Embeddings",
|
||||||
|
"uniqueTags": "Tags uniques",
|
||||||
|
"unusedModels": "Modèles inutilisés",
|
||||||
|
"avgUsesPerModel": "Moy. utilisations/modèle"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "LoRAs les plus utilisés",
|
"mostUsedLoras": "LoRAs les plus utilisés",
|
||||||
"mostUsedCheckpoints": "Checkpoints les plus utilisés",
|
"mostUsedCheckpoints": "Checkpoints les plus utilisés",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "Aperçus intelligents",
|
"smartInsights": "Aperçus intelligents",
|
||||||
"recommendations": "Recommandations"
|
"recommendations": "Recommandations",
|
||||||
|
"noInsights": "Aucun aperçu disponible",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "Nombre élevé de LoRAs inutilisées",
|
||||||
|
"description": "{percent}% de vos LoRAs ({count}/{total}) n'ont jamais été utilisées.",
|
||||||
|
"suggestion": "Envisagez d'organiser ou d'archiver les modèles inutilisés pour libérer de l'espace."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "Points de contrôle inutilisés détectés",
|
||||||
|
"description": "{percent}% de vos points de contrôle ({count}/{total}) n'ont jamais été utilisés.",
|
||||||
|
"suggestion": "Examinez et envisagez de supprimer les points de contrôle dont vous n'avez plus besoin."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "Nombre élevé d'Embeddings inutilisées",
|
||||||
|
"description": "{percent}% de vos embeddings ({count}/{total}) n'ont jamais été utilisées.",
|
||||||
|
"suggestion": "Envisagez d'organiser ou d'archiver les embeddings inutilisées pour optimiser votre collection."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "Grande collection détectée",
|
||||||
|
"description": "Votre collection de modèles utilise {size} de stockage.",
|
||||||
|
"suggestion": "Envisagez d'utiliser un stockage externe ou des solutions cloud pour une meilleure organisation."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "Utilisateur actif",
|
||||||
|
"description": "Vous avez effectué {count} générations jusqu'à présent !",
|
||||||
|
"suggestion": "Continuez à explorer et à créer du contenu formidable avec vos modèles."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "Aperçu de la collection",
|
"collectionOverview": "Aperçu de la collection",
|
||||||
"baseModelDistribution": "Distribution des modèles de base",
|
"baseModelDistribution": "Distribution des modèles de base",
|
||||||
"usageTrends": "Tendances d'utilisation (30 derniers jours)",
|
"usageTrends": "Tendances d'utilisation (30 derniers jours)",
|
||||||
"usageDistribution": "Distribution de l'utilisation"
|
"usageDistribution": "Distribution de l'utilisation",
|
||||||
|
"date": "Date",
|
||||||
|
"usageCount": "Nombre d'utilisations",
|
||||||
|
"fileSizeBytes": "Taille du fichier (octets)",
|
||||||
|
"models": "Modèles",
|
||||||
|
"loraUsage": "Utilisation LoRA",
|
||||||
|
"checkpointUsage": "Utilisation Checkpoint",
|
||||||
|
"embeddingUsage": "Utilisation Embedding"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Point de contrôle",
|
||||||
|
"diffusion_model": "Modèle de diffusion",
|
||||||
|
"embedding": "Embeddings"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"noModels": "Aucun modèle trouvé",
|
||||||
|
"errorLoading": "Erreur de chargement des données",
|
||||||
|
"noStorageData": "Aucune donnée de stockage disponible",
|
||||||
|
"rootFolder": "Racine",
|
||||||
|
"chartLibraryMissing": "Le graphique nécessite la bibliothèque Chart.js"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count} modèles",
|
||||||
|
"chartUsage": "{name}: {size}, {count} utilisations",
|
||||||
|
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "מפתח API של Civitai",
|
"civitaiApiKey": "מפתח API של Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||||
|
"civitaiApiKeyConfigured": "מוגדר",
|
||||||
|
"civitaiApiKeyNotConfigured": "לא מוגדר",
|
||||||
|
"civitaiApiKeySet": "הגדר",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "מארח Civitai",
|
"label": "מארח Civitai",
|
||||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "אחסון",
|
"storage": "אחסון",
|
||||||
"insights": "תובנות"
|
"insights": "תובנות"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "סה\"כ דגמים",
|
||||||
|
"totalStorage": "סה\"כ אחסון",
|
||||||
|
"totalGenerations": "סה\"כ יצירות",
|
||||||
|
"usageRate": "שיעור שימוש",
|
||||||
|
"loras": "LoRA",
|
||||||
|
"checkpoints": "נקודות ביקורת",
|
||||||
|
"embeddings": "הטמעות",
|
||||||
|
"uniqueTags": "תגיות ייחודיות",
|
||||||
|
"unusedModels": "דגמים שאינם בשימוש",
|
||||||
|
"avgUsesPerModel": "ממוצע שימושים/דגם"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "LoRAs הנפוצים ביותר",
|
"mostUsedLoras": "LoRAs הנפוצים ביותר",
|
||||||
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
|
"mostUsedCheckpoints": "Checkpoints הנפוצים ביותר",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "תובנות חכמות",
|
"smartInsights": "תובנות חכמות",
|
||||||
"recommendations": "המלצות"
|
"recommendations": "המלצות",
|
||||||
|
"noInsights": "אין תובנות זמינות",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "כמות גבוהה של LoRAs שאינן בשימוש",
|
||||||
|
"description": "{percent}% מה-LoRAs שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
|
||||||
|
"suggestion": "שקול לארגן או לאחסן בארכיון מודלים שאינם בשימוש כדי לפנות שטח אחסון."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "התגלו נקודות ביקורת שאינן בשימוש",
|
||||||
|
"description": "{percent}% מנקודות הביקורת שלך ({count}/{total}) מעולם לא נעשה בהן שימוש.",
|
||||||
|
"suggestion": "בדוק ושקול להסיר נקודות ביקורת שאינך צריך עוד."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "כמות גבוהה של Embeddings שאינם בשימוש",
|
||||||
|
"description": "{percent}% מה-Embeddings שלך ({count}/{total}) מעולם לא נעשה בהם שימוש.",
|
||||||
|
"suggestion": "שקול לארגן או לאחסן בארכיון Embeddings שאינם בשימוש כדי לייעל את האוסף."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "התגלה אוסף גדול",
|
||||||
|
"description": "אוסף המודלים שלך משתמש ב-{size} של אחסון.",
|
||||||
|
"suggestion": "שקול להשתמש באחסון חיצוני או בפתרונות ענן לארגון טוב יותר."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "משתמש פעיל",
|
||||||
|
"description": "השלמת {count} יצירות עד כה!",
|
||||||
|
"suggestion": "המשך לחקור וליצור תוכן מדהים עם המודלים שלך."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "סקירת אוסף",
|
"collectionOverview": "סקירת אוסף",
|
||||||
"baseModelDistribution": "התפלגות מודלי בסיס",
|
"baseModelDistribution": "התפלגות מודלי בסיס",
|
||||||
"usageTrends": "מגמות שימוש (30 יום אחרונים)",
|
"usageTrends": "מגמות שימוש (30 יום אחרונים)",
|
||||||
"usageDistribution": "התפלגות שימוש"
|
"usageDistribution": "התפלגות שימוש",
|
||||||
|
"date": "תאריך",
|
||||||
|
"usageCount": "מספר שימושים",
|
||||||
|
"fileSizeBytes": "גודל קובץ (בתים)",
|
||||||
|
"models": "דגמים",
|
||||||
|
"loraUsage": "שימוש ב-LoRA",
|
||||||
|
"checkpointUsage": "שימוש ב-Checkpoint",
|
||||||
|
"embeddingUsage": "שימוש ב-Embedding"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "נקודת ביקורת",
|
||||||
|
"diffusion_model": "מודל דיפוזיה",
|
||||||
|
"embedding": "הטמעות"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "טוען...",
|
||||||
|
"noModels": "לא נמצאו דגמים",
|
||||||
|
"errorLoading": "שגיאה בטעינת נתונים",
|
||||||
|
"noStorageData": "אין נתוני אחסון זמינים",
|
||||||
|
"rootFolder": "שורש",
|
||||||
|
"chartLibraryMissing": "הגרף דורש את ספריית Chart.js"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count} דגמים",
|
||||||
|
"chartUsage": "{name}: {size}, {count} שימושים",
|
||||||
|
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Civitai APIキー",
|
"civitaiApiKey": "Civitai APIキー",
|
||||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||||
|
"civitaiApiKeyConfigured": "設定済み",
|
||||||
|
"civitaiApiKeyNotConfigured": "未設定",
|
||||||
|
"civitaiApiKeySet": "設定",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Civitai ホスト",
|
"label": "Civitai ホスト",
|
||||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "ストレージ",
|
"storage": "ストレージ",
|
||||||
"insights": "インサイト"
|
"insights": "インサイト"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "モデル総数",
|
||||||
|
"totalStorage": "ストレージ合計",
|
||||||
|
"totalGenerations": "生成回数合計",
|
||||||
|
"usageRate": "使用率",
|
||||||
|
"loras": "LoRA",
|
||||||
|
"checkpoints": "Checkpoint",
|
||||||
|
"embeddings": "Embedding",
|
||||||
|
"uniqueTags": "ユニークタグ",
|
||||||
|
"unusedModels": "未使用モデル",
|
||||||
|
"avgUsesPerModel": "平均使用回数/モデル"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "最も使用されているLoRA",
|
"mostUsedLoras": "最も使用されているLoRA",
|
||||||
"mostUsedCheckpoints": "最も使用されているCheckpoint",
|
"mostUsedCheckpoints": "最も使用されているCheckpoint",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "スマートインサイト",
|
"smartInsights": "スマートインサイト",
|
||||||
"recommendations": "推奨事項"
|
"recommendations": "推奨事項",
|
||||||
|
"noInsights": "インサイトはありません",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "未使用のLoRAが多数あります",
|
||||||
|
"description": "LoRAの{percent}%({count}/{total})が一度も使用されていません。",
|
||||||
|
"suggestion": "未使用のモデルを整理またはアーカイブしてストレージを解放してください。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "未使用のCheckpointを検出",
|
||||||
|
"description": "Checkpointの{percent}%({count}/{total})が一度も使用されていません。",
|
||||||
|
"suggestion": "不要なCheckpointを確認して削除を検討してください。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "未使用のEmbeddingが多数あります",
|
||||||
|
"description": "Embeddingの{percent}%({count}/{total})が一度も使用されていません。",
|
||||||
|
"suggestion": "未使用のEmbeddingを整理またはアーカイブしてコレクションを最適化してください。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "大規模コレクションを検出",
|
||||||
|
"description": "モデルコレクションが{size}のストレージを使用しています。",
|
||||||
|
"suggestion": "外部ストレージやクラウドソリューションの使用を検討してください。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "アクティブユーザー",
|
||||||
|
"description": "これまでに{count}回の生成を完了しました!",
|
||||||
|
"suggestion": "モデルを使って素晴らしいコンテンツを作り続けてください。"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "コレクション概要",
|
"collectionOverview": "コレクション概要",
|
||||||
"baseModelDistribution": "ベースモデル分布",
|
"baseModelDistribution": "ベースモデル分布",
|
||||||
"usageTrends": "使用傾向(過去30日)",
|
"usageTrends": "使用傾向(過去30日)",
|
||||||
"usageDistribution": "使用分布"
|
"usageDistribution": "使用分布",
|
||||||
|
"date": "日付",
|
||||||
|
"usageCount": "使用回数",
|
||||||
|
"fileSizeBytes": "ファイルサイズ(バイト)",
|
||||||
|
"models": "モデル",
|
||||||
|
"loraUsage": "LoRA 使用量",
|
||||||
|
"checkpointUsage": "Checkpoint 使用量",
|
||||||
|
"embeddingUsage": "Embedding 使用量"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "拡散モデル",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"noModels": "モデルが見つかりません",
|
||||||
|
"errorLoading": "データ読み込みエラー",
|
||||||
|
"noStorageData": "ストレージデータがありません",
|
||||||
|
"rootFolder": "ルート",
|
||||||
|
"chartLibraryMissing": "Chart.js ライブラリが必要です"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count} モデル",
|
||||||
|
"chartUsage": "{name}: {size}, {count} 回使用",
|
||||||
|
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Civitai API 키",
|
"civitaiApiKey": "Civitai API 키",
|
||||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||||
|
"civitaiApiKeyConfigured": "설정됨",
|
||||||
|
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||||
|
"civitaiApiKeySet": "설정",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Civitai 호스트",
|
"label": "Civitai 호스트",
|
||||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "저장소",
|
"storage": "저장소",
|
||||||
"insights": "인사이트"
|
"insights": "인사이트"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "모델 총계",
|
||||||
|
"totalStorage": "총 저장 공간",
|
||||||
|
"totalGenerations": "총 생성 횟수",
|
||||||
|
"usageRate": "사용률",
|
||||||
|
"loras": "LoRA",
|
||||||
|
"checkpoints": "Checkpoint",
|
||||||
|
"embeddings": "Embedding",
|
||||||
|
"uniqueTags": "고유 태그",
|
||||||
|
"unusedModels": "미사용 모델",
|
||||||
|
"avgUsesPerModel": "모델당 평균 사용"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "가장 많이 사용된 LoRA",
|
"mostUsedLoras": "가장 많이 사용된 LoRA",
|
||||||
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
|
"mostUsedCheckpoints": "가장 많이 사용된 Checkpoint",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "스마트 인사이트",
|
"smartInsights": "스마트 인사이트",
|
||||||
"recommendations": "추천"
|
"recommendations": "추천",
|
||||||
|
"noInsights": "인사이트 없음",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "사용하지 않은 LoRA가 많음",
|
||||||
|
"description": "LoRA의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||||
|
"suggestion": "사용하지 않는 모델을 정리하거나 보관하여 저장 공간을 확보하세요."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "사용하지 않은 Checkpoint 감지",
|
||||||
|
"description": "Checkpoint의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||||
|
"suggestion": "더 이상 필요하지 않은 Checkpoint를 검토하고 제거하세요."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "사용하지 않은 Embedding이 많음",
|
||||||
|
"description": "Embedding의 {percent}%({count}/{total})가 한 번도 사용되지 않았습니다.",
|
||||||
|
"suggestion": "사용하지 않는 Embedding을 정리하여 컬렉션을 최적화하세요."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "대규모 컬렉션 감지",
|
||||||
|
"description": "모델 컬렉션이 {size}의 저장 공간을 사용 중입니다.",
|
||||||
|
"suggestion": "더 나은 관리를 위해 외부 저장소나 클라우드 솔루션을 고려하세요."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "활성 사용자",
|
||||||
|
"description": "지금까지 {count}번의 생성을 완료했습니다!",
|
||||||
|
"suggestion": "모델로 계속해서 멋진 콘텐츠를 탐색하고 만들어보세요."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "컬렉션 개요",
|
"collectionOverview": "컬렉션 개요",
|
||||||
"baseModelDistribution": "베이스 모델 분포",
|
"baseModelDistribution": "베이스 모델 분포",
|
||||||
"usageTrends": "사용량 트렌드 (최근 30일)",
|
"usageTrends": "사용량 트렌드 (최근 30일)",
|
||||||
"usageDistribution": "사용량 분포"
|
"usageDistribution": "사용량 분포",
|
||||||
|
"date": "날짜",
|
||||||
|
"usageCount": "사용 횟수",
|
||||||
|
"fileSizeBytes": "파일 크기(바이트)",
|
||||||
|
"models": "모델",
|
||||||
|
"loraUsage": "LoRA 사용량",
|
||||||
|
"checkpointUsage": "Checkpoint 사용량",
|
||||||
|
"embeddingUsage": "Embedding 사용량"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "확산 모델",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"noModels": "모델을 찾을 수 없음",
|
||||||
|
"errorLoading": "데이터 로딩 오류",
|
||||||
|
"noStorageData": "저장 데이터 없음",
|
||||||
|
"rootFolder": "루트",
|
||||||
|
"chartLibraryMissing": "Chart.js 라이브러리가 필요합니다"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count}개 모델",
|
||||||
|
"chartUsage": "{name}: {size}, {count}회 사용",
|
||||||
|
"chartPercentage": "{label}: {value}({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Ключ API Civitai",
|
"civitaiApiKey": "Ключ API Civitai",
|
||||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||||
|
"civitaiApiKeyConfigured": "Настроен",
|
||||||
|
"civitaiApiKeyNotConfigured": "Не настроен",
|
||||||
|
"civitaiApiKeySet": "Настроить",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Хост Civitai",
|
"label": "Хост Civitai",
|
||||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "Хранение",
|
"storage": "Хранение",
|
||||||
"insights": "Аналитика"
|
"insights": "Аналитика"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "Всего моделей",
|
||||||
|
"totalStorage": "Всего хранилища",
|
||||||
|
"totalGenerations": "Всего генераций",
|
||||||
|
"usageRate": "Коэффициент использования",
|
||||||
|
"loras": "LoRA",
|
||||||
|
"checkpoints": "Контрольные точки",
|
||||||
|
"embeddings": "Эмбеддинги",
|
||||||
|
"uniqueTags": "Уникальные теги",
|
||||||
|
"unusedModels": "Неиспользуемые модели",
|
||||||
|
"avgUsesPerModel": "Сред. использований/модель"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "Наиболее используемые LoRAs",
|
"mostUsedLoras": "Наиболее используемые LoRAs",
|
||||||
"mostUsedCheckpoints": "Наиболее используемые Checkpoints",
|
"mostUsedCheckpoints": "Наиболее используемые Checkpoints",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "Умная аналитика",
|
"smartInsights": "Умная аналитика",
|
||||||
"recommendations": "Рекомендации"
|
"recommendations": "Рекомендации",
|
||||||
|
"noInsights": "Нет доступных данных",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "Большое количество неиспользуемых LoRA",
|
||||||
|
"description": "{percent}% ваших LoRA ({count}/{total}) никогда не использовались.",
|
||||||
|
"suggestion": "Рассмотрите возможность организации или архивирования неиспользуемых моделей для освобождения места."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "Обнаружены неиспользуемые контрольные точки",
|
||||||
|
"description": "{percent}% ваших контрольных точек ({count}/{total}) никогда не использовались.",
|
||||||
|
"suggestion": "Проверьте и удалите ненужные контрольные точки."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "Большое количество неиспользуемых эмбеддингов",
|
||||||
|
"description": "{percent}% ваших эмбеддингов ({count}/{total}) никогда не использовались.",
|
||||||
|
"suggestion": "Организуйте или архивируйте неиспользуемые эмбеддинги для оптимизации коллекции."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "Обнаружена большая коллекция",
|
||||||
|
"description": "Ваша коллекция моделей использует {size} хранилища.",
|
||||||
|
"suggestion": "Рассмотрите внешнее хранилище или облачные решения для лучшей организации."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "Активный пользователь",
|
||||||
|
"description": "Вы завершили {count} генераций!",
|
||||||
|
"suggestion": "Продолжайте исследовать и создавать удивительный контент с вашими моделями."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "Обзор коллекции",
|
"collectionOverview": "Обзор коллекции",
|
||||||
"baseModelDistribution": "Распределение базовых моделей",
|
"baseModelDistribution": "Распределение базовых моделей",
|
||||||
"usageTrends": "Тенденции использования (за последние 30 дней)",
|
"usageTrends": "Тенденции использования (за последние 30 дней)",
|
||||||
"usageDistribution": "Распределение использования"
|
"usageDistribution": "Распределение использования",
|
||||||
|
"date": "Дата",
|
||||||
|
"usageCount": "Количество использований",
|
||||||
|
"fileSizeBytes": "Размер файла (байты)",
|
||||||
|
"models": "Модели",
|
||||||
|
"loraUsage": "Использование LoRA",
|
||||||
|
"checkpointUsage": "Использование Checkpoint",
|
||||||
|
"embeddingUsage": "Использование Embedding"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Контрольная точка",
|
||||||
|
"diffusion_model": "Диффузионная модель",
|
||||||
|
"embedding": "Эмбеддинги"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"noModels": "Модели не найдены",
|
||||||
|
"errorLoading": "Ошибка загрузки данных",
|
||||||
|
"noStorageData": "Нет данных о хранилище",
|
||||||
|
"rootFolder": "Корень",
|
||||||
|
"chartLibraryMissing": "Для графика требуется библиотека Chart.js"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}: {count} моделей",
|
||||||
|
"chartUsage": "{name}: {size}, {count} использований",
|
||||||
|
"chartPercentage": "{label}: {value} ({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Civitai API 密钥",
|
"civitaiApiKey": "Civitai API 密钥",
|
||||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||||
|
"civitaiApiKeyConfigured": "已配置",
|
||||||
|
"civitaiApiKeyNotConfigured": "未配置",
|
||||||
|
"civitaiApiKeySet": "设置",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Civitai 站点",
|
"label": "Civitai 站点",
|
||||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "存储",
|
"storage": "存储",
|
||||||
"insights": "洞察"
|
"insights": "洞察"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "模型总数",
|
||||||
|
"totalStorage": "总存储空间",
|
||||||
|
"totalGenerations": "总生成次数",
|
||||||
|
"usageRate": "使用率",
|
||||||
|
"loras": "LoRA",
|
||||||
|
"checkpoints": "Checkpoint",
|
||||||
|
"embeddings": "Embedding",
|
||||||
|
"uniqueTags": "唯一标签",
|
||||||
|
"unusedModels": "未使用模型",
|
||||||
|
"avgUsesPerModel": "平均使用次数/模型"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "最常用 LoRA",
|
"mostUsedLoras": "最常用 LoRA",
|
||||||
"mostUsedCheckpoints": "最常用 Checkpoint",
|
"mostUsedCheckpoints": "最常用 Checkpoint",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "智能洞察",
|
"smartInsights": "智能洞察",
|
||||||
"recommendations": "推荐"
|
"recommendations": "推荐",
|
||||||
|
"noInsights": "暂无可用洞察",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "大量未使用的 LoRA",
|
||||||
|
"description": "你的 LoRA 中有 {percent}%({count}/{total})从未被使用过。",
|
||||||
|
"suggestion": "考虑整理或归档未使用的模型以释放存储空间。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "检测到未使用的 Checkpoint",
|
||||||
|
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})从未被使用过。",
|
||||||
|
"suggestion": "审查并考虑删除不再需要的 Checkpoint。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "大量未使用的 Embedding",
|
||||||
|
"description": "你的 Embedding 中有 {percent}%({count}/{total})从未被使用过。",
|
||||||
|
"suggestion": "考虑整理或归档未使用的 Embedding 以优化你的收藏。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "检测到大型收藏",
|
||||||
|
"description": "你的模型收藏正在使用 {size} 的存储空间。",
|
||||||
|
"suggestion": "考虑使用外部存储或云解决方案以获得更好的组织。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "活跃用户",
|
||||||
|
"description": "你已经完成了 {count} 次生成!",
|
||||||
|
"suggestion": "继续探索并用你的模型创作精彩内容。"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "收藏概览",
|
"collectionOverview": "收藏概览",
|
||||||
"baseModelDistribution": "基础模型分布",
|
"baseModelDistribution": "基础模型分布",
|
||||||
"usageTrends": "使用趋势(最近30天)",
|
"usageTrends": "使用趋势(最近30天)",
|
||||||
"usageDistribution": "使用分布"
|
"usageDistribution": "使用分布",
|
||||||
|
"date": "日期",
|
||||||
|
"usageCount": "使用次数",
|
||||||
|
"fileSizeBytes": "文件大小(字节)",
|
||||||
|
"models": "模型",
|
||||||
|
"loraUsage": "LoRA 使用量",
|
||||||
|
"checkpointUsage": "Checkpoint 使用量",
|
||||||
|
"embeddingUsage": "Embedding 使用量"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "扩散模型",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noModels": "未找到模型",
|
||||||
|
"errorLoading": "数据加载失败",
|
||||||
|
"noStorageData": "暂无存储数据",
|
||||||
|
"rootFolder": "根目录",
|
||||||
|
"chartLibraryMissing": "需要 Chart.js 库来显示图表"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}:{count} 个模型",
|
||||||
|
"chartUsage": "{name}:{size},{count} 次使用",
|
||||||
|
"chartPercentage": "{label}:{value}({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
"civitaiApiKey": "Civitai API 金鑰",
|
"civitaiApiKey": "Civitai API 金鑰",
|
||||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||||
|
"civitaiApiKeyConfigured": "已設定",
|
||||||
|
"civitaiApiKeyNotConfigured": "未設定",
|
||||||
|
"civitaiApiKeySet": "設定",
|
||||||
"civitaiHost": {
|
"civitaiHost": {
|
||||||
"label": "Civitai 站點",
|
"label": "Civitai 站點",
|
||||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||||
@@ -1013,6 +1016,18 @@
|
|||||||
"storage": "儲存空間",
|
"storage": "儲存空間",
|
||||||
"insights": "洞察"
|
"insights": "洞察"
|
||||||
},
|
},
|
||||||
|
"metrics": {
|
||||||
|
"totalModels": "模型總數",
|
||||||
|
"totalStorage": "總儲存空間",
|
||||||
|
"totalGenerations": "總生成次數",
|
||||||
|
"usageRate": "使用率",
|
||||||
|
"loras": "LoRA",
|
||||||
|
"checkpoints": "Checkpoint",
|
||||||
|
"embeddings": "Embedding",
|
||||||
|
"uniqueTags": "唯一標籤",
|
||||||
|
"unusedModels": "未使用模型",
|
||||||
|
"avgUsesPerModel": "平均使用次數/模型"
|
||||||
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"mostUsedLoras": "最常用的 LoRA",
|
"mostUsedLoras": "最常用的 LoRA",
|
||||||
"mostUsedCheckpoints": "最常用的 Checkpoint",
|
"mostUsedCheckpoints": "最常用的 Checkpoint",
|
||||||
@@ -1030,13 +1045,77 @@
|
|||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
"smartInsights": "智慧洞察",
|
"smartInsights": "智慧洞察",
|
||||||
"recommendations": "推薦"
|
"recommendations": "推薦",
|
||||||
|
"noInsights": "暫無可用洞察",
|
||||||
|
"unusedLoras": {
|
||||||
|
"high": {
|
||||||
|
"title": "大量未使用的 LoRA",
|
||||||
|
"description": "你的 LoRA 中有 {percent}%({count}/{total})從未被使用過。",
|
||||||
|
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedCheckpoints": {
|
||||||
|
"detected": {
|
||||||
|
"title": "檢測到未使用的 Checkpoint",
|
||||||
|
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})從未被使用過。",
|
||||||
|
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unusedEmbeddings": {
|
||||||
|
"high": {
|
||||||
|
"title": "大量未使用的 Embedding",
|
||||||
|
"description": "你的 Embedding 中有 {percent}%({count}/{total})從未被使用過。",
|
||||||
|
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"large": {
|
||||||
|
"title": "檢測到大型收藏",
|
||||||
|
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
|
||||||
|
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"active": {
|
||||||
|
"title": "活躍用戶",
|
||||||
|
"description": "你已經完成了 {count} 次生成!",
|
||||||
|
"suggestion": "繼續探索並用你的模型創作精彩內容。"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
"collectionOverview": "收藏總覽",
|
"collectionOverview": "收藏總覽",
|
||||||
"baseModelDistribution": "基礎模型分布",
|
"baseModelDistribution": "基礎模型分布",
|
||||||
"usageTrends": "使用趨勢(最近 30 天)",
|
"usageTrends": "使用趨勢(最近 30 天)",
|
||||||
"usageDistribution": "使用分布"
|
"usageDistribution": "使用分布",
|
||||||
|
"date": "日期",
|
||||||
|
"usageCount": "使用次數",
|
||||||
|
"fileSizeBytes": "檔案大小(位元組)",
|
||||||
|
"models": "模型",
|
||||||
|
"loraUsage": "LoRA 使用量",
|
||||||
|
"checkpointUsage": "Checkpoint 使用量",
|
||||||
|
"embeddingUsage": "Embedding 使用量"
|
||||||
|
},
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"locon": "LyCORIS",
|
||||||
|
"dora": "DoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"diffusion_model": "擴散模型",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"loading": "載入中...",
|
||||||
|
"noModels": "找不到模型",
|
||||||
|
"errorLoading": "資料載入失敗",
|
||||||
|
"noStorageData": "暫無儲存資料",
|
||||||
|
"rootFolder": "根目錄",
|
||||||
|
"chartLibraryMissing": "需要 Chart.js 函式庫來顯示圖表"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"tagCount": "{tag}:{count} 個模型",
|
||||||
|
"chartUsage": "{name}:{size},{count} 次使用",
|
||||||
|
"chartPercentage": "{label}:{value}({pct}%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
|
|||||||
@@ -901,6 +901,55 @@ class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
|||||||
"node_id": node_id
|
"node_id": node_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LoraTextLoaderManagerExtractor(NodeMetadataExtractor):
|
||||||
|
"""Extract LoRA metadata from LoraTextLoaderLM (LoRA Text Loader).
|
||||||
|
|
||||||
|
The node accepts a `lora_syntax` STRING containing <lora:name:strength> tags
|
||||||
|
(same format as the ComfyUI prompt), plus an optional `lora_stack`.
|
||||||
|
This extractor parses the syntax string using the same regex as the node.
|
||||||
|
"""
|
||||||
|
@staticmethod
|
||||||
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
|
if not inputs:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_loras = []
|
||||||
|
|
||||||
|
# Process lora_stack if available (optional input)
|
||||||
|
if "lora_stack" in inputs:
|
||||||
|
lora_stack = inputs.get("lora_stack", [])
|
||||||
|
for item in lora_stack:
|
||||||
|
# lora_stack entries are (path, model_strength, clip_strength) tuples
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
lora_path = item[0]
|
||||||
|
model_strength = item[1]
|
||||||
|
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": round(float(model_strength), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Process lora_syntax string input
|
||||||
|
if "lora_syntax" in inputs:
|
||||||
|
lora_syntax = inputs.get("lora_syntax", "")
|
||||||
|
if lora_syntax and isinstance(lora_syntax, str):
|
||||||
|
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||||
|
matches = re.findall(pattern, lora_syntax, re.IGNORECASE)
|
||||||
|
for match in matches:
|
||||||
|
lora_name = match[0]
|
||||||
|
model_strength = float(match[1])
|
||||||
|
active_loras.append({
|
||||||
|
"name": lora_name,
|
||||||
|
"strength": round(model_strength, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
if active_loras:
|
||||||
|
metadata[LORAS][node_id] = {
|
||||||
|
"lora_list": active_loras,
|
||||||
|
"node_id": node_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FluxGuidanceExtractor(NodeMetadataExtractor):
|
class FluxGuidanceExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -1146,6 +1195,7 @@ NODE_EXTRACTORS = {
|
|||||||
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
|
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
|
||||||
"LoraLoader": LoraLoaderExtractor,
|
"LoraLoader": LoraLoaderExtractor,
|
||||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||||
|
"LoraTextLoaderLM": LoraTextLoaderManagerExtractor,
|
||||||
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||||
"TensorRTLoader": TensorRTLoaderExtractor,
|
"TensorRTLoader": TensorRTLoaderExtractor,
|
||||||
# Conditioning
|
# Conditioning
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ from ...utils.constants import (
|
|||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from ...utils.example_images_paths import is_valid_example_images_root
|
from ...utils.example_images_paths import (
|
||||||
|
find_non_compliant_items_in_example_images_root,
|
||||||
|
is_valid_example_images_root,
|
||||||
|
)
|
||||||
from ...utils.lora_metadata import extract_trained_words
|
from ...utils.lora_metadata import extract_trained_words
|
||||||
from ...utils.session_logging import get_standalone_session_log_snapshot
|
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||||
from ...utils.usage_stats import UsageStats
|
from ...utils.usage_stats import UsageStats
|
||||||
@@ -1328,6 +1331,9 @@ class SettingsHandler:
|
|||||||
"folder_paths",
|
"folder_paths",
|
||||||
"libraries",
|
"libraries",
|
||||||
"active_library",
|
"active_library",
|
||||||
|
# Sensitive — never expose the actual value to the frontend;
|
||||||
|
# frontend receives a boolean instead (civitai_api_key_set).
|
||||||
|
"civitai_api_key",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1382,6 +1388,9 @@ class SettingsHandler:
|
|||||||
value = self._settings.get(key)
|
value = self._settings.get(key)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
response_data[key] = value
|
response_data[key] = value
|
||||||
|
# Sensitive fields: only expose a boolean indicating whether set
|
||||||
|
raw_key = self._settings.get("civitai_api_key")
|
||||||
|
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||||
settings_file = getattr(self._settings, "settings_file", None)
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
if settings_file:
|
if settings_file:
|
||||||
response_data["settings_file"] = settings_file
|
response_data["settings_file"] = settings_file
|
||||||
@@ -1492,6 +1501,16 @@ class SettingsHandler:
|
|||||||
if not os.path.isdir(folder_path):
|
if not os.path.isdir(folder_path):
|
||||||
return "Please set a dedicated folder for example images."
|
return "Please set a dedicated folder for example images."
|
||||||
if not self._is_dedicated_example_images_folder(folder_path):
|
if not self._is_dedicated_example_images_folder(folder_path):
|
||||||
|
offending = find_non_compliant_items_in_example_images_root(folder_path)
|
||||||
|
if offending:
|
||||||
|
items_str = ", ".join(repr(item) for item in offending[:5])
|
||||||
|
if len(offending) > 5:
|
||||||
|
items_str += f" … and {len(offending) - 5} more"
|
||||||
|
return (
|
||||||
|
f"The folder contains items that are not valid example image "
|
||||||
|
f"folders: {items_str}. Please use a dedicated, empty folder "
|
||||||
|
f"for example images to prevent accidental data loss."
|
||||||
|
)
|
||||||
return "Please set a dedicated folder for example images."
|
return "Please set a dedicated folder for example images."
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -477,9 +477,12 @@ class StatsRoutes:
|
|||||||
if unused_lora_percent > 50:
|
if unused_lora_percent > 50:
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'warning',
|
'type': 'warning',
|
||||||
'title': 'High Number of Unused LoRAs',
|
'key': 'insights.unusedLoras.high',
|
||||||
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
|
'params': {
|
||||||
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
|
'percent': f'{unused_lora_percent:.1f}',
|
||||||
|
'count': str(unused_loras),
|
||||||
|
'total': str(total_loras)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if total_checkpoints > 0:
|
if total_checkpoints > 0:
|
||||||
@@ -487,9 +490,12 @@ class StatsRoutes:
|
|||||||
if unused_checkpoint_percent > 30:
|
if unused_checkpoint_percent > 30:
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'warning',
|
'type': 'warning',
|
||||||
'title': 'Unused Checkpoints Detected',
|
'key': 'insights.unusedCheckpoints.detected',
|
||||||
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
|
'params': {
|
||||||
'suggestion': 'Review and consider removing checkpoints you no longer need.'
|
'percent': f'{unused_checkpoint_percent:.1f}',
|
||||||
|
'count': str(unused_checkpoints),
|
||||||
|
'total': str(total_checkpoints)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if total_embeddings > 0:
|
if total_embeddings > 0:
|
||||||
@@ -497,9 +503,12 @@ class StatsRoutes:
|
|||||||
if unused_embedding_percent > 50:
|
if unused_embedding_percent > 50:
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'warning',
|
'type': 'warning',
|
||||||
'title': 'High Number of Unused Embeddings',
|
'key': 'insights.unusedEmbeddings.high',
|
||||||
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
|
'params': {
|
||||||
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
|
'percent': f'{unused_embedding_percent:.1f}',
|
||||||
|
'count': str(unused_embeddings),
|
||||||
|
'total': str(total_embeddings)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# Storage insights
|
# Storage insights
|
||||||
@@ -510,18 +519,20 @@ class StatsRoutes:
|
|||||||
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'info',
|
'type': 'info',
|
||||||
'title': 'Large Collection Detected',
|
'key': 'insights.collection.large',
|
||||||
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
|
'params': {
|
||||||
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
|
'size': self._format_size(total_size)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
# Recent activity insight
|
# Recent activity insight
|
||||||
if usage_data.get('total_executions', 0) > 100:
|
if usage_data.get('total_executions', 0) > 100:
|
||||||
insights.append({
|
insights.append({
|
||||||
'type': 'success',
|
'type': 'success',
|
||||||
'title': 'Active User',
|
'key': 'insights.activity.active',
|
||||||
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
|
'params': {
|
||||||
'suggestion': 'Keep exploring and creating amazing content with your models.'
|
'count': str(usage_data['total_executions'])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
|||||||
@@ -427,7 +427,18 @@ class MetadataSyncService:
|
|||||||
metadata = await metadata_loader(metadata_path)
|
metadata = await metadata_loader(metadata_path)
|
||||||
|
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
if isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
if key == "tags" and isinstance(value, list):
|
||||||
|
# Normalize tags: trim, lowercase, deduplicate
|
||||||
|
normalized = []
|
||||||
|
seen = set()
|
||||||
|
for tag in value:
|
||||||
|
if isinstance(tag, str):
|
||||||
|
t = tag.strip().lower()
|
||||||
|
if t and t not in seen:
|
||||||
|
normalized.append(t)
|
||||||
|
seen.add(t)
|
||||||
|
metadata[key] = normalized
|
||||||
|
elif isinstance(value, dict) and isinstance(metadata.get(key), dict):
|
||||||
metadata[key].update(value)
|
metadata[key].update(value)
|
||||||
else:
|
else:
|
||||||
metadata[key] = value
|
metadata[key] = value
|
||||||
|
|||||||
@@ -294,12 +294,14 @@ class ModelFilterSet:
|
|||||||
for tag, state in tag_filters.items():
|
for tag, state in tag_filters.items():
|
||||||
if not tag:
|
if not tag:
|
||||||
continue
|
continue
|
||||||
|
# Normalize to lowercase for case-insensitive matching
|
||||||
|
normalized = tag.strip().lower()
|
||||||
if state == "exclude":
|
if state == "exclude":
|
||||||
exclude_tags.add(tag)
|
exclude_tags.add(normalized)
|
||||||
else:
|
else:
|
||||||
include_tags.add(tag)
|
include_tags.add(normalized)
|
||||||
else:
|
else:
|
||||||
include_tags = {tag for tag in tag_filters if tag}
|
include_tags = {tag.strip().lower() for tag in tag_filters if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
||||||
@@ -318,13 +320,17 @@ class ModelFilterSet:
|
|||||||
return True
|
return True
|
||||||
# Otherwise, check if all non-special tags match
|
# Otherwise, check if all non-special tags match
|
||||||
if non_special_tags:
|
if non_special_tags:
|
||||||
return all(tag in (item_tags or []) for tag in non_special_tags)
|
# Case-insensitive: normalize item tags too
|
||||||
|
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||||
|
return all(tag in normalized_item_tags for tag in non_special_tags)
|
||||||
return True
|
return True
|
||||||
# Normal case: all tags must match
|
# Normal case: all tags must match (case-insensitive)
|
||||||
return all(tag in (item_tags or []) for tag in non_special_tags)
|
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||||
|
return all(tag in normalized_item_tags for tag in non_special_tags)
|
||||||
else:
|
else:
|
||||||
# OR logic (default): item must have ANY include tag
|
# OR logic (default): item must have ANY include tag (case-insensitive)
|
||||||
return any(tag in include_tags for tag in (item_tags or []))
|
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||||
|
return bool(normalized_item_tags & include_tags)
|
||||||
|
|
||||||
items = [item for item in items if matches_include(item.get("tags"))]
|
items = [item for item in items if matches_include(item.get("tags"))]
|
||||||
|
|
||||||
@@ -333,7 +339,9 @@ class ModelFilterSet:
|
|||||||
def matches_exclude(item_tags):
|
def matches_exclude(item_tags):
|
||||||
if not item_tags and "__no_tags__" in exclude_tags:
|
if not item_tags and "__no_tags__" in exclude_tags:
|
||||||
return True
|
return True
|
||||||
return any(tag in exclude_tags for tag in (item_tags or []))
|
# Case-insensitive: normalize item tags
|
||||||
|
normalized_item_tags = {t.strip().lower() for t in (item_tags or []) if isinstance(t, str)}
|
||||||
|
return bool(normalized_item_tags & exclude_tags)
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
item for item in items if not matches_exclude(item.get("tags"))
|
item for item in items if not matches_exclude(item.get("tags"))
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ class SettingsManager:
|
|||||||
self._template_path = (
|
self._template_path = (
|
||||||
Path(__file__).resolve().parents[2] / "settings.json.example"
|
Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||||
)
|
)
|
||||||
|
# Known placeholder value in settings.json.example; any file containing
|
||||||
|
# this value should be treated as "not configured".
|
||||||
|
self._TEMPLATE_PLACEHOLDER_API_KEY = "your_civitai_api_key_here"
|
||||||
self.settings = self._load_settings()
|
self.settings = self._load_settings()
|
||||||
self._migrate_setting_keys()
|
self._migrate_setting_keys()
|
||||||
self._ensure_default_settings()
|
self._ensure_default_settings()
|
||||||
@@ -165,6 +168,12 @@ class SettingsManager:
|
|||||||
self._original_disk_payload = copy.deepcopy(data)
|
self._original_disk_payload = copy.deepcopy(data)
|
||||||
if self._matches_template_payload(data):
|
if self._matches_template_payload(data):
|
||||||
self._preserve_disk_template = True
|
self._preserve_disk_template = True
|
||||||
|
# Clean up the template placeholder so it is not treated
|
||||||
|
# as a real key (affects both the frontend boolean and
|
||||||
|
# the downloader's Authorization header).
|
||||||
|
placeholder = self._TEMPLATE_PLACEHOLDER_API_KEY
|
||||||
|
if data.get("civitai_api_key") == placeholder:
|
||||||
|
data["civitai_api_key"] = ""
|
||||||
return data
|
return data
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
logger.error("Failed to parse settings.json: %s", exc)
|
logger.error("Failed to parse settings.json: %s", exc)
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ class TagUpdateService:
|
|||||||
if isinstance(tag, str) and tag.strip():
|
if isinstance(tag, str) and tag.strip():
|
||||||
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
|
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
|
||||||
normalized = tag.strip().lower()
|
normalized = tag.strip().lower()
|
||||||
if normalized.lower() not in existing_lower:
|
if normalized not in existing_lower:
|
||||||
existing_tags.append(normalized)
|
existing_tags.append(normalized)
|
||||||
existing_lower.append(normalized.lower())
|
existing_lower.append(normalized)
|
||||||
tags_added.append(normalized)
|
tags_added.append(normalized)
|
||||||
|
|
||||||
metadata["tags"] = existing_tags
|
metadata["tags"] = existing_tags
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ from ..services.settings_manager import get_settings_manager
|
|||||||
|
|
||||||
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
|
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
|
||||||
|
|
||||||
|
# Filesystem/metadata files that are never created by the example images system
|
||||||
|
# and are safe to ignore during validation. The cleanup service only operates on
|
||||||
|
# directories, so these files pose no data-loss risk.
|
||||||
|
_SAFE_FILENAMES: frozenset[str] = frozenset({
|
||||||
|
".DS_Store", # macOS folder metadata
|
||||||
|
"Thumbs.db", # Windows thumbnail cache
|
||||||
|
"desktop.ini", # Windows folder customization
|
||||||
|
".localized", # macOS folder name localization
|
||||||
|
".gitkeep", # Placeholder to keep empty dirs in git
|
||||||
|
".gitignore", # Git ignore rules
|
||||||
|
})
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -180,6 +192,22 @@ def is_hash_folder(name: str) -> bool:
|
|||||||
return bool(_HEX_PATTERN.fullmatch(name or ""))
|
return bool(_HEX_PATTERN.fullmatch(name or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_ignorable_entry(item: str, item_path: str) -> bool:
|
||||||
|
"""Return True if *item* is a harmless system/hidden file we can skip.
|
||||||
|
|
||||||
|
These files are never created by the example images system and are safe to
|
||||||
|
ignore because the cleanup/delete operations only act on **directories**,
|
||||||
|
never on individual files (other than ``.download_progress.json``).
|
||||||
|
"""
|
||||||
|
if item in _SAFE_FILENAMES:
|
||||||
|
return True
|
||||||
|
# Hide Unix hidden files (dotfiles) that are regular files,
|
||||||
|
# since the cleanup system never deletes or moves files.
|
||||||
|
if item.startswith(".") and os.path.isfile(item_path):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def is_valid_example_images_root(folder_path: str) -> bool:
|
def is_valid_example_images_root(folder_path: str) -> bool:
|
||||||
"""Check whether a folder looks like a dedicated example images root."""
|
"""Check whether a folder looks like a dedicated example images root."""
|
||||||
|
|
||||||
@@ -190,9 +218,16 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
|||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
item_path = os.path.join(folder_path, item)
|
item_path = os.path.join(folder_path, item)
|
||||||
|
|
||||||
|
# .download_progress.json is an expected metadata file — check before
|
||||||
|
# the generic dotfile rule so it stays explicitly documented.
|
||||||
if item == ".download_progress.json" and os.path.isfile(item_path):
|
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip harmless system/hidden files — cleanup only touches directories
|
||||||
|
if _is_safe_ignorable_entry(item, item_path):
|
||||||
|
continue
|
||||||
|
|
||||||
if os.path.isdir(item_path):
|
if os.path.isdir(item_path):
|
||||||
if is_hash_folder(item):
|
if is_hash_folder(item):
|
||||||
continue
|
continue
|
||||||
@@ -211,6 +246,41 @@ def is_valid_example_images_root(folder_path: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find_non_compliant_items_in_example_images_root(folder_path: str) -> list[str]:
|
||||||
|
"""Return the names of items that prevent *folder_path* from being a valid
|
||||||
|
example images root, or an empty list if the folder is valid.
|
||||||
|
|
||||||
|
This mirrors ``is_valid_example_images_root`` but **returns** the offending
|
||||||
|
names instead of a boolean, so callers can produce actionable error messages.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
items = os.listdir(folder_path)
|
||||||
|
except OSError as exc:
|
||||||
|
return [f"<cannot list directory: {exc}>"]
|
||||||
|
|
||||||
|
offending: list[str] = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item_path = os.path.join(folder_path, item)
|
||||||
|
|
||||||
|
# Same skip rules as is_valid_example_images_root
|
||||||
|
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||||
|
continue
|
||||||
|
if _is_safe_ignorable_entry(item, item_path):
|
||||||
|
continue
|
||||||
|
if os.path.isdir(item_path):
|
||||||
|
if is_hash_folder(item):
|
||||||
|
continue
|
||||||
|
if item == "_deleted":
|
||||||
|
continue
|
||||||
|
if _library_folder_has_only_hash_dirs(item_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
offending.append(item)
|
||||||
|
|
||||||
|
return offending
|
||||||
|
|
||||||
|
|
||||||
def _library_folder_has_only_hash_dirs(path: str) -> bool:
|
def _library_folder_has_only_hash_dirs(path: str) -> bool:
|
||||||
"""Return True when a library subfolder only contains hash folders or metadata files."""
|
"""Return True when a library subfolder only contains hash folders or metadata files."""
|
||||||
|
|
||||||
|
|||||||
@@ -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.3"
|
version = "1.1.4"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-tag-compact {
|
.model-tag-compact {
|
||||||
@@ -28,6 +30,9 @@
|
|||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for empty tags placeholder */
|
/* Style for empty tags placeholder */
|
||||||
@@ -118,8 +123,9 @@
|
|||||||
/* Model Tags Edit Mode */
|
/* Model Tags Edit Mode */
|
||||||
.model-tags-header {
|
.model-tags-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-tags-btn {
|
.edit-tags-btn {
|
||||||
@@ -132,6 +138,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
margin-left: var(--space-1);
|
margin-left: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-tags-btn.visible,
|
.edit-tags-btn.visible,
|
||||||
|
|||||||
@@ -335,7 +335,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* API key input specific styles */
|
/* API key input — CSS masking (prevents Chrome password manager triggers) */
|
||||||
|
.api-key-masked {
|
||||||
|
-webkit-text-security: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API key input specific styles (shared with proxy password) */
|
||||||
.api-key-input {
|
.api-key-input {
|
||||||
width: 100%; /* Take full width of parent */
|
width: 100%; /* Take full width of parent */
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -345,7 +350,7 @@
|
|||||||
|
|
||||||
.api-key-input input {
|
.api-key-input input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 40px 6px 10px; /* Add left padding */
|
padding: 6px 40px 6px 10px; /* Right padding for eye button */
|
||||||
height: 32px;
|
height: 32px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
@@ -353,6 +358,13 @@
|
|||||||
background-color: var(--lora-surface);
|
background-color: var(--lora-surface);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-input input:focus {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-key-input .toggle-visibility {
|
.api-key-input .toggle-visibility {
|
||||||
@@ -364,12 +376,98 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-key-input .toggle-visibility:hover {
|
.api-key-input .toggle-visibility:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* API key item — stack status/edit views vertically for smooth cross-fade */
|
||||||
|
.api-key-item .setting-control {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API key status display (shown when not editing) */
|
||||||
|
.api-key-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||||
|
max-height: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
pointer-events: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status color modifiers — replace inline styles */
|
||||||
|
.api-key-status--configured .fa-check-circle {
|
||||||
|
color: var(--lora-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-status--unconfigured .fa-times-circle {
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes for status icon colors (used by JS) */
|
||||||
|
.text-success {
|
||||||
|
color: var(--lora-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API key inline edit container — flex row with input + buttons */
|
||||||
|
.api-key-edit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||||
|
max-height: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-edit.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
pointer-events: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-edit .api-key-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-edit .primary-btn,
|
||||||
|
.api-key-edit .secondary-btn {
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Text input wrapper styles for consistent input styling */
|
/* Text input wrapper styles for consistent input styling */
|
||||||
.text-input-wrapper {
|
.text-input-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#recipeTagsContainer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-modal-header h2 {
|
.recipe-modal-header h2 {
|
||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 var(--space-1);
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
@@ -95,127 +99,11 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-editor.tags-editor input {
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove obsolete button styles */
|
/* Remove obsolete button styles */
|
||||||
.editor-actions {
|
.editor-actions {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Special styling for tags content */
|
|
||||||
.tags-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-display {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-tags {
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.6;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Recipe Tags styles */
|
|
||||||
.recipe-tags-container {
|
|
||||||
position: relative;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tags-compact {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tag-compact {
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: var(--text-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .recipe-tag-compact {
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tag-more {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: var(--lora-text);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tags-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 8px);
|
|
||||||
left: 0;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
box-shadow: var(--shadow-dropdown);
|
|
||||||
padding: 10px 14px;
|
|
||||||
max-width: 400px;
|
|
||||||
z-index: 10;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
transition: var(--transition-base);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-tags-tooltip.visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateY(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-content {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-tag {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: 3px 8px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .tooltip-tag {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
border: 1px solid var(--lora-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
#recipeModal .modal-content {
|
#recipeModal .modal-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1153,7 +1041,7 @@
|
|||||||
max-height: 2.4em;
|
max-height: 2.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-tags-container {
|
#recipeTagsContainer {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ export class BaseModelApiClient {
|
|||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = pageState.currentPage + 1;
|
pageState.currentPage = pageState.currentPage + 1;
|
||||||
|
|
||||||
|
// When resetting to page 1, scroll back to the top
|
||||||
|
// This covers: folder selection, filter/sort/search changes,
|
||||||
|
// favorites/update/excluded view toggles, alphabet filter, etc.
|
||||||
|
if (resetPage) {
|
||||||
|
const scrollContainer = document.querySelector('.page-content');
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updateFolders) {
|
if (updateFolders) {
|
||||||
sidebarManager.refresh();
|
sidebarManager.refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
|||||||
import { downloadManager } from '../managers/DownloadManager.js';
|
import { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||||
|
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
|
||||||
|
import { setupTagEditMode } from './shared/ModelTags.js';
|
||||||
|
|
||||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||||
'prompt',
|
'prompt',
|
||||||
@@ -139,14 +141,6 @@ class RecipeModal {
|
|||||||
this.saveTitleEdit();
|
this.saveTitleEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tags edit
|
|
||||||
const tagsEditor = document.getElementById('recipeTagsEditor');
|
|
||||||
if (tagsEditor && tagsEditor.classList.contains('active') &&
|
|
||||||
!tagsEditor.contains(event.target) &&
|
|
||||||
!event.target.closest('.edit-icon')) {
|
|
||||||
this.saveTagsEdit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle reconnect input
|
// Handle reconnect input
|
||||||
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
|
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
|
||||||
reconnectContainers.forEach(container => {
|
reconnectContainers.forEach(container => {
|
||||||
@@ -236,98 +230,10 @@ class RecipeModal {
|
|||||||
this.filePath = hydratedRecipe.file_path;
|
this.filePath = hydratedRecipe.file_path;
|
||||||
this.listFilePath = hydratedRecipe.file_path;
|
this.listFilePath = hydratedRecipe.file_path;
|
||||||
|
|
||||||
// Set recipe tags if they exist
|
// Render tags using shared utility
|
||||||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
const tagsContainer = document.getElementById('recipeTagsContainer');
|
||||||
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
|
if (tagsContainer) {
|
||||||
|
this.updateTagsDisplay(tagsContainer, hydratedRecipe.tags || []);
|
||||||
if (tagsCompactElement) {
|
|
||||||
// Add tags container with edit functionality
|
|
||||||
tagsCompactElement.innerHTML = `
|
|
||||||
<div class="editable-content tags-content">
|
|
||||||
<div class="tags-display"></div>
|
|
||||||
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
|
|
||||||
</div>
|
|
||||||
<div id="recipeTagsEditor" class="content-editor tags-editor">
|
|
||||||
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
|
||||||
|
|
||||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
|
||||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
|
||||||
const maxVisibleTags = 5;
|
|
||||||
const visibleTags = hydratedRecipe.tags.slice(0, maxVisibleTags);
|
|
||||||
const remainingTags = hydratedRecipe.tags.length > maxVisibleTags ? hydratedRecipe.tags.slice(maxVisibleTags) : [];
|
|
||||||
|
|
||||||
// Add visible tags
|
|
||||||
visibleTags.forEach(tag => {
|
|
||||||
const tagElement = document.createElement('div');
|
|
||||||
tagElement.className = 'recipe-tag-compact';
|
|
||||||
tagElement.textContent = tag;
|
|
||||||
tagsDisplay.appendChild(tagElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add "more" button if needed
|
|
||||||
if (remainingTags.length > 0) {
|
|
||||||
const moreButton = document.createElement('div');
|
|
||||||
moreButton.className = 'recipe-tag-more';
|
|
||||||
moreButton.textContent = `+${remainingTags.length} more`;
|
|
||||||
tagsDisplay.appendChild(moreButton);
|
|
||||||
|
|
||||||
// Add tooltip functionality
|
|
||||||
moreButton.addEventListener('mouseenter', () => {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
moreButton.addEventListener('mouseleave', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add all tags to tooltip
|
|
||||||
if (tagsTooltipContent) {
|
|
||||||
tagsTooltipContent.innerHTML = '';
|
|
||||||
hydratedRecipe.tags.forEach(tag => {
|
|
||||||
const tooltipTag = document.createElement('div');
|
|
||||||
tooltipTag.className = 'tooltip-tag';
|
|
||||||
tooltipTag.textContent = tag;
|
|
||||||
tagsTooltipContent.appendChild(tooltipTag);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event listeners for tags editing
|
|
||||||
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
|
|
||||||
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
|
||||||
|
|
||||||
// Set current tags in the input
|
|
||||||
if (hydratedRecipe.tags && hydratedRecipe.tags.length > 0) {
|
|
||||||
tagsInput.value = hydratedRecipe.tags.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
|
||||||
|
|
||||||
// Add key event listener for Enter key
|
|
||||||
tagsInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.saveTagsEdit();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.cancelTagsEdit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set recipe image
|
// Set recipe image
|
||||||
@@ -609,17 +515,35 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncTagsDisplay(tags) {
|
syncTagsDisplay(tags) {
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
const container = document.getElementById('recipeTagsContainer');
|
||||||
if (!tagsContainer) {
|
if (!container) return;
|
||||||
return;
|
this.updateTagsDisplay(container, tags || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateTagsDisplay(tagsContainer, tags || []);
|
// Re-render tags display using shared utility, wire edit mode with ModelTags
|
||||||
|
updateTagsDisplay(container, tags) {
|
||||||
|
const filePath = this.filePath || '';
|
||||||
|
|
||||||
const tagsInput = tagsContainer.querySelector('.tags-input');
|
container.innerHTML = renderCompactTags(tags, filePath);
|
||||||
if (tagsInput) {
|
|
||||||
tagsInput.value = tags && tags.length > 0 ? tags.join(', ') : '';
|
// Setup tooltip for all tags
|
||||||
}
|
setupTagTooltip(container);
|
||||||
|
|
||||||
|
// Wire edit button using shared tag editing (no suggestions for recipes)
|
||||||
|
setupTagEditMode(null, {
|
||||||
|
container: container,
|
||||||
|
showSuggestions: false,
|
||||||
|
normalizeTag: false,
|
||||||
|
saveHandler: async (filePath, tags) => {
|
||||||
|
await updateRecipeMetadata(filePath, { tags }, this.getMetadataUpdateOptions());
|
||||||
|
},
|
||||||
|
onSaved: (tags) => {
|
||||||
|
this.currentRecipe.tags = tags;
|
||||||
|
this.commitField('tags');
|
||||||
|
const c = document.getElementById('recipeTagsContainer');
|
||||||
|
if (c) this.updateTagsDisplay(c, tags);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
syncPromptField(field, value, placeholder) {
|
syncPromptField(field, value, placeholder) {
|
||||||
@@ -976,139 +900,6 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags editing methods
|
|
||||||
showTagsEditor() {
|
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
|
||||||
if (tagsContainer) {
|
|
||||||
tagsContainer.querySelector('.editable-content').classList.add('hide');
|
|
||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
|
||||||
editor.classList.add('active');
|
|
||||||
const input = editor.querySelector('input');
|
|
||||||
input.oninput = () => this.markFieldDirty('tags');
|
|
||||||
input.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
saveTagsEdit() {
|
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
|
||||||
if (tagsContainer) {
|
|
||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
|
||||||
const input = editor.querySelector('input');
|
|
||||||
const tagsText = input.value.trim();
|
|
||||||
|
|
||||||
// Parse tags
|
|
||||||
let newTags = [];
|
|
||||||
if (tagsText) {
|
|
||||||
newTags = tagsText.split(',')
|
|
||||||
.map(tag => tag.trim())
|
|
||||||
.filter(tag => tag.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if tags changed
|
|
||||||
const oldTags = this.currentRecipe.tags || [];
|
|
||||||
const tagsChanged =
|
|
||||||
newTags.length !== oldTags.length ||
|
|
||||||
newTags.some((tag, index) => tag !== oldTags[index]);
|
|
||||||
|
|
||||||
if (tagsChanged) {
|
|
||||||
// Update the recipe on the server
|
|
||||||
updateRecipeMetadata(this.filePath, { tags: newTags }, this.getMetadataUpdateOptions())
|
|
||||||
.then(data => {
|
|
||||||
// Show success toast
|
|
||||||
showToast('toast.recipes.tagsUpdated', {}, 'success');
|
|
||||||
|
|
||||||
// Update the current recipe object
|
|
||||||
this.currentRecipe.tags = newTags;
|
|
||||||
this.commitField('tags');
|
|
||||||
|
|
||||||
// Update tags in the UI
|
|
||||||
this.updateTagsDisplay(tagsContainer, newTags);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
// Error is handled in the API function
|
|
||||||
this.clearFieldDirty('tags');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.clearFieldDirty('tags');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide editor
|
|
||||||
editor.classList.remove('active');
|
|
||||||
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method to update tags display
|
|
||||||
updateTagsDisplay(tagsContainer, tags) {
|
|
||||||
const tagsDisplay = tagsContainer.querySelector('.tags-display');
|
|
||||||
tagsDisplay.innerHTML = '';
|
|
||||||
|
|
||||||
if (tags.length > 0) {
|
|
||||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
|
||||||
const maxVisibleTags = 5;
|
|
||||||
const visibleTags = tags.slice(0, maxVisibleTags);
|
|
||||||
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
|
|
||||||
|
|
||||||
// Add visible tags
|
|
||||||
visibleTags.forEach(tag => {
|
|
||||||
const tagElement = document.createElement('div');
|
|
||||||
tagElement.className = 'recipe-tag-compact';
|
|
||||||
tagElement.textContent = tag;
|
|
||||||
tagsDisplay.appendChild(tagElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add "more" button if needed
|
|
||||||
if (remainingTags.length > 0) {
|
|
||||||
const moreButton = document.createElement('div');
|
|
||||||
moreButton.className = 'recipe-tag-more';
|
|
||||||
moreButton.textContent = `+${remainingTags.length} more`;
|
|
||||||
tagsDisplay.appendChild(moreButton);
|
|
||||||
|
|
||||||
// Update tooltip content
|
|
||||||
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
|
|
||||||
if (tooltipContent) {
|
|
||||||
tooltipContent.innerHTML = '';
|
|
||||||
tags.forEach(tag => {
|
|
||||||
const tooltipTag = document.createElement('div');
|
|
||||||
tooltipTag.className = 'tooltip-tag';
|
|
||||||
tooltipTag.textContent = tag;
|
|
||||||
tooltipContent.appendChild(tooltipTag);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-add tooltip functionality
|
|
||||||
moreButton.addEventListener('mouseenter', () => {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
moreButton.addEventListener('mouseleave', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelTagsEdit() {
|
|
||||||
const tagsContainer = document.getElementById('recipeTagsCompact');
|
|
||||||
if (tagsContainer) {
|
|
||||||
// Reset input value
|
|
||||||
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
|
||||||
const input = editor.querySelector('input');
|
|
||||||
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
|
||||||
this.clearFieldDirty('tags');
|
|
||||||
|
|
||||||
// Hide editor
|
|
||||||
editor.classList.remove('active');
|
|
||||||
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupPromptEditors() {
|
setupPromptEditors() {
|
||||||
const promptConfigs = [
|
const promptConfigs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.js';
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||||
@@ -457,21 +457,69 @@ export class SidebarManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||||
|
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||||
|
|
||||||
if (useBulkMove) {
|
if (useBulkMove) {
|
||||||
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
const results = await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
|
||||||
|
movedFiles = (results || [])
|
||||||
|
.filter(r => r.success)
|
||||||
|
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||||
} else {
|
} else {
|
||||||
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
const result = await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
|
||||||
|
if (result) {
|
||||||
|
movedFiles.push({
|
||||||
|
original_file_path: result.original_file_path || this.draggedFilePaths[0],
|
||||||
|
new_file_path: result.new_file_path
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log('[SidebarManager] apiClient.move successful');
|
console.log('[SidebarManager] apiClient.move successful');
|
||||||
|
|
||||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
// Update VirtualScroller in-place instead of full reload
|
||||||
console.log('[SidebarManager] calling resetAndReload');
|
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||||
await this.pageControls.resetAndReload(true);
|
const pageState = getCurrentPageState();
|
||||||
} else {
|
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
console.log('[SidebarManager] calling refresh');
|
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||||
await this.refresh();
|
const isFolderFiltered = pageState.activeFolder !== null;
|
||||||
|
|
||||||
|
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Determine if items in the target folder are visible in the current view
|
||||||
|
let itemsRemainVisible = true;
|
||||||
|
if (isFolderFiltered) {
|
||||||
|
if (isRecursive) {
|
||||||
|
itemsRemainVisible = normalizedActive === '' ||
|
||||||
|
normalizedTarget === normalizedActive ||
|
||||||
|
normalizedTarget.startsWith(normalizedActive + '/');
|
||||||
|
} else {
|
||||||
|
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsRemainVisible) {
|
||||||
|
// Items stay visible — update each item's file_path to reflect new location
|
||||||
|
for (const moved of movedFiles) {
|
||||||
|
if (moved.original_file_path && moved.new_file_path) {
|
||||||
|
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: normalizedTarget
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Items no longer visible in current folder — remove from VirtualScroller
|
||||||
|
const pathsToRemove = movedFiles
|
||||||
|
.map(m => m.original_file_path)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (pathsToRemove.length > 0) {
|
||||||
|
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh sidebar folder tree only (no model data reload)
|
||||||
|
await this.refresh();
|
||||||
|
|
||||||
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
if (this.draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||||
bulkManager.toggleBulkMode();
|
bulkManager.toggleBulkMode();
|
||||||
}
|
}
|
||||||
@@ -530,21 +578,69 @@ export class SidebarManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
|
||||||
|
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||||
|
|
||||||
if (useBulkMove) {
|
if (useBulkMove) {
|
||||||
await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination);
|
||||||
|
movedFiles = (results || [])
|
||||||
|
.filter(r => r.success)
|
||||||
|
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||||
} else {
|
} else {
|
||||||
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
|
||||||
|
if (result) {
|
||||||
|
movedFiles.push({
|
||||||
|
original_file_path: result.original_file_path || draggedFilePaths[0],
|
||||||
|
new_file_path: result.new_file_path
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log('[SidebarManager] apiClient.move successful');
|
console.log('[SidebarManager] apiClient.move successful');
|
||||||
|
|
||||||
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
|
// Update VirtualScroller in-place instead of full reload
|
||||||
console.log('[SidebarManager] calling resetAndReload');
|
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||||
await this.pageControls.resetAndReload(true);
|
const pageState = getCurrentPageState();
|
||||||
} else {
|
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
console.log('[SidebarManager] calling refresh');
|
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||||
await this.refresh();
|
const isFolderFiltered = pageState.activeFolder !== null;
|
||||||
|
|
||||||
|
const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Determine if items in the target folder are visible in the current view
|
||||||
|
let itemsRemainVisible = true;
|
||||||
|
if (isFolderFiltered) {
|
||||||
|
if (isRecursive) {
|
||||||
|
itemsRemainVisible = normalizedActive === '' ||
|
||||||
|
normalizedTarget === normalizedActive ||
|
||||||
|
normalizedTarget.startsWith(normalizedActive + '/');
|
||||||
|
} else {
|
||||||
|
itemsRemainVisible = normalizedTarget === normalizedActive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsRemainVisible) {
|
||||||
|
// Items stay visible — update each item's file_path to reflect new location
|
||||||
|
for (const moved of movedFiles) {
|
||||||
|
if (moved.original_file_path && moved.new_file_path) {
|
||||||
|
state.virtualScroller.updateSingleItem(moved.original_file_path, {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: normalizedTarget
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Items no longer visible in current folder — remove from VirtualScroller
|
||||||
|
const pathsToRemove = movedFiles
|
||||||
|
.map(m => m.original_file_path)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (pathsToRemove.length > 0) {
|
||||||
|
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh sidebar folder tree only (no model data reload)
|
||||||
|
await this.refresh();
|
||||||
|
|
||||||
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
|
||||||
bulkManager.toggleBulkMode();
|
bulkManager.toggleBulkMode();
|
||||||
}
|
}
|
||||||
@@ -1346,7 +1442,7 @@ export class SidebarManager {
|
|||||||
this.pageControls.pageState.activeFolder = normalizedPath;
|
this.pageControls.pageState.activeFolder = normalizedPath;
|
||||||
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
setStorageItem(`${this.pageType}_activeFolder`, normalizedPath);
|
||||||
|
|
||||||
// Reload models with new filter
|
// Reload models with new filter (loadMoreWithVirtualScroll will scroll to top)
|
||||||
await this.pageControls.resetAndReload();
|
await this.pageControls.resetAndReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ let priorityTagSuggestionsLoaded = false;
|
|||||||
let priorityTagSuggestionsPromise = null;
|
let priorityTagSuggestionsPromise = null;
|
||||||
let activeTagDragState = null;
|
let activeTagDragState = null;
|
||||||
|
|
||||||
|
// Configurable options for tag editing (set by setupTagEditMode)
|
||||||
|
let tagEditOptions = {
|
||||||
|
showSuggestions: true,
|
||||||
|
saveHandler: null,
|
||||||
|
onSaved: null,
|
||||||
|
normalizeTag: true,
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeModelTypeKey(modelType) {
|
function normalizeModelTypeKey(modelType) {
|
||||||
if (!modelType) {
|
if (!modelType) {
|
||||||
return '';
|
return '';
|
||||||
@@ -140,13 +148,30 @@ let saveTagsHandler = null;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up tag editing mode
|
* Set up tag editing mode
|
||||||
|
* @param {string|null} modelType - Model type for suggestions (e.g. 'loras', 'checkpoints')
|
||||||
|
* @param {Object} [options] - Optional configuration
|
||||||
|
* @param {boolean} [options.showSuggestions=true] - Show priority tag suggestions dropdown
|
||||||
|
* @param {Function} [options.saveHandler] - Custom save function, async (filePath, tags) => {}
|
||||||
|
* @param {Function} [options.onSaved] - Called after successful save, (tags) => {}
|
||||||
|
* @param {boolean} [options.normalizeTag=true] - Lowercase tag on add
|
||||||
*/
|
*/
|
||||||
export function setupTagEditMode(modelType = null) {
|
export function setupTagEditMode(modelType = null, options = {}) {
|
||||||
const editBtn = document.querySelector('.edit-tags-btn');
|
// Store options for use by saveTags and addNewTag
|
||||||
|
tagEditOptions = {
|
||||||
|
showSuggestions: options.showSuggestions !== false,
|
||||||
|
saveHandler: options.saveHandler || null,
|
||||||
|
onSaved: options.onSaved || null,
|
||||||
|
normalizeTag: options.normalizeTag !== false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = options.container || document;
|
||||||
|
const editBtn = root.querySelector('.edit-tags-btn');
|
||||||
if (!editBtn) return;
|
if (!editBtn) return;
|
||||||
|
|
||||||
setActiveModelTypeKey(modelType);
|
if (tagEditOptions.showSuggestions) {
|
||||||
ensurePriorityTagSuggestions();
|
setActiveModelTypeKey(modelType);
|
||||||
|
ensurePriorityTagSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
// Store original tags for restoring on cancel
|
// Store original tags for restoring on cancel
|
||||||
let originalTags = [];
|
let originalTags = [];
|
||||||
@@ -158,7 +183,8 @@ export function setupTagEditMode(modelType = null) {
|
|||||||
|
|
||||||
// Create new handler and store reference
|
// Create new handler and store reference
|
||||||
const editBtnClickHandler = function() {
|
const editBtnClickHandler = function() {
|
||||||
const tagsSection = document.querySelector('.model-tags-container');
|
const tagsSection = this.closest('.model-tags-container');
|
||||||
|
if (!tagsSection) return;
|
||||||
const isEditMode = tagsSection.classList.toggle('edit-mode');
|
const isEditMode = tagsSection.classList.toggle('edit-mode');
|
||||||
const filePath = this.dataset.filePath;
|
const filePath = this.dataset.filePath;
|
||||||
|
|
||||||
@@ -193,16 +219,18 @@ export function setupTagEditMode(modelType = null) {
|
|||||||
tagsSection.appendChild(editContainer);
|
tagsSection.appendChild(editContainer);
|
||||||
|
|
||||||
// Setup the tag input field behavior
|
// Setup the tag input field behavior
|
||||||
setupTagInput();
|
setupTagInput(tagsSection);
|
||||||
|
|
||||||
// Create and add preset suggestions dropdown
|
// Create and add preset suggestions dropdown
|
||||||
const tagForm = editContainer.querySelector('.metadata-add-form');
|
if (tagEditOptions.showSuggestions) {
|
||||||
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
|
const tagForm = editContainer.querySelector('.metadata-add-form');
|
||||||
tagForm.appendChild(suggestionsDropdown);
|
const suggestionsDropdown = createSuggestionsDropdown(originalTags);
|
||||||
|
tagForm.appendChild(suggestionsDropdown);
|
||||||
|
}
|
||||||
|
|
||||||
// Setup delete buttons for existing tags
|
// Setup delete buttons for existing tags
|
||||||
setupDeleteButtons();
|
setupDeleteButtons();
|
||||||
setupTagDragAndDrop();
|
setupTagDragAndDrop(tagsSection);
|
||||||
|
|
||||||
// Transfer click event from original button to the cloned one
|
// Transfer click event from original button to the cloned one
|
||||||
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
|
const newEditBtn = editContainer.querySelector('.metadata-header-btn');
|
||||||
@@ -218,7 +246,7 @@ export function setupTagEditMode(modelType = null) {
|
|||||||
// Just show the existing edit container
|
// Just show the existing edit container
|
||||||
tagsEditContainer.style.display = 'block';
|
tagsEditContainer.style.display = 'block';
|
||||||
editBtn.style.display = 'none';
|
editBtn.style.display = 'none';
|
||||||
setupTagDragAndDrop();
|
setupTagDragAndDrop(tagsSection);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Exit edit mode
|
// Exit edit mode
|
||||||
@@ -255,7 +283,7 @@ export function setupTagEditMode(modelType = null) {
|
|||||||
saveTagsHandler = function(e) {
|
saveTagsHandler = function(e) {
|
||||||
if (e.target.classList.contains('save-tags-btn') ||
|
if (e.target.classList.contains('save-tags-btn') ||
|
||||||
e.target.closest('.save-tags-btn')) {
|
e.target.closest('.save-tags-btn')) {
|
||||||
saveTags();
|
saveTags(e.target);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,19 +295,28 @@ export function setupTagEditMode(modelType = null) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save tags
|
* Save tags
|
||||||
|
* @param {Element} [triggerElement] - The element that triggered the save (e.g. save button)
|
||||||
*/
|
*/
|
||||||
async function saveTags() {
|
async function saveTags(triggerElement = null) {
|
||||||
const editBtn = document.querySelector('.edit-tags-btn');
|
let editBtn;
|
||||||
if (!editBtn) return;
|
let scope;
|
||||||
|
if (triggerElement) {
|
||||||
|
scope = triggerElement.closest('.model-tags-container');
|
||||||
|
editBtn = scope ? scope.querySelector('.edit-tags-btn') : document.querySelector('.edit-tags-btn');
|
||||||
|
} else {
|
||||||
|
scope = document.querySelector('.model-tags-container');
|
||||||
|
editBtn = scope ? scope.querySelector('.edit-tags-btn') : null;
|
||||||
|
}
|
||||||
|
if (!editBtn || !scope) return;
|
||||||
|
|
||||||
const filePath = editBtn.dataset.filePath;
|
const filePath = editBtn.dataset.filePath;
|
||||||
const tagElements = document.querySelectorAll('.metadata-item');
|
const tagElements = scope.querySelectorAll('.metadata-item');
|
||||||
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
|
||||||
|
|
||||||
// Flush uncommitted input as a tag so it's not silently lost on save
|
// Flush uncommitted input as a tag so it's not silently lost on save
|
||||||
const tagInput = document.querySelector('.metadata-input');
|
const tagInput = scope.querySelector('.metadata-input');
|
||||||
if (tagInput) {
|
if (tagInput) {
|
||||||
const pendingTag = tagInput.value.trim().toLowerCase();
|
const pendingTag = tagEditOptions.normalizeTag ? tagInput.value.trim().toLowerCase() : tagInput.value.trim();
|
||||||
if (pendingTag && !tags.includes(pendingTag)) {
|
if (pendingTag && !tags.includes(pendingTag)) {
|
||||||
tags.push(pendingTag);
|
tags.push(pendingTag);
|
||||||
}
|
}
|
||||||
@@ -287,7 +324,7 @@ async function saveTags() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get original tags to compare
|
// Get original tags to compare
|
||||||
const originalTagElements = document.querySelectorAll('.tooltip-tag');
|
const originalTagElements = scope.querySelectorAll('.tooltip-tag');
|
||||||
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
|
const originalTags = Array.from(originalTagElements).map(tag => tag.textContent);
|
||||||
|
|
||||||
// Check if tags have actually changed
|
// Check if tags have actually changed
|
||||||
@@ -301,59 +338,68 @@ async function saveTags() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save tags metadata
|
// Use custom save handler if provided, otherwise default model API
|
||||||
await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
|
if (tagEditOptions.saveHandler) {
|
||||||
|
await tagEditOptions.saveHandler(filePath, tags);
|
||||||
|
} else {
|
||||||
|
await getModelApiClient().saveModelMetadata(filePath, { tags: tags });
|
||||||
|
}
|
||||||
|
|
||||||
// Set flag to skip restoring original tags when exiting edit mode
|
// Set flag to skip restoring original tags when exiting edit mode
|
||||||
editBtn.dataset.skipRestore = "true";
|
editBtn.dataset.skipRestore = "true";
|
||||||
|
|
||||||
// Update the compact tags display
|
// Use custom onSaved if provided (e.g. for recipe dirty state + re-render)
|
||||||
const compactTagsContainer = document.querySelector('.model-tags-container');
|
if (tagEditOptions.onSaved) {
|
||||||
if (compactTagsContainer) {
|
tagEditOptions.onSaved(tags);
|
||||||
// Generate new compact tags HTML
|
} else {
|
||||||
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
|
// Update the compact tags display
|
||||||
|
const compactTagsContainer = scope;
|
||||||
if (compactTagsDisplay) {
|
if (compactTagsContainer) {
|
||||||
// Clear current tags
|
// Generate new compact tags HTML
|
||||||
compactTagsDisplay.innerHTML = '';
|
const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact');
|
||||||
|
|
||||||
// Add visible tags (up to 5)
|
if (compactTagsDisplay) {
|
||||||
const visibleTags = tags.slice(0, 5);
|
// Clear current tags
|
||||||
visibleTags.forEach(tag => {
|
compactTagsDisplay.innerHTML = '';
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = 'model-tag-compact';
|
// Add visible tags (up to 5)
|
||||||
span.textContent = tag;
|
const visibleTags = tags.slice(0, 5);
|
||||||
compactTagsDisplay.appendChild(span);
|
visibleTags.forEach(tag => {
|
||||||
});
|
const span = document.createElement('span');
|
||||||
|
span.className = 'model-tag-compact';
|
||||||
|
span.textContent = tag;
|
||||||
|
compactTagsDisplay.appendChild(span);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add more indicator if needed
|
||||||
|
const remainingCount = Math.max(0, tags.length - 5);
|
||||||
|
if (remainingCount > 0) {
|
||||||
|
const more = document.createElement('span');
|
||||||
|
more.className = 'model-tag-more';
|
||||||
|
more.dataset.count = remainingCount;
|
||||||
|
more.textContent = `+${remainingCount}`;
|
||||||
|
compactTagsDisplay.appendChild(more);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add more indicator if needed
|
// Update tooltip content
|
||||||
const remainingCount = Math.max(0, tags.length - 5);
|
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
|
||||||
if (remainingCount > 0) {
|
if (tooltipContent) {
|
||||||
const more = document.createElement('span');
|
tooltipContent.innerHTML = '';
|
||||||
more.className = 'model-tag-more';
|
|
||||||
more.dataset.count = remainingCount;
|
tags.forEach(tag => {
|
||||||
more.textContent = `+${remainingCount}`;
|
const span = document.createElement('span');
|
||||||
compactTagsDisplay.appendChild(more);
|
span.className = 'tooltip-tag';
|
||||||
|
span.textContent = tag;
|
||||||
|
tooltipContent.appendChild(span);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tooltip content
|
// Exit edit mode
|
||||||
const tooltipContent = compactTagsContainer.querySelector('.tooltip-content');
|
editBtn.click();
|
||||||
if (tooltipContent) {
|
|
||||||
tooltipContent.innerHTML = '';
|
|
||||||
|
|
||||||
tags.forEach(tag => {
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = 'tooltip-tag';
|
|
||||||
span.textContent = tag;
|
|
||||||
tooltipContent.appendChild(span);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit edit mode
|
|
||||||
editBtn.click();
|
|
||||||
|
|
||||||
showToast('modelTags.messages.updated', {}, 'success');
|
showToast('modelTags.messages.updated', {}, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving tags:', error);
|
console.error('Error saving tags:', error);
|
||||||
@@ -470,16 +516,19 @@ function renderPriorityTagSuggestions(container, existingTags = []) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up tag input behavior
|
* Set up tag input behavior
|
||||||
|
* @param {Element} scopeContainer - The .model-tags-container element
|
||||||
*/
|
*/
|
||||||
function setupTagInput() {
|
function setupTagInput(scopeContainer) {
|
||||||
const tagInput = document.querySelector('.metadata-input');
|
const tagInput = scopeContainer
|
||||||
|
? scopeContainer.querySelector('.metadata-input')
|
||||||
|
: document.querySelector('.metadata-input');
|
||||||
|
|
||||||
if (tagInput) {
|
if (tagInput) {
|
||||||
tagInput.focus();
|
tagInput.focus();
|
||||||
tagInput.addEventListener('keydown', function(e) {
|
tagInput.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addNewTag(this.value);
|
addNewTag(this.value, this);
|
||||||
this.value = ''; // Clear input after adding
|
this.value = ''; // Clear input after adding
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -504,9 +553,12 @@ function setupDeleteButtons() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable drag-and-drop sorting for tag items
|
* Enable drag-and-drop sorting for tag items
|
||||||
|
* @param {Element} [scopeContainer] - Optional scoped .model-tags-container element
|
||||||
*/
|
*/
|
||||||
function setupTagDragAndDrop() {
|
function setupTagDragAndDrop(scopeContainer) {
|
||||||
const container = document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
|
const container = scopeContainer
|
||||||
|
? scopeContainer.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR)
|
||||||
|
: document.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR);
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -712,12 +764,14 @@ function finishPointerDrag() {
|
|||||||
/**
|
/**
|
||||||
* Add a new tag
|
* Add a new tag
|
||||||
* @param {string} tag - Tag to add
|
* @param {string} tag - Tag to add
|
||||||
|
* @param {Element} [scopeElement] - Element within the correct .model-tags-container for scoping
|
||||||
*/
|
*/
|
||||||
function addNewTag(tag) {
|
function addNewTag(tag, scopeElement = null) {
|
||||||
tag = tag.trim().toLowerCase();
|
tag = tagEditOptions.normalizeTag ? tag.trim().toLowerCase() : tag.trim();
|
||||||
if (!tag) return;
|
if (!tag) return;
|
||||||
|
|
||||||
const tagsContainer = document.querySelector('.metadata-items');
|
const scope = scopeElement ? scopeElement.closest('.model-tags-container') : document;
|
||||||
|
const tagsContainer = scope.querySelector('.metadata-items');
|
||||||
if (!tagsContainer) return;
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
// Validation: Check length
|
// Validation: Check length
|
||||||
@@ -762,7 +816,7 @@ function addNewTag(tag) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tagsContainer.appendChild(newTag);
|
tagsContainer.appendChild(newTag);
|
||||||
setupTagDragAndDrop();
|
setupTagDragAndDrop(scope);
|
||||||
|
|
||||||
// Update status of items in the suggestions dropdown
|
// Update status of items in the suggestions dropdown
|
||||||
updateSuggestionsDropdown();
|
updateSuggestionsDropdown();
|
||||||
|
|||||||
@@ -78,10 +78,12 @@ export function renderCompactTags(tags, filePath = '') {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up tag tooltip functionality
|
* Set up tag tooltip functionality
|
||||||
|
* @param {Element} [scopeContainer] - Optional container to scope the querySelector
|
||||||
*/
|
*/
|
||||||
export function setupTagTooltip() {
|
export function setupTagTooltip(scopeContainer = null) {
|
||||||
const tagsContainer = document.querySelector('.model-tags-container');
|
const root = scopeContainer || document;
|
||||||
const tooltip = document.querySelector('.model-tags-tooltip');
|
const tagsContainer = root.querySelector('.model-tags-container');
|
||||||
|
const tooltip = root.querySelector('.model-tags-tooltip');
|
||||||
|
|
||||||
if (tagsContainer && tooltip) {
|
if (tagsContainer && tooltip) {
|
||||||
tagsContainer.addEventListener('mouseenter', () => {
|
tagsContainer.addEventListener('mouseenter', () => {
|
||||||
|
|||||||
@@ -327,10 +327,15 @@ export class DoctorManager {
|
|||||||
case 'open-settings':
|
case 'open-settings':
|
||||||
modalManager.showModal('settingsModal');
|
modalManager.showModal('settingsModal');
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const input = document.getElementById('civitaiApiKey');
|
// Open the API key editor directly
|
||||||
if (input) {
|
if (typeof settingsManager.editApiKey === 'function') {
|
||||||
input.focus();
|
settingsManager.editApiKey();
|
||||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
} else {
|
||||||
|
const input = document.getElementById('civitaiApiKey');
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -321,29 +321,94 @@ class MoveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let movedFiles = []; // Array of { original_file_path, new_file_path }
|
||||||
|
|
||||||
if (this.bulkFilePaths) {
|
if (this.bulkFilePaths) {
|
||||||
// Bulk move mode
|
// Bulk move mode
|
||||||
await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||||
|
movedFiles = (results || [])
|
||||||
|
.filter(r => r.success)
|
||||||
|
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
||||||
|
|
||||||
// Deselect moving items
|
// Deselect moving items
|
||||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||||
} else {
|
} else {
|
||||||
// Single move mode
|
// Single move mode
|
||||||
await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
|
||||||
|
if (result) {
|
||||||
|
movedFiles.push({
|
||||||
|
original_file_path: result.original_file_path || this.currentFilePath,
|
||||||
|
new_file_path: result.new_file_path
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Deselect moving item
|
// Deselect moving item
|
||||||
bulkManager.deselectItem(this.currentFilePath);
|
bulkManager.deselectItem(this.currentFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh UI by reloading the current page, same as drag-and-drop behavior
|
// Update VirtualScroller in-place instead of full reload
|
||||||
// This ensures all metadata (like preview URLs) are correctly formatted by the backend
|
if (movedFiles.length > 0 && state.virtualScroller) {
|
||||||
if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') {
|
// Get current page state for folder filter check
|
||||||
await sidebarManager.pageControls.resetAndReload(true);
|
const pageState = getCurrentPageState();
|
||||||
} else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') {
|
const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
await sidebarManager.lastPageControls.resetAndReload(true);
|
const isRecursive = pageState.searchOptions?.recursive ?? true;
|
||||||
|
const isFolderFiltered = pageState.activeFolder !== null;
|
||||||
|
|
||||||
|
// Determine which items are still visible after the move
|
||||||
|
const pathsToRemove = [];
|
||||||
|
const pathsToUpdate = []; // { originalPath, newData }
|
||||||
|
|
||||||
|
for (const moved of movedFiles) {
|
||||||
|
if (!moved.original_file_path) continue;
|
||||||
|
|
||||||
|
if (isFolderFiltered) {
|
||||||
|
// Compute relative folder of the new path
|
||||||
|
const newRelativeFolder = this._getRelativeFolder(moved.new_file_path);
|
||||||
|
const normalizedNewFolder = newRelativeFolder.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Check if the new location is still within the active folder
|
||||||
|
let stillVisible;
|
||||||
|
if (isRecursive) {
|
||||||
|
stillVisible = normalizedActive === '' ||
|
||||||
|
normalizedNewFolder === normalizedActive ||
|
||||||
|
normalizedNewFolder.startsWith(normalizedActive + '/');
|
||||||
|
} else {
|
||||||
|
stillVisible = normalizedNewFolder === normalizedActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stillVisible) {
|
||||||
|
pathsToUpdate.push({
|
||||||
|
originalPath: moved.original_file_path,
|
||||||
|
newData: {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: newRelativeFolder
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pathsToRemove.push(moved.original_file_path);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No folder filter active — items remain visible, just update path
|
||||||
|
pathsToUpdate.push({
|
||||||
|
originalPath: moved.original_file_path,
|
||||||
|
newData: {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: this._getRelativeFolder(moved.new_file_path)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates to the VirtualScroller
|
||||||
|
if (pathsToRemove.length > 0) {
|
||||||
|
state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove);
|
||||||
|
}
|
||||||
|
for (const update of pathsToUpdate) {
|
||||||
|
state.virtualScroller.updateSingleItem(update.originalPath, update.newData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh folder tree in sidebar
|
// Refresh folder tree in sidebar (no model data reload)
|
||||||
await sidebarManager.refresh();
|
await sidebarManager.refresh();
|
||||||
|
|
||||||
modalManager.closeModal('moveModal');
|
modalManager.closeModal('moveModal');
|
||||||
|
|||||||
@@ -347,9 +347,9 @@ export class SettingsManager {
|
|||||||
if (this.isOpen) {
|
if (this.isOpen) {
|
||||||
this.loadSettingsToUI();
|
this.loadSettingsToUI();
|
||||||
} else {
|
} else {
|
||||||
// Clear sensitive fields on close to prevent browser save-password prompts
|
// Reset API key edit mode on close
|
||||||
const apiKeyInput = document.getElementById('civitaiApiKey');
|
this.cancelEditApiKey(true);
|
||||||
if (apiKeyInput) apiKeyInput.value = '';
|
// Clear proxy password on close
|
||||||
const proxyPasswordInput = document.getElementById('proxyPassword');
|
const proxyPasswordInput = document.getElementById('proxyPassword');
|
||||||
if (proxyPasswordInput) proxyPasswordInput.value = '';
|
if (proxyPasswordInput) proxyPasswordInput.value = '';
|
||||||
}
|
}
|
||||||
@@ -825,10 +825,8 @@ export class SettingsManager {
|
|||||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const civitaiApiKeyInput = document.getElementById('civitaiApiKey');
|
// Update API key status display (do NOT pre-fill the input)
|
||||||
if (civitaiApiKeyInput) {
|
this.updateApiKeyStatus();
|
||||||
civitaiApiKeyInput.value = state.global.settings.civitai_api_key || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const civitaiHostSelect = document.getElementById('civitaiHost');
|
const civitaiHostSelect = document.getElementById('civitaiHost');
|
||||||
if (civitaiHostSelect) {
|
if (civitaiHostSelect) {
|
||||||
@@ -2898,16 +2896,97 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CivitAI API Key management ──────────────────────────────
|
||||||
|
|
||||||
|
updateApiKeyStatus() {
|
||||||
|
const hasKey = !!(state.global.settings.civitai_api_key_set ||
|
||||||
|
state.global.settings.civitai_api_key);
|
||||||
|
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
||||||
|
const statusText = document.getElementById('civitaiApiKeyStatusText');
|
||||||
|
const actionBtn = document.getElementById('civitaiApiKeyActionBtn');
|
||||||
|
if (!statusText || !actionBtn) return;
|
||||||
|
|
||||||
|
if (hasKey) {
|
||||||
|
statusText.classList.remove('api-key-status--unconfigured');
|
||||||
|
statusText.classList.add('api-key-status--configured');
|
||||||
|
statusText.innerHTML = '<i class="fas fa-check-circle text-success"></i> '
|
||||||
|
+ translate('settings.civitaiApiKeyConfigured', {}, 'Configured');
|
||||||
|
actionBtn.textContent = translate('common.actions.change', {}, 'Change');
|
||||||
|
} else {
|
||||||
|
statusText.classList.remove('api-key-status--configured');
|
||||||
|
statusText.classList.add('api-key-status--unconfigured');
|
||||||
|
statusText.innerHTML = '<i class="fas fa-times-circle text-error"></i> '
|
||||||
|
+ translate('settings.civitaiApiKeyNotConfigured', {}, 'Not configured');
|
||||||
|
actionBtn.textContent = translate('settings.civitaiApiKeySet', {}, 'Set up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editApiKey() {
|
||||||
|
const statusEl = document.getElementById('civitaiApiKeyStatus');
|
||||||
|
if (statusEl) statusEl.classList.add('is-hidden');
|
||||||
|
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
||||||
|
if (editContainer) editContainer.classList.remove('is-hidden');
|
||||||
|
// Focus the input
|
||||||
|
const input = document.getElementById('civitaiApiKey');
|
||||||
|
if (input) {
|
||||||
|
input.value = ''; // Never pre-fill the secret
|
||||||
|
setTimeout(() => input.focus(), 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEditApiKey(silent) {
|
||||||
|
const editContainer = document.getElementById('civitaiApiKeyEdit');
|
||||||
|
if (editContainer) editContainer.classList.add('is-hidden');
|
||||||
|
const statusContainer = document.getElementById('civitaiApiKeyStatus');
|
||||||
|
if (statusContainer) statusContainer.classList.remove('is-hidden');
|
||||||
|
// Clear any typed value
|
||||||
|
const input = document.getElementById('civitaiApiKey');
|
||||||
|
if (input) input.value = '';
|
||||||
|
if (!silent) {
|
||||||
|
this.updateApiKeyStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveApiKey() {
|
||||||
|
const input = document.getElementById('civitaiApiKey');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const value = input.value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.saveSetting('civitai_api_key', value);
|
||||||
|
showToast('toast.settings.settingsUpdated',
|
||||||
|
{ setting: 'CivitAI API Key' }, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast('toast.settings.settingSaveFailed',
|
||||||
|
{ message: error.message }, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the in-memory flag so the UI reflects the change
|
||||||
|
state.global.settings.civitai_api_key_set = !!value;
|
||||||
|
this.cancelEditApiKey(true);
|
||||||
|
this.updateApiKeyStatus();
|
||||||
|
}
|
||||||
|
|
||||||
toggleInputVisibility(button) {
|
toggleInputVisibility(button) {
|
||||||
const input = button.parentElement.querySelector('input');
|
const input = button.parentElement.querySelector('input');
|
||||||
|
if (!input) return;
|
||||||
const icon = button.querySelector('i');
|
const icon = button.querySelector('i');
|
||||||
|
if (input.dataset.mask === 'css') {
|
||||||
if (input.type === 'password') {
|
// CSS-masked input (CivitAI API key) — toggle class, not type
|
||||||
|
input.classList.toggle('api-key-masked');
|
||||||
|
if (icon) {
|
||||||
|
icon.className = input.classList.contains('api-key-masked')
|
||||||
|
? 'fas fa-eye'
|
||||||
|
: 'fas fa-eye-slash';
|
||||||
|
}
|
||||||
|
} else if (input.type === 'password') {
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
icon.className = 'fas fa-eye-slash';
|
if (icon) icon.className = 'fas fa-eye-slash';
|
||||||
} else {
|
} else {
|
||||||
input.type = 'password';
|
input.type = 'password';
|
||||||
icon.className = 'fas fa-eye';
|
if (icon) icon.className = 'fas fa-eye';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
|
|||||||
|
|
||||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||||
civitai_api_key: '',
|
civitai_api_key: '',
|
||||||
|
civitai_api_key_set: false,
|
||||||
civitai_host: 'civitai.com',
|
civitai_host: 'civitai.com',
|
||||||
download_backend: 'python',
|
download_backend: 'python',
|
||||||
aria2c_path: '',
|
aria2c_path: '',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Statistics page functionality
|
// Statistics page functionality
|
||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { showToast } from './utils/uiHelpers.js';
|
import { showToast } from './utils/uiHelpers.js';
|
||||||
|
import { translate } from './utils/i18nHelpers.js';
|
||||||
|
import { i18n } from './i18n/index.js';
|
||||||
|
|
||||||
// Chart.js import (assuming it's available globally or via CDN)
|
// Chart.js import (assuming it's available globally or via CDN)
|
||||||
// If Chart.js isn't available, we'll need to add it to the project
|
// If Chart.js isn't available, we'll need to add it to the project
|
||||||
@@ -124,43 +126,43 @@ export class StatisticsManager {
|
|||||||
{
|
{
|
||||||
icon: 'fas fa-magic',
|
icon: 'fas fa-magic',
|
||||||
value: this.data.collection.total_models,
|
value: this.data.collection.total_models,
|
||||||
label: 'Total Models',
|
label: translate('statistics.metrics.totalModels'),
|
||||||
format: 'number'
|
format: 'number'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-database',
|
icon: 'fas fa-database',
|
||||||
value: this.data.collection.total_size,
|
value: this.data.collection.total_size,
|
||||||
label: 'Total Storage',
|
label: translate('statistics.metrics.totalStorage'),
|
||||||
format: 'size'
|
format: 'size'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-play-circle',
|
icon: 'fas fa-play-circle',
|
||||||
value: this.data.collection.total_generations,
|
value: this.data.collection.total_generations,
|
||||||
label: 'Total Generations',
|
label: translate('statistics.metrics.totalGenerations'),
|
||||||
format: 'number'
|
format: 'number'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-chart-line',
|
icon: 'fas fa-chart-line',
|
||||||
value: this.calculateUsageRate(),
|
value: this.calculateUsageRate(),
|
||||||
label: 'Usage Rate',
|
label: translate('statistics.metrics.usageRate'),
|
||||||
format: 'percentage'
|
format: 'percentage'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-layer-group',
|
icon: 'fas fa-layer-group',
|
||||||
value: this.data.collection.lora_count,
|
value: this.data.collection.lora_count,
|
||||||
label: 'LoRAs',
|
label: translate('statistics.metrics.loras'),
|
||||||
format: 'number'
|
format: 'number'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-check-circle',
|
icon: 'fas fa-check-circle',
|
||||||
value: this.data.collection.checkpoint_count,
|
value: this.data.collection.checkpoint_count,
|
||||||
label: 'Checkpoints',
|
label: translate('statistics.metrics.checkpoints'),
|
||||||
format: 'number'
|
format: 'number'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-code',
|
icon: 'fas fa-code',
|
||||||
value: this.data.collection.embedding_count,
|
value: this.data.collection.embedding_count,
|
||||||
label: 'Embeddings',
|
label: translate('statistics.metrics.embeddings'),
|
||||||
format: 'number'
|
format: 'number'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -189,18 +191,14 @@ export class StatisticsManager {
|
|||||||
case 'size':
|
case 'size':
|
||||||
return this.formatFileSize(value);
|
return this.formatFileSize(value);
|
||||||
case 'percentage':
|
case 'percentage':
|
||||||
return `${value.toFixed(1)}%`;
|
return new Intl.NumberFormat(i18n.getCurrentLocale(), { style: 'percent', maximumFractionDigits: 1 }).format(value / 100);
|
||||||
default:
|
default:
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
formatFileSize(bytes) {
|
formatFileSize(bytes) {
|
||||||
if (bytes === 0) return '0 Bytes';
|
return i18n.formatFileSize(bytes);
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
calculateUsageRate() {
|
calculateUsageRate() {
|
||||||
@@ -250,7 +248,7 @@ export class StatisticsManager {
|
|||||||
if (!ctx || !this.data.collection) return;
|
if (!ctx || !this.data.collection) return;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
|
labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [
|
data: [
|
||||||
this.data.collection.lora_count,
|
this.data.collection.lora_count,
|
||||||
@@ -290,28 +288,28 @@ export class StatisticsManager {
|
|||||||
const checkpointData = this.data.baseModels.checkpoints;
|
const checkpointData = this.data.baseModels.checkpoints;
|
||||||
const embeddingData = this.data.baseModels.embeddings;
|
const embeddingData = this.data.baseModels.embeddings;
|
||||||
|
|
||||||
const allModels = new Set([
|
const allModels = Array.from(new Set([
|
||||||
...Object.keys(loraData),
|
...Object.keys(loraData),
|
||||||
...Object.keys(checkpointData),
|
...Object.keys(checkpointData),
|
||||||
...Object.keys(embeddingData)
|
...Object.keys(embeddingData)
|
||||||
]);
|
])).sort();
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: Array.from(allModels),
|
labels: allModels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'LoRAs',
|
label: translate('statistics.metrics.loras'),
|
||||||
data: Array.from(allModels).map(model => loraData[model] || 0),
|
data: allModels.map(model => loraData[model] || 0),
|
||||||
backgroundColor: 'oklch(68% 0.28 256 / 0.7)'
|
backgroundColor: 'oklch(68% 0.28 256 / 0.7)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Checkpoints',
|
label: translate('statistics.metrics.checkpoints'),
|
||||||
data: Array.from(allModels).map(model => checkpointData[model] || 0),
|
data: allModels.map(model => checkpointData[model] || 0),
|
||||||
backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
|
backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Embeddings',
|
label: translate('statistics.metrics.embeddings'),
|
||||||
data: Array.from(allModels).map(model => embeddingData[model] || 0),
|
data: allModels.map(model => embeddingData[model] || 0),
|
||||||
backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
|
backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -345,21 +343,21 @@ export class StatisticsManager {
|
|||||||
labels: timeline.map(item => new Date(item.date).toLocaleDateString()),
|
labels: timeline.map(item => new Date(item.date).toLocaleDateString()),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'LoRA Usage',
|
label: translate('statistics.charts.loraUsage'),
|
||||||
data: timeline.map(item => item.lora_usage),
|
data: timeline.map(item => item.lora_usage),
|
||||||
borderColor: 'oklch(68% 0.28 256)',
|
borderColor: 'oklch(68% 0.28 256)',
|
||||||
backgroundColor: 'oklch(68% 0.28 256 / 0.1)',
|
backgroundColor: 'oklch(68% 0.28 256 / 0.1)',
|
||||||
fill: true
|
fill: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Checkpoint Usage',
|
label: translate('statistics.charts.checkpointUsage'),
|
||||||
data: timeline.map(item => item.checkpoint_usage),
|
data: timeline.map(item => item.checkpoint_usage),
|
||||||
borderColor: 'oklch(68% 0.28 200)',
|
borderColor: 'oklch(68% 0.28 200)',
|
||||||
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
|
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
|
||||||
fill: true
|
fill: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Embedding Usage',
|
label: translate('statistics.charts.embeddingUsage'),
|
||||||
data: timeline.map(item => item.embedding_usage),
|
data: timeline.map(item => item.embedding_usage),
|
||||||
borderColor: 'oklch(68% 0.28 120)',
|
borderColor: 'oklch(68% 0.28 120)',
|
||||||
backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
|
backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
|
||||||
@@ -383,14 +381,14 @@ export class StatisticsManager {
|
|||||||
display: true,
|
display: true,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Date'
|
text: translate('statistics.charts.date')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
display: true,
|
display: true,
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Usage Count'
|
text: translate('statistics.charts.usageCount')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,7 +414,7 @@ export class StatisticsManager {
|
|||||||
const data = {
|
const data = {
|
||||||
labels: allModels.map(model => model.name),
|
labels: allModels.map(model => model.name),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Usage Count',
|
label: translate('statistics.charts.usageCount'),
|
||||||
data: allModels.map(model => model.usage_count),
|
data: allModels.map(model => model.usage_count),
|
||||||
backgroundColor: allModels.map(model => {
|
backgroundColor: allModels.map(model => {
|
||||||
switch(model.type) {
|
switch(model.type) {
|
||||||
@@ -450,7 +448,7 @@ export class StatisticsManager {
|
|||||||
if (!ctx || !this.data.collection) return;
|
if (!ctx || !this.data.collection) return;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
|
labels: [translate('statistics.metrics.loras'), translate('statistics.metrics.checkpoints'), translate('statistics.metrics.embeddings')],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [
|
data: [
|
||||||
this.data.collection.lora_size,
|
this.data.collection.lora_size,
|
||||||
@@ -504,7 +502,7 @@ export class StatisticsManager {
|
|||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Models',
|
label: translate('statistics.charts.models'),
|
||||||
data: allData.map(item => ({
|
data: allData.map(item => ({
|
||||||
x: item.size,
|
x: item.size,
|
||||||
y: item.usage_count,
|
y: item.usage_count,
|
||||||
@@ -532,14 +530,14 @@ export class StatisticsManager {
|
|||||||
x: {
|
x: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'File Size (bytes)'
|
text: translate('statistics.charts.fileSizeBytes')
|
||||||
},
|
},
|
||||||
type: 'logarithmic'
|
type: 'logarithmic'
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Usage Count'
|
text: translate('statistics.charts.usageCount')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -548,7 +546,7 @@ export class StatisticsManager {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
label: (context) => {
|
label: (context) => {
|
||||||
const point = context.raw;
|
const point = context.raw;
|
||||||
return `${point.name}: ${this.formatFileSize(point.x)}, ${point.y} uses`;
|
return translate('statistics.tooltips.chartUsage', { name: point.name, size: this.formatFileSize(point.x), count: point.y });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,12 +561,12 @@ export class StatisticsManager {
|
|||||||
|
|
||||||
const distribution = this.data.collection.model_types_distribution;
|
const distribution = this.data.collection.model_types_distribution;
|
||||||
const typeDisplayNames = {
|
const typeDisplayNames = {
|
||||||
lora: 'LoRA',
|
lora: translate('statistics.modelTypes.lora'),
|
||||||
locon: 'LyCORIS',
|
locon: translate('statistics.modelTypes.locon'),
|
||||||
dora: 'DoRA',
|
dora: translate('statistics.modelTypes.dora'),
|
||||||
checkpoint: 'Checkpoint',
|
checkpoint: translate('statistics.modelTypes.checkpoint'),
|
||||||
diffusion_model: 'Diffusion Model',
|
diffusion_model: translate('statistics.modelTypes.diffusion_model'),
|
||||||
embedding: 'Embeddings'
|
embedding: translate('statistics.modelTypes.embedding')
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorPalette = {
|
const colorPalette = {
|
||||||
@@ -610,7 +608,7 @@ export class StatisticsManager {
|
|||||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||||
const value = context.parsed;
|
const value = context.parsed;
|
||||||
const pct = ((value / total) * 100).toFixed(1);
|
const pct = ((value / total) * 100).toFixed(1);
|
||||||
return ` ${context.label}: ${value} (${pct}%)`;
|
return translate('statistics.tooltips.chartPercentage', { label: context.label, value, pct });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,7 +652,7 @@ export class StatisticsManager {
|
|||||||
|
|
||||||
// Show loading indicator on initial load
|
// Show loading indicator on initial load
|
||||||
if (state.offset === 0) {
|
if (state.offset === 0) {
|
||||||
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> ' + translate('statistics.placeholders.loading') + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -670,7 +668,7 @@ export class StatisticsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (items.length === 0 && state.offset === 0) {
|
if (items.length === 0 && state.offset === 0) {
|
||||||
container.innerHTML = '<div class="loading-placeholder">No models found</div>';
|
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noModels') + '</div>';
|
||||||
state.hasMore = false;
|
state.hasMore = false;
|
||||||
} else if (items.length < state.limit) {
|
} else if (items.length < state.limit) {
|
||||||
state.hasMore = false;
|
state.hasMore = false;
|
||||||
@@ -683,7 +681,7 @@ export class StatisticsManager {
|
|||||||
onerror="this.src='/loras_static/images/no-preview.png'">
|
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<div class="model-name" title="${model.name}">${model.name}</div>
|
<div class="model-name" title="${model.name}">${model.name}</div>
|
||||||
<div class="model-meta">${model.base_model} • ${model.folder || 'Root'}</div>
|
<div class="model-meta">${model.base_model} • ${model.folder || translate('statistics.placeholders.rootFolder')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="model-usage">${model.usage_count}</div>
|
<div class="model-usage">${model.usage_count}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -695,7 +693,7 @@ export class StatisticsManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading ${type} list:`, error);
|
console.error(`Error loading ${type} list:`, error);
|
||||||
if (state.offset === 0) {
|
if (state.offset === 0) {
|
||||||
container.innerHTML = '<div class="loading-placeholder">Error loading data</div>';
|
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.errorLoading') + '</div>';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
@@ -718,7 +716,7 @@ export class StatisticsManager {
|
|||||||
].sort((a, b) => b.size - a.size).slice(0, 10);
|
].sort((a, b) => b.size - a.size).slice(0, 10);
|
||||||
|
|
||||||
if (allModels.length === 0) {
|
if (allModels.length === 0) {
|
||||||
container.innerHTML = '<div class="loading-placeholder">No storage data available</div>';
|
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.placeholders.noStorageData') + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,7 +724,7 @@ export class StatisticsManager {
|
|||||||
<div class="model-item">
|
<div class="model-item">
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<div class="model-name" title="${model.name}">${model.name}</div>
|
<div class="model-name" title="${model.name}">${model.name}</div>
|
||||||
<div class="model-meta">${model.type} • ${model.base_model}</div>
|
<div class="model-meta">${translate('statistics.modelTypes.' + model.type.toLowerCase())} • ${model.base_model}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="model-usage">${this.formatFileSize(model.size)}</div>
|
<div class="model-usage">${this.formatFileSize(model.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -744,7 +742,7 @@ export class StatisticsManager {
|
|||||||
const size = Math.ceil((tagData.count / maxCount) * 5);
|
const size = Math.ceil((tagData.count / maxCount) * 5);
|
||||||
return `
|
return `
|
||||||
<span class="tag-cloud-item size-${size}"
|
<span class="tag-cloud-item size-${size}"
|
||||||
title="${tagData.tag}: ${tagData.count} models">
|
title="${translate('statistics.tooltips.tagCount', { tag: tagData.tag, count: tagData.count })}">
|
||||||
${tagData.tag}
|
${tagData.tag}
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
@@ -758,17 +756,30 @@ export class StatisticsManager {
|
|||||||
const insights = this.data.insights.insights;
|
const insights = this.data.insights.insights;
|
||||||
|
|
||||||
if (insights.length === 0) {
|
if (insights.length === 0) {
|
||||||
container.innerHTML = '<div class="loading-placeholder">No insights available</div>';
|
container.innerHTML = '<div class="loading-placeholder">' + translate('statistics.insights.noInsights') + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = insights.map(insight => `
|
container.innerHTML = insights.map(insight => {
|
||||||
|
const params = insight.params || {};
|
||||||
|
let title, description, suggestion;
|
||||||
|
if (insight.key) {
|
||||||
|
title = translate('statistics.' + insight.key + '.title', params);
|
||||||
|
description = translate('statistics.' + insight.key + '.description', params);
|
||||||
|
suggestion = translate('statistics.' + insight.key + '.suggestion', params);
|
||||||
|
} else {
|
||||||
|
// Backward compatibility for insights without key/params
|
||||||
|
title = insight.title || '';
|
||||||
|
description = insight.description || '';
|
||||||
|
suggestion = insight.suggestion || '';
|
||||||
|
}
|
||||||
|
return `
|
||||||
<div class="insight-card type-${insight.type}">
|
<div class="insight-card type-${insight.type}">
|
||||||
<div class="insight-title">${insight.title}</div>
|
<div class="insight-title">${title}</div>
|
||||||
<div class="insight-description">${insight.description}</div>
|
<div class="insight-description">${description}</div>
|
||||||
<div class="insight-suggestion">${insight.suggestion}</div>
|
<div class="insight-suggestion">${suggestion}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`}).join('');
|
||||||
|
|
||||||
// Render collection analysis cards
|
// Render collection analysis cards
|
||||||
this.renderCollectionAnalysis();
|
this.renderCollectionAnalysis();
|
||||||
@@ -782,25 +793,25 @@ export class StatisticsManager {
|
|||||||
{
|
{
|
||||||
icon: 'fas fa-percentage',
|
icon: 'fas fa-percentage',
|
||||||
value: this.calculateUsageRate(),
|
value: this.calculateUsageRate(),
|
||||||
label: 'Usage Rate',
|
label: translate('statistics.metrics.usageRate'),
|
||||||
format: 'percentage'
|
format: 'percentage'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-tags',
|
icon: 'fas fa-tags',
|
||||||
value: this.data.tags?.total_unique_tags || 0,
|
value: this.data.tags?.total_unique_tags || 0,
|
||||||
label: 'Unique Tags',
|
label: translate('statistics.metrics.uniqueTags'),
|
||||||
format: 'number'
|
format: 'number'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-clock',
|
icon: 'fas fa-clock',
|
||||||
value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints,
|
value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints,
|
||||||
label: 'Unused Models',
|
label: translate('statistics.metrics.unusedModels'),
|
||||||
format: 'number'
|
format: 'number'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'fas fa-chart-line',
|
icon: 'fas fa-chart-line',
|
||||||
value: this.calculateAverageUsage(),
|
value: this.calculateAverageUsage(),
|
||||||
label: 'Avg. Uses/Model',
|
label: translate('statistics.metrics.avgUsesPerModel'),
|
||||||
format: 'decimal'
|
format: 'decimal'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -829,7 +840,7 @@ export class StatisticsManager {
|
|||||||
const chartCanvases = document.querySelectorAll('canvas');
|
const chartCanvases = document.querySelectorAll('canvas');
|
||||||
chartCanvases.forEach(canvas => {
|
chartCanvases.forEach(canvas => {
|
||||||
const container = canvas.parentElement;
|
const container = canvas.parentElement;
|
||||||
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> Chart requires Chart.js library</div>';
|
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> ' + translate('statistics.placeholders.chartLibraryMissing') + '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -931,6 +931,38 @@ export class VirtualScroller {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove multiple items by their file paths.
|
||||||
|
* More efficient than calling removeItemByFilePath individually.
|
||||||
|
* @param {string[]} filePaths - Array of file paths to remove
|
||||||
|
* @returns {boolean} - True if any items were removed
|
||||||
|
*/
|
||||||
|
removeMultipleItemsByFilePath(filePaths) {
|
||||||
|
if (!Array.isArray(filePaths) || filePaths.length === 0 || this.disabled || this.items.length === 0) return false;
|
||||||
|
|
||||||
|
// Build a set for fast lookup
|
||||||
|
const pathsToRemove = new Set(filePaths);
|
||||||
|
const originalLength = this.items.length;
|
||||||
|
|
||||||
|
// Filter out removed items; keep those not in the set
|
||||||
|
this.items = this.items.filter(item => !pathsToRemove.has(item.file_path));
|
||||||
|
|
||||||
|
const removedCount = originalLength - this.items.length;
|
||||||
|
if (removedCount === 0) return false;
|
||||||
|
|
||||||
|
this.totalItems = Math.max(0, this.totalItems - removedCount);
|
||||||
|
|
||||||
|
// Update the spacer height
|
||||||
|
this.updateSpacerHeight();
|
||||||
|
|
||||||
|
// Re-render to fill gaps left by removed items
|
||||||
|
this.clearRenderedItems();
|
||||||
|
this.scheduleRender();
|
||||||
|
|
||||||
|
console.log(`Removed ${removedCount} items from virtual scroller data`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Add keyboard navigation methods
|
// Add keyboard navigation methods
|
||||||
handlePageUpDown(direction) {
|
handlePageUpDown(direction) {
|
||||||
// Prevent duplicate animations by checking last trigger time
|
// Prevent duplicate animations by checking last trigger time
|
||||||
|
|||||||
@@ -95,21 +95,36 @@
|
|||||||
<div class="setting-item api-key-item">
|
<div class="setting-item api-key-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label for="civitaiApiKey">{{ t('settings.civitaiApiKey') }}</label>
|
<label>{{ t('settings.civitaiApiKey') }}</label>
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i>
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control">
|
<div class="setting-control">
|
||||||
<div class="api-key-input">
|
<!-- Status display (shown when not editing) -->
|
||||||
<input type="password"
|
<div id="civitaiApiKeyStatus" class="api-key-status">
|
||||||
id="civitaiApiKey"
|
<span id="civitaiApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
|
||||||
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
|
<i class="fas fa-times-circle text-error"></i>
|
||||||
autocomplete="new-password"
|
{{ t('settings.civitaiApiKeyNotConfigured') }}
|
||||||
onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')"
|
</span>
|
||||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
<button type="button" class="secondary-btn" id="civitaiApiKeyActionBtn" onclick="settingsManager.editApiKey()">
|
||||||
<button class="toggle-visibility">
|
{{ t('settings.civitaiApiKeySet') }}
|
||||||
<i class="fas fa-eye"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Inline edit view (shown when editing) -->
|
||||||
|
<div id="civitaiApiKeyEdit" class="api-key-edit is-hidden">
|
||||||
|
<div class="api-key-input">
|
||||||
|
<input type="text"
|
||||||
|
id="civitaiApiKey"
|
||||||
|
class="api-key-masked"
|
||||||
|
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
|
||||||
|
autocomplete="off"
|
||||||
|
data-mask="css" />
|
||||||
|
<button type="button" class="toggle-visibility">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey()">{{ t('common.actions.save') }}</button>
|
||||||
|
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey()">{{ t('common.actions.cancel') }}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,13 +6,8 @@
|
|||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
<!-- Header Actions: populated dynamically in RecipeModal.js -->
|
||||||
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
|
||||||
<!-- Recipe Tags Container -->
|
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
@@ -246,12 +246,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -375,12 +370,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -474,12 +464,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -588,12 +573,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -682,12 +662,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -790,12 +765,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -873,12 +843,10 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
recipeModal.markFieldDirty('title');
|
recipeModal.markFieldDirty('title');
|
||||||
recipeModal.markFieldDirty('tags');
|
|
||||||
recipeModal.markFieldDirty('prompt');
|
recipeModal.markFieldDirty('prompt');
|
||||||
recipeModal.markFieldDirty('negative_prompt');
|
recipeModal.markFieldDirty('negative_prompt');
|
||||||
|
|
||||||
document.querySelector('#recipeTitleEditor .title-input').value = 'Local Title';
|
document.querySelector('#recipeTitleEditor .title-input').value = 'Local Title';
|
||||||
document.querySelector('#recipeTagsEditor .tags-input').value = 'local-tag-1, local-tag-2';
|
|
||||||
document.getElementById('recipePromptInput').value = 'local prompt';
|
document.getElementById('recipePromptInput').value = 'local prompt';
|
||||||
document.getElementById('recipeNegativePromptInput').value = 'local negative';
|
document.getElementById('recipeNegativePromptInput').value = 'local negative';
|
||||||
|
|
||||||
@@ -899,7 +867,6 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
|
|
||||||
expect(document.querySelector('#recipeTitleEditor .title-input').value).toBe('Local Title');
|
expect(document.querySelector('#recipeTitleEditor .title-input').value).toBe('Local Title');
|
||||||
expect(document.querySelector('#recipeTagsEditor .tags-input').value).toBe('local-tag-1, local-tag-2');
|
|
||||||
expect(document.getElementById('recipePromptInput').value).toBe('local prompt');
|
expect(document.getElementById('recipePromptInput').value).toBe('local prompt');
|
||||||
expect(document.getElementById('recipeNegativePromptInput').value).toBe('local negative');
|
expect(document.getElementById('recipeNegativePromptInput').value).toBe('local negative');
|
||||||
expect(recipeModal.currentRecipe.title).toBe('Hydrated Title');
|
expect(recipeModal.currentRecipe.title).toBe('Hydrated Title');
|
||||||
@@ -918,12 +885,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -1057,12 +1019,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -1170,8 +1127,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div id="recipeModal" class="modal">
|
<div id="recipeModal" class="modal">
|
||||||
<div id="recipeModalTitle"></div>
|
<div id="recipeModalTitle"></div>
|
||||||
<div id="recipePreviewContainer"></div>
|
<div id="recipePreviewContainer"></div>
|
||||||
<div id="recipeTagsCompact"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
<div id="recipeTagsTooltip"><div id="recipeTagsTooltipContent"></div></div>
|
|
||||||
<div id="recipePrompt"></div>
|
<div id="recipePrompt"></div>
|
||||||
<textarea id="recipePromptInput"></textarea>
|
<textarea id="recipePromptInput"></textarea>
|
||||||
<div id="recipeNegativePrompt"></div>
|
<div id="recipeNegativePrompt"></div>
|
||||||
@@ -1224,8 +1180,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div id="recipeModal" class="modal">
|
<div id="recipeModal" class="modal">
|
||||||
<div id="recipeModalTitle"></div>
|
<div id="recipeModalTitle"></div>
|
||||||
<div id="recipePreviewContainer"></div>
|
<div id="recipePreviewContainer"></div>
|
||||||
<div id="recipeTagsCompact"></div>
|
<div id="recipeTagsContainer"></div>
|
||||||
<div id="recipeTagsTooltip"><div id="recipeTagsTooltipContent"></div></div>
|
|
||||||
<div id="recipePrompt"></div>
|
<div id="recipePrompt"></div>
|
||||||
<textarea id="recipePromptInput"></textarea>
|
<textarea id="recipePromptInput"></textarea>
|
||||||
<div id="recipeNegativePrompt"></div>
|
<div id="recipeNegativePrompt"></div>
|
||||||
@@ -1300,12 +1255,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -1418,12 +1368,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -1541,12 +1486,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -1654,12 +1594,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -1776,12 +1711,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -1878,12 +1808,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
@@ -2007,12 +1932,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<header class="recipe-modal-header">
|
<header class="recipe-modal-header">
|
||||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
<div class="recipe-tags-container">
|
<div id="recipeTagsContainer"></div>
|
||||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
|
||||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
|
||||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="recipe-top-section">
|
<div class="recipe-top-section">
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ FALSE_POSITIVES = {
|
|||||||
"array",
|
"array",
|
||||||
"object",
|
"object",
|
||||||
"non.existent.key",
|
"non.existent.key",
|
||||||
|
"statistics.modelTypes.",
|
||||||
|
"statistics.",
|
||||||
}
|
}
|
||||||
|
|
||||||
SPECIAL_UI_HELPER_KEYS = {
|
SPECIAL_UI_HELPER_KEYS = {
|
||||||
|
|||||||
@@ -733,6 +733,65 @@ def test_lora_manager_cache_updates_when_loras_removed(metadata_registry):
|
|||||||
assert "lora_node" not in metadata[LORAS]
|
assert "lora_node" not in metadata[LORAS]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lora_text_loader_extracts_loras_from_syntax(metadata_registry):
|
||||||
|
"""LoraTextLoaderLM extractor parses <lora:name:strength> tags from lora_syntax string."""
|
||||||
|
metadata_registry.start_collection("prompt1")
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"text_loader",
|
||||||
|
"LoraTextLoaderLM",
|
||||||
|
{"lora_syntax": ["<lora:foo:0.8> <lora:bar:1.0>"]},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt1")
|
||||||
|
|
||||||
|
assert "text_loader" in metadata[LORAS]
|
||||||
|
lora_list = metadata[LORAS]["text_loader"]["lora_list"]
|
||||||
|
assert len(lora_list) == 2
|
||||||
|
assert lora_list[0] == {"name": "foo", "strength": 0.8}
|
||||||
|
assert lora_list[1] == {"name": "bar", "strength": 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lora_text_loader_extracts_loras_from_lora_stack(metadata_registry):
|
||||||
|
"""LoraTextLoaderLM extractor also processes the optional lora_stack input."""
|
||||||
|
metadata_registry.start_collection("prompt1")
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"stack_loader",
|
||||||
|
"LoraTextLoaderLM",
|
||||||
|
{
|
||||||
|
"lora_syntax": [""],
|
||||||
|
"lora_stack": (("/models/loras/my-lora.safetensors", 0.6, 0.5),),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt1")
|
||||||
|
|
||||||
|
assert "stack_loader" in metadata[LORAS]
|
||||||
|
lora_list = metadata[LORAS]["stack_loader"]["lora_list"]
|
||||||
|
assert len(lora_list) == 1
|
||||||
|
assert lora_list[0] == {"name": "my-lora", "strength": 0.6}
|
||||||
|
|
||||||
|
|
||||||
|
def test_lora_text_loader_handles_empty_syntax(metadata_registry):
|
||||||
|
"""LoraTextLoaderLM extractor produces no metadata when no loras are provided."""
|
||||||
|
metadata_registry.start_collection("prompt1")
|
||||||
|
|
||||||
|
metadata_registry.record_node_execution(
|
||||||
|
"empty_loader",
|
||||||
|
"LoraTextLoaderLM",
|
||||||
|
{"lora_syntax": [""]},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = metadata_registry.get_metadata("prompt1")
|
||||||
|
|
||||||
|
assert "empty_loader" not in metadata[LORAS]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_registry):
|
def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_registry):
|
||||||
metadata_registry.start_collection("prompt1")
|
metadata_registry.start_collection("prompt1")
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
'messages': list([
|
'messages': list([
|
||||||
]),
|
]),
|
||||||
'settings': dict({
|
'settings': dict({
|
||||||
'civitai_api_key': 'test-key',
|
'civitai_api_key_set': True,
|
||||||
'language': 'en',
|
'language': 'en',
|
||||||
'theme': 'dark',
|
'theme': 'dark',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -134,8 +134,10 @@ async def test_get_settings_excludes_no_sync_keys():
|
|||||||
|
|
||||||
assert payload["success"] is True
|
assert payload["success"] is True
|
||||||
# Regular settings should be synced
|
# Regular settings should be synced
|
||||||
assert payload["settings"]["civitai_api_key"] == "abc"
|
|
||||||
assert payload["settings"]["regular_setting"] == "value"
|
assert payload["settings"]["regular_setting"] == "value"
|
||||||
|
# civitai_api_key is in _NO_SYNC_KEYS; only the boolean flag is returned
|
||||||
|
assert payload["settings"].get("civitai_api_key") is None
|
||||||
|
assert payload["settings"]["civitai_api_key_set"] is True
|
||||||
# _NO_SYNC_KEYS should not be synced
|
# _NO_SYNC_KEYS should not be synced
|
||||||
assert "hash_chunk_size_mb" not in payload["settings"]
|
assert "hash_chunk_size_mb" not in payload["settings"]
|
||||||
assert "folder_paths" not in payload["settings"]
|
assert "folder_paths" not in payload["settings"]
|
||||||
|
|||||||
@@ -302,15 +302,15 @@ async def test_get_insights(stats_routes):
|
|||||||
insights = payload["data"]["insights"]
|
insights = payload["data"]["insights"]
|
||||||
assert len(insights) == 3
|
assert len(insights) == 3
|
||||||
|
|
||||||
titles = {entry["title"] for entry in insights}
|
keys = {entry["key"] for entry in insights}
|
||||||
assert "High Number of Unused LoRAs" in titles
|
assert "insights.unusedLoras.high" in keys
|
||||||
assert "Unused Checkpoints Detected" in titles
|
assert "insights.unusedCheckpoints.detected" in keys
|
||||||
assert "High Number of Unused Embeddings" in titles
|
assert "insights.unusedEmbeddings.high" in keys
|
||||||
|
|
||||||
descriptions = {entry["description"] for entry in insights}
|
params_list = [entry["params"] for entry in insights]
|
||||||
assert any("2/3" in desc for desc in descriptions)
|
assert any(p["total"] == "3" for p in params_list)
|
||||||
assert any("1/2" in desc for desc in descriptions)
|
assert any(p["total"] == "2" for p in params_list)
|
||||||
assert any("1/1" in desc for desc in descriptions)
|
assert any(p["total"] == "1" for p in params_list)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
from py.services.settings_manager import get_settings_manager
|
from py.services.settings_manager import get_settings_manager
|
||||||
from py.utils.example_images_paths import (
|
from py.utils.example_images_paths import (
|
||||||
ensure_library_root_exists,
|
ensure_library_root_exists,
|
||||||
|
find_non_compliant_items_in_example_images_root,
|
||||||
get_model_folder,
|
get_model_folder,
|
||||||
get_model_relative_path,
|
get_model_relative_path,
|
||||||
is_valid_example_images_root,
|
is_valid_example_images_root,
|
||||||
@@ -140,3 +141,68 @@ def test_is_valid_example_images_root_accepts_legacy_library_structure(tmp_path,
|
|||||||
(hash_folder / 'image.png').write_text('data', encoding='utf-8')
|
(hash_folder / 'image.png').write_text('data', encoding='utf-8')
|
||||||
|
|
||||||
assert is_valid_example_images_root(str(tmp_path)) is True
|
assert is_valid_example_images_root(str(tmp_path)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_non_compliant_items_returns_empty_for_valid_root(tmp_path, settings_manager):
|
||||||
|
"""An empty folder or one with only hash dirs should return []."""
|
||||||
|
settings_manager.settings['example_images_path'] = str(tmp_path)
|
||||||
|
|
||||||
|
# Empty folder
|
||||||
|
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||||
|
|
||||||
|
# Only hash folders
|
||||||
|
hash_folder = tmp_path / ('f' * 64)
|
||||||
|
hash_folder.mkdir()
|
||||||
|
(hash_folder / 'image.png').write_text('data', encoding='utf-8')
|
||||||
|
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_non_compliant_items_returns_offending_names(tmp_path, settings_manager):
|
||||||
|
"""A folder with non-hash items should return their names."""
|
||||||
|
settings_manager.settings['example_images_path'] = str(tmp_path)
|
||||||
|
|
||||||
|
# Create a valid hash folder so the root is otherwise acceptable
|
||||||
|
hash_folder = tmp_path / ('a' * 64)
|
||||||
|
hash_folder.mkdir()
|
||||||
|
|
||||||
|
# Add an offending file
|
||||||
|
(tmp_path / 'readme.txt').write_text('hello', encoding='utf-8')
|
||||||
|
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == ['readme.txt']
|
||||||
|
|
||||||
|
# Add an offending directory with content (empty dirs are accepted as
|
||||||
|
# potential legacy library folders by _library_folder_has_only_hash_dirs)
|
||||||
|
offending_dir = tmp_path / 'not_a_hash'
|
||||||
|
offending_dir.mkdir()
|
||||||
|
(offending_dir / 'some_file.txt').write_text('data', encoding='utf-8')
|
||||||
|
items = find_non_compliant_items_in_example_images_root(str(tmp_path))
|
||||||
|
assert 'readme.txt' in items
|
||||||
|
assert 'not_a_hash' in items
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_non_compliant_items_ignores_hidden_files(tmp_path, settings_manager):
|
||||||
|
"""Hidden/system files should not appear in offending list."""
|
||||||
|
settings_manager.settings['example_images_path'] = str(tmp_path)
|
||||||
|
|
||||||
|
# .DS_Store is an allowed file
|
||||||
|
(tmp_path / '.DS_Store').write_text('', encoding='utf-8')
|
||||||
|
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||||
|
|
||||||
|
# Thumbs.db too
|
||||||
|
(tmp_path / 'Thumbs.db').write_text('', encoding='utf-8')
|
||||||
|
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_non_compliant_items_accepts_download_progress_json(tmp_path, settings_manager):
|
||||||
|
""".download_progress.json should be recognised as a valid metadata file."""
|
||||||
|
settings_manager.settings['example_images_path'] = str(tmp_path)
|
||||||
|
|
||||||
|
(tmp_path / '.download_progress.json').write_text('{}', encoding='utf-8')
|
||||||
|
assert find_non_compliant_items_in_example_images_root(str(tmp_path)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_non_compliant_items_reports_directory_error(tmp_path):
|
||||||
|
"""When the directory cannot be listed, return an explanatory message."""
|
||||||
|
non_existent = tmp_path / 'does-not-exist'
|
||||||
|
result = find_non_compliant_items_in_example_images_root(str(non_existent))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert 'cannot list directory' in result[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user