mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-10 04:49:24 -03:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b509f27cb7 | ||
|
|
5c2ef48917 | ||
|
|
ad2bd82c67 | ||
|
|
17ba350153 | ||
|
|
60175334b5 | ||
|
|
f65a01df00 | ||
|
|
430e24d70b | ||
|
|
14f0c48fdd | ||
|
|
34791c2ad7 | ||
|
|
3f6824eef6 | ||
|
|
3919dfa3f4 | ||
|
|
7124b5293f | ||
|
|
d2a04f8993 | ||
|
|
7027a7c270 | ||
|
|
0a1d7dfd4c | ||
|
|
3962b1a96d | ||
|
|
8b856276bf | ||
|
|
c97c802956 | ||
|
|
24e2909627 | ||
|
|
b768f1368f | ||
|
|
37ccd29fc0 | ||
|
|
7416080cfb | ||
|
|
26be187d42 | ||
|
|
d7caa1fa47 | ||
|
|
2629fcce23 | ||
|
|
438e7d07b9 | ||
|
|
e9932ea870 | ||
|
|
5dd8b96422 | ||
|
|
5e1cf68bbd | ||
|
|
1044fa3c83 | ||
|
|
397892bb7f | ||
|
|
f105500740 | ||
|
|
806555cf06 | ||
|
|
5cd7204101 | ||
|
|
3b602a3698 | ||
|
|
15dfaed462 | ||
|
|
0e51851025 | ||
|
|
0d0f4defca | ||
|
|
818fa34a48 | ||
|
|
78303b2a5e | ||
|
|
9ce56dd40c | ||
|
|
33e5f3d85d | ||
|
|
031d5e4f40 | ||
|
|
4ff5774e34 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ model_cache/
|
||||
.claude/
|
||||
.sisyphus/
|
||||
.codex
|
||||
.omo
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
vue-widgets/node_modules/
|
||||
|
||||
@@ -15,227 +15,233 @@
|
||||
"Phil",
|
||||
"Carl G.",
|
||||
"Arlecchino Shion",
|
||||
"$MetaSamsara",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"$MetaSamsara",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"JongWon Han",
|
||||
"runte3221",
|
||||
"FreelancerZ",
|
||||
"Fraser Cross",
|
||||
"Polymorphic Indeterminate",
|
||||
"Marc Whiffen",
|
||||
"Skalabananen",
|
||||
"Birdy",
|
||||
"Kiba",
|
||||
"Mozzel",
|
||||
"itismyelement",
|
||||
"Gingko Biloba",
|
||||
"Reno Lam",
|
||||
"onesecondinosaur",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
"Sen314",
|
||||
"Estragon",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Takkan",
|
||||
"Charles Blakemore",
|
||||
"Rosenthal",
|
||||
"ClockDaemon",
|
||||
"Francisco Tatis",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"iamresist",
|
||||
"Wolffen",
|
||||
"Ricky Carter",
|
||||
"JongWon Han",
|
||||
"James Todd",
|
||||
"Steven Pfeiffer",
|
||||
"VantAI",
|
||||
"Tim",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"FreelancerZ",
|
||||
"fnkylove",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Edgar Tejeda",
|
||||
"Jorge Hussni",
|
||||
"Liam MacDougal",
|
||||
"Sterilized",
|
||||
"Fraser Cross",
|
||||
"Polymorphic Indeterminate",
|
||||
"Marc Whiffen",
|
||||
"Birdy",
|
||||
"Skalabananen",
|
||||
"BadassArabianMofo",
|
||||
"quarz",
|
||||
"Reno Lam",
|
||||
"Greg",
|
||||
"JSST",
|
||||
"sig",
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"lmsupporter",
|
||||
"wfpearl",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Jack B Nimble",
|
||||
"Nazono_hito",
|
||||
"Melville Parrish",
|
||||
"daniel dove",
|
||||
"Lustre",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"JW Sin",
|
||||
"contrite831",
|
||||
"Alex",
|
||||
"bh",
|
||||
"carozzz",
|
||||
"Marlon Daniels",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"Adam Shaw",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"Anthony Rizzo",
|
||||
"M Postkasse",
|
||||
"Gooohokrbe",
|
||||
"RedrockVP",
|
||||
"James Todd",
|
||||
"ASLPro3D",
|
||||
"Wicked Choices by ASLPro3D",
|
||||
"OldBones",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Steven Pfeiffer",
|
||||
"Weasyl",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Lisster",
|
||||
"Zach Gonser",
|
||||
"Big Red",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Nick Walker",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"Bishoujoker",
|
||||
"Todd Keck",
|
||||
"aai",
|
||||
"Briton Heilbrun",
|
||||
"Tori",
|
||||
"wildnut",
|
||||
"jean jahren",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Penfore",
|
||||
"Greg",
|
||||
"Sangheili460",
|
||||
"MagnaInsomnia",
|
||||
"Karl P.",
|
||||
"Akira_HentAI",
|
||||
"Gordon Cole",
|
||||
"AbstractAss",
|
||||
"lmsupporter",
|
||||
"andrew.tappan",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"wfpearl",
|
||||
"fancypants",
|
||||
"Eldithor",
|
||||
"Jack B Nimble",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"bh",
|
||||
"takyamtom",
|
||||
"Jwk0205",
|
||||
"Starkselle",
|
||||
"Bro Xie",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"greebles",
|
||||
"太郎 ゲーム",
|
||||
"Some Guy Named Barry",
|
||||
"Cosmosis",
|
||||
"M Postkasse",
|
||||
"AELOX",
|
||||
"Nicfit23",
|
||||
"FloPro4Sho",
|
||||
"wamekukyouzin",
|
||||
"Jacob Hoehler",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Cory Paza",
|
||||
"Christopher Michel",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"Philip Hempel",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"nahinahi9",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"aai",
|
||||
"Mouthlessman",
|
||||
"otaku fra",
|
||||
"ViperC",
|
||||
"Ran C",
|
||||
"MiraiKuriyamaSy",
|
||||
"Sangheili460",
|
||||
"Karl P.",
|
||||
"yuxz69",
|
||||
"Adam Taylor",
|
||||
"Weird_With_A_Beard",
|
||||
"esthe",
|
||||
"The Spawn",
|
||||
"graysock",
|
||||
"Pozadine1",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
"fancypants",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
"CloudValley",
|
||||
"linnfrey",
|
||||
"IamAyam",
|
||||
"skaterb949",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"confiscated Zyra",
|
||||
"Bro Xie",
|
||||
"yer fey",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"太郎 ゲーム",
|
||||
"Error_Rule34_Not_found",
|
||||
"Roslynd",
|
||||
"Tee Gee",
|
||||
"jinxedx",
|
||||
"tarek helmi",
|
||||
"Neco28",
|
||||
"Max Marklund",
|
||||
"AELOX",
|
||||
"David Ortega",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"Cristian Vazquez",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"Pronredn",
|
||||
"Christopher Michel",
|
||||
"DougPeterson",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"nahinahi9",
|
||||
"lh qwe",
|
||||
"Kevin John Duck",
|
||||
"conner",
|
||||
"Dustin Chen",
|
||||
"Kevin Christopher",
|
||||
"Blackfish95",
|
||||
"dd",
|
||||
"Princess Bright Eyes",
|
||||
"Paul Kroll",
|
||||
"Felipe dos Santos",
|
||||
"Bas Imagineer",
|
||||
"Markus",
|
||||
"John Statham",
|
||||
"Douglas Gaspar",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"dw",
|
||||
"decoy",
|
||||
"elu3199",
|
||||
"Hasturkun",
|
||||
"Jon Sandman",
|
||||
"Ubivis",
|
||||
"CloudValley",
|
||||
"thesoftwaredruid",
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
@@ -243,61 +249,65 @@
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
"地獄の禄",
|
||||
"MJG",
|
||||
"David LaVallee",
|
||||
"linnfrey",
|
||||
"奚明 刘",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"Griffin Dahlberg",
|
||||
"준희 김",
|
||||
"Error_Rule34_Not_found",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"jcay015",
|
||||
"Gerald Welly",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Geolog",
|
||||
"Eris3D",
|
||||
"Tomohiro Baba",
|
||||
"David Ortega",
|
||||
"Noora",
|
||||
"Mattssn",
|
||||
"a _",
|
||||
"Jeff",
|
||||
"James Coleman",
|
||||
"Kevin Christopher",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"dd",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
"Dušan Ryban",
|
||||
"tedcor",
|
||||
"Sam",
|
||||
"Fotek Design",
|
||||
"sjon kreutz",
|
||||
"MadSpin",
|
||||
"Metryman55",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"地獄の禄",
|
||||
"ae",
|
||||
"Tr4shP4nda",
|
||||
"Gamalonia",
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"momokai",
|
||||
"Mirko Katzula",
|
||||
"dan",
|
||||
"Piccio08",
|
||||
"kumakichi",
|
||||
"cppbel",
|
||||
"奚明 刘",
|
||||
"Brian M",
|
||||
"Josef Lanzl",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"준희 김",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Gerald Welly",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Mateo Curić",
|
||||
"Geolog",
|
||||
"Eris3D",
|
||||
"Tomohiro Baba",
|
||||
"m",
|
||||
"Noora",
|
||||
"Pierce McBride",
|
||||
"Mattssn",
|
||||
"Mikko Hemilä",
|
||||
"Jamie Ogletree",
|
||||
"a _",
|
||||
"James Coleman",
|
||||
"Martial",
|
||||
"Emil Andersson",
|
||||
"Ouro Boros",
|
||||
"Chad Idk",
|
||||
"Steam Steam",
|
||||
"CryptoTraderJK",
|
||||
"Yuji Kaneko",
|
||||
"Davaitamin",
|
||||
"Dušan Ryban",
|
||||
"Rops Alot",
|
||||
"tedcor",
|
||||
"Sam",
|
||||
"Fotek Design",
|
||||
"sjon kreutz",
|
||||
"Ace Ventura",
|
||||
"MadSpin",
|
||||
"Metryman55",
|
||||
"inbijiburu",
|
||||
"Nick “Loadstone” D",
|
||||
"Gamalonia",
|
||||
"momokai",
|
||||
"starbugx",
|
||||
"Moon Knight",
|
||||
"몽타주",
|
||||
@@ -306,59 +316,13 @@
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"ken",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Vir",
|
||||
"Richard",
|
||||
"Andrew",
|
||||
"Brian M",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Draven T",
|
||||
"mrjuan",
|
||||
"Brian Buie",
|
||||
"Thought2Form",
|
||||
"Kevin Picco",
|
||||
"Sadlip",
|
||||
"Aquatic Coffee",
|
||||
"m",
|
||||
"ethanfel",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
"Focuschannel",
|
||||
"Mikko Hemilä",
|
||||
"Jacob McDaniel",
|
||||
"Jamie Ogletree",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Martial",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"Michael Anthony Scott",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Decx _",
|
||||
"Yuji Kaneko",
|
||||
"Pat Hen",
|
||||
"Jordan Shaw",
|
||||
"Rops Alot",
|
||||
"Thesharingbrother",
|
||||
"Ace Ventura",
|
||||
"ResidentDeviant",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"zenobeus",
|
||||
"ken",
|
||||
"Crocket",
|
||||
"keemun",
|
||||
"Wind",
|
||||
"Jackthemind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
@@ -369,6 +333,50 @@
|
||||
"JohnDoe42054",
|
||||
"BillyHill",
|
||||
"emyth",
|
||||
"Vir",
|
||||
"gzmzmvp",
|
||||
"Richard",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Gregory Kozhemiak",
|
||||
"Draven T",
|
||||
"mrjuan",
|
||||
"Brian Buie",
|
||||
"Sadlip",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Aquatic Coffee",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"ethanfel",
|
||||
"Joshua Gray",
|
||||
"Morgandel",
|
||||
"Focuschannel",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Anthony Faxlandez",
|
||||
"battu",
|
||||
"Michael Anthony Scott",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Decx _",
|
||||
"Pat Hen",
|
||||
"Jordan Shaw",
|
||||
"四糸凜音",
|
||||
"Nihongasuki",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"FrxzenSnxw",
|
||||
"zenobeus",
|
||||
"Crocket",
|
||||
"Jackthemind",
|
||||
"chriphost",
|
||||
"KitKatM",
|
||||
"ryoma",
|
||||
@@ -388,43 +396,53 @@
|
||||
"Menard",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"gzmzmvp",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"TheHolySheep",
|
||||
"raf8osz",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"ElitaSSJ4",
|
||||
"Wolfe7D1",
|
||||
"blikkies",
|
||||
"Chris",
|
||||
"Gregory Kozhemiak",
|
||||
"elleshar666",
|
||||
"Shock Shockor",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Goldwaters",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Kauffy",
|
||||
"Zude",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"John J Linehan",
|
||||
"Kyler",
|
||||
"Elliot E",
|
||||
"Morgandel",
|
||||
"Theerat Jiramate",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
"aRtFuL_DodGeR",
|
||||
"Noah",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"hexxish",
|
||||
"DarkSunset",
|
||||
"notedfakes",
|
||||
"Nathan",
|
||||
"Billy Gladky",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Michael Scott",
|
||||
"Probis",
|
||||
"Ed Wang",
|
||||
"Wes Sims",
|
||||
"ItsGeneralButtNaked",
|
||||
"SRDB",
|
||||
"g unit",
|
||||
"Distortik",
|
||||
"Filippo Ferrari",
|
||||
"Youguang",
|
||||
"Saya",
|
||||
"andrewzpong",
|
||||
"FrxzenSnxw",
|
||||
"BossGame",
|
||||
"lrdchs",
|
||||
"Tree Tagger",
|
||||
@@ -437,17 +455,13 @@
|
||||
"Ginnie",
|
||||
"Raku",
|
||||
"emadsultan",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"Pkrsky",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"nanana",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"quantenmecha",
|
||||
"Jason+Nash",
|
||||
"BillyBoy84",
|
||||
@@ -465,43 +479,40 @@
|
||||
"Welkor",
|
||||
"David Schenck",
|
||||
"John Martin",
|
||||
"Wolfe7D1",
|
||||
"Ink Temptation",
|
||||
"moranqianlong",
|
||||
"Kalli Core",
|
||||
"Time Valentine",
|
||||
"elleshar666",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"Михал Михалыч",
|
||||
"Matt",
|
||||
"Kauffy",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Kyron Mahan",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
"Bryan Rutkowski",
|
||||
"Nick Kage",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"Ezokewn",
|
||||
"SendingRavens",
|
||||
"Xenon Xue",
|
||||
"notedfakes",
|
||||
"JackJohnnyJim",
|
||||
"Edward Ten Eyck",
|
||||
"Michael Docherty",
|
||||
"Michael Scott",
|
||||
"Paul Hartsuyker",
|
||||
"Henrique Faiolli",
|
||||
"elitassj",
|
||||
"Solixer",
|
||||
"Jacob Winter",
|
||||
"Ryan Presley Ng",
|
||||
"Wes Sims",
|
||||
"jinksta187",
|
||||
"Donor4115",
|
||||
"Manu Thetug",
|
||||
"Karlanx",
|
||||
"Lyavph",
|
||||
"David",
|
||||
"Meilo",
|
||||
"Filippo Ferrari",
|
||||
"operationancut",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
"MaartenAlbers",
|
||||
@@ -509,6 +520,7 @@
|
||||
"xybrightsummer",
|
||||
"jreedatchison",
|
||||
"PhilW",
|
||||
"Marcus thronico",
|
||||
"Janik",
|
||||
"Cruel",
|
||||
"MRBlack",
|
||||
@@ -519,7 +531,15 @@
|
||||
"Scott",
|
||||
"Muratoraccio",
|
||||
"D",
|
||||
"nanana",
|
||||
"YassineKhaled",
|
||||
"Y",
|
||||
"MatteKey",
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Inkognito",
|
||||
"G",
|
||||
"Tan+Huynh",
|
||||
"D",
|
||||
"Dark_Pest",
|
||||
"Alex",
|
||||
"Jacky+Ho",
|
||||
@@ -534,12 +554,7 @@
|
||||
"rsamerica",
|
||||
"sfasdfasfdsa",
|
||||
"Alan+Cano",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"generic404",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"abattoirblues",
|
||||
"zounik",
|
||||
"4IXplr0r3r",
|
||||
@@ -553,26 +568,27 @@
|
||||
"ja s",
|
||||
"Doug Mason",
|
||||
"Jeremy Townsend",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Locrospiel",
|
||||
"Frogmilk",
|
||||
"Sean voets",
|
||||
"Owen Gwosdz",
|
||||
"SPJ",
|
||||
"Jarrid Lee",
|
||||
"Kor",
|
||||
"Joseph Hanson",
|
||||
"Bryan Rutkowski",
|
||||
"John Rednoulf",
|
||||
"Boba Smith",
|
||||
"Devil Lude",
|
||||
"David Murcko",
|
||||
"Jack Dole",
|
||||
"max blo",
|
||||
"Sauv",
|
||||
"Steven",
|
||||
"CptNeo",
|
||||
"JackJohnnyJim",
|
||||
"TenaciousD",
|
||||
"Dmitry Ryzhov",
|
||||
"Khánh Đặng",
|
||||
"Maso",
|
||||
"Edward Ten Eyck",
|
||||
"Eric Ketchum",
|
||||
"Kevin Wallace",
|
||||
"Jimmy Borup",
|
||||
@@ -580,14 +596,10 @@
|
||||
"mercur",
|
||||
"Pete Pain",
|
||||
"RHopkirk",
|
||||
"jinksta187",
|
||||
"Andrew Wilkinson",
|
||||
"Yavizu3d",
|
||||
"Maxim",
|
||||
"Manu Thetug",
|
||||
"Karlanx",
|
||||
"Yves Poezevara",
|
||||
"operationancut",
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
@@ -611,7 +623,6 @@
|
||||
"pixl",
|
||||
"Robin",
|
||||
"chahknoir",
|
||||
"Marcus thronico",
|
||||
"nd",
|
||||
"keno94d",
|
||||
"James Melzer",
|
||||
@@ -625,6 +636,7 @@
|
||||
"Captain_Swag",
|
||||
"obkircher",
|
||||
"gwyar",
|
||||
"ResidentDeviant",
|
||||
"D",
|
||||
"edgecase",
|
||||
"Neoxena",
|
||||
@@ -635,6 +647,19 @@
|
||||
"SelfishMedic",
|
||||
"adderleighn",
|
||||
"EnragedAntelope",
|
||||
"Kachac",
|
||||
"tyrant2811",
|
||||
"Kevin",
|
||||
"Rune+Osnes",
|
||||
"jcx29",
|
||||
"cloudghost",
|
||||
"Yongkwan+Lee",
|
||||
"PoorStudent",
|
||||
"lucites",
|
||||
"Alex+Zaw",
|
||||
"Mobius2020",
|
||||
"ExLightSaber",
|
||||
"YaboiRay",
|
||||
"Drizzly",
|
||||
"Sildoren",
|
||||
"Darvidous",
|
||||
@@ -656,19 +681,10 @@
|
||||
"low9",
|
||||
"Winged",
|
||||
"you+halo9",
|
||||
"YassineKhaled",
|
||||
"YK12",
|
||||
"MatteKey",
|
||||
"Flob",
|
||||
"ShiroSenpai",
|
||||
"Somebody",
|
||||
"Inkognito",
|
||||
"Somebody",
|
||||
"Gramer+Gumbyte",
|
||||
"Crescent~San",
|
||||
"Tan+Huynh",
|
||||
"AiGirlTS",
|
||||
"D",
|
||||
"datasl4ve",
|
||||
"Somebody",
|
||||
"koopa990",
|
||||
@@ -677,21 +693,26 @@
|
||||
"Bula",
|
||||
"KUJYAKU",
|
||||
"Coeur+de+cochon",
|
||||
"Obsidian.Studios",
|
||||
"han b",
|
||||
"Zomba Mann",
|
||||
"Nico",
|
||||
"Maximilian Krischan",
|
||||
"Banana Joe",
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"Hans Meier",
|
||||
"Tú Nguyễn Lý Hoàng",
|
||||
"shira1011",
|
||||
"Michael Eid",
|
||||
"beersandbacon",
|
||||
"Neko Desco",
|
||||
"Bob barker",
|
||||
"Ben D",
|
||||
"G",
|
||||
"Ronan Delevacq",
|
||||
"james",
|
||||
"karim ben brik",
|
||||
"Vinarus",
|
||||
"Michael Zhu",
|
||||
"Nemisu",
|
||||
"Seraphy",
|
||||
@@ -701,30 +722,32 @@
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Rim",
|
||||
"Dave Abraham",
|
||||
"Joaquin Hierrezuelo",
|
||||
"Jairus Knudsen",
|
||||
"Jarrid Lee",
|
||||
"Poophead27 Blyat",
|
||||
"Xan Dionysus",
|
||||
"Nathan lee",
|
||||
"Lyle Liston",
|
||||
"Middo",
|
||||
"Forbidden Atelier",
|
||||
"John Rednoulf",
|
||||
"Thomas Sankowski",
|
||||
"Spire",
|
||||
"DrB",
|
||||
"AZ Party Oasis",
|
||||
"Adictedtohumping",
|
||||
"Boba Smith",
|
||||
"Towelie",
|
||||
"Ryan Smith",
|
||||
"MR.Bear",
|
||||
"matt",
|
||||
"dsffsdfsdfsdfsdfsdf",
|
||||
"somethingtosay8",
|
||||
"Jean-françois SEMA",
|
||||
"3zS4QNQ4",
|
||||
"Terminuz",
|
||||
"Kurt",
|
||||
"ivistorm",
|
||||
"Sauv",
|
||||
"Ivan Imes",
|
||||
"Faburizu",
|
||||
"Jack Lawfield",
|
||||
"jimyjomson",
|
||||
"Borte",
|
||||
"Chase Kwon",
|
||||
@@ -744,6 +767,7 @@
|
||||
"hannibal",
|
||||
"Jo+Example",
|
||||
"BrentBertram",
|
||||
"inusanorthcape",
|
||||
"Tigon",
|
||||
"eumelzocker",
|
||||
"dxjaymz",
|
||||
@@ -752,5 +776,5 @@
|
||||
"Somebody",
|
||||
"CK"
|
||||
],
|
||||
"totalCount": 749
|
||||
"totalCount": 773
|
||||
}
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||
"noTags": "Keine Tags",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "Download-Backend",
|
||||
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den experimentellen externen Downloader-Prozess.",
|
||||
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den empfohlenen externen Downloader-Prozess.",
|
||||
"options": {
|
||||
"python": "Python (integriert)",
|
||||
"aria2": "aria2 (experimentell)"
|
||||
"aria2": "aria2 (empfohlen)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
|
||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
|
||||
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen",
|
||||
"loraSyntaxFormat": "LoRA-Syntaxformat",
|
||||
"loraSyntaxFormatHelp": "LoRA-Syntaxformat. Der vollständige Pfad enthält den Unterordnerpfad (<lora:style/anime/x:1.0>) für verlustfreie Modellauflösung. Legacy verwendet nur den Dateinamen (<lora:x:1.0>) — A1111-Konvention, kann bei doppelten Dateinamen in verschiedenen Ordnern zu Mehrdeutigkeiten führen.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Vollständiger Pfad (Unterordner/Name)",
|
||||
"legacy": "Legacy A1111 (nur Name)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Metadaten-Archiv-Datenbank aktivieren",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "Modellname bearbeiten",
|
||||
"editFileName": "Dateiname bearbeiten",
|
||||
"editBaseModel": "Basis-Modell bearbeiten",
|
||||
"editVersionName": "Versionsname bearbeiten",
|
||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
||||
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
||||
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "Handlungsbedarf",
|
||||
"error": "Aktion erforderlich"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Erneut ausführen",
|
||||
"exportBundle": "Paket exportieren"
|
||||
"exportBundle": "Paket exportieren",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Dateinamenskonflikte auflösen",
|
||||
"message": "Umbenennen durch Anhängen eines 4-stelligen Hashs an jeden doppelten Dateinamen.",
|
||||
"note": "Dieser Vorgang benennt Dateien auf der Festplatte um. Modellreferenzen in vorhandenen Workflows müssen möglicherweise aktualisiert werden, wenn Sie das A1111-Syntaxformat verwenden.",
|
||||
"detail": "Beispiel: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Benennt <strong>{count}</strong> Datei(en) in <strong>{groups}</strong> Duplikatgruppe(n) um",
|
||||
"confirm": "Dateien umbenennen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Anwendungs-Update erkannt",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "License",
|
||||
"noCreditRequired": "No Credit Required",
|
||||
"allowSellingGeneratedContent": "Allow Selling",
|
||||
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
|
||||
"noCreditRequiredTooltip": "Use the model without crediting the creator",
|
||||
"noTags": "No tags",
|
||||
"autoTags": "Auto Tags",
|
||||
"noBaseModelMatches": "No base models match the current search.",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "Download backend",
|
||||
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the experimental external downloader process.",
|
||||
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the recommended external downloader process.",
|
||||
"options": {
|
||||
"python": "Python (built-in)",
|
||||
"aria2": "aria2 (experimental)"
|
||||
"aria2": "aria2 (recommended)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Include Trigger Words in LoRA Syntax",
|
||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
|
||||
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard",
|
||||
"loraSyntaxFormat": "LoRA Syntax Format",
|
||||
"loraSyntaxFormatHelp": "LoRA syntax format. Full includes subfolder path (<lora:style/anime/x:1.0>) for lossless model resolution. Legacy uses filename only (<lora:x:1.0>) — A1111 convention, may be ambiguous with duplicate filenames across folders.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Full path (subfolder/name)",
|
||||
"legacy": "Legacy A1111 (name only)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Enable Metadata Archive Database",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "Set Content Rating for Selected",
|
||||
"copyAll": "Copy Selected Syntax",
|
||||
"refreshAll": "Refresh Selected Metadata",
|
||||
"repairMetadata": "Repair Metadata for Selected",
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
"moveAll": "Move Selected to Folder",
|
||||
"autoOrganize": "Auto-Organize Selected",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "Edit model name",
|
||||
"editFileName": "Edit file name",
|
||||
"editBaseModel": "Edit base model",
|
||||
"editVersionName": "Edit version name",
|
||||
"viewOnCivitai": "View on Civitai",
|
||||
"viewOnCivitaiText": "View on Civitai",
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "No recipes selected",
|
||||
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
||||
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
||||
"repairBulkFailed": "Failed to repair selected recipes: {message}",
|
||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "Needs Attention",
|
||||
"error": "Action Required"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Run Again",
|
||||
"exportBundle": "Export Bundle"
|
||||
"exportBundle": "Export Bundle",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Failed to load diagnostics: {message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Resolve Filename Conflicts",
|
||||
"message": "Renaming by appending a 4-character hash to each duplicate filename.",
|
||||
"note": "This operation renames files on disk. Model references in existing workflows may need updating if you use the A1111 syntax format.",
|
||||
"detail": "Example: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Will rename <strong>{count}</strong> file(s) across <strong>{groups}</strong> duplicate group(s).",
|
||||
"confirm": "Rename Files",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Application Update Detected",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||
"noTags": "Sin etiquetas",
|
||||
"autoTags": "Etiquetas automáticas",
|
||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "Backend de descarga",
|
||||
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo experimental de descarga.",
|
||||
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo recomendado de descarga.",
|
||||
"options": {
|
||||
"python": "Python (integrado)",
|
||||
"aria2": "aria2 (experimental)"
|
||||
"aria2": "aria2 (recomendado)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
|
||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
|
||||
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles",
|
||||
"loraSyntaxFormat": "Formato de sintaxis LoRA",
|
||||
"loraSyntaxFormatHelp": "Formato de sintaxis LoRA. El formato completo incluye la ruta de la subcarpeta (<lora:style/anime/x:1.0>) para una resolución de modelo sin pérdidas. El formato heredado usa solo el nombre del archivo (<lora:x:1.0>) — convención A1111, puede ser ambiguo con nombres de archivo duplicados entre carpetas.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Ruta completa (subcarpeta/nombre)",
|
||||
"legacy": "A1111 heredado (solo nombre)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Habilitar base de datos de archivo de metadatos",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"repairMetadata": "Reparar metadatos de la selección",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "Editar nombre del modelo",
|
||||
"editFileName": "Editar nombre de archivo",
|
||||
"editBaseModel": "Editar modelo base",
|
||||
"editVersionName": "Editar nombre de versión",
|
||||
"viewOnCivitai": "Ver en Civitai",
|
||||
"viewOnCivitaiText": "Ver en Civitai",
|
||||
"viewCreatorProfile": "Ver perfil del creador",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "No se han seleccionado recetas",
|
||||
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
||||
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
||||
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
|
||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "Requiere atención",
|
||||
"error": "Se requiere acción"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Ejecutar de nuevo",
|
||||
"exportBundle": "Exportar paquete"
|
||||
"exportBundle": "Exportar paquete",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Resolver conflictos de nombres de archivo",
|
||||
"message": "Renombrar añadiendo un hash de 4 caracteres a cada nombre de archivo duplicado.",
|
||||
"note": "Esta operación renombra archivos en el disco. Es posible que las referencias a modelos en flujos de trabajo existentes deban actualizarse si usas el formato de sintaxis A1111.",
|
||||
"detail": "Ejemplo: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Renombrará <strong>{count}</strong> archivo(s) en <strong>{groups}</strong> grupo(s) de duplicados",
|
||||
"confirm": "Renombrar archivos",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Actualización de la aplicación detectada",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
||||
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
||||
"noTags": "Aucun tag",
|
||||
"autoTags": "Auto-Tags",
|
||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "Moteur de téléchargement",
|
||||
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe expérimental de téléchargement.",
|
||||
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe recommandé de téléchargement.",
|
||||
"options": {
|
||||
"python": "Python (intégré)",
|
||||
"aria2": "aria2 (expérimental)"
|
||||
"aria2": "aria2 (recommandé)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
|
||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
|
||||
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers",
|
||||
"loraSyntaxFormat": "Format de syntaxe LoRA",
|
||||
"loraSyntaxFormatHelp": "Format de syntaxe LoRA. Le format complet inclut le chemin du sous-dossier (<lora:style/anime/x:1.0>) pour une résolution de modèle sans perte. Le format hérité utilise uniquement le nom du fichier (<lora:x:1.0>) — convention A1111, peut être ambiguë en cas de noms de fichiers en double dans différents dossiers.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Chemin complet (sous-dossier/nom)",
|
||||
"legacy": "A1111 hérité (nom uniquement)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Activer la base de données d'archive des métadonnées",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "Définir la classification du contenu pour tous",
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
"autoOrganize": "Auto-organiser la sélection",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "Modifier le nom du modèle",
|
||||
"editFileName": "Modifier le nom de fichier",
|
||||
"editBaseModel": "Modifier le modèle de base",
|
||||
"editVersionName": "Modifier le nom de la version",
|
||||
"viewOnCivitai": "Voir sur Civitai",
|
||||
"viewOnCivitaiText": "Voir sur Civitai",
|
||||
"viewCreatorProfile": "Voir le profil du créateur",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "Aucune recette sélectionnée",
|
||||
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
|
||||
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
|
||||
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
|
||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "Nécessite une attention",
|
||||
"error": "Action requise"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Relancer",
|
||||
"exportBundle": "Exporter le lot"
|
||||
"exportBundle": "Exporter le lot",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Résoudre les conflits de noms de fichiers",
|
||||
"message": "Renommer en ajoutant un hachage de 4 caractères à chaque nom de fichier en double.",
|
||||
"note": "Cette opération renomme les fichiers sur le disque. Les références de modèle dans les workflows existants peuvent nécessiter une mise à jour si vous utilisez le format de syntaxe A1111.",
|
||||
"detail": "Exemple : <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Renommera <strong>{count}</strong> fichier(s) dans <strong>{groups}</strong> groupe(s) de doublons",
|
||||
"confirm": "Renommer les fichiers",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Mise à jour de l'application détectée",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||
"noTags": "ללא תגיות",
|
||||
"autoTags": "תגיות אוטומטיות",
|
||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "מנגנון הורדה",
|
||||
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני ניסיוני.",
|
||||
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני מומלץ.",
|
||||
"options": {
|
||||
"python": "Python (מובנה)",
|
||||
"aria2": "aria2 (ניסיוני)"
|
||||
"aria2": "aria2 (מומלץ)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
|
||||
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח",
|
||||
"loraSyntaxFormat": "פורמט תחביר LoRA",
|
||||
"loraSyntaxFormatHelp": "פורמט תחביר LoRA. נתיב מלא כולל תת-תיקייה (<lora:style/anime/x:1.0>) לפתרון מודל ללא אובדן. גרסה ישנה משתמשת בשם קובץ בלבד (<lora:x:1.0>) — מוסכמת A1111, עלולה להיות לא חד משמעית עם שמות קבצים כפולים בתיקיות שונות.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "נתיב מלא (תת-תיקייה/שם)",
|
||||
"legacy": "A1111 ישן (שם בלבד)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "הפעל מסד נתונים של ארכיון מטא-דאטה",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||
"copyAll": "העתק את כל התחבירים",
|
||||
"refreshAll": "רענן את כל המטא-דאטה",
|
||||
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
"moveAll": "העבר הכל לתיקייה",
|
||||
"autoOrganize": "ארגן אוטומטית נבחרים",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "ערוך שם מודל",
|
||||
"editFileName": "ערוך שם קובץ",
|
||||
"editBaseModel": "ערוך מודל בסיס",
|
||||
"editVersionName": "ערוך שם גרסה",
|
||||
"viewOnCivitai": "הצג ב-Civitai",
|
||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "דורש תשומת לב",
|
||||
"error": "נדרשת פעולה"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "הפעל שוב",
|
||||
"exportBundle": "ייצוא חבילה"
|
||||
"exportBundle": "ייצוא חבילה",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "פתור התנגשויות בשמות קבצים",
|
||||
"message": "שינוי שם על ידי הוספת האש באורך 4 תווים לכל שם קובץ כפול.",
|
||||
"note": "פעולה זו משנה שמות של קבצים בדיסק. ייתכן שיהיה צורך לעדכן הפניות למודלים בזרימות עבודה קיימות אם אתה משתמש בפורמט התחביר A1111.",
|
||||
"detail": "דוגמה: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "ישנה שם של <strong>{count}</strong> קבצים ב-<strong>{groups}</strong> קבוצות כפולות",
|
||||
"confirm": "שנה שמות קבצים",
|
||||
"cancel": "ביטול"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "זוהה עדכון יישום",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||
"noTags": "タグなし",
|
||||
"autoTags": "自動タグ",
|
||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "ダウンロードバックエンド",
|
||||
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は実験的な外部ダウンローダープロセスを使用します。",
|
||||
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は推奨の外部ダウンローダープロセスを使用します。",
|
||||
"options": {
|
||||
"python": "Python(内蔵)",
|
||||
"aria2": "aria2(実験的)"
|
||||
"aria2": "aria2(推奨)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA構文にトリガーワードを含める",
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
|
||||
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます",
|
||||
"loraSyntaxFormat": "LoRA構文形式",
|
||||
"loraSyntaxFormatHelp": "LoRA構文形式。フルパスはサブフォルダパスを含み(<lora:style/anime/x:1.0>)、モデルをロスレスで解決します。レガシーはファイル名のみ(<lora:x:1.0>)— A1111規約ですが、フォルダ間でファイル名が重複する場合に曖昧になる可能性があります。",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "フルパス(サブフォルダ/名前)",
|
||||
"legacy": "レガシーA1111(名前のみ)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
"autoOrganize": "自動整理を実行",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "モデル名を編集",
|
||||
"editFileName": "ファイル名を編集",
|
||||
"editBaseModel": "ベースモデルを編集",
|
||||
"editVersionName": "バージョン名を編集",
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"viewOnCivitaiText": "Civitaiで表示",
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "レシピが選択されていません",
|
||||
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "要注意",
|
||||
"error": "対応が必要"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API キー"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "モデルキャッシュの健全性"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "ファイル名重複競合"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI バージョン"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "再実行",
|
||||
"exportBundle": "パッケージをエクスポート"
|
||||
"exportBundle": "パッケージをエクスポート",
|
||||
"open-settings": "設定を開く",
|
||||
"open-settings-syntax-format": "フルパス構文に切り替え",
|
||||
"repair-cache": "キャッシュを再構築",
|
||||
"resolve-filename-conflicts": "競合を解決",
|
||||
"reload-page": "UI をリロード"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "競合",
|
||||
"version": "バージョン"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "ファイル名の競合を解決",
|
||||
"message": "重複したファイル名に4文字のハッシュを追加してリネームします。",
|
||||
"note": "この操作はディスク上のファイルをリネームします。A1111 構文形式を使用している場合、既存のワークフロー内のモデル参照を更新する必要があるかもしれません。",
|
||||
"detail": "例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "<strong>{groups}</strong> 組の重複にわたって <strong>{count}</strong> 個のファイルをリネームします",
|
||||
"confirm": "ファイルをリネーム",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "アプリケーション更新が検出されました",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||
"noTags": "태그 없음",
|
||||
"autoTags": "자동 태그",
|
||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "다운로드 백엔드",
|
||||
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 실험적인 외부 다운로더 프로세스를 사용합니다.",
|
||||
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 권장되는 외부 다운로더 프로세스를 사용합니다.",
|
||||
"options": {
|
||||
"python": "Python(내장)",
|
||||
"aria2": "aria2(실험적)"
|
||||
"aria2": "aria2(권장)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
|
||||
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다",
|
||||
"loraSyntaxFormat": "LoRA 구문 형식",
|
||||
"loraSyntaxFormatHelp": "LoRA 구문 형식. 전체 경로는 하위 폴더 경로(<lora:style/anime/x:1.0>)를 포함하여 손실 없는 모델 해상도를 제공합니다. 레거시는 파일 이름만(<lora:x:1.0>) 사용 — A1111 규칙이지만, 폴더 간 파일명 중복 시 모호할 수 있습니다.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "전체 경로(하위 폴더/이름)",
|
||||
"legacy": "레거시 A1111(이름만)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "모델명 편집",
|
||||
"editFileName": "파일명 편집",
|
||||
"editBaseModel": "베이스 모델 편집",
|
||||
"editVersionName": "버전명 편집",
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"viewOnCivitaiText": "Civitai에서 보기",
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "주의 필요",
|
||||
"error": "조치 필요"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 키"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "모델 캐시 상태"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "파일명 중복 충돌"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 버전"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "다시 실행",
|
||||
"exportBundle": "번들 내보내기"
|
||||
"exportBundle": "번들 내보내기",
|
||||
"open-settings": "설정 열기",
|
||||
"open-settings-syntax-format": "전체 경로 구문으로 전환",
|
||||
"repair-cache": "캐시 재구축",
|
||||
"resolve-filename-conflicts": "충돌 해결",
|
||||
"reload-page": "UI 새로고침"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "충돌",
|
||||
"version": "버전"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "진단 로드 실패: {message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "파일명 충돌 해결",
|
||||
"message": "중복 파일명에 4자리 해시를 추가하여 이름을 변경합니다.",
|
||||
"note": "이 작업은 디스크에 있는 파일의 이름을 변경합니다. A1111 구문 형식을 사용하는 경우 기존 워크플로우의 모델 참조를 업데이트해야 할 수 있습니다.",
|
||||
"detail": "예시: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "<strong>{groups}</strong>개 중복 그룹에서 <strong>{count}</strong>개 파일 이름을 변경합니다",
|
||||
"confirm": "파일 이름 변경",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "애플리케이션 업데이트 감지",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||
"noTags": "Без тегов",
|
||||
"autoTags": "Авто-теги",
|
||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "Бэкенд загрузки",
|
||||
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует экспериментальный внешний процесс загрузки.",
|
||||
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует рекомендуемый внешний процесс загрузки.",
|
||||
"options": {
|
||||
"python": "Python (встроенный)",
|
||||
"aria2": "aria2 (экспериментальный)"
|
||||
"aria2": "aria2 (рекомендуемый)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
|
||||
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена",
|
||||
"loraSyntaxFormat": "Формат синтаксиса LoRA",
|
||||
"loraSyntaxFormatHelp": "Формат синтаксиса LoRA. Полный путь включает подпапку (<lora:style/anime/x:1.0>) для безпотерьного разрешения модели. Устаревший использует только имя файла (<lora:x:1.0>) — соглашение A1111, может быть неоднозначным при дублировании имён файлов в разных папках.",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "Полный путь (подпапка/имя)",
|
||||
"legacy": "Устаревший A1111 (только имя)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Включить архив метаданных",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "Установить рейтинг контента для всех",
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "Редактировать название модели",
|
||||
"editFileName": "Редактировать имя файла",
|
||||
"editBaseModel": "Редактировать базовую модель",
|
||||
"editVersionName": "Редактировать название версии",
|
||||
"viewOnCivitai": "Посмотреть на Civitai",
|
||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "Рецепты не выбраны",
|
||||
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "Требует внимания",
|
||||
"error": "Требуется действие"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Запустить снова",
|
||||
"exportBundle": "Экспортировать пакет"
|
||||
"exportBundle": "Экспортировать пакет",
|
||||
"open-settings": "Open Settings",
|
||||
"open-settings-syntax-format": "Switch to Full Path Syntax",
|
||||
"repair-cache": "Rebuild Cache",
|
||||
"resolve-filename-conflicts": "Resolve Conflicts",
|
||||
"reload-page": "Reload UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "Conflicts",
|
||||
"version": "Version"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "Разрешить конфликты имён файлов",
|
||||
"message": "Переименование с добавлением 4-символьного хеша к каждому дублирующемуся имени файла.",
|
||||
"note": "Эта операция переименовывает файлы на диске. Если вы используете синтаксис A1111, ссылки на модели в существующих рабочих процессах могут потребовать обновления.",
|
||||
"detail": "Пример: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "Будет переименовано <strong>{count}</strong> файл(ов) в <strong>{groups}</strong> группе(ах) дубликатов",
|
||||
"confirm": "Переименовать файлы",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Обнаружено обновление приложения",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||
"noTags": "无标签",
|
||||
"autoTags": "自动标签",
|
||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "下载后端",
|
||||
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用实验性的外部下载进程。",
|
||||
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用推荐的外部下载进程。",
|
||||
"options": {
|
||||
"python": "Python(内置)",
|
||||
"aria2": "aria2(实验性)"
|
||||
"aria2": "aria2(推荐)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "复制 LoRA 语法时包含触发词",
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
|
||||
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词",
|
||||
"loraSyntaxFormat": "LoRA 语法格式",
|
||||
"loraSyntaxFormatHelp": "LoRA 语法格式。完整路径(Full)包含子文件夹路径 (<lora:style/anime/x:1.0>),解析精确无歧义。旧版(Legacy)仅使用文件名 (<lora:x:1.0>)——A1111 原始约定,同名文件跨文件夹时可能产生歧义。",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "完整路径(子文件夹/名称)",
|
||||
"legacy": "旧版 A1111(仅名称)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "启用元数据归档数据库",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "为所选中设置内容评级",
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"repairMetadata": "修复所选中元数据",
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "编辑模型名称",
|
||||
"editFileName": "编辑文件名",
|
||||
"editBaseModel": "编辑基础模型",
|
||||
"editVersionName": "编辑版本名称",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||
"noRecipesSelected": "未选择任何配方",
|
||||
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "需要关注",
|
||||
"error": "需要处理"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 密钥"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "模型缓存健康状态"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "文件名重复冲突"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新检查",
|
||||
"exportBundle": "导出诊断包"
|
||||
"exportBundle": "导出诊断包",
|
||||
"open-settings": "打开设置",
|
||||
"open-settings-syntax-format": "切换为完整路径语法",
|
||||
"repair-cache": "重建缓存",
|
||||
"resolve-filename-conflicts": "解决冲突",
|
||||
"reload-page": "刷新 UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "冲突详情",
|
||||
"version": "版本信息"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "加载诊断结果失败:{message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "解决文件名冲突",
|
||||
"message": "通过在每个重复文件名后附加 4 位哈希值来重命名文件。",
|
||||
"note": "此操作会重命名磁盘上的文件。如果使用 A1111 语法格式,现有工作流中的模型引用可能需要更新。",
|
||||
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "将重命名 <strong>{count}</strong> 个文件(共 <strong>{groups}</strong> 组重复)",
|
||||
"confirm": "重命名文件",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "检测到应用更新",
|
||||
|
||||
@@ -232,6 +232,8 @@
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||
"noTags": "無標籤",
|
||||
"autoTags": "自動標籤",
|
||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||
@@ -267,10 +269,10 @@
|
||||
},
|
||||
"downloadBackend": {
|
||||
"label": "下載後端",
|
||||
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用實驗性的外部下載程序。",
|
||||
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用推薦的外部下載程序。",
|
||||
"options": {
|
||||
"python": "Python(內建)",
|
||||
"aria2": "aria2(實驗性)"
|
||||
"aria2": "aria2(推薦)"
|
||||
}
|
||||
},
|
||||
"aria2cPath": {
|
||||
@@ -577,7 +579,13 @@
|
||||
},
|
||||
"misc": {
|
||||
"includeTriggerWords": "在 LoRA 語法中包含觸發詞",
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
|
||||
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞",
|
||||
"loraSyntaxFormat": "LoRA 語法格式",
|
||||
"loraSyntaxFormatHelp": "LoRA 語法格式。完整路徑(Full)包含子資料夾路徑 (<lora:style/anime/x:1.0>),解析精確無歧義。舊版(Legacy)僅使用檔名 (<lora:x:1.0>)——A1111 原始約定,同名檔案跨資料夾時可能產生歧義。",
|
||||
"loraSyntaxFormatOptions": {
|
||||
"full": "完整路徑(子資料夾/名稱)",
|
||||
"legacy": "舊版 A1111(僅名稱)"
|
||||
}
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
||||
@@ -681,6 +689,7 @@
|
||||
"setContentRating": "為全部設定內容分級",
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"repairMetadata": "修復所選中元數據",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
@@ -1172,6 +1181,7 @@
|
||||
"editModelName": "編輯模型名稱",
|
||||
"editFileName": "編輯檔案名稱",
|
||||
"editBaseModel": "編輯基礎模型",
|
||||
"editVersionName": "編輯版本名稱",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
@@ -1684,6 +1694,9 @@
|
||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||
"noRecipesSelected": "未選取任何食譜",
|
||||
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||
},
|
||||
@@ -1921,9 +1934,32 @@
|
||||
"warning": "需要注意",
|
||||
"error": "需要處理"
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 金鑰"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "模型快取健康狀態"
|
||||
},
|
||||
"filename_conflicts": {
|
||||
"title": "檔案名稱重複衝突"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新執行",
|
||||
"exportBundle": "匯出套件"
|
||||
"exportBundle": "匯出套件",
|
||||
"open-settings": "開啟設定",
|
||||
"open-settings-syntax-format": "切換為完整路徑語法",
|
||||
"repair-cache": "重建快取",
|
||||
"resolve-filename-conflicts": "解決衝突",
|
||||
"reload-page": "重新載入 UI"
|
||||
},
|
||||
"labels": {
|
||||
"conflicts": "衝突詳情",
|
||||
"version": "版本"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "載入診斷失敗:{message}",
|
||||
@@ -1935,6 +1971,15 @@
|
||||
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
|
||||
}
|
||||
},
|
||||
"conflictConfirm": {
|
||||
"title": "解決檔案名稱衝突",
|
||||
"message": "通過在每個重複檔案名稱後附加 4 位元哈希值來重新命名檔案。",
|
||||
"note": "此操作會重新命名磁碟上的檔案。如果使用 A1111 語法格式,現有工作流程中的模型參考可能需要更新。",
|
||||
"detail": "示例:<code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "將重新命名 <strong>{count}</strong> 個檔案(共 <strong>{groups}</strong> 組重複)",
|
||||
"confirm": "重新命名檔案",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "偵測到應用程式更新",
|
||||
|
||||
@@ -9,6 +9,7 @@ from ..utils.utils import get_lora_info_absolute
|
||||
from .utils import (
|
||||
FlexibleOptionalInputType,
|
||||
any_type,
|
||||
apply_lora_syntax_format,
|
||||
detect_nunchaku_model_kind,
|
||||
extract_lora_name,
|
||||
get_loras_list,
|
||||
@@ -52,7 +53,7 @@ def _collect_widget_entries(kwargs):
|
||||
for lora in get_loras_list(kwargs):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
lora_name = lora["name"]
|
||||
lora_name = apply_lora_syntax_format(lora["name"])
|
||||
model_strength = float(lora["strength"])
|
||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
||||
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list
|
||||
|
||||
import logging
|
||||
|
||||
@@ -48,7 +48,7 @@ class LoraStackerLM:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
lora_name = apply_lora_syntax_format(lora['name'])
|
||||
model_strength = float(lora['strength'])
|
||||
# Get clip strength - use model strength as default if not specified
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
@@ -44,11 +44,29 @@ import folder_paths # type: ignore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_lora_syntax_format():
|
||||
try:
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
return get_settings_manager().get("lora_syntax_format", "legacy")
|
||||
except Exception:
|
||||
return "legacy"
|
||||
|
||||
|
||||
def apply_lora_syntax_format(name):
|
||||
fmt = get_lora_syntax_format()
|
||||
if fmt == "legacy":
|
||||
return name.replace("\\", "/").rstrip("/").split("/")[-1]
|
||||
return name
|
||||
|
||||
|
||||
def extract_lora_name(lora_path):
|
||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
||||
# Get the basename without extension
|
||||
basename = os.path.basename(lora_path)
|
||||
return os.path.splitext(basename)[0]
|
||||
normalized = lora_path.replace("\\", "/")
|
||||
basename = os.path.basename(normalized)
|
||||
name_no_ext = os.path.splitext(basename)[0]
|
||||
dirname = os.path.dirname(normalized)
|
||||
if dirname and dirname not in (".", "/") and not normalized.startswith("/"):
|
||||
return apply_lora_syntax_format(f"{dirname}/{name_no_ext}")
|
||||
return apply_lora_syntax_format(name_no_ext)
|
||||
|
||||
|
||||
def get_loras_list(kwargs):
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from abc import ABC, abstractmethod
|
||||
from ..config import config
|
||||
from ..utils.constants import VALID_LORA_TYPES
|
||||
from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -173,6 +173,20 @@ class RecipeMetadataParser(ABC):
|
||||
checkpoint['isDeleted'] = True
|
||||
return checkpoint
|
||||
|
||||
# Validate that the model type is actually a checkpoint.
|
||||
# Unlike populate_lora_from_civitai which has this check,
|
||||
# this function was missing type validation — allowing LoRA
|
||||
# version data to be saved as the recipe's checkpoint when the
|
||||
# wrong version ID was passed downstream (fixed in v2.7+).
|
||||
model_type = civitai_data.get('model', {}).get('type', '').lower()
|
||||
if model_type not in VALID_CHECKPOINT_SUB_TYPES:
|
||||
logger.warning(
|
||||
f"Cannot populate checkpoint: model version {civitai_data.get('id')} "
|
||||
f"has type '{model_type}', expected one of {VALID_CHECKPOINT_SUB_TYPES}. "
|
||||
f"Skipping checkpoint enrichment."
|
||||
)
|
||||
return checkpoint
|
||||
|
||||
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||
checkpoint['name'] = civitai_data['model']['name']
|
||||
|
||||
|
||||
@@ -185,8 +185,67 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
# Process standard resources array
|
||||
if "resources" in metadata and isinstance(metadata["resources"], list):
|
||||
for resource in metadata["resources"]:
|
||||
resource_type = resource.get("type", "lora")
|
||||
|
||||
# Track resources with type "model" — these are checkpoint models.
|
||||
# The resources array is the most reliable source for checkpoint
|
||||
# identification because it has an explicit type field and hash,
|
||||
# unlike modelVersionIds which is a flat list with no type info.
|
||||
if resource_type == "model":
|
||||
checkpoint_entry = {
|
||||
"id": 0,
|
||||
"modelId": 0,
|
||||
"name": resource.get("name", "Unknown Model"),
|
||||
"version": "",
|
||||
"type": resource.get("type", "model"),
|
||||
"existsLocally": False,
|
||||
"localPath": None,
|
||||
"file_name": resource.get("name", ""),
|
||||
"hash": resource.get("hash", "") or "",
|
||||
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
|
||||
# Try to look up base model from the checkpoint hash
|
||||
if checkpoint_entry["hash"] and metadata_provider:
|
||||
try:
|
||||
civitai_info = (
|
||||
await metadata_provider.get_model_by_hash(
|
||||
checkpoint_entry["hash"]
|
||||
)
|
||||
)
|
||||
civitai_data, error_msg = (
|
||||
(civitai_info, None)
|
||||
if not isinstance(civitai_info, tuple)
|
||||
else civitai_info
|
||||
)
|
||||
if civitai_data and error_msg != "Model not found":
|
||||
if 'model' in civitai_data and 'name' in civitai_data['model']:
|
||||
checkpoint_entry['name'] = civitai_data['model']['name']
|
||||
checkpoint_entry['id'] = civitai_data.get('id', 0)
|
||||
checkpoint_entry['modelId'] = civitai_data.get('modelId', 0)
|
||||
if 'name' in civitai_data:
|
||||
checkpoint_entry['version'] = civitai_data['name']
|
||||
base_model = civitai_data.get('baseModel', '')
|
||||
if base_model:
|
||||
checkpoint_entry['baseModel'] = base_model
|
||||
if not result['base_model']:
|
||||
result['base_model'] = base_model
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching checkpoint info for hash "
|
||||
f"{checkpoint_entry['hash']}: {e}"
|
||||
)
|
||||
|
||||
if result["model"] is None:
|
||||
result["model"] = checkpoint_entry
|
||||
continue
|
||||
|
||||
# Modified to process resources without a type field as potential LoRAs
|
||||
if resource.get("type", "lora") == "lora":
|
||||
if resource_type == "lora":
|
||||
lora_hash = resource.get("hash", "")
|
||||
|
||||
# Try to get hash from the hashes field if not present in resource
|
||||
|
||||
@@ -686,6 +686,9 @@ class DoctorHandler:
|
||||
)
|
||||
|
||||
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||
if self._settings.get("lora_syntax_format", "legacy") == "full":
|
||||
return web.json_response({"success": True, "renamed": [], "count": 0})
|
||||
|
||||
renamed: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
@@ -990,11 +993,29 @@ class DoctorHandler:
|
||||
}
|
||||
|
||||
async def _check_filename_conflicts(self) -> dict[str, Any]:
|
||||
# When full path syntax is active, duplicate filenames across subfolders
|
||||
# are not ambiguous (<lora:subfolder/name:strength>), so skip the check.
|
||||
if self._settings.get("lora_syntax_format", "legacy") == "full":
|
||||
return {
|
||||
"id": "filename_conflicts",
|
||||
"title": "Duplicate Filename Conflicts",
|
||||
"status": "ok",
|
||||
"summary": "Full path syntax is active — duplicate filenames across folders are not ambiguous.",
|
||||
"details": [],
|
||||
"actions": [],
|
||||
}
|
||||
|
||||
all_conflicts: list[dict[str, Any]] = []
|
||||
total_conflict_groups = 0
|
||||
total_conflict_files = 0
|
||||
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
# Duplicate filename detection targets LoRAs which use basename-only
|
||||
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
||||
# models via relative paths with extensions, so conflicts there would
|
||||
# be false positives.
|
||||
if model_type != "lora":
|
||||
continue
|
||||
try:
|
||||
scanner = await factory()
|
||||
hash_index = getattr(scanner, "_hash_index", None)
|
||||
@@ -1042,12 +1063,22 @@ class DoctorHandler:
|
||||
"total_conflict_files": total_conflict_files,
|
||||
}
|
||||
]
|
||||
for conflict in all_conflicts:
|
||||
|
||||
# Show at most 5 conflict groups inline; note any remainder.
|
||||
MAX_VISIBLE_CONFLICTS = 5
|
||||
visible_conflicts = all_conflicts[:MAX_VISIBLE_CONFLICTS]
|
||||
for conflict in visible_conflicts:
|
||||
details.append(
|
||||
f"[{conflict['label']}] '{conflict['filename']}' "
|
||||
f"'{conflict['filename']}' "
|
||||
f"found in {len(conflict['paths'])} locations"
|
||||
)
|
||||
|
||||
hidden_count = len(all_conflicts) - MAX_VISIBLE_CONFLICTS
|
||||
if hidden_count > 0:
|
||||
details.append(
|
||||
f"...and {hidden_count} more duplicate filename group(s)"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": "filename_conflicts",
|
||||
"title": "Duplicate Filename Conflicts",
|
||||
@@ -1058,7 +1089,11 @@ class DoctorHandler:
|
||||
{
|
||||
"id": "resolve-filename-conflicts",
|
||||
"label": "Resolve Conflicts",
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "open-settings-syntax-format",
|
||||
"label": "Switch to Full Path Syntax",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -788,7 +788,7 @@ class ModelManagementHandler:
|
||||
|
||||
metadata_updates = {k: v for k, v in data.items() if k != "file_path"}
|
||||
|
||||
await self._metadata_sync.save_metadata_updates(
|
||||
updated_metadata = await self._metadata_sync.save_metadata_updates(
|
||||
file_path=file_path,
|
||||
updates=metadata_updates,
|
||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||
@@ -799,7 +799,12 @@ class ModelManagementHandler:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
await cache.resort()
|
||||
|
||||
return web.json_response({"success": True})
|
||||
from ...services.auto_tag_service import extract_auto_tags
|
||||
auto_tags = extract_auto_tags(updated_metadata)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "auto_tags": auto_tags}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error saving metadata: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
@@ -816,14 +821,16 @@ class ModelManagementHandler:
|
||||
if not isinstance(new_tags, list):
|
||||
return web.Response(text="Tags must be a list", status=400)
|
||||
|
||||
tags = await self._tag_update_service.add_tags(
|
||||
tags, auto_tags = await self._tag_update_service.add_tags(
|
||||
file_path=file_path,
|
||||
new_tags=new_tags,
|
||||
metadata_loader=self._metadata_sync.load_local_metadata,
|
||||
update_cache=self._service.scanner.update_single_model_cache,
|
||||
)
|
||||
|
||||
return web.json_response({"success": True, "tags": tags})
|
||||
return web.json_response(
|
||||
{"success": True, "tags": tags, "auto_tags": auto_tags}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error adding tags: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
@@ -1170,6 +1177,12 @@ class ModelQueryHandler:
|
||||
|
||||
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
settings = get_settings_manager()
|
||||
if settings.get("lora_syntax_format", "legacy") == "full":
|
||||
return web.json_response(
|
||||
{"success": True, "conflicts": [], "count": 0}
|
||||
)
|
||||
|
||||
duplicates = self._service.find_duplicate_filenames()
|
||||
result = []
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,6 +13,12 @@ from ...config import config as global_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK_SIZE = 256 * 1024 # 256 KB
|
||||
|
||||
# Video file extensions that bypass native sendfile on Windows
|
||||
# to avoid IOCP/ProactorEventLoop crashes during client disconnect.
|
||||
_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".mov", ".avi", ".mkv"})
|
||||
|
||||
|
||||
class PreviewHandler:
|
||||
"""Serve preview assets for the active library at request time."""
|
||||
@@ -48,8 +55,51 @@ class PreviewHandler:
|
||||
logger.debug("Preview file not found at %s", str(resolved))
|
||||
raise web.HTTPNotFound(text="Preview file not found")
|
||||
|
||||
# Video files: stream manually to avoid Windows native sendfile crash.
|
||||
# aiohttp's FileResponse uses _sendfile_native on Windows (IOCP-based),
|
||||
# which breaks when the client disconnects mid-transfer — this happens
|
||||
# constantly when users scroll through a gallery of animated previews.
|
||||
suffix = resolved.suffix.lower()
|
||||
if suffix in _VIDEO_EXTENSIONS:
|
||||
return await self._stream_file(request, resolved)
|
||||
|
||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
|
||||
return web.FileResponse(path=resolved, chunk_size=_CHUNK_SIZE)
|
||||
|
||||
async def _stream_file(
|
||||
self, request: web.Request, path: Path
|
||||
) -> web.StreamResponse:
|
||||
"""Stream a file chunk-by-chunk, bypassing native sendfile.
|
||||
|
||||
This avoids the Windows IOCP ``_sendfile_native`` crash that occurs
|
||||
when the client disconnects during a large file transfer.
|
||||
"""
|
||||
content_type, _ = mimetypes.guess_type(str(path))
|
||||
if content_type is None:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
file_size = path.stat().st_size
|
||||
resp = web.StreamResponse()
|
||||
resp.content_type = content_type
|
||||
resp.content_length = file_size
|
||||
|
||||
await resp.prepare(request)
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
await resp.write(chunk)
|
||||
except (ConnectionResetError, ConnectionAbortedError):
|
||||
# Client disconnected during streaming — expected when scrolling
|
||||
# rapidly through a library with animated previews.
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug("I/O error streaming preview %s: %s", path, exc)
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
__all__ = ["PreviewHandler"]
|
||||
|
||||
@@ -87,6 +87,7 @@ class RecipeHandlerSet:
|
||||
"repair_recipes": self.management.repair_recipes,
|
||||
"cancel_repair": self.management.cancel_repair,
|
||||
"repair_recipe": self.management.repair_recipe,
|
||||
"repair_recipes_bulk": self.management.repair_recipes_bulk,
|
||||
"get_repair_progress": self.management.get_repair_progress,
|
||||
"start_batch_import": self.batch_import.start_batch_import,
|
||||
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
||||
@@ -706,6 +707,69 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def repair_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||
"""Bulk repair metadata for multiple recipes by their IDs.
|
||||
|
||||
Accepts a JSON body with a "recipe_ids" array and iterates
|
||||
repair_recipe_by_id over each entry, collecting statistics.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe scanner unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
data = await request.json()
|
||||
recipe_ids = data.get("recipe_ids", [])
|
||||
if not recipe_ids:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "recipe_ids are required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
total = len(recipe_ids)
|
||||
repaired = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
recipes = []
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
try:
|
||||
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||
if result.get("success"):
|
||||
repaired += result.get("repaired", 0)
|
||||
skipped += result.get("skipped", 0)
|
||||
if result.get("recipe"):
|
||||
recipes.append(result["recipe"])
|
||||
else:
|
||||
errors += 1
|
||||
except RecipeNotFoundError:
|
||||
skipped += 1
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error repairing recipe %s: %s", recipe_id, exc
|
||||
)
|
||||
errors += 1
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"total": total,
|
||||
"repaired": repaired,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"recipes": recipes,
|
||||
})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error performing bulk repair: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
|
||||
async def repair_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -1293,11 +1357,18 @@ class RecipeManagementHandler:
|
||||
image_info.get("meta") if civitai_image_id and image_info else None
|
||||
)
|
||||
if civitai_image_id and image_info:
|
||||
# modelVersionId (singular) — the primary version for this
|
||||
# image on CivitAI. May be absent, or may *not* be the
|
||||
# checkpoint (e.g. when the image was generated with a LoRA
|
||||
# as the primary subject). When absent, DO NOT fall back to
|
||||
# modelVersionIds[0] — that array mixes checkpoints, LoRAs,
|
||||
# and other model version IDs without ordering guarantees.
|
||||
# The downstream enrichment flow will find the real
|
||||
# checkpoint via meta.resources (type:"model" hash) or
|
||||
# meta.civitaiResources (type:"checkpoint" version ID), so
|
||||
# leaving model_ver_id as None is safe and avoids the bug
|
||||
# where a LoRA version ID was treated as the checkpoint.
|
||||
model_ver_id = image_info.get("modelVersionId")
|
||||
if not model_ver_id:
|
||||
ids = image_info.get("modelVersionIds")
|
||||
if isinstance(ids, list) and ids:
|
||||
model_ver_id = ids[0]
|
||||
|
||||
# Inject root-level modelVersionIds into meta so downstream
|
||||
# parsers (CivitaiApiMetadataParser) can discover ALL resources
|
||||
|
||||
@@ -58,6 +58,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
||||
RouteDefinition(
|
||||
|
||||
@@ -14,12 +14,30 @@ from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .downloader import DownloadProgress, get_downloader
|
||||
from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _try_certifi_ca_path() -> str | None:
|
||||
"""Return the certifi CA bundle path if available, else None."""
|
||||
try:
|
||||
import certifi # type: ignore[import-untyped]
|
||||
|
||||
path = certifi.where()
|
||||
if os.path.isfile(path):
|
||||
logger.debug(
|
||||
"aria2 --ca-certificate: using certifi CA bundle at %s", path
|
||||
)
|
||||
return path
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logger.debug("aria2 --ca-certificate: certifi not available")
|
||||
return None
|
||||
|
||||
|
||||
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
"https://civitai.com/api/download/",
|
||||
"https://civitai.red/api/download/",
|
||||
@@ -39,7 +57,7 @@ class Aria2Transfer:
|
||||
|
||||
|
||||
class Aria2Downloader:
|
||||
"""Manage an aria2 RPC daemon for experimental model downloads."""
|
||||
"""Manage an aria2 RPC daemon for recommended model downloads."""
|
||||
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
@@ -391,6 +409,15 @@ class Aria2Downloader:
|
||||
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
|
||||
)
|
||||
except aiohttp.ClientError as exc:
|
||||
if is_ssl_cert_verify_error(exc):
|
||||
logger.error(
|
||||
"SSL certificate verification failed during Civitai redirect "
|
||||
"resolution for %s. This is usually caused by an outdated CA "
|
||||
"certificate bundle. Recommended fixes:\n"
|
||||
" 1. pip install --upgrade certifi\n"
|
||||
" 2. pip install pip-system-certs",
|
||||
url,
|
||||
)
|
||||
raise Aria2Error(
|
||||
f"Failed to resolve authenticated Civitai redirect: {exc}"
|
||||
) from exc
|
||||
@@ -414,6 +441,11 @@ class Aria2Downloader:
|
||||
f"--rpc-listen-port={self._rpc_port}",
|
||||
f"--rpc-secret={self._rpc_secret}",
|
||||
"--check-certificate=true",
|
||||
# Point aria2 at certifi's CA bundle when available so it uses
|
||||
# the same certificate store as Python downloads.
|
||||
*((
|
||||
f"--ca-certificate={ca_cert}",
|
||||
) if (ca_cert := _try_certifi_ca_path()) else ()),
|
||||
"--allow-overwrite=true",
|
||||
"--auto-file-renaming=false",
|
||||
"--file-allocation=none",
|
||||
|
||||
@@ -76,46 +76,64 @@ def _collect_sources(model_data: Dict) -> List[str]:
|
||||
def extract_auto_tags(model_data: Dict) -> List[str]:
|
||||
"""Extract auto-detected tags from model metadata.
|
||||
|
||||
Matches predefined patterns against filename, base_model, and
|
||||
CivitAI version name. Returns a sorted, deduplicated list of tag labels.
|
||||
Uses a two-layer approach:
|
||||
Layer 1 — Regex-based detection against filename, base_model, and
|
||||
CivitAI version name.
|
||||
Layer 2 — Merge in any user-defined tags that overlap with known
|
||||
auto-tag categories. This provides a manual fallback when
|
||||
auto-detection fails (e.g. "I2V HN" or unlabeled models).
|
||||
|
||||
HIGH/LOW tags are only returned when the base_model indicates a Wan
|
||||
family model — no other model architecture uses this distinction.
|
||||
|
||||
Args:
|
||||
model_data: Model metadata dict with keys:
|
||||
file_name, base_model, civitai (with optional 'name' field).
|
||||
file_name, base_model, civitai (with optional 'name' field),
|
||||
tags (user-defined tag list, used as fallback).
|
||||
|
||||
Returns:
|
||||
Sorted list of unique auto-tag strings (e.g. ["I2V"]).
|
||||
"""
|
||||
sources = _collect_sources(model_data)
|
||||
if not sources:
|
||||
return []
|
||||
|
||||
base_model = model_data.get("base_model", "")
|
||||
is_wan = "wan" in base_model.lower()
|
||||
|
||||
found: Set[str] = set()
|
||||
|
||||
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
|
||||
if label in ("HIGH", "LOW"):
|
||||
if not is_wan:
|
||||
continue
|
||||
# Use case-insensitive character class + case-sensitive boundary,
|
||||
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
|
||||
# Boundary: not followed by lowercase letter (= word has ended).
|
||||
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
|
||||
if label == "LOW":
|
||||
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
|
||||
# ── Layer 1: regex-based detection ────────────────────────────
|
||||
if sources:
|
||||
for label, pattern in AUTO_TAG_CATEGORIES.items():
|
||||
# HIGH/LOW are Wan-specific — skip for non-Wan to avoid noise
|
||||
if label in ("HIGH", "LOW"):
|
||||
if not is_wan:
|
||||
continue
|
||||
# Use case-insensitive character class + case-sensitive boundary,
|
||||
# so "HighNoise" (camelCase) matches but "highlight" doesn't.
|
||||
# Boundary: not followed by lowercase letter (= word has ended).
|
||||
ci = "".join(f"[{c.lower()}{c.upper()}]" for c in label)
|
||||
if label == "LOW":
|
||||
regex = re.compile(r"(?<![Ff])" + ci + r"(?![a-z])")
|
||||
else:
|
||||
regex = re.compile(ci + r"(?![a-z])")
|
||||
else:
|
||||
regex = re.compile(ci + r"(?![a-z])")
|
||||
else:
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
for source in sources:
|
||||
if regex.search(source):
|
||||
found.add(label)
|
||||
break
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
for source in sources:
|
||||
if regex.search(source):
|
||||
found.add(label)
|
||||
break
|
||||
|
||||
# ── Layer 2: user-defined tags as manual fallback ─────────────
|
||||
# When auto-detection fails (abbreviated names like "Hi"/"Lo",
|
||||
# "I2V HN", or unlabeled models), users can add canonical tags
|
||||
# (HIGH, LOW, I2V, etc.) to the model's regular tags for correct
|
||||
# badge display and filtering. Matching is case-insensitive so
|
||||
# "high"/"High"/"HIGH" all resolve to the canonical label.
|
||||
user_tags = model_data.get("tags")
|
||||
if user_tags:
|
||||
label_map = {label.lower(): label for label in AUTO_TAG_CATEGORIES}
|
||||
for t in user_tags:
|
||||
canonical = label_map.get(t.lower())
|
||||
if canonical:
|
||||
found.add(canonical)
|
||||
|
||||
return sorted(found)
|
||||
|
||||
@@ -870,22 +870,75 @@ class BaseModelService(ABC):
|
||||
"""Get the static preview URL for a model file"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
name_normalized = model_name.replace("\\", "/")
|
||||
name_no_ext = name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if name_no_ext.lower().endswith(ext):
|
||||
name_no_ext = name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in name_no_ext
|
||||
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model["file_name"] == model_name:
|
||||
file_name = model.get("file_name", "")
|
||||
folder = model.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||
preview_url = model.get("preview_url")
|
||||
if preview_url:
|
||||
from ..config import config
|
||||
|
||||
return config.get_preview_static_url(preview_url)
|
||||
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = model
|
||||
elif best_fallback is None:
|
||||
best_fallback = model
|
||||
|
||||
if best_fallback:
|
||||
preview_url = best_fallback.get("preview_url")
|
||||
if preview_url:
|
||||
from ..config import config
|
||||
|
||||
return config.get_preview_static_url(preview_url)
|
||||
|
||||
return "/loras_static/images/no-preview.png"
|
||||
|
||||
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
|
||||
"""Get the Civitai URL for a model file"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
name_normalized = model_name.replace("\\", "/")
|
||||
name_no_ext = name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if name_no_ext.lower().endswith(ext):
|
||||
name_no_ext = name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in name_no_ext
|
||||
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model["file_name"] == model_name:
|
||||
file_name = model.get("file_name", "")
|
||||
folder = model.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||
civitai_data = model.get("civitai", {})
|
||||
model_id = civitai_data.get("modelId")
|
||||
version_id = civitai_data.get("id")
|
||||
@@ -904,6 +957,27 @@ class BaseModelService(ABC):
|
||||
"version_id": str(version_id) if version_id else None,
|
||||
}
|
||||
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = model
|
||||
elif best_fallback is None:
|
||||
best_fallback = model
|
||||
|
||||
if best_fallback:
|
||||
civitai_data = best_fallback.get("civitai", {})
|
||||
model_id = civitai_data.get("modelId")
|
||||
if model_id:
|
||||
version_id = civitai_data.get("id")
|
||||
civitai_host = self.settings.get("civitai_host", "civitai.com")
|
||||
civitai_url = build_civitai_model_page_url(
|
||||
model_id, version_id, host=civitai_host
|
||||
)
|
||||
return {
|
||||
"civitai_url": civitai_url,
|
||||
"model_id": str(model_id),
|
||||
"version_id": str(version_id) if version_id else None,
|
||||
}
|
||||
|
||||
return {"civitai_url": None, "model_id": None, "version_id": None}
|
||||
|
||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||
from .connectivity_guard import (
|
||||
OFFLINE_FRIENDLY_MESSAGE,
|
||||
@@ -45,6 +46,14 @@ class CivitaiClient:
|
||||
self._initialized = True
|
||||
|
||||
self.base_url = "https://civitai.red/api/v1"
|
||||
# In-memory cache to avoid redundant get_model_version_info calls
|
||||
# within the same import/scan flow. Only successful results are cached.
|
||||
# Uses OrderedDict with LRU eviction at MAX_CACHE_ENTRIES to prevent
|
||||
# unbounded growth in long-running server processes.
|
||||
self._version_info_cache: OrderedDict[
|
||||
str, Tuple[Optional[Dict], Optional[str]]
|
||||
] = OrderedDict()
|
||||
self._MAX_CACHE_ENTRIES = 500
|
||||
|
||||
def _build_image_info_url(self, image_id: str) -> str:
|
||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
@@ -57,22 +66,57 @@ class CivitaiClient:
|
||||
use_auth: bool = False,
|
||||
**kwargs,
|
||||
) -> Tuple[bool, Dict | str]:
|
||||
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
||||
"""Wrapper around downloader.make_request that surfaces rate limits,
|
||||
with retry for transient server errors (5xx, Cloudflare 524, network flakiness)."""
|
||||
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
method,
|
||||
url,
|
||||
use_auth=use_auth,
|
||||
**kwargs,
|
||||
)
|
||||
if not success and isinstance(result, RateLimitError):
|
||||
if result.provider is None:
|
||||
result.provider = "civitai_api"
|
||||
raise result
|
||||
if not success and is_offline_cooldown_error(result):
|
||||
return False, OFFLINE_FRIENDLY_MESSAGE
|
||||
return success, result
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
method,
|
||||
url,
|
||||
use_auth=use_auth,
|
||||
**kwargs,
|
||||
)
|
||||
if success:
|
||||
return True, result
|
||||
|
||||
if isinstance(result, RateLimitError):
|
||||
if result.provider is None:
|
||||
result.provider = "civitai_api"
|
||||
raise result
|
||||
|
||||
if is_offline_cooldown_error(result):
|
||||
return False, OFFLINE_FRIENDLY_MESSAGE
|
||||
|
||||
# Transient server error — retry with exponential backoff
|
||||
if self._is_transient_server_error(str(result)):
|
||||
if attempt < max_retries - 1:
|
||||
wait = 2**attempt # 1s, 2s, 4s
|
||||
logger.info(
|
||||
"Transient error on %s %s, retrying in %ds "
|
||||
"(attempt %d/%d): %s",
|
||||
method,
|
||||
url,
|
||||
wait,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
result,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
logger.warning(
|
||||
"All %d retries exhausted for %s %s: %s",
|
||||
max_retries,
|
||||
method,
|
||||
url,
|
||||
result,
|
||||
)
|
||||
return False, result
|
||||
|
||||
return False, result
|
||||
|
||||
return False, "Unexpected error in _make_request"
|
||||
|
||||
@staticmethod
|
||||
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
||||
@@ -201,6 +245,29 @@ class CivitaiClient:
|
||||
|
||||
return _from_value(payload)
|
||||
|
||||
@staticmethod
|
||||
def _is_transient_server_error(message: str) -> bool:
|
||||
"""Return True when the message indicates a transient upstream failure.
|
||||
|
||||
Recognises Cloudflare 524, generic 5xx, and connectivity-level flakiness
|
||||
that should not be treated as a permanent failure.
|
||||
"""
|
||||
normalized = message.lower()
|
||||
if "status 5" in normalized or "status 524" in normalized:
|
||||
return True
|
||||
if any(
|
||||
keyword in normalized
|
||||
for keyword in (
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"temporary failure",
|
||||
"name resolution",
|
||||
"connection closed",
|
||||
)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
@@ -223,6 +290,13 @@ class CivitaiClient:
|
||||
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
|
||||
return None
|
||||
if message:
|
||||
if self._is_transient_server_error(message):
|
||||
logger.info(
|
||||
"Transient server error for model %s: %s",
|
||||
model_id,
|
||||
message,
|
||||
)
|
||||
return None
|
||||
raise RuntimeError(message)
|
||||
return None
|
||||
except RateLimitError:
|
||||
@@ -336,6 +410,25 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
target_version = self._select_target_version(model_data, model_id, version_id)
|
||||
|
||||
# If modelVersions is empty (e.g. CivitAI cache lag for newly published
|
||||
# models) but a specific version_id is known, fall back to fetching the
|
||||
# version directly via the individual model-versions endpoint, then
|
||||
# enrich it with the model-level data we already have.
|
||||
if target_version is None and version_id is not None:
|
||||
logger.info(
|
||||
"modelVersions empty for model %s; falling back to direct "
|
||||
"version lookup for %s",
|
||||
model_id,
|
||||
version_id,
|
||||
)
|
||||
version = await self._fetch_version_by_id(version_id)
|
||||
if version:
|
||||
self._enrich_version_with_model_data(version, model_data)
|
||||
self._remove_comfy_metadata(version)
|
||||
return version
|
||||
return None
|
||||
|
||||
if target_version is None:
|
||||
return None
|
||||
|
||||
@@ -482,6 +575,14 @@ class CivitaiClient:
|
||||
- The model version data or None if not found
|
||||
- An error message if there was an error, or None on success
|
||||
"""
|
||||
# In-memory cache avoids redundant API calls within the same
|
||||
# import/scan flow (e.g. _resolve_base_model_from_checkpoint
|
||||
# followed by _resolve_and_populate_checkpoint with the same id).
|
||||
if version_id in self._version_info_cache:
|
||||
logger.debug("Cache hit for model version info: %s", version_id)
|
||||
self._version_info_cache.move_to_end(version_id) # LRU bump
|
||||
return self._version_info_cache[version_id]
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/model-versions/{version_id}"
|
||||
|
||||
@@ -491,6 +592,11 @@ class CivitaiClient:
|
||||
if success:
|
||||
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||
self._remove_comfy_metadata(result)
|
||||
self._version_info_cache[version_id] = (result, None)
|
||||
self._version_info_cache.move_to_end(version_id)
|
||||
# Evict oldest entry when over capacity
|
||||
if len(self._version_info_cache) > self._MAX_CACHE_ENTRIES:
|
||||
self._version_info_cache.popitem(last=False)
|
||||
return result, None
|
||||
|
||||
# Handle specific error cases
|
||||
@@ -532,6 +638,13 @@ class CivitaiClient:
|
||||
if not success:
|
||||
if is_expected_offline_error(result):
|
||||
return None
|
||||
if self._is_transient_server_error(str(result)):
|
||||
logger.info(
|
||||
"Transient server error fetching image info for ID %s: %s",
|
||||
image_id,
|
||||
result,
|
||||
)
|
||||
return None
|
||||
logger.error(
|
||||
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||
image_id,
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..utils.constants import (
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.file_utils import calculate_sha256
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -2239,8 +2240,11 @@ class DownloadManager:
|
||||
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
# Update size to actual downloaded file size
|
||||
entry.size = os.path.getsize(file_path)
|
||||
# Use SHA256 from API metadata (already set in from_civitai_info)
|
||||
# Do not recalculate to avoid blocking during ComfyUI execution
|
||||
# Compute SHA256 locally when the API response didn't include it
|
||||
if not entry.sha256:
|
||||
sha256 = await calculate_sha256(file_path)
|
||||
if sha256:
|
||||
entry.sha256 = sha256.lower()
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
@@ -13,6 +13,7 @@ This module provides a centralized download service with:
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import ssl
|
||||
import aiohttp
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
@@ -31,6 +32,20 @@ from .errors import RateLimitError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_ssl_cert_verify_error(exc: BaseException) -> bool:
|
||||
"""Check if an exception represents an SSL certificate verification failure.
|
||||
|
||||
Matches ``ssl.SSLCertVerificationError``, ``aiohttp.ClientConnectorCertificateError``
|
||||
(which wraps the former), and falls back to the standard OpenSSL error text.
|
||||
"""
|
||||
if isinstance(exc, ssl.SSLCertVerificationError):
|
||||
return True
|
||||
cert_error = getattr(exc, "certificate_error", None)
|
||||
if isinstance(cert_error, ssl.SSLCertVerificationError):
|
||||
return True
|
||||
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadProgress:
|
||||
"""Snapshot of a download transfer at a moment in time."""
|
||||
@@ -265,9 +280,22 @@ class Downloader:
|
||||
logger.debug(
|
||||
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
|
||||
)
|
||||
# Build SSL context: prefer certifi's CA bundle for broader
|
||||
# CA coverage across different Python environments (especially
|
||||
# embedded/compatibility Python builds).
|
||||
try:
|
||||
import certifi # type: ignore[import-untyped]
|
||||
|
||||
ca_path = certifi.where()
|
||||
ssl_context = ssl.create_default_context(cafile=ca_path)
|
||||
logger.debug("SSL: using certifi CA bundle at %s", ca_path)
|
||||
except (ImportError, FileNotFoundError, ValueError, OSError):
|
||||
ssl_context = ssl.create_default_context()
|
||||
logger.debug("SSL: certifi unavailable; using system default CA bundle")
|
||||
|
||||
# Optimize TCP connection parameters
|
||||
connector = aiohttp.TCPConnector(
|
||||
ssl=True,
|
||||
ssl=ssl_context,
|
||||
limit=8, # Concurrent connections
|
||||
ttl_dns_cache=300, # DNS cache timeout
|
||||
force_close=False, # Keep connections for reuse
|
||||
@@ -736,6 +764,17 @@ class Downloader:
|
||||
DownloadRestartRequested,
|
||||
) as e:
|
||||
retry_count += 1
|
||||
|
||||
if is_ssl_cert_verify_error(e):
|
||||
logger.error(
|
||||
"SSL certificate verification failed when connecting to %s. "
|
||||
"This is usually caused by an outdated CA certificate bundle "
|
||||
"in the Python environment. Recommended fixes:\n"
|
||||
" 1. pip install --upgrade certifi\n"
|
||||
" 2. pip install pip-system-certs",
|
||||
url,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
||||
)
|
||||
|
||||
@@ -312,8 +312,23 @@ class LoraService(BaseModelService):
|
||||
"""Return cached raw metadata for a LoRA matching the given filename."""
|
||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||
|
||||
fn_normalized = filename.replace("\\", "/")
|
||||
fn_no_ext = fn_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if fn_no_ext.lower().endswith(ext):
|
||||
fn_no_ext = fn_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
for lora in cache.raw_data if cache else []:
|
||||
if lora.get("file_name") == filename:
|
||||
file_name = lora.get("file_name", "")
|
||||
folder = lora.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
if fn_no_ext in (file_name_no_ext, path_name):
|
||||
return lora
|
||||
|
||||
return None
|
||||
@@ -401,7 +416,10 @@ class LoraService(BaseModelService):
|
||||
locked_loras = locked_loras[:target_count]
|
||||
|
||||
# Filter out locked LoRAs from available pool
|
||||
locked_names = {lora["name"] for lora in locked_loras}
|
||||
locked_names = {
|
||||
os.path.basename(lora["name"]) if "/" in str(lora.get("name", "")) else lora["name"]
|
||||
for lora in locked_loras
|
||||
}
|
||||
available_pool = [
|
||||
l for l in available_loras if l["file_name"] not in locked_names
|
||||
]
|
||||
@@ -456,7 +474,7 @@ class LoraService(BaseModelService):
|
||||
|
||||
result_loras.append(
|
||||
{
|
||||
"name": lora["file_name"],
|
||||
"name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||
"strength": model_str,
|
||||
"clipStrength": clip_str,
|
||||
"active": True,
|
||||
@@ -672,8 +690,9 @@ class LoraService(BaseModelService):
|
||||
# Return minimal data needed for cycling
|
||||
return [
|
||||
{
|
||||
"file_name": lora["file_name"],
|
||||
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
|
||||
"model_name": lora.get("model_name", lora["file_name"]),
|
||||
"folder": lora.get("folder", ""),
|
||||
}
|
||||
for lora in available_loras
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ class ModelHashIndex:
|
||||
def __init__(self):
|
||||
self._hash_to_path: Dict[str, str] = {}
|
||||
self._filename_to_hash: Dict[str, str] = {}
|
||||
self._autov2_to_path: Dict[str, str] = {}
|
||||
# New data structures for tracking duplicates
|
||||
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
||||
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
||||
@@ -63,6 +64,9 @@ class ModelHashIndex:
|
||||
# Add new mappings
|
||||
self._hash_to_path[sha256] = file_path
|
||||
self._filename_to_hash[filename] = sha256
|
||||
# AutoV2 = first 10 chars of SHA256
|
||||
if len(sha256) >= 10:
|
||||
self._autov2_to_path[sha256[:10]] = file_path
|
||||
|
||||
def _get_filename_from_path(self, file_path: str) -> str:
|
||||
"""Extract filename without extension from path"""
|
||||
@@ -157,7 +161,12 @@ class ModelHashIndex:
|
||||
del self._duplicate_filenames[filename]
|
||||
if filename in self._filename_to_hash:
|
||||
del self._filename_to_hash[filename]
|
||||
|
||||
|
||||
# Remove from AutoV2 index
|
||||
autov2_keys_to_remove = [k for k, v in self._autov2_to_path.items() if v == file_path]
|
||||
for k in autov2_keys_to_remove:
|
||||
del self._autov2_to_path[k]
|
||||
|
||||
def remove_by_hash(self, sha256: str) -> None:
|
||||
"""Remove entry by hash"""
|
||||
sha256 = sha256.lower()
|
||||
@@ -177,6 +186,10 @@ class ModelHashIndex:
|
||||
# Remove hash-to-path mapping
|
||||
del self._hash_to_path[sha256]
|
||||
|
||||
autov2_key = sha256[:10]
|
||||
if autov2_key in self._autov2_to_path:
|
||||
del self._autov2_to_path[autov2_key]
|
||||
|
||||
# Update filename-to-hash and duplicate filenames for all paths
|
||||
for path_to_remove in paths_to_remove:
|
||||
fname = self._get_filename_from_path(path_to_remove)
|
||||
@@ -195,13 +208,24 @@ class ModelHashIndex:
|
||||
# If only one entry remains, it's no longer a duplicate
|
||||
del self._duplicate_filenames[fname]
|
||||
|
||||
def has_hash(self, sha256: str) -> bool:
|
||||
"""Check if hash exists in index"""
|
||||
return sha256.lower() in self._hash_to_path
|
||||
|
||||
def get_path(self, sha256: str) -> Optional[str]:
|
||||
"""Get file path for a hash"""
|
||||
return self._hash_to_path.get(sha256.lower())
|
||||
def has_hash(self, hash_value: str) -> bool:
|
||||
"""Check if hash exists in index (SHA256 or AutoV2)"""
|
||||
normalized = hash_value.lower()
|
||||
if normalized in self._hash_to_path:
|
||||
return True
|
||||
if len(normalized) == 10:
|
||||
return normalized in self._autov2_to_path
|
||||
return False
|
||||
|
||||
def get_path(self, hash_value: str) -> Optional[str]:
|
||||
"""Get file path for a hash (SHA256 or AutoV2)"""
|
||||
normalized = hash_value.lower()
|
||||
path = self._hash_to_path.get(normalized)
|
||||
if path is not None:
|
||||
return path
|
||||
if len(normalized) == 10:
|
||||
return self._autov2_to_path.get(normalized)
|
||||
return None
|
||||
|
||||
def get_hash(self, file_path: str) -> Optional[str]:
|
||||
"""Get hash for a file path"""
|
||||
@@ -209,13 +233,16 @@ class ModelHashIndex:
|
||||
return self._filename_to_hash.get(filename)
|
||||
|
||||
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
||||
"""Get hash for a filename without extension"""
|
||||
"""Get hash for a filename (bare basename or path-prefixed name)"""
|
||||
if "/" in filename or "\\" in filename:
|
||||
filename = os.path.splitext(os.path.basename(filename.replace("\\", "/")))[0]
|
||||
return self._filename_to_hash.get(filename)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries"""
|
||||
self._hash_to_path.clear()
|
||||
self._filename_to_hash.clear()
|
||||
self._autov2_to_path.clear()
|
||||
self._duplicate_hashes.clear()
|
||||
self._duplicate_filenames.clear()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import random
|
||||
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -482,6 +482,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
return None, "Model not found"
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
not_found_confirmed = False
|
||||
for provider, label in self._iter_providers():
|
||||
try:
|
||||
result = await self._call_with_rate_limit(
|
||||
@@ -492,8 +493,24 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
if not_found_confirmed:
|
||||
logger.debug(
|
||||
"Suppressing rate limit from %s for model %s: "
|
||||
"already confirmed as not found by another provider",
|
||||
label,
|
||||
model_id,
|
||||
)
|
||||
return None
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
except ResourceNotFoundError:
|
||||
not_found_confirmed = True
|
||||
logger.debug(
|
||||
"Provider %s reports model %s as not found",
|
||||
label,
|
||||
model_id,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
||||
continue
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Set,
|
||||
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..config import config
|
||||
from ..utils.file_utils import find_preview_file, get_preview_extension
|
||||
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
@@ -1067,6 +1067,19 @@ class ModelScanner:
|
||||
|
||||
model_data = self._build_cache_entry(metadata, folder=normalized_folder)
|
||||
|
||||
# Compute SHA256 hash when metadata provided none (e.g., CivitAI API response has empty hashes)
|
||||
if not model_data.get('sha256') and file_path:
|
||||
try:
|
||||
logger.info(f"Computing SHA256 hash for {file_path} (was empty from metadata)")
|
||||
sha256 = await calculate_sha256(file_path)
|
||||
if sha256:
|
||||
model_data['sha256'] = sha256.lower()
|
||||
if isinstance(metadata, BaseModelMetadata):
|
||||
metadata.sha256 = sha256.lower()
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compute SHA256 for {file_path}: {e}")
|
||||
|
||||
# Skip excluded models
|
||||
if model_data.get('exclude', False):
|
||||
excluded_models.append(model_data['file_path'])
|
||||
@@ -1101,7 +1114,15 @@ class ModelScanner:
|
||||
|
||||
def _log_duplicate_filename_summary(self) -> None:
|
||||
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
||||
if self._hash_index is None:
|
||||
# Duplicate filename detection is only relevant for LoRAs, which use
|
||||
# basename-only syntax (<lora:name:strength>). Checkpoints and embeddings
|
||||
# use full relative paths for resolution, so conflicts are not ambiguous.
|
||||
if self._hash_index is None or self.model_type != "lora":
|
||||
return
|
||||
|
||||
# When full path syntax is active, duplicate filenames across subfolders
|
||||
# are fully qualified, so there is no ambiguity — skip the warning.
|
||||
if get_settings_manager().get("lora_syntax_format", "legacy") == "full":
|
||||
return
|
||||
|
||||
duplicates = self._hash_index.get_duplicate_filenames()
|
||||
@@ -1473,6 +1494,15 @@ class ModelScanner:
|
||||
file_path_override=normalized_new_path,
|
||||
)
|
||||
|
||||
# Ensure sha256 is populated even when metadata doesn't have it
|
||||
if not cache_entry.get('sha256') and normalized_new_path and os.path.exists(normalized_new_path):
|
||||
try:
|
||||
sha256 = await calculate_sha256(normalized_new_path)
|
||||
if sha256:
|
||||
cache_entry['sha256'] = sha256.lower()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to compute SHA256 for {normalized_new_path}: {e}")
|
||||
|
||||
if recalculate_type:
|
||||
cache_entry = self.adjust_cached_entry(cache_entry)
|
||||
|
||||
@@ -1572,12 +1602,39 @@ class ModelScanner:
|
||||
"""Get model information by name"""
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
|
||||
name_normalized = name.replace("\\", "/")
|
||||
name_no_ext = name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if name_no_ext.lower().endswith(ext):
|
||||
name_no_ext = name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in name_no_ext
|
||||
basename = os.path.basename(name_no_ext) if has_path else name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model.get("file_name") == name:
|
||||
file_name = model.get("file_name", "")
|
||||
folder = model.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if name_no_ext == file_name_no_ext or name_no_ext == path_name:
|
||||
return model
|
||||
|
||||
return None
|
||||
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = model
|
||||
elif best_fallback is None:
|
||||
best_fallback = model
|
||||
|
||||
return best_fallback
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -1000,12 +1000,11 @@ class ModelUpdateService:
|
||||
fallback_error_message = str(exc) or "resource not found"
|
||||
mark_model_as_ignored = True
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
logger.error(
|
||||
logger.warning(
|
||||
"Failed to fetch versions for model %s (%s): %s",
|
||||
model_id,
|
||||
model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
fallback_error_message = str(exc)
|
||||
if response is not None:
|
||||
|
||||
@@ -65,7 +65,7 @@ class RecipeScanner:
|
||||
cls._instance._civitai_client = None # Will be lazily initialized
|
||||
return cls._instance
|
||||
|
||||
REPAIR_VERSION = 3
|
||||
REPAIR_VERSION = 4
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -292,6 +292,32 @@ class RecipeScanner:
|
||||
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
||||
return False
|
||||
|
||||
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
|
||||
# A checkpoint whose modelVersionId also appears in a LoRA entry is
|
||||
# definitely wrong — the CivitAI import code used to pick
|
||||
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
|
||||
# Clearing it lets the enrichment flow re-resolve the correct
|
||||
# checkpoint from CivitAI image metadata.
|
||||
cp = recipe.get("checkpoint")
|
||||
lora_mvids = {
|
||||
l.get("modelVersionId")
|
||||
for l in recipe.get("loras", [])
|
||||
if l.get("modelVersionId")
|
||||
}
|
||||
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
|
||||
cp_mvid = cp["modelVersionId"]
|
||||
logger.info(
|
||||
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
|
||||
"clearing corrupted checkpoint and removing matching LoRA entry",
|
||||
recipe.get("id"),
|
||||
cp_mvid,
|
||||
)
|
||||
recipe["checkpoint"] = None
|
||||
recipe["loras"] = [
|
||||
l for l in recipe.get("loras", [])
|
||||
if l.get("modelVersionId") != cp_mvid
|
||||
]
|
||||
|
||||
# 2. Identification: Is repair needed?
|
||||
has_checkpoint = (
|
||||
"checkpoint" in recipe
|
||||
@@ -2517,6 +2543,7 @@ class RecipeScanner:
|
||||
continue
|
||||
|
||||
file_name = None
|
||||
folder = ""
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
if (
|
||||
hash_value
|
||||
@@ -2526,6 +2553,11 @@ class RecipeScanner:
|
||||
file_path = self._lora_scanner._hash_index.get_path(hash_value)
|
||||
if file_path:
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
if lora_cache is not None:
|
||||
for cached_lora in getattr(lora_cache, "raw_data", []):
|
||||
if cached_lora.get("file_path") == file_path:
|
||||
folder = cached_lora.get("folder", "")
|
||||
break
|
||||
|
||||
if not file_name and lora.get("modelVersionId") and lora_cache is not None:
|
||||
for cached_lora in getattr(lora_cache, "raw_data", []):
|
||||
@@ -2540,13 +2572,16 @@ class RecipeScanner:
|
||||
file_name = os.path.splitext(os.path.basename(cached_path))[
|
||||
0
|
||||
]
|
||||
folder = cached_lora.get("folder", "")
|
||||
break
|
||||
|
||||
if not file_name:
|
||||
file_name = lora.get("file_name", "unknown-lora")
|
||||
folder = lora.get("folder", "")
|
||||
|
||||
lora_name = f"{folder}/{file_name}" if folder else file_name
|
||||
strength = lora.get("strength", 1.0)
|
||||
syntax_parts.append(f"<lora:{file_name}:{strength}>")
|
||||
syntax_parts.append(f"<lora:{lora_name}:{strength}>")
|
||||
|
||||
return syntax_parts
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"compact_mode": False,
|
||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||
"model_name_display": "model_name",
|
||||
"lora_syntax_format": "legacy",
|
||||
"model_card_footer_action": "replace_preview",
|
||||
"show_version_on_card": True,
|
||||
"update_flag_strategy": "same_base",
|
||||
|
||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from typing import Awaitable, Callable, Dict, List, Sequence
|
||||
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
|
||||
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
|
||||
|
||||
class TagUpdateService:
|
||||
@@ -20,9 +22,8 @@ class TagUpdateService:
|
||||
new_tags: Sequence[str],
|
||||
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
||||
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
||||
) -> List[str]:
|
||||
"""Add tags to a metadata entry while keeping case-insensitive uniqueness."""
|
||||
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Add tags to a metadata entry and return updated tags and auto_tags."""
|
||||
base, _ = os.path.splitext(file_path)
|
||||
metadata_path = f"{base}.metadata.json"
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
@@ -44,5 +45,6 @@ class TagUpdateService:
|
||||
await self._metadata_manager.save_metadata(file_path, metadata)
|
||||
await update_cache(file_path, file_path, metadata)
|
||||
|
||||
return existing_tags
|
||||
auto_tags = extract_auto_tags(metadata)
|
||||
return existing_tags, auto_tags
|
||||
|
||||
|
||||
@@ -239,9 +239,9 @@ def _resolve_commercial_bits(values: Sequence[str]) -> int:
|
||||
normalized_values.add(normalized)
|
||||
|
||||
has_sell = "sell" in normalized_values
|
||||
has_rent = has_sell or "rent" in normalized_values
|
||||
has_rentcivit = has_rent or "rentcivit" in normalized_values
|
||||
has_image = has_sell or "image" in normalized_values
|
||||
has_rent = "rent" in normalized_values
|
||||
has_rentcivit = "rentcivit" in normalized_values
|
||||
has_image = "image" in normalized_values
|
||||
|
||||
commercial_bits = (
|
||||
(1 if has_sell else 0) << 3
|
||||
|
||||
@@ -397,13 +397,12 @@ class DownloadManager:
|
||||
|
||||
models_with_hash = len(all_models_with_hash)
|
||||
|
||||
# Calculate pending count: check which models actually need processing
|
||||
# A model is pending if it has a hash, is not in processed_models,
|
||||
# and its folder doesn't exist or is empty
|
||||
# Calculate pending count: check which models actually need processing.
|
||||
# A model is pending if it has a hash, is not already processed or known-failed,
|
||||
# and its folder doesn't exist or is empty.
|
||||
pending_hashes = set()
|
||||
for model_hash, model_name in all_models_with_hash:
|
||||
if model_hash not in processed_models:
|
||||
# Check if model folder exists with files
|
||||
if model_hash not in processed_models and model_hash not in failed_models:
|
||||
model_dir = ExampleImagePathResolver.get_model_folder(
|
||||
model_hash, active_library
|
||||
)
|
||||
|
||||
@@ -15,30 +15,64 @@ def get_lora_info(lora_name):
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
lora_name_normalized = lora_name.replace("\\", "/")
|
||||
lora_name_no_ext = lora_name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if lora_name_no_ext.lower().endswith(ext):
|
||||
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in lora_name_no_ext
|
||||
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get("file_name") == lora_name:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
# Check all lora roots including extra paths
|
||||
all_roots = list(config.loras_roots or []) + list(
|
||||
config.extra_loras_roots or []
|
||||
file_name = item.get("file_name", "")
|
||||
folder = item.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if lora_name_no_ext not in (file_name_no_ext, path_name):
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = item
|
||||
elif best_fallback is None:
|
||||
best_fallback = item
|
||||
continue
|
||||
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
all_roots = list(config.loras_roots or []) + list(
|
||||
config.extra_loras_roots or []
|
||||
)
|
||||
for root in all_roots:
|
||||
root = root.replace(os.sep, "/")
|
||||
if file_path.startswith(root):
|
||||
relative_path = os.path.relpath(file_path, root).replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
for root in all_roots:
|
||||
root = root.replace(os.sep, "/")
|
||||
if file_path.startswith(root):
|
||||
relative_path = os.path.relpath(file_path, root).replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = (
|
||||
civitai.get("trainedWords", []) if civitai else []
|
||||
)
|
||||
return relative_path, trigger_words
|
||||
# If not found in any root, return path with trigger words from cache
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
trigger_words = (
|
||||
civitai.get("trainedWords", []) if civitai else []
|
||||
)
|
||||
return relative_path, trigger_words
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
if best_fallback:
|
||||
file_path = best_fallback.get("file_path")
|
||||
if file_path:
|
||||
civitai = best_fallback.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
return lora_name, []
|
||||
|
||||
try:
|
||||
@@ -77,15 +111,54 @@ def get_lora_info_absolute(lora_name):
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
lora_name_normalized = lora_name.replace("\\", "/")
|
||||
lora_name_no_ext = lora_name_normalized
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if lora_name_no_ext.lower().endswith(ext):
|
||||
lora_name_no_ext = lora_name_no_ext[: -len(ext)]
|
||||
break
|
||||
|
||||
has_path = "/" in lora_name_no_ext
|
||||
basename = os.path.basename(lora_name_no_ext) if has_path else lora_name_no_ext
|
||||
best_fallback = None
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get("file_name") == lora_name:
|
||||
file_name = item.get("file_name", "")
|
||||
folder = item.get("folder", "")
|
||||
file_name_no_ext = file_name
|
||||
for ext in (".safetensors", ".ckpt", ".pt", ".bin"):
|
||||
if file_name_no_ext.lower().endswith(ext):
|
||||
file_name_no_ext = file_name_no_ext[: -len(ext)]
|
||||
break
|
||||
path_name = f"{folder}/{file_name_no_ext}".replace("\\", "/") if folder else file_name_no_ext
|
||||
|
||||
if lora_name_no_ext == file_name_no_ext:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
# Return absolute path directly
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
if lora_name_no_ext == path_name:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
if has_path and file_name_no_ext == basename:
|
||||
if folder and lora_name_no_ext.startswith(folder.replace("\\", "/") + "/"):
|
||||
best_fallback = item
|
||||
elif best_fallback is None:
|
||||
best_fallback = item
|
||||
|
||||
if best_fallback:
|
||||
file_path = best_fallback.get("file_path")
|
||||
if file_path:
|
||||
civitai = best_fallback.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
|
||||
return lora_name, []
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.0.7"
|
||||
version = "1.0.10"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
"C:/path/to/your/checkpoints_folder",
|
||||
"C:/path/to/another/checkpoints_folder"
|
||||
],
|
||||
"unet": [
|
||||
"C:/path/to/your/diffusion_models_folder",
|
||||
"C:/path/to/another/diffusion_models_folder"
|
||||
],
|
||||
"embeddings": [
|
||||
"C:/path/to/your/embeddings_folder",
|
||||
"C:/path/to/another/embeddings_folder"
|
||||
]
|
||||
},
|
||||
"example_images_open_mode": "system",
|
||||
"example_images_local_root": "",
|
||||
"example_images_open_uri_template": "",
|
||||
"auto_organize_exclusions": []
|
||||
}
|
||||
|
||||
@@ -255,25 +255,28 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* File name copy styles */
|
||||
.file-name-wrapper {
|
||||
/* Editable inline field styles (file name, version name, etc.) */
|
||||
.file-name-wrapper,
|
||||
.version-name-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
padding: 4px 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-name-content {
|
||||
padding: 2px 4px;
|
||||
.file-name-content,
|
||||
.version-name-content {
|
||||
padding: 2px 4px 2px 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid transparent;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-name-wrapper.editing .file-name-content {
|
||||
.file-name-wrapper.editing .file-name-content,
|
||||
.version-name-wrapper.editing .version-name-content {
|
||||
border: 1px solid var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
outline: none;
|
||||
@@ -283,7 +286,8 @@
|
||||
.edit-model-name-btn,
|
||||
.edit-file-name-btn,
|
||||
.edit-base-model-btn,
|
||||
.edit-model-description-btn {
|
||||
.edit-model-description-btn,
|
||||
.edit-version-name-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
@@ -299,9 +303,11 @@
|
||||
.edit-file-name-btn.visible,
|
||||
.edit-base-model-btn.visible,
|
||||
.edit-model-description-btn.visible,
|
||||
.edit-version-name-btn.visible,
|
||||
.model-name-header:hover .edit-model-name-btn,
|
||||
.file-name-wrapper:hover .edit-file-name-btn,
|
||||
.base-model-display:hover .edit-base-model-btn,
|
||||
.version-name-wrapper:hover .edit-version-name-btn,
|
||||
.model-name-header:hover .edit-model-description-btn {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -309,14 +315,16 @@
|
||||
.edit-model-name-btn:hover,
|
||||
.edit-file-name-btn:hover,
|
||||
.edit-base-model-btn:hover,
|
||||
.edit-model-description-btn:hover {
|
||||
.edit-model-description-btn:hover,
|
||||
.edit-version-name-btn:hover {
|
||||
opacity: 0.8 !important;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .edit-model-name-btn:hover,
|
||||
[data-theme="dark"] .edit-file-name-btn:hover,
|
||||
[data-theme="dark"] .edit-base-model-btn:hover {
|
||||
[data-theme="dark"] .edit-base-model-btn:hover,
|
||||
[data-theme="dark"] .edit-version-name-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@@ -338,7 +346,7 @@
|
||||
}
|
||||
|
||||
.base-model-content {
|
||||
padding: 2px 4px;
|
||||
padding: 2px 4px 2px 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-color);
|
||||
|
||||
@@ -33,6 +33,39 @@
|
||||
animation: modalFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin: var(--space-2) 0;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .resolve-conflicts-detail {
|
||||
color: var(--text-color);
|
||||
margin: var(--space-2) 0;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .resolve-conflicts-detail code {
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
#resolveFilenameConflictsModal .resolve-conflicts-impact {
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
margin: var(--space-2) 0;
|
||||
color: var(--text-color);
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.delete-model-info,
|
||||
.exclude-model-info {
|
||||
/* Update info display styling */
|
||||
|
||||
@@ -1369,3 +1369,14 @@ input:checked + .toggle-slider:before {
|
||||
background: var(--lora-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Highlight animation for setting items targeted from Doctor actions */
|
||||
@keyframes settings-highlight-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(from var(--lora-accent) r g b / 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(from var(--lora-accent) r g b / 0.2); }
|
||||
}
|
||||
|
||||
.settings-setting-highlight {
|
||||
animation: settings-highlight-pulse 1.5s ease-in-out 3;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
@@ -422,8 +422,12 @@ export class BaseModelApiClient {
|
||||
throw new Error('Failed to save metadata');
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, data);
|
||||
return response.json();
|
||||
const result = await response.json();
|
||||
state.virtualScroller.updateSingleItem(filePath, {
|
||||
...data,
|
||||
auto_tags: result.auto_tags,
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
@@ -448,7 +452,10 @@ export class BaseModelApiClient {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.tags) {
|
||||
state.virtualScroller.updateSingleItem(filePath, { tags: result.tags });
|
||||
state.virtualScroller.updateSingleItem(filePath, {
|
||||
tags: result.tags,
|
||||
auto_tags: result.auto_tags,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -15,6 +15,7 @@ const RECIPE_ENDPOINTS = {
|
||||
move: '/api/lm/recipe/move',
|
||||
moveBulk: '/api/lm/recipes/move-bulk',
|
||||
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||
repairBulk: '/api/lm/recipes/repair-bulk',
|
||||
};
|
||||
|
||||
const RECIPE_SIDEBAR_CONFIG = {
|
||||
@@ -557,6 +558,38 @@ export class RecipeSidebarApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
async repairBulkModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
}
|
||||
|
||||
const recipeIds = filePaths
|
||||
.map((path) => extractRecipeId(path))
|
||||
.filter((id) => !!id);
|
||||
|
||||
if (recipeIds.length === 0) {
|
||||
throw new Error('No recipe IDs could be derived from file paths');
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.repairBulk, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to repair recipes');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async bulkDeleteModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
|
||||
@@ -41,6 +41,11 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
||||
const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]');
|
||||
|
||||
if (repairMetadataItem) {
|
||||
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
if (sendToWorkflowAppendItem) {
|
||||
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
||||
@@ -127,33 +132,38 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||
|
||||
if (skipMetadataRefreshItem && resumeMetadataRefreshItem) {
|
||||
const skipCount = this.countSkipStatus(true);
|
||||
const resumeCount = this.countSkipStatus(false);
|
||||
const totalCount = skipCount + resumeCount;
|
||||
|
||||
if (skipCount === totalCount) {
|
||||
if (!config.skipMetadataRefresh) {
|
||||
skipMetadataRefreshItem.style.display = 'none';
|
||||
resumeMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.resumeMetadataRefresh'
|
||||
);
|
||||
} else if (resumeCount === totalCount) {
|
||||
skipMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.style.display = 'none';
|
||||
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.skipMetadataRefresh'
|
||||
);
|
||||
} else {
|
||||
skipMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.style.display = 'flex';
|
||||
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.skipMetadataRefreshCount',
|
||||
{ count: resumeCount }
|
||||
);
|
||||
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.resumeMetadataRefreshCount',
|
||||
{ count: skipCount }
|
||||
);
|
||||
const skipCount = this.countSkipStatus(true);
|
||||
const resumeCount = this.countSkipStatus(false);
|
||||
const totalCount = skipCount + resumeCount;
|
||||
|
||||
if (skipCount === totalCount) {
|
||||
skipMetadataRefreshItem.style.display = 'none';
|
||||
resumeMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.resumeMetadataRefresh'
|
||||
);
|
||||
} else if (resumeCount === totalCount) {
|
||||
skipMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.style.display = 'none';
|
||||
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.skipMetadataRefresh'
|
||||
);
|
||||
} else {
|
||||
skipMetadataRefreshItem.style.display = 'flex';
|
||||
resumeMetadataRefreshItem.style.display = 'flex';
|
||||
skipMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.skipMetadataRefreshCount',
|
||||
{ count: resumeCount }
|
||||
);
|
||||
resumeMetadataRefreshItem.querySelector('span').textContent = translate(
|
||||
'loras.bulkOperations.resumeMetadataRefreshCount',
|
||||
{ count: skipCount }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +261,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'delete-all':
|
||||
bulkManager.showBulkDeleteModal();
|
||||
break;
|
||||
case 'repair-metadata':
|
||||
bulkManager.repairSelectedRecipes();
|
||||
break;
|
||||
case 'set-favorite': {
|
||||
const allFavorited = this.countFavoritedInSelection() === state.selectedModels.size;
|
||||
bulkManager.setBulkFavorites(!allFavorited);
|
||||
|
||||
@@ -166,7 +166,9 @@ async function toggleFavorite(card) {
|
||||
function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const loraSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
|
||||
const folder = card.dataset.folder || '';
|
||||
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
|
||||
const loraSyntax = buildLoraSyntax(loraName, usageTips);
|
||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||
const modelPath = card.dataset.filepath;
|
||||
|
||||
@@ -66,6 +66,12 @@ function updateModalFilePathReferences(newFilePath) {
|
||||
fileNameContent.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
const versionNameContent = scopedQuery('.version-name-content');
|
||||
if (versionNameContent && versionNameContent.dataset) {
|
||||
versionNameContent.dataset.filePath = newFilePath;
|
||||
versionNameContent.setAttribute('data-file-path', newFilePath);
|
||||
}
|
||||
|
||||
const editTagsBtn = scopedQuery('.edit-tags-btn');
|
||||
if (editTagsBtn) {
|
||||
editTagsBtn.dataset.filePath = newFilePath;
|
||||
@@ -516,3 +522,127 @@ export function setupFileNameEditing(filePath) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up version name editing functionality
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
export function setupVersionNameEditing(filePath) {
|
||||
const versionNameContent = document.querySelector('.version-name-content');
|
||||
const editBtn = document.querySelector('.edit-version-name-btn');
|
||||
|
||||
if (!versionNameContent || !editBtn) return;
|
||||
|
||||
// Store the file path in a data attribute for later use
|
||||
versionNameContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const versionNameWrapper = document.querySelector('.version-name-wrapper');
|
||||
versionNameWrapper.addEventListener('mouseenter', () => {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
versionNameWrapper.addEventListener('mouseleave', () => {
|
||||
if (!versionNameWrapper.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
versionNameWrapper.classList.add('editing');
|
||||
versionNameContent.setAttribute('contenteditable', 'true');
|
||||
// Store original value for comparison later
|
||||
versionNameContent.dataset.originalValue = versionNameContent.textContent.trim();
|
||||
versionNameContent.focus();
|
||||
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
if (versionNameContent.childNodes.length > 0) {
|
||||
range.setStart(versionNameContent.childNodes[0], versionNameContent.textContent.length);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle keyboard events in edit mode
|
||||
versionNameContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.blur(); // Trigger save on Enter
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Restore original value
|
||||
this.textContent = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Limit version name length
|
||||
versionNameContent.addEventListener('input', function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (this.textContent.length > 100) {
|
||||
this.textContent = this.textContent.substring(0, 100);
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.setStart(this.childNodes[0], 100);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
showToast('toast.models.nameTooLong', {}, 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle focus out - save changes
|
||||
versionNameContent.addEventListener('blur', async function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
const newVersionName = this.textContent.trim();
|
||||
const originalValue = this.dataset.originalValue;
|
||||
|
||||
// Basic validation
|
||||
if (!newVersionName) {
|
||||
// Restore original value if empty
|
||||
this.textContent = originalValue;
|
||||
showToast('toast.models.nameCannotBeEmpty', {}, 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newVersionName === originalValue) {
|
||||
// No changes, just exit edit mode
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve current file path from modal state
|
||||
const filePath = getActiveModalFilePath(this.dataset.filePath);
|
||||
|
||||
await getModelApiClient().saveModelMetadata(filePath, { civitai: { name: newVersionName } });
|
||||
|
||||
showToast('toast.models.nameUpdatedSuccessfully', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error updating version name:', error);
|
||||
this.textContent = originalValue; // Restore original version name
|
||||
showToast('toast.models.nameUpdateFailed', {}, 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
function exitEditMode() {
|
||||
versionNameContent.removeAttribute('contenteditable');
|
||||
versionNameWrapper.classList.remove('editing');
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { setupTabSwitching } from './ModelDescription.js';
|
||||
import {
|
||||
setupModelNameEditing,
|
||||
setupBaseModelEditing,
|
||||
setupFileNameEditing
|
||||
setupFileNameEditing,
|
||||
setupVersionNameEditing
|
||||
} from './ModelMetadata.js';
|
||||
import { setupTagEditMode } from './ModelTags.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
@@ -466,7 +467,12 @@ export async function showModelModal(model, modelType) {
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>${translate('modals.model.metadata.version', {}, 'Version')}</label>
|
||||
<span>${modelWithFullData.civitai?.name || 'N/A'}</span>
|
||||
<div class="version-name-wrapper">
|
||||
<span class="version-name-content">${modelWithFullData.civitai?.name || 'N/A'}</span>
|
||||
<button class="edit-version-name-btn" title="${translate('modals.model.actions.editVersionName', {}, 'Edit version name')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>${translate('modals.model.metadata.fileName', {}, 'File Name')}</label>
|
||||
@@ -660,6 +666,7 @@ export async function showModelModal(model, modelType) {
|
||||
setupTagTooltip();
|
||||
setupTagEditMode(modelType);
|
||||
setupModelNameEditing(modelWithFullData.file_path);
|
||||
setupVersionNameEditing(modelWithFullData.file_path);
|
||||
setupBaseModelEditing(modelWithFullData.file_path);
|
||||
setupFileNameEditing(modelWithFullData.file_path);
|
||||
setupEventHandlers(modelWithFullData.file_path, modelType);
|
||||
|
||||
@@ -274,7 +274,17 @@ async function saveTags() {
|
||||
|
||||
const filePath = editBtn.dataset.filePath;
|
||||
const tagElements = document.querySelectorAll('.metadata-item');
|
||||
const 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
|
||||
const tagInput = document.querySelector('.metadata-input');
|
||||
if (tagInput) {
|
||||
const pendingTag = tagInput.value.trim().toLowerCase();
|
||||
if (pendingTag && !tags.includes(pendingTag)) {
|
||||
tags.push(pendingTag);
|
||||
}
|
||||
tagInput.value = '';
|
||||
}
|
||||
|
||||
// Get original tags to compare
|
||||
const originalTagElements = document.querySelectorAll('.tooltip-tag');
|
||||
@@ -465,6 +475,7 @@ function setupTagInput() {
|
||||
const tagInput = document.querySelector('.metadata-input');
|
||||
|
||||
if (tagInput) {
|
||||
tagInput.focus();
|
||||
tagInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||
@@ -74,7 +74,7 @@ export class BulkManager {
|
||||
unfavorite: true
|
||||
},
|
||||
recipes: {
|
||||
addTags: false,
|
||||
addTags: true,
|
||||
sendToWorkflow: false,
|
||||
copyAll: false,
|
||||
refreshAll: false,
|
||||
@@ -85,7 +85,8 @@ export class BulkManager {
|
||||
setContentRating: false,
|
||||
skipMetadataRefresh: false,
|
||||
setFavorite: true,
|
||||
unfavorite: true
|
||||
unfavorite: true,
|
||||
repairMetadata: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -656,6 +657,76 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
async repairSelectedRecipes() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.currentPageType !== 'recipes') {
|
||||
showToast('This operation is only available for recipes', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiClient = this.getActiveApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
|
||||
if (typeof apiClient.repairBulkModels !== 'function') {
|
||||
showToast('Bulk repair is not supported for this model type', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.loadingManager.showSimpleLoading('Repairing recipe metadata...');
|
||||
|
||||
const result = await apiClient.repairBulkModels(filePaths);
|
||||
|
||||
if (result.success) {
|
||||
const total = result.total || filePaths.length;
|
||||
const repaired = result.repaired || 0;
|
||||
const skipped = result.skipped || 0;
|
||||
|
||||
const recipes = result.recipes || [];
|
||||
for (const recipe of recipes) {
|
||||
if (recipe.file_path) {
|
||||
state.virtualScroller.updateSingleItem(
|
||||
recipe.file_path,
|
||||
recipe
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (repaired > 0) {
|
||||
showToast(
|
||||
'toast.recipes.repairBulkComplete',
|
||||
{ repaired, skipped, total },
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
'toast.recipes.repairBulkSkipped',
|
||||
{ total },
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
this.clearSelection();
|
||||
} else {
|
||||
throw new Error(result.error || 'Bulk repair failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during bulk recipe repair:', error);
|
||||
showToast('toast.recipes.repairBulkFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
if (state.loadingManager?.hide) {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllMetadata() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
@@ -785,6 +856,7 @@ export class BulkManager {
|
||||
// Setup tag input behavior
|
||||
const tagInput = document.querySelector('.bulk-metadata-input');
|
||||
if (tagInput) {
|
||||
tagInput.focus();
|
||||
tagInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -1008,7 +1080,17 @@ export class BulkManager {
|
||||
|
||||
async saveBulkTags(mode = 'append') {
|
||||
const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
|
||||
const 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
|
||||
const tagInput = document.querySelector('.bulk-metadata-input');
|
||||
if (tagInput) {
|
||||
const pendingTag = tagInput.value.trim().toLowerCase();
|
||||
if (pendingTag && !tags.includes(pendingTag)) {
|
||||
tags.push(pendingTag);
|
||||
}
|
||||
tagInput.value = '';
|
||||
}
|
||||
|
||||
if (tags.length === 0) {
|
||||
showToast('toast.models.noTagsToAdd', {}, 'warning');
|
||||
@@ -1032,6 +1114,8 @@ export class BulkManager {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
const isRecipes = state.currentPageType === 'recipes';
|
||||
|
||||
// Add or replace tags for each selected model based on mode
|
||||
for (const filePath of filePaths) {
|
||||
if (cancelled) {
|
||||
@@ -1039,7 +1123,9 @@ export class BulkManager {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
if (mode === 'replace') {
|
||||
if (isRecipes) {
|
||||
await this._saveRecipeTags(filePath, tags, mode);
|
||||
} else if (mode === 'replace') {
|
||||
await apiClient.saveModelMetadata(filePath, { tags: tags });
|
||||
} else {
|
||||
await apiClient.addTags(filePath, { tags: tags });
|
||||
@@ -1078,6 +1164,35 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
async _saveRecipeTags(filePath, newTags, mode) {
|
||||
const recipeId = extractRecipeId(filePath);
|
||||
if (!recipeId) throw new Error('Unable to determine recipe ID');
|
||||
|
||||
let finalTags = newTags;
|
||||
if (mode === 'append') {
|
||||
const recipeItem = state.virtualScroller?.items?.find(
|
||||
item => item.file_path === filePath
|
||||
);
|
||||
const existingTags = recipeItem?.tags || [];
|
||||
finalTags = [...new Set([...existingTags, ...newTags])];
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/lm/recipe/${encodeURIComponent(recipeId)}/update`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags: finalTags }),
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to update recipe tags');
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, { tags: finalTags });
|
||||
}
|
||||
|
||||
cleanupBulkAddTagsModal() {
|
||||
// Clear tags container
|
||||
const tagsContainer = document.getElementById('bulkTagsItems');
|
||||
|
||||
@@ -225,6 +225,13 @@ export class DoctorManager {
|
||||
renderIssueCard(item) {
|
||||
const status = item.status || 'ok';
|
||||
const tagLabel = this.getStatusLabel(status);
|
||||
|
||||
const titleKey = `doctor.issues.${item.id || ''}.title`;
|
||||
const displayTitle = translate(titleKey, {}, item.title || '');
|
||||
|
||||
const summaryKey = `doctor.issues.${item.id || ''}.summary.${status}`;
|
||||
const displaySummary = translate(summaryKey, {}, item.summary || '');
|
||||
|
||||
const details = Array.isArray(item.details) ? item.details : [];
|
||||
const listItems = details
|
||||
.filter((detail) => typeof detail === 'string')
|
||||
@@ -235,19 +242,22 @@ export class DoctorManager {
|
||||
.map((detail) => this.renderInlineDetail(detail))
|
||||
.join('');
|
||||
const actions = (item.actions || [])
|
||||
.map((action) => `
|
||||
.map((action) => {
|
||||
const actionLabel = translate(`doctor.actions.${action.id}`, {}, action.label);
|
||||
return `
|
||||
<button class="${action.id === 'repair-cache' || action.id === 'reload-page' ? 'primary-btn' : 'secondary-btn'}" data-doctor-action="${escapeHtml(action.id)}">
|
||||
${escapeHtml(action.label)}
|
||||
${escapeHtml(actionLabel)}
|
||||
</button>
|
||||
`)
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<section class="doctor-issue-card" data-status="${escapeHtml(status)}" data-issue-id="${escapeHtml(item.id || '')}">
|
||||
<div class="doctor-issue-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(item.title || '')}</h3>
|
||||
<p class="doctor-issue-summary">${escapeHtml(item.summary || '')}</p>
|
||||
<h3>${escapeHtml(displayTitle)}</h3>
|
||||
<p class="doctor-issue-summary">${escapeHtml(displaySummary)}</p>
|
||||
</div>
|
||||
<span class="doctor-issue-tag">${escapeHtml(tagLabel)}</span>
|
||||
</div>
|
||||
@@ -262,7 +272,7 @@ export class DoctorManager {
|
||||
if (detail.conflict_groups || detail.total_conflict_files) {
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
<strong>${escapeHtml(translate('doctor.status.warning', {}, 'Conflicts'))}</strong>
|
||||
<strong>${escapeHtml(translate('doctor.labels.conflicts', {}, 'Conflicts'))}</strong>
|
||||
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -324,11 +334,42 @@ export class DoctorManager {
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
case 'open-settings-syntax-format':
|
||||
modalManager.showModal('settingsModal');
|
||||
window.setTimeout(() => {
|
||||
// Switch to Interface section
|
||||
document.querySelectorAll('.settings-section').forEach((s) => s.classList.remove('active'));
|
||||
const interfaceSection = document.getElementById('section-interface');
|
||||
if (interfaceSection) {
|
||||
interfaceSection.classList.add('active');
|
||||
}
|
||||
document.querySelectorAll('.settings-nav-item').forEach((n) => n.classList.remove('active'));
|
||||
const interfaceNav = document.querySelector('.settings-nav-item[data-section="interface"]');
|
||||
if (interfaceNav) {
|
||||
interfaceNav.classList.add('active');
|
||||
}
|
||||
|
||||
// Focus and scroll to the LoRA Syntax Format dropdown
|
||||
const select = document.getElementById('loraSyntaxFormat');
|
||||
if (select) {
|
||||
select.focus();
|
||||
select.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Add temporary highlight animation
|
||||
const settingItem = select.closest('.setting-item');
|
||||
if (settingItem) {
|
||||
settingItem.classList.add('settings-setting-highlight');
|
||||
setTimeout(() => {
|
||||
settingItem.classList.remove('settings-setting-highlight');
|
||||
}, 4500);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
case 'repair-cache':
|
||||
await this.repairCache();
|
||||
break;
|
||||
case 'resolve-filename-conflicts':
|
||||
await this.resolveFilenameConflicts();
|
||||
await this.promptResolveConflicts();
|
||||
break;
|
||||
case 'reload-page':
|
||||
this.reloadUi();
|
||||
@@ -358,6 +399,62 @@ export class DoctorManager {
|
||||
}
|
||||
}
|
||||
|
||||
_getConflictStats() {
|
||||
const conflict = (this.lastDiagnostics?.diagnostics || []).find(
|
||||
(d) => d.id === 'filename_conflicts'
|
||||
);
|
||||
if (!conflict || !Array.isArray(conflict.details)) {
|
||||
return { groups: 0, files: 0 };
|
||||
}
|
||||
const summary = conflict.details.find(
|
||||
(d) => d && typeof d === 'object' && d.conflict_groups !== undefined
|
||||
);
|
||||
return {
|
||||
groups: summary?.conflict_groups || 0,
|
||||
files: summary?.total_conflict_files || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async promptResolveConflicts() {
|
||||
const stats = this._getConflictStats();
|
||||
if (stats.groups === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailEl = document.getElementById('resolveConflictsDetail');
|
||||
if (detailEl) {
|
||||
detailEl.innerHTML = translate(
|
||||
'conflictConfirm.detail',
|
||||
{},
|
||||
'Example: <code>Add_Details_v1.2</code> \u2192 <code>Add_Details_v1.2-a3f7</code>'
|
||||
);
|
||||
}
|
||||
|
||||
const impactEl = document.getElementById('resolveConflictsImpact');
|
||||
if (impactEl) {
|
||||
impactEl.innerHTML = translate(
|
||||
'conflictConfirm.impact',
|
||||
{ count: stats.files, groups: stats.groups },
|
||||
`Will rename <strong>${stats.files}</strong> file(s) across <strong>${stats.groups}</strong> duplicate group(s).`
|
||||
);
|
||||
}
|
||||
|
||||
this._confirmResolveResolve = null;
|
||||
modalManager.showModal('resolveFilenameConflictsModal');
|
||||
return new Promise((resolve) => {
|
||||
this._confirmResolveResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
async confirmResolveConflicts() {
|
||||
modalManager.closeModal('resolveFilenameConflictsModal');
|
||||
if (this._confirmResolveResolve) {
|
||||
this._confirmResolveResolve(true);
|
||||
this._confirmResolveResolve = null;
|
||||
}
|
||||
await this.resolveFilenameConflicts();
|
||||
}
|
||||
|
||||
async resolveFilenameConflicts() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
@@ -449,3 +546,8 @@ export class DoctorManager {
|
||||
}
|
||||
|
||||
export const doctorManager = new DoctorManager();
|
||||
|
||||
// Make available globally for HTML onclick handlers
|
||||
if (typeof window !== 'undefined') {
|
||||
window.doctorManager = doctorManager;
|
||||
}
|
||||
|
||||
@@ -316,6 +316,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Register resolveFilenameConflictsModal
|
||||
const resolveFilenameConflictsModal = document.getElementById('resolveFilenameConflictsModal');
|
||||
if (resolveFilenameConflictsModal) {
|
||||
this.registerModal('resolveFilenameConflictsModal', {
|
||||
element: resolveFilenameConflictsModal,
|
||||
onClose: () => {
|
||||
this.getModal('resolveFilenameConflictsModal').element.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
@@ -396,7 +409,8 @@ export class ModalManager {
|
||||
id === "modelDuplicateDeleteModal" ||
|
||||
id === "clearCacheModal" ||
|
||||
id === "bulkDeleteModal" ||
|
||||
id === "checkUpdatesConfirmModal"
|
||||
id === "checkUpdatesConfirmModal" ||
|
||||
id === "resolveFilenameConflictsModal"
|
||||
) {
|
||||
modal.element.classList.add("show");
|
||||
} else {
|
||||
|
||||
@@ -295,6 +295,13 @@ export class SettingsManager {
|
||||
// Update state
|
||||
state.global.settings[settingKey] = value;
|
||||
|
||||
if (settingKey === 'lora_syntax_format') {
|
||||
try {
|
||||
localStorage.setItem('lm:lora-syntax-format-changed', Date.now().toString());
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isBackendSetting(settingKey)) {
|
||||
return;
|
||||
}
|
||||
@@ -949,6 +956,12 @@ export class SettingsManager {
|
||||
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false;
|
||||
}
|
||||
|
||||
// Set lora syntax format
|
||||
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
|
||||
if (loraSyntaxFormatSelect) {
|
||||
loraSyntaxFormatSelect.value = state.global.settings.lora_syntax_format || 'legacy';
|
||||
}
|
||||
|
||||
// Load metadata archive settings
|
||||
await this.loadMetadataArchiveSettings();
|
||||
|
||||
|
||||
@@ -731,9 +731,16 @@ export class UpdateService {
|
||||
}
|
||||
|
||||
// Simple markdown parser for changelog items
|
||||
// Simple markdown parser for changelog items
|
||||
// Escape HTML entities first so angle brackets in content (e.g. `<lora:x>`)
|
||||
// aren't swallowed by innerHTML's HTML parser as invalid tags
|
||||
parseMarkdown(text) {
|
||||
if (!text) return '';
|
||||
|
||||
text = text.replace(/&/g, '&');
|
||||
text = text.replace(/</g, '<');
|
||||
text = text.replace(/>/g, '>');
|
||||
|
||||
// Handle bold text (**text**)
|
||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
card_info_display: 'always',
|
||||
show_folder_sidebar: true,
|
||||
model_name_display: 'model_name',
|
||||
lora_syntax_format: 'legacy',
|
||||
model_card_footer_action: 'example_images',
|
||||
show_version_on_card: true,
|
||||
include_trigger_words: false,
|
||||
|
||||
@@ -420,17 +420,23 @@ export function getLoraStrengthsFromUsageTips(usageTips = {}) {
|
||||
export function buildLoraSyntax(fileName, usageTips = {}) {
|
||||
const { strength, hasStrength, clipStrength, hasClipStrength } = getLoraStrengthsFromUsageTips(usageTips);
|
||||
|
||||
const effectiveName = state.global.settings?.lora_syntax_format === 'legacy'
|
||||
? fileName.split('/').pop()
|
||||
: fileName;
|
||||
|
||||
if (hasClipStrength) {
|
||||
const modelStrength = hasStrength ? strength : 1;
|
||||
return `<lora:${fileName}:${modelStrength}:${clipStrength}>`;
|
||||
return `<lora:${effectiveName}:${modelStrength}:${clipStrength}>`;
|
||||
}
|
||||
|
||||
return `<lora:${fileName}:${strength}>`;
|
||||
return `<lora:${effectiveName}:${strength}>`;
|
||||
}
|
||||
|
||||
export function copyLoraSyntax(card) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || "{}");
|
||||
const baseSyntax = buildLoraSyntax(card.dataset.file_name, usageTips);
|
||||
const folder = card.dataset.folder || '';
|
||||
const loraName = folder ? `${folder}/${card.dataset.file_name}` : card.dataset.file_name;
|
||||
const baseSyntax = buildLoraSyntax(loraName, usageTips);
|
||||
|
||||
// Check if trigger words should be included
|
||||
const includeTriggerWords = state.global.settings.include_trigger_words;
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
<div class="context-menu-item" data-action="check-updates">
|
||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="repair-metadata">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.bulkOperations.repairMetadata') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="skip-metadata-refresh">
|
||||
<i class="fas fa-ban"></i> <span>{{ t('loras.bulkOperations.skipMetadataRefresh') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -218,10 +218,10 @@
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.license') }}</h4>
|
||||
<div class="filter-tags">
|
||||
<div class="filter-tag license-tag" data-license="noCredit">
|
||||
<div class="filter-tag license-tag" data-license="noCredit" title="{{ t('header.filter.noCreditRequiredTooltip') }}">
|
||||
{{ t('header.filter.noCreditRequired') }}
|
||||
</div>
|
||||
<div class="filter-tag license-tag" data-license="allowSelling">
|
||||
<div class="filter-tag license-tag" data-license="allowSelling" title="{{ t('header.filter.allowSellingGeneratedContentTooltip') }}">
|
||||
{{ t('header.filter.allowSellingGeneratedContent') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,4 +108,21 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolve Filename Conflicts Confirmation Modal -->
|
||||
<div id="resolveFilenameConflictsModal" class="modal delete-modal">
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>{{ t('conflictConfirm.title') }}</h2>
|
||||
<p class="confirmation-message">{{ t('conflictConfirm.message') }}</p>
|
||||
<p class="resolve-conflicts-detail" id="resolveConflictsDetail"></p>
|
||||
<div class="resolve-conflicts-impact" id="resolveConflictsImpact"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="modalManager.closeModal('resolveFilenameConflictsModal')">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" id="resolveConflictsConfirmBtn" onclick="doctorManager.confirmResolveConflicts()">
|
||||
<i class="fas fa-check"></i>
|
||||
{{ t('conflictConfirm.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -536,7 +536,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
@@ -595,6 +595,22 @@
|
||||
<div class="settings-subsection-header">
|
||||
<h4>{{ t('settings.sections.misc') }}</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="loraSyntaxFormat">
|
||||
{{ t('settings.misc.loraSyntaxFormat') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.misc.loraSyntaxFormatHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="loraSyntaxFormat" onchange="settingsManager.saveSelectSetting('loraSyntaxFormat', 'lora_syntax_format')">
|
||||
<option value="full">{{ t('settings.misc.loraSyntaxFormatOptions.full') }}</option>
|
||||
<option value="legacy">{{ t('settings.misc.loraSyntaxFormatOptions.legacy') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
|
||||
@@ -467,7 +467,10 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||
class Provider:
|
||||
async def get_model_version_info(self, model_version_id):
|
||||
provider_calls.append(model_version_id)
|
||||
return {"baseModel": "Flux Provider"}, None
|
||||
return {
|
||||
"baseModel": "Flux Provider",
|
||||
"model": {"type": "Checkpoint", "name": "Flux"},
|
||||
}, None
|
||||
|
||||
async def fake_get_default_metadata_provider():
|
||||
return Provider()
|
||||
|
||||
@@ -298,3 +298,113 @@ async def test_parse_metadata_handles_modelVersionIds(monkeypatch):
|
||||
assert lora2["type"] == "lora"
|
||||
assert lora2["hash"] == "aabbccdd0022"
|
||||
assert lora2["baseModel"] == "SDXL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_extracts_checkpoint_from_resources_model_type(monkeypatch):
|
||||
"""resources entries with type:"model" should be captured as the checkpoint,
|
||||
not skipped (which was the old buggy behavior), and not mixed into loras."""
|
||||
captured_hashes = []
|
||||
|
||||
async def fake_metadata_provider():
|
||||
class Provider:
|
||||
async def get_model_by_hash(self, model_hash):
|
||||
captured_hashes.append(model_hash)
|
||||
if model_hash == "a1b2c3d4e5":
|
||||
return ({
|
||||
"id": 999,
|
||||
"modelId": 888,
|
||||
"name": "v1.0",
|
||||
"model": {"name": "Real Checkpoint", "type": "Checkpoint"},
|
||||
"baseModel": "SDXL 1.0",
|
||||
"images": [{"url": "https://image.civitai.com/cp/original=true"}],
|
||||
"files": [{"type": "Model", "primary": True, "sizeKB": 1024, "name": "cp.safetensors"}]
|
||||
}, None)
|
||||
return None, "Model not found"
|
||||
|
||||
return Provider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
|
||||
fake_metadata_provider,
|
||||
)
|
||||
|
||||
parser = CivitaiApiMetadataParser()
|
||||
|
||||
metadata = {
|
||||
"prompt": "test",
|
||||
"resources": [
|
||||
{"hash": "a1b2c3d4e5", "name": "Real Checkpoint", "type": "model"},
|
||||
{"hash": "f6g7h8i9j0", "name": "Some LoRA", "type": "lora", "weight": 0.8},
|
||||
],
|
||||
"Model hash": "a1b2c3d4e5",
|
||||
}
|
||||
|
||||
result = await parser.parse_metadata(metadata)
|
||||
|
||||
# The type:"model" resource should be in result["model"], not in result["loras"]
|
||||
assert result["model"] is not None, "checkpoint model should be extracted"
|
||||
assert result["model"]["name"] == "Real Checkpoint"
|
||||
assert result["model"]["hash"] == "a1b2c3d4e5"
|
||||
assert result["model"]["type"] == "model"
|
||||
|
||||
# The LoRA resource should be in result["loras"]
|
||||
assert len(result["loras"]) == 1
|
||||
assert result["loras"][0]["name"] == "Some LoRA"
|
||||
|
||||
# The checkpoint hash should have triggered a lookup
|
||||
assert "a1b2c3d4e5" in captured_hashes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_resources_model_type_does_not_duplicate_checkpoint_in_loras(monkeypatch):
|
||||
"""When a resources entry has type:"model", it should NOT also appear in loras.
|
||||
Regression test for the bug where the checkpoint model appeared in both places."""
|
||||
async def fake_metadata_provider():
|
||||
class Provider:
|
||||
async def get_model_by_hash(self, model_hash):
|
||||
if model_hash == "cp123hash":
|
||||
return ({
|
||||
"id": 100,
|
||||
"modelId": 200,
|
||||
"name": "v2",
|
||||
"model": {"name": "My Checkpoint", "type": "Checkpoint"},
|
||||
"baseModel": "SDXL",
|
||||
"files": [{"type": "Model", "primary": True, "sizeKB": 1024, "name": "cp.safetensors"}]
|
||||
}, None)
|
||||
if model_hash == "lora1hash":
|
||||
return ({
|
||||
"id": 300,
|
||||
"modelId": 400,
|
||||
"name": "v1",
|
||||
"model": {"name": "Style LoRA", "type": "LORA"},
|
||||
"baseModel": "SDXL",
|
||||
"files": [{"type": "Model", "primary": True, "sizeKB": 512, "name": "style.safetensors"}]
|
||||
}, None)
|
||||
return None, "Model not found"
|
||||
|
||||
return Provider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
|
||||
fake_metadata_provider,
|
||||
)
|
||||
|
||||
parser = CivitaiApiMetadataParser()
|
||||
metadata = {
|
||||
"resources": [
|
||||
{"hash": "cp123hash", "name": "My Checkpoint", "type": "model"},
|
||||
{"hash": "lora1hash", "name": "Style LoRA", "type": "lora", "weight": 0.5},
|
||||
],
|
||||
}
|
||||
|
||||
result = await parser.parse_metadata(metadata)
|
||||
|
||||
# Checkpoint must NOT appear in loras
|
||||
lora_names = {l["name"] for l in result["loras"]}
|
||||
assert "My Checkpoint" not in lora_names
|
||||
assert "Style LoRA" in lora_names
|
||||
|
||||
# Checkpoint must be in result["model"]
|
||||
assert result["model"] is not None
|
||||
assert result["model"]["name"] == "My Checkpoint"
|
||||
|
||||
@@ -65,32 +65,26 @@ async def test_allow_selling_filter():
|
||||
"""Test the allow selling generated content filtering logic."""
|
||||
service = DummyModelService()
|
||||
|
||||
# Create test data with different license flags
|
||||
# CommercialUse values are independent — Sell does NOT imply Image.
|
||||
test_data = [
|
||||
# Model allowing selling (contains Image in allowCommercialUse)
|
||||
{"file_path": "model1.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["Image"]})},
|
||||
# Model not allowing selling (doesn't contain Image in allowCommercialUse)
|
||||
{"file_path": "model2.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["RentCivit"]})},
|
||||
# Model with default license flags (includes Sell by default, which implies Image)
|
||||
{"file_path": "model3.safetensors", "license_flags": build_license_flags(None)},
|
||||
# Model allowing selling (contains Sell in allowCommercialUse, which implies Image)
|
||||
{"file_path": "model4.safetensors", "license_flags": build_license_flags({"allowCommercialUse": ["Sell"]})},
|
||||
# Model with empty allowCommercialUse (doesn't allow selling)
|
||||
{"file_path": "model5.safetensors", "license_flags": build_license_flags({"allowCommercialUse": []})},
|
||||
]
|
||||
|
||||
# Test allow_selling=True (should return models that allow selling - have Image permission)
|
||||
# Default and Sell permissions both include Image, so model3 and model4 will be included
|
||||
# Test allow_selling=True (should return only models with the Image permission)
|
||||
filtered = await service._apply_allow_selling_filter(test_data, allow_selling=True)
|
||||
assert len(filtered) == 3 # model1, model3 (default includes Sell which implies Image), model4
|
||||
assert len(filtered) == 1 # only model1 has Image permission
|
||||
file_paths = {item["file_path"] for item in filtered}
|
||||
assert file_paths == {"model1.safetensors", "model3.safetensors", "model4.safetensors"}
|
||||
assert file_paths == {"model1.safetensors"}
|
||||
|
||||
# Test allow_selling=False (should return models that don't allow selling - don't have Image permission)
|
||||
# Test allow_selling=False (should return models without the Image permission)
|
||||
filtered = await service._apply_allow_selling_filter(test_data, allow_selling=False)
|
||||
assert len(filtered) == 2 # model2 and model5
|
||||
assert len(filtered) == 4 # model2, model3, model4, model5
|
||||
file_paths = {item["file_path"] for item in filtered}
|
||||
assert file_paths == {"model2.safetensors", "model5.safetensors"}
|
||||
assert file_paths == {"model2.safetensors", "model3.safetensors", "model4.safetensors", "model5.safetensors"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -131,13 +131,12 @@ async def test_pool_filter_allow_selling_true(lora_service, sample_loras):
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
|
||||
# Should keep models with Image permission (allowSelling)
|
||||
# Models: no_credit_required_for_selling, credit_required_for_selling, default_license
|
||||
assert len(filtered) == 3
|
||||
# Sell alone does not imply Image, so default_license is excluded.
|
||||
assert len(filtered) == 2
|
||||
file_names = {lora["file_name"] for lora in filtered}
|
||||
assert file_names == {
|
||||
"no_credit_required_for_selling.safetensors",
|
||||
"credit_required_for_selling.safetensors",
|
||||
"default_license.safetensors",
|
||||
}
|
||||
|
||||
|
||||
@@ -178,12 +177,11 @@ async def test_pool_filter_both_license_filters(lora_service, sample_loras):
|
||||
# Should keep models where both conditions are met:
|
||||
# - allowNoCredit=True (no credit required)
|
||||
# - Image permission exists (allow selling)
|
||||
# Models: no_credit_required_for_selling, default_license
|
||||
assert len(filtered) == 2
|
||||
# default_license has ["Sell"] without Image, so it's excluded.
|
||||
assert len(filtered) == 1
|
||||
file_names = {lora["file_name"] for lora in filtered}
|
||||
assert file_names == {
|
||||
"no_credit_required_for_selling.safetensors",
|
||||
"default_license.safetensors",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -132,7 +132,8 @@ async def test_initialize_cache_populates_cache(tmp_path: Path):
|
||||
_normalize_path(tmp_path / "one.txt"),
|
||||
_normalize_path(tmp_path / "nested" / "two.txt"),
|
||||
}
|
||||
assert {item["license_flags"] for item in cache.raw_data} == {DEFAULT_LICENSE_FLAGS}
|
||||
# build_license_flags({}) returns 113 (defaults: allowNoCredit + ["Sell"] + derivatives + differentLicense)
|
||||
assert {item["license_flags"] for item in cache.raw_data} == {113}
|
||||
|
||||
assert scanner._hash_index.get_path("hash-one") == _normalize_path(tmp_path / "one.txt")
|
||||
assert scanner._hash_index.get_path("hash-two") == _normalize_path(tmp_path / "nested" / "two.txt")
|
||||
@@ -190,7 +191,8 @@ async def test_initialize_in_background_applies_scan_result(tmp_path: Path, monk
|
||||
_normalize_path(tmp_path / "one.txt"),
|
||||
_normalize_path(tmp_path / "nested" / "two.txt"),
|
||||
}
|
||||
assert {item["license_flags"] for item in cache.raw_data} == {DEFAULT_LICENSE_FLAGS}
|
||||
# build_license_flags({}) returns 113 (defaults: allowNoCredit + ["Sell"] + derivatives + differentLicense)
|
||||
assert {item["license_flags"] for item in cache.raw_data} == {113}
|
||||
assert scanner._hash_index.get_path("hash-two") == _normalize_path(tmp_path / "nested" / "two.txt")
|
||||
assert scanner._tags_count == {"alpha": 1, "beta": 1}
|
||||
assert scanner._excluded_models == [_normalize_path(tmp_path / "skip-file.txt")]
|
||||
@@ -636,6 +638,8 @@ async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplo
|
||||
root = tmp_path / "loras"
|
||||
root.mkdir()
|
||||
scanner = DummyScanner(root)
|
||||
# Duplicate filename detection is only active for LoRAs
|
||||
scanner.model_type = "lora"
|
||||
|
||||
# Simulate duplicate filenames in the hash index
|
||||
scanner._hash_index.add_entry("aaa111", str(root / "model.safetensors"))
|
||||
@@ -646,7 +650,7 @@ async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplo
|
||||
assert len(caplog.records) >= 1
|
||||
log_msg = caplog.records[-1].message
|
||||
assert "Duplicate filename conflict detected" in log_msg
|
||||
assert "1 dummy filename(s)" in log_msg
|
||||
assert "1 lora filename(s)" in log_msg
|
||||
assert "2 files total" in log_msg
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
||||
"id": 5678,
|
||||
"modelId": 1234,
|
||||
"name": "v1.0",
|
||||
"model": {"name": "Full Model Name"},
|
||||
"model": {"name": "Full Model Name", "type": "Checkpoint"},
|
||||
"baseModel": "SDXL 1.0",
|
||||
"images": [{"url": "https://image.url/thumb.jpg"}],
|
||||
"files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}]
|
||||
@@ -142,7 +142,7 @@ async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner)
|
||||
"id": 5678,
|
||||
"modelId": 1234,
|
||||
"name": "v1.0",
|
||||
"model": {"name": "Full Model Name"},
|
||||
"model": {"name": "Full Model Name", "type": "Checkpoint"},
|
||||
"baseModel": "SDXL 1.0",
|
||||
"images": [{"url": "https://image.url/thumb.jpg"}],
|
||||
"files": [
|
||||
@@ -183,7 +183,7 @@ async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
|
||||
"id": 999,
|
||||
"modelId": 888,
|
||||
"name": "v2.0",
|
||||
"model": {"name": "Hashed Model"},
|
||||
"model": {"name": "Hashed Model", "type": "Checkpoint"},
|
||||
"baseModel": "SD 1.5",
|
||||
"files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}]
|
||||
}, None)
|
||||
|
||||
@@ -255,7 +255,7 @@ def test_tag_update_service_adds_unique_tags(tmp_path: Path) -> None:
|
||||
cache_updates.append(metadata)
|
||||
return True
|
||||
|
||||
tags = asyncio.run(
|
||||
tags, auto_tags = asyncio.run(
|
||||
service.add_tags(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
new_tags=["new", "existing"],
|
||||
@@ -265,5 +265,6 @@ def test_tag_update_service_adds_unique_tags(tmp_path: Path) -> None:
|
||||
)
|
||||
|
||||
assert tags == ["existing", "new"]
|
||||
assert auto_tags == []
|
||||
assert manager.saved
|
||||
assert cache_updates
|
||||
|
||||
@@ -43,7 +43,7 @@ async def test_tag_update_service_handles_case_insensitive_tags(tmp_path: Path)
|
||||
return True
|
||||
|
||||
# Try to add "Test" (different case) - should not be added since "test" already exists
|
||||
tags = await service.add_tags(
|
||||
tags, auto_tags = await service.add_tags(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
new_tags=["Test"],
|
||||
metadata_loader=loader,
|
||||
@@ -52,6 +52,7 @@ async def test_tag_update_service_handles_case_insensitive_tags(tmp_path: Path)
|
||||
|
||||
# Should still only have "test" (lowercase) in the tags
|
||||
assert tags == ["test"]
|
||||
assert auto_tags == [] # no file_name/base_model in metadata, so no auto-detection
|
||||
assert len(manager.saved) == 1
|
||||
saved_metadata = manager.saved[0][1]
|
||||
assert saved_metadata["tags"] == ["test"]
|
||||
@@ -76,7 +77,7 @@ async def test_tag_update_service_adds_new_tags_in_lowercase(tmp_path: Path) ->
|
||||
return True
|
||||
|
||||
# Add new tags with mixed case
|
||||
tags = await service.add_tags(
|
||||
tags, auto_tags = await service.add_tags(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
new_tags=["NewTag", "ANOTHER_TAG"],
|
||||
metadata_loader=loader,
|
||||
@@ -87,6 +88,7 @@ async def test_tag_update_service_adds_new_tags_in_lowercase(tmp_path: Path) ->
|
||||
assert "existing" in tags
|
||||
assert "newtag" in tags
|
||||
assert "another_tag" in tags
|
||||
assert auto_tags == []
|
||||
assert len(manager.saved) == 1
|
||||
saved_metadata = manager.saved[0][1]
|
||||
assert "newtag" in saved_metadata["tags"]
|
||||
|
||||
@@ -126,6 +126,80 @@ class TestExtractAutoTags:
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
# ── Layer 2: user-defined tags as manual fallback ───────────
|
||||
|
||||
def test_user_tags_fallback_when_detection_fails(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "BOTH-v1.0",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH", "I2V", "T2V"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V", "T2V"}
|
||||
|
||||
def test_user_tags_augment_partial_detection(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan_i2v_hn_v2",
|
||||
"base_model": "Wan 2.2 I2V",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
def test_user_tags_non_auto_tag_ignored(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "model_v1",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH", "character", "style", "nsfw"],
|
||||
})
|
||||
assert set(result) == {"HIGH"}
|
||||
|
||||
def test_user_tags_overrides_non_wan_gate(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "flux_model_v1",
|
||||
"base_model": "Flux.1 D",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH", "LOW", "Turbo"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "LOW", "Turbo"}
|
||||
|
||||
def test_user_tags_no_duplication(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan_i2v_high_v3",
|
||||
"base_model": "Wan 2.2",
|
||||
"civitai": {},
|
||||
"tags": ["HIGH", "I2V"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "I2V"}
|
||||
|
||||
def test_user_tags_lightning_turbo_manual(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "sdxl_model_v1",
|
||||
"base_model": "SDXL",
|
||||
"civitai": {},
|
||||
"tags": ["Lightning"],
|
||||
})
|
||||
assert set(result) == {"Lightning"}
|
||||
|
||||
def test_user_tags_case_insensitive_lowercase(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "wan_masterpieces_v2",
|
||||
"base_model": "Wan Video 14B t2v",
|
||||
"civitai": {},
|
||||
"tags": ["high"],
|
||||
})
|
||||
assert set(result) == {"HIGH", "T2V"}
|
||||
|
||||
def test_user_tags_case_insensitive_mixed(self):
|
||||
result = extract_auto_tags({
|
||||
"file_name": "model_v1",
|
||||
"base_model": "SDXL",
|
||||
"civitai": {},
|
||||
"tags": ["lightning", "turbo", "i2v"],
|
||||
})
|
||||
assert set(result) == {"Lightning", "Turbo", "I2V"}
|
||||
|
||||
|
||||
class TestAutoTagCategories:
|
||||
def test_all_patterns_compile(self):
|
||||
|
||||
@@ -16,7 +16,9 @@ def test_resolve_license_payload_defaults():
|
||||
assert payload["allowDerivatives"] is True
|
||||
assert payload["allowDifferentLicense"] is True
|
||||
assert payload["allowCommercialUse"] == ["Sell"]
|
||||
assert flags == 127
|
||||
# Default ["Sell"] only sets the Sell bit (16), plus NoCredit (1),
|
||||
# Derivatives (32) and DifferentLicense (64) = 113.
|
||||
assert flags == 113
|
||||
|
||||
|
||||
def test_build_license_flags_custom_values():
|
||||
@@ -34,11 +36,10 @@ def test_build_license_flags_custom_values():
|
||||
assert payload["allowDifferentLicense"] is False
|
||||
|
||||
flags = build_license_flags(source)
|
||||
# Sell automatically enables all commercial bits including image.
|
||||
assert flags == 30
|
||||
assert flags == 18
|
||||
|
||||
|
||||
def test_build_license_flags_respects_commercial_hierarchy():
|
||||
def test_build_license_flags_independent_values():
|
||||
base = {
|
||||
"allowNoCredit": False,
|
||||
"allowDerivatives": False,
|
||||
@@ -46,14 +47,10 @@ def test_build_license_flags_respects_commercial_hierarchy():
|
||||
}
|
||||
|
||||
assert build_license_flags({**base, "allowCommercialUse": []}) == 0
|
||||
# Rent adds rent and rentcivit permissions.
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Rent"]}) == 12
|
||||
# RentCivit alone should only set its own bit.
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Rent"]}) == 8
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["RentCivit"]}) == 4
|
||||
# Image only toggles the image bit.
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Image"]}) == 2
|
||||
# Sell forces all commercial bits regardless of image listing.
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Sell"]}) == 30
|
||||
assert build_license_flags({**base, "allowCommercialUse": ["Sell"]}) == 16
|
||||
|
||||
|
||||
def test_build_license_flags_parses_aggregate_string():
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
import pytest
|
||||
|
||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.utils.utils import (
|
||||
calculate_recipe_fingerprint,
|
||||
calculate_relative_path_for_model,
|
||||
get_lora_info,
|
||||
get_lora_info_absolute,
|
||||
sanitize_folder_name,
|
||||
)
|
||||
|
||||
|
||||
class _FakeCache:
|
||||
def __init__(self, items):
|
||||
self.raw_data = list(items)
|
||||
|
||||
|
||||
class _FakeScanner:
|
||||
def __init__(self, items):
|
||||
self._cache = _FakeCache(items)
|
||||
|
||||
async def get_cached_data(self):
|
||||
return self._cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_lora_scanner(monkeypatch):
|
||||
def _setup(items):
|
||||
scanner = _FakeScanner(items)
|
||||
|
||||
async def get_scanner():
|
||||
return scanner
|
||||
|
||||
monkeypatch.setattr(ServiceRegistry, "get_lora_scanner", get_scanner)
|
||||
return scanner
|
||||
|
||||
return _setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_settings(monkeypatch):
|
||||
manager = get_settings_manager()
|
||||
@@ -114,3 +144,114 @@ def test_calculate_recipe_fingerprint_empty_input():
|
||||
)
|
||||
def test_sanitize_folder_name(original, expected):
|
||||
assert sanitize_folder_name(original) == expected
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_bare_name(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("mylora")
|
||||
|
||||
assert path == "/models/Lora/SDXL/mylora.safetensors"
|
||||
assert triggers == ["trigger1"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_with_path(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL/Styles", "file_path": "/models/Lora/SDXL/Styles/mylora.safetensors", "civitai": {"trainedWords": ["artistic"]}},
|
||||
{"file_name": "other", "folder": "", "file_path": "/models/Lora/other.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("SDXL/Styles/mylora")
|
||||
|
||||
assert path == "/models/Lora/SDXL/Styles/mylora.safetensors"
|
||||
assert triggers == ["artistic"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_path_fallback_to_basename(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "RenamedFolder", "file_path": "/models/Lora/RenamedFolder/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("OldFolder/mylora")
|
||||
|
||||
assert path == "/models/Lora/RenamedFolder/mylora.safetensors"
|
||||
assert triggers == ["trigger1"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_prefers_folder_match(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "V1", "file_path": "/models/Lora/V1/mylora.safetensors", "civitai": {"trainedWords": ["v1"]}},
|
||||
{"file_name": "mylora", "folder": "V2", "file_path": "/models/Lora/V2/mylora.safetensors", "civitai": {"trainedWords": ["v2"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("V2/mylora")
|
||||
|
||||
assert path == "/models/Lora/V2/mylora.safetensors"
|
||||
assert triggers == ["v2"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_no_folder_in_cache_no_path_in_name(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "", "file_path": "/models/Lora/mylora.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("mylora")
|
||||
|
||||
assert path == "/models/Lora/mylora.safetensors"
|
||||
assert triggers == []
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_strips_extension(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["hello"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("SDXL/mylora.safetensors")
|
||||
|
||||
assert path == "/models/Lora/SDXL/mylora.safetensors"
|
||||
assert triggers == ["hello"]
|
||||
|
||||
|
||||
def test_get_lora_info_absolute_not_found_returns_original(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info_absolute("nonexistent")
|
||||
|
||||
assert path == "nonexistent"
|
||||
assert triggers == []
|
||||
|
||||
|
||||
def test_get_lora_info_bare_name(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {"trainedWords": ["trigger1"]}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info("mylora")
|
||||
|
||||
assert triggers == ["trigger1"]
|
||||
|
||||
|
||||
def test_get_lora_info_with_path(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL/Styles", "file_path": "/models/Lora/SDXL/Styles/mylora.safetensors", "civitai": {"trainedWords": ["artistic"]}},
|
||||
{"file_name": "other", "folder": "", "file_path": "/models/Lora/other.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info("SDXL/Styles/mylora")
|
||||
|
||||
assert triggers == ["artistic"]
|
||||
|
||||
|
||||
def test_get_lora_info_not_found_returns_original(mock_lora_scanner):
|
||||
mock_lora_scanner([
|
||||
{"file_name": "mylora", "folder": "SDXL", "file_path": "/models/Lora/SDXL/mylora.safetensors", "civitai": {}},
|
||||
])
|
||||
|
||||
path, triggers = get_lora_info("nonexistent")
|
||||
|
||||
assert path == "nonexistent"
|
||||
assert triggers == []
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
<div class="section__toggles">
|
||||
<label class="toggle-item">
|
||||
<span class="toggle-item__label">No Credit Required</span>
|
||||
<span class="toggle-item__label" title="Use the model without crediting the creator">No Credit Required</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-switch"
|
||||
@@ -20,7 +20,7 @@
|
||||
</label>
|
||||
|
||||
<label class="toggle-item">
|
||||
<span class="toggle-item__label">Allow Selling</span>
|
||||
<span class="toggle-item__label" title="Allow selling generated images">Allow Selling</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-switch"
|
||||
|
||||
@@ -104,6 +104,66 @@ function removeLoraExtension(fileName = '') {
|
||||
return fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||
}
|
||||
|
||||
let _loraSyntaxFormatCache = null;
|
||||
let _loraSyntaxFormatRefreshPromise = null;
|
||||
|
||||
function _getLoraSyntaxFormat() {
|
||||
if (_loraSyntaxFormatCache !== null) {
|
||||
return _loraSyntaxFormatCache;
|
||||
}
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
async function _fetchLoraSyntaxFormat() {
|
||||
try {
|
||||
const response = await api.fetchApi('/lm/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
_loraSyntaxFormatCache = data.settings.lora_syntax_format || 'legacy';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
if (_loraSyntaxFormatCache === null) {
|
||||
_loraSyntaxFormatCache = 'legacy';
|
||||
}
|
||||
}
|
||||
|
||||
function _triggerBackgroundRefresh() {
|
||||
if (_loraSyntaxFormatRefreshPromise) {
|
||||
return;
|
||||
}
|
||||
_loraSyntaxFormatRefreshPromise = _fetchLoraSyntaxFormat().finally(() => {
|
||||
_loraSyntaxFormatRefreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshLoraSyntaxFormat() {
|
||||
await _fetchLoraSyntaxFormat();
|
||||
}
|
||||
|
||||
function _initLoraSyntaxFormat() {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
_initLoraSyntaxFormat();
|
||||
|
||||
function _initLoraSyntaxFormatReactive() {
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === 'lm:lora-syntax-format-changed') {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
_initLoraSyntaxFormatReactive();
|
||||
|
||||
function parseSearchTokens(term = '') {
|
||||
const include = [];
|
||||
const exclude = [];
|
||||
@@ -226,7 +286,14 @@ const MODEL_BEHAVIORS = {
|
||||
}
|
||||
},
|
||||
async getInsertText(_instance, relativePath) {
|
||||
const fileName = removeLoraExtension(splitRelativePath(relativePath).fileName);
|
||||
const { directories, fileName } = splitRelativePath(relativePath);
|
||||
const baseName = removeLoraExtension(fileName);
|
||||
const folder = directories.length ? directories.join('/') + '/' : '';
|
||||
const loraName = folder + baseName;
|
||||
|
||||
const resultName = _getLoraSyntaxFormat() === 'legacy'
|
||||
? baseName
|
||||
: loraName;
|
||||
|
||||
let strength = 1.0;
|
||||
let hasStrength = false;
|
||||
@@ -262,9 +329,9 @@ const MODEL_BEHAVIORS = {
|
||||
}
|
||||
|
||||
if (clipStrength !== null) {
|
||||
return formatAutocompleteInsertion(`<lora:${fileName}:${strength}:${clipStrength}>`);
|
||||
return formatAutocompleteInsertion(`<lora:${resultName}:${strength}:${clipStrength}>`);
|
||||
}
|
||||
return formatAutocompleteInsertion(`<lora:${fileName}:${strength}>`);
|
||||
return formatAutocompleteInsertion(`<lora:${resultName}:${strength}>`);
|
||||
}
|
||||
},
|
||||
embeddings: {
|
||||
@@ -1430,6 +1497,11 @@ class AutoComplete {
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
// Prevent textarea from losing focus - same fix as createItemElement
|
||||
itemEl.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
itemEl.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index, { manual: true });
|
||||
});
|
||||
@@ -2158,6 +2230,16 @@ class AutoComplete {
|
||||
item.appendChild(nameSpan);
|
||||
}
|
||||
|
||||
// Prevent textarea from losing focus when clicking dropdown items.
|
||||
// Without this, the blur event fires before click, and the blur handler's
|
||||
// formatAutocompleteTextOnBlur() modifies the text and triggers hide()
|
||||
// via suppressAutocompleteOnce, removing this item from the DOM before
|
||||
// the click handler can execute. This specifically breaks the case where
|
||||
// the text has a comma not followed by a space (e.g. "<lora:X:1>,search").
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Hover and selection handlers
|
||||
item.addEventListener('mouseenter', () => {
|
||||
this.selectItem(index, { manual: true });
|
||||
@@ -2745,4 +2827,4 @@ class AutoComplete {
|
||||
}
|
||||
}
|
||||
|
||||
export { AutoComplete };
|
||||
export { AutoComplete, refreshLoraSyntaxFormat };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { forwardMiddleMouseToCanvas, forwardWheelToCanvas } from "./utils.js";
|
||||
import { copyToClipboard } from "./loras_widget_utils.js";
|
||||
|
||||
const MIN_HEIGHT = 150;
|
||||
const GROUP_EDITOR_ID = "lm-trigger-group-editor";
|
||||
@@ -568,6 +569,56 @@ function toggleGroupEditor(widget, index, anchorEl) {
|
||||
openGroupEditor(widget, index, anchorEl);
|
||||
}
|
||||
|
||||
function showTagContextMenu(event, tagData, index, widget, anchorEl) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
closeGroupEditor(widget);
|
||||
|
||||
const existingMenu = document.querySelector('.lm-lora-context-menu');
|
||||
if (existingMenu) {
|
||||
existingMenu.remove();
|
||||
}
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'lm-lora-context-menu';
|
||||
menu.style.left = `${event.clientX}px`;
|
||||
menu.style.top = `${event.clientY}px`;
|
||||
|
||||
const copyOption = document.createElement('div');
|
||||
copyOption.className = 'lm-lora-menu-item';
|
||||
copyOption.innerHTML = `<span class="lm-lora-menu-item-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></span>Copy`;
|
||||
copyOption.addEventListener('click', () => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
copyToClipboard(tagData.text, 'Copied to clipboard');
|
||||
});
|
||||
menu.appendChild(copyOption);
|
||||
|
||||
if (isGroupTag(tagData) && Array.isArray(tagData.items) && tagData.items.length > 1) {
|
||||
const editOption = document.createElement('div');
|
||||
editOption.className = 'lm-lora-menu-item';
|
||||
editOption.innerHTML = `<span class="lm-lora-menu-item-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></span>Edit Group`;
|
||||
editOption.addEventListener('click', () => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
toggleGroupEditor(widget, index, anchorEl);
|
||||
renderGroupEditor(widget, tagData, index);
|
||||
});
|
||||
menu.appendChild(editOption);
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const closeMenu = (e) => {
|
||||
if (!menu.contains(e.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||
}
|
||||
|
||||
export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.02, options = {}) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "comfy-tags-container";
|
||||
@@ -618,6 +669,10 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
});
|
||||
});
|
||||
|
||||
tagEl.addEventListener("contextmenu", (e) => {
|
||||
showTagContextMenu(e, tagData, index, widget, tagEl);
|
||||
});
|
||||
|
||||
if (showStrengthInfo) {
|
||||
tagEl.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -728,11 +783,13 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
};
|
||||
|
||||
editButton.addEventListener("click", openEditor);
|
||||
groupChip.addEventListener("contextmenu", openEditor);
|
||||
|
||||
groupChip.appendChild(editButton);
|
||||
}
|
||||
|
||||
groupChip.addEventListener("contextmenu", (e) => {
|
||||
showTagContextMenu(e, tagData, index, widget, groupChip);
|
||||
});
|
||||
|
||||
groupChip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (editButton && e.target === editButton) {
|
||||
@@ -773,6 +830,11 @@ export function addTagsWidget(node, name, opts, callback, wheelSensitivity = 0.0
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
|
||||
const existingMenu = document.querySelector('.lm-lora-context-menu');
|
||||
if (existingMenu) {
|
||||
existingMenu.remove();
|
||||
}
|
||||
|
||||
const normalizedTags = Array.isArray(tagsData) ? tagsData : [];
|
||||
const showStrengthInfo = widget.allowStrengthAdjustment ?? allowStrengthAdjustment;
|
||||
const groupAnchors = new Map();
|
||||
|
||||
@@ -398,13 +398,13 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section[data-v-dea4adf6] {
|
||||
.section[data-v-07ddd3df] {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section__header[data-v-dea4adf6] {
|
||||
.section__header[data-v-07ddd3df] {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section__title[data-v-dea4adf6] {
|
||||
.section__title[data-v-07ddd3df] {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -412,21 +412,21 @@
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.section__toggles[data-v-dea4adf6] {
|
||||
.section__toggles[data-v-07ddd3df] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.toggle-item[data-v-dea4adf6] {
|
||||
.toggle-item[data-v-07ddd3df] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-item__label[data-v-dea4adf6] {
|
||||
.toggle-item__label[data-v-07ddd3df] {
|
||||
font-size: 12px;
|
||||
color: var(--fg-color, #fff);
|
||||
}
|
||||
.toggle-switch[data-v-dea4adf6] {
|
||||
.toggle-switch[data-v-07ddd3df] {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
@@ -435,7 +435,7 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-switch__track[data-v-dea4adf6] {
|
||||
.toggle-switch__track[data-v-07ddd3df] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
@@ -443,11 +443,11 @@
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.toggle-switch--active .toggle-switch__track[data-v-dea4adf6] {
|
||||
.toggle-switch--active .toggle-switch__track[data-v-07ddd3df] {
|
||||
background: rgba(66, 153, 225, 0.3);
|
||||
border-color: rgba(66, 153, 225, 0.6);
|
||||
}
|
||||
.toggle-switch__thumb[data-v-dea4adf6] {
|
||||
.toggle-switch__thumb[data-v-07ddd3df] {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 2px;
|
||||
@@ -458,12 +458,12 @@
|
||||
transition: all 0.2s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.toggle-switch--active .toggle-switch__thumb[data-v-dea4adf6] {
|
||||
.toggle-switch--active .toggle-switch__thumb[data-v-07ddd3df] {
|
||||
transform: translateX(16px);
|
||||
background: #4299e1;
|
||||
opacity: 1;
|
||||
}
|
||||
.toggle-switch:hover .toggle-switch__thumb[data-v-dea4adf6] {
|
||||
.toggle-switch:hover .toggle-switch__thumb[data-v-07ddd3df] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -2223,7 +2223,7 @@ to { transform: rotate(360deg);
|
||||
})();
|
||||
var _a;
|
||||
import { app as app$1 } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { api as api$1 } from "../../../scripts/api.js";
|
||||
/**
|
||||
* @vue/shared v3.5.26
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
@@ -11094,7 +11094,10 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
|
||||
], -1)),
|
||||
createBaseVNode("div", _hoisted_2$e, [
|
||||
createBaseVNode("label", _hoisted_3$c, [
|
||||
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "toggle-item__label" }, "No Credit Required", -1)),
|
||||
_cache[3] || (_cache[3] = createBaseVNode("span", {
|
||||
class: "toggle-item__label",
|
||||
title: "Use the model without crediting the creator"
|
||||
}, "No Credit Required", -1)),
|
||||
createBaseVNode("button", {
|
||||
type: "button",
|
||||
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.noCreditRequired }]),
|
||||
@@ -11107,7 +11110,10 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
|
||||
])], 10, _hoisted_4$a)
|
||||
]),
|
||||
createBaseVNode("label", _hoisted_5$8, [
|
||||
_cache[5] || (_cache[5] = createBaseVNode("span", { class: "toggle-item__label" }, "Allow Selling", -1)),
|
||||
_cache[5] || (_cache[5] = createBaseVNode("span", {
|
||||
class: "toggle-item__label",
|
||||
title: "Allow selling generated images"
|
||||
}, "Allow Selling", -1)),
|
||||
createBaseVNode("button", {
|
||||
type: "button",
|
||||
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.allowSelling }]),
|
||||
@@ -11124,7 +11130,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
|
||||
};
|
||||
}
|
||||
});
|
||||
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$i, [["__scopeId", "data-v-dea4adf6"]]);
|
||||
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$i, [["__scopeId", "data-v-07ddd3df"]]);
|
||||
const _hoisted_1$e = { class: "preview" };
|
||||
const _hoisted_2$d = { class: "preview__title" };
|
||||
const _hoisted_3$b = ["disabled"];
|
||||
@@ -15031,6 +15037,54 @@ function createModeChangeCallback(node, updateDownstreamLoaders2, nodeSpecificCa
|
||||
};
|
||||
}
|
||||
const app = {};
|
||||
const api = {
|
||||
fetchApi: (...args) => fetch(...args),
|
||||
addEventListener: (eventName, handler) => document.addEventListener(eventName, handler),
|
||||
removeEventListener: (eventName, handler) => document.removeEventListener(eventName, handler)
|
||||
};
|
||||
let _loraSyntaxFormatCache = null;
|
||||
let _loraSyntaxFormatRefreshPromise = null;
|
||||
async function _fetchLoraSyntaxFormat() {
|
||||
try {
|
||||
const response = await api.fetchApi("/lm/settings");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.settings) {
|
||||
_loraSyntaxFormatCache = data.settings.lora_syntax_format || "legacy";
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
if (_loraSyntaxFormatCache === null) {
|
||||
_loraSyntaxFormatCache = "legacy";
|
||||
}
|
||||
}
|
||||
function _triggerBackgroundRefresh() {
|
||||
if (_loraSyntaxFormatRefreshPromise) {
|
||||
return;
|
||||
}
|
||||
_loraSyntaxFormatRefreshPromise = _fetchLoraSyntaxFormat().finally(() => {
|
||||
_loraSyntaxFormatRefreshPromise = null;
|
||||
});
|
||||
}
|
||||
function _initLoraSyntaxFormat() {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
_initLoraSyntaxFormat();
|
||||
function _initLoraSyntaxFormatReactive() {
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === "lm:lora-syntax-format-changed") {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
_triggerBackgroundRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
_initLoraSyntaxFormatReactive();
|
||||
const ROOT_GRAPH_ID = "root";
|
||||
const LORA_PROVIDER_NODE_TYPES = [
|
||||
"Lora Stacker (LoraManager)",
|
||||
@@ -15407,7 +15461,7 @@ function createLoraRandomizerWidget(node) {
|
||||
const vueApp = createApp(LoraRandomizerWidget, {
|
||||
widget,
|
||||
node,
|
||||
api
|
||||
api: api$1
|
||||
});
|
||||
vueApp.use(PrimeVue, {
|
||||
unstyled: true,
|
||||
@@ -15482,7 +15536,7 @@ function createLoraCyclerWidget(node) {
|
||||
const vueApp = createApp(LoraCyclerWidget, {
|
||||
widget,
|
||||
node,
|
||||
api
|
||||
api: api$1
|
||||
});
|
||||
vueApp.use(PrimeVue, {
|
||||
unstyled: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user