mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
75 Commits
1ed5eef985
...
v1.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fd2b43d6 | ||
|
|
c53f44e7ef | ||
|
|
ae7bfdb517 | ||
|
|
68bf8442eb | ||
|
|
605fbf4117 | ||
|
|
406d5fea6a | ||
|
|
af2146f96c | ||
|
|
bdc8dec860 | ||
|
|
c4fa1631ee | ||
|
|
506d763dc2 | ||
|
|
a2cd09b619 | ||
|
|
cdd77029b6 | ||
|
|
439679e15f | ||
|
|
2640258902 | ||
|
|
b910388d54 | ||
|
|
083de395b1 | ||
|
|
4514ca94b7 | ||
|
|
62247bdd87 | ||
|
|
6d0d9600a7 | ||
|
|
70cd3f4e1b | ||
|
|
a95c518b30 | ||
|
|
ba1800095e | ||
|
|
39c083db79 | ||
|
|
55e9e4bb6f | ||
|
|
0253d001e6 | ||
|
|
9998da3241 | ||
|
|
6666a72775 | ||
|
|
5f1bd894b9 | ||
|
|
1817142a7b | ||
|
|
25fa175aa2 | ||
|
|
39643eb2bc | ||
|
|
4ac78f8aa8 | ||
|
|
0bcca0ba68 | ||
|
|
72f8e0d1be | ||
|
|
85b6c91192 | ||
|
|
908016cbd6 | ||
|
|
a5ac9cf81b | ||
|
|
32875042bd | ||
|
|
51fe7aa07e | ||
|
|
db4726a961 | ||
|
|
e13d70248a | ||
|
|
1c4919a3e8 | ||
|
|
18ddadc9ec | ||
|
|
b6dd6938b0 | ||
|
|
b711ac468a | ||
|
|
727d0ef043 | ||
|
|
9344d86332 | ||
|
|
d36b16c213 | ||
|
|
33a7f07558 | ||
|
|
4f599aeced | ||
|
|
30db8c3d1d | ||
|
|
05636712f0 | ||
|
|
d8e5fe1247 | ||
|
|
3e9210394a | ||
|
|
4dd2c0526f | ||
|
|
9bdb337962 | ||
|
|
f93baf5fc0 | ||
|
|
14cb7fec47 | ||
|
|
f3b3e0adad | ||
|
|
ba3f15dbc6 | ||
|
|
8dc2a2f76b | ||
|
|
316f17dd46 | ||
|
|
3dc10b1404 | ||
|
|
331889d872 | ||
|
|
06f1a82d4c | ||
|
|
267082c712 | ||
|
|
a4cb51e96c | ||
|
|
ca44c367b3 | ||
|
|
301ab14781 | ||
|
|
2626dbab8e | ||
|
|
12bbb0572d | ||
|
|
00f5c1e887 | ||
|
|
89b1675ec7 | ||
|
|
dcc7bd33b5 | ||
|
|
e5152108ba |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ model_cache/
|
||||
# agent
|
||||
.opencode/
|
||||
.claude/
|
||||
.codex
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
vue-widgets/node_modules/
|
||||
|
||||
@@ -7,6 +7,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.nodes.prompt import PromptLM
|
||||
from .py.nodes.text import TextLM
|
||||
from .py.nodes.lora_stacker import LoraStackerLM
|
||||
from .py.nodes.lora_stack_combiner import LoraStackCombinerLM
|
||||
from .py.nodes.save_image import SaveImageLM
|
||||
from .py.nodes.debug_metadata import DebugMetadataLM
|
||||
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
|
||||
@@ -39,6 +40,9 @@ except (
|
||||
"py.nodes.trigger_word_toggle"
|
||||
).TriggerWordToggleLM
|
||||
LoraStackerLM = importlib.import_module("py.nodes.lora_stacker").LoraStackerLM
|
||||
LoraStackCombinerLM = importlib.import_module(
|
||||
"py.nodes.lora_stack_combiner"
|
||||
).LoraStackCombinerLM
|
||||
SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM
|
||||
DebugMetadataLM = importlib.import_module("py.nodes.debug_metadata").DebugMetadataLM
|
||||
WanVideoLoraSelectLM = importlib.import_module(
|
||||
@@ -63,6 +67,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
UNETLoaderLM.NAME: UNETLoaderLM,
|
||||
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
||||
LoraStackerLM.NAME: LoraStackerLM,
|
||||
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
|
||||
SaveImageLM.NAME: SaveImageLM,
|
||||
DebugMetadataLM.NAME: DebugMetadataLM,
|
||||
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
"Insomnia Art Designs",
|
||||
"megakirbs",
|
||||
"Brennok",
|
||||
"wackop",
|
||||
"2018cfh",
|
||||
"W+K+White",
|
||||
"wackop",
|
||||
"Takkan",
|
||||
"stone9k",
|
||||
"Carl G.",
|
||||
"$MetaSamsara",
|
||||
"itismyelement",
|
||||
"onesecondinosaur",
|
||||
"Carl G.",
|
||||
"stone9k",
|
||||
"Rosenthal",
|
||||
"Francisco Tatis",
|
||||
"Tobi_Swagg",
|
||||
"Andrew Wilson",
|
||||
"Greybush",
|
||||
"Gooohokrbe",
|
||||
@@ -29,18 +29,16 @@
|
||||
"VantAI",
|
||||
"runte3221",
|
||||
"FreelancerZ",
|
||||
"Julian V",
|
||||
"Edgar Tejeda",
|
||||
"Birdy",
|
||||
"Liam MacDougal",
|
||||
"Fraser Cross",
|
||||
"Polymorphic Indeterminate",
|
||||
"Birdy",
|
||||
"Marc Whiffen",
|
||||
"Kiba",
|
||||
"Jorge Hussni",
|
||||
"Reno Lam",
|
||||
"Kiba",
|
||||
"Skalabananen",
|
||||
"esthe",
|
||||
"Reno Lam",
|
||||
"sig",
|
||||
"Christian Byrne",
|
||||
"DM",
|
||||
@@ -49,24 +47,22 @@
|
||||
"J\\B/ 8r0wns0n",
|
||||
"Snaggwort",
|
||||
"Arlecchino Shion",
|
||||
"Charles Blakemore",
|
||||
"Rob Williams",
|
||||
"ClockDaemon",
|
||||
"KD",
|
||||
"Omnidex",
|
||||
"Tyler Trebuchon",
|
||||
"Release Cabrakan",
|
||||
"confiscated Zyra",
|
||||
"Tobi_Swagg",
|
||||
"SG",
|
||||
"carozzz",
|
||||
"James Dooley",
|
||||
"zenbound",
|
||||
"Buzzard",
|
||||
"jmack",
|
||||
"Adam Shaw",
|
||||
"Tee Gee",
|
||||
"Mark Corneglio",
|
||||
"SarcasticHashtag",
|
||||
"Anthony Rizzo",
|
||||
"tarek helmi",
|
||||
"Cosmosis",
|
||||
"iamresist",
|
||||
"RedrockVP",
|
||||
@@ -75,45 +71,34 @@
|
||||
"James Todd",
|
||||
"Steven Pfeiffer",
|
||||
"Tim",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Lisster",
|
||||
"Michael Wong",
|
||||
"Illrigger",
|
||||
"whudunit",
|
||||
"Tom Corrigan",
|
||||
"JackieWang",
|
||||
"fnkylove",
|
||||
"Julian V",
|
||||
"Steven Owens",
|
||||
"Yushio",
|
||||
"Vik71it",
|
||||
"lh qwe",
|
||||
"Echo",
|
||||
"Lilleman",
|
||||
"Robert Stacey",
|
||||
"PM",
|
||||
"Todd Keck",
|
||||
"Briton Heilbrun",
|
||||
"Mozzel",
|
||||
"Gingko Biloba",
|
||||
"Felipe dos Santos",
|
||||
"Penfore",
|
||||
"BadassArabianMofo",
|
||||
"Sterilized",
|
||||
"BadassArabianMofo",
|
||||
"Pascal Dahle",
|
||||
"Markus",
|
||||
"quarz",
|
||||
"Greg",
|
||||
"Douglas Gaspar",
|
||||
"Penfore",
|
||||
"JSST",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"esthe",
|
||||
"lmsupporter",
|
||||
"Phil",
|
||||
"Charles Blakemore",
|
||||
"IamAyam",
|
||||
"wfpearl",
|
||||
"Rob Williams",
|
||||
"Baekdoosixt",
|
||||
"Jonathan Ross",
|
||||
"Jack B Nimble",
|
||||
@@ -125,127 +110,118 @@
|
||||
"contrite831",
|
||||
"Alex",
|
||||
"bh",
|
||||
"confiscated Zyra",
|
||||
"Marlon Daniels",
|
||||
"Starkselle",
|
||||
"Aaron Bleuer",
|
||||
"LacesOut!",
|
||||
"Graham Colehour",
|
||||
"greebles",
|
||||
"Adam Shaw",
|
||||
"Tee Gee",
|
||||
"Anthony Rizzo",
|
||||
"tarek helmi",
|
||||
"M Postkasse",
|
||||
"Tomohiro Baba",
|
||||
"David Ortega",
|
||||
"ASLPro3D",
|
||||
"Jacob Hoehler",
|
||||
"FinalyFree",
|
||||
"Weasyl",
|
||||
"Lex Song",
|
||||
"Timmy",
|
||||
"Johnny",
|
||||
"Cory Paza",
|
||||
"Tak",
|
||||
"Gonzalo Andre Allendes Lopez",
|
||||
"Zach Gonser",
|
||||
"Big Red",
|
||||
"Jimmy Ledbetter",
|
||||
"whudunit",
|
||||
"Luc Job",
|
||||
"dl0901dm",
|
||||
"Philip Hempel",
|
||||
"corde",
|
||||
"Nick Walker",
|
||||
"lh qwe",
|
||||
"Bishoujoker",
|
||||
"conner",
|
||||
"aai",
|
||||
"Yaboi",
|
||||
"Briton Heilbrun",
|
||||
"Tori",
|
||||
"wildnut",
|
||||
"Princess Bright Eyes",
|
||||
"Damon Cunliffe",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
"AbstractAss",
|
||||
"Felipe dos Santos",
|
||||
"ViperC",
|
||||
"jean jahren",
|
||||
"Aleksander Wujczyk",
|
||||
"AM Kuro",
|
||||
"jean jahren",
|
||||
"Ran C",
|
||||
"tedcor",
|
||||
"Markus",
|
||||
"S Sang",
|
||||
"MagnaInsomnia",
|
||||
"Akira_HentAI",
|
||||
"Karl P.",
|
||||
"Akira_HentAI",
|
||||
"MagnaInsomnia",
|
||||
"Gordon Cole",
|
||||
"yuxz69",
|
||||
"MadSpin",
|
||||
"Douglas Gaspar",
|
||||
"AlexDuKaNa",
|
||||
"George",
|
||||
"andrew.tappan",
|
||||
"dw",
|
||||
"N/A",
|
||||
"The Spawn",
|
||||
"Phil",
|
||||
"graysock",
|
||||
"Greenmoustache",
|
||||
"zounic",
|
||||
"Gamalonia",
|
||||
"fancypants",
|
||||
"Vir",
|
||||
"Joboshy",
|
||||
"Digital",
|
||||
"JaxMax",
|
||||
"takyamtom",
|
||||
"Bohemian Corporal",
|
||||
"奚明 刘",
|
||||
"Dan",
|
||||
"Seth Christensen",
|
||||
"Jwk0205",
|
||||
"Bro Xie",
|
||||
"Draven T",
|
||||
"yer fey",
|
||||
"준희 김",
|
||||
"batblue",
|
||||
"carey6409",
|
||||
"Olive",
|
||||
"太郎 ゲーム",
|
||||
"Some Guy Named Barry",
|
||||
"jinxedx",
|
||||
"Aquatic Coffee",
|
||||
"Max Marklund",
|
||||
"Tomohiro Baba",
|
||||
"David Ortega",
|
||||
"AELOX",
|
||||
"Dankin",
|
||||
"Nicfit23",
|
||||
"Noora",
|
||||
"ethanfel",
|
||||
"wamekukyouzin",
|
||||
"drum matthieu",
|
||||
"Dogmaster",
|
||||
"Matt Wenzel",
|
||||
"Mattssn",
|
||||
"Frank Nitty",
|
||||
"Lex Song",
|
||||
"John Saveas",
|
||||
"Focuschannel",
|
||||
"Christopher Michel",
|
||||
"Serge Bekenkamp",
|
||||
"Jimmy Ledbetter",
|
||||
"LeoZero",
|
||||
"Antonio Pontes",
|
||||
"ApathyJones",
|
||||
"nahinahi9",
|
||||
"Anthony Faxlandez",
|
||||
"Dustin Chen",
|
||||
"dan",
|
||||
"Blackfish95",
|
||||
"Yaboi",
|
||||
"Mouthlessman",
|
||||
"Steam Steam",
|
||||
"Paul Kroll",
|
||||
"Damon Cunliffe",
|
||||
"CryptoTraderJK",
|
||||
"Davaitamin",
|
||||
"otaku fra",
|
||||
"semicolon drainpipe",
|
||||
"Thesharingbrother",
|
||||
"Ran C",
|
||||
"tedcor",
|
||||
"Fotek Design",
|
||||
"Bas Imagineer",
|
||||
"Pat Hen",
|
||||
"ResidentDeviant",
|
||||
"Adam Taylor",
|
||||
"JC",
|
||||
"Weird_With_A_Beard",
|
||||
"Prompt Pirate",
|
||||
"MadSpin",
|
||||
"Pozadine1",
|
||||
"uwutismxd",
|
||||
"Qarob",
|
||||
"AIGooner",
|
||||
"inbijiburu",
|
||||
"decoy",
|
||||
"Luc",
|
||||
"ProtonPrince",
|
||||
"DiffDuck",
|
||||
@@ -258,53 +234,54 @@
|
||||
"thesoftwaredruid",
|
||||
"wundershark",
|
||||
"mr_dinosaur",
|
||||
"Tyrswood",
|
||||
"linnfrey",
|
||||
"zenobeus",
|
||||
"Jackthemind",
|
||||
"Stryker",
|
||||
"Gamalonia",
|
||||
"Vir",
|
||||
"Pkrsky",
|
||||
"raf8osz",
|
||||
"blikkies",
|
||||
"Joboshy",
|
||||
"Bohemian Corporal",
|
||||
"Dan",
|
||||
"Josef Lanzl",
|
||||
"Seth Christensen",
|
||||
"Griffin Dahlberg",
|
||||
"준희 김",
|
||||
"Draven T",
|
||||
"yer fey",
|
||||
"Error_Rule34_Not_found",
|
||||
"Gerald Welly",
|
||||
"Shock Shockor",
|
||||
"Roslynd",
|
||||
"Geolog",
|
||||
"Goldwaters",
|
||||
"jinxedx",
|
||||
"Neco28",
|
||||
"Zude",
|
||||
"Aquatic Coffee",
|
||||
"Dankin",
|
||||
"ethanfel",
|
||||
"Cristian Vazquez",
|
||||
"Kyler",
|
||||
"Frank Nitty",
|
||||
"Magic Noob",
|
||||
"aRtFuL_DodGeR",
|
||||
"X",
|
||||
"Focuschannel",
|
||||
"DougPeterson",
|
||||
"Jeff",
|
||||
"Bruce",
|
||||
"CrimsonDX",
|
||||
"Kevin John Duck",
|
||||
"Anthony Faxlandez",
|
||||
"Kevin Christopher",
|
||||
"Ouro Boros",
|
||||
"DarkSunset",
|
||||
"Blackfish95",
|
||||
"dd",
|
||||
"Billy Gladky",
|
||||
"Probis",
|
||||
"shrshpp",
|
||||
"Dušan Ryban",
|
||||
"ItsGeneralButtNaked",
|
||||
"sjon kreutz",
|
||||
"Nimess",
|
||||
"Paul Kroll",
|
||||
"MiraiKuriyamaSy",
|
||||
"semicolon drainpipe",
|
||||
"Thesharingbrother",
|
||||
"Bas Imagineer",
|
||||
"Pat Hen",
|
||||
"John Statham",
|
||||
"Youguang",
|
||||
"ResidentDeviant",
|
||||
"Nihongasuki",
|
||||
"Metryman55",
|
||||
"andrewzpong",
|
||||
"FrxzenSnxw",
|
||||
"BossGame",
|
||||
"JC",
|
||||
"Prompt Pirate",
|
||||
"uwutismxd",
|
||||
"decoy",
|
||||
"Tyrswood",
|
||||
"Ray Wing",
|
||||
"Ranzitho",
|
||||
"Gus",
|
||||
@@ -316,7 +293,6 @@
|
||||
"WRL_SPR",
|
||||
"capn",
|
||||
"Joseph",
|
||||
"lrdchs",
|
||||
"Mirko Katzula",
|
||||
"dan",
|
||||
"Piccio08",
|
||||
@@ -326,51 +302,135 @@
|
||||
"Moon Knight",
|
||||
"몽타주",
|
||||
"Kland",
|
||||
"Hailshem",
|
||||
"zenobeus",
|
||||
"Jackthemind",
|
||||
"ryoma",
|
||||
"John Martin",
|
||||
"Stryker",
|
||||
"raf8osz",
|
||||
"ElitaSSJ4",
|
||||
"blikkies",
|
||||
"Chris",
|
||||
"Brian M",
|
||||
"Nerezza",
|
||||
"sanborondon",
|
||||
"moranqianlong",
|
||||
"Taylor Funk",
|
||||
"aezin",
|
||||
"Thought2Form",
|
||||
"jcay015",
|
||||
"Kevin Picco",
|
||||
"Erik Lopez",
|
||||
"Shock Shockor",
|
||||
"Mateo Curić",
|
||||
"Haru Yotu",
|
||||
"Goldwaters",
|
||||
"Zude",
|
||||
"Eris3D",
|
||||
"m",
|
||||
"Pierce McBride",
|
||||
"Joshua Gray",
|
||||
"Kyler",
|
||||
"Mikko Hemilä",
|
||||
"Matura Arbeit",
|
||||
"aRtFuL_DodGeR",
|
||||
"Jamie Ogletree",
|
||||
"TBitz33",
|
||||
"Emil Bernhoff",
|
||||
"a _",
|
||||
"SendingRavens",
|
||||
"James Coleman",
|
||||
"CrimsonDX",
|
||||
"Martial",
|
||||
"battu",
|
||||
"Emil Andersson",
|
||||
"Chad Idk",
|
||||
"Michael Docherty",
|
||||
"DarkSunset",
|
||||
"Billy Gladky",
|
||||
"Yuji Kaneko",
|
||||
"Probis",
|
||||
"Dušan Ryban",
|
||||
"ItsGeneralButtNaked",
|
||||
"Jordan Shaw",
|
||||
"Rops Alot",
|
||||
"Sam",
|
||||
"sjon kreutz",
|
||||
"Nimess",
|
||||
"SRDB",
|
||||
"Ace Ventura",
|
||||
"g unit",
|
||||
"Youguang",
|
||||
"Metryman55",
|
||||
"andrewzpong",
|
||||
"FrxzenSnxw",
|
||||
"BossGame",
|
||||
"lrdchs",
|
||||
"momokai",
|
||||
"Hailshem",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"ken",
|
||||
"Inversity",
|
||||
"AIVORY3D",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"Kevinj",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
"Nat_20",
|
||||
"Edward Weeks",
|
||||
"kyoumei",
|
||||
"RadStorm04",
|
||||
"JohnDoe42054",
|
||||
"BillyHill",
|
||||
"emyth",
|
||||
"chriphost",
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"ResidentDeviant",
|
||||
"gzmzmvp",
|
||||
"Welkor",
|
||||
"John Martin",
|
||||
"Richard",
|
||||
"Andrew",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"moranqianlong",
|
||||
"Gregory Kozhemiak",
|
||||
"mrjuan",
|
||||
"Brian Buie",
|
||||
"Sadlip",
|
||||
"Haru Yotu",
|
||||
"Eric Whitney",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"Mike Simone",
|
||||
"Morgandel",
|
||||
"Kyron Mahan",
|
||||
"Matura Arbeit",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"X",
|
||||
"Sloan Steddy",
|
||||
"TBitz33",
|
||||
"Anonym dkjglfleeoeldldldlkf",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"SendingRavens",
|
||||
"Derek Baker",
|
||||
"Michael Anthony Scott",
|
||||
"Atilla Berke Pekduyar",
|
||||
"Michael Docherty",
|
||||
"Nathan",
|
||||
"Decx _",
|
||||
"Paul Hartsuyker",
|
||||
"elitassj",
|
||||
"Jacob Winter",
|
||||
"Jordan Shaw",
|
||||
"Sam",
|
||||
"Rops Alot",
|
||||
"SRDB",
|
||||
"g unit",
|
||||
"Ace Ventura",
|
||||
"Distortik",
|
||||
"David",
|
||||
"Meilo",
|
||||
"Pen Bouryoung",
|
||||
"四糸凜音",
|
||||
"shinonomeiro",
|
||||
"Snille",
|
||||
"MaartenAlbers",
|
||||
@@ -378,101 +438,104 @@
|
||||
"xybrightsummer",
|
||||
"jreedatchison",
|
||||
"PhilW",
|
||||
"momokai",
|
||||
"Tree Tagger",
|
||||
"Janik",
|
||||
"kudari",
|
||||
"Naomi Hale Danchi",
|
||||
"dc7431",
|
||||
"ken",
|
||||
"Inversity",
|
||||
"Crocket",
|
||||
"AIVORY3D",
|
||||
"epicgamer0020690",
|
||||
"Joshua Porrata",
|
||||
"Cruel",
|
||||
"keemun",
|
||||
"SuBu",
|
||||
"RedPIXel",
|
||||
"MRBlack",
|
||||
"Kevinj",
|
||||
"Wind",
|
||||
"Nexus",
|
||||
"Mitchell Robson",
|
||||
"Ramneek“Guy”Ashok",
|
||||
"squid_actually",
|
||||
"Nat_20",
|
||||
"Kiyoe",
|
||||
"Edward Weeks",
|
||||
"kyoumei",
|
||||
"RadStorm04",
|
||||
"JohnDoe42054",
|
||||
"BillyHill",
|
||||
"humptynutz",
|
||||
"emyth",
|
||||
"michael.isaza",
|
||||
"Kalnei",
|
||||
"chriphost",
|
||||
"KitKatM",
|
||||
"socrasteeze",
|
||||
"ResidentDeviant",
|
||||
"Whitepinetrader",
|
||||
"OrganicArtifact",
|
||||
"Scott",
|
||||
"gzmzmvp",
|
||||
"Welkor",
|
||||
"MudkipMedkitz",
|
||||
"deanbrian",
|
||||
"POPPIN",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Menard",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"D",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"nanana",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"Buecyb99",
|
||||
"4IXplr0r3r",
|
||||
"dfklsjfkljslfjd",
|
||||
"hayden",
|
||||
"Richard",
|
||||
"ahoystan",
|
||||
"Leland Saunders",
|
||||
"Andrew",
|
||||
"Wolfe7D1",
|
||||
"Ink Temptation",
|
||||
"Bob Barker",
|
||||
"Robert Wegemund",
|
||||
"Littlehuggy",
|
||||
"Gregory Kozhemiak",
|
||||
"mrjuan",
|
||||
"edk",
|
||||
"Kalli Core",
|
||||
"Aeternyx",
|
||||
"Brian Buie",
|
||||
"elleshar666",
|
||||
"YOU SINWOO",
|
||||
"Sadlip",
|
||||
"ja s",
|
||||
"Eric Whitney",
|
||||
"Doug Mason",
|
||||
"Joey Callahan",
|
||||
"Ivan Tadic",
|
||||
"y2Rxy7FdXzWo",
|
||||
"Kauffy",
|
||||
"Jeremy Townsend",
|
||||
"Mike Simone",
|
||||
"EpicElric",
|
||||
"Sean voets",
|
||||
"Owen Gwosdz",
|
||||
"Morgandel",
|
||||
"John J Linehan",
|
||||
"Elliot E",
|
||||
"Thomas Wanner",
|
||||
"Kyron Mahan",
|
||||
"Theerat Jiramate",
|
||||
"Noah",
|
||||
"Jacob McDaniel",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
"Devil Lude",
|
||||
"Nick Kage",
|
||||
"kevin stoddard",
|
||||
"Sloan Steddy",
|
||||
"Jack Dole",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Ezokewn",
|
||||
"Temikus",
|
||||
"Artokun",
|
||||
"Michael Taylor",
|
||||
"Derek Baker",
|
||||
"Michael Anthony Scott",
|
||||
"Atilla Berke Pekduyar",
|
||||
"hexxish",
|
||||
"CptNeo",
|
||||
"notedfakes",
|
||||
"Maso",
|
||||
"Nathan",
|
||||
"Decx _",
|
||||
"Eric Ketchum",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Michael Scott",
|
||||
"Kevin Wallace",
|
||||
"Matheus Couto",
|
||||
"Paul Hartsuyker",
|
||||
"Saya",
|
||||
"ChicRic",
|
||||
"mercur",
|
||||
"J C",
|
||||
"Distortik",
|
||||
"Ed Wang",
|
||||
"Ryan Presley Ng",
|
||||
"Wes Sims",
|
||||
"Donor4115",
|
||||
"Yves Poezevara",
|
||||
"Teriak47",
|
||||
"Just me",
|
||||
"Raf Stahelin",
|
||||
"Вячеслав Маринин",
|
||||
"Lyavph",
|
||||
"Filippo Ferrari",
|
||||
"Cola Matthew",
|
||||
"OniNoKen",
|
||||
"Iain Wisely",
|
||||
@@ -505,117 +568,100 @@
|
||||
"RevyHiep",
|
||||
"Captain_Swag",
|
||||
"obkircher",
|
||||
"Tree Tagger",
|
||||
"gwyar",
|
||||
"D",
|
||||
"edgecase",
|
||||
"Neoxena",
|
||||
"mrmhalo",
|
||||
"dg",
|
||||
"Whitepinetrader",
|
||||
"Maarten Harms",
|
||||
"OrganicArtifact",
|
||||
"四糸凜音",
|
||||
"MudkipMedkitz",
|
||||
"Israel",
|
||||
"deanbrian",
|
||||
"POPPIN",
|
||||
"Muratoraccio",
|
||||
"SelfishMedic",
|
||||
"Ginnie",
|
||||
"Alex Wortman",
|
||||
"Cody",
|
||||
"adderleighn",
|
||||
"Raku",
|
||||
"smart.edge5178",
|
||||
"emadsultan",
|
||||
"InformedViewz",
|
||||
"CHKeeho80",
|
||||
"Bubbafett",
|
||||
"leaf",
|
||||
"Menard",
|
||||
"Skyfire83",
|
||||
"Adam Rinehart",
|
||||
"D",
|
||||
"Pitpe11",
|
||||
"TheD1rtyD03",
|
||||
"EnragedAntelope",
|
||||
"moonpetal",
|
||||
"SomeDude",
|
||||
"g9p0o",
|
||||
"nanana",
|
||||
"TheHolySheep",
|
||||
"Monte Won",
|
||||
"SpringBootisTrash",
|
||||
"carsten",
|
||||
"ikok",
|
||||
"Buecyb99",
|
||||
"4IXplr0r3r",
|
||||
"Alan+Cano",
|
||||
"FeralOpticsAI",
|
||||
"Pavlaki",
|
||||
"generic404",
|
||||
"Mateusz+Kosela",
|
||||
"Doug+Rintoul",
|
||||
"Noor",
|
||||
"Yorunai",
|
||||
"Bula",
|
||||
"quantenmecha",
|
||||
"abattoirblues",
|
||||
"Jason+Nash",
|
||||
"BillyBoy84",
|
||||
"DarkRoast",
|
||||
"zounik",
|
||||
"letzte",
|
||||
"Nasty+Hobbit",
|
||||
"SgtFluffles",
|
||||
"lrdchs2",
|
||||
"Duk3+Rand0m",
|
||||
"KUJYAKU",
|
||||
"NathenChoi",
|
||||
"Thomas+Reck",
|
||||
"Larses",
|
||||
"cocona",
|
||||
"Coeur+de+cochon",
|
||||
"David Schenck",
|
||||
"han b",
|
||||
"Nico",
|
||||
"Wolfe7D1",
|
||||
"Banana Joe",
|
||||
"_ G3n",
|
||||
"Donovan Jenkins",
|
||||
"Ink Temptation",
|
||||
"edk",
|
||||
"JBsuede",
|
||||
"Michael Eid",
|
||||
"beersandbacon",
|
||||
"Maximilian Pyko",
|
||||
"Invis",
|
||||
"Kalli Core",
|
||||
"Justin Houston",
|
||||
"Time Valentine",
|
||||
"james",
|
||||
"elleshar666",
|
||||
"OrochiNights",
|
||||
"Michael Zhu",
|
||||
"ACTUALLY_the_Real_Willem_Dafoe",
|
||||
"gonzalo",
|
||||
"Seraphy",
|
||||
"Михал Михалыч",
|
||||
"雨の心 落",
|
||||
"Matt",
|
||||
"AllTimeNoobie",
|
||||
"jumpd",
|
||||
"John C",
|
||||
"Kauffy",
|
||||
"Rim",
|
||||
"Dismem",
|
||||
"EpicElric",
|
||||
"John J Linehan",
|
||||
"Frogmilk",
|
||||
"SPJ",
|
||||
"Xan Dionysus",
|
||||
"Nathan lee",
|
||||
"Mewtora",
|
||||
"Elliot E",
|
||||
"Middo",
|
||||
"Forbidden Atelier",
|
||||
"Edward Kennedy",
|
||||
"Justin Blaylock",
|
||||
"Bryan Rutkowski",
|
||||
"Adictedtohumping",
|
||||
"Devil Lude",
|
||||
"Nick Kage",
|
||||
"Towelie",
|
||||
"Vane Holzer",
|
||||
"psytrax",
|
||||
"Cyrus Fett",
|
||||
"Jean-françois SEMA",
|
||||
"Kurt",
|
||||
"hexxish",
|
||||
"giani kidd",
|
||||
"CptNeo",
|
||||
"notedfakes",
|
||||
"max blo",
|
||||
"Xenon Xue",
|
||||
"JackJohnnyJim",
|
||||
"Edward Ten Eyck",
|
||||
"Chase Kwon",
|
||||
"Inyoshu",
|
||||
"Goober719",
|
||||
"Eric Ketchum",
|
||||
"Chad Barnes",
|
||||
"NICHOLAS BAXLEY",
|
||||
"Michael Scott",
|
||||
"James Ming",
|
||||
"vanditking",
|
||||
"kripitonga",
|
||||
"Rizzi",
|
||||
"nimin",
|
||||
"OMAR LUCIANO",
|
||||
"hannibal",
|
||||
"Jo+Example",
|
||||
"BrentBertram",
|
||||
"eumelzocker",
|
||||
@@ -623,5 +669,5 @@
|
||||
"L C",
|
||||
"Dude"
|
||||
],
|
||||
"totalCount": 620
|
||||
"totalCount": 666
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
133
locales/de.json
133
locales/de.json
@@ -175,6 +175,9 @@
|
||||
"success": "{count} Rezepte erfolgreich repariert.",
|
||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai-Host",
|
||||
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
|
||||
"options": {
|
||||
"com": "civitai.com (nur SFW)",
|
||||
"red": "civitai.red (uneingeschränkt)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai-Host-Einstellung verfügbar",
|
||||
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Einstellungsordner öffnen",
|
||||
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "Video-Einstellungen",
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"misc": "Verschiedenes",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Standard-Roots",
|
||||
"recipeSettings": "Rezepte",
|
||||
"extraFolderPaths": "Zusätzliche Ordnerpfade",
|
||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||
"priorityTags": "Prioritäts-Tags",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Automatische Backups",
|
||||
"autoEnabledHelp": "Erstellt einmal täglich einen lokalen Schnappschuss und behält die neuesten Schnappschüsse gemäß der Aufbewahrungsrichtlinie.",
|
||||
"retention": "Aufbewahrungsanzahl",
|
||||
"retentionHelp": "Wie viele automatische Schnappschüsse behalten werden, bevor ältere entfernt werden.",
|
||||
"management": "Backup-Verwaltung",
|
||||
"managementHelp": "Exportiere deinen aktuellen Benutzerstatus oder stelle ihn aus einem Backup-Archiv wieder her.",
|
||||
"scopeHelp": "Sichert deine Einstellungen, den Downloadverlauf und den Status der Modellaktualisierung. Modelldateien und neu erzeugbare Caches sind nicht enthalten.",
|
||||
"locationSummary": "Aktueller Backup-Speicherort",
|
||||
"openFolderButton": "Backup-Ordner öffnen",
|
||||
"openFolderSuccess": "Backup-Ordner geöffnet",
|
||||
"openFolderFailed": "Backup-Ordner konnte nicht geöffnet werden",
|
||||
"locationCopied": "Backup-Pfad in die Zwischenablage kopiert: {{path}}",
|
||||
"locationClipboardFallback": "Backup-Pfad: {{path}}",
|
||||
"exportButton": "Backup exportieren",
|
||||
"exportSuccess": "Backup erfolgreich exportiert.",
|
||||
"exportFailed": "Backup konnte nicht exportiert werden: {message}",
|
||||
"importButton": "Backup importieren",
|
||||
"importConfirm": "Dieses Backup importieren und den lokalen Benutzerstatus überschreiben?",
|
||||
"importSuccess": "Backup erfolgreich importiert.",
|
||||
"importFailed": "Backup konnte nicht importiert werden: {message}",
|
||||
"latestSnapshot": "Neuester Schnappschuss",
|
||||
"latestAutoSnapshot": "Neuester automatischer Schnappschuss",
|
||||
"snapshotCount": "Gespeicherte Schnappschüsse",
|
||||
"noneAvailable": "Noch keine Schnappschüsse vorhanden"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Downloads für Basismodelle überspringen",
|
||||
"help": "Gilt für alle Download-Abläufe. Hier können nur unterstützte Basismodelle ausgewählt werden.",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "Ausgeschlossene Basismodelle konnten nicht gespeichert werden: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "Bereits heruntergeladene Modellversionen überspringen",
|
||||
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Anzeige-Dichte",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"recipesPath": "Rezepte-Speicherpfad",
|
||||
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "Rezepte-Speicher wird verschoben...",
|
||||
"noDefault": "Kein Standard"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Zusätzliche Ordnerpfade",
|
||||
"help": "Fügen Sie zusätzliche Modellordner außerhalb der Standardpfade von ComfyUI hinzu. Diese Pfade werden separat gespeichert und zusammen mit den Standardordnern gescannt.",
|
||||
"description": "Konfigurieren Sie zusätzliche Ordner zum Scannen von Modellen. Diese Pfade sind spezifisch für LoRA Manager und werden mit den Standardpfaden von ComfyUI zusammengeführt.",
|
||||
"description": "Zusätzliche Modellstammverzeichnisse, die ausschließlich für LoRA Manager gelten. Laden Sie Modelle von Speicherorten außerhalb der Standardordner von ComfyUI – ideal für große Bibliotheken, die ComfyUI sonst verlangsamen würden.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA-Pfade",
|
||||
"checkpoint": "Checkpoint-Pfade",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Embedding-Pfade"
|
||||
},
|
||||
"pathPlaceholder": "/pfad/zu/extra/modellen",
|
||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert.",
|
||||
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert. Neustart erforderlich, um Änderungen anzuwenden.",
|
||||
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
"restoreModel": "Modell wiederherstellen",
|
||||
"deleteModel": "Modell löschen",
|
||||
"shareRecipe": "Rezept teilen",
|
||||
"viewAllLoras": "Alle LoRAs anzeigen",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben"
|
||||
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben",
|
||||
"sendToWorkflow": "An Workflow senden"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "Sidebar lösen",
|
||||
"switchToListView": "Zur Listenansicht wechseln",
|
||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||
"recursiveOn": "Unterordner durchsuchen",
|
||||
"recursiveOff": "Nur aktuellen Ordner durchsuchen",
|
||||
"recursiveOn": "Unterordner einbeziehen",
|
||||
"recursiveOff": "Nur aktueller Ordner",
|
||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "Early Access",
|
||||
"earlyAccessTooltip": "Early Access erforderlich",
|
||||
"inLibrary": "In Bibliothek",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||
"errors": {
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "Auf Civitai anzeigen",
|
||||
"viewOnCivitaiText": "Auf Civitai anzeigen",
|
||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||
"openFileLocation": "Dateispeicherort öffnen"
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
"sendToWorkflow": "An ComfyUI senden",
|
||||
"sendToWorkflowText": "An ComfyUI senden"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Dateispeicherort erfolgreich geöffnet",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||
"clipboardFallback": "Pfad: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Version",
|
||||
"fileName": "Dateiname",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "in {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Aktuelle Version",
|
||||
"current": "Geöffnete Version",
|
||||
"currentTooltip": "Das ist die Version, mit der dieses Modal geöffnet wurde",
|
||||
"inLibrary": "In der Bibliothek",
|
||||
"inLibraryTooltip": "Diese Version befindet sich in Ihrer lokalen Bibliothek",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"downloadedTooltip": "Diese Version wurde bereits heruntergeladen, befindet sich aber derzeit nicht in Ihrer Bibliothek",
|
||||
"newer": "Neuere Version",
|
||||
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
||||
"earlyAccess": "Früher Zugriff",
|
||||
"ignored": "Ignoriert"
|
||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||
"ignored": "Ignoriert",
|
||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Herunterladen",
|
||||
"downloadTooltip": "Diese Version herunterladen",
|
||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||
"delete": "Löschen",
|
||||
"deleteTooltip": "Diese lokale Version löschen",
|
||||
"ignore": "Ignorieren",
|
||||
"unignore": "Ignorierung aufheben",
|
||||
"ignoreTooltip": "Update-Benachrichtigungen für diese Version ignorieren",
|
||||
"unignoreTooltip": "Update-Benachrichtigungen für diese Version fortsetzen",
|
||||
"viewVersionOnCivitai": "Version auf Civitai anzeigen",
|
||||
"earlyAccessTooltip": "Erfordert Early-Access-Kauf",
|
||||
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
|
||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Rezept",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "Rezeptname erfolgreich aktualisiert",
|
||||
"tagsUpdated": "Rezept-Tags erfolgreich aktualisiert",
|
||||
"sourceUrlUpdated": "Quell-URL erfolgreich aktualisiert",
|
||||
"promptUpdated": "Prompt erfolgreich aktualisiert",
|
||||
"negativePromptUpdated": "Negativer Prompt erfolgreich aktualisiert",
|
||||
"promptEditorHint": "Drücken Sie Enter zum Speichern, Shift+Enter für neue Zeile",
|
||||
"noRecipeId": "Keine Rezept-ID verfügbar",
|
||||
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
|
||||
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
|
||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "Fehler beim Speichern der Basis-Modell-Zuordnungen: {message}",
|
||||
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
||||
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
||||
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
||||
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
||||
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
||||
"compactModeToggled": "Kompakt-Modus {state}",
|
||||
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "Fehler beim Löschen von {type}: {message}",
|
||||
"excludeSuccess": "{type} erfolgreich ausgeschlossen",
|
||||
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
|
||||
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
|
||||
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
|
||||
"fileNameUpdated": "Dateiname erfolgreich aktualisiert",
|
||||
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
||||
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "Systemdiagnose",
|
||||
"title": "Doktor",
|
||||
"buttonTitle": "Diagnose und häufige Fehlerbehebungen ausführen",
|
||||
"loading": "Umgebung wird geprüft...",
|
||||
"footer": "Exportiere ein Diagnosepaket, falls das Problem nach der Reparatur weiterhin besteht.",
|
||||
"summary": {
|
||||
"idle": "Führe eine Überprüfung von Einstellungen, Cache-Integrität und UI-Konsistenz durch.",
|
||||
"ok": "Keine aktiven Probleme wurden in der aktuellen Umgebung gefunden.",
|
||||
"warning": "{count} Problem(e) wurden gefunden. Die meisten lassen sich direkt über dieses Panel beheben.",
|
||||
"error": "Bevor die App vollständig fehlerfrei ist, müssen {count} Problem(e) behoben werden."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Gesund",
|
||||
"warning": "Handlungsbedarf",
|
||||
"error": "Aktion erforderlich"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Erneut ausführen",
|
||||
"exportBundle": "Paket exportieren"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Diagnose konnte nicht geladen werden: {message}",
|
||||
"repairSuccess": "Cache-Neuaufbau abgeschlossen.",
|
||||
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
|
||||
"exportSuccess": "Diagnosepaket exportiert.",
|
||||
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Anwendungs-Update erkannt",
|
||||
|
||||
143
locales/en.json
143
locales/en.json
@@ -175,6 +175,9 @@
|
||||
"success": "Successfully repaired {count} recipes.",
|
||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||
"error": "Recipe repair failed: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Manage Excluded Models"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai host",
|
||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW)",
|
||||
"red": "civitai.red (unrestricted)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai host preference available",
|
||||
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
||||
"openSettings": "Open Settings"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Open settings folder",
|
||||
"tooltip": "Open folder containing settings.json",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "Video Settings",
|
||||
"layoutSettings": "Layout Settings",
|
||||
"misc": "Miscellaneous",
|
||||
"backup": "Backups",
|
||||
"folderSettings": "Default Roots",
|
||||
"recipeSettings": "Recipes",
|
||||
"extraFolderPaths": "Extra Folder Paths",
|
||||
"downloadPathTemplates": "Download Path Templates",
|
||||
"priorityTags": "Priority Tags",
|
||||
@@ -323,9 +341,35 @@
|
||||
"saveFailed": "Unable to save skip paths: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Automatic backups",
|
||||
"autoEnabledHelp": "Create a local snapshot once per day and keep the latest snapshots according to the retention policy.",
|
||||
"retention": "Retention count",
|
||||
"retentionHelp": "How many automatic snapshots to keep before older ones are pruned.",
|
||||
"management": "Backup management",
|
||||
"managementHelp": "Export your current user state or restore it from a backup archive.",
|
||||
"scopeHelp": "Backs up your settings, download history, and model update state. It does not include model files or rebuildable caches.",
|
||||
"locationSummary": "Current backup location",
|
||||
"openFolderButton": "Open backup folder",
|
||||
"openFolderSuccess": "Opened backup folder",
|
||||
"openFolderFailed": "Failed to open backup folder",
|
||||
"locationCopied": "Backup path copied to clipboard: {{path}}",
|
||||
"locationClipboardFallback": "Backup path: {{path}}",
|
||||
"exportButton": "Export backup",
|
||||
"exportSuccess": "Backup exported successfully.",
|
||||
"exportFailed": "Failed to export backup: {message}",
|
||||
"importButton": "Import backup",
|
||||
"importConfirm": "Import this backup and overwrite local user state?",
|
||||
"importSuccess": "Backup imported successfully.",
|
||||
"importFailed": "Failed to import backup: {message}",
|
||||
"latestSnapshot": "Latest snapshot",
|
||||
"latestAutoSnapshot": "Latest automatic snapshot",
|
||||
"snapshotCount": "Saved snapshots",
|
||||
"noneAvailable": "No snapshots yet"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Skip downloads for base models",
|
||||
"help": "When a model version uses one of these base models, LoRA Manager will skip the download before any file transfer starts. Applies to all download flows. Only supported base models can be selected here.",
|
||||
"help": "When enabled, versions using the selected base models will be skipped.",
|
||||
"searchPlaceholder": "Filter base models...",
|
||||
"empty": "No base models match the current search.",
|
||||
"summary": {
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "Unable to save excluded base models: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "Skip previously downloaded model versions",
|
||||
"help": "When enabled, versions downloaded before will be skipped."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||
"defaultEmbeddingRoot": "Embedding Root",
|
||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
||||
"recipesPath": "Recipes Storage Path",
|
||||
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "Migrating recipes storage...",
|
||||
"noDefault": "No Default"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Extra Folder Paths",
|
||||
"help": "Add additional model folders outside of ComfyUI's standard paths. These paths are stored separately and scanned alongside the default folders.",
|
||||
"description": "Configure additional folders to scan for models. These paths are specific to LoRA Manager and will be merged with ComfyUI's default paths.",
|
||||
"description": "Additional model root paths exclusive to LoRA Manager. Load models from locations outside ComfyUI's standard folders—ideal for large libraries that would otherwise slow down ComfyUI.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA Paths",
|
||||
"checkpoint": "Checkpoint Paths",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Embedding Paths"
|
||||
},
|
||||
"pathPlaceholder": "/path/to/extra/models",
|
||||
"saveSuccess": "Extra folder paths updated.",
|
||||
"saveSuccess": "Extra folder paths updated. Restart required to apply changes.",
|
||||
"saveError": "Failed to update extra folder paths: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "This path is already configured"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "Move to Folder",
|
||||
"repairMetadata": "Repair metadata",
|
||||
"excludeModel": "Exclude Model",
|
||||
"restoreModel": "Restore Model",
|
||||
"deleteModel": "Delete Model",
|
||||
"shareRecipe": "Share Recipe",
|
||||
"viewAllLoras": "View All LoRAs",
|
||||
@@ -649,9 +702,9 @@
|
||||
"title": "Import a recipe from image or URL",
|
||||
"urlLocalPath": "URL / Local Path",
|
||||
"uploadImage": "Upload Image",
|
||||
"urlSectionDescription": "Input a Civitai image URL or local file path to import as a recipe.",
|
||||
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
|
||||
"imageUrlOrPath": "Image URL or File Path:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... or C:/path/to/image.png",
|
||||
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
|
||||
"fetchImage": "Fetch Image",
|
||||
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
|
||||
"selectImage": "Select Image",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Move to {otherType} Folder"
|
||||
"moveToOtherTypeFolder": "Move to {otherType} Folder",
|
||||
"sendToWorkflow": "Send to Workflow"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "Unpin Sidebar",
|
||||
"switchToListView": "Switch to List View",
|
||||
"switchToTreeView": "Switch to Tree View",
|
||||
"recursiveOn": "Search subfolders",
|
||||
"recursiveOff": "Search current folder only",
|
||||
"recursiveOn": "Include subfolders",
|
||||
"recursiveOff": "Current folder only",
|
||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "Early Access",
|
||||
"earlyAccessTooltip": "Early access required",
|
||||
"inLibrary": "In Library",
|
||||
"downloaded": "Downloaded",
|
||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||
"alreadyInLibrary": "Already in Library",
|
||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||
"errors": {
|
||||
@@ -1051,9 +1107,9 @@
|
||||
},
|
||||
"proceedText": "Only proceed if you're sure this is what you want.",
|
||||
"urlLabel": "Civitai Model URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||
"helpText": {
|
||||
"title": "Paste any Civitai model URL. Supported formats:",
|
||||
"title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "View on Civitai",
|
||||
"viewOnCivitaiText": "View on Civitai",
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
"openFileLocation": "Open File Location"
|
||||
"openFileLocation": "Open File Location",
|
||||
"sendToWorkflow": "Send to ComfyUI",
|
||||
"sendToWorkflowText": "Send to ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "File location opened successfully",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "Path copied to clipboard: {{path}}",
|
||||
"clipboardFallback": "Path: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Unable to send to ComfyUI: No file path available"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Version",
|
||||
"fileName": "File Name",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "in {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Current Version",
|
||||
"current": "Opened Version",
|
||||
"currentTooltip": "This is the version you opened this modal from",
|
||||
"inLibrary": "In Library",
|
||||
"inLibraryTooltip": "This version exists in your local library",
|
||||
"downloaded": "Downloaded",
|
||||
"downloadedTooltip": "This version was downloaded before, but is not currently in your library",
|
||||
"newer": "Newer Version",
|
||||
"newerTooltip": "This version is newer than your latest local version",
|
||||
"earlyAccess": "Early Access",
|
||||
"ignored": "Ignored"
|
||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||
"ignored": "Ignored",
|
||||
"ignoredTooltip": "Update notifications are disabled for this version"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download this version",
|
||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||
"delete": "Delete",
|
||||
"deleteTooltip": "Delete this local version",
|
||||
"ignore": "Ignore",
|
||||
"unignore": "Unignore",
|
||||
"ignoreTooltip": "Ignore update notifications for this version",
|
||||
"unignoreTooltip": "Resume update notifications for this version",
|
||||
"viewVersionOnCivitai": "View version on Civitai",
|
||||
"earlyAccessTooltip": "Requires early access purchase",
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "Recipe replaced in workflow",
|
||||
"recipeFailedToSend": "Failed to send recipe to workflow",
|
||||
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
||||
"noTargetNodeSelected": "No target node selected"
|
||||
"noTargetNodeSelected": "No target node selected",
|
||||
"modelUpdated": "Model updated in workflow",
|
||||
"modelFailed": "Failed to update model node"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "Recipe name updated successfully",
|
||||
"tagsUpdated": "Recipe tags updated successfully",
|
||||
"sourceUrlUpdated": "Source URL updated successfully",
|
||||
"promptUpdated": "Prompt updated successfully",
|
||||
"negativePromptUpdated": "Negative prompt updated successfully",
|
||||
"promptEditorHint": "Press Enter to save, Shift+Enter for new line",
|
||||
"noRecipeId": "No recipe ID available",
|
||||
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
|
||||
"copyFailed": "Error copying recipe syntax: {message}",
|
||||
"noMissingLoras": "No missing LoRAs to download",
|
||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||
"downloadTemplatesUpdated": "Download path templates updated",
|
||||
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
||||
"recipesPathUpdated": "Recipes storage path updated",
|
||||
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||
"settingsUpdated": "Settings updated: {setting}",
|
||||
"compactModeToggled": "Compact Mode {state}",
|
||||
"settingSaveFailed": "Failed to save setting: {message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "Failed to delete {type}: {message}",
|
||||
"excludeSuccess": "{type} excluded successfully",
|
||||
"excludeFailed": "Failed to exclude {type}: {message}",
|
||||
"restoreSuccess": "{type} restored successfully",
|
||||
"restoreFailed": "Failed to restore {type}: {message}",
|
||||
"fileNameUpdated": "File name updated successfully",
|
||||
"fileRenameFailed": "Failed to rename file: {error}",
|
||||
"previewUpdated": "Preview updated successfully",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "System diagnostics",
|
||||
"title": "Doctor",
|
||||
"buttonTitle": "Run diagnostics and common fixes",
|
||||
"loading": "Checking environment...",
|
||||
"footer": "Export a diagnostics bundle if the issue still persists after repair.",
|
||||
"summary": {
|
||||
"idle": "Run a health check for settings, cache integrity, and UI consistency.",
|
||||
"ok": "No active issues were found in the current environment.",
|
||||
"warning": "{count} issue(s) were found. Most can be fixed directly from this panel.",
|
||||
"error": "{count} issue(s) need attention before the app is fully healthy."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Healthy",
|
||||
"warning": "Needs Attention",
|
||||
"error": "Action Required"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Run Again",
|
||||
"exportBundle": "Export Bundle"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Failed to load diagnostics: {message}",
|
||||
"repairSuccess": "Cache rebuild completed.",
|
||||
"repairFailed": "Cache rebuild failed: {message}",
|
||||
"exportSuccess": "Diagnostics bundle exported.",
|
||||
"exportFailed": "Failed to export diagnostics bundle: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Application Update Detected",
|
||||
|
||||
133
locales/es.json
133
locales/es.json
@@ -175,6 +175,9 @@
|
||||
"success": "Se repararon con éxito {count} recetas.",
|
||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||
"error": "Error al reparar recetas: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "Host de Civitai",
|
||||
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
|
||||
"options": {
|
||||
"com": "civitai.com (solo SFW)",
|
||||
"red": "civitai.red (sin restricciones)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Preferencia de host de Civitai disponible",
|
||||
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
|
||||
"openSettings": "Abrir ajustes"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Abrir carpeta de ajustes",
|
||||
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "Configuración de video",
|
||||
"layoutSettings": "Configuración de diseño",
|
||||
"misc": "Varios",
|
||||
"backup": "Copias de seguridad",
|
||||
"folderSettings": "Raíces predeterminadas",
|
||||
"recipeSettings": "Recetas",
|
||||
"extraFolderPaths": "Rutas de carpetas adicionales",
|
||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||
"priorityTags": "Etiquetas prioritarias",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Copias de seguridad automáticas",
|
||||
"autoEnabledHelp": "Crea una instantánea local una vez al día y conserva las más recientes según la política de retención.",
|
||||
"retention": "Cantidad de retención",
|
||||
"retentionHelp": "Cuántas instantáneas automáticas conservar antes de eliminar las antiguas.",
|
||||
"management": "Gestión de copias",
|
||||
"managementHelp": "Exporta tu estado de usuario actual o restáuralo desde un archivo de copia de seguridad.",
|
||||
"scopeHelp": "Incluye tu configuración, el historial de descargas y el estado de actualización de los modelos. No incluye los archivos de modelo ni las cachés que se pueden regenerar.",
|
||||
"locationSummary": "Ubicación actual de la copia",
|
||||
"openFolderButton": "Abrir carpeta de copias",
|
||||
"openFolderSuccess": "Carpeta de copias abierta",
|
||||
"openFolderFailed": "No se pudo abrir la carpeta de copias",
|
||||
"locationCopied": "Ruta de la copia copiada al portapapeles: {{path}}",
|
||||
"locationClipboardFallback": "Ruta de la copia: {{path}}",
|
||||
"exportButton": "Exportar copia",
|
||||
"exportSuccess": "Copia exportada correctamente.",
|
||||
"exportFailed": "No se pudo exportar la copia: {message}",
|
||||
"importButton": "Importar copia",
|
||||
"importConfirm": "¿Importar esta copia y sobrescribir el estado local del usuario?",
|
||||
"importSuccess": "Copia importada correctamente.",
|
||||
"importFailed": "No se pudo importar la copia: {message}",
|
||||
"latestSnapshot": "Última instantánea",
|
||||
"latestAutoSnapshot": "Última instantánea automática",
|
||||
"snapshotCount": "Instantáneas guardadas",
|
||||
"noneAvailable": "Aún no hay instantáneas"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Omitir descargas para modelos base",
|
||||
"help": "Se aplica a todos los flujos de descarga. Aquí solo se pueden seleccionar modelos base compatibles.",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "No se pudieron guardar los modelos base excluidos: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "Omitir versiones de modelos previamente descargadas",
|
||||
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Densidad de visualización",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||
"defaultEmbeddingRoot": "Raíz de embedding",
|
||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||
"recipesPath": "Ruta de almacenamiento de recetas",
|
||||
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "Migrando el almacenamiento de recetas...",
|
||||
"noDefault": "Sin predeterminado"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Rutas de carpetas adicionales",
|
||||
"help": "Agregue carpetas de modelos adicionales fuera de las rutas estándar de ComfyUI. Estas rutas se almacenan por separado y se escanean junto con las carpetas predeterminadas.",
|
||||
"description": "Configure carpetas adicionales para escanear modelos. Estas rutas son específicas de LoRA Manager y se fusionarán con las rutas predeterminadas de ComfyUI.",
|
||||
"description": "Rutas raíz de modelos adicionales exclusivas para LoRA Manager. Cargue modelos desde ubicaciones fuera de las carpetas estándar de ComfyUI, ideal para bibliotecas grandes que de otro modo ralentizarían ComfyUI.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "Rutas de LoRA",
|
||||
"checkpoint": "Rutas de Checkpoint",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Rutas de Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/ruta/a/modelos/extra",
|
||||
"saveSuccess": "Rutas de carpetas adicionales actualizadas.",
|
||||
"saveSuccess": "Rutas de carpetas adicionales actualizadas. Se requiere reinicio para aplicar los cambios.",
|
||||
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Esta ruta ya está configurada"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"excludeModel": "Excluir modelo",
|
||||
"restoreModel": "Restaurar modelo",
|
||||
"deleteModel": "Eliminar modelo",
|
||||
"shareRecipe": "Compartir receta",
|
||||
"viewAllLoras": "Ver todos los LoRAs",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}"
|
||||
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}",
|
||||
"sendToWorkflow": "Enviar al flujo de trabajo"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "Desfijar barra lateral",
|
||||
"switchToListView": "Cambiar a vista de lista",
|
||||
"switchToTreeView": "Cambiar a vista de árbol",
|
||||
"recursiveOn": "Buscar en subcarpetas",
|
||||
"recursiveOff": "Buscar solo en la carpeta actual",
|
||||
"recursiveOn": "Incluir subcarpetas",
|
||||
"recursiveOff": "Solo carpeta actual",
|
||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"earlyAccessTooltip": "Acceso temprano requerido",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"downloaded": "Descargado",
|
||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||
"alreadyInLibrary": "Ya en la biblioteca",
|
||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||
"errors": {
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "Ver en Civitai",
|
||||
"viewOnCivitaiText": "Ver en Civitai",
|
||||
"viewCreatorProfile": "Ver perfil del creador",
|
||||
"openFileLocation": "Abrir ubicación del archivo"
|
||||
"openFileLocation": "Abrir ubicación del archivo",
|
||||
"sendToWorkflow": "Enviar a ComfyUI",
|
||||
"sendToWorkflowText": "Enviar a ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Ubicación del archivo abierta exitosamente",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||
"clipboardFallback": "Ruta: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Versión",
|
||||
"fileName": "Nombre de archivo",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "en {count}d"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Versión actual",
|
||||
"current": "Versión abierta",
|
||||
"currentTooltip": "Es la versión con la que abriste este modal",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"inLibraryTooltip": "Esta versión existe en tu biblioteca local",
|
||||
"downloaded": "Descargado",
|
||||
"downloadedTooltip": "Esta versión se descargó antes, pero ahora no está en tu biblioteca",
|
||||
"newer": "Versión más reciente",
|
||||
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"ignored": "Ignorada"
|
||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||
"ignored": "Ignorada",
|
||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Descargar",
|
||||
"downloadTooltip": "Descargar esta versión",
|
||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||
"delete": "Eliminar",
|
||||
"deleteTooltip": "Eliminar esta versión local",
|
||||
"ignore": "Ignorar",
|
||||
"unignore": "Dejar de ignorar",
|
||||
"ignoreTooltip": "Ignorar las notificaciones de actualización de esta versión",
|
||||
"unignoreTooltip": "Reanudar las notificaciones de actualización de esta versión",
|
||||
"viewVersionOnCivitai": "Ver versión en Civitai",
|
||||
"earlyAccessTooltip": "Requiere compra de acceso temprano",
|
||||
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
|
||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||
"modelFailed": "Error al actualizar nodo de modelo"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Receta",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "Nombre de receta actualizado exitosamente",
|
||||
"tagsUpdated": "Etiquetas de receta actualizadas exitosamente",
|
||||
"sourceUrlUpdated": "URL de origen actualizada exitosamente",
|
||||
"promptUpdated": "Prompt actualizado exitosamente",
|
||||
"negativePromptUpdated": "Prompt negativo actualizado exitosamente",
|
||||
"promptEditorHint": "Presiona Enter para guardar, Shift+Enter para nueva línea",
|
||||
"noRecipeId": "No hay ID de receta disponible",
|
||||
"sendToWorkflowFailed": "Error al enviar la receta al flujo de trabajo: {message}",
|
||||
"copyFailed": "Error copiando sintaxis de receta: {message}",
|
||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
||||
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
||||
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
||||
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
||||
"settingsUpdated": "Configuración actualizada: {setting}",
|
||||
"compactModeToggled": "Modo compacto {state}",
|
||||
"settingSaveFailed": "Error al guardar configuración: {message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "Error al eliminar {type}: {message}",
|
||||
"excludeSuccess": "{type} excluido exitosamente",
|
||||
"excludeFailed": "Error al excluir {type}: {message}",
|
||||
"restoreSuccess": "{type} restaurado correctamente",
|
||||
"restoreFailed": "No se pudo restaurar {type}: {message}",
|
||||
"fileNameUpdated": "Nombre de archivo actualizado exitosamente",
|
||||
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
||||
"previewUpdated": "Vista previa actualizada exitosamente",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "Diagnósticos del sistema",
|
||||
"title": "Doctor",
|
||||
"buttonTitle": "Ejecutar diagnósticos y correcciones comunes",
|
||||
"loading": "Comprobando el entorno...",
|
||||
"footer": "Exporta un paquete de diagnóstico si el problema persiste después de la reparación.",
|
||||
"summary": {
|
||||
"idle": "Ejecuta una comprobación del estado de la configuración, la integridad de la caché y la coherencia de la interfaz.",
|
||||
"ok": "No se encontraron problemas activos en el entorno actual.",
|
||||
"warning": "Se encontraron {count} problema(s). La mayoría se puede solucionar directamente desde este panel.",
|
||||
"error": "Se encontraron {count} problema(s). Deben atenderse antes de que la aplicación esté completamente saludable."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Saludable",
|
||||
"warning": "Requiere atención",
|
||||
"error": "Se requiere acción"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Ejecutar de nuevo",
|
||||
"exportBundle": "Exportar paquete"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Error al cargar los diagnósticos: {message}",
|
||||
"repairSuccess": "Reconstrucción de caché completada.",
|
||||
"repairFailed": "Error al reconstruir la caché: {message}",
|
||||
"exportSuccess": "Paquete de diagnósticos exportado.",
|
||||
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Actualización de la aplicación detectada",
|
||||
|
||||
133
locales/fr.json
133
locales/fr.json
@@ -175,6 +175,9 @@
|
||||
"success": "{count} recettes réparées avec succès.",
|
||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||
"error": "Échec de la réparation des recettes : {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Clé API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "Hôte Civitai",
|
||||
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW uniquement)",
|
||||
"red": "civitai.red (sans restriction)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Préférence d’hôte Civitai disponible",
|
||||
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Ouvrir le dossier des paramètres",
|
||||
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "Paramètres vidéo",
|
||||
"layoutSettings": "Paramètres d'affichage",
|
||||
"misc": "Divers",
|
||||
"backup": "Sauvegardes",
|
||||
"folderSettings": "Racines par défaut",
|
||||
"recipeSettings": "Recipes",
|
||||
"extraFolderPaths": "Chemins de dossiers supplémentaires",
|
||||
"downloadPathTemplates": "Modèles de chemin de téléchargement",
|
||||
"priorityTags": "Étiquettes prioritaires",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Sauvegardes automatiques",
|
||||
"autoEnabledHelp": "Crée un instantané local une fois par jour et conserve les plus récents selon la politique de rétention.",
|
||||
"retention": "Nombre de rétention",
|
||||
"retentionHelp": "Combien d'instantanés automatiques conserver avant de supprimer les plus anciens.",
|
||||
"management": "Gestion des sauvegardes",
|
||||
"managementHelp": "Exporte l'état actuel de l'utilisateur ou restaure-le depuis une archive de sauvegarde.",
|
||||
"scopeHelp": "Inclut vos paramètres, l'historique des téléchargements et l'état des mises à jour des modèles. Les fichiers de modèle et les caches régénérables ne sont pas inclus.",
|
||||
"locationSummary": "Emplacement actuel des sauvegardes",
|
||||
"openFolderButton": "Ouvrir le dossier de sauvegarde",
|
||||
"openFolderSuccess": "Dossier de sauvegarde ouvert",
|
||||
"openFolderFailed": "Impossible d'ouvrir le dossier de sauvegarde",
|
||||
"locationCopied": "Chemin de sauvegarde copié dans le presse-papiers : {{path}}",
|
||||
"locationClipboardFallback": "Chemin de sauvegarde : {{path}}",
|
||||
"exportButton": "Exporter la sauvegarde",
|
||||
"exportSuccess": "Sauvegarde exportée avec succès.",
|
||||
"exportFailed": "Échec de l'export de la sauvegarde : {message}",
|
||||
"importButton": "Importer la sauvegarde",
|
||||
"importConfirm": "Importer cette sauvegarde et écraser l'état local de l'utilisateur ?",
|
||||
"importSuccess": "Sauvegarde importée avec succès.",
|
||||
"importFailed": "Échec de l'import de la sauvegarde : {message}",
|
||||
"latestSnapshot": "Dernier instantané",
|
||||
"latestAutoSnapshot": "Dernier instantané automatique",
|
||||
"snapshotCount": "Instantanés enregistrés",
|
||||
"noneAvailable": "Aucun instantané pour le moment"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Ignorer les téléchargements pour certains modèles de base",
|
||||
"help": "S’applique à tous les flux de téléchargement. Seuls les modèles de base pris en charge peuvent être sélectionnés ici.",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "Impossible d’enregistrer les modèles de base exclus : {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "Ignorer les versions de modèles précédemment téléchargées",
|
||||
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Densité d'affichage",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultEmbeddingRoot": "Racine Embedding",
|
||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||
"recipesPath": "Recipes Storage Path",
|
||||
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "Migrating recipes storage...",
|
||||
"noDefault": "Aucun par défaut"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Chemins de dossiers supplémentaires",
|
||||
"help": "Ajoutez des dossiers de modèles supplémentaires en dehors des chemins standard de ComfyUI. Ces chemins sont stockés séparément et analysés aux côtés des dossiers par défaut.",
|
||||
"description": "Configurez des dossiers supplémentaires pour l'analyse de modèles. Ces chemins sont spécifiques à LoRA Manager et seront fusionnés avec les chemins par défaut de ComfyUI.",
|
||||
"description": "Chemins racine de modèles supplémentaires exclusifs à LoRA Manager. Chargez des modèles depuis des emplacements en dehors des dossiers standard de ComfyUI, idéal pour les grandes bibliothèques qui ralentiraient autrement ComfyUI.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "Chemins LoRA",
|
||||
"checkpoint": "Chemins Checkpoint",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Chemins Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/chemin/vers/modèles/supplémentaires",
|
||||
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour.",
|
||||
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour. Redémarrage requis pour appliquer les changements.",
|
||||
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Ce chemin est déjà configuré"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
"restoreModel": "Restaurer le modèle",
|
||||
"deleteModel": "Supprimer le modèle",
|
||||
"shareRecipe": "Partager la recipe",
|
||||
"viewAllLoras": "Voir tous les LoRAs",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}"
|
||||
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}",
|
||||
"sendToWorkflow": "Envoyer vers le workflow"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "Désépingler la barre latérale",
|
||||
"switchToListView": "Passer en vue liste",
|
||||
"switchToTreeView": "Passer en vue arborescence",
|
||||
"recursiveOn": "Rechercher dans les sous-dossiers",
|
||||
"recursiveOff": "Rechercher uniquement dans le dossier actuel",
|
||||
"recursiveOn": "Inclure les sous-dossiers",
|
||||
"recursiveOff": "Dossier actuel uniquement",
|
||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"earlyAccessTooltip": "Accès anticipé requis",
|
||||
"inLibrary": "Dans la bibliothèque",
|
||||
"downloaded": "Téléchargé",
|
||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||
"errors": {
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "Voir sur Civitai",
|
||||
"viewOnCivitaiText": "Voir sur Civitai",
|
||||
"viewCreatorProfile": "Voir le profil du créateur",
|
||||
"openFileLocation": "Ouvrir l'emplacement du fichier"
|
||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendToWorkflowText": "Envoyer vers ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Emplacement du fichier ouvert avec succès",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||
"clipboardFallback": "Chemin: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Version",
|
||||
"fileName": "Nom de fichier",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "dans {count}j"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Version actuelle",
|
||||
"current": "Version ouverte",
|
||||
"currentTooltip": "C'est la version à partir de laquelle cette fenêtre a été ouverte",
|
||||
"inLibrary": "Dans la bibliothèque",
|
||||
"inLibraryTooltip": "Cette version existe dans votre bibliothèque locale",
|
||||
"downloaded": "Téléchargé",
|
||||
"downloadedTooltip": "Cette version a déjà été téléchargée, mais n'est pas actuellement dans votre bibliothèque",
|
||||
"newer": "Version plus récente",
|
||||
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"ignored": "Ignorée"
|
||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||
"ignored": "Ignorée",
|
||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Télécharger",
|
||||
"downloadTooltip": "Télécharger cette version",
|
||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||
"delete": "Supprimer",
|
||||
"deleteTooltip": "Supprimer cette version locale",
|
||||
"ignore": "Ignorer",
|
||||
"unignore": "Ne plus ignorer",
|
||||
"ignoreTooltip": "Ignorer les notifications de mise à jour pour cette version",
|
||||
"unignoreTooltip": "Reprendre les notifications de mise à jour pour cette version",
|
||||
"viewVersionOnCivitai": "Voir la version sur Civitai",
|
||||
"earlyAccessTooltip": "Nécessite l'achat de l'accès anticipé",
|
||||
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
|
||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
|
||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||
"modelFailed": "Échec de la mise à jour du nœud modèle"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "Nom de la recipe mis à jour avec succès",
|
||||
"tagsUpdated": "Tags de la recipe mis à jour avec succès",
|
||||
"sourceUrlUpdated": "URL source mise à jour avec succès",
|
||||
"promptUpdated": "Prompt mis à jour avec succès",
|
||||
"negativePromptUpdated": "Prompt négatif mis à jour avec succès",
|
||||
"promptEditorHint": "Appuyez sur Entrée pour sauvegarder, Maj+Entrée pour nouvelle ligne",
|
||||
"noRecipeId": "Aucun ID de recipe disponible",
|
||||
"sendToWorkflowFailed": "Échec de l'envoi de la recette vers le workflow : {message}",
|
||||
"copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
|
||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
||||
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
||||
"recipesPathUpdated": "Recipes storage path updated",
|
||||
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
||||
"compactModeToggled": "Mode compact {state}",
|
||||
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "Échec de la suppression de {type} : {message}",
|
||||
"excludeSuccess": "{type} exclu avec succès",
|
||||
"excludeFailed": "Échec de l'exclusion de {type} : {message}",
|
||||
"restoreSuccess": "{type} restauré avec succès",
|
||||
"restoreFailed": "Échec de la restauration de {type} : {message}",
|
||||
"fileNameUpdated": "Nom de fichier mis à jour avec succès",
|
||||
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
||||
"previewUpdated": "Aperçu mis à jour avec succès",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "Diagnostics système",
|
||||
"title": "Docteur",
|
||||
"buttonTitle": "Lancer les diagnostics et les corrections courantes",
|
||||
"loading": "Vérification de l'environnement...",
|
||||
"footer": "Exportez un lot de diagnostic si le problème persiste après la réparation.",
|
||||
"summary": {
|
||||
"idle": "Lancez une vérification de l'état des paramètres, de l'intégrité du cache et de la cohérence de l'interface.",
|
||||
"ok": "Aucun problème actif n'a été trouvé dans l'environnement actuel.",
|
||||
"warning": "{count} problème(s) ont été trouvés. La plupart peuvent être corrigés directement depuis ce panneau.",
|
||||
"error": "{count} problème(s) nécessitent une attention avant que l'application soit entièrement saine."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Sain",
|
||||
"warning": "Nécessite une attention",
|
||||
"error": "Action requise"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Relancer",
|
||||
"exportBundle": "Exporter le lot"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Échec du chargement des diagnostics : {message}",
|
||||
"repairSuccess": "Reconstruction du cache terminée.",
|
||||
"repairFailed": "Échec de la reconstruction du cache : {message}",
|
||||
"exportSuccess": "Lot de diagnostics exporté.",
|
||||
"exportFailed": "Échec de l'export du lot de diagnostics : {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Mise à jour de l'application détectée",
|
||||
|
||||
133
locales/he.json
133
locales/he.json
@@ -175,6 +175,9 @@
|
||||
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||
"error": "תיקון המתכונים נכשל: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "מפתח API של Civitai",
|
||||
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
|
||||
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "מארח Civitai",
|
||||
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW בלבד)",
|
||||
"red": "civitai.red (ללא הגבלות)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "העדפת מארח Civitai זמינה",
|
||||
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
|
||||
"openSettings": "פתח הגדרות"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "פתח תיקיית הגדרות",
|
||||
"tooltip": "פתח את התיקייה שמכילה את settings.json",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "הגדרות וידאו",
|
||||
"layoutSettings": "הגדרות פריסה",
|
||||
"misc": "שונות",
|
||||
"backup": "גיבויים",
|
||||
"folderSettings": "תיקיות ברירת מחדל",
|
||||
"recipeSettings": "מתכונים",
|
||||
"extraFolderPaths": "נתיבי תיקיות נוספים",
|
||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||
"priorityTags": "תגיות עדיפות",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "גיבויים אוטומטיים",
|
||||
"autoEnabledHelp": "יוצר צילום מצב מקומי פעם ביום ושומר את הצילומים האחרונים לפי מדיניות השמירה.",
|
||||
"retention": "כמות שמירה",
|
||||
"retentionHelp": "כמה צילומי מצב אוטומטיים לשמור לפני שמסירים ישנים.",
|
||||
"management": "ניהול גיבויים",
|
||||
"managementHelp": "ייצא את מצב המשתמש הנוכחי או שחזר אותו מארכיון גיבוי.",
|
||||
"scopeHelp": "כולל את ההגדרות שלך, היסטוריית ההורדות ומצב עדכוני המודלים. אינו כולל קובצי מודל או מטמונים שניתן לשחזר.",
|
||||
"locationSummary": "מיקום הגיבוי הנוכחי",
|
||||
"openFolderButton": "פתח את תיקיית הגיבויים",
|
||||
"openFolderSuccess": "תיקיית הגיבויים נפתחה",
|
||||
"openFolderFailed": "לא ניתן היה לפתוח את תיקיית הגיבויים",
|
||||
"locationCopied": "נתיב הגיבוי הועתק ללוח: {{path}}",
|
||||
"locationClipboardFallback": "נתיב הגיבוי: {{path}}",
|
||||
"exportButton": "ייצא גיבוי",
|
||||
"exportSuccess": "הגיבוי יוצא בהצלחה.",
|
||||
"exportFailed": "נכשל ייצוא הגיבוי: {message}",
|
||||
"importButton": "ייבא גיבוי",
|
||||
"importConfirm": "לייבא את הגיבוי הזה ולדרוס את מצב המשתמש המקומי?",
|
||||
"importSuccess": "הגיבוי יובא בהצלחה.",
|
||||
"importFailed": "נכשל ייבוא הגיבוי: {message}",
|
||||
"latestSnapshot": "צילום המצב האחרון",
|
||||
"latestAutoSnapshot": "צילום המצב האוטומטי האחרון",
|
||||
"snapshotCount": "צילומי מצב שמורים",
|
||||
"noneAvailable": "עדיין אין צילומי מצב"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "דלג על הורדות עבור מודלי בסיס",
|
||||
"help": "חל על כל תהליכי ההורדה. ניתן לבחור כאן רק מודלי בסיס נתמכים.",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "דלג על גרסאות מודלים שהורדו בעבר",
|
||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||
"recipesPath": "נתיב אחסון מתכונים",
|
||||
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "מעביר את אחסון המתכונים...",
|
||||
"noDefault": "אין ברירת מחדל"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "נתיבי תיקיות נוספים",
|
||||
"help": "הוסף תיקיות מודלים נוספות מחוץ לנתיבים הסטנדרטיים של ComfyUI. נתיבים אלה נשמרים בנפרד ונסרקים לצד תיקיות ברירת המחדל.",
|
||||
"description": "הגדר תיקיות נוספות לסריקת מודלים. נתיבים אלה ספציפיים ל-LoRA Manager וימוזגו עם נתיבי ברירת המחדל של ComfyUI.",
|
||||
"description": "נתיבי שורש מודלים נוספים בלעדיים ל-LoRA Manager. טען מודלים ממיקומים מחוץ לתיקיות הסטנדרטיות של ComfyUI - אידיאלי לספריות גדולות שאחרת יאטו את ComfyUI.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "נתיבי LoRA",
|
||||
"checkpoint": "נתיבי Checkpoint",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "נתיבי Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/נתיב/למודלים/נוספים",
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.",
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-דאטה",
|
||||
"excludeModel": "החרג מודל",
|
||||
"restoreModel": "שחזור מודל",
|
||||
"deleteModel": "מחק מודל",
|
||||
"shareRecipe": "שתף מתכון",
|
||||
"viewAllLoras": "הצג את כל ה-LoRAs",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}"
|
||||
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}",
|
||||
"sendToWorkflow": "שלח ל-workflow"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "שחרר סרגל צד",
|
||||
"switchToListView": "עבור לתצוגת רשימה",
|
||||
"switchToTreeView": "תצוגת עץ",
|
||||
"recursiveOn": "חיפוש בתיקיות משנה",
|
||||
"recursiveOff": "חיפוש רק בתיקייה הנוכחית",
|
||||
"recursiveOn": "כלול תיקיות משנה",
|
||||
"recursiveOff": "רק התיקייה הנוכחית",
|
||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"earlyAccessTooltip": "נדרשת גישה מוקדמת",
|
||||
"inLibrary": "בספרייה",
|
||||
"downloaded": "הורד",
|
||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||
"alreadyInLibrary": "כבר בספרייה",
|
||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||
"errors": {
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "הצג ב-Civitai",
|
||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||
"openFileLocation": "פתח מיקום קובץ"
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendToWorkflowText": "שלח ל-ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "מיקום הקובץ נפתח בהצלחה",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "גרסה",
|
||||
"fileName": "שם קובץ",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "בעוד {count} ימים"
|
||||
},
|
||||
"badges": {
|
||||
"current": "גרסה נוכחית",
|
||||
"current": "גרסה שנפתחה",
|
||||
"currentTooltip": "זוהי הגרסה שממנה נפתח החלון הזה",
|
||||
"inLibrary": "בספרייה",
|
||||
"inLibraryTooltip": "גרסה זו קיימת בספרייה המקומית שלך",
|
||||
"downloaded": "הורד",
|
||||
"downloadedTooltip": "גרסה זו הורדה בעבר, אך אינה נמצאת כרגע בספרייה שלך",
|
||||
"newer": "גרסה חדשה יותר",
|
||||
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"ignored": "התעלם"
|
||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||
"ignored": "התעלם",
|
||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו"
|
||||
},
|
||||
"actions": {
|
||||
"download": "הורדה",
|
||||
"downloadTooltip": "הורד את הגרסה הזו",
|
||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||
"delete": "מחיקה",
|
||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||
"ignore": "התעלם",
|
||||
"unignore": "בטל התעלמות",
|
||||
"ignoreTooltip": "התעלם מהתראות העדכון עבור גרסה זו",
|
||||
"unignoreTooltip": "חזור לקבל התראות עדכון עבור גרסה זו",
|
||||
"viewVersionOnCivitai": "הצג את הגרסה ב-Civitai",
|
||||
"earlyAccessTooltip": "נדרש רכישת גישה מוקדמת",
|
||||
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
|
||||
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד"
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
||||
"modelUpdated": "מודל עודכן ב-workflow",
|
||||
"modelFailed": "עדכון צומת המודל נכשל"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "מתכון",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "שם המתכון עודכן בהצלחה",
|
||||
"tagsUpdated": "תגיות המתכון עודכנו בהצלחה",
|
||||
"sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה",
|
||||
"promptUpdated": "הפרומפט עודכן בהצלחה",
|
||||
"negativePromptUpdated": "הפרומפט השלילי עודכן בהצלחה",
|
||||
"promptEditorHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
||||
"noRecipeId": "אין מזהה מתכון זמין",
|
||||
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
||||
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
||||
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
||||
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
||||
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
||||
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
||||
"compactModeToggled": "מצב קומפקטי {state}",
|
||||
"settingSaveFailed": "שמירת ההגדרה נכשלה: {message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "מחיקת {type} נכשלה: {message}",
|
||||
"excludeSuccess": "{type} הוחרג בהצלחה",
|
||||
"excludeFailed": "החרגת {type} נכשלה: {message}",
|
||||
"restoreSuccess": "{type} שוחזר בהצלחה",
|
||||
"restoreFailed": "שחזור {type} נכשל: {message}",
|
||||
"fileNameUpdated": "שם הקובץ עודכן בהצלחה",
|
||||
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
||||
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "אבחון מערכת",
|
||||
"title": "דוקטור",
|
||||
"buttonTitle": "הפעלת אבחון ותיקונים נפוצים",
|
||||
"loading": "בודק את הסביבה...",
|
||||
"footer": "ייצא חבילת אבחון אם הבעיה עדיין נמשכת לאחר התיקון.",
|
||||
"summary": {
|
||||
"idle": "הרץ בדיקת תקינות עבור הגדרות, שלמות המטמון ועקביות הממשק.",
|
||||
"ok": "לא נמצאו בעיות פעילות בסביבה הנוכחית.",
|
||||
"warning": "נמצאה/נמצאו {count} בעיה/בעיות. את רובן אפשר לתקן ישירות מלוח זה.",
|
||||
"error": "יש לטפל ב-{count} בעיה/בעיות לפני שהאפליקציה תהיה תקינה לחלוטין."
|
||||
},
|
||||
"status": {
|
||||
"ok": "תקין",
|
||||
"warning": "דורש תשומת לב",
|
||||
"error": "נדרשת פעולה"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "הפעל שוב",
|
||||
"exportBundle": "ייצוא חבילה"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "טעינת האבחון נכשלה: {message}",
|
||||
"repairSuccess": "בניית המטמון מחדש הושלמה.",
|
||||
"repairFailed": "בניית המטמון מחדש נכשלה: {message}",
|
||||
"exportSuccess": "חבילת האבחון יוצאה.",
|
||||
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "זוהה עדכון יישום",
|
||||
|
||||
133
locales/ja.json
133
locales/ja.json
@@ -175,6 +175,9 @@
|
||||
"success": "{count} 件のレシピを正常に修復しました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
"options": {
|
||||
"com": "civitai.com(SFW のみ)",
|
||||
"red": "civitai.red(制限なし)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai ホスト設定を利用できます",
|
||||
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||
"openSettings": "設定を開く"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "設定フォルダーを開く",
|
||||
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "動画設定",
|
||||
"layoutSettings": "レイアウト設定",
|
||||
"misc": "その他",
|
||||
"backup": "バックアップ",
|
||||
"folderSettings": "デフォルトルート",
|
||||
"recipeSettings": "レシピ",
|
||||
"extraFolderPaths": "追加フォルダーパス",
|
||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||
"priorityTags": "優先タグ",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "スキップパスの保存に失敗しました:{message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "自動バックアップ",
|
||||
"autoEnabledHelp": "1日1回ローカルのスナップショットを作成し、保持ポリシーに従って最新のものを残します。",
|
||||
"retention": "保持数",
|
||||
"retentionHelp": "古いものを削除する前に、何件の自動スナップショットを保持するかを指定します。",
|
||||
"management": "バックアップ管理",
|
||||
"managementHelp": "現在のユーザー状態をエクスポートするか、バックアップアーカイブから復元します。",
|
||||
"scopeHelp": "設定、ダウンロード履歴、モデル更新の状態をバックアップします。モデルファイルや再生成できるキャッシュは含まれません。",
|
||||
"locationSummary": "現在のバックアップ場所",
|
||||
"openFolderButton": "バックアップフォルダを開く",
|
||||
"openFolderSuccess": "バックアップフォルダを開きました",
|
||||
"openFolderFailed": "バックアップフォルダを開けませんでした",
|
||||
"locationCopied": "バックアップパスをクリップボードにコピーしました: {{path}}",
|
||||
"locationClipboardFallback": "バックアップパス: {{path}}",
|
||||
"exportButton": "バックアップをエクスポート",
|
||||
"exportSuccess": "バックアップを正常にエクスポートしました。",
|
||||
"exportFailed": "バックアップのエクスポートに失敗しました: {message}",
|
||||
"importButton": "バックアップをインポート",
|
||||
"importConfirm": "このバックアップをインポートして、ローカルのユーザー状態を上書きしますか?",
|
||||
"importSuccess": "バックアップを正常にインポートしました。",
|
||||
"importFailed": "バックアップのインポートに失敗しました: {message}",
|
||||
"latestSnapshot": "最新のスナップショット",
|
||||
"latestAutoSnapshot": "最新の自動スナップショット",
|
||||
"snapshotCount": "保存済みスナップショット",
|
||||
"noneAvailable": "まだスナップショットはありません"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "ベースモデルのダウンロードをスキップ",
|
||||
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "除外するベースモデルを保存できませんでした: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "以前にダウンロードしたモデルバージョンをスキップ",
|
||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||
"defaultEmbeddingRoot": "Embeddingルート",
|
||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||
"recipesPath": "レシピ保存先",
|
||||
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "レシピ保存先を移動中...",
|
||||
"noDefault": "デフォルトなし"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "追加フォルダーパス",
|
||||
"help": "ComfyUIの標準パスの外部に追加のモデルフォルダを追加します。これらのパスは別々に保存され、デフォルトのフォルダと一緒にスキャンされます。",
|
||||
"description": "モデルをスキャンするための追加フォルダを設定します。これらのパスはLoRA Manager固有であり、ComfyUIのデフォルトパスとマージされます。",
|
||||
"description": "LoRA Manager専用の追加モデルルートパス。ComfyUIの標準フォルダー外の場所からモデルを読み込みます。ComfyUIの動作を低下させる可能性のある大規模ライブラリに最適です。",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "LoRAパス",
|
||||
"checkpoint": "Checkpointパス",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Embeddingパス"
|
||||
},
|
||||
"pathPlaceholder": "/追加モデルへのパス",
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。",
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "このパスはすでに設定されています"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"excludeModel": "モデルを除外",
|
||||
"restoreModel": "モデルを復元",
|
||||
"deleteModel": "モデルを削除",
|
||||
"shareRecipe": "レシピを共有",
|
||||
"viewAllLoras": "すべてのLoRAを表示",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "{otherType} フォルダに移動"
|
||||
"moveToOtherTypeFolder": "{otherType} フォルダに移動",
|
||||
"sendToWorkflow": "ワークフローに送信"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "サイドバーの固定を解除",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"recursiveOn": "サブフォルダーを検索",
|
||||
"recursiveOff": "現在のフォルダーのみを検索",
|
||||
"recursiveOn": "サブフォルダーを含める",
|
||||
"recursiveOff": "現在のフォルダーのみ",
|
||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "アーリーアクセス",
|
||||
"earlyAccessTooltip": "アーリーアクセスが必要",
|
||||
"inLibrary": "ライブラリ内",
|
||||
"downloaded": "ダウンロード済み",
|
||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||
"alreadyInLibrary": "既にライブラリ内",
|
||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||
"errors": {
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"viewOnCivitaiText": "Civitaiで表示",
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
"openFileLocation": "ファイルの場所を開く"
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
"sendToWorkflowText": "ComfyUI に送信"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "ファイルの場所を正常に開きました",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "バージョン",
|
||||
"fileName": "ファイル名",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "{count}日後"
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在のバージョン",
|
||||
"current": "開いたバージョン",
|
||||
"currentTooltip": "このモーダルを開くために選択したバージョンです",
|
||||
"inLibrary": "ライブラリにあります",
|
||||
"inLibraryTooltip": "このバージョンはローカルライブラリに存在します",
|
||||
"downloaded": "ダウンロード済み",
|
||||
"downloadedTooltip": "このバージョンは以前ダウンロードされましたが、現在はライブラリにありません",
|
||||
"newer": "新しいバージョン",
|
||||
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
||||
"earlyAccess": "早期アクセス",
|
||||
"ignored": "無視中"
|
||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||
"ignored": "無視中",
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"downloadTooltip": "このバージョンをダウンロード",
|
||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||
"delete": "削除",
|
||||
"deleteTooltip": "このローカルバージョンを削除",
|
||||
"ignore": "無視",
|
||||
"unignore": "無視を解除",
|
||||
"ignoreTooltip": "このバージョンの更新通知を無視",
|
||||
"unignoreTooltip": "このバージョンの更新通知を再開",
|
||||
"viewVersionOnCivitai": "Civitai でバージョンを表示",
|
||||
"earlyAccessTooltip": "早期アクセス購入が必要",
|
||||
"resumeModelUpdates": "このモデルの更新を再開",
|
||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||
"modelUpdated": "モデルがワークフローで更新されました",
|
||||
"modelFailed": "モデルノードの更新に失敗しました"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "レシピ名が正常に更新されました",
|
||||
"tagsUpdated": "レシピタグが正常に更新されました",
|
||||
"sourceUrlUpdated": "ソースURLが正常に更新されました",
|
||||
"promptUpdated": "プロンプトが正常に更新されました",
|
||||
"negativePromptUpdated": "ネガティブプロンプトが正常に更新されました",
|
||||
"promptEditorHint": "Enterキーで保存、Shift+Enterで改行",
|
||||
"noRecipeId": "レシピIDが利用できません",
|
||||
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
||||
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
||||
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
||||
"recipesPathUpdated": "レシピ保存先を更新しました",
|
||||
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
||||
"settingsUpdated": "設定が更新されました:{setting}",
|
||||
"compactModeToggled": "コンパクトモード {state}",
|
||||
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "{type}の削除に失敗しました:{message}",
|
||||
"excludeSuccess": "{type}が正常に除外されました",
|
||||
"excludeFailed": "{type}の除外に失敗しました:{message}",
|
||||
"restoreSuccess": "{type}を復元しました",
|
||||
"restoreFailed": "{type}の復元に失敗しました: {message}",
|
||||
"fileNameUpdated": "ファイル名が正常に更新されました",
|
||||
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
||||
"previewUpdated": "プレビューが正常に更新されました",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "システム診断",
|
||||
"title": "ドクター",
|
||||
"buttonTitle": "診断と一般的な修復を実行",
|
||||
"loading": "環境を確認中...",
|
||||
"footer": "修復後も問題が続く場合は、診断パッケージをエクスポートしてください。",
|
||||
"summary": {
|
||||
"idle": "設定、キャッシュ整合性、UI の一貫性をヘルスチェックします。",
|
||||
"ok": "現在の環境でアクティブな問題は見つかりませんでした。",
|
||||
"warning": "{count} 件の問題が見つかりました。ほとんどはこのパネルから直接修復できます。",
|
||||
"error": "アプリが完全に正常になる前に、{count} 件の問題に対処する必要があります。"
|
||||
},
|
||||
"status": {
|
||||
"ok": "正常",
|
||||
"warning": "要注意",
|
||||
"error": "対応が必要"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "再実行",
|
||||
"exportBundle": "パッケージをエクスポート"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "診断の読み込みに失敗しました: {message}",
|
||||
"repairSuccess": "キャッシュの再構築が完了しました。",
|
||||
"repairFailed": "キャッシュの再構築に失敗しました: {message}",
|
||||
"exportSuccess": "診断パッケージをエクスポートしました。",
|
||||
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "アプリケーション更新が検出されました",
|
||||
|
||||
133
locales/ko.json
133
locales/ko.json
@@ -175,6 +175,9 @@
|
||||
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||
"error": "레시피 복구 실패: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
"options": {
|
||||
"com": "civitai.com(SFW 전용)",
|
||||
"red": "civitai.red(무제한)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai 호스트 기본 설정 사용 가능",
|
||||
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||
"openSettings": "설정 열기"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "설정 폴더 열기",
|
||||
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "비디오 설정",
|
||||
"layoutSettings": "레이아웃 설정",
|
||||
"misc": "기타",
|
||||
"backup": "백업",
|
||||
"folderSettings": "기본 루트",
|
||||
"recipeSettings": "레시피",
|
||||
"extraFolderPaths": "추가 폴다 경로",
|
||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||
"priorityTags": "우선순위 태그",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "자동 백업",
|
||||
"autoEnabledHelp": "하루에 한 번 로컬 스냅샷을 만들고 보존 정책에 따라 최신 스냅샷을 유지합니다.",
|
||||
"retention": "보존 개수",
|
||||
"retentionHelp": "오래된 자동 스냅샷을 삭제하기 전에 몇 개를 유지할지 지정합니다.",
|
||||
"management": "백업 관리",
|
||||
"managementHelp": "현재 사용자 상태를 내보내거나 백업 아카이브에서 복원합니다.",
|
||||
"scopeHelp": "설정, 다운로드 기록, 모델 업데이트 상태를 백업합니다. 모델 파일과 다시 생성할 수 있는 캐시는 포함되지 않습니다.",
|
||||
"locationSummary": "현재 백업 위치",
|
||||
"openFolderButton": "백업 폴더 열기",
|
||||
"openFolderSuccess": "백업 폴더를 열었습니다",
|
||||
"openFolderFailed": "백업 폴더를 열지 못했습니다",
|
||||
"locationCopied": "백업 경로를 클립보드에 복사했습니다: {{path}}",
|
||||
"locationClipboardFallback": "백업 경로: {{path}}",
|
||||
"exportButton": "백업 내보내기",
|
||||
"exportSuccess": "백업을 성공적으로 내보냈습니다.",
|
||||
"exportFailed": "백업 내보내기에 실패했습니다: {message}",
|
||||
"importButton": "백업 가져오기",
|
||||
"importConfirm": "이 백업을 가져와서 로컬 사용자 상태를 덮어쓰시겠습니까?",
|
||||
"importSuccess": "백업을 성공적으로 가져왔습니다.",
|
||||
"importFailed": "백업 가져오기에 실패했습니다: {message}",
|
||||
"latestSnapshot": "최근 스냅샷",
|
||||
"latestAutoSnapshot": "최근 자동 스냅샷",
|
||||
"snapshotCount": "저장된 스냅샷",
|
||||
"noneAvailable": "아직 스냅샷이 없습니다"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "기본 모델 다운로드 건너뛰기",
|
||||
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "이전에 다운로드한 모델 버전 건너뛰기",
|
||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||
"defaultEmbeddingRoot": "Embedding 루트",
|
||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||
"recipesPath": "레시피 저장 경로",
|
||||
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "레시피 저장 경로를 이동 중...",
|
||||
"noDefault": "기본값 없음"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "추가 폴다 경로",
|
||||
"help": "ComfyUI의 표준 경로 외부에 추가 모델 폴드를 추가하세요. 이러한 경로는 별도로 저장되며 기본 폴와 함께 스캔됩니다.",
|
||||
"description": "모델을 스캔하기 위한 추가 폴를 설정하세요. 이러한 경로는 LoRA Manager 특유의 것이며 ComfyUI의 기본 경로와 병합됩니다.",
|
||||
"description": "LoRA Manager 전용 추가 모델 루트 경로입니다. ComfyUI의 표준 폴더 외부 위치에서 모델을 로드하여 대규모 라이브러리로 인한 성능 저하를 방지합니다.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 경로",
|
||||
"checkpoint": "Checkpoint 경로",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Embedding 경로"
|
||||
},
|
||||
"pathPlaceholder": "/추가/모델/경로",
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.",
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"excludeModel": "모델 제외",
|
||||
"restoreModel": "모델 복원",
|
||||
"deleteModel": "모델 삭제",
|
||||
"shareRecipe": "레시피 공유",
|
||||
"viewAllLoras": "모든 LoRA 보기",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "{otherType} 폴더로 이동"
|
||||
"moveToOtherTypeFolder": "{otherType} 폴더로 이동",
|
||||
"sendToWorkflow": "워크플로우로 전송"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "사이드바 고정 해제",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"recursiveOn": "하위 폴더 검색",
|
||||
"recursiveOff": "현재 폴더만 검색",
|
||||
"recursiveOn": "하위 폴더 포함",
|
||||
"recursiveOff": "현재 폴더만",
|
||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"earlyAccessTooltip": "얼리 액세스 필요",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"downloaded": "다운로드됨",
|
||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||
"errors": {
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"viewOnCivitaiText": "Civitai에서 보기",
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
"openFileLocation": "파일 위치 열기"
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
"sendToWorkflowText": "ComfyUI로 보내기"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "버전",
|
||||
"fileName": "파일명",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "{count}일 후"
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재 버전",
|
||||
"current": "열린 버전",
|
||||
"currentTooltip": "이 모달을 열 때 사용한 버전입니다",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"inLibraryTooltip": "이 버전은 로컬 라이브러리에 있습니다",
|
||||
"downloaded": "다운로드됨",
|
||||
"downloadedTooltip": "이 버전은 이전에 다운로드되었지만 현재는 라이브러리에 없습니다",
|
||||
"newer": "최신 버전",
|
||||
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"ignored": "무시됨"
|
||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||
"ignored": "무시됨",
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"downloadTooltip": "이 버전 다운로드",
|
||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||
"delete": "삭제",
|
||||
"deleteTooltip": "이 로컬 버전 삭제",
|
||||
"ignore": "무시",
|
||||
"unignore": "무시 해제",
|
||||
"ignoreTooltip": "이 버전의 업데이트 알림 무시",
|
||||
"unignoreTooltip": "이 버전의 업데이트 알림 다시 받기",
|
||||
"viewVersionOnCivitai": "Civitai에서 버전 보기",
|
||||
"earlyAccessTooltip": "얼리 액세스 구매 필요",
|
||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||
"modelFailed": "모델 노드 업데이트 실패"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다",
|
||||
"tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다",
|
||||
"sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다",
|
||||
"promptUpdated": "프롬프트가 성공적으로 업데이트되었습니다",
|
||||
"negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다",
|
||||
"promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄",
|
||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
||||
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
||||
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
||||
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
||||
"settingsUpdated": "설정 업데이트됨: {setting}",
|
||||
"compactModeToggled": "컴팩트 모드 {state}",
|
||||
"settingSaveFailed": "설정 저장 실패: {message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "{type} 삭제 실패: {message}",
|
||||
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
|
||||
"excludeFailed": "{type} 제외 실패: {message}",
|
||||
"restoreSuccess": "{type} 복원 완료",
|
||||
"restoreFailed": "{type} 복원 실패: {message}",
|
||||
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
|
||||
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
||||
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "시스템 진단",
|
||||
"title": "닥터",
|
||||
"buttonTitle": "진단 및 일반적인 수정 실행",
|
||||
"loading": "환경을 확인하는 중...",
|
||||
"footer": "수리 후에도 문제가 계속되면 진단 번들을 내보내세요.",
|
||||
"summary": {
|
||||
"idle": "설정, 캐시 무결성, UI 일관성에 대한 상태 검사를 실행합니다.",
|
||||
"ok": "현재 환경에서 활성 문제를 찾지 못했습니다.",
|
||||
"warning": "{count}개의 문제가 발견되었습니다. 대부분은 이 패널에서 바로 해결할 수 있습니다.",
|
||||
"error": "앱이 완전히 정상 상태가 되기 전에 {count}개의 문제를 처리해야 합니다."
|
||||
},
|
||||
"status": {
|
||||
"ok": "정상",
|
||||
"warning": "주의 필요",
|
||||
"error": "조치 필요"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "다시 실행",
|
||||
"exportBundle": "번들 내보내기"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "진단 로드 실패: {message}",
|
||||
"repairSuccess": "캐시 재구성이 완료되었습니다.",
|
||||
"repairFailed": "캐시 재구성 실패: {message}",
|
||||
"exportSuccess": "진단 번들이 내보내졌습니다.",
|
||||
"exportFailed": "진단 번들 내보내기 실패: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "애플리케이션 업데이트 감지",
|
||||
|
||||
133
locales/ru.json
133
locales/ru.json
@@ -175,6 +175,9 @@
|
||||
"success": "Успешно восстановлено {count} рецептов.",
|
||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||
"error": "Ошибка восстановления рецептов: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"civitaiHost": {
|
||||
"label": "Хост Civitai",
|
||||
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
|
||||
"options": {
|
||||
"com": "civitai.com (только SFW)",
|
||||
"red": "civitai.red (без ограничений)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Доступна настройка хоста Civitai",
|
||||
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
|
||||
"openSettings": "Открыть настройки"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Открыть папку настроек",
|
||||
"tooltip": "Открыть папку, содержащую settings.json",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "Настройки видео",
|
||||
"layoutSettings": "Настройки макета",
|
||||
"misc": "Разное",
|
||||
"backup": "Резервные копии",
|
||||
"folderSettings": "Корневые папки",
|
||||
"recipeSettings": "Рецепты",
|
||||
"extraFolderPaths": "Дополнительные пути к папкам",
|
||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||
"priorityTags": "Приоритетные теги",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "Не удалось сохранить пути для пропуска: {message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "Автоматические резервные копии",
|
||||
"autoEnabledHelp": "Создаёт локальный снимок раз в день и хранит последние снимки согласно политике хранения.",
|
||||
"retention": "Количество хранения",
|
||||
"retentionHelp": "Сколько автоматических снимков сохранять перед удалением старых.",
|
||||
"management": "Управление резервными копиями",
|
||||
"managementHelp": "Экспортируйте текущее состояние пользователя или восстановите его из архива резервной копии.",
|
||||
"scopeHelp": "Резервная копия включает ваши настройки, историю загрузок и состояние обновлений моделей. Файлы моделей и пересоздаваемые кэши не входят.",
|
||||
"locationSummary": "Текущее расположение резервных копий",
|
||||
"openFolderButton": "Открыть папку резервных копий",
|
||||
"openFolderSuccess": "Папка резервных копий открыта",
|
||||
"openFolderFailed": "Не удалось открыть папку резервных копий",
|
||||
"locationCopied": "Путь к резервной копии скопирован в буфер обмена: {{path}}",
|
||||
"locationClipboardFallback": "Путь к резервной копии: {{path}}",
|
||||
"exportButton": "Экспортировать резервную копию",
|
||||
"exportSuccess": "Резервная копия успешно экспортирована.",
|
||||
"exportFailed": "Не удалось экспортировать резервную копию: {message}",
|
||||
"importButton": "Импортировать резервную копию",
|
||||
"importConfirm": "Импортировать эту резервную копию и перезаписать локальное состояние пользователя?",
|
||||
"importSuccess": "Резервная копия успешно импортирована.",
|
||||
"importFailed": "Не удалось импортировать резервную копию: {message}",
|
||||
"latestSnapshot": "Последний снимок",
|
||||
"latestAutoSnapshot": "Последний автоматический снимок",
|
||||
"snapshotCount": "Сохранённые снимки",
|
||||
"noneAvailable": "Снимков пока нет"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "Пропускать загрузки для базовых моделей",
|
||||
"help": "Применяется ко всем сценариям загрузки. Здесь можно выбрать только поддерживаемые базовые модели.",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "Не удалось сохранить исключённые базовые модели: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "Пропускать ранее загруженные версии моделей",
|
||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||
"recipesPath": "Путь хранения рецептов",
|
||||
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "Перенос хранилища рецептов...",
|
||||
"noDefault": "Не задано"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "Дополнительные пути к папкам",
|
||||
"help": "Добавьте дополнительные папки моделей за пределами стандартных путей ComfyUI. Эти пути хранятся отдельно и сканируются вместе с папками по умолчанию.",
|
||||
"description": "Настройте дополнительные папки для сканирования моделей. Эти пути специфичны для LoRA Manager и будут объединены с путями по умолчанию ComfyUI.",
|
||||
"description": "Дополнительные корневые пути моделей, эксклюзивные для LoRA Manager. Загружайте модели из расположений за пределами стандартных папок ComfyUI — идеально подходит для больших библиотек, которые иначе замедлили бы ComfyUI.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "Пути LoRA",
|
||||
"checkpoint": "Пути Checkpoint",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Пути Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/путь/к/дополнительным/моделям",
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены.",
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Этот путь уже настроен"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"excludeModel": "Исключить модель",
|
||||
"restoreModel": "Восстановить модель",
|
||||
"deleteModel": "Удалить модель",
|
||||
"shareRecipe": "Поделиться рецептом",
|
||||
"viewAllLoras": "Посмотреть все LoRAs",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Переместить в папку {otherType}"
|
||||
"moveToOtherTypeFolder": "Переместить в папку {otherType}",
|
||||
"sendToWorkflow": "Отправить в workflow"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "Открепить боковую панель",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"recursiveOn": "Искать во вложенных папках",
|
||||
"recursiveOff": "Искать только в текущей папке",
|
||||
"recursiveOn": "Включать вложенные папки",
|
||||
"recursiveOff": "Только текущая папка",
|
||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"earlyAccessTooltip": "Требуется ранний доступ",
|
||||
"inLibrary": "В библиотеке",
|
||||
"downloaded": "Загружено",
|
||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||
"alreadyInLibrary": "Уже в библиотеке",
|
||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||
"errors": {
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "Посмотреть на Civitai",
|
||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||
"openFileLocation": "Открыть расположение файла"
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
"sendToWorkflowText": "Отправить в ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Расположение файла успешно открыто",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Версия",
|
||||
"fileName": "Имя файла",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "через {count}д"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Текущая версия",
|
||||
"current": "Открытая версия",
|
||||
"currentTooltip": "Это версия, с которой было открыто это окно",
|
||||
"inLibrary": "В библиотеке",
|
||||
"inLibraryTooltip": "Эта версия есть в вашей локальной библиотеке",
|
||||
"downloaded": "Загружено",
|
||||
"downloadedTooltip": "Эта версия уже загружалась, но сейчас отсутствует в вашей библиотеке",
|
||||
"newer": "Более новая версия",
|
||||
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"ignored": "Игнорируется"
|
||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||
"ignored": "Игнорируется",
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"downloadTooltip": "Скачать эту версию",
|
||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||
"delete": "Удалить",
|
||||
"deleteTooltip": "Удалить эту локальную версию",
|
||||
"ignore": "Игнорировать",
|
||||
"unignore": "Перестать игнорировать",
|
||||
"ignoreTooltip": "Игнорировать уведомления об обновлениях для этой версии",
|
||||
"unignoreTooltip": "Возобновить уведомления об обновлениях для этой версии",
|
||||
"viewVersionOnCivitai": "Посмотреть версию на Civitai",
|
||||
"earlyAccessTooltip": "Требуется покупка раннего доступа",
|
||||
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "Рецепт заменён в workflow",
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||
"noTargetNodeSelected": "Целевой узел не выбран"
|
||||
"noTargetNodeSelected": "Целевой узел не выбран",
|
||||
"modelUpdated": "Модель обновлена в workflow",
|
||||
"modelFailed": "Не удалось обновить узел модели"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "Название рецепта успешно обновлено",
|
||||
"tagsUpdated": "Теги рецепта успешно обновлены",
|
||||
"sourceUrlUpdated": "Исходный URL успешно обновлен",
|
||||
"promptUpdated": "Промпт успешно обновлён",
|
||||
"negativePromptUpdated": "Негативный промпт успешно обновлён",
|
||||
"promptEditorHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
||||
"noRecipeId": "ID рецепта недоступен",
|
||||
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
||||
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
||||
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
||||
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
||||
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
||||
"settingsUpdated": "Настройки обновлены: {setting}",
|
||||
"compactModeToggled": "Компактный режим {state}",
|
||||
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "Не удалось удалить {type}: {message}",
|
||||
"excludeSuccess": "{type} успешно исключен",
|
||||
"excludeFailed": "Не удалось исключить {type}: {message}",
|
||||
"restoreSuccess": "{type} успешно восстановлен",
|
||||
"restoreFailed": "Не удалось восстановить {type}: {message}",
|
||||
"fileNameUpdated": "Имя файла успешно обновлено",
|
||||
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
||||
"previewUpdated": "Превью успешно обновлено",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "Системная диагностика",
|
||||
"title": "Доктор",
|
||||
"buttonTitle": "Запустить диагностику и обычные исправления",
|
||||
"loading": "Проверка окружения...",
|
||||
"footer": "Экспортируйте диагностический пакет, если проблема сохраняется после исправления.",
|
||||
"summary": {
|
||||
"idle": "Выполнить проверку настроек, целостности кэша и согласованности интерфейса.",
|
||||
"ok": "В текущем окружении активных проблем не обнаружено.",
|
||||
"warning": "Обнаружено {count} проблем(ы). Большинство можно исправить прямо из этой панели.",
|
||||
"error": "Перед тем как приложение станет полностью исправным, нужно устранить {count} проблем(ы)."
|
||||
},
|
||||
"status": {
|
||||
"ok": "Исправно",
|
||||
"warning": "Требует внимания",
|
||||
"error": "Требуется действие"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "Запустить снова",
|
||||
"exportBundle": "Экспортировать пакет"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "Не удалось загрузить диагностику: {message}",
|
||||
"repairSuccess": "Перестройка кэша завершена.",
|
||||
"repairFailed": "Не удалось перестроить кэш: {message}",
|
||||
"exportSuccess": "Диагностический пакет экспортирован.",
|
||||
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "Обнаружено обновление приложения",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "成功修复了 {count} 个配方。",
|
||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||
"error": "配方修复失败:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
"options": {
|
||||
"com": "civitai.com(仅 SFW)",
|
||||
"red": "civitai.red(无限制)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 Civitai 站点偏好设置",
|
||||
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||
"openSettings": "打开设置"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "打开设置文件夹",
|
||||
"tooltip": "打开包含 settings.json 的文件夹",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "视频设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"misc": "其他",
|
||||
"backup": "备份",
|
||||
"folderSettings": "默认根目录",
|
||||
"recipeSettings": "配方",
|
||||
"extraFolderPaths": "额外文件夹路径",
|
||||
"downloadPathTemplates": "下载路径模板",
|
||||
"priorityTags": "优先标签",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "无法保存跳过路径:{message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "自动备份",
|
||||
"autoEnabledHelp": "每天创建一次本地快照,并按保留策略保留最新快照。",
|
||||
"retention": "保留数量",
|
||||
"retentionHelp": "在删除旧快照之前,要保留多少个自动快照。",
|
||||
"management": "备份管理",
|
||||
"managementHelp": "导出当前用户状态,或从备份归档中恢复。",
|
||||
"scopeHelp": "备份你的设置、下载历史和模型更新状态。不包含模型文件或可重建的缓存。",
|
||||
"locationSummary": "当前备份位置",
|
||||
"openFolderButton": "打开备份文件夹",
|
||||
"openFolderSuccess": "已打开备份文件夹",
|
||||
"openFolderFailed": "无法打开备份文件夹",
|
||||
"locationCopied": "备份路径已复制到剪贴板:{{path}}",
|
||||
"locationClipboardFallback": "备份路径:{{path}}",
|
||||
"exportButton": "导出备份",
|
||||
"exportSuccess": "备份导出成功。",
|
||||
"exportFailed": "备份导出失败:{message}",
|
||||
"importButton": "导入备份",
|
||||
"importConfirm": "导入此备份并覆盖本地用户状态吗?",
|
||||
"importSuccess": "备份导入成功。",
|
||||
"importFailed": "备份导入失败:{message}",
|
||||
"latestSnapshot": "最近快照",
|
||||
"latestAutoSnapshot": "最近自动快照",
|
||||
"snapshotCount": "已保存快照",
|
||||
"noneAvailable": "还没有快照"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "跳过这些基础模型的下载",
|
||||
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "无法保存已排除的基础模型:{message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "跳过已下载的模型版本",
|
||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||
"defaultEmbeddingRoot": "Embedding 根目录",
|
||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||
"recipesPath": "配方存储路径",
|
||||
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "正在迁移配方存储...",
|
||||
"noDefault": "无默认"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "额外文件夹路径",
|
||||
"help": "在 ComfyUI 的标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。",
|
||||
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。",
|
||||
"description": "LoRA Manager 专属的额外模型根目录。从 ComfyUI 标准文件夹之外的位置加载模型,特别适合管理大型模型库,避免影响 ComfyUI 性能。",
|
||||
"restartRequired": "需要重启才能生效",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 路径",
|
||||
"checkpoint": "Checkpoint 路径",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Embedding 路径"
|
||||
},
|
||||
"pathPlaceholder": "/额外/模型/路径",
|
||||
"saveSuccess": "额外文件夹路径已更新。",
|
||||
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
||||
"saveError": "更新额外文件夹路径失败:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路径已配置"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "恢复模型",
|
||||
"deleteModel": "删除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "查看所有 LoRA",
|
||||
@@ -649,9 +702,9 @@
|
||||
"title": "从图片或 URL 导入配方",
|
||||
"urlLocalPath": "URL / 本地路径",
|
||||
"uploadImage": "上传图片",
|
||||
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。",
|
||||
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL,或本地文件路径以导入为配方。",
|
||||
"imageUrlOrPath": "图片 URL 或文件路径:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
|
||||
"fetchImage": "获取图片",
|
||||
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
|
||||
"selectImage": "选择图片",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹"
|
||||
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
|
||||
"sendToWorkflow": "发送到工作流"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "取消固定侧边栏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"recursiveOn": "搜索子文件夹",
|
||||
"recursiveOff": "仅搜索当前文件夹",
|
||||
"recursiveOn": "包含子文件夹",
|
||||
"recursiveOff": "仅当前文件夹",
|
||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "早期访问",
|
||||
"earlyAccessTooltip": "需要早期访问权限",
|
||||
"inLibrary": "已在库中",
|
||||
"downloaded": "已下载",
|
||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||
"alreadyInLibrary": "已存在于库中",
|
||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||
"errors": {
|
||||
@@ -1051,9 +1107,9 @@
|
||||
},
|
||||
"proceedText": "仅在你确定需要此操作时继续。",
|
||||
"urlLabel": "Civitai 模型 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||
"helpText": {
|
||||
"title": "粘贴任意 Civitai 模型 URL。支持格式:",
|
||||
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
"openFileLocation": "打开文件位置"
|
||||
"openFileLocation": "打开文件位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
"sendToWorkflowText": "发送到 ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "文件位置已成功打开",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "无法发送到 ComfyUI:没有可用的文件路径"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "版本",
|
||||
"fileName": "文件名",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "{count}天后"
|
||||
},
|
||||
"badges": {
|
||||
"current": "当前版本",
|
||||
"current": "已打开版本",
|
||||
"currentTooltip": "这是你用来打开此弹窗的版本",
|
||||
"inLibrary": "已在库中",
|
||||
"inLibraryTooltip": "此版本已存在于你的本地库中",
|
||||
"downloaded": "已下载",
|
||||
"downloadedTooltip": "此版本之前下载过,但当前不在你的本地库中",
|
||||
"newer": "较新的版本",
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "抢先体验",
|
||||
"ignored": "已忽略"
|
||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已关闭更新通知"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"downloadTooltip": "下载此版本",
|
||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||
"delete": "删除",
|
||||
"deleteTooltip": "删除此本地版本",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"ignoreTooltip": "忽略此版本的更新通知",
|
||||
"unignoreTooltip": "恢复此版本的更新通知",
|
||||
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||
"earlyAccessTooltip": "需要购买抢先体验",
|
||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||
"ignoreModelUpdates": "忽略该模型的更新",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "配方已替换到工作流",
|
||||
"recipeFailedToSend": "发送配方到工作流失败",
|
||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||
"noTargetNodeSelected": "未选择目标节点"
|
||||
"noTargetNodeSelected": "未选择目标节点",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型节点失败"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "配方名称更新成功",
|
||||
"tagsUpdated": "配方标签更新成功",
|
||||
"sourceUrlUpdated": "来源 URL 更新成功",
|
||||
"promptUpdated": "提示词更新成功",
|
||||
"negativePromptUpdated": "负面提示词更新成功",
|
||||
"promptEditorHint": "按 Enter 保存,Shift+Enter 换行",
|
||||
"noRecipeId": "无配方 ID",
|
||||
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
||||
"copyFailed": "复制配方语法出错:{message}",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||
"downloadTemplatesUpdated": "下载路径模板已更新",
|
||||
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
||||
"recipesPathUpdated": "配方存储路径已更新",
|
||||
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
||||
"settingsUpdated": "设置已更新:{setting}",
|
||||
"compactModeToggled": "紧凑模式 {state}",
|
||||
"settingSaveFailed": "保存设置失败:{message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "删除 {type} 失败:{message}",
|
||||
"excludeSuccess": "{type} 排除成功",
|
||||
"excludeFailed": "排除 {type} 失败:{message}",
|
||||
"restoreSuccess": "{type} 已成功恢复",
|
||||
"restoreFailed": "恢复 {type} 失败:{message}",
|
||||
"fileNameUpdated": "文件名更新成功",
|
||||
"fileRenameFailed": "重命名文件失败:{error}",
|
||||
"previewUpdated": "预览图片更新成功",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "系统诊断",
|
||||
"title": "医生",
|
||||
"buttonTitle": "运行诊断并尝试修复常见问题",
|
||||
"loading": "正在检查当前环境...",
|
||||
"footer": "如果修复后问题仍然存在,可以导出诊断包进一步排查。",
|
||||
"summary": {
|
||||
"idle": "检查设置、缓存健康状况和前后端 UI 版本是否一致。",
|
||||
"ok": "当前环境未发现活动问题。",
|
||||
"warning": "发现 {count} 个问题,大多数可以直接在这里处理。",
|
||||
"error": "发现 {count} 个需要尽快处理的问题。"
|
||||
},
|
||||
"status": {
|
||||
"ok": "健康",
|
||||
"warning": "需要关注",
|
||||
"error": "需要处理"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新检查",
|
||||
"exportBundle": "导出诊断包"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "加载诊断结果失败:{message}",
|
||||
"repairSuccess": "缓存重建完成。",
|
||||
"repairFailed": "缓存重建失败:{message}",
|
||||
"exportSuccess": "诊断包已导出。",
|
||||
"exportFailed": "导出诊断包失败:{message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "检测到应用更新",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"success": "成功修復 {count} 個配方。",
|
||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||
"error": "配方修復失敗:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -250,6 +253,19 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
"options": {
|
||||
"com": "civitai.com(僅 SFW)",
|
||||
"red": "civitai.red(無限制)"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 Civitai 站點偏好設定",
|
||||
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
|
||||
"openSettings": "開啟設定"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
"label": "開啟設定資料夾",
|
||||
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||
@@ -263,7 +279,9 @@
|
||||
"videoSettings": "影片設定",
|
||||
"layoutSettings": "版面設定",
|
||||
"misc": "其他",
|
||||
"backup": "備份",
|
||||
"folderSettings": "預設根目錄",
|
||||
"recipeSettings": "配方",
|
||||
"extraFolderPaths": "額外資料夾路徑",
|
||||
"downloadPathTemplates": "下載路徑範本",
|
||||
"priorityTags": "優先標籤",
|
||||
@@ -323,6 +341,32 @@
|
||||
"saveFailed": "無法儲存跳過路徑:{message}"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"autoEnabled": "自動備份",
|
||||
"autoEnabledHelp": "每天建立一次本地快照,並依保留政策保留最新快照。",
|
||||
"retention": "保留數量",
|
||||
"retentionHelp": "在刪除舊快照之前,要保留多少自動快照。",
|
||||
"management": "備份管理",
|
||||
"managementHelp": "匯出目前的使用者狀態,或從備份封存中還原。",
|
||||
"scopeHelp": "備份你的設定、下載歷史與模型更新狀態。不包含模型檔案或可重建的快取。",
|
||||
"locationSummary": "目前備份位置",
|
||||
"openFolderButton": "開啟備份資料夾",
|
||||
"openFolderSuccess": "已開啟備份資料夾",
|
||||
"openFolderFailed": "無法開啟備份資料夾",
|
||||
"locationCopied": "備份路徑已複製到剪貼簿:{{path}}",
|
||||
"locationClipboardFallback": "備份路徑:{{path}}",
|
||||
"exportButton": "匯出備份",
|
||||
"exportSuccess": "備份匯出成功。",
|
||||
"exportFailed": "備份匯出失敗:{message}",
|
||||
"importButton": "匯入備份",
|
||||
"importConfirm": "要匯入此備份並覆寫本機使用者狀態嗎?",
|
||||
"importSuccess": "備份匯入成功。",
|
||||
"importFailed": "備份匯入失敗:{message}",
|
||||
"latestSnapshot": "最近快照",
|
||||
"latestAutoSnapshot": "最近自動快照",
|
||||
"snapshotCount": "已儲存快照",
|
||||
"noneAvailable": "目前還沒有快照"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "跳過這些基礎模型的下載",
|
||||
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
|
||||
@@ -341,6 +385,10 @@
|
||||
"saveFailed": "無法儲存已排除的基礎模型:{message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "跳過已下載的模型版本",
|
||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -389,12 +437,16 @@
|
||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||
"defaultEmbeddingRoot": "Embedding 根目錄",
|
||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||
"recipesPath": "配方儲存路徑",
|
||||
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
"recipesPathMigrating": "正在遷移配方儲存...",
|
||||
"noDefault": "未設定預設"
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "額外資料夾路徑",
|
||||
"help": "在 ComfyUI 的標準路徑之外新增額外的模型資料夾。這些路徑單獨儲存,並與預設資料夾一起掃描。",
|
||||
"description": "設定額外的資料夾以掃描模型。這些路徑是 LoRA Manager 特有的,將與 ComfyUI 的預設路徑合併。",
|
||||
"description": "LoRA Manager 專屬的額外模型根目錄。從 ComfyUI 標準資料夾之外的位置載入模型,特別適合管理大型模型庫,避免影響 ComfyUI 效能。",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 路徑",
|
||||
"checkpoint": "Checkpoint 路徑",
|
||||
@@ -402,7 +454,7 @@
|
||||
"embedding": "Embedding 路徑"
|
||||
},
|
||||
"pathPlaceholder": "/額外/模型/路徑",
|
||||
"saveSuccess": "額外資料夾路徑已更新。",
|
||||
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路徑已設定"
|
||||
@@ -631,6 +683,7 @@
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "還原模型",
|
||||
"deleteModel": "刪除模型",
|
||||
"shareRecipe": "分享配方",
|
||||
"viewAllLoras": "檢視全部 LoRA",
|
||||
@@ -826,7 +879,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾"
|
||||
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
|
||||
"sendToWorkflow": "傳送到工作流"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +893,8 @@
|
||||
"unpinSidebar": "取消固定側邊欄",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"recursiveOn": "搜尋子資料夾",
|
||||
"recursiveOff": "僅搜尋目前資料夾",
|
||||
"recursiveOn": "包含子資料夾",
|
||||
"recursiveOff": "僅目前資料夾",
|
||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"dragDrop": {
|
||||
@@ -920,6 +974,8 @@
|
||||
"earlyAccess": "早期存取",
|
||||
"earlyAccessTooltip": "需要早期存取",
|
||||
"inLibrary": "已在庫存",
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||
"alreadyInLibrary": "已在庫存",
|
||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||
"errors": {
|
||||
@@ -1069,7 +1125,9 @@
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
"openFileLocation": "開啟檔案位置"
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
"sendToWorkflowText": "傳送到 ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "檔案位置已成功開啟",
|
||||
@@ -1077,6 +1135,9 @@
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "版本",
|
||||
"fileName": "檔案名稱",
|
||||
@@ -1184,17 +1245,30 @@
|
||||
"days": "{count}天後"
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前版本",
|
||||
"current": "已開啟版本",
|
||||
"currentTooltip": "這是你用來開啟此彈窗的版本",
|
||||
"inLibrary": "已在庫中",
|
||||
"inLibraryTooltip": "此版本已存在於你的本地庫中",
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "此版本之前下載過,但目前不在你的本地庫中",
|
||||
"newer": "較新版本",
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "搶先體驗",
|
||||
"ignored": "已忽略"
|
||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已關閉更新通知"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"downloadTooltip": "下載此版本",
|
||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||
"delete": "刪除",
|
||||
"deleteTooltip": "刪除此本地版本",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"ignoreTooltip": "忽略此版本的更新通知",
|
||||
"unignoreTooltip": "恢復此版本的更新通知",
|
||||
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||
"earlyAccessTooltip": "需要購買搶先體驗",
|
||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||
"ignoreModelUpdates": "忽略此模型的更新",
|
||||
@@ -1334,7 +1408,9 @@
|
||||
"recipeReplaced": "配方已取代於工作流",
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||
"noTargetNodeSelected": "未選擇目標節點"
|
||||
"noTargetNodeSelected": "未選擇目標節點",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型節點失敗"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
@@ -1505,7 +1581,11 @@
|
||||
"nameUpdated": "配方名稱已更新",
|
||||
"tagsUpdated": "配方標籤已更新",
|
||||
"sourceUrlUpdated": "來源網址已更新",
|
||||
"promptUpdated": "提示詞更新成功",
|
||||
"negativePromptUpdated": "負面提示詞更新成功",
|
||||
"promptEditorHint": "按 Enter 儲存,Shift+Enter 換行",
|
||||
"noRecipeId": "無配方 ID",
|
||||
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
||||
"copyFailed": "複製配方語法錯誤:{message}",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
@@ -1613,6 +1693,8 @@
|
||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
||||
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
||||
"recipesPathUpdated": "配方儲存路徑已更新",
|
||||
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
||||
"settingsUpdated": "設定已更新:{setting}",
|
||||
"compactModeToggled": "緊湊模式已{state}",
|
||||
"settingSaveFailed": "儲存設定失敗:{message}",
|
||||
@@ -1725,6 +1807,8 @@
|
||||
"deleteFailed": "刪除 {type} 失敗:{message}",
|
||||
"excludeSuccess": "{type} 已成功排除",
|
||||
"excludeFailed": "排除 {type} 失敗:{message}",
|
||||
"restoreSuccess": "{type} 已成功還原",
|
||||
"restoreFailed": "還原 {type} 失敗:{message}",
|
||||
"fileNameUpdated": "檔案名稱已成功更新",
|
||||
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
||||
"previewUpdated": "預覽圖片已成功更新",
|
||||
@@ -1756,6 +1840,35 @@
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"doctor": {
|
||||
"kicker": "系統診斷",
|
||||
"title": "醫生",
|
||||
"buttonTitle": "執行診斷與常見修復",
|
||||
"loading": "正在檢查環境...",
|
||||
"footer": "如果修復後問題仍然存在,請匯出診斷套件。",
|
||||
"summary": {
|
||||
"idle": "針對設定、快取完整性與 UI 一致性執行健康檢查。",
|
||||
"ok": "目前環境中未發現任何活動中的問題。",
|
||||
"warning": "找到 {count} 個問題。大多可以直接在此面板修復。",
|
||||
"error": "應先處理 {count} 個問題,應用程式才能完全正常。"
|
||||
},
|
||||
"status": {
|
||||
"ok": "健康",
|
||||
"warning": "需要注意",
|
||||
"error": "需要處理"
|
||||
},
|
||||
"actions": {
|
||||
"runAgain": "重新執行",
|
||||
"exportBundle": "匯出套件"
|
||||
},
|
||||
"toast": {
|
||||
"loadFailed": "載入診斷失敗:{message}",
|
||||
"repairSuccess": "快取重建完成。",
|
||||
"repairFailed": "快取重建失敗:{message}",
|
||||
"exportSuccess": "診斷套件已匯出。",
|
||||
"exportFailed": "匯出診斷套件失敗:{message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
"versionMismatch": {
|
||||
"title": "偵測到應用程式更新",
|
||||
|
||||
73
py/config.py
73
py/config.py
@@ -25,6 +25,31 @@ standalone_mode = (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_valid_default_root(
|
||||
current: str, primary_paths: List[str], name: str
|
||||
) -> str:
|
||||
"""Return a valid default root from the current primary path set."""
|
||||
|
||||
valid_paths = [path for path in primary_paths if isinstance(path, str) and path.strip()]
|
||||
if not valid_paths:
|
||||
return ""
|
||||
|
||||
if current in valid_paths:
|
||||
return current
|
||||
|
||||
if current:
|
||||
logger.info(
|
||||
"Repaired stale %s from '%s' to '%s'",
|
||||
name,
|
||||
current,
|
||||
valid_paths[0],
|
||||
)
|
||||
else:
|
||||
logger.info("Auto-setting %s to '%s'", name, valid_paths[0])
|
||||
|
||||
return valid_paths[0]
|
||||
|
||||
|
||||
def _normalize_folder_paths_for_comparison(
|
||||
folder_paths: Mapping[str, Iterable[str]],
|
||||
) -> Dict[str, Set[str]]:
|
||||
@@ -109,6 +134,7 @@ class Config:
|
||||
self.extra_checkpoints_roots: List[str] = []
|
||||
self.extra_unet_roots: List[str] = []
|
||||
self.extra_embeddings_roots: List[str] = []
|
||||
self.recipes_path: str = ""
|
||||
# Scan symbolic links during initialization
|
||||
self._initialize_symlink_mappings()
|
||||
|
||||
@@ -197,25 +223,23 @@ class Config:
|
||||
"Failed to rename legacy 'default' library: %s", rename_error
|
||||
)
|
||||
|
||||
default_lora_root = comfy_library.get("default_lora_root", "")
|
||||
if not default_lora_root and len(self.loras_roots) == 1:
|
||||
default_lora_root = self.loras_roots[0]
|
||||
default_lora_root = _resolve_valid_default_root(
|
||||
comfy_library.get("default_lora_root", ""),
|
||||
list(self.loras_roots or []),
|
||||
"default_lora_root",
|
||||
)
|
||||
|
||||
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "")
|
||||
if (
|
||||
not default_checkpoint_root
|
||||
and self.checkpoints_roots
|
||||
and len(self.checkpoints_roots) == 1
|
||||
):
|
||||
default_checkpoint_root = self.checkpoints_roots[0]
|
||||
default_checkpoint_root = _resolve_valid_default_root(
|
||||
comfy_library.get("default_checkpoint_root", ""),
|
||||
list(self.checkpoints_roots or []),
|
||||
"default_checkpoint_root",
|
||||
)
|
||||
|
||||
default_embedding_root = comfy_library.get("default_embedding_root", "")
|
||||
if (
|
||||
not default_embedding_root
|
||||
and self.embeddings_roots
|
||||
and len(self.embeddings_roots) == 1
|
||||
):
|
||||
default_embedding_root = self.embeddings_roots[0]
|
||||
default_embedding_root = _resolve_valid_default_root(
|
||||
comfy_library.get("default_embedding_root", ""),
|
||||
list(self.embeddings_roots or []),
|
||||
"default_embedding_root",
|
||||
)
|
||||
|
||||
metadata = dict(comfy_library.get("metadata", {}))
|
||||
metadata.setdefault("display_name", "ComfyUI")
|
||||
@@ -629,6 +653,8 @@ class Config:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.extra_embeddings_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
if self.recipes_path:
|
||||
preview_roots.update(self._expand_preview_root(self.recipes_path))
|
||||
|
||||
for target, link in self._path_mappings.items():
|
||||
preview_roots.update(self._expand_preview_root(target))
|
||||
@@ -706,7 +732,9 @@ class Config:
|
||||
return unique_paths
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path_for_comparison(path: str, *, resolve_realpath: bool = False) -> str:
|
||||
def _normalize_path_for_comparison(
|
||||
path: str, *, resolve_realpath: bool = False
|
||||
) -> str:
|
||||
"""Normalize a path for equality checks across platforms."""
|
||||
candidate = os.path.realpath(path) if resolve_realpath else path
|
||||
return os.path.normcase(os.path.normpath(candidate)).replace(os.sep, "/")
|
||||
@@ -886,9 +914,11 @@ class Config:
|
||||
self,
|
||||
folder_paths: Mapping[str, Iterable[str]],
|
||||
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||
recipes_path: str = "",
|
||||
) -> None:
|
||||
self._path_mappings.clear()
|
||||
self._preview_root_paths = set()
|
||||
self.recipes_path = recipes_path if isinstance(recipes_path, str) else ""
|
||||
|
||||
lora_paths = folder_paths.get("loras", []) or []
|
||||
checkpoint_paths = folder_paths.get("checkpoints", []) or []
|
||||
@@ -1144,7 +1174,12 @@ class Config:
|
||||
if not isinstance(extra_folder_paths, Mapping):
|
||||
extra_folder_paths = None
|
||||
|
||||
self._apply_library_paths(folder_paths, extra_folder_paths)
|
||||
recipes_path = (
|
||||
str(library_config.get("recipes_path", ""))
|
||||
if isinstance(library_config, Mapping)
|
||||
else ""
|
||||
)
|
||||
self._apply_library_paths(folder_paths, extra_folder_paths, recipes_path)
|
||||
|
||||
logger.info(
|
||||
"Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)",
|
||||
|
||||
@@ -222,6 +222,7 @@ class LoraManager:
|
||||
|
||||
# Register DownloadManager with ServiceRegistry
|
||||
await ServiceRegistry.get_download_manager()
|
||||
await ServiceRegistry.get_backup_service()
|
||||
|
||||
from .services.metadata_service import initialize_metadata_providers
|
||||
|
||||
|
||||
@@ -595,6 +595,15 @@ class MetadataProcessor:
|
||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||
else:
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||
# Generic guider nodes often expose separate positive/negative inputs.
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", max_depth=10)
|
||||
if not positive_node_id:
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||
|
||||
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", max_depth=10)
|
||||
if not negative_node_id:
|
||||
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||
|
||||
@@ -427,6 +429,75 @@ class ImageSizeExtractor(NodeMetadataExtractor):
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
|
||||
"""Extract LoRA metadata from rgthree Power Lora Loader.
|
||||
|
||||
The node passes LoRAs as dynamic kwargs: LORA_1, LORA_2, ... each containing
|
||||
{'on': bool, 'lora': filename, 'strength': float, 'strengthTwo': float}.
|
||||
"""
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
active_loras = []
|
||||
for key, value in inputs.items():
|
||||
if not key.upper().startswith('LORA_'):
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
if not value.get('on') or not value.get('lora'):
|
||||
continue
|
||||
lora_name = os.path.splitext(os.path.basename(value['lora']))[0]
|
||||
active_loras.append({
|
||||
"name": lora_name,
|
||||
"strength": round(float(value.get('strength', 1.0)), 2)
|
||||
})
|
||||
|
||||
if active_loras:
|
||||
metadata[LORAS][node_id] = {
|
||||
"lora_list": active_loras,
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
|
||||
class TensorRTLoaderExtractor(NodeMetadataExtractor):
|
||||
"""Extract checkpoint metadata from TensorRT Loader.
|
||||
|
||||
extract() parses the engine filename from 'unet_name' as a best-effort
|
||||
fallback (strips profile suffix after '_$' and counter suffix).
|
||||
|
||||
update() checks if the output MODEL has attachments["source_model"]
|
||||
set by the node (NubeBuster fork) and overrides with the real name.
|
||||
Vanilla TRT doesn't set this — the filename parse stands.
|
||||
"""
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "unet_name" not in inputs:
|
||||
return
|
||||
unet_name = inputs.get("unet_name")
|
||||
# Strip path and extension, then drop the $_profile suffix
|
||||
model_name = os.path.splitext(os.path.basename(unet_name))[0]
|
||||
if "_$" in model_name:
|
||||
model_name = model_name[:model_name.index("_$")]
|
||||
# Strip counter suffix (e.g. _00001_) left by ComfyUI's save path
|
||||
model_name = re.sub(r'_\d+_?$', '', model_name)
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata):
|
||||
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
|
||||
return
|
||||
first_output = outputs[0]
|
||||
if not isinstance(first_output, tuple) or len(first_output) < 1:
|
||||
return
|
||||
model = first_output[0]
|
||||
# NubeBuster fork sets attachments["source_model"] on the ModelPatcher
|
||||
source_model = getattr(model, 'attachments', {}).get("source_model")
|
||||
if source_model:
|
||||
_store_checkpoint_metadata(metadata, node_id, source_model)
|
||||
|
||||
|
||||
class LoraLoaderManagerExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -577,8 +648,6 @@ class SamplerCustomAdvancedExtractor(BaseSamplerExtractor):
|
||||
# Extract latent dimensions
|
||||
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
|
||||
|
||||
import json
|
||||
|
||||
class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -715,8 +784,11 @@ NODE_EXTRACTORS = {
|
||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
"LoraLoaderLM": LoraLoaderManagerExtractor,
|
||||
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
|
||||
"TensorRTLoader": TensorRTLoaderExtractor,
|
||||
# Conditioning
|
||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||
"CLIPTextEncodeAttentionBias": CLIPTextEncodeExtractor, # From https://github.com/silveroxides/ComfyUI_PromptAttention
|
||||
"PromptLM": CLIPTextEncodeExtractor,
|
||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||
|
||||
@@ -4,15 +4,21 @@ from typing import Awaitable, Callable, Dict, List
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
# Use wildcard for CivitAI to support their CDN subdomains (e.g., image-b2.civitai.com)
|
||||
# Security note: This is acceptable because:
|
||||
# 1. CSP img-src only controls image/video loading, not script execution
|
||||
# 2. All *.civitai.com subdomains are controlled by Civitai
|
||||
# 3. Explicit domain list would require constant updates as Civitai adds CDN nodes
|
||||
REMOTE_MEDIA_SOURCES = (
|
||||
"https://image.civitai.com",
|
||||
"https://*.civitai.com",
|
||||
"https://img.genur.art",
|
||||
)
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def relax_csp_for_remote_media(
|
||||
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
|
||||
request: web.Request,
|
||||
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
|
||||
) -> web.StreamResponse:
|
||||
"""Allow LoRA Manager media previews to load from trusted remote domains.
|
||||
|
||||
@@ -43,7 +49,9 @@ async def relax_csp_for_remote_media(
|
||||
directive_order.append(name)
|
||||
directives[name] = values
|
||||
|
||||
def merge_sources(name: str, sources: List[str], defaults: List[str] | None = None) -> None:
|
||||
def merge_sources(
|
||||
name: str, sources: List[str], defaults: List[str] | None = None
|
||||
) -> None:
|
||||
existing = directives.get(name, list(defaults or []))
|
||||
|
||||
for source in sources:
|
||||
|
||||
@@ -8,6 +8,7 @@ and tracks the cycle progress which persists across workflow save/load.
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..utils.utils import get_lora_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,6 +55,9 @@ class LoraCyclerLM:
|
||||
current_index = cycler_config.get("current_index", 1) # 1-based
|
||||
model_strength = float(cycler_config.get("model_strength", 1.0))
|
||||
clip_strength = float(cycler_config.get("clip_strength", 1.0))
|
||||
use_same_clip_strength = cycler_config.get("use_same_clip_strength", True)
|
||||
use_preset_strength = cycler_config.get("use_preset_strength", False)
|
||||
preset_strength_scale = float(cycler_config.get("preset_strength_scale", 1.0))
|
||||
sort_by = "filename"
|
||||
|
||||
# Include "no lora" option
|
||||
@@ -131,6 +135,39 @@ class LoraCyclerLM:
|
||||
else:
|
||||
# Normalize path separators
|
||||
lora_path = lora_path.replace("/", os.sep)
|
||||
|
||||
if use_preset_strength:
|
||||
lora_metadata = await lora_service.get_lora_metadata_by_filename(
|
||||
current_lora["file_name"]
|
||||
)
|
||||
if lora_metadata:
|
||||
recommended_strength = (
|
||||
lora_service.get_recommended_strength_from_lora_data(
|
||||
lora_metadata
|
||||
)
|
||||
)
|
||||
if recommended_strength is not None:
|
||||
model_strength = round(
|
||||
recommended_strength * preset_strength_scale, 2
|
||||
)
|
||||
|
||||
if use_same_clip_strength:
|
||||
clip_strength = model_strength
|
||||
else:
|
||||
recommended_clip_strength = (
|
||||
lora_service.get_recommended_clip_strength_from_lora_data(
|
||||
lora_metadata
|
||||
)
|
||||
)
|
||||
if recommended_clip_strength is not None:
|
||||
clip_strength = round(
|
||||
recommended_clip_strength * preset_strength_scale, 2
|
||||
)
|
||||
elif use_same_clip_strength:
|
||||
clip_strength = model_strength
|
||||
elif use_same_clip_strength:
|
||||
clip_strength = model_strength
|
||||
|
||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||
|
||||
# Calculate next index (wrap to 1 if at end)
|
||||
|
||||
26
py/nodes/lora_stack_combiner.py
Normal file
26
py/nodes/lora_stack_combiner.py
Normal file
@@ -0,0 +1,26 @@
|
||||
class LoraStackCombinerLM:
|
||||
NAME = "Lora Stack Combiner (LoraManager)"
|
||||
CATEGORY = "Lora Manager/stackers"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"lora_stack_a": ("LORA_STACK",),
|
||||
"lora_stack_b": ("LORA_STACK",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK",)
|
||||
RETURN_NAMES = ("LORA_STACK",)
|
||||
FUNCTION = "combine_stacks"
|
||||
|
||||
def combine_stacks(self, lora_stack_a, lora_stack_b):
|
||||
combined_stack = []
|
||||
|
||||
if lora_stack_a:
|
||||
combined_stack.extend(lora_stack_a)
|
||||
if lora_stack_b:
|
||||
combined_stack.extend(lora_stack_b)
|
||||
|
||||
return (combined_stack,)
|
||||
@@ -1,15 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import inspect
|
||||
|
||||
from ..services.wildcard_service import (
|
||||
contains_dynamic_syntax,
|
||||
get_wildcard_service,
|
||||
is_trigger_words_input,
|
||||
)
|
||||
|
||||
class _AllContainer:
|
||||
"""Container that accepts any key for dynamic input validation."""
|
||||
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
class _PromptOptionalInputs:
|
||||
"""Lookup that preserves explicit optional inputs and dynamic trigger slots."""
|
||||
|
||||
def __getitem__(self, key):
|
||||
return ("STRING", {"forceInput": True})
|
||||
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
|
||||
self._explicit_inputs = explicit_inputs
|
||||
|
||||
def __contains__(self, item: object) -> bool:
|
||||
if not isinstance(item, str):
|
||||
return False
|
||||
return item in self._explicit_inputs or is_trigger_words_input(item)
|
||||
|
||||
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
|
||||
if key in self._explicit_inputs:
|
||||
return self._explicit_inputs[key]
|
||||
if is_trigger_words_input(key):
|
||||
return (
|
||||
"STRING",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "Trigger words to prepend. Connect to add more inputs.",
|
||||
},
|
||||
)
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
class PromptLM:
|
||||
@@ -20,12 +43,19 @@ class PromptLM:
|
||||
DESCRIPTION = (
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||
"to guide the diffusion model towards generating specific images. "
|
||||
"Supports dynamic trigger words inputs."
|
||||
"Supports dynamic trigger words inputs and runtime wildcard expansion."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
dyn_inputs = {
|
||||
optional_inputs: dict[str, tuple[str, dict[str, Any]]] = {
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
|
||||
},
|
||||
),
|
||||
"trigger_words1": (
|
||||
"STRING",
|
||||
{
|
||||
@@ -35,10 +65,9 @@ class PromptLM:
|
||||
),
|
||||
}
|
||||
|
||||
# Bypass validation for dynamic inputs during graph execution
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||
dyn_inputs = _AllContainer()
|
||||
optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
@@ -46,8 +75,8 @@ class PromptLM:
|
||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||
{
|
||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||
"placeholder": "Enter prompt... /char, /artist for quick tag search",
|
||||
"tooltip": "The text to be encoded.",
|
||||
"placeholder": "Enter prompt... /character, /artist, /wildcard for quick search",
|
||||
"tooltip": "The text to be encoded. Wildcard references inserted with /wildcard are expanded at runtime.",
|
||||
},
|
||||
),
|
||||
"clip": (
|
||||
@@ -55,7 +84,7 @@ class PromptLM:
|
||||
{"tooltip": "The CLIP model used for encoding the text."},
|
||||
),
|
||||
},
|
||||
"optional": dyn_inputs,
|
||||
"optional": optional_inputs,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING", "STRING")
|
||||
@@ -65,20 +94,39 @@ class PromptLM:
|
||||
)
|
||||
FUNCTION = "encode"
|
||||
|
||||
def encode(self, text: str, clip: Any, **kwargs):
|
||||
# Collect all trigger words from dynamic inputs
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
text: str,
|
||||
clip: Any | None = None,
|
||||
seed: int | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
del clip, kwargs
|
||||
if contains_dynamic_syntax(text) and seed is None:
|
||||
return float("NaN")
|
||||
return False
|
||||
|
||||
def encode(
|
||||
self,
|
||||
text: str,
|
||||
clip: Any,
|
||||
seed: int | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
expanded_text = get_wildcard_service().expand_text(text, seed=seed)
|
||||
|
||||
trigger_words = []
|
||||
for key, value in kwargs.items():
|
||||
if key.startswith("trigger_words") and value:
|
||||
if is_trigger_words_input(key) and value:
|
||||
trigger_words.append(value)
|
||||
|
||||
# Build final prompt
|
||||
if trigger_words:
|
||||
prompt = ", ".join(trigger_words + [text])
|
||||
prompt = ", ".join(trigger_words + [expanded_text])
|
||||
else:
|
||||
prompt = text
|
||||
prompt = expanded_text
|
||||
|
||||
from nodes import CLIPTextEncode # type: ignore
|
||||
|
||||
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||
return (conditioning, prompt)
|
||||
return (conditioning, prompt)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
|
||||
|
||||
|
||||
class TextLM:
|
||||
"""A simple text node with autocomplete support."""
|
||||
|
||||
NAME = "Text (LoraManager)"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = (
|
||||
"A simple text input node with autocomplete support for tags and styles."
|
||||
"A simple text input node with autocomplete support for tags, styles, and wildcard expansion."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -15,8 +20,17 @@ class TextLM:
|
||||
"AUTOCOMPLETE_TEXT_PROMPT,STRING",
|
||||
{
|
||||
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
|
||||
"placeholder": "Enter text... /char, /artist for quick tag search",
|
||||
"tooltip": "The text output.",
|
||||
"placeholder": "Enter text... /character, /artist, /wildcard for quick search",
|
||||
"tooltip": "The text output. Wildcard references inserted with /wildcard are expanded at runtime.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -24,10 +38,14 @@ class TextLM:
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("STRING",)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The text output.",
|
||||
)
|
||||
OUTPUT_TOOLTIPS = ("The text output.",)
|
||||
FUNCTION = "process"
|
||||
|
||||
def process(self, text: str):
|
||||
return (text,)
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, text: str, seed: int | None = None):
|
||||
if contains_dynamic_syntax(text) and seed is None:
|
||||
return float("NaN")
|
||||
return False
|
||||
|
||||
def process(self, text: str, seed: int | None = None):
|
||||
return (get_wildcard_service().expand_text(text, seed=seed),)
|
||||
|
||||
@@ -13,4 +13,5 @@ GEN_PARAM_KEYS = [
|
||||
'seed',
|
||||
'size',
|
||||
'clip_skip',
|
||||
'denoising_strength',
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
from .merger import GenParamsMerger
|
||||
from .base import RecipeMetadataParser
|
||||
from ..services.metadata_service import get_default_metadata_provider
|
||||
from ..utils.civitai_utils import extract_civitai_image_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,11 +39,12 @@ class RecipeEnricher:
|
||||
source_url = recipe.get("source_url") or recipe.get("source_path", "")
|
||||
|
||||
# Check if it's a Civitai image URL
|
||||
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
|
||||
if image_id_match:
|
||||
image_id = image_id_match.group(1)
|
||||
image_id = extract_civitai_image_id(str(source_url))
|
||||
if image_id:
|
||||
try:
|
||||
image_info = await civitai_client.get_image_info(image_id)
|
||||
image_info = await civitai_client.get_image_info(
|
||||
image_id, source_url=str(source_url)
|
||||
)
|
||||
if image_info:
|
||||
# Handle nested meta often found in Civitai API responses
|
||||
raw_meta = image_info.get("meta")
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import logging
|
||||
|
||||
from .constants import GEN_PARAM_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenParamsMerger:
|
||||
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||
|
||||
ALLOWED_KEYS = set(GEN_PARAM_KEYS)
|
||||
|
||||
BLACKLISTED_KEYS = {
|
||||
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
|
||||
"draft", "extra", "width", "height", "process", "quantity", "workflow",
|
||||
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
|
||||
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
|
||||
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
|
||||
"checkpoint", "checksum", "model_checksum"
|
||||
"checkpoint", "checksum", "model_checksum", "raw_metadata",
|
||||
}
|
||||
|
||||
|
||||
NORMALIZATION_MAPPING = {
|
||||
# Civitai specific
|
||||
"cfg": "cfg_scale",
|
||||
"cfgScale": "cfg_scale",
|
||||
"clipSkip": "clip_skip",
|
||||
"negativePrompt": "negative_prompt",
|
||||
# Case variations
|
||||
"Sampler": "sampler",
|
||||
"sampler_name": "sampler",
|
||||
"scheduler": "sampler",
|
||||
"Steps": "steps",
|
||||
"Seed": "seed",
|
||||
"Size": "size",
|
||||
@@ -36,63 +42,40 @@ class GenParamsMerger:
|
||||
def merge(
|
||||
request_params: Optional[Dict[str, Any]] = None,
|
||||
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||
embedded_metadata: Optional[Dict[str, Any]] = None
|
||||
embedded_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge generation parameters from three sources.
|
||||
|
||||
Priority: request_params > civitai_meta > embedded_metadata
|
||||
|
||||
Args:
|
||||
request_params: Params provided directly in the import request
|
||||
civitai_meta: Params from Civitai Image API 'meta' field
|
||||
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
||||
|
||||
Returns:
|
||||
Merged parameters dictionary
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# 1. Start with embedded metadata (lowest priority)
|
||||
Priority: request_params > civitai_meta > embedded_metadata
|
||||
"""
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
if embedded_metadata:
|
||||
# If it's a full recipe metadata, we use its gen_params
|
||||
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
||||
if "gen_params" in embedded_metadata and isinstance(
|
||||
embedded_metadata["gen_params"], dict
|
||||
):
|
||||
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
|
||||
else:
|
||||
# Otherwise assume the dict itself contains gen_params
|
||||
GenParamsMerger._update_normalized(result, embedded_metadata)
|
||||
|
||||
# 2. Layer Civitai meta (medium priority)
|
||||
if civitai_meta:
|
||||
GenParamsMerger._update_normalized(result, civitai_meta)
|
||||
|
||||
# 3. Layer request params (highest priority)
|
||||
if request_params:
|
||||
GenParamsMerger._update_normalized(result, request_params)
|
||||
|
||||
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
|
||||
final_result = {}
|
||||
for k, v in result.items():
|
||||
if k in GenParamsMerger.BLACKLISTED_KEYS:
|
||||
continue
|
||||
if k in GenParamsMerger.NORMALIZATION_MAPPING:
|
||||
continue
|
||||
final_result[k] = v
|
||||
|
||||
return final_result
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
||||
"""Update target dict with normalized keys from source."""
|
||||
for k, v in source.items():
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
|
||||
target[normalized_key] = v
|
||||
# Also keep the original key for now if it's not the same,
|
||||
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
|
||||
# Actually, if we rename it, we should probably NOT keep both in 'target'
|
||||
# because we want to filter them out at the end anyway.
|
||||
if normalized_key != k:
|
||||
# If we are overwriting an existing snake_case key with a camelCase one's value,
|
||||
# that's fine because of the priority order of calls to _update_normalized.
|
||||
pass
|
||||
target[k] = v
|
||||
"""Update target dict with normalized, persistence-safe keys from source."""
|
||||
for key, value in source.items():
|
||||
if key in GenParamsMerger.BLACKLISTED_KEYS:
|
||||
continue
|
||||
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||
continue
|
||||
|
||||
target[normalized_key] = value
|
||||
|
||||
@@ -42,6 +42,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
"height",
|
||||
"Model",
|
||||
"Model hash",
|
||||
"modelVersionIds",
|
||||
)
|
||||
return any(key in payload for key in civitai_image_fields)
|
||||
|
||||
@@ -429,6 +430,65 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Process modelVersionIds from Civitai image API
|
||||
# These are model version IDs returned at root level when meta doesn't contain resources
|
||||
if "modelVersionIds" in metadata and isinstance(
|
||||
metadata["modelVersionIds"], list
|
||||
):
|
||||
for version_id in metadata["modelVersionIds"]:
|
||||
version_id_str = str(version_id)
|
||||
|
||||
# Skip if we've already added this LoRA by version ID
|
||||
if version_id_str in added_loras:
|
||||
continue
|
||||
|
||||
# Initialize lora entry with version ID
|
||||
lora_entry = {
|
||||
"id": version_id,
|
||||
"modelId": 0,
|
||||
"name": "Unknown LoRA",
|
||||
"version": "",
|
||||
"type": "lora",
|
||||
"weight": 1.0,
|
||||
"existsLocally": False,
|
||||
"thumbnailUrl": "/loras_static/images/no-preview.png",
|
||||
"baseModel": "",
|
||||
"size": 0,
|
||||
"downloadUrl": "",
|
||||
"isDeleted": False,
|
||||
}
|
||||
|
||||
# Fetch model info from Civitai
|
||||
if metadata_provider and version_id_str:
|
||||
try:
|
||||
civitai_info = (
|
||||
await metadata_provider.get_model_version_info(
|
||||
version_id_str
|
||||
)
|
||||
)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching Civitai info for model version {version_id}: {e}"
|
||||
)
|
||||
|
||||
# Track this LoRA for deduplication
|
||||
if version_id_str:
|
||||
added_loras[version_id_str] = len(result["loras"])
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# If we found LoRA hashes in the metadata but haven't already
|
||||
# populated entries for them, fall back to creating LoRAs from
|
||||
# the hashes section. Some Civitai image responses only include
|
||||
|
||||
141
py/routes/handlers/base_model_handlers.py
Normal file
141
py/routes/handlers/base_model_handlers.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Handlers for base model related endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...services.civitai_base_model_service import get_civitai_base_model_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseModelHandlerSet:
|
||||
"""Collection of handlers for base model operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_model_service_factory: Callable[[], Any] = get_civitai_base_model_service,
|
||||
) -> None:
|
||||
self._base_model_service_factory = base_model_service_factory
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
) -> Dict[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
"""Return mapping of route names to handler methods."""
|
||||
return {
|
||||
"get_base_models": self.get_base_models,
|
||||
"refresh_base_models": self.refresh_base_models,
|
||||
"get_base_model_categories": self.get_base_model_categories,
|
||||
"get_base_model_cache_status": self.get_base_model_cache_status,
|
||||
}
|
||||
|
||||
async def get_base_models(self, request: web.Request) -> web.Response:
|
||||
"""Get merged base models (hardcoded + remote from Civitai).
|
||||
|
||||
Query Parameters:
|
||||
refresh: If 'true', force refresh from API
|
||||
|
||||
Returns:
|
||||
JSON response with:
|
||||
- models: List of base model names
|
||||
- source: 'cache', 'api', or 'fallback'
|
||||
- last_updated: ISO timestamp
|
||||
- counts: hardcoded_count, remote_count, merged_count
|
||||
"""
|
||||
try:
|
||||
service = await self._base_model_service_factory()
|
||||
|
||||
# Check for refresh parameter
|
||||
force_refresh = request.query.get("refresh", "").lower() == "true"
|
||||
|
||||
result = await service.get_base_models(force_refresh=force_refresh)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"data": result,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_base_models: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def refresh_base_models(self, request: web.Request) -> web.Response:
|
||||
"""Force refresh base models from Civitai API.
|
||||
|
||||
Returns:
|
||||
JSON response with refreshed data
|
||||
"""
|
||||
try:
|
||||
service = await self._base_model_service_factory()
|
||||
result = await service.refresh_cache()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"data": result,
|
||||
"message": "Base models cache refreshed successfully",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in refresh_base_models: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_base_model_categories(self, request: web.Request) -> web.Response:
|
||||
"""Get categorized base models.
|
||||
|
||||
Returns:
|
||||
JSON response with categorized models
|
||||
"""
|
||||
try:
|
||||
service = await self._base_model_service_factory()
|
||||
categories = service.get_model_categories()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"data": categories,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_base_model_categories: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_base_model_cache_status(self, request: web.Request) -> web.Response:
|
||||
"""Get cache status for base models.
|
||||
|
||||
Returns:
|
||||
JSON response with cache status
|
||||
"""
|
||||
try:
|
||||
service = await self._base_model_service_factory()
|
||||
status = service.get_cache_status()
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"data": status,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_base_model_cache_status: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e)},
|
||||
status=500,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ from ...services.download_coordinator import DownloadCoordinator
|
||||
from ...services.metadata_sync_service import MetadataSyncService
|
||||
from ...services.model_file_service import ModelMoveService
|
||||
from ...services.preview_asset_service import PreviewAssetService
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
from ...services.settings_manager import SettingsManager, get_settings_manager
|
||||
from ...services.tag_update_service import TagUpdateService
|
||||
from ...services.use_cases import (
|
||||
@@ -64,7 +65,6 @@ class ModelPageView:
|
||||
self._settings = settings_service
|
||||
self._server_i18n = server_i18n
|
||||
self._logger = logger
|
||||
self._app_version = self._get_app_version()
|
||||
|
||||
def _load_supporters(self) -> dict:
|
||||
"""Load supporters data from JSON file."""
|
||||
@@ -155,7 +155,7 @@ class ModelPageView:
|
||||
"request": request,
|
||||
"folders": [],
|
||||
"t": self._server_i18n.get_translation,
|
||||
"version": self._app_version,
|
||||
"version": self._get_app_version(),
|
||||
}
|
||||
|
||||
if not is_initializing:
|
||||
@@ -224,6 +224,42 @@ class ModelListingHandler:
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def get_excluded_models(self, request: web.Request) -> web.Response:
|
||||
start_time = time.perf_counter()
|
||||
try:
|
||||
params = self._parse_common_params(request)
|
||||
result = await self._service.get_excluded_paginated_data(**params)
|
||||
|
||||
format_start = time.perf_counter()
|
||||
formatted_result = {
|
||||
"items": [
|
||||
await self._service.format_response(item)
|
||||
for item in result["items"]
|
||||
],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
"total_pages": result["total_pages"],
|
||||
}
|
||||
format_duration = time.perf_counter() - format_start
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
self._logger.debug(
|
||||
"Request for %s/excluded took %.3fs (formatting: %.3fs)",
|
||||
self._service.model_type,
|
||||
duration,
|
||||
format_duration,
|
||||
)
|
||||
return web.json_response(formatted_result)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error retrieving excluded %ss: %s",
|
||||
self._service.model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
def _parse_common_params(self, request: web.Request) -> Dict:
|
||||
page = int(request.query.get("page", "1"))
|
||||
page_size = min(int(request.query.get("page_size", "20")), 100)
|
||||
@@ -392,6 +428,21 @@ class ModelManagementHandler:
|
||||
self._logger.error("Error excluding model: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
|
||||
async def unexclude_model(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get("file_path")
|
||||
if not file_path:
|
||||
return web.Response(text="Model path is required", status=400)
|
||||
|
||||
result = await self._lifecycle_service.unexclude_model(file_path)
|
||||
return web.json_response(result)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error restoring model: %s", exc, exc_info=True)
|
||||
return web.Response(text=str(exc), status=500)
|
||||
|
||||
async def fetch_civitai(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -1532,6 +1583,20 @@ class ModelCivitaiHandler:
|
||||
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
version_index = cache.version_index
|
||||
downloaded_version_ids: set[int] = set()
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
downloaded_version_ids = set(
|
||||
await history_service.get_downloaded_version_ids(
|
||||
self._service.model_type,
|
||||
model_id,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.debug(
|
||||
"Failed to load download history for CivitAI versions: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
for version in versions:
|
||||
version_id = None
|
||||
@@ -1548,6 +1613,9 @@ class ModelCivitaiHandler:
|
||||
else None
|
||||
)
|
||||
version["existsLocally"] = cache_entry is not None
|
||||
version["hasBeenDownloaded"] = (
|
||||
version_id in downloaded_version_ids if version_id is not None else False
|
||||
)
|
||||
if cache_entry and isinstance(cache_entry, Mapping):
|
||||
local_path = cache_entry.get("file_path")
|
||||
if local_path:
|
||||
@@ -2266,7 +2334,7 @@ class ModelUpdateHandler:
|
||||
self,
|
||||
record,
|
||||
*,
|
||||
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
|
||||
version_context: Optional[Dict[int, Dict[str, Any]]] = None,
|
||||
) -> Dict:
|
||||
context = version_context or {}
|
||||
# Check user setting for hiding early access versions
|
||||
@@ -2295,7 +2363,7 @@ class ModelUpdateHandler:
|
||||
|
||||
@staticmethod
|
||||
def _serialize_version(
|
||||
version, context: Optional[Dict[str, Optional[str]]]
|
||||
version, context: Optional[Dict[str, Any]]
|
||||
) -> Dict:
|
||||
context = context or {}
|
||||
preview_override = context.get("preview_override")
|
||||
@@ -2329,6 +2397,7 @@ class ModelUpdateHandler:
|
||||
"sizeBytes": version.size_bytes,
|
||||
"previewUrl": preview_url,
|
||||
"isInLibrary": version.is_in_library,
|
||||
"hasBeenDownloaded": bool(context.get("has_been_downloaded", False)),
|
||||
"shouldIgnore": version.should_ignore,
|
||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||
"isEarlyAccess": is_early_access,
|
||||
@@ -2338,8 +2407,31 @@ class ModelUpdateHandler:
|
||||
|
||||
async def _build_version_context(
|
||||
self, record
|
||||
) -> Dict[int, Dict[str, Optional[str]]]:
|
||||
context: Dict[int, Dict[str, Optional[str]]] = {}
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
context: Dict[int, Dict[str, Any]] = {}
|
||||
downloaded_version_ids: set[int] = set()
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
downloaded_version_ids = set(
|
||||
await history_service.get_downloaded_version_ids(
|
||||
record.model_type,
|
||||
record.model_id,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.debug(
|
||||
"Failed to load download history while building version context: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
for version in record.versions:
|
||||
context[version.version_id] = {
|
||||
"file_path": None,
|
||||
"file_name": None,
|
||||
"preview_override": None,
|
||||
"has_been_downloaded": version.version_id in downloaded_version_ids,
|
||||
}
|
||||
|
||||
try:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
@@ -2358,16 +2450,21 @@ class ModelUpdateHandler:
|
||||
cache_entry = version_index.get(version.version_id)
|
||||
if isinstance(cache_entry, Mapping):
|
||||
preview = cache_entry.get("preview_url")
|
||||
context_entry: Dict[str, Optional[str]] = {
|
||||
"file_path": cache_entry.get("file_path"),
|
||||
"file_name": cache_entry.get("file_name"),
|
||||
"preview_override": None,
|
||||
}
|
||||
context_entry = context.setdefault(
|
||||
version.version_id,
|
||||
{
|
||||
"file_path": None,
|
||||
"file_name": None,
|
||||
"preview_override": None,
|
||||
"has_been_downloaded": version.version_id in downloaded_version_ids,
|
||||
},
|
||||
)
|
||||
context_entry["file_path"] = cache_entry.get("file_path")
|
||||
context_entry["file_name"] = cache_entry.get("file_name")
|
||||
if isinstance(preview, str) and preview:
|
||||
context_entry["preview_override"] = config.get_preview_static_url(
|
||||
preview
|
||||
)
|
||||
context[version.version_id] = context_entry
|
||||
return context
|
||||
|
||||
|
||||
@@ -2391,8 +2488,10 @@ class ModelHandlerSet:
|
||||
return {
|
||||
"handle_models_page": self.page_view.handle,
|
||||
"get_models": self.listing.get_models,
|
||||
"get_excluded_models": self.listing.get_excluded_models,
|
||||
"delete_model": self.management.delete_model,
|
||||
"exclude_model": self.management.exclude_model,
|
||||
"unexclude_model": self.management.unexclude_model,
|
||||
"fetch_civitai": self.management.fetch_civitai,
|
||||
"fetch_all_civitai": self.civitai.fetch_all_civitai,
|
||||
"relink_civitai": self.management.relink_civitai,
|
||||
|
||||
@@ -26,7 +26,7 @@ from ...services.recipes import (
|
||||
RecipeValidationError,
|
||||
)
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||
from ...utils.exif_utils import ExifUtils
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
@@ -81,6 +81,7 @@ class RecipeHandlerSet:
|
||||
"bulk_delete": self.management.bulk_delete,
|
||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
|
||||
"scan_recipes": self.query.scan_recipes,
|
||||
"move_recipe": self.management.move_recipe,
|
||||
"repair_recipes": self.management.repair_recipes,
|
||||
@@ -218,6 +219,7 @@ class RecipeListingHandler:
|
||||
filters["tags"] = tag_filters
|
||||
|
||||
lora_hash = request.query.get("lora_hash")
|
||||
checkpoint_hash = request.query.get("checkpoint_hash")
|
||||
|
||||
result = await recipe_scanner.get_paginated_data(
|
||||
page=page,
|
||||
@@ -227,6 +229,7 @@ class RecipeListingHandler:
|
||||
filters=filters,
|
||||
search_options=search_options,
|
||||
lora_hash=lora_hash,
|
||||
checkpoint_hash=checkpoint_hash,
|
||||
folder=folder,
|
||||
recursive=recursive,
|
||||
)
|
||||
@@ -423,6 +426,28 @@ class RecipeQueryHandler:
|
||||
self._logger.error("Error getting recipes for Lora: %s", exc)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_recipes_for_checkpoint(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
checkpoint_hash = request.query.get("hash")
|
||||
if not checkpoint_hash:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Checkpoint hash is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
matching_recipes = await recipe_scanner.get_recipes_for_checkpoint(
|
||||
checkpoint_hash
|
||||
)
|
||||
return web.json_response({"success": True, "recipes": matching_recipes})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting recipes for checkpoint: %s", exc)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def scan_recipes(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -731,6 +756,14 @@ class RecipeManagementHandler:
|
||||
)
|
||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||
|
||||
self._logger.info(
|
||||
"Remote recipe import received: url=%s, request_gen_params_keys=%s, lora_count=%d, checkpoint_keys=%s",
|
||||
image_url,
|
||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||
len(lora_entries),
|
||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||
)
|
||||
|
||||
# 2. Initial Metadata Construction
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": params.get("base_model", "") or "",
|
||||
@@ -1163,13 +1196,15 @@ class RecipeManagementHandler:
|
||||
temp_path = temp_file.name
|
||||
download_url = image_url
|
||||
image_info = None
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
||||
if civitai_match:
|
||||
civitai_image_id = extract_civitai_image_id(image_url)
|
||||
if civitai_image_id:
|
||||
if civitai_client is None:
|
||||
raise RecipeDownloadError(
|
||||
"Civitai client unavailable for image download"
|
||||
)
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=image_url
|
||||
)
|
||||
if not image_info:
|
||||
raise RecipeDownloadError(
|
||||
"Failed to fetch image information from Civitai"
|
||||
@@ -1203,7 +1238,7 @@ class RecipeManagementHandler:
|
||||
return (
|
||||
file_obj.read(),
|
||||
extension,
|
||||
image_info.get("meta") if civitai_match and image_info else None,
|
||||
image_info.get("meta") if civitai_image_id and image_info else None,
|
||||
)
|
||||
except RecipeDownloadError:
|
||||
raise
|
||||
|
||||
@@ -22,11 +22,16 @@ class RouteDefinition:
|
||||
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
|
||||
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
|
||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
|
||||
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||
@@ -37,6 +42,21 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/model-version-download-status",
|
||||
"get_model_version_download_status",
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST",
|
||||
"/api/lm/model-version-download-status",
|
||||
"set_model_version_download_status",
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/set-model-version-download-status",
|
||||
"set_model_version_download_status",
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-metadata-archive", "download_metadata_archive"
|
||||
@@ -47,6 +67,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/backup/status", "get_backup_status"),
|
||||
RouteDefinition("POST", "/api/lm/backup/export", "export_backup"),
|
||||
RouteDefinition("POST", "/api/lm/backup/import", "import_backup"),
|
||||
RouteDefinition("POST", "/api/lm/backup/open-location", "open_backup_location"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/model-versions-status", "get_model_versions_status"
|
||||
),
|
||||
@@ -56,6 +80,15 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/example-workflows/{filename}", "get_example_workflow"
|
||||
),
|
||||
# Base model management routes
|
||||
RouteDefinition("GET", "/api/lm/base-models", "get_base_models"),
|
||||
RouteDefinition("POST", "/api/lm/base-models/refresh", "refresh_base_models"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/base-models/categories", "get_base_model_categories"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -19,10 +19,12 @@ from ..services.downloader import get_downloader
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from .handlers.misc_handlers import (
|
||||
CustomWordsHandler,
|
||||
DoctorHandler,
|
||||
ExampleWorkflowsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
BackupHandler,
|
||||
MetadataArchiveHandler,
|
||||
MiscHandlerSet,
|
||||
ModelExampleFilesHandler,
|
||||
@@ -33,8 +35,10 @@ from .handlers.misc_handlers import (
|
||||
SupportersHandler,
|
||||
TrainedWordsHandler,
|
||||
UsageStatsHandler,
|
||||
WildcardsHandler,
|
||||
build_service_registry_adapter,
|
||||
)
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -115,6 +119,7 @@ class MiscRoutes:
|
||||
settings_service=self._settings,
|
||||
metadata_provider_updater=self._metadata_provider_updater,
|
||||
)
|
||||
backup = BackupHandler()
|
||||
filesystem = FileSystemHandler(settings_service=self._settings)
|
||||
node_registry_handler = NodeRegistryHandler(
|
||||
node_registry=self._node_registry,
|
||||
@@ -126,8 +131,11 @@ class MiscRoutes:
|
||||
metadata_provider_factory=self._metadata_provider_factory,
|
||||
)
|
||||
custom_words = CustomWordsHandler()
|
||||
wildcards = WildcardsHandler()
|
||||
supporters = SupportersHandler()
|
||||
doctor = DoctorHandler(settings_service=self._settings)
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
base_model = BaseModelHandlerSet()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -139,10 +147,14 @@ class MiscRoutes:
|
||||
node_registry=node_registry_handler,
|
||||
model_library=model_library,
|
||||
metadata_archive=metadata_archive,
|
||||
backup=backup,
|
||||
filesystem=filesystem,
|
||||
custom_words=custom_words,
|
||||
wildcards=wildcards,
|
||||
supporters=supporters,
|
||||
doctor=doctor,
|
||||
example_workflows=example_workflows,
|
||||
base_model=base_model,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ class RouteDefinition:
|
||||
|
||||
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
|
||||
|
||||
@@ -51,6 +51,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||
|
||||
411
py/services/backup_service.py
Normal file
411
py/services/backup_service.py
Normal file
@@ -0,0 +1,411 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from ..utils.cache_paths import CacheType, get_cache_base_dir, get_cache_file_path
|
||||
from ..utils.settings_paths import get_settings_dir
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
BACKUP_MANIFEST_VERSION = 1
|
||||
DEFAULT_BACKUP_RETENTION_COUNT = 5
|
||||
DEFAULT_BACKUP_INTERVAL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackupEntry:
|
||||
kind: str
|
||||
archive_path: str
|
||||
target_path: str
|
||||
sha256: str
|
||||
size: int
|
||||
mtime: float
|
||||
|
||||
|
||||
class BackupService:
|
||||
"""Create and restore user-state backup archives."""
|
||||
|
||||
_instance: "BackupService | None" = None
|
||||
_instance_lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, *, settings_manager=None, backup_dir: str | None = None) -> None:
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._backup_dir = Path(backup_dir or self._resolve_backup_dir())
|
||||
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = asyncio.Lock()
|
||||
self._auto_task: asyncio.Task[None] | None = None
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "BackupService":
|
||||
async with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
cls._instance._ensure_auto_snapshot_task()
|
||||
return cls._instance
|
||||
|
||||
@staticmethod
|
||||
def _resolve_backup_dir() -> str:
|
||||
return os.path.join(get_settings_dir(create=True), "backups")
|
||||
|
||||
def get_backup_dir(self) -> str:
|
||||
return str(self._backup_dir)
|
||||
|
||||
def _ensure_auto_snapshot_task(self) -> None:
|
||||
if self._auto_task is not None and not self._auto_task.done():
|
||||
return
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
self._auto_task = loop.create_task(self._auto_backup_loop())
|
||||
|
||||
def _get_setting_bool(self, key: str, default: bool) -> bool:
|
||||
try:
|
||||
return bool(self._settings.get(key, default))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _get_setting_int(self, key: str, default: int) -> int:
|
||||
try:
|
||||
value = self._settings.get(key, default)
|
||||
return max(1, int(value))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _settings_file_path(self) -> str:
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
return str(settings_file)
|
||||
return os.path.join(get_settings_dir(create=True), "settings.json")
|
||||
|
||||
def _download_history_path(self) -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
history_dir = os.path.join(base_dir, "download_history")
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
return os.path.join(history_dir, "downloaded_versions.sqlite")
|
||||
|
||||
def _model_update_dir(self) -> str:
|
||||
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent)
|
||||
|
||||
def _model_update_targets(self) -> list[tuple[str, str, str]]:
|
||||
"""Return (kind, archive_path, target_path) tuples for backup."""
|
||||
|
||||
targets: list[tuple[str, str, str]] = []
|
||||
|
||||
settings_path = self._settings_file_path()
|
||||
targets.append(("settings", "settings/settings.json", settings_path))
|
||||
|
||||
history_path = self._download_history_path()
|
||||
targets.append(
|
||||
(
|
||||
"download_history",
|
||||
"cache/download_history/downloaded_versions.sqlite",
|
||||
history_path,
|
||||
)
|
||||
)
|
||||
|
||||
symlink_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||
targets.append(
|
||||
(
|
||||
"symlink_map",
|
||||
"cache/symlink/symlink_map.json",
|
||||
symlink_path,
|
||||
)
|
||||
)
|
||||
|
||||
model_update_dir = Path(self._model_update_dir())
|
||||
if model_update_dir.exists():
|
||||
for sqlite_file in sorted(model_update_dir.glob("*.sqlite")):
|
||||
targets.append(
|
||||
(
|
||||
"model_update",
|
||||
f"cache/model_update/{sqlite_file.name}",
|
||||
str(sqlite_file),
|
||||
)
|
||||
)
|
||||
|
||||
return targets
|
||||
|
||||
@staticmethod
|
||||
def _hash_file(path: str) -> tuple[str, int, float]:
|
||||
digest = hashlib.sha256()
|
||||
total = 0
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
total += len(chunk)
|
||||
digest.update(chunk)
|
||||
mtime = os.path.getmtime(path)
|
||||
return digest.hexdigest(), total, mtime
|
||||
|
||||
def _build_manifest(self, entries: Iterable[BackupEntry], *, snapshot_type: str) -> dict[str, Any]:
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
active_library = None
|
||||
try:
|
||||
active_library = self._settings.get_active_library_name()
|
||||
except Exception:
|
||||
active_library = None
|
||||
|
||||
return {
|
||||
"manifest_version": BACKUP_MANIFEST_VERSION,
|
||||
"created_at": created_at,
|
||||
"snapshot_type": snapshot_type,
|
||||
"active_library": active_library,
|
||||
"files": [
|
||||
{
|
||||
"kind": entry.kind,
|
||||
"archive_path": entry.archive_path,
|
||||
"target_path": entry.target_path,
|
||||
"sha256": entry.sha256,
|
||||
"size": entry.size,
|
||||
"mtime": entry.mtime,
|
||||
}
|
||||
for entry in entries
|
||||
],
|
||||
}
|
||||
|
||||
def _write_archive(self, archive_path: str, entries: list[BackupEntry], manifest: dict[str, Any]) -> None:
|
||||
with zipfile.ZipFile(
|
||||
archive_path,
|
||||
mode="w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6,
|
||||
) as zf:
|
||||
zf.writestr(
|
||||
"manifest.json",
|
||||
json.dumps(manifest, indent=2, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
for entry in entries:
|
||||
zf.write(entry.target_path, arcname=entry.archive_path)
|
||||
|
||||
async def create_snapshot(self, *, snapshot_type: str = "manual", persist: bool = False) -> dict[str, Any]:
|
||||
"""Create a backup archive.
|
||||
|
||||
If ``persist`` is true, the archive is stored in the backup directory
|
||||
and retained according to the configured retention policy.
|
||||
"""
|
||||
|
||||
async with self._lock:
|
||||
raw_targets = self._model_update_targets()
|
||||
entries: list[BackupEntry] = []
|
||||
for kind, archive_path, target_path in raw_targets:
|
||||
if not os.path.exists(target_path):
|
||||
continue
|
||||
sha256, size, mtime = self._hash_file(target_path)
|
||||
entries.append(
|
||||
BackupEntry(
|
||||
kind=kind,
|
||||
archive_path=archive_path,
|
||||
target_path=target_path,
|
||||
sha256=sha256,
|
||||
size=size,
|
||||
mtime=mtime,
|
||||
)
|
||||
)
|
||||
|
||||
if not entries:
|
||||
raise FileNotFoundError("No backupable files were found")
|
||||
|
||||
manifest = self._build_manifest(entries, snapshot_type=snapshot_type)
|
||||
archive_name = self._build_archive_name(snapshot_type=snapshot_type)
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".zip", dir=str(self._backup_dir))
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
self._write_archive(temp_path, entries, manifest)
|
||||
if persist:
|
||||
final_path = self._backup_dir / archive_name
|
||||
os.replace(temp_path, final_path)
|
||||
self._prune_snapshots()
|
||||
return {
|
||||
"archive_path": str(final_path),
|
||||
"archive_name": final_path.name,
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
with open(temp_path, "rb") as handle:
|
||||
data = handle.read()
|
||||
return {
|
||||
"archive_name": archive_name,
|
||||
"archive_bytes": data,
|
||||
"manifest": manifest,
|
||||
}
|
||||
finally:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.remove(temp_path)
|
||||
|
||||
def _build_archive_name(self, *, snapshot_type: str) -> str:
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return f"lora-manager-backup-{timestamp}-{snapshot_type}.zip"
|
||||
|
||||
def _prune_snapshots(self) -> None:
|
||||
retention = self._get_setting_int(
|
||||
"backup_retention_count", DEFAULT_BACKUP_RETENTION_COUNT
|
||||
)
|
||||
archives = sorted(
|
||||
self._backup_dir.glob("lora-manager-backup-*-auto.zip"),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in archives[retention:]:
|
||||
with contextlib.suppress(OSError):
|
||||
path.unlink()
|
||||
|
||||
async def restore_snapshot(self, archive_path: str) -> dict[str, Any]:
|
||||
"""Restore backup contents from a ZIP archive."""
|
||||
|
||||
async with self._lock:
|
||||
try:
|
||||
zf = zipfile.ZipFile(archive_path, mode="r")
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ValueError("Backup archive is not a valid ZIP file") from exc
|
||||
|
||||
with zf:
|
||||
try:
|
||||
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
except KeyError as exc:
|
||||
raise ValueError("Backup archive is missing manifest.json") from exc
|
||||
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("Backup manifest is invalid")
|
||||
if manifest.get("manifest_version") != BACKUP_MANIFEST_VERSION:
|
||||
raise ValueError("Backup manifest version is not supported")
|
||||
|
||||
files = manifest.get("files", [])
|
||||
if not isinstance(files, list):
|
||||
raise ValueError("Backup manifest file list is invalid")
|
||||
|
||||
extracted_paths: list[tuple[str, str]] = []
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="lora-manager-restore-"))
|
||||
try:
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
archive_member = item.get("archive_path")
|
||||
if not isinstance(archive_member, str) or not archive_member:
|
||||
continue
|
||||
archive_member_path = Path(archive_member)
|
||||
if archive_member_path.is_absolute() or ".." in archive_member_path.parts:
|
||||
raise ValueError(f"Invalid archive member path: {archive_member}")
|
||||
|
||||
kind = item.get("kind")
|
||||
target_path = self._resolve_restore_target(kind, archive_member)
|
||||
if target_path is None:
|
||||
continue
|
||||
|
||||
extracted_path = temp_dir / archive_member_path
|
||||
extracted_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(archive_member) as source, open(
|
||||
extracted_path, "wb"
|
||||
) as destination:
|
||||
shutil.copyfileobj(source, destination)
|
||||
|
||||
expected_hash = item.get("sha256")
|
||||
if isinstance(expected_hash, str) and expected_hash:
|
||||
actual_hash, _, _ = self._hash_file(str(extracted_path))
|
||||
if actual_hash != expected_hash:
|
||||
raise ValueError(
|
||||
f"Checksum mismatch for {archive_member}"
|
||||
)
|
||||
|
||||
extracted_paths.append((str(extracted_path), target_path))
|
||||
|
||||
for extracted_path, target_path in extracted_paths:
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
os.replace(extracted_path, target_path)
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"restored_files": len(extracted_paths),
|
||||
"snapshot_type": manifest.get("snapshot_type"),
|
||||
}
|
||||
|
||||
def _resolve_restore_target(self, kind: Any, archive_member: str) -> str | None:
|
||||
if kind == "settings":
|
||||
return self._settings_file_path()
|
||||
if kind == "download_history":
|
||||
return self._download_history_path()
|
||||
if kind == "symlink_map":
|
||||
return get_cache_file_path(CacheType.SYMLINK, create_dir=True)
|
||||
if kind == "model_update":
|
||||
filename = os.path.basename(archive_member)
|
||||
return str(Path(get_cache_file_path(CacheType.MODEL_UPDATE, create_dir=True)).parent / filename)
|
||||
return None
|
||||
|
||||
async def create_auto_snapshot_if_due(self) -> Optional[dict[str, Any]]:
|
||||
if not self._get_setting_bool("backup_auto_enabled", True):
|
||||
return None
|
||||
|
||||
latest = self.get_latest_auto_snapshot()
|
||||
now = time.time()
|
||||
if latest and now - latest["mtime"] < DEFAULT_BACKUP_INTERVAL_SECONDS:
|
||||
return None
|
||||
|
||||
return await self.create_snapshot(snapshot_type="auto", persist=True)
|
||||
|
||||
async def _auto_backup_loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self.create_auto_snapshot_if_due()
|
||||
await asyncio.sleep(DEFAULT_BACKUP_INTERVAL_SECONDS)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning("Automatic backup snapshot failed: %s", exc, exc_info=True)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
def get_available_snapshots(self) -> list[dict[str, Any]]:
|
||||
snapshots: list[dict[str, Any]] = []
|
||||
for path in sorted(self._backup_dir.glob("lora-manager-backup-*.zip")):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
snapshots.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"path": str(path),
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"is_auto": path.name.endswith("-auto.zip"),
|
||||
}
|
||||
)
|
||||
snapshots.sort(key=lambda item: item["mtime"], reverse=True)
|
||||
return snapshots
|
||||
|
||||
def get_latest_auto_snapshot(self) -> Optional[dict[str, Any]]:
|
||||
autos = [snapshot for snapshot in self.get_available_snapshots() if snapshot["is_auto"]]
|
||||
if not autos:
|
||||
return None
|
||||
return autos[0]
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
snapshots = self.get_available_snapshots()
|
||||
return {
|
||||
"backupDir": self.get_backup_dir(),
|
||||
"enabled": self._get_setting_bool("backup_auto_enabled", True),
|
||||
"retentionCount": self._get_setting_int(
|
||||
"backup_retention_count", DEFAULT_BACKUP_RETENTION_COUNT
|
||||
),
|
||||
"snapshotCount": len(snapshots),
|
||||
"latestSnapshot": snapshots[0] if snapshots else None,
|
||||
"latestAutoSnapshot": self.get_latest_auto_snapshot(),
|
||||
}
|
||||
@@ -20,6 +20,7 @@ from .model_query import (
|
||||
resolve_sub_type,
|
||||
)
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.civitai_utils import build_civitai_model_page_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -178,6 +179,57 @@ class BaseModelService(ABC):
|
||||
)
|
||||
return paginated
|
||||
|
||||
async def get_excluded_paginated_data(
|
||||
self,
|
||||
page: int,
|
||||
page_size: int,
|
||||
sort_by: str = "name",
|
||||
search: str = None,
|
||||
fuzzy_search: bool = False,
|
||||
search_options: dict = None,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
"""Get paginated excluded model data."""
|
||||
excluded_paths = list(self.scanner.get_excluded_models())
|
||||
excluded_entries: List[Dict[str, Any]] = []
|
||||
stale_paths: List[str] = []
|
||||
|
||||
for file_path in excluded_paths:
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
stale_paths.append(file_path)
|
||||
continue
|
||||
|
||||
entry = await self._build_excluded_entry(file_path)
|
||||
if entry:
|
||||
excluded_entries.append(entry)
|
||||
else:
|
||||
stale_paths.append(file_path)
|
||||
|
||||
if stale_paths:
|
||||
current_excluded = getattr(self.scanner, "_excluded_models", None)
|
||||
if isinstance(current_excluded, list):
|
||||
stale_set = set(stale_paths)
|
||||
self.scanner._excluded_models = [
|
||||
path for path in current_excluded if path not in stale_set
|
||||
]
|
||||
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
|
||||
if callable(persist_current_cache):
|
||||
await persist_current_cache()
|
||||
|
||||
excluded_entries = self._sort_entries(excluded_entries, sort_by)
|
||||
|
||||
if search:
|
||||
excluded_entries = await self._apply_search_filters(
|
||||
excluded_entries,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
)
|
||||
|
||||
paginated = self._paginate(excluded_entries, page, page_size)
|
||||
paginated["items"] = await self._annotate_update_flags(paginated["items"])
|
||||
return paginated
|
||||
|
||||
async def _fetch_with_usage_sort(self, sort_params):
|
||||
"""Fetch data sorted by usage count (desc/asc)."""
|
||||
cache = await self.cache_repository.get_cache()
|
||||
@@ -217,6 +269,62 @@ class BaseModelService(ABC):
|
||||
)
|
||||
return annotated
|
||||
|
||||
def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
|
||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||
key_name = sort_params.key
|
||||
|
||||
if key_name == "date":
|
||||
key_fn = lambda item: (
|
||||
float(item.get("modified", 0.0) or 0.0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
elif key_name == "size":
|
||||
key_fn = lambda item: (
|
||||
int(item.get("size", 0) or 0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
elif key_name == "usage":
|
||||
key_fn = lambda item: (
|
||||
int(item.get("usage_count", 0) or 0),
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
else:
|
||||
key_fn = lambda item: (
|
||||
(item.get("model_name") or item.get("file_name") or "").lower(),
|
||||
item.get("file_path", "").lower(),
|
||||
)
|
||||
|
||||
return sorted(data, key=key_fn, reverse=sort_params.order == "desc")
|
||||
|
||||
async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
|
||||
root_path = self.scanner._find_root_for_file(file_path)
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path,
|
||||
self.metadata_class,
|
||||
)
|
||||
if should_skip:
|
||||
return None
|
||||
|
||||
if metadata is None:
|
||||
metadata = await self.scanner._create_default_metadata(file_path)
|
||||
if metadata is None:
|
||||
return None
|
||||
|
||||
metadata = self.scanner.adjust_metadata(metadata, file_path, root_path)
|
||||
folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace(
|
||||
os.path.sep, "/"
|
||||
)
|
||||
entry = self.scanner._build_cache_entry(metadata, folder=folder)
|
||||
entry = self.scanner.adjust_cached_entry(entry)
|
||||
entry["exclude"] = True
|
||||
return entry
|
||||
|
||||
async def _apply_hash_filters(
|
||||
self, data: List[Dict], hash_filters: Dict
|
||||
) -> List[Dict]:
|
||||
@@ -774,9 +882,12 @@ class BaseModelService(ABC):
|
||||
version_id = civitai_data.get("id")
|
||||
|
||||
if model_id:
|
||||
civitai_url = f"https://civitai.com/models/{model_id}"
|
||||
if version_id:
|
||||
civitai_url += f"?modelVersionId={version_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,
|
||||
|
||||
@@ -105,12 +105,18 @@ class CheckpointScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
# Load current metadata
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if metadata is None:
|
||||
logger.error(f"No metadata found for {file_path}")
|
||||
return None
|
||||
if should_skip:
|
||||
logger.error(f"Invalid metadata found for {file_path}")
|
||||
return None
|
||||
created_metadata = await self._create_default_metadata(file_path)
|
||||
if created_metadata is None:
|
||||
logger.error(f"No metadata found for {file_path}")
|
||||
return None
|
||||
metadata = created_metadata
|
||||
|
||||
# Check if hash is already calculated
|
||||
if metadata.hash_status == "completed" and metadata.sha256:
|
||||
|
||||
@@ -42,6 +42,7 @@ class CheckpointService(BaseModelService):
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
|
||||
430
py/services/civitai_base_model_service.py
Normal file
430
py/services/civitai_base_model_service.py
Normal file
@@ -0,0 +1,430 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.constants import SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS
|
||||
from .downloader import get_downloader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CivitaiBaseModelService:
|
||||
"""Service for fetching and managing Civitai base models.
|
||||
|
||||
This service provides:
|
||||
- Fetching base models from Civitai API
|
||||
- Caching with TTL (7 days default)
|
||||
- Merging hardcoded and remote base models
|
||||
- Generating abbreviations for new/unknown models
|
||||
"""
|
||||
|
||||
_instance: Optional[CivitaiBaseModelService] = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
# Default TTL for cache in seconds (7 days)
|
||||
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
|
||||
|
||||
# Civitai API endpoint for enums
|
||||
CIVITAI_ENUMS_URL = "https://civitai.red/api/v1/enums"
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> CivitaiBaseModelService:
|
||||
"""Get singleton instance of the service."""
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the service."""
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
# Cache storage
|
||||
self._cache: Optional[Dict[str, Any]] = None
|
||||
self._cache_timestamp: Optional[datetime] = None
|
||||
self._cache_ttl = self.DEFAULT_CACHE_TTL
|
||||
|
||||
# Hardcoded models for fallback
|
||||
self._hardcoded_models = set(SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS)
|
||||
|
||||
logger.info("CivitaiBaseModelService initialized")
|
||||
|
||||
async def get_base_models(self, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
"""Get merged base models (hardcoded + remote).
|
||||
|
||||
Args:
|
||||
force_refresh: If True, fetch from API regardless of cache state.
|
||||
|
||||
Returns:
|
||||
Dictionary containing:
|
||||
- models: List of merged base model names
|
||||
- source: 'cache', 'api', or 'fallback'
|
||||
- last_updated: ISO timestamp of last successful API fetch
|
||||
- hardcoded_count: Number of hardcoded models
|
||||
- remote_count: Number of remote models
|
||||
- merged_count: Total unique models
|
||||
"""
|
||||
# Check if cache is valid
|
||||
if not force_refresh and self._is_cache_valid():
|
||||
logger.debug("Returning cached base models")
|
||||
return self._build_response("cache")
|
||||
|
||||
# Try to fetch from API
|
||||
try:
|
||||
remote_models = await self._fetch_from_civitai()
|
||||
if remote_models:
|
||||
self._update_cache(remote_models)
|
||||
return self._build_response("api")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch base models from Civitai: {e}")
|
||||
|
||||
# Fallback to hardcoded models
|
||||
return self._build_response("fallback")
|
||||
|
||||
async def refresh_cache(self) -> Dict[str, Any]:
|
||||
"""Force refresh the cache from Civitai API.
|
||||
|
||||
Returns:
|
||||
Response dict same as get_base_models()
|
||||
"""
|
||||
return await self.get_base_models(force_refresh=True)
|
||||
|
||||
def get_cache_status(self) -> Dict[str, Any]:
|
||||
"""Get current cache status.
|
||||
|
||||
Returns:
|
||||
Dictionary containing:
|
||||
- has_cache: Whether cache exists
|
||||
- last_updated: ISO timestamp or None
|
||||
- is_expired: Whether cache is expired
|
||||
- ttl_seconds: TTL in seconds
|
||||
- age_seconds: Age of cache in seconds (if exists)
|
||||
"""
|
||||
if self._cache is None or self._cache_timestamp is None:
|
||||
return {
|
||||
"has_cache": False,
|
||||
"last_updated": None,
|
||||
"is_expired": True,
|
||||
"ttl_seconds": self._cache_ttl,
|
||||
"age_seconds": None,
|
||||
}
|
||||
|
||||
age = (datetime.now(timezone.utc) - self._cache_timestamp).total_seconds()
|
||||
return {
|
||||
"has_cache": True,
|
||||
"last_updated": self._cache_timestamp.isoformat(),
|
||||
"is_expired": age > self._cache_ttl,
|
||||
"ttl_seconds": self._cache_ttl,
|
||||
"age_seconds": int(age),
|
||||
}
|
||||
|
||||
def generate_abbreviation(self, model_name: str) -> str:
|
||||
"""Generate abbreviation for a base model name.
|
||||
|
||||
Algorithm:
|
||||
1. Extract version patterns (e.g., "2.5" from "Wan Video 2.5")
|
||||
2. Extract main acronym (e.g., "SD" from "SD 1.5")
|
||||
3. Handle special cases (Flux, Wan, etc.)
|
||||
4. Fallback to first letters of words (max 4 chars)
|
||||
|
||||
Args:
|
||||
model_name: Full base model name
|
||||
|
||||
Returns:
|
||||
Generated abbreviation (max 4 characters)
|
||||
"""
|
||||
if not model_name or not isinstance(model_name, str):
|
||||
return "OTH"
|
||||
|
||||
name = model_name.strip()
|
||||
if not name:
|
||||
return "OTH"
|
||||
|
||||
# Check if it's already in hardcoded abbreviations
|
||||
# This is a simplified check - in practice you'd have a mapping
|
||||
lower_name = name.lower()
|
||||
|
||||
# Special cases
|
||||
special_cases = {
|
||||
"sd 1.4": "SD1",
|
||||
"sd 1.5": "SD1",
|
||||
"sd 1.5 lcm": "SD1",
|
||||
"sd 1.5 hyper": "SD1",
|
||||
"sd 2.0": "SD2",
|
||||
"sd 2.1": "SD2",
|
||||
"sd 3": "SD3",
|
||||
"sd 3.5": "SD3",
|
||||
"sd 3.5 medium": "SD3",
|
||||
"sd 3.5 large": "SD3",
|
||||
"sd 3.5 large turbo": "SD3",
|
||||
"sdxl 1.0": "XL",
|
||||
"sdxl lightning": "XL",
|
||||
"sdxl hyper": "XL",
|
||||
"flux.1 d": "F1D",
|
||||
"flux.1 s": "F1S",
|
||||
"flux.1 krea": "F1KR",
|
||||
"flux.1 kontext": "F1KX",
|
||||
"flux.2 d": "F2D",
|
||||
"flux.2 klein 9b": "FK9",
|
||||
"flux.2 klein 9b-base": "FK9B",
|
||||
"flux.2 klein 4b": "FK4",
|
||||
"flux.2 klein 4b-base": "FK4B",
|
||||
"auraflow": "AF",
|
||||
"chroma": "CHR",
|
||||
"pixart a": "PXA",
|
||||
"pixart e": "PXE",
|
||||
"hunyuan 1": "HY",
|
||||
"hunyuan video": "HYV",
|
||||
"lumina": "L",
|
||||
"kolors": "KLR",
|
||||
"noobai": "NAI",
|
||||
"illustrious": "IL",
|
||||
"pony": "PONY",
|
||||
"pony v7": "PNY7",
|
||||
"hidream": "HID",
|
||||
"qwen": "QWEN",
|
||||
"zimageturbo": "ZIT",
|
||||
"zimagebase": "ZIB",
|
||||
"anima": "ANI",
|
||||
"svd": "SVD",
|
||||
"ltxv": "LTXV",
|
||||
"ltxv2": "LTV2",
|
||||
"ltxv 2.3": "LTX",
|
||||
"cogvideox": "CVX",
|
||||
"mochi": "MCHI",
|
||||
"wan video": "WAN",
|
||||
"wan video 1.3b t2v": "WAN",
|
||||
"wan video 14b t2v": "WAN",
|
||||
"wan video 14b i2v 480p": "WAN",
|
||||
"wan video 14b i2v 720p": "WAN",
|
||||
"wan video 2.2 ti2v-5b": "WAN",
|
||||
"wan video 2.2 t2v-a14b": "WAN",
|
||||
"wan video 2.2 i2v-a14b": "WAN",
|
||||
"wan video 2.5 t2v": "WAN",
|
||||
"wan video 2.5 i2v": "WAN",
|
||||
}
|
||||
|
||||
if lower_name in special_cases:
|
||||
return special_cases[lower_name]
|
||||
|
||||
# Try to extract acronym from version pattern
|
||||
# e.g., "Model Name 2.5" -> "MN25"
|
||||
version_match = re.search(r"(\d+(?:\.\d+)?)", name)
|
||||
version = version_match.group(1) if version_match else ""
|
||||
|
||||
# Remove version and common words
|
||||
words = re.sub(r"\d+(?:\.\d+)?", "", name)
|
||||
words = re.sub(
|
||||
r"\b(model|video|diffusion|checkpoint|textualinversion)\b",
|
||||
"",
|
||||
words,
|
||||
flags=re.I,
|
||||
)
|
||||
words = words.strip()
|
||||
|
||||
# Get first letters of remaining words
|
||||
tokens = re.findall(r"[A-Za-z]+", words)
|
||||
if tokens:
|
||||
# Build abbreviation from first letters
|
||||
abbrev = "".join(token[0].upper() for token in tokens)
|
||||
# Add version if present
|
||||
if version:
|
||||
# Clean version (remove dots for abbreviation)
|
||||
version_clean = version.replace(".", "")
|
||||
abbrev = abbrev[: 4 - len(version_clean)] + version_clean
|
||||
return abbrev[:4]
|
||||
|
||||
# Final fallback: just take first 4 alphanumeric chars
|
||||
alphanumeric = re.sub(r"[^A-Za-z0-9]", "", name)
|
||||
if alphanumeric:
|
||||
return alphanumeric[:4].upper()
|
||||
|
||||
return "OTH"
|
||||
|
||||
async def _fetch_from_civitai(self) -> Optional[Set[str]]:
|
||||
"""Fetch base models from Civitai API.
|
||||
|
||||
Returns:
|
||||
Set of base model names, or None if failed
|
||||
"""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
"GET",
|
||||
self.CIVITAI_ENUMS_URL,
|
||||
use_auth=False, # enums endpoint doesn't require auth
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Failed to fetch enums from Civitai: {result}")
|
||||
return None
|
||||
|
||||
if isinstance(result, str):
|
||||
data = json.loads(result)
|
||||
else:
|
||||
data = result
|
||||
|
||||
# Extract base models from response
|
||||
base_models = set()
|
||||
|
||||
# Use ActiveBaseModel if available (recommended active models)
|
||||
if "ActiveBaseModel" in data:
|
||||
base_models.update(data["ActiveBaseModel"])
|
||||
logger.info(f"Fetched {len(base_models)} models from ActiveBaseModel")
|
||||
# Fallback to full BaseModel list
|
||||
elif "BaseModel" in data:
|
||||
base_models.update(data["BaseModel"])
|
||||
logger.info(f"Fetched {len(base_models)} models from BaseModel")
|
||||
else:
|
||||
logger.warning("No base model data found in Civitai response")
|
||||
return None
|
||||
|
||||
return base_models
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching from Civitai: {e}")
|
||||
return None
|
||||
|
||||
def _update_cache(self, remote_models: Set[str]) -> None:
|
||||
"""Update internal cache with fetched models.
|
||||
|
||||
Args:
|
||||
remote_models: Set of base model names from API
|
||||
"""
|
||||
self._cache = {
|
||||
"remote_models": sorted(remote_models),
|
||||
"hardcoded_models": sorted(self._hardcoded_models),
|
||||
}
|
||||
self._cache_timestamp = datetime.now(timezone.utc)
|
||||
logger.info(f"Cache updated with {len(remote_models)} remote models")
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""Check if current cache is valid (not expired).
|
||||
|
||||
Returns:
|
||||
True if cache exists and is not expired
|
||||
"""
|
||||
if self._cache is None or self._cache_timestamp is None:
|
||||
return False
|
||||
|
||||
age = (datetime.now(timezone.utc) - self._cache_timestamp).total_seconds()
|
||||
return age <= self._cache_ttl
|
||||
|
||||
def _build_response(self, source: str) -> Dict[str, Any]:
|
||||
"""Build response dictionary.
|
||||
|
||||
Args:
|
||||
source: 'cache', 'api', or 'fallback'
|
||||
|
||||
Returns:
|
||||
Response dictionary
|
||||
"""
|
||||
if source == "fallback" or self._cache is None:
|
||||
# Use only hardcoded models
|
||||
merged = sorted(self._hardcoded_models)
|
||||
return {
|
||||
"models": merged,
|
||||
"source": source,
|
||||
"last_updated": None,
|
||||
"hardcoded_count": len(self._hardcoded_models),
|
||||
"remote_count": 0,
|
||||
"merged_count": len(merged),
|
||||
}
|
||||
|
||||
# Merge hardcoded and remote models
|
||||
remote_set = set(self._cache.get("remote_models", []))
|
||||
merged = sorted(self._hardcoded_models | remote_set)
|
||||
|
||||
return {
|
||||
"models": merged,
|
||||
"source": source,
|
||||
"last_updated": self._cache_timestamp.isoformat()
|
||||
if self._cache_timestamp
|
||||
else None,
|
||||
"hardcoded_count": len(self._hardcoded_models),
|
||||
"remote_count": len(remote_set),
|
||||
"merged_count": len(merged),
|
||||
}
|
||||
|
||||
def get_model_categories(self) -> Dict[str, List[str]]:
|
||||
"""Get categorized base models.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping category names to lists of model names
|
||||
"""
|
||||
# Define category patterns
|
||||
categories = {
|
||||
"Stable Diffusion 1.x": ["SD 1.4", "SD 1.5", "SD 1.5 LCM", "SD 1.5 Hyper"],
|
||||
"Stable Diffusion 2.x": ["SD 2.0", "SD 2.1"],
|
||||
"Stable Diffusion 3.x": [
|
||||
"SD 3",
|
||||
"SD 3.5",
|
||||
"SD 3.5 Medium",
|
||||
"SD 3.5 Large",
|
||||
"SD 3.5 Large Turbo",
|
||||
],
|
||||
"SDXL": ["SDXL 1.0", "SDXL Lightning", "SDXL Hyper"],
|
||||
"Flux Models": [
|
||||
"Flux.1 D",
|
||||
"Flux.1 S",
|
||||
"Flux.1 Krea",
|
||||
"Flux.1 Kontext",
|
||||
"Flux.2 D",
|
||||
"Flux.2 Klein 9B",
|
||||
"Flux.2 Klein 9B-base",
|
||||
"Flux.2 Klein 4B",
|
||||
"Flux.2 Klein 4B-base",
|
||||
],
|
||||
"Video Models": [
|
||||
"SVD",
|
||||
"LTXV",
|
||||
"LTXV2",
|
||||
"LTXV 2.3",
|
||||
"CogVideoX",
|
||||
"Mochi",
|
||||
"Hunyuan Video",
|
||||
"Wan Video",
|
||||
"Wan Video 1.3B t2v",
|
||||
"Wan Video 14B t2v",
|
||||
"Wan Video 14B i2v 480p",
|
||||
"Wan Video 14B i2v 720p",
|
||||
"Wan Video 2.2 TI2V-5B",
|
||||
"Wan Video 2.2 T2V-A14B",
|
||||
"Wan Video 2.2 I2V-A14B",
|
||||
"Wan Video 2.5 T2V",
|
||||
"Wan Video 2.5 I2V",
|
||||
],
|
||||
"Other Models": [
|
||||
"Illustrious",
|
||||
"Pony",
|
||||
"Pony V7",
|
||||
"HiDream",
|
||||
"Qwen",
|
||||
"AuraFlow",
|
||||
"Chroma",
|
||||
"ZImageTurbo",
|
||||
"ZImageBase",
|
||||
"PixArt a",
|
||||
"PixArt E",
|
||||
"Hunyuan 1",
|
||||
"Lumina",
|
||||
"Kolors",
|
||||
"NoobAI",
|
||||
"Anima",
|
||||
],
|
||||
}
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
# Convenience function for getting the singleton instance
|
||||
async def get_civitai_base_model_service() -> CivitaiBaseModelService:
|
||||
"""Get the singleton instance of CivitaiBaseModelService."""
|
||||
return await CivitaiBaseModelService.get_instance()
|
||||
@@ -39,7 +39,10 @@ class CivitaiClient:
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self.base_url = "https://civitai.com/api/v1"
|
||||
self.base_url = "https://civitai.red/api/v1"
|
||||
|
||||
def _build_image_info_url(self, image_id: str) -> str:
|
||||
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
@@ -190,7 +193,9 @@ class CivitaiClient:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
success, result = await self._make_request(
|
||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
# Also return model type along with versions
|
||||
@@ -346,7 +351,9 @@ class CivitaiClient:
|
||||
|
||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||
success, data = await self._make_request(
|
||||
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return data
|
||||
@@ -358,7 +365,9 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/model-versions/{version_id}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
@@ -371,7 +380,9 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True
|
||||
"GET",
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True,
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
@@ -453,13 +464,11 @@ class CivitaiClient:
|
||||
try:
|
||||
url = f"{self.base_url}/model-versions/{version_id}"
|
||||
|
||||
logger.debug(f"Resolving DNS for model version info: {url}")
|
||||
logger.debug("Resolving Civitai model version info: %s", url)
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
logger.debug(
|
||||
f"Successfully fetched model version info for: {version_id}"
|
||||
)
|
||||
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||
self._remove_comfy_metadata(result)
|
||||
return result, None
|
||||
|
||||
@@ -479,48 +488,58 @@ class CivitaiClient:
|
||||
logger.error(error_msg)
|
||||
return None, error_msg
|
||||
|
||||
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
||||
async def get_image_info(
|
||||
self, image_id: str, source_url: str | None = None
|
||||
) -> Optional[Dict]:
|
||||
"""Fetch image information from Civitai API
|
||||
|
||||
Args:
|
||||
image_id: The Civitai image ID
|
||||
source_url: Original image page URL. Accepted for caller compatibility;
|
||||
API requests always target ``civitai.red``.
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The image data or None if not found
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
requested_id = int(image_id)
|
||||
|
||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||
url = self._build_image_info_url(image_id)
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
if result and "items" in result and isinstance(result["items"], list):
|
||||
items = result["items"]
|
||||
|
||||
# First, try to find the item with matching ID
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("id") == requested_id:
|
||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
||||
return item
|
||||
|
||||
# No matching ID found - log warning with details about returned items
|
||||
returned_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and "id" in item
|
||||
]
|
||||
logger.warning(
|
||||
f"CivitAI API returned no matching image for requested ID {image_id}. "
|
||||
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
|
||||
f"This may indicate the image was deleted, hidden, or there is a database lag."
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(f"No image found with ID: {image_id}")
|
||||
if not success:
|
||||
logger.error(
|
||||
"Failed to fetch image info for ID %s from civitai.red: %s",
|
||||
image_id,
|
||||
result,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
|
||||
if result and "items" in result and isinstance(result["items"], list):
|
||||
items = result["items"]
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("id") == requested_id:
|
||||
logger.debug(
|
||||
"Successfully fetched image info for ID %s from civitai.red",
|
||||
image_id,
|
||||
)
|
||||
return item
|
||||
|
||||
returned_ids = [
|
||||
item.get("id")
|
||||
for item in items
|
||||
if isinstance(item, dict) and "id" in item
|
||||
]
|
||||
|
||||
logger.warning(
|
||||
"CivitAI API returned no matching image for requested ID %s from civitai.red. Returned %d item(s) with IDs: %s. This may indicate the image was deleted, hidden, or there is a database lag.",
|
||||
image_id,
|
||||
len(items),
|
||||
returned_ids,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning("No image found with ID: %s", image_id)
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
@@ -539,8 +558,12 @@ class CivitaiClient:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/models?username={username}"
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
success, result = await self._make_request(
|
||||
"GET",
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={"username": username},
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||
|
||||
@@ -7,11 +7,13 @@ with category filtering and enriched results including post counts.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_EMBEDDED_COMMAND_PATTERN = re.compile(r"\s/\w")
|
||||
class CustomWordsService:
|
||||
"""Service for autocomplete via TagFTSIndex.
|
||||
|
||||
@@ -77,12 +79,28 @@ class CustomWordsService:
|
||||
Returns:
|
||||
List of dicts with tag_name, category, and post_count.
|
||||
"""
|
||||
normalized_search = search_term.strip()
|
||||
if not normalized_search:
|
||||
return []
|
||||
|
||||
# Prompt widgets should only send the active token, but guard against
|
||||
# accidental full-prompt queries reaching the FTS path.
|
||||
if (
|
||||
"__" in normalized_search
|
||||
or "," in normalized_search
|
||||
or ">" in normalized_search
|
||||
or "\n" in normalized_search
|
||||
or "\r" in normalized_search
|
||||
or _EMBEDDED_COMMAND_PATTERN.search(normalized_search)
|
||||
):
|
||||
logger.debug("Skipping prompt-like custom words query: %s", normalized_search)
|
||||
return []
|
||||
|
||||
tag_index = self._get_tag_index()
|
||||
if tag_index is not None:
|
||||
results = tag_index.search(
|
||||
search_term, categories=categories, limit=limit, offset=offset
|
||||
return tag_index.search(
|
||||
normalized_search, categories=categories, limit=limit, offset=offset
|
||||
)
|
||||
return results
|
||||
|
||||
logger.debug("TagFTSIndex not available, returning empty results")
|
||||
return []
|
||||
|
||||
@@ -16,7 +16,7 @@ from ..utils.constants import (
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -31,6 +31,11 @@ import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
"https://civitai.com/api/download/",
|
||||
"https://civitai.red/api/download/",
|
||||
)
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
_instance = None
|
||||
@@ -64,6 +69,19 @@ class DownloadManager:
|
||||
"""Get the checkpoint scanner from registry"""
|
||||
return await ServiceRegistry.get_checkpoint_scanner()
|
||||
|
||||
async def _has_been_downloaded(self, model_type: str, model_version_id: int) -> bool:
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
return await history_service.has_been_downloaded(model_type, model_version_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to read download history for %s version %s: %s",
|
||||
model_type,
|
||||
model_version_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
async def download_from_civitai(
|
||||
self,
|
||||
model_id: int = None,
|
||||
@@ -355,6 +373,57 @@ class DownloadManager:
|
||||
"error": f'Model type "{model_type_from_info}" is not supported for download',
|
||||
}
|
||||
|
||||
resolved_version_id = model_version_id
|
||||
raw_version_id = version_info.get("id")
|
||||
if resolved_version_id is None and raw_version_id is not None:
|
||||
try:
|
||||
resolved_version_id = int(raw_version_id)
|
||||
except (TypeError, ValueError):
|
||||
resolved_version_id = None
|
||||
|
||||
if (
|
||||
get_settings_manager().get_skip_previously_downloaded_model_versions()
|
||||
and resolved_version_id is not None
|
||||
and await self._has_been_downloaded(model_type, resolved_version_id)
|
||||
):
|
||||
file_name = ""
|
||||
files = version_info.get("files")
|
||||
if isinstance(files, list):
|
||||
primary_file = next(
|
||||
(
|
||||
file_info
|
||||
for file_info in files
|
||||
if isinstance(file_info, dict) and file_info.get("primary")
|
||||
),
|
||||
None,
|
||||
)
|
||||
selected_file = primary_file
|
||||
if selected_file is None:
|
||||
selected_file = next(
|
||||
(file_info for file_info in files if isinstance(file_info, dict)),
|
||||
None,
|
||||
)
|
||||
if isinstance(selected_file, dict):
|
||||
raw_file_name = selected_file.get("name", "")
|
||||
if isinstance(raw_file_name, str):
|
||||
file_name = raw_file_name.strip()
|
||||
|
||||
message = (
|
||||
f"Skipped download for '{file_name or version_info.get('name') or f'model_version:{resolved_version_id}'}' "
|
||||
f"because version {resolved_version_id} was already downloaded before"
|
||||
)
|
||||
logger.info(message)
|
||||
return {
|
||||
"success": True,
|
||||
"skipped": True,
|
||||
"status": "skipped",
|
||||
"reason": "previously_downloaded_version",
|
||||
"message": message,
|
||||
"model_version_id": resolved_version_id,
|
||||
"file_name": file_name,
|
||||
"download_id": download_id,
|
||||
}
|
||||
|
||||
excluded_base_models = get_settings_manager().get_download_skip_base_models()
|
||||
base_model_value = version_info.get("baseModel", "")
|
||||
if (
|
||||
@@ -575,7 +644,9 @@ class DownloadManager:
|
||||
if mirrors:
|
||||
for mirror in mirrors:
|
||||
if mirror.get("deletedAt") is None and mirror.get("url"):
|
||||
download_urls.append(mirror["url"])
|
||||
download_urls.append(
|
||||
normalize_civitai_download_url(mirror["url"])
|
||||
)
|
||||
|
||||
# When source is 'civarchive', prioritize non-Civitai URLs
|
||||
# This avoids failed downloads from deleted Civitai models
|
||||
@@ -583,18 +654,20 @@ class DownloadManager:
|
||||
civitai_urls = [
|
||||
u
|
||||
for u in download_urls
|
||||
if u.startswith("https://civitai.com/api/download/")
|
||||
if u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
]
|
||||
non_civitai_urls = [
|
||||
u
|
||||
for u in download_urls
|
||||
if not u.startswith("https://civitai.com/api/download/")
|
||||
if not u.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
]
|
||||
download_urls = non_civitai_urls + civitai_urls
|
||||
else:
|
||||
download_url = file_info.get("downloadUrl")
|
||||
if download_url:
|
||||
download_urls.append(download_url)
|
||||
download_urls.append(
|
||||
normalize_civitai_download_url(download_url)
|
||||
)
|
||||
|
||||
if not download_urls:
|
||||
return {"success": False, "error": "No mirror URL found"}
|
||||
@@ -640,6 +713,13 @@ class DownloadManager:
|
||||
or version_info.get("modelId")
|
||||
or (version_info.get("model") or {}).get("id")
|
||||
)
|
||||
await self._record_downloaded_version_history(
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
version_info,
|
||||
model_version_id,
|
||||
save_path,
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
@@ -669,6 +749,55 @@ class DownloadManager:
|
||||
}
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _record_downloaded_version_history(
|
||||
self,
|
||||
model_type: str,
|
||||
model_id_value,
|
||||
version_info: Dict,
|
||||
fallback_version_id=None,
|
||||
file_path: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Skipping download history sync; failed to acquire history service: %s",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
if history_service is None:
|
||||
return
|
||||
|
||||
resolved_model_id = model_id_value
|
||||
if resolved_model_id is None:
|
||||
resolved_model_id = version_info.get("modelId")
|
||||
if resolved_model_id is None:
|
||||
model_info = version_info.get("model")
|
||||
if isinstance(model_info, dict):
|
||||
resolved_model_id = model_info.get("id")
|
||||
|
||||
version_id = version_info.get("id")
|
||||
if version_id is None:
|
||||
version_id = fallback_version_id
|
||||
|
||||
try:
|
||||
await history_service.mark_downloaded(
|
||||
model_type,
|
||||
int(version_id),
|
||||
model_id=int(resolved_model_id) if resolved_model_id is not None else None,
|
||||
source="download",
|
||||
file_path=file_path,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(
|
||||
"Skipping download history sync; invalid identifiers model=%s version=%s",
|
||||
resolved_model_id,
|
||||
version_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync download history for %s: %s", model_type, exc)
|
||||
|
||||
async def _sync_downloaded_version(
|
||||
self,
|
||||
model_type: str,
|
||||
@@ -1013,7 +1142,8 @@ class DownloadManager:
|
||||
pause_control.update_stall_timeout(downloader.stall_timeout)
|
||||
last_error = None
|
||||
for download_url in download_urls:
|
||||
use_auth = download_url.startswith("https://civitai.com/api/download/")
|
||||
download_url = normalize_civitai_download_url(download_url)
|
||||
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||
download_kwargs = {
|
||||
"progress_callback": lambda progress, snapshot=None: (
|
||||
self._handle_download_progress(
|
||||
|
||||
313
py/services/downloaded_version_history_service.py
Normal file
313
py/services/downloaded_version_history_service.py
Normal file
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Iterable, Mapping, Optional, Sequence
|
||||
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_model_type(model_type: str | None) -> Optional[str]:
|
||||
if not isinstance(model_type, str):
|
||||
return None
|
||||
normalized = model_type.strip().lower()
|
||||
if normalized in {"lora", "locon", "dora"}:
|
||||
return "lora"
|
||||
if normalized == "checkpoint":
|
||||
return "checkpoint"
|
||||
if normalized in {"embedding", "textualinversion"}:
|
||||
return "embedding"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_int(value) -> Optional[int]:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_database_path() -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
history_dir = os.path.join(base_dir, "download_history")
|
||||
os.makedirs(history_dir, exist_ok=True)
|
||||
return os.path.join(history_dir, "downloaded_versions.sqlite")
|
||||
|
||||
|
||||
class DownloadedVersionHistoryService:
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS downloaded_model_versions (
|
||||
model_type TEXT NOT NULL,
|
||||
version_id INTEGER NOT NULL,
|
||||
model_id INTEGER,
|
||||
first_seen_at REAL NOT NULL,
|
||||
last_seen_at REAL NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
last_file_path TEXT,
|
||||
last_library_name TEXT,
|
||||
is_deleted_override INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (model_type, version_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
|
||||
ON downloaded_model_versions(model_type, model_id);
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
|
||||
self._db_path = db_path or _resolve_database_path()
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._lock = asyncio.Lock()
|
||||
self._schema_initialized = False
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
|
||||
def _ensure_directory(self) -> None:
|
||||
directory = os.path.dirname(self._db_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _initialize_schema(self) -> None:
|
||||
if self._schema_initialized:
|
||||
return
|
||||
with self._connect() as conn:
|
||||
conn.executescript(self._SCHEMA)
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
|
||||
def get_database_path(self) -> str:
|
||||
return self._db_path
|
||||
|
||||
def _get_active_library_name(self) -> str | None:
|
||||
try:
|
||||
value = self._settings.get_active_library_name()
|
||||
except Exception:
|
||||
return None
|
||||
return value or None
|
||||
|
||||
async def mark_downloaded(
|
||||
self,
|
||||
model_type: str,
|
||||
version_id: int,
|
||||
*,
|
||||
model_id: int | None = None,
|
||||
source: str = "manual",
|
||||
file_path: str | None = None,
|
||||
library_name: str | None = None,
|
||||
) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
normalized_model_id = _normalize_int(model_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return
|
||||
|
||||
active_library_name = library_name or self._get_active_library_name()
|
||||
timestamp = time.time()
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
normalized_model_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
source,
|
||||
file_path,
|
||||
active_library_name,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_downloaded_bulk(
|
||||
self,
|
||||
model_type: str,
|
||||
records: Sequence[Mapping[str, object]],
|
||||
*,
|
||||
source: str = "scan",
|
||||
library_name: str | None = None,
|
||||
) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
if normalized_type is None or not records:
|
||||
return
|
||||
|
||||
timestamp = time.time()
|
||||
active_library_name = library_name or self._get_active_library_name()
|
||||
payload: list[tuple[object, ...]] = []
|
||||
for record in records:
|
||||
version_id = _normalize_int(record.get("version_id"))
|
||||
if version_id is None:
|
||||
continue
|
||||
payload.append(
|
||||
(
|
||||
normalized_type,
|
||||
version_id,
|
||||
_normalize_int(record.get("model_id")),
|
||||
timestamp,
|
||||
timestamp,
|
||||
source,
|
||||
record.get("file_path"),
|
||||
active_library_name,
|
||||
)
|
||||
)
|
||||
|
||||
if not payload:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return
|
||||
|
||||
timestamp = time.time()
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 1
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
timestamp,
|
||||
timestamp,
|
||||
self._get_active_library_name(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return False
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT is_deleted_override
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
).fetchone()
|
||||
return bool(row) and not bool(row["is_deleted_override"])
|
||||
|
||||
async def get_downloaded_version_ids(
|
||||
self, model_type: str, model_id: int
|
||||
) -> list[int]:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_model_id = _normalize_int(model_id)
|
||||
if normalized_type is None or normalized_model_id is None:
|
||||
return []
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
|
||||
ORDER BY version_id ASC
|
||||
""",
|
||||
(normalized_type, normalized_model_id),
|
||||
).fetchall()
|
||||
return [int(row["version_id"]) for row in rows]
|
||||
|
||||
async def get_downloaded_version_ids_bulk(
|
||||
self, model_type: str, model_ids: Iterable[int]
|
||||
) -> dict[int, set[int]]:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
if normalized_type is None:
|
||||
return {}
|
||||
|
||||
normalized_model_ids = sorted(
|
||||
{
|
||||
value
|
||||
for value in (_normalize_int(model_id) for model_id in model_ids)
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
if not normalized_model_ids:
|
||||
return {}
|
||||
|
||||
placeholders = ", ".join(["?"] * len(normalized_model_ids))
|
||||
params: list[object] = [normalized_type, *normalized_model_ids]
|
||||
|
||||
async with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT model_id, version_id
|
||||
FROM downloaded_model_versions
|
||||
WHERE model_type = ?
|
||||
AND model_id IN ({placeholders})
|
||||
AND is_deleted_override = 0
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
result: dict[int, set[int]] = {}
|
||||
for row in rows:
|
||||
model_id = _normalize_int(row["model_id"])
|
||||
version_id = _normalize_int(row["version_id"])
|
||||
if model_id is None or version_id is None:
|
||||
continue
|
||||
result.setdefault(model_id, set()).add(version_id)
|
||||
return result
|
||||
@@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService):
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"exclude": bool(embedding_data.get("exclude", False)),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
@@ -47,6 +48,7 @@ class LoraService(BaseModelService):
|
||||
"usage_tips": lora_data.get("usage_tips", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"exclude": bool(lora_data.get("exclude", False)),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(
|
||||
lora_data.get("skip_metadata_refresh", False)
|
||||
@@ -278,6 +280,42 @@ class LoraService(BaseModelService):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_recommended_strength_from_lora_data(lora_data: Dict) -> Optional[float]:
|
||||
"""Parse usage_tips JSON and extract recommended model strength."""
|
||||
try:
|
||||
usage_tips = lora_data.get("usage_tips", "")
|
||||
if not usage_tips:
|
||||
return None
|
||||
tips_data = json.loads(usage_tips)
|
||||
return tips_data.get("strength")
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_recommended_clip_strength_from_lora_data(
|
||||
lora_data: Dict,
|
||||
) -> Optional[float]:
|
||||
"""Parse usage_tips JSON and extract recommended clip strength."""
|
||||
try:
|
||||
usage_tips = lora_data.get("usage_tips", "")
|
||||
if not usage_tips:
|
||||
return None
|
||||
tips_data = json.loads(usage_tips)
|
||||
return tips_data.get("clipStrength")
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
async def get_lora_metadata_by_filename(self, filename: str) -> Optional[Dict]:
|
||||
"""Return cached raw metadata for a LoRA matching the given filename."""
|
||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||
|
||||
for lora in cache.raw_data if cache else []:
|
||||
if lora.get("file_name") == filename:
|
||||
return lora
|
||||
|
||||
return None
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict:
|
||||
"""Find LoRAs with duplicate SHA256 hashes"""
|
||||
return self.scanner._hash_index.get_duplicate_hashes()
|
||||
@@ -328,34 +366,10 @@ class LoraService(BaseModelService):
|
||||
List of LoRA dicts with randomized strengths
|
||||
"""
|
||||
import random
|
||||
import json
|
||||
|
||||
# Use a local Random instance to avoid affecting global random state
|
||||
# This ensures each execution with a different seed produces different results
|
||||
rng = random.Random(seed)
|
||||
|
||||
def get_recommended_strength(lora_data: Dict) -> Optional[float]:
|
||||
"""Parse usage_tips JSON and extract recommended strength"""
|
||||
try:
|
||||
usage_tips = lora_data.get("usage_tips", "")
|
||||
if not usage_tips:
|
||||
return None
|
||||
tips_data = json.loads(usage_tips)
|
||||
return tips_data.get("strength")
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
def get_recommended_clip_strength(lora_data: Dict) -> Optional[float]:
|
||||
"""Parse usage_tips JSON and extract recommended clip strength"""
|
||||
try:
|
||||
usage_tips = lora_data.get("usage_tips", "")
|
||||
if not usage_tips:
|
||||
return None
|
||||
tips_data = json.loads(usage_tips)
|
||||
return tips_data.get("clipStrength")
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
if locked_loras is None:
|
||||
locked_loras = []
|
||||
|
||||
@@ -403,7 +417,9 @@ class LoraService(BaseModelService):
|
||||
result_loras = []
|
||||
for lora in selected:
|
||||
if use_recommended_strength:
|
||||
recommended_strength = get_recommended_strength(lora)
|
||||
recommended_strength = self.get_recommended_strength_from_lora_data(
|
||||
lora
|
||||
)
|
||||
if recommended_strength is not None:
|
||||
scale = rng.uniform(
|
||||
recommended_strength_scale_min, recommended_strength_scale_max
|
||||
@@ -421,7 +437,9 @@ class LoraService(BaseModelService):
|
||||
if use_same_clip_strength:
|
||||
clip_str = model_str
|
||||
elif use_recommended_strength:
|
||||
recommended_clip_strength = get_recommended_clip_strength(lora)
|
||||
recommended_clip_strength = (
|
||||
self.get_recommended_clip_strength_from_lora_data(lora)
|
||||
)
|
||||
if recommended_clip_strength is not None:
|
||||
scale = rng.uniform(
|
||||
recommended_strength_scale_min, recommended_strength_scale_max
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Opti
|
||||
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -207,11 +208,56 @@ class ModelLifecycleService:
|
||||
|
||||
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||
if isinstance(excluded, list):
|
||||
excluded.append(file_path)
|
||||
if file_path not in excluded:
|
||||
excluded.append(file_path)
|
||||
|
||||
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||
if callable(persist_current_cache):
|
||||
await persist_current_cache()
|
||||
|
||||
message = f"Model {os.path.basename(file_path)} excluded"
|
||||
return {"success": True, "message": message}
|
||||
|
||||
async def unexclude_model(self, file_path: str) -> Dict[str, object]:
|
||||
"""Restore a previously excluded model to the active cache."""
|
||||
|
||||
if not file_path:
|
||||
raise ValueError("Model path is required")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise ValueError("Model file does not exist")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_payload = await self._metadata_loader(metadata_path)
|
||||
metadata_payload["exclude"] = False
|
||||
|
||||
await self._metadata_manager.save_metadata(file_path, metadata_payload)
|
||||
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path,
|
||||
self._scanner.model_class,
|
||||
)
|
||||
if should_skip:
|
||||
metadata = None
|
||||
if metadata is None:
|
||||
metadata = metadata_payload
|
||||
|
||||
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||
if isinstance(excluded, list):
|
||||
self._scanner._excluded_models = [
|
||||
path for path in excluded if path != file_path
|
||||
]
|
||||
|
||||
await self._scanner.update_single_model_cache(
|
||||
file_path,
|
||||
file_path,
|
||||
metadata,
|
||||
recalculate_type=True,
|
||||
)
|
||||
|
||||
message = f"Model {os.path.basename(file_path)} restored"
|
||||
return {"success": True, "message": message}
|
||||
|
||||
async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]:
|
||||
"""Delete a collection of models via the scanner bulk operation."""
|
||||
|
||||
|
||||
@@ -411,6 +411,7 @@ class ModelScanner:
|
||||
if scan_result:
|
||||
await self._apply_scan_result(scan_result)
|
||||
await self._save_persistent_cache(scan_result)
|
||||
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||
|
||||
# Send final progress update
|
||||
await ws_manager.broadcast_init_progress({
|
||||
@@ -516,6 +517,7 @@ class ModelScanner:
|
||||
)
|
||||
|
||||
await self._apply_scan_result(scan_result)
|
||||
await self._sync_download_history(adjusted_raw_data, source='scan')
|
||||
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'loading_cache',
|
||||
@@ -576,6 +578,7 @@ class ModelScanner:
|
||||
excluded_models=list(self._excluded_models)
|
||||
)
|
||||
await self._save_persistent_cache(snapshot)
|
||||
await self._sync_download_history(snapshot.raw_data, source='scan')
|
||||
def _count_model_files(self) -> int:
|
||||
"""Count all model files with supported extensions in all roots
|
||||
|
||||
@@ -704,6 +707,7 @@ class ModelScanner:
|
||||
scan_result = await self._gather_model_data()
|
||||
await self._apply_scan_result(scan_result)
|
||||
await self._save_persistent_cache(scan_result)
|
||||
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||
@@ -1101,6 +1105,49 @@ class ModelScanner:
|
||||
|
||||
await self._cache.resort()
|
||||
|
||||
async def _sync_download_history(
|
||||
self,
|
||||
raw_data: List[Mapping[str, Any]],
|
||||
*,
|
||||
source: str,
|
||||
) -> None:
|
||||
records: List[Dict[str, Any]] = []
|
||||
for item in raw_data or []:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
civitai = item.get('civitai')
|
||||
if not isinstance(civitai, Mapping):
|
||||
continue
|
||||
|
||||
version_id = civitai.get('id')
|
||||
if version_id in (None, ''):
|
||||
continue
|
||||
|
||||
records.append(
|
||||
{
|
||||
'version_id': version_id,
|
||||
'model_id': civitai.get('modelId'),
|
||||
'file_path': item.get('file_path'),
|
||||
}
|
||||
)
|
||||
|
||||
if not records:
|
||||
return
|
||||
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
await history_service.mark_downloaded_bulk(
|
||||
self.model_type,
|
||||
records,
|
||||
source=source,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"%s Scanner: Failed to sync download history: %s",
|
||||
self.model_type.capitalize(),
|
||||
exc,
|
||||
)
|
||||
|
||||
async def _gather_model_data(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
@@ -234,12 +235,52 @@ class ModelUpdateService:
|
||||
ON model_update_versions(model_id);
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str, *, ttl_seconds: int = 24 * 60 * 60, settings_manager=None) -> None:
|
||||
self._db_path = db_path
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str | None = None,
|
||||
*,
|
||||
ttl_seconds: int = 24 * 60 * 60,
|
||||
settings_manager=None,
|
||||
) -> None:
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._library_name = self._get_active_library_name()
|
||||
self._db_path = db_path or self._resolve_default_path(self._library_name)
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._lock = asyncio.Lock()
|
||||
self._schema_initialized = False
|
||||
self._settings = settings_manager or get_settings_manager()
|
||||
self._custom_db_path = db_path is not None
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
|
||||
def _get_active_library_name(self) -> str:
|
||||
try:
|
||||
value = self._settings.get_active_library_name()
|
||||
except Exception:
|
||||
value = None
|
||||
return value or "default"
|
||||
|
||||
def _resolve_default_path(self, library_name: str) -> str:
|
||||
env_override = os.environ.get("LORA_MANAGER_MODEL_UPDATE_DB")
|
||||
return resolve_cache_path_with_migration(
|
||||
CacheType.MODEL_UPDATE,
|
||||
library_name=library_name,
|
||||
env_override=env_override,
|
||||
)
|
||||
|
||||
def on_library_changed(self) -> None:
|
||||
"""Switch to the database for the active library."""
|
||||
|
||||
if self._custom_db_path:
|
||||
return
|
||||
|
||||
library_name = self._get_active_library_name()
|
||||
new_path = self._resolve_default_path(library_name)
|
||||
if new_path == self._db_path:
|
||||
return
|
||||
|
||||
self._library_name = library_name
|
||||
self._db_path = new_path
|
||||
self._schema_initialized = False
|
||||
self._ensure_directory()
|
||||
self._initialize_schema()
|
||||
|
||||
@@ -262,11 +303,114 @@ class ModelUpdateService:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.executescript(self._SCHEMA)
|
||||
self._apply_migrations(conn)
|
||||
self._migrate_from_legacy_snapshot(conn)
|
||||
self._schema_initialized = True
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.error("Failed to initialize update schema: %s", exc, exc_info=True)
|
||||
raise
|
||||
|
||||
def _migrate_from_legacy_snapshot(self, conn: sqlite3.Connection) -> None:
|
||||
"""Copy update tracking data out of the legacy model snapshot database."""
|
||||
|
||||
if self._custom_db_path:
|
||||
return
|
||||
|
||||
try:
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
|
||||
legacy_path = get_persistent_cache(self._library_name).get_database_path()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not legacy_path or os.path.abspath(legacy_path) == os.path.abspath(self._db_path):
|
||||
return
|
||||
if not os.path.exists(legacy_path):
|
||||
return
|
||||
|
||||
try:
|
||||
existing_row = conn.execute(
|
||||
"SELECT 1 FROM model_update_status LIMIT 1"
|
||||
).fetchone()
|
||||
if existing_row:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
with sqlite3.connect(legacy_path, check_same_thread=False) as legacy_conn:
|
||||
legacy_conn.row_factory = sqlite3.Row
|
||||
status_rows = legacy_conn.execute(
|
||||
"""
|
||||
SELECT model_id, model_type, last_checked_at, should_ignore_model
|
||||
FROM model_update_status
|
||||
"""
|
||||
).fetchall()
|
||||
if not status_rows:
|
||||
return
|
||||
|
||||
version_rows = legacy_conn.execute(
|
||||
"""
|
||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore,
|
||||
early_access_ends_at, is_early_access
|
||||
FROM model_update_versions
|
||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT OR REPLACE INTO model_update_status (
|
||||
model_id, model_type, last_checked_at, should_ignore_model
|
||||
) VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
int(row["model_id"]),
|
||||
row["model_type"],
|
||||
row["last_checked_at"],
|
||||
int(row["should_ignore_model"] or 0),
|
||||
)
|
||||
for row in status_rows
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT OR REPLACE INTO model_update_versions (
|
||||
model_id, version_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore,
|
||||
early_access_ends_at, is_early_access
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
int(row["model_id"]),
|
||||
int(row["version_id"]),
|
||||
int(row["sort_index"] or 0),
|
||||
row["name"],
|
||||
row["base_model"],
|
||||
row["released_at"],
|
||||
row["size_bytes"],
|
||||
row["preview_url"],
|
||||
int(row["is_in_library"] or 0),
|
||||
int(row["should_ignore"] or 0),
|
||||
row["early_access_ends_at"],
|
||||
int(row["is_early_access"] or 0),
|
||||
)
|
||||
for row in version_rows
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"Migrated model update tracking data from legacy snapshot DB for %s",
|
||||
self._library_name,
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.debug("Legacy model update migration skipped: %s", exc)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning("Failed to migrate model update data: %s", exc, exc_info=True)
|
||||
|
||||
def _apply_migrations(self, conn: sqlite3.Connection) -> None:
|
||||
"""Ensure legacy databases match the current schema without dropping data."""
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from .service_registry import ServiceRegistry
|
||||
from .lora_scanner import LoraScanner
|
||||
from .metadata_service import get_default_metadata_provider
|
||||
from .checkpoint_scanner import CheckpointScanner
|
||||
from .settings_manager import get_settings_manager
|
||||
from .recipes.errors import RecipeNotFoundError
|
||||
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
||||
from natsort import natsorted
|
||||
@@ -951,6 +952,30 @@ class RecipeScanner:
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to update FTS index for recipe: %s", exc)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_recipe_gen_params(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a recipe copy with normalized generation parameter aliases added."""
|
||||
|
||||
normalized_recipe = dict(recipe_data)
|
||||
gen_params = recipe_data.get("gen_params")
|
||||
if not isinstance(gen_params, dict):
|
||||
return normalized_recipe
|
||||
|
||||
normalized_gen_params = dict(gen_params)
|
||||
for key, value in gen_params.items():
|
||||
if value in (None, ""):
|
||||
continue
|
||||
|
||||
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key)
|
||||
if normalized_key not in GenParamsMerger.ALLOWED_KEYS:
|
||||
continue
|
||||
|
||||
if normalized_gen_params.get(normalized_key) in (None, ""):
|
||||
normalized_gen_params[normalized_key] = value
|
||||
|
||||
normalized_recipe["gen_params"] = normalized_gen_params
|
||||
return normalized_recipe
|
||||
|
||||
async def _enrich_cache_metadata(self) -> None:
|
||||
"""Perform remote metadata enrichment after the initial scan."""
|
||||
|
||||
@@ -1090,6 +1115,14 @@ class RecipeScanner:
|
||||
@property
|
||||
def recipes_dir(self) -> str:
|
||||
"""Get path to recipes directory"""
|
||||
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
|
||||
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
|
||||
recipes_dir = os.path.abspath(
|
||||
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
|
||||
)
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
return recipes_dir
|
||||
|
||||
if not config.loras_roots:
|
||||
return ""
|
||||
|
||||
@@ -1336,6 +1369,7 @@ class RecipeScanner:
|
||||
# Ensure gen_params exists
|
||||
if "gen_params" not in recipe_data:
|
||||
recipe_data["gen_params"] = {}
|
||||
recipe_data = self._normalize_recipe_gen_params(recipe_data)
|
||||
|
||||
# Update lora information with local paths and availability
|
||||
lora_metadata_updated = await self._update_lora_information(recipe_data)
|
||||
@@ -1615,6 +1649,9 @@ class RecipeScanner:
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Coerce legacy or malformed checkpoint entries into a dict."""
|
||||
|
||||
if checkpoint_raw is None:
|
||||
return None
|
||||
|
||||
if isinstance(checkpoint_raw, dict):
|
||||
return dict(checkpoint_raw)
|
||||
|
||||
@@ -1632,9 +1669,6 @@ class RecipeScanner:
|
||||
"file_name": file_name,
|
||||
}
|
||||
|
||||
logger.warning(
|
||||
"Unexpected checkpoint payload type %s", type(checkpoint_raw).__name__
|
||||
)
|
||||
return None
|
||||
|
||||
def _enrich_checkpoint_entry(self, checkpoint: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -1790,6 +1824,7 @@ class RecipeScanner:
|
||||
filters: dict = None,
|
||||
search_options: dict = None,
|
||||
lora_hash: str = None,
|
||||
checkpoint_hash: str = None,
|
||||
bypass_filters: bool = True,
|
||||
folder: str | None = None,
|
||||
recursive: bool = True,
|
||||
@@ -1804,7 +1839,8 @@ class RecipeScanner:
|
||||
filters: Dictionary of filters to apply
|
||||
search_options: Dictionary of search options to apply
|
||||
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
||||
bypass_filters: If True, ignore other filters when a lora_hash is provided
|
||||
checkpoint_hash: Optional SHA256 hash of a checkpoint to filter recipes by
|
||||
bypass_filters: If True, ignore other filters when a hash filter is provided
|
||||
folder: Optional folder filter relative to recipes directory
|
||||
recursive: Whether to include recipes in subfolders of the selected folder
|
||||
"""
|
||||
@@ -1852,9 +1888,23 @@ class RecipeScanner:
|
||||
# Skip other filters if bypass_filters is True
|
||||
pass
|
||||
# Otherwise continue with normal filtering after applying LoRA hash filter
|
||||
elif checkpoint_hash:
|
||||
normalized_checkpoint_hash = checkpoint_hash.lower()
|
||||
filtered_data = [
|
||||
item
|
||||
for item in filtered_data
|
||||
if isinstance(item.get("checkpoint"), dict)
|
||||
and (item["checkpoint"].get("hash", "") or "").lower()
|
||||
== normalized_checkpoint_hash
|
||||
]
|
||||
|
||||
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
||||
if not (lora_hash and bypass_filters):
|
||||
if bypass_filters:
|
||||
pass
|
||||
|
||||
has_hash_filter = bool(lora_hash or checkpoint_hash)
|
||||
|
||||
# Skip further filtering if we're only filtering by model hash with bypass enabled
|
||||
if not (has_hash_filter and bypass_filters):
|
||||
# Apply folder filter before other criteria
|
||||
if folder is not None:
|
||||
normalized_folder = folder.strip("/")
|
||||
@@ -2030,7 +2080,10 @@ class RecipeScanner:
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
|
||||
# Get paginated items
|
||||
paginated_items = filtered_data[start_idx:end_idx]
|
||||
paginated_items = [
|
||||
self._normalize_recipe_gen_params(item)
|
||||
for item in filtered_data[start_idx:end_idx]
|
||||
]
|
||||
|
||||
# Add inLibrary information and URLs for each recipe
|
||||
for item in paginated_items:
|
||||
@@ -2089,8 +2142,18 @@ class RecipeScanner:
|
||||
if not recipe:
|
||||
return None
|
||||
|
||||
# Prefer the on-disk recipe JSON for fields that are not persisted in the
|
||||
# SQLite cache yet, such as source_path.
|
||||
merged_recipe = self._normalize_recipe_gen_params({**recipe})
|
||||
recipe_json = await self._load_recipe_json(recipe_id)
|
||||
if recipe_json:
|
||||
for field in ("source_path", "checkpoint", "loras", "gen_params"):
|
||||
if field not in recipe_json:
|
||||
merged_recipe.pop(field, None)
|
||||
merged_recipe.update(recipe_json)
|
||||
|
||||
# Format the recipe with all needed information
|
||||
formatted_recipe = {**recipe} # Copy all fields
|
||||
formatted_recipe = {**merged_recipe}
|
||||
|
||||
# Format file path to URL
|
||||
if "file_path" in formatted_recipe:
|
||||
@@ -2124,6 +2187,30 @@ class RecipeScanner:
|
||||
|
||||
return formatted_recipe
|
||||
|
||||
async def _load_recipe_json(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load the raw recipe JSON payload for a recipe ID if it exists."""
|
||||
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as f:
|
||||
recipe_data = json.load(f)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to load recipe JSON for %s from %s: %s",
|
||||
recipe_id,
|
||||
recipe_json_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
if not isinstance(recipe_data, dict):
|
||||
return None
|
||||
|
||||
return self._normalize_recipe_gen_params(recipe_data)
|
||||
|
||||
def _format_file_url(self, file_path: str) -> str:
|
||||
"""Format file path as URL for serving in web UI"""
|
||||
if not file_path:
|
||||
@@ -2334,6 +2421,38 @@ class RecipeScanner:
|
||||
|
||||
return matching_recipes
|
||||
|
||||
async def get_recipes_for_checkpoint(
|
||||
self, checkpoint_hash: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return recipes that reference a given checkpoint hash."""
|
||||
|
||||
if not checkpoint_hash:
|
||||
return []
|
||||
|
||||
normalized_hash = checkpoint_hash.lower()
|
||||
cache = await self.get_cached_data()
|
||||
matching_recipes: List[Dict[str, Any]] = []
|
||||
|
||||
for recipe in cache.raw_data:
|
||||
checkpoint = self._normalize_checkpoint_entry(recipe.get("checkpoint"))
|
||||
if not checkpoint:
|
||||
continue
|
||||
|
||||
enriched_checkpoint = self._enrich_checkpoint_entry(dict(checkpoint))
|
||||
if (enriched_checkpoint.get("hash") or "").lower() != normalized_hash:
|
||||
continue
|
||||
|
||||
recipe_copy = {**recipe}
|
||||
recipe_copy["checkpoint"] = enriched_checkpoint
|
||||
recipe_copy["loras"] = [
|
||||
self._enrich_lora_entry(dict(entry))
|
||||
for entry in recipe.get("loras", [])
|
||||
]
|
||||
recipe_copy["file_url"] = self._format_file_url(recipe.get("file_path"))
|
||||
matching_recipes.append(recipe_copy)
|
||||
|
||||
return matching_recipes
|
||||
|
||||
async def get_recipe_syntax_tokens(self, recipe_id: str) -> List[str]:
|
||||
"""Build LoRA syntax tokens for a recipe."""
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
@@ -14,7 +13,7 @@ import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
|
||||
from .errors import (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
@@ -104,9 +103,11 @@ class RecipeAnalysisService:
|
||||
extension = ".jpg" # Default
|
||||
|
||||
try:
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
|
||||
if civitai_match:
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
civitai_image_id = extract_civitai_image_id(url)
|
||||
if civitai_image_id:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=url
|
||||
)
|
||||
if not image_info:
|
||||
raise RecipeDownloadError(
|
||||
"Failed to fetch image information from Civitai"
|
||||
@@ -143,6 +144,12 @@ class RecipeAnalysisService:
|
||||
):
|
||||
metadata = metadata["meta"]
|
||||
|
||||
# Include modelVersionIds from root level if available
|
||||
# Civitai API returns modelVersionIds at root level, not in meta
|
||||
model_version_ids = image_info.get("modelVersionIds")
|
||||
if model_version_ids and isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
|
||||
# Validate that metadata contains meaningful recipe fields
|
||||
# If not, treat as None to trigger EXIF extraction from downloaded image
|
||||
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):
|
||||
|
||||
@@ -12,6 +12,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
from ...config import config
|
||||
from ...recipes.constants import GEN_PARAM_KEYS
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
|
||||
@@ -90,23 +91,7 @@ class RecipePersistenceService:
|
||||
current_time = time.time()
|
||||
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
||||
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
||||
|
||||
gen_params = metadata.get("gen_params") or {}
|
||||
if not gen_params and "raw_metadata" in metadata:
|
||||
raw_metadata = metadata.get("raw_metadata", {})
|
||||
gen_params = {
|
||||
"prompt": raw_metadata.get("prompt", ""),
|
||||
"negative_prompt": raw_metadata.get("negative_prompt", ""),
|
||||
"steps": raw_metadata.get("steps", ""),
|
||||
"sampler": raw_metadata.get("sampler", ""),
|
||||
"cfg_scale": raw_metadata.get("cfg_scale", ""),
|
||||
"seed": raw_metadata.get("seed", ""),
|
||||
"size": raw_metadata.get("size", ""),
|
||||
"clip_skip": raw_metadata.get("clip_skip", ""),
|
||||
}
|
||||
|
||||
# Drop checkpoint duplication from generation parameters to store it only at top level
|
||||
gen_params.pop("checkpoint", None)
|
||||
gen_params = self._sanitize_gen_params_for_storage(metadata)
|
||||
|
||||
fingerprint = calculate_recipe_fingerprint(loras_data)
|
||||
recipe_data: Dict[str, Any] = {
|
||||
@@ -133,6 +118,7 @@ class RecipePersistenceService:
|
||||
json_filename = f"{recipe_id}.recipe.json"
|
||||
json_path = os.path.join(recipes_dir, json_filename)
|
||||
json_path = os.path.normpath(json_path)
|
||||
|
||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
@@ -152,6 +138,30 @@ class RecipePersistenceService:
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_gen_params_for_storage(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
gen_params = metadata.get("gen_params")
|
||||
if isinstance(gen_params, dict) and gen_params:
|
||||
source = gen_params
|
||||
else:
|
||||
source = metadata.get("raw_metadata")
|
||||
|
||||
if not isinstance(source, dict):
|
||||
return {}
|
||||
|
||||
allowed_keys = set(GEN_PARAM_KEYS)
|
||||
sanitized: dict[str, Any] = {}
|
||||
for key in allowed_keys:
|
||||
if key not in source:
|
||||
continue
|
||||
value = source.get(key)
|
||||
if value in (None, ""):
|
||||
continue
|
||||
sanitized[key] = value
|
||||
|
||||
sanitized.pop("checkpoint", None)
|
||||
return sanitized
|
||||
|
||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||
"""Delete an existing recipe."""
|
||||
|
||||
@@ -173,11 +183,23 @@ class RecipePersistenceService:
|
||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
||||
"""Update persisted metadata for a recipe."""
|
||||
|
||||
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
|
||||
allowed_fields = (
|
||||
"title",
|
||||
"tags",
|
||||
"source_path",
|
||||
"preview_nsfw_level",
|
||||
"favorite",
|
||||
"gen_params",
|
||||
)
|
||||
|
||||
if not any(key in updates for key in allowed_fields):
|
||||
raise RecipeValidationError(
|
||||
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)"
|
||||
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)"
|
||||
)
|
||||
|
||||
if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
|
||||
raise RecipeValidationError("gen_params must be an object")
|
||||
|
||||
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||
if not success:
|
||||
raise RecipeNotFoundError("Recipe not found or update failed")
|
||||
|
||||
@@ -159,10 +159,51 @@ class ServiceRegistry:
|
||||
return cls._services[service_name]
|
||||
|
||||
from .model_update_service import ModelUpdateService
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
cache = get_persistent_cache()
|
||||
service = ModelUpdateService(cache.get_database_path())
|
||||
service = ModelUpdateService(settings_manager=get_settings_manager())
|
||||
cls._services[service_name] = service
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_downloaded_version_history_service(cls):
|
||||
"""Get or create the downloaded-version history service."""
|
||||
|
||||
service_name = "downloaded_version_history_service"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
from .downloaded_version_history_service import (
|
||||
DownloadedVersionHistoryService,
|
||||
)
|
||||
|
||||
service = DownloadedVersionHistoryService()
|
||||
cls._services[service_name] = service
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_backup_service(cls):
|
||||
"""Get or create the backup service."""
|
||||
|
||||
service_name = "backup_service"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
from .backup_service import BackupService
|
||||
|
||||
service = await BackupService.get_instance()
|
||||
cls._services[service_name] = service
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
@@ -255,4 +296,4 @@ class ServiceRegistry:
|
||||
"""Clear all registered services - mainly for testing"""
|
||||
cls._services.clear()
|
||||
cls._locks.clear()
|
||||
logger.info("Cleared all registered services")
|
||||
logger.info("Cleared all registered services")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -450,9 +450,9 @@ class TagFTSIndex:
|
||||
the tag_name, the result will include a "matched_alias" field.
|
||||
|
||||
Ranking is based on a combination of:
|
||||
1. FTS5 bm25 relevance score (how well the text matches)
|
||||
2. Post count (popularity)
|
||||
3. Exact prefix match boost (tag_name starts with query)
|
||||
1. Exact prefix match boost (tag_name starts with query)
|
||||
2. Post count to preserve expected autocomplete ordering
|
||||
3. FTS5 bm25 relevance score as a deterministic tie-breaker
|
||||
|
||||
Args:
|
||||
query: The search query string.
|
||||
@@ -484,65 +484,17 @@ class TagFTSIndex:
|
||||
with self._lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
# Build the SQL query with bm25 ranking
|
||||
# FTS5 bm25() returns negative scores, lower is better
|
||||
# We use -bm25() to get higher=better scores
|
||||
# Weights: -100.0 for exact matches, 1.0 for others
|
||||
# Add LOG10(post_count) weighting to boost popular tags
|
||||
# Use CASE to boost tag_name prefix matches above alias matches
|
||||
if categories:
|
||||
placeholders = ",".join("?" * len(categories))
|
||||
sql = f"""
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
END AS is_tag_name_match,
|
||||
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
|
||||
FROM tag_fts
|
||||
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||
WHERE tag_fts.searchable_text MATCH ?
|
||||
AND t.category IN ({placeholders})
|
||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
# Escape special LIKE characters and add wildcard
|
||||
query_escaped = (
|
||||
query_lower.lstrip("/")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
params = (
|
||||
[query_escaped + "%", fts_query]
|
||||
+ categories
|
||||
+ [limit, offset]
|
||||
)
|
||||
else:
|
||||
sql = """
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
END AS is_tag_name_match,
|
||||
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
|
||||
FROM tag_fts
|
||||
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||
WHERE tag_fts.searchable_text MATCH ?
|
||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
query_escaped = (
|
||||
query_lower.lstrip("/")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
params = [query_escaped + "%", fts_query, limit, offset]
|
||||
|
||||
sql, params = self._build_search_statement(
|
||||
query_lower=query_lower,
|
||||
fts_query=fts_query,
|
||||
categories=categories,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
cursor = conn.execute(sql, params)
|
||||
rows = cursor.fetchall()
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
for row in rows:
|
||||
result = {
|
||||
"tag_name": row[0],
|
||||
"category": row[1],
|
||||
@@ -571,6 +523,62 @@ class TagFTSIndex:
|
||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||
return []
|
||||
|
||||
def _build_search_statement(
|
||||
self,
|
||||
query_lower: str,
|
||||
fts_query: str,
|
||||
categories: Optional[List[int]],
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[str, list[object]]:
|
||||
"""Build the SQL statement and params for a tag search."""
|
||||
# Escape special LIKE characters and add wildcard
|
||||
query_escaped = (
|
||||
query_lower.lstrip("/")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
|
||||
# FTS5 bm25() returns negative scores, lower is better.
|
||||
# We use -bm25() to get higher=better scores, but keep post_count as the
|
||||
# primary sort within tag-name prefix matches so autocomplete ordering
|
||||
# remains aligned with the existing popularity-first behavior.
|
||||
if categories:
|
||||
placeholders = ",".join("?" * len(categories))
|
||||
sql = f"""
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
END AS is_tag_name_match,
|
||||
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
|
||||
FROM tag_fts
|
||||
CROSS JOIN tags t ON t.rowid = tag_fts.rowid
|
||||
WHERE tag_fts.searchable_text MATCH ?
|
||||
AND t.category IN ({placeholders})
|
||||
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [query_escaped + "%", fts_query] + categories + [limit, offset]
|
||||
else:
|
||||
sql = """
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
END AS is_tag_name_match,
|
||||
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
|
||||
FROM tag_fts
|
||||
JOIN tags t ON tag_fts.rowid = t.rowid
|
||||
WHERE tag_fts.searchable_text MATCH ?
|
||||
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [query_escaped + "%", fts_query, limit, offset]
|
||||
|
||||
return sql, params
|
||||
|
||||
def _find_matched_alias(
|
||||
self, query: str, tag_name: str, aliases_str: str
|
||||
) -> Optional[str]:
|
||||
|
||||
428
py/services/wildcard_service.py
Normal file
428
py/services/wildcard_service.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""Managed wildcard loading, search, and text expansion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ..utils.settings_paths import get_settings_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
|
||||
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
|
||||
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
|
||||
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*([0-9.]+)::")
|
||||
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
|
||||
|
||||
|
||||
def _normalize_wildcard_key(value: str) -> str:
|
||||
return value.replace("\\", "/").strip("/").lower()
|
||||
|
||||
|
||||
def _is_numeric_string(value: str) -> bool:
|
||||
return bool(_NUMERIC_PATTERN.match(value))
|
||||
|
||||
|
||||
def contains_dynamic_syntax(text: str) -> bool:
|
||||
"""Return True when text contains supported wildcard or option syntax."""
|
||||
|
||||
return isinstance(text, str) and bool(
|
||||
_WILDCARD_PATTERN.search(text) or _OPTION_PATTERN.search(text)
|
||||
)
|
||||
|
||||
|
||||
def get_wildcards_dir(create: bool = False) -> str:
|
||||
"""Return the managed wildcard directory inside the settings folder."""
|
||||
|
||||
settings_dir = get_settings_dir(create=create)
|
||||
wildcards_dir = os.path.join(settings_dir, "wildcards")
|
||||
if create:
|
||||
os.makedirs(wildcards_dir, exist_ok=True)
|
||||
return wildcards_dir
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WildcardEntry:
|
||||
key: str
|
||||
values_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WildcardMetadata:
|
||||
has_wildcards: bool
|
||||
wildcards_dir: str
|
||||
supported_formats: tuple[str, ...]
|
||||
|
||||
|
||||
class WildcardService:
|
||||
"""Discover wildcard keys and expand wildcard syntax."""
|
||||
|
||||
_instance: Optional["WildcardService"] = None
|
||||
|
||||
def __new__(cls) -> "WildcardService":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if getattr(self, "_initialized", False):
|
||||
return
|
||||
self._initialized = True
|
||||
self._cached_signature: tuple[tuple[str, int, int], ...] | None = None
|
||||
self._wildcard_dict: dict[str, list[str]] = {}
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "WildcardService":
|
||||
return cls()
|
||||
|
||||
def search_keys(
|
||||
self, search_term: str, limit: int = 20, offset: int = 0
|
||||
) -> list[str]:
|
||||
"""Search wildcard keys for autocomplete."""
|
||||
|
||||
normalized_term = _normalize_wildcard_key(search_term).strip()
|
||||
if not normalized_term:
|
||||
return []
|
||||
|
||||
ranked: list[tuple[int, str]] = []
|
||||
compact_term = normalized_term.replace("/", "")
|
||||
for key in self.get_wildcard_dict().keys():
|
||||
score = self._score_entry(key, normalized_term, compact_term)
|
||||
if score is not None:
|
||||
ranked.append((score, key))
|
||||
|
||||
ranked.sort(key=lambda item: (-item[0], item[1]))
|
||||
keys = [key for _, key in ranked]
|
||||
return keys[offset : offset + limit]
|
||||
|
||||
def expand_text(self, text: str, seed: int | None = None) -> str:
|
||||
"""Expand wildcard and dynamic prompt syntax for a text value."""
|
||||
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
|
||||
rng = random.Random(seed) if seed is not None else random.Random()
|
||||
wildcard_dict = self.get_wildcard_dict()
|
||||
if not wildcard_dict:
|
||||
return self._expand_options_only(text, rng)
|
||||
|
||||
current = text
|
||||
remaining_depth = 100
|
||||
|
||||
while remaining_depth > 0:
|
||||
remaining_depth -= 1
|
||||
after_options, options_replaced = self._replace_options(current, rng)
|
||||
current, wildcards_replaced = self._replace_wildcards(
|
||||
after_options, rng, wildcard_dict
|
||||
)
|
||||
if not options_replaced and not wildcards_replaced:
|
||||
break
|
||||
|
||||
return current
|
||||
|
||||
def get_wildcard_dict(self) -> dict[str, list[str]]:
|
||||
signature = self._build_signature()
|
||||
if signature != self._cached_signature:
|
||||
self._wildcard_dict = self._scan_wildcard_dict()
|
||||
self._cached_signature = signature
|
||||
return self._wildcard_dict
|
||||
|
||||
def get_entries(self) -> list[WildcardEntry]:
|
||||
return [
|
||||
WildcardEntry(key=key, values_count=len(values))
|
||||
for key, values in sorted(self.get_wildcard_dict().items())
|
||||
]
|
||||
|
||||
def get_metadata(self, *, create_dir: bool = False) -> WildcardMetadata:
|
||||
wildcards_dir = get_wildcards_dir(create=create_dir)
|
||||
return WildcardMetadata(
|
||||
has_wildcards=bool(self.get_wildcard_dict()),
|
||||
wildcards_dir=wildcards_dir,
|
||||
supported_formats=(".txt", ".yaml", ".yml", ".json"),
|
||||
)
|
||||
|
||||
def _build_signature(self) -> tuple[tuple[str, int, int], ...]:
|
||||
root = get_wildcards_dir(create=False)
|
||||
if not os.path.isdir(root):
|
||||
return ()
|
||||
|
||||
signature: list[tuple[str, int, int]] = []
|
||||
for current_root, _dirs, files in os.walk(root, followlinks=True):
|
||||
for file_name in sorted(files):
|
||||
if not file_name.lower().endswith((".txt", ".yaml", ".yml", ".json")):
|
||||
continue
|
||||
file_path = os.path.join(current_root, file_name)
|
||||
try:
|
||||
stat = os.stat(file_path)
|
||||
except OSError:
|
||||
continue
|
||||
rel_path = os.path.relpath(file_path, root).replace("\\", "/")
|
||||
signature.append((rel_path, int(stat.st_mtime_ns), int(stat.st_size)))
|
||||
signature.sort()
|
||||
return tuple(signature)
|
||||
|
||||
def _scan_wildcard_dict(self) -> dict[str, list[str]]:
|
||||
root = get_wildcards_dir(create=False)
|
||||
if not os.path.isdir(root):
|
||||
return {}
|
||||
|
||||
collected: dict[str, list[str]] = {}
|
||||
for current_root, _dirs, files in os.walk(root, followlinks=True):
|
||||
for file_name in sorted(files):
|
||||
file_path = os.path.join(current_root, file_name)
|
||||
lower_name = file_name.lower()
|
||||
try:
|
||||
if lower_name.endswith(".txt"):
|
||||
rel_path = os.path.relpath(file_path, root)
|
||||
key = _normalize_wildcard_key(os.path.splitext(rel_path)[0])
|
||||
values = self._read_txt(file_path)
|
||||
if values:
|
||||
collected[key] = values
|
||||
elif lower_name.endswith((".yaml", ".yml")):
|
||||
payload = self._read_yaml(file_path)
|
||||
self._merge_nested_entries(collected, payload)
|
||||
elif lower_name.endswith(".json"):
|
||||
payload = self._read_json(file_path)
|
||||
self._merge_nested_entries(collected, payload)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to load wildcard file %s: %s", file_path, exc)
|
||||
|
||||
return collected
|
||||
|
||||
def _read_txt(self, file_path: str) -> list[str]:
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as handle:
|
||||
return [line.strip() for line in handle.read().splitlines() if line.strip()]
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to read wildcard txt file %s: %s", file_path, exc)
|
||||
return []
|
||||
|
||||
def _read_yaml(self, file_path: str) -> Any:
|
||||
with open(file_path, "r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
|
||||
def _read_json(self, file_path: str) -> Any:
|
||||
with open(file_path, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
def _merge_nested_entries(
|
||||
self, collected: dict[str, list[str]], payload: Any
|
||||
) -> None:
|
||||
for key, values in self._flatten_payload(payload):
|
||||
collected[key] = values
|
||||
|
||||
def _flatten_payload(
|
||||
self, payload: Any, prefix: str = ""
|
||||
) -> list[tuple[str, list[str]]]:
|
||||
entries: list[tuple[str, list[str]]] = []
|
||||
|
||||
if isinstance(payload, dict):
|
||||
for key, value in payload.items():
|
||||
next_prefix = f"{prefix}/{key}" if prefix else str(key)
|
||||
entries.extend(self._flatten_payload(value, next_prefix))
|
||||
return entries
|
||||
|
||||
if isinstance(payload, list):
|
||||
normalized_prefix = _normalize_wildcard_key(prefix)
|
||||
values = [value.strip() for value in payload if isinstance(value, str) and value.strip()]
|
||||
if normalized_prefix and values:
|
||||
entries.append((normalized_prefix, values))
|
||||
return entries
|
||||
|
||||
return entries
|
||||
|
||||
def _score_entry(
|
||||
self, key: str, normalized_term: str, compact_term: str
|
||||
) -> int | None:
|
||||
key_compact = key.replace("/", "")
|
||||
if key == normalized_term:
|
||||
return 5000
|
||||
if key.startswith(normalized_term):
|
||||
return 4000
|
||||
if f"/{normalized_term}" in key:
|
||||
return 3500
|
||||
if normalized_term in key:
|
||||
return 3000
|
||||
if compact_term and key_compact.startswith(compact_term):
|
||||
return 2500
|
||||
if compact_term and compact_term in key_compact:
|
||||
return 2000
|
||||
return None
|
||||
|
||||
def _expand_options_only(self, text: str, rng: random.Random) -> str:
|
||||
current = text
|
||||
remaining_depth = 100
|
||||
while remaining_depth > 0:
|
||||
remaining_depth -= 1
|
||||
current, replaced = self._replace_options(current, rng)
|
||||
if not replaced:
|
||||
break
|
||||
return current
|
||||
|
||||
def _replace_options(
|
||||
self, text: str, rng: random.Random
|
||||
) -> tuple[str, bool]:
|
||||
replaced_any = False
|
||||
|
||||
def replace_option(match: re.Match[str]) -> str:
|
||||
nonlocal replaced_any
|
||||
replacement = self._resolve_option_group(match.group(1), rng)
|
||||
replaced_any = True
|
||||
return replacement
|
||||
|
||||
return _OPTION_PATTERN.sub(replace_option, text), replaced_any
|
||||
|
||||
def _resolve_option_group(self, group_text: str, rng: random.Random) -> str:
|
||||
options = group_text.split("|")
|
||||
multi_select_pattern = options[0].split("$$")
|
||||
select_range: tuple[int, int] | None = None
|
||||
select_separator = " "
|
||||
|
||||
if len(multi_select_pattern) > 1:
|
||||
count_spec = multi_select_pattern[0]
|
||||
range_match = re.match(r"(\d+)(-(\d+))?$", count_spec)
|
||||
shorthand_match = re.match(r"-(\d+)$", count_spec)
|
||||
if range_match:
|
||||
start_text = range_match.group(1)
|
||||
end_text = range_match.group(3)
|
||||
if end_text is not None and _is_numeric_string(start_text) and _is_numeric_string(end_text):
|
||||
select_range = (int(start_text), int(end_text))
|
||||
elif _is_numeric_string(start_text):
|
||||
value = int(start_text)
|
||||
select_range = (value, value)
|
||||
elif shorthand_match:
|
||||
end_text = shorthand_match.group(1)
|
||||
if _is_numeric_string(end_text):
|
||||
select_range = (1, int(end_text))
|
||||
|
||||
if select_range is not None and len(multi_select_pattern) == 2:
|
||||
options[0] = multi_select_pattern[1]
|
||||
elif select_range is not None and len(multi_select_pattern) >= 3:
|
||||
select_separator = multi_select_pattern[1]
|
||||
options[0] = multi_select_pattern[2]
|
||||
|
||||
weighted_options: list[tuple[float, str]] = []
|
||||
for option in options:
|
||||
weight = 1.0
|
||||
parts = option.split("::", 1)
|
||||
if len(parts) == 2 and _is_numeric_string(parts[0].strip()):
|
||||
weight = float(parts[0].strip())
|
||||
weighted_options.append((weight, option))
|
||||
|
||||
if select_range is None:
|
||||
selection_count = 1
|
||||
else:
|
||||
selection_count = rng.randint(select_range[0], select_range[1])
|
||||
|
||||
if selection_count <= 1:
|
||||
return self._strip_weight_prefix(self._weighted_choice(weighted_options, rng))
|
||||
|
||||
selection_count = min(selection_count, len(weighted_options))
|
||||
selected: list[str] = []
|
||||
used_indexes: set[int] = set()
|
||||
while len(selected) < selection_count:
|
||||
picked_index = self._weighted_choice_index(weighted_options, rng)
|
||||
if picked_index in used_indexes:
|
||||
if len(used_indexes) == len(weighted_options):
|
||||
break
|
||||
continue
|
||||
used_indexes.add(picked_index)
|
||||
selected.append(
|
||||
self._strip_weight_prefix(weighted_options[picked_index][1])
|
||||
)
|
||||
|
||||
return select_separator.join(selected)
|
||||
|
||||
def _weighted_choice(
|
||||
self, weighted_options: list[tuple[float, str]], rng: random.Random
|
||||
) -> str:
|
||||
return weighted_options[self._weighted_choice_index(weighted_options, rng)][1]
|
||||
|
||||
def _weighted_choice_index(
|
||||
self, weighted_options: list[tuple[float, str]], rng: random.Random
|
||||
) -> int:
|
||||
total_weight = sum(max(weight, 0.0) for weight, _value in weighted_options)
|
||||
if total_weight <= 0:
|
||||
return rng.randrange(len(weighted_options))
|
||||
|
||||
threshold = rng.uniform(0, total_weight)
|
||||
cumulative = 0.0
|
||||
for index, (weight, _value) in enumerate(weighted_options):
|
||||
cumulative += max(weight, 0.0)
|
||||
if threshold <= cumulative:
|
||||
return index
|
||||
return len(weighted_options) - 1
|
||||
|
||||
def _strip_weight_prefix(self, value: str) -> str:
|
||||
return _WEIGHTED_OPTION_PATTERN.sub("", value, count=1)
|
||||
|
||||
def _replace_wildcards(
|
||||
self,
|
||||
text: str,
|
||||
rng: random.Random,
|
||||
wildcard_dict: dict[str, list[str]],
|
||||
) -> tuple[str, bool]:
|
||||
replaced_any = False
|
||||
|
||||
def replace_match(match: re.Match[str]) -> str:
|
||||
nonlocal replaced_any
|
||||
replacement = self._resolve_wildcard_match(match.group(1), rng, wildcard_dict)
|
||||
if replacement is None:
|
||||
return match.group(0)
|
||||
replaced_any = True
|
||||
return replacement
|
||||
|
||||
return _WILDCARD_PATTERN.sub(replace_match, text), replaced_any
|
||||
|
||||
def _resolve_wildcard_match(
|
||||
self,
|
||||
raw_key: str,
|
||||
rng: random.Random,
|
||||
wildcard_dict: dict[str, list[str]],
|
||||
) -> str | None:
|
||||
keyword = _normalize_wildcard_key(raw_key)
|
||||
if keyword in wildcard_dict:
|
||||
return rng.choice(wildcard_dict[keyword])
|
||||
|
||||
if "*" in keyword:
|
||||
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
|
||||
compiled = re.compile(f"^{regex_pattern}$")
|
||||
aggregated: list[str] = []
|
||||
for key, values in wildcard_dict.items():
|
||||
if compiled.match(key):
|
||||
aggregated.extend(values)
|
||||
if aggregated:
|
||||
return rng.choice(aggregated)
|
||||
|
||||
if "/" not in keyword:
|
||||
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
|
||||
if fallback_keyword != keyword:
|
||||
return self._resolve_wildcard_match(fallback_keyword, rng, wildcard_dict)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_trigger_words_input(name: str) -> bool:
|
||||
return bool(_TRIGGER_WORD_PATTERN.match(name))
|
||||
|
||||
|
||||
def get_wildcard_service() -> WildcardService:
|
||||
return WildcardService.get_instance()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WildcardService",
|
||||
"WildcardMetadata",
|
||||
"contains_dynamic_syntax",
|
||||
"get_wildcard_service",
|
||||
"get_wildcards_dir",
|
||||
"is_trigger_words_input",
|
||||
]
|
||||
@@ -11,6 +11,8 @@ Target structure:
|
||||
│ └── symlink_map.json
|
||||
├── model/
|
||||
│ └── {library_name}.sqlite
|
||||
├── model_update/
|
||||
│ └── {library_name}.sqlite
|
||||
├── recipe/
|
||||
│ └── {library_name}.sqlite
|
||||
└── fts/
|
||||
@@ -36,6 +38,7 @@ class CacheType(Enum):
|
||||
"""Types of cache files managed by the cache path resolver."""
|
||||
|
||||
MODEL = "model"
|
||||
MODEL_UPDATE = "model_update"
|
||||
RECIPE = "recipe"
|
||||
RECIPE_FTS = "recipe_fts"
|
||||
TAG_FTS = "tag_fts"
|
||||
@@ -45,6 +48,7 @@ class CacheType(Enum):
|
||||
# Subdirectory structure for each cache type
|
||||
_CACHE_SUBDIRS = {
|
||||
CacheType.MODEL: "model",
|
||||
CacheType.MODEL_UPDATE: "model_update",
|
||||
CacheType.RECIPE: "recipe",
|
||||
CacheType.RECIPE_FTS: "fts",
|
||||
CacheType.TAG_FTS: "fts",
|
||||
@@ -54,6 +58,7 @@ _CACHE_SUBDIRS = {
|
||||
# Filename patterns for each cache type
|
||||
_CACHE_FILENAMES = {
|
||||
CacheType.MODEL: "{library_name}.sqlite",
|
||||
CacheType.MODEL_UPDATE: "{library_name}.sqlite",
|
||||
CacheType.RECIPE: "{library_name}.sqlite",
|
||||
CacheType.RECIPE_FTS: "recipe_fts.sqlite",
|
||||
CacheType.TAG_FTS: "tag_fts.sqlite",
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, Mapping, Sequence
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
|
||||
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
|
||||
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
|
||||
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
|
||||
_LICENSE_DEFAULTS: Dict[str, Any] = {
|
||||
"allowNoCredit": True,
|
||||
@@ -17,12 +20,141 @@ _COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
|
||||
_COMMERCIAL_SHIFT = 1
|
||||
|
||||
|
||||
def is_supported_civitai_page_host(hostname: str | None) -> bool:
|
||||
"""Return whether the hostname is a supported Civitai page domain."""
|
||||
|
||||
if not hostname:
|
||||
return False
|
||||
return hostname.lower() in _SUPPORTED_CIVITAI_PAGE_HOSTS
|
||||
|
||||
|
||||
def normalize_civitai_page_host(hostname: str | None) -> str:
|
||||
"""Return a supported Civitai page host or the default host."""
|
||||
|
||||
if not isinstance(hostname, str):
|
||||
return DEFAULT_CIVITAI_PAGE_HOST
|
||||
|
||||
normalized = hostname.strip().lower()
|
||||
if is_supported_civitai_page_host(normalized):
|
||||
return normalized
|
||||
|
||||
return DEFAULT_CIVITAI_PAGE_HOST
|
||||
|
||||
|
||||
def build_civitai_model_page_url(
|
||||
model_id: str | int | None,
|
||||
version_id: str | int | None = None,
|
||||
*,
|
||||
host: str | None = None,
|
||||
) -> str | None:
|
||||
"""Build a Civitai model or model-version page URL."""
|
||||
|
||||
normalized_host = normalize_civitai_page_host(host)
|
||||
normalized_model_id = str(model_id).strip() if model_id is not None else ""
|
||||
normalized_version_id = str(version_id).strip() if version_id is not None else ""
|
||||
|
||||
if normalized_model_id:
|
||||
path = f"/models/{normalized_model_id}"
|
||||
query = f"modelVersionId={normalized_version_id}" if normalized_version_id else ""
|
||||
return urlunparse(("https", normalized_host, path, "", query, ""))
|
||||
|
||||
if normalized_version_id:
|
||||
return urlunparse(
|
||||
("https", normalized_host, f"/model-versions/{normalized_version_id}", "", "", "")
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_supported_civitai_page_url(url: str | None):
|
||||
if not url:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return None
|
||||
|
||||
if not is_supported_civitai_page_host(parsed.hostname):
|
||||
return None
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def extract_civitai_model_url_parts(
|
||||
url: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Extract model and version identifiers from a supported Civitai model URL."""
|
||||
|
||||
parsed = _parse_supported_civitai_page_url(url)
|
||||
if parsed is None:
|
||||
return None, None
|
||||
|
||||
path_match = re.search(r"/models/(\d+)", parsed.path)
|
||||
if not path_match:
|
||||
return None, None
|
||||
|
||||
model_id = path_match.group(1)
|
||||
|
||||
query_params = parse_qs(parsed.query)
|
||||
version_values = query_params.get("modelVersionId") or []
|
||||
version_id = version_values[0] if version_values else None
|
||||
return model_id, version_id
|
||||
|
||||
|
||||
def extract_civitai_image_id(url: str | None) -> str | None:
|
||||
"""Extract the image identifier from a supported Civitai image page URL."""
|
||||
|
||||
parsed = _parse_supported_civitai_page_url(url)
|
||||
if parsed is None:
|
||||
return None
|
||||
|
||||
path_match = re.search(r"/images/(\d+)", parsed.path)
|
||||
if not path_match:
|
||||
return None
|
||||
|
||||
return path_match.group(1)
|
||||
|
||||
|
||||
def normalize_civitai_download_url(url: str | None) -> str | None:
|
||||
"""Rewrite Civitai download URLs to the canonical authenticated host."""
|
||||
|
||||
if not url:
|
||||
return url
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return url
|
||||
|
||||
hostname = parsed.hostname.lower() if parsed.hostname else None
|
||||
if hostname != "civitai.red" or not parsed.path.startswith("/api/download/"):
|
||||
return url
|
||||
|
||||
return urlunparse(parsed._replace(netloc="civitai.com"))
|
||||
|
||||
|
||||
def extract_civitai_page_host(url: str | None) -> str | None:
|
||||
"""Extract the supported Civitai page host from a URL."""
|
||||
|
||||
parsed = _parse_supported_civitai_page_url(url)
|
||||
if parsed is None:
|
||||
return None
|
||||
|
||||
return parsed.hostname.lower() if parsed.hostname else None
|
||||
|
||||
|
||||
def _normalize_commercial_values(value: Any) -> Sequence[str]:
|
||||
"""Return a normalized list of commercial permissions preserving source values."""
|
||||
|
||||
def _split_aggregate(value_str: str) -> list[str]:
|
||||
stripped = value_str.strip()
|
||||
looks_aggregate = "," in stripped or (stripped.startswith("{") and stripped.endswith("}"))
|
||||
looks_aggregate = "," in stripped or (
|
||||
stripped.startswith("{") and stripped.endswith("}")
|
||||
)
|
||||
if not looks_aggregate:
|
||||
return [value_str]
|
||||
|
||||
@@ -141,14 +273,18 @@ def build_license_flags(payload: Mapping[str, Any] | None) -> int:
|
||||
return flags
|
||||
|
||||
|
||||
def resolve_license_info(model_data: Mapping[str, Any] | None) -> tuple[Dict[str, Any], int]:
|
||||
def resolve_license_info(
|
||||
model_data: Mapping[str, Any] | None,
|
||||
) -> tuple[Dict[str, Any], int]:
|
||||
"""Return normalized license payload and its encoded bitset."""
|
||||
|
||||
payload = resolve_license_payload(model_data)
|
||||
return payload, build_license_flags(payload)
|
||||
|
||||
|
||||
def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -> tuple[str | None, bool]:
|
||||
def rewrite_preview_url(
|
||||
source_url: str | None, media_type: str | None = None
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Rewrite Civitai preview URLs to use optimized renditions.
|
||||
|
||||
Args:
|
||||
@@ -168,7 +304,12 @@ def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -
|
||||
except ValueError:
|
||||
return source_url, False
|
||||
|
||||
if parsed.netloc.lower() != "image.civitai.com":
|
||||
hostname = parsed.hostname
|
||||
if hostname is None:
|
||||
return source_url, False
|
||||
|
||||
hostname = hostname.lower()
|
||||
if hostname == "civitai.com" or not hostname.endswith(".civitai.com"):
|
||||
return source_url, False
|
||||
|
||||
replacement = "/width=450,optimized=true"
|
||||
@@ -188,6 +329,10 @@ def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -
|
||||
|
||||
__all__ = [
|
||||
"build_license_flags",
|
||||
"extract_civitai_image_id",
|
||||
"extract_civitai_page_host",
|
||||
"extract_civitai_model_url_parts",
|
||||
"is_supported_civitai_page_host",
|
||||
"resolve_license_payload",
|
||||
"resolve_license_info",
|
||||
"rewrite_preview_url",
|
||||
|
||||
@@ -101,6 +101,7 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
[
|
||||
"ZImageTurbo",
|
||||
"ZImageBase",
|
||||
"Wan Video 1.3B t2v",
|
||||
"Wan Video 14B t2v",
|
||||
"Wan Video 14B i2v 480p",
|
||||
@@ -110,6 +111,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
"Wan Video 2.2 T2V-A14B",
|
||||
"Wan Video 2.5 T2V",
|
||||
"Wan Video 2.5 I2V",
|
||||
"CogVideoX",
|
||||
"Mochi",
|
||||
"Qwen",
|
||||
]
|
||||
)
|
||||
@@ -151,6 +154,7 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
"NoobAI",
|
||||
"Illustrious",
|
||||
"Pony",
|
||||
"Pony V7",
|
||||
"HiDream",
|
||||
"Qwen",
|
||||
"ZImageTurbo",
|
||||
@@ -158,6 +162,9 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
"SVD",
|
||||
"LTXV",
|
||||
"LTXV2",
|
||||
"LTXV 2.3",
|
||||
"CogVideoX",
|
||||
"Mochi",
|
||||
"Wan Video",
|
||||
"Wan Video 1.3B t2v",
|
||||
"Wan Video 14B t2v",
|
||||
@@ -166,6 +173,9 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
"Wan Video 2.2 TI2V-5B",
|
||||
"Wan Video 2.2 T2V-A14B",
|
||||
"Wan Video 2.2 I2V-A14B",
|
||||
"Wan Video 2.5 T2V",
|
||||
"Wan Video 2.5 I2V",
|
||||
"Hunyuan Video",
|
||||
"Anima",
|
||||
]
|
||||
)
|
||||
|
||||
136
py/utils/session_logging.py
Normal file
136
py/utils/session_logging.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
_SESSION_HANDLER_NAME = "lora_manager_standalone_session_memory"
|
||||
_FILE_HANDLER_NAME = "lora_manager_standalone_session_file"
|
||||
_session_state: "StandaloneSessionLogState | None" = None
|
||||
_session_lock = threading.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class StandaloneSessionLogState:
|
||||
started_at: str
|
||||
session_id: str
|
||||
log_file_path: str | None
|
||||
memory_handler: "StandaloneSessionMemoryHandler"
|
||||
|
||||
|
||||
class StandaloneSessionMemoryHandler(logging.Handler):
|
||||
def __init__(self, capacity: int = 4000) -> None:
|
||||
super().__init__()
|
||||
self._entries: deque[str] = deque(maxlen=capacity)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
rendered = self.format(record)
|
||||
except Exception:
|
||||
rendered = record.getMessage()
|
||||
|
||||
with self._lock:
|
||||
self._entries.append(rendered)
|
||||
|
||||
def render(self, max_lines: int | None = None) -> str:
|
||||
with self._lock:
|
||||
entries = list(self._entries)
|
||||
|
||||
if max_lines is not None and max_lines > 0:
|
||||
entries = entries[-max_lines:]
|
||||
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
return "\n".join(entries) + "\n"
|
||||
|
||||
|
||||
def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str | None:
|
||||
if not settings_file:
|
||||
return None
|
||||
|
||||
settings_dir = os.path.dirname(os.path.abspath(settings_file))
|
||||
log_dir = os.path.join(settings_dir, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
timestamp = started_at.strftime("%Y%m%dT%H%M%SZ")
|
||||
return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
|
||||
|
||||
|
||||
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
|
||||
global _session_state
|
||||
|
||||
with _session_lock:
|
||||
if _session_state is not None:
|
||||
return _session_state
|
||||
|
||||
started_dt = datetime.now(timezone.utc)
|
||||
started_at = started_dt.replace(microsecond=0).isoformat()
|
||||
session_id = f"{started_dt.strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
|
||||
formatter = logging.Formatter(LOG_FORMAT)
|
||||
root_logger = logging.getLogger()
|
||||
if root_logger.level > logging.INFO:
|
||||
root_logger.setLevel(logging.INFO)
|
||||
|
||||
memory_handler = StandaloneSessionMemoryHandler()
|
||||
memory_handler.set_name(_SESSION_HANDLER_NAME)
|
||||
memory_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(memory_handler)
|
||||
|
||||
log_file_path = _build_log_file_path(settings_file, started_dt)
|
||||
if log_file_path:
|
||||
file_handler = logging.FileHandler(log_file_path, encoding="utf-8")
|
||||
file_handler.set_name(_FILE_HANDLER_NAME)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
_session_state = StandaloneSessionLogState(
|
||||
started_at=started_at,
|
||||
session_id=session_id,
|
||||
log_file_path=log_file_path,
|
||||
memory_handler=memory_handler,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("lora-manager-standalone")
|
||||
logger.info("LoRA Manager standalone startup time: %s", started_at)
|
||||
logger.info("LoRA Manager standalone session id: %s", session_id)
|
||||
if log_file_path:
|
||||
logger.info("LoRA Manager standalone session log path: %s", log_file_path)
|
||||
|
||||
return _session_state
|
||||
|
||||
|
||||
def get_standalone_session_log_snapshot(max_lines: int = 2000) -> dict[str, Any] | None:
|
||||
state = _session_state
|
||||
if state is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"started_at": state.started_at,
|
||||
"session_id": state.session_id,
|
||||
"log_file_path": state.log_file_path,
|
||||
"in_memory_text": state.memory_handler.render(max_lines=max_lines),
|
||||
}
|
||||
|
||||
|
||||
def reset_standalone_session_logging_for_tests() -> None:
|
||||
global _session_state
|
||||
|
||||
with _session_lock:
|
||||
root_logger = logging.getLogger()
|
||||
handlers_to_remove = [
|
||||
handler
|
||||
for handler in root_logger.handlers
|
||||
if handler.get_name() in {_SESSION_HANDLER_NAME, _FILE_HANDLER_NAME}
|
||||
]
|
||||
for handler in handlers_to_remove:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
_session_state = None
|
||||
@@ -29,6 +29,18 @@ if not standalone_mode:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_CHECKPOINT_EXTENSIONS = {
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".safetensors",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
|
||||
class UsageStats:
|
||||
"""Track usage statistics for models and save to JSON"""
|
||||
|
||||
@@ -291,6 +303,151 @@ class UsageStats:
|
||||
# Process loras
|
||||
if LORAS in metadata and isinstance(metadata[LORAS], dict):
|
||||
await self._process_loras(metadata[LORAS], today)
|
||||
|
||||
def _increment_usage_counter(self, category: str, stat_key: str, today_date: str) -> None:
|
||||
"""Increment usage counters for a resolved stats key."""
|
||||
if stat_key not in self.stats[category]:
|
||||
self.stats[category][stat_key] = {
|
||||
"total": 0,
|
||||
"history": {}
|
||||
}
|
||||
|
||||
self.stats[category][stat_key]["total"] += 1
|
||||
|
||||
if today_date not in self.stats[category][stat_key]["history"]:
|
||||
self.stats[category][stat_key]["history"][today_date] = 0
|
||||
self.stats[category][stat_key]["history"][today_date] += 1
|
||||
|
||||
def _normalize_model_lookup_name(self, model_name: str) -> str:
|
||||
"""Normalize a model reference to its base filename without extension."""
|
||||
return os.path.splitext(os.path.basename(model_name))[0]
|
||||
|
||||
async def _find_cached_checkpoint_entry(self, checkpoint_scanner, model_name: str):
|
||||
"""Best-effort lookup for a checkpoint cache entry by filename/model name."""
|
||||
get_cached_data = getattr(checkpoint_scanner, "get_cached_data", None)
|
||||
if not callable(get_cached_data):
|
||||
return None
|
||||
|
||||
cache = await get_cached_data()
|
||||
raw_data = getattr(cache, "raw_data", None)
|
||||
if not isinstance(raw_data, list):
|
||||
return None
|
||||
|
||||
normalized_name = self._normalize_model_lookup_name(model_name)
|
||||
for entry in raw_data:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
for candidate_key in ("file_name", "model_name", "file_path"):
|
||||
candidate_value = entry.get(candidate_key)
|
||||
if not candidate_value or not isinstance(candidate_value, str):
|
||||
continue
|
||||
if self._normalize_model_lookup_name(candidate_value) == normalized_name:
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
async def _find_checkpoint_file_on_disk(self, checkpoint_scanner, model_name: str):
|
||||
"""Search checkpoint roots directly for a matching file.
|
||||
|
||||
This is used when usage tracking sees a checkpoint name before the cache has
|
||||
been refreshed. The lookup is intentionally exact: we only match the model
|
||||
basename and supported checkpoint extensions.
|
||||
"""
|
||||
get_model_roots = getattr(checkpoint_scanner, "get_model_roots", None)
|
||||
if not callable(get_model_roots):
|
||||
return None
|
||||
|
||||
roots = [root for root in get_model_roots() if root]
|
||||
if not roots:
|
||||
return None
|
||||
|
||||
supported_extensions = getattr(
|
||||
checkpoint_scanner, "file_extensions", _DEFAULT_CHECKPOINT_EXTENSIONS
|
||||
)
|
||||
if not isinstance(supported_extensions, (set, frozenset, list, tuple)):
|
||||
supported_extensions = _DEFAULT_CHECKPOINT_EXTENSIONS
|
||||
|
||||
normalized_name = self._normalize_model_lookup_name(model_name)
|
||||
matches: list[str] = []
|
||||
|
||||
for root_path in roots:
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for dirpath, _dirnames, filenames in os.walk(root_path):
|
||||
for filename in filenames:
|
||||
extension = os.path.splitext(filename)[1].lower()
|
||||
if extension not in supported_extensions:
|
||||
continue
|
||||
|
||||
if os.path.splitext(filename)[0] != normalized_name:
|
||||
continue
|
||||
|
||||
matches.append(os.path.join(dirpath, filename).replace(os.sep, "/"))
|
||||
|
||||
if len(matches) > 1:
|
||||
logger.warning(
|
||||
"Multiple checkpoint files matched '%s'; skipping usage tracking: %s",
|
||||
normalized_name,
|
||||
", ".join(matches),
|
||||
)
|
||||
return None
|
||||
|
||||
return matches[0] if matches else None
|
||||
|
||||
async def _resolve_checkpoint_hash(self, checkpoint_scanner, model_name: str):
|
||||
"""Resolve a checkpoint hash, calculating pending hashes on demand when needed."""
|
||||
model_filename = self._normalize_model_lookup_name(model_name)
|
||||
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
|
||||
if model_hash:
|
||||
return model_hash
|
||||
|
||||
cached_entry = await self._find_cached_checkpoint_entry(checkpoint_scanner, model_name)
|
||||
if cached_entry:
|
||||
cached_hash = cached_entry.get("sha256")
|
||||
if cached_hash:
|
||||
return cached_hash
|
||||
|
||||
hash_status = cached_entry.get("hash_status")
|
||||
if hash_status and hash_status != "pending":
|
||||
logger.warning(
|
||||
"Checkpoint '%s' has hash_status=%s; skipping usage tracking",
|
||||
model_filename,
|
||||
hash_status,
|
||||
)
|
||||
return None
|
||||
|
||||
file_path = cached_entry.get("file_path") if cached_entry else None
|
||||
if not file_path:
|
||||
file_path = await self._find_checkpoint_file_on_disk(
|
||||
checkpoint_scanner, model_name
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
logger.warning(
|
||||
f"No hash found for checkpoint '{model_filename}', skipping usage tracking"
|
||||
)
|
||||
return None
|
||||
|
||||
calculate_hash = getattr(checkpoint_scanner, "calculate_hash_for_model", None)
|
||||
if not callable(calculate_hash):
|
||||
logger.warning("Checkpoint scanner not available for usage tracking")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Calculating hash for checkpoint '%s' from %s",
|
||||
model_filename,
|
||||
file_path,
|
||||
)
|
||||
calculated_hash = await calculate_hash(file_path)
|
||||
if calculated_hash:
|
||||
return calculated_hash
|
||||
|
||||
logger.warning(
|
||||
f"Failed to calculate hash for checkpoint '{model_filename}', skipping usage tracking"
|
||||
)
|
||||
return None
|
||||
|
||||
async def _process_checkpoints(self, models_data, today_date):
|
||||
"""Process checkpoint models from metadata"""
|
||||
@@ -311,27 +468,12 @@ class UsageStats:
|
||||
model_name = model_info.get("name")
|
||||
if not model_name:
|
||||
continue
|
||||
|
||||
# Clean up filename (remove extension if present)
|
||||
model_filename = os.path.splitext(os.path.basename(model_name))[0]
|
||||
|
||||
# Get hash for this checkpoint
|
||||
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
|
||||
if model_hash:
|
||||
# Update stats for this checkpoint with date tracking
|
||||
if model_hash not in self.stats["checkpoints"]:
|
||||
self.stats["checkpoints"][model_hash] = {
|
||||
"total": 0,
|
||||
"history": {}
|
||||
}
|
||||
|
||||
# Increment total count
|
||||
self.stats["checkpoints"][model_hash]["total"] += 1
|
||||
|
||||
# Increment today's count
|
||||
if today_date not in self.stats["checkpoints"][model_hash]["history"]:
|
||||
self.stats["checkpoints"][model_hash]["history"][today_date] = 0
|
||||
self.stats["checkpoints"][model_hash]["history"][today_date] += 1
|
||||
|
||||
model_hash = await self._resolve_checkpoint_hash(checkpoint_scanner, model_name)
|
||||
if not model_hash:
|
||||
continue
|
||||
|
||||
self._increment_usage_counter("checkpoints", model_hash, today_date)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing checkpoint usage: {e}", exc_info=True)
|
||||
|
||||
@@ -360,21 +502,11 @@ class UsageStats:
|
||||
|
||||
# Get hash for this LoRA
|
||||
lora_hash = lora_scanner.get_hash_by_filename(lora_name)
|
||||
if lora_hash:
|
||||
# Update stats for this LoRA with date tracking
|
||||
if lora_hash not in self.stats["loras"]:
|
||||
self.stats["loras"][lora_hash] = {
|
||||
"total": 0,
|
||||
"history": {}
|
||||
}
|
||||
|
||||
# Increment total count
|
||||
self.stats["loras"][lora_hash]["total"] += 1
|
||||
|
||||
# Increment today's count
|
||||
if today_date not in self.stats["loras"][lora_hash]["history"]:
|
||||
self.stats["loras"][lora_hash]["history"][today_date] = 0
|
||||
self.stats["loras"][lora_hash]["history"][today_date] += 1
|
||||
if not lora_hash:
|
||||
logger.warning(f"No hash found for LoRA '{lora_name}', skipping usage tracking")
|
||||
continue
|
||||
|
||||
self._increment_usage_counter("loras", lora_hash, today_date)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.0.0"
|
||||
version = "1.0.5"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
@@ -14,7 +14,8 @@ dependencies = [
|
||||
"natsort",
|
||||
"GitPython",
|
||||
"aiosqlite",
|
||||
"platformdirs"
|
||||
"platformdirs",
|
||||
"pyyaml"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -11,3 +11,4 @@ GitPython
|
||||
aiosqlite
|
||||
beautifulsoup4
|
||||
platformdirs
|
||||
pyyaml
|
||||
|
||||
@@ -113,6 +113,8 @@ import asyncio
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
from py.utils.session_logging import setup_standalone_session_logging
|
||||
|
||||
# Increase allowable header size to align with in-ComfyUI configuration.
|
||||
HEADER_SIZE_LIMIT = 16384
|
||||
|
||||
@@ -125,6 +127,8 @@ logger = logging.getLogger("lora-manager-standalone")
|
||||
# Configure aiohttp access logger to be less verbose
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||
|
||||
setup_standalone_session_logging(ensure_settings_file(logger))
|
||||
|
||||
|
||||
# Add specific suppression for connection reset errors
|
||||
class ConnectionResetFilter(logging.Filter):
|
||||
|
||||
@@ -243,3 +243,58 @@
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.excluded-view-banner {
|
||||
margin-bottom: var(--space-2);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
|
||||
var(--card-bg)
|
||||
);
|
||||
}
|
||||
|
||||
.excluded-view-banner__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.excluded-view-banner__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.excluded-view-banner__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.excluded-view-banner__back:hover {
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.excluded-view-banner__content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.excluded-view-banner__back {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,3 +680,22 @@
|
||||
margin-left: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.excluded-model {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.model-excluded-badge {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in oklab, var(--warning-color, #d97706) 85%, white 15%);
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -835,7 +835,8 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .creator-info,
|
||||
[data-theme="dark"] .civitai-view {
|
||||
[data-theme="dark"] .civitai-view,
|
||||
[data-theme="dark"] .modal-send-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
@@ -875,7 +876,8 @@
|
||||
|
||||
/* Add hover effect for creator info */
|
||||
.creator-info:hover,
|
||||
.civitai-view:hover {
|
||||
.civitai-view:hover,
|
||||
.modal-send-btn:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
transform: translateY(-1px);
|
||||
@@ -910,3 +912,42 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Send to ComfyUI Button */
|
||||
.modal-send-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .modal-send-btn {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.modal-send-btn:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-send-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-send-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-send-btn span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -163,6 +163,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model-version-row.is-clickable .version-actions,
|
||||
.model-version-row.is-clickable .version-badges,
|
||||
.model-version-row.is-clickable .version-action,
|
||||
.model-version-row.is-clickable .version-civitai-link {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.model-version-row.is-clickable .version-action,
|
||||
.model-version-row.is-clickable .version-civitai-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.model-version-row.is-current {
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent),
|
||||
@@ -217,6 +229,7 @@
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.versions-tab-version-name {
|
||||
@@ -226,6 +239,27 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.version-civitai-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
flex: 0 0 auto;
|
||||
transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.version-civitai-link:hover,
|
||||
.version-civitai-link:focus-visible {
|
||||
color: var(--lora-accent);
|
||||
background: color-mix(in oklch, var(--lora-accent) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.version-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -311,6 +311,161 @@ button:disabled,
|
||||
color: var(--lora-error, #ef4444);
|
||||
}
|
||||
|
||||
.backup-status {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-status {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.backup-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.backup-summary-card {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-summary-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.backup-summary-label {
|
||||
color: var(--text-color);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.backup-summary-value {
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.backup-summary-value.status-enabled {
|
||||
color: var(--lora-success, #10b981);
|
||||
}
|
||||
|
||||
.backup-summary-value.status-disabled {
|
||||
color: var(--lora-error, #ef4444);
|
||||
}
|
||||
|
||||
.backup-status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.backup-status-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 180px) 1fr;
|
||||
gap: var(--space-2);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.backup-status-label {
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.backup-status-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-status-primary {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.backup-status-secondary {
|
||||
color: var(--text-color);
|
||||
opacity: 0.72;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.backup-location-details {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-location-details {
|
||||
border-color: var(--lora-border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.backup-location-details summary {
|
||||
cursor: pointer;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.backup-location-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.backup-location-panel .text-btn {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.backup-location-path {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--text-color);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .backup-location-path {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.backup-status-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-location-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-location-panel .text-btn {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add styles for delete preview image */
|
||||
.delete-preview {
|
||||
max-width: 150px;
|
||||
|
||||
281
static/css/components/modal/doctor-modal.css
Normal file
281
static/css/components/modal/doctor-modal.css
Normal file
@@ -0,0 +1,281 @@
|
||||
.doctor-trigger {
|
||||
min-width: 120px;
|
||||
position: relative;
|
||||
border-color: color-mix(in srgb, var(--lora-accent) 24%, var(--border-color));
|
||||
}
|
||||
|
||||
.doctor-trigger i {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.doctor-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--lora-error);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doctor-status-badge.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doctor-modal {
|
||||
width: min(960px, 92vw);
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.doctor-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.doctor-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--lora-border);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.doctor-kicker {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--lora-accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.doctor-hero h2 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.doctor-hero p {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.doctor-summary-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.doctor-status-ok {
|
||||
background: color-mix(in oklch, var(--lora-success) 14%, transparent);
|
||||
border-color: color-mix(in oklch, var(--lora-success) 28%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-success) 72%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-status-warning {
|
||||
background: color-mix(in oklch, var(--lora-warning) 16%, transparent);
|
||||
border-color: color-mix(in oklch, var(--lora-warning) 30%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-warning) 70%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-status-error {
|
||||
background: color-mix(in oklch, var(--lora-error) 16%, transparent);
|
||||
border-color: color-mix(in oklch, var(--lora-error) 30%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-error) 68%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-loading-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
min-height: 22px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, visibility 0.18s ease;
|
||||
}
|
||||
|
||||
.doctor-loading-state.visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.doctor-issues-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.doctor-issue-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="warning"] {
|
||||
border-color: color-mix(in oklch, var(--lora-warning) 32%, var(--lora-border));
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="error"] {
|
||||
border-color: color-mix(in oklch, var(--lora-error) 28%, var(--lora-border));
|
||||
}
|
||||
|
||||
.doctor-issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.doctor-issue-header h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.doctor-issue-summary {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.doctor-issue-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="ok"] .doctor-issue-tag {
|
||||
background: color-mix(in oklch, var(--lora-success) 14%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-success) 72%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="warning"] .doctor-issue-tag {
|
||||
background: color-mix(in oklch, var(--lora-warning) 16%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-warning) 70%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-issue-card[data-status="error"] .doctor-issue-tag {
|
||||
background: color-mix(in oklch, var(--lora-error) 16%, transparent);
|
||||
color: color-mix(in oklch, var(--lora-error) 68%, var(--text-color));
|
||||
}
|
||||
|
||||
.doctor-issue-details {
|
||||
margin: 14px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.doctor-issue-details li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.doctor-issue-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.doctor-inline-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.doctor-inline-detail {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.doctor-inline-detail strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.doctor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.doctor-footer-note {
|
||||
color: var(--text-color);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.doctor-footer-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .doctor-hero,
|
||||
[data-theme="dark"] .doctor-issue-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: var(--lora-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.doctor-trigger {
|
||||
min-width: auto;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.doctor-trigger span:not(.doctor-status-badge) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doctor-hero,
|
||||
.doctor-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.doctor-hero {
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
.doctor-summary-badge {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.doctor-footer-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.doctor-footer-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,13 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@supports not (scrollbar-gutter: stable) {
|
||||
.settings-content {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content .settings-form {
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.param-header label {
|
||||
@@ -431,7 +432,14 @@
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
.param-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.copy-btn,
|
||||
.edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
@@ -442,7 +450,8 @@
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
.copy-btn:hover,
|
||||
.edit-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
@@ -461,6 +470,48 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.param-content.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.param-content.is-placeholder {
|
||||
color: color-mix(in oklch, var(--text-color), transparent 35%);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.param-editor {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.param-editor.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.param-textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 10px 12px;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.param-editor-hint {
|
||||
font-size: 0.78em;
|
||||
line-height: 1.4;
|
||||
color: color-mix(in oklch, var(--text-color), transparent 35%);
|
||||
}
|
||||
|
||||
/* Other Parameters */
|
||||
.other-params {
|
||||
display: flex;
|
||||
@@ -565,6 +616,26 @@
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.send-recipe-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.send-recipe-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--lora-surface);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#recipeLorasCount {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
@@ -965,6 +1036,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 860px) {
|
||||
#recipeModal .modal-content {
|
||||
padding-top: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-modal-header {
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recipe-modal-header h2 {
|
||||
font-size: 1.25em;
|
||||
max-height: 2.5em;
|
||||
}
|
||||
|
||||
.recipe-tags-container {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recipe-gen-params {
|
||||
height: auto;
|
||||
max-height: 210px;
|
||||
}
|
||||
|
||||
.recipe-gen-params h3 {
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.gen-params-container {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.param-content {
|
||||
max-height: 90px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.param-textarea {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.other-params {
|
||||
margin-top: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recipe-bottom-section {
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-section-header {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
.badge-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -21,6 +21,27 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.downloaded-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: color-mix(in oklch, var(--badge-update-bg, #4a90e2) 22%, transparent);
|
||||
color: var(--badge-update-bg, #4a90e2);
|
||||
border: 1px solid color-mix(in oklch, var(--badge-update-bg, #4a90e2) 50%, transparent);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.downloaded-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Early Access Badge */
|
||||
.early-access-badge {
|
||||
display: inline-flex;
|
||||
@@ -108,4 +129,4 @@
|
||||
color: var(--lora-error);
|
||||
font-size: 0.9em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
@import 'components/modal/delete-modal.css';
|
||||
@import 'components/modal/update-modal.css';
|
||||
@import 'components/modal/settings-modal.css';
|
||||
@import 'components/modal/doctor-modal.css';
|
||||
@import 'components/modal/help-modal.css';
|
||||
@import 'components/modal/relink-civitai-modal.css';
|
||||
@import 'components/modal/example-access-modal.css';
|
||||
|
||||
@@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) {
|
||||
return {
|
||||
// Base CRUD operations
|
||||
list: `/api/lm/${modelType}/list`,
|
||||
excluded: `/api/lm/${modelType}/excluded`,
|
||||
delete: `/api/lm/${modelType}/delete`,
|
||||
exclude: `/api/lm/${modelType}/exclude`,
|
||||
unexclude: `/api/lm/${modelType}/unexclude`,
|
||||
rename: `/api/lm/${modelType}/rename`,
|
||||
save: `/api/lm/${modelType}/save-metadata`,
|
||||
cancelTask: `/api/lm/${modelType}/cancel-task`,
|
||||
|
||||
@@ -51,6 +51,7 @@ export class BaseModelApiClient {
|
||||
async fetchModelsPage(page = 1, pageSize = null) {
|
||||
const pageState = this.getPageState();
|
||||
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
|
||||
try {
|
||||
const params = this._buildQueryParams({
|
||||
@@ -71,7 +72,10 @@ export class BaseModelApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`);
|
||||
const endpoint = isExcludedView
|
||||
? this.apiConfig.endpoints.excluded
|
||||
: this.apiConfig.endpoints.list;
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export class BaseModelApiClient {
|
||||
totalPages: data.total_pages,
|
||||
currentPage: page,
|
||||
hasMore: page < data.total_pages,
|
||||
folders: data.folders
|
||||
folders: data.folders || []
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
@@ -212,6 +216,50 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async unexcludeModel(filePath) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Restoring ${this.apiConfig.config.singularName}...`);
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.unexclude, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: filePath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to restore ${this.apiConfig.config.singularName}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.removeItemByFilePath(filePath);
|
||||
}
|
||||
showToast(
|
||||
'toast.api.restoreSuccess',
|
||||
{ type: this.apiConfig.config.displayName },
|
||||
'success',
|
||||
`Restored ${this.apiConfig.config.displayName}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(data.error || `Failed to restore ${this.apiConfig.config.singularName}`);
|
||||
} catch (error) {
|
||||
console.error(`Error restoring ${this.apiConfig.config.singularName}:`, error);
|
||||
showToast(
|
||||
'toast.api.restoreFailed',
|
||||
{ type: this.apiConfig.config.singularName, message: error.message },
|
||||
'error',
|
||||
`Failed to restore ${this.apiConfig.config.singularName}: ${error.message}`
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async renameModelFile(filePath, newFileName) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
||||
@@ -883,20 +931,21 @@ export class BaseModelApiClient {
|
||||
|
||||
_buildQueryParams(baseParams, pageState) {
|
||||
const params = new URLSearchParams(baseParams);
|
||||
const isExcludedView = pageState.viewMode === 'excluded';
|
||||
|
||||
if (pageState.activeFolder !== null) {
|
||||
if (!isExcludedView && pageState.activeFolder !== null) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
|
||||
if (pageState.showFavoritesOnly) {
|
||||
if (!isExcludedView && pageState.showFavoritesOnly) {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
if (pageState.showUpdateAvailableOnly) {
|
||||
if (!isExcludedView && pageState.showUpdateAvailableOnly) {
|
||||
params.append('update_available_only', 'true');
|
||||
}
|
||||
|
||||
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||
if (!isExcludedView && this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||
params.append('first_letter', pageState.activeLetterFilter);
|
||||
}
|
||||
|
||||
@@ -918,7 +967,7 @@ export class BaseModelApiClient {
|
||||
|
||||
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
|
||||
|
||||
if (pageState.filters) {
|
||||
if (!isExcludedView && pageState.filters) {
|
||||
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
|
||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||
if (state === 'include') {
|
||||
@@ -981,7 +1030,9 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
if (!isExcludedView) {
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
164
static/js/api/civitaiBaseModelApi.js
Normal file
164
static/js/api/civitaiBaseModelApi.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* API client for Civitai base model management
|
||||
* Handles fetching and refreshing base models from Civitai API
|
||||
*/
|
||||
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
const BASE_MODEL_ENDPOINTS = {
|
||||
getModels: '/api/lm/base-models',
|
||||
refresh: '/api/lm/base-models/refresh',
|
||||
categories: '/api/lm/base-models/categories',
|
||||
cacheStatus: '/api/lm/base-models/cache-status',
|
||||
};
|
||||
|
||||
/**
|
||||
* Civitai Base Model API Client
|
||||
*/
|
||||
export class CivitaiBaseModelApi {
|
||||
constructor() {
|
||||
this.cache = null;
|
||||
this.cacheTimestamp = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base models (with caching)
|
||||
* @param {boolean} forceRefresh - Force refresh from API
|
||||
* @returns {Promise<Object>} Response with models, source, and counts
|
||||
*/
|
||||
async getBaseModels(forceRefresh = false) {
|
||||
try {
|
||||
const url = new URL(BASE_MODEL_ENDPOINTS.getModels, window.location.origin);
|
||||
if (forceRefresh) {
|
||||
url.searchParams.append('refresh', 'true');
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch base models: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.cache = data.data;
|
||||
this.cacheTimestamp = Date.now();
|
||||
return data.data;
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to fetch base models');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching base models:', error);
|
||||
showToast('Failed to fetch base models', { message: error.message }, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh base models from Civitai API
|
||||
* @returns {Promise<Object>} Refreshed data
|
||||
*/
|
||||
async refreshBaseModels() {
|
||||
try {
|
||||
const response = await fetch(BASE_MODEL_ENDPOINTS.refresh, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to refresh base models: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.cache = data.data;
|
||||
this.cacheTimestamp = Date.now();
|
||||
showToast('Base models refreshed successfully', {}, 'success');
|
||||
return data.data;
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to refresh base models');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing base models:', error);
|
||||
showToast('Failed to refresh base models', { message: error.message }, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base model categories
|
||||
* @returns {Promise<Object>} Categories with model lists
|
||||
*/
|
||||
async getCategories() {
|
||||
try {
|
||||
const response = await fetch(BASE_MODEL_ENDPOINTS.categories);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch categories: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
return data.data;
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to fetch categories');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status
|
||||
* @returns {Promise<Object>} Cache status information
|
||||
*/
|
||||
async getCacheStatus() {
|
||||
try {
|
||||
const response = await fetch(BASE_MODEL_ENDPOINTS.cacheStatus);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch cache status: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
return data.data;
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to fetch cache status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching cache status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached models (if available)
|
||||
* @returns {Object|null} Cached data or null
|
||||
*/
|
||||
getCachedModels() {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache is available
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasCache() {
|
||||
return this.cache !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache age in milliseconds
|
||||
* @returns {number|null} Age in ms or null if no cache
|
||||
*/
|
||||
getCacheAge() {
|
||||
if (!this.cacheTimestamp) return null;
|
||||
return Date.now() - this.cacheTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const civitaiBaseModelApi = new CivitaiBaseModelApi();
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RecipeCard } from '../components/RecipeCard.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
|
||||
|
||||
const RECIPE_ENDPOINTS = {
|
||||
list: '/api/lm/recipes',
|
||||
@@ -31,6 +32,20 @@ export function extractRecipeId(filePath) {
|
||||
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||
}
|
||||
|
||||
export async function fetchRecipeDetails(recipeId) {
|
||||
if (!recipeId) {
|
||||
throw new Error('Unable to determine recipe ID');
|
||||
}
|
||||
|
||||
const encodedRecipeId = encodeURIComponent(recipeId);
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recipes with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
@@ -61,7 +76,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
// If we have a specific recipe ID to load
|
||||
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||
// Special case: load specific recipe
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
|
||||
const response = await fetch(
|
||||
`${RECIPE_ENDPOINTS.detail}/${encodeURIComponent(pageState.customFilter.recipeId)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
@@ -83,6 +100,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
|
||||
params.append('lora_hash', pageState.customFilter.loraHash);
|
||||
params.append('bypass_filters', 'true');
|
||||
} else if (pageState.customFilter?.active && pageState.customFilter?.checkpointHash) {
|
||||
params.append('checkpoint_hash', pageState.customFilter.checkpointHash);
|
||||
params.append('bypass_filters', 'true');
|
||||
} else {
|
||||
// Normal filtering logic
|
||||
|
||||
@@ -163,10 +183,12 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
fetchPageFunction,
|
||||
preserveScroll = false
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||
|
||||
try {
|
||||
pageState.isLoading = true;
|
||||
@@ -188,6 +210,10 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page will be 2
|
||||
|
||||
if (scrollSnapshot) {
|
||||
await restoreScrollPosition(scrollSnapshot);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading ${modelType}s:`, error);
|
||||
@@ -208,10 +234,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
modelType = 'lora',
|
||||
resetPage = false,
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
fetchPageFunction,
|
||||
preserveScroll = false
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
const scrollSnapshot = preserveScroll ? captureScrollPosition() : null;
|
||||
|
||||
try {
|
||||
// Start loading state
|
||||
@@ -236,6 +264,10 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page to load would be 2
|
||||
|
||||
if (scrollSnapshot) {
|
||||
await restoreScrollPosition(scrollSnapshot);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${modelType}s:`, error);
|
||||
@@ -251,11 +283,12 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
* @param {boolean} updateFolders - Whether to update folder tags
|
||||
* @returns {Promise<Object>} The fetch result
|
||||
*/
|
||||
export async function resetAndReload(updateFolders = false) {
|
||||
export async function resetAndReload(updateFolders = false, options = {}) {
|
||||
return resetAndReloadWithVirtualScroll({
|
||||
modelType: 'recipe',
|
||||
updateFolders,
|
||||
fetchPageFunction: fetchRecipesPage
|
||||
fetchPageFunction: fetchRecipesPage,
|
||||
preserveScroll: options.preserveScroll === true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,7 +300,7 @@ export async function syncChanges() {
|
||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
||||
|
||||
// Simply reload the recipes without rebuilding cache
|
||||
await resetAndReload();
|
||||
await resetAndReload(false, { preserveScroll: true });
|
||||
|
||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
@@ -295,7 +328,7 @@ export async function refreshRecipes() {
|
||||
}
|
||||
|
||||
// After successful cache rebuild, reload the recipes
|
||||
await resetAndReload();
|
||||
await resetAndReload(false, { preserveScroll: true });
|
||||
|
||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
@@ -346,9 +379,10 @@ export function createRecipeCard(recipe) {
|
||||
* @param {Object} updates - The metadata updates to apply
|
||||
* @returns {Promise<Object>} The updated recipe data
|
||||
*/
|
||||
export async function updateRecipeMetadata(filePath, updates) {
|
||||
export async function updateRecipeMetadata(filePath, updates, options = {}) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
const listFilePath = options.listFilePath || filePath;
|
||||
|
||||
// Extract recipeId from filePath (basename without extension)
|
||||
const recipeId = extractRecipeId(filePath);
|
||||
@@ -356,7 +390,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
throw new Error('Unable to determine recipe ID');
|
||||
}
|
||||
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${encodeURIComponent(recipeId)}/update`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -371,7 +405,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
throw new Error(data.error || 'Failed to update recipe');
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, updates);
|
||||
state.virtualScroller.updateSingleItem(listFilePath, updates);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'
|
||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
import { i18n } from '../../i18n/index.js';
|
||||
import { sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
|
||||
export class CheckpointContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -22,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
|
||||
// Update the "Move to other root" label based on current model type
|
||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
||||
@@ -60,6 +63,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
this.currentCard.querySelector('.fa-copy').click();
|
||||
}
|
||||
break;
|
||||
case 'sendworkflow':
|
||||
// Send checkpoint to workflow (always replace mode)
|
||||
this.sendCheckpointToWorkflow();
|
||||
break;
|
||||
case 'refresh-metadata':
|
||||
// Refresh metadata from CivitAI
|
||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||
@@ -77,8 +84,57 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async sendCheckpointToWorkflow() {
|
||||
const modelPath = this.currentCard.dataset.filepath;
|
||||
if (!modelPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subtype = (this.currentCard.dataset.sub_type || 'checkpoint').toLowerCase();
|
||||
const isDiffusionModel = subtype === 'diffusion_model';
|
||||
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
|
||||
const actionTypeText = i18n.t(
|
||||
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
|
||||
{},
|
||||
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||||
);
|
||||
const successMessage = i18n.t(
|
||||
'uiHelpers.workflow.modelUpdated',
|
||||
{},
|
||||
'Model updated in workflow'
|
||||
);
|
||||
const failureMessage = i18n.t(
|
||||
'uiHelpers.workflow.modelFailed',
|
||||
{},
|
||||
'Failed to update model node'
|
||||
);
|
||||
const missingNodesMessage = i18n.t(
|
||||
'uiHelpers.workflow.noMatchingNodes',
|
||||
{},
|
||||
'No compatible nodes available in the current workflow'
|
||||
);
|
||||
const missingTargetMessage = i18n.t(
|
||||
'uiHelpers.workflow.noTargetNodeSelected',
|
||||
{},
|
||||
'No target node selected'
|
||||
);
|
||||
|
||||
await sendModelPathToWorkflow(modelPath, {
|
||||
widgetName,
|
||||
collectionType: MODEL_TYPES.CHECKPOINT,
|
||||
actionTypeText,
|
||||
successMessage,
|
||||
failureMessage,
|
||||
missingNodesMessage,
|
||||
missingTargetMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mix in shared methods
|
||||
|
||||
@@ -18,6 +18,11 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
async saveModelMetadata(filePath, data) {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action) {
|
||||
// First try to handle with common actions
|
||||
@@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
|
||||
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||
|
||||
if (isRecipesPage) {
|
||||
@@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
licenseRefreshItem?.classList.add('hidden');
|
||||
downloadExamplesItem?.classList.add('hidden');
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
licenseRefreshItem?.classList.remove('hidden');
|
||||
downloadExamplesItem?.classList.remove('hidden');
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to repair recipes:', error);
|
||||
});
|
||||
break;
|
||||
case 'manage-excluded-models':
|
||||
this.manageExcludedModels();
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unhandled global context menu action: ${action}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
manageExcludedModels() {
|
||||
window.pageControls?.enterExcludedView?.().catch((error) => {
|
||||
console.error('Failed to open excluded models view:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async downloadExampleImages(menuItem) {
|
||||
const downloadPath = state?.global?.settings?.example_images_path;
|
||||
if (!downloadPath) {
|
||||
|
||||
@@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
// First try to handle with common actions
|
||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||
@@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,43 @@ import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { MODEL_CONFIG } from '../../api/apiConfig.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
||||
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
|
||||
|
||||
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
||||
export const ModelContextMenuMixin = {
|
||||
isExcludedView() {
|
||||
return state?.pages?.[state.currentPageType]?.viewMode === 'excluded';
|
||||
},
|
||||
|
||||
updateExcludeMenuItem() {
|
||||
const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]');
|
||||
if (!excludeItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExcludedView = this.isExcludedView();
|
||||
excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude';
|
||||
excludeItem.innerHTML = isExcludedView
|
||||
? `<i class="fas fa-undo"></i> <span>${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}</span>`
|
||||
: `<i class="fas fa-eye-slash"></i> <span>${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}</span>`;
|
||||
},
|
||||
|
||||
async restoreExcludedModel(filePath) {
|
||||
const restored = await getModelApiClient().unexcludeModel(filePath);
|
||||
if (!restored) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.pageControls?.exitExcludedView) {
|
||||
await window.pageControls.exitExcludedView();
|
||||
} else {
|
||||
const resetFn = this.resetAndReload || resetAndReload;
|
||||
if (typeof resetFn === 'function') {
|
||||
await resetFn(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// NSFW Selector methods
|
||||
initNSFWSelector() {
|
||||
if (this._nsfwSelectorInitialized) {
|
||||
@@ -154,25 +188,7 @@ export const ModelContextMenuMixin = {
|
||||
},
|
||||
|
||||
extractModelVersionId(url) {
|
||||
try {
|
||||
// Handle all three URL formats:
|
||||
// 1. https://civitai.com/models/649516
|
||||
// 2. https://civitai.com/models/649516?modelVersionId=726676
|
||||
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Extract model ID from path
|
||||
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
|
||||
const modelId = pathMatch ? pathMatch[1] : null;
|
||||
|
||||
// Extract model version ID from query parameters
|
||||
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
||||
|
||||
return { modelId, modelVersionId };
|
||||
} catch (e) {
|
||||
return { modelId: null, modelVersionId: null };
|
||||
}
|
||||
return extractCivitaiModelUrlParts(url);
|
||||
},
|
||||
|
||||
parseModelId(value) {
|
||||
|
||||
@@ -23,7 +23,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Override resetAndReload for recipe context
|
||||
async resetAndReload() {
|
||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||
return resetAndReload();
|
||||
return resetAndReload(false, { preserveScroll: true });
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { sidebarManager } from '../SidebarManager.js';
|
||||
|
||||
@@ -38,8 +38,12 @@ export class PageControls {
|
||||
|
||||
// Initialize favorites filter button state
|
||||
this.initFavoritesFilter();
|
||||
|
||||
this.initExcludedViewControls();
|
||||
this.syncExcludedViewState();
|
||||
|
||||
console.log(`PageControls initialized for ${pageType} page`);
|
||||
window.pageControls = this;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +60,19 @@ export class PageControls {
|
||||
|
||||
// Load sort preference
|
||||
this.loadSortPreference();
|
||||
|
||||
if (!this.pageState.viewMode) {
|
||||
this.pageState.viewMode = 'active';
|
||||
}
|
||||
if (!this.pageState.excludedViewState) {
|
||||
this.pageState.excludedViewState = {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
if (!this.pageState.filters?.search) {
|
||||
this.pageState.filters.search = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +133,15 @@ export class PageControls {
|
||||
// Page-specific event listeners
|
||||
this.initPageSpecificListeners();
|
||||
}
|
||||
|
||||
initExcludedViewControls() {
|
||||
const backButton = document.getElementById('excludedViewBackBtn');
|
||||
if (backButton) {
|
||||
backButton.addEventListener('click', async () => {
|
||||
await this.exitExcludedView();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dropdown functionality
|
||||
@@ -334,6 +360,13 @@ export class PageControls {
|
||||
* @param {string} sortValue - The sort value to save
|
||||
*/
|
||||
saveSortPreference(sortValue) {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
this.pageState.excludedViewState = {
|
||||
...(this.pageState.excludedViewState || {}),
|
||||
sortBy: sortValue,
|
||||
};
|
||||
return;
|
||||
}
|
||||
setStorageItem(`${this.pageType}_sort`, sortValue);
|
||||
}
|
||||
|
||||
@@ -353,18 +386,8 @@ export class PageControls {
|
||||
const metaData = JSON.parse(card.dataset.meta);
|
||||
const civitaiId = metaData.modelId;
|
||||
const versionId = metaData.id;
|
||||
|
||||
// Build URL
|
||||
if (civitaiId) {
|
||||
let url = `https://civitai.com/models/${civitaiId}`;
|
||||
if (versionId) {
|
||||
url += `?modelVersionId=${versionId}`;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
// If no ID, try searching by name
|
||||
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
|
||||
}
|
||||
|
||||
openCivitaiByMetadata(civitaiId, versionId, modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,6 +506,8 @@ export class PageControls {
|
||||
// Update app state
|
||||
this.pageState.showFavoritesOnly = showFavoritesOnly;
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -499,12 +524,17 @@ export class PageControls {
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorites-only filter and reload models
|
||||
*/
|
||||
async toggleFavoritesOnly() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
|
||||
// Toggle the filter state in storage
|
||||
@@ -531,6 +561,9 @@ export class PageControls {
|
||||
* Toggle update-available-only filter and reload models
|
||||
*/
|
||||
async toggleUpdateAvailableOnly() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
const storageKey = `show_update_available_only_${this.pageType}`;
|
||||
const newState = !this.pageState.showUpdateAvailableOnly;
|
||||
@@ -545,6 +578,234 @@ export class PageControls {
|
||||
|
||||
await this.resetAndReload(true);
|
||||
}
|
||||
|
||||
cloneFilters(filters = this.pageState.filters) {
|
||||
return JSON.parse(JSON.stringify(filters || {}));
|
||||
}
|
||||
|
||||
buildExcludedFilters(search = '') {
|
||||
return {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
search,
|
||||
tagLogic: 'any',
|
||||
};
|
||||
}
|
||||
|
||||
applyFilterState(filters) {
|
||||
this.pageState.filters = filters;
|
||||
|
||||
if (window.filterManager) {
|
||||
window.filterManager.filters = window.filterManager.initializeFilters(filters);
|
||||
window.filterManager.updateActiveFiltersCount();
|
||||
if (typeof window.filterManager.updateSelections === 'function') {
|
||||
window.filterManager.updateSelections();
|
||||
}
|
||||
window.filterManager.closeFilterPanel();
|
||||
}
|
||||
}
|
||||
|
||||
updateActionButtonStates() {
|
||||
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||
if (favoriteFilterBtn) {
|
||||
favoriteFilterBtn.classList.toggle('active', Boolean(this.pageState.showFavoritesOnly));
|
||||
}
|
||||
|
||||
const updateFilterBtn = document.getElementById('updateFilterBtn');
|
||||
if (updateFilterBtn) {
|
||||
updateFilterBtn.classList.toggle('active', Boolean(this.pageState.showUpdateAvailableOnly));
|
||||
}
|
||||
}
|
||||
|
||||
syncExcludedViewState() {
|
||||
const isExcludedView = this.pageState.viewMode === 'excluded';
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const excludedBanner = document.getElementById('excludedViewBanner');
|
||||
const filterButton = document.getElementById('filterButton');
|
||||
const breadcrumbContainer = document.getElementById('breadcrumbContainer');
|
||||
const duplicatesBanner = document.getElementById('duplicatesBanner');
|
||||
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
|
||||
const hiddenSelectors = [
|
||||
'[data-action="fetch"]',
|
||||
'[data-action="download"]',
|
||||
'[data-action="bulk"]',
|
||||
'[data-action="find-duplicates"]',
|
||||
'#favoriteFilterBtn',
|
||||
'.update-filter-group',
|
||||
];
|
||||
const customFilterIndicator = document.getElementById('customFilterIndicator');
|
||||
|
||||
document.body.classList.toggle('excluded-view-active', isExcludedView);
|
||||
excludedBanner?.classList.toggle('hidden', !isExcludedView);
|
||||
breadcrumbContainer?.classList.toggle('hidden', isExcludedView);
|
||||
alphabetBarContainer?.classList.toggle('hidden', isExcludedView);
|
||||
|
||||
if (duplicatesBanner && isExcludedView) {
|
||||
duplicatesBanner.style.display = 'none';
|
||||
}
|
||||
|
||||
hiddenSelectors.forEach((selector) => {
|
||||
document.querySelectorAll(selector).forEach((element) => {
|
||||
element.classList.toggle('hidden', isExcludedView);
|
||||
});
|
||||
});
|
||||
|
||||
if (customFilterIndicator && isExcludedView) {
|
||||
customFilterIndicator.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (filterButton) {
|
||||
filterButton.disabled = isExcludedView;
|
||||
filterButton.classList.toggle('hidden', isExcludedView);
|
||||
}
|
||||
|
||||
const activeFiltersCount = document.getElementById('activeFiltersCount');
|
||||
if (activeFiltersCount && isExcludedView) {
|
||||
activeFiltersCount.style.display = 'none';
|
||||
}
|
||||
|
||||
if (sortSelect) {
|
||||
sortSelect.value = this.pageState.sortBy;
|
||||
}
|
||||
if (searchInput) {
|
||||
searchInput.value = this.pageState.filters?.search || '';
|
||||
}
|
||||
|
||||
this.updateActionButtonStates();
|
||||
|
||||
if (this.sidebarManager) {
|
||||
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
|
||||
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
|
||||
console.error('Failed to update sidebar visibility:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
suspendInteractiveModes() {
|
||||
const snapshot = {
|
||||
bulkMode: Boolean(state.bulkMode),
|
||||
duplicatesMode: Boolean(this.pageState.duplicatesMode),
|
||||
};
|
||||
|
||||
if (snapshot.bulkMode && window.bulkManager?.toggleBulkMode) {
|
||||
window.bulkManager.toggleBulkMode();
|
||||
}
|
||||
|
||||
if (snapshot.duplicatesMode && window.modelDuplicatesManager?.exitDuplicateMode) {
|
||||
window.modelDuplicatesManager.exitDuplicateMode();
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async restoreInteractiveModes(snapshot = {}) {
|
||||
if (snapshot.bulkMode && !state.bulkMode && window.bulkManager?.toggleBulkMode) {
|
||||
window.bulkManager.toggleBulkMode();
|
||||
}
|
||||
|
||||
if (!snapshot.duplicatesMode || this.pageState.duplicatesMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicatesManager = window.modelDuplicatesManager;
|
||||
if (!duplicatesManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof duplicatesManager.enterDuplicateMode === 'function' &&
|
||||
Array.isArray(duplicatesManager.duplicateGroups) &&
|
||||
duplicatesManager.duplicateGroups.length > 0) {
|
||||
duplicatesManager.enterDuplicateMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof duplicatesManager.findDuplicates === 'function') {
|
||||
await duplicatesManager.findDuplicates();
|
||||
}
|
||||
}
|
||||
|
||||
syncCustomFilterIndicator() {
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (!indicator) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
indicator.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.checkCustomFilters === 'function') {
|
||||
this.checkCustomFilters();
|
||||
}
|
||||
}
|
||||
|
||||
async enterExcludedView() {
|
||||
if (this.pageState.viewMode === 'excluded') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interactionSnapshot = this.suspendInteractiveModes();
|
||||
|
||||
this.pageState.activeViewSnapshot = {
|
||||
sortBy: this.pageState.sortBy,
|
||||
activeFolder: this.pageState.activeFolder,
|
||||
activeLetterFilter: this.pageState.activeLetterFilter ?? null,
|
||||
showFavoritesOnly: this.pageState.showFavoritesOnly,
|
||||
showUpdateAvailableOnly: this.pageState.showUpdateAvailableOnly,
|
||||
bulkMode: interactionSnapshot.bulkMode,
|
||||
duplicatesMode: interactionSnapshot.duplicatesMode,
|
||||
filters: this.cloneFilters(),
|
||||
};
|
||||
|
||||
const excludedState = this.pageState.excludedViewState || {
|
||||
sortBy: 'name:asc',
|
||||
search: '',
|
||||
};
|
||||
|
||||
this.pageState.viewMode = 'excluded';
|
||||
this.pageState.sortBy = excludedState.sortBy || 'name:asc';
|
||||
this.pageState.currentPage = 1;
|
||||
this.pageState.activeFolder = null;
|
||||
this.pageState.activeLetterFilter = null;
|
||||
this.pageState.showFavoritesOnly = false;
|
||||
this.pageState.showUpdateAvailableOnly = false;
|
||||
|
||||
this.applyFilterState(this.buildExcludedFilters(excludedState.search || ''));
|
||||
this.syncExcludedViewState();
|
||||
await this.resetAndReload(false);
|
||||
}
|
||||
|
||||
async exitExcludedView() {
|
||||
if (this.pageState.viewMode !== 'excluded') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pageState.excludedViewState = {
|
||||
...(this.pageState.excludedViewState || {}),
|
||||
sortBy: this.pageState.sortBy,
|
||||
search: this.pageState.filters?.search || '',
|
||||
};
|
||||
|
||||
const snapshot = this.pageState.activeViewSnapshot || {};
|
||||
this.pageState.viewMode = 'active';
|
||||
this.pageState.sortBy = snapshot.sortBy || this.convertLegacySortFormat(getStorageItem(`${this.pageType}_sort`) || 'name:asc');
|
||||
this.pageState.currentPage = 1;
|
||||
this.pageState.activeFolder = snapshot.activeFolder ?? getStorageItem(`${this.pageType}_activeFolder`);
|
||||
this.pageState.activeLetterFilter = snapshot.activeLetterFilter ?? null;
|
||||
this.pageState.showFavoritesOnly = Boolean(snapshot.showFavoritesOnly);
|
||||
this.pageState.showUpdateAvailableOnly = Boolean(snapshot.showUpdateAvailableOnly);
|
||||
this.applyFilterState(snapshot.filters || this.buildExcludedFilters(''));
|
||||
this.pageState.activeViewSnapshot = null;
|
||||
|
||||
this.syncExcludedViewState();
|
||||
await this.resetAndReload(true);
|
||||
this.syncCustomFilterIndicator();
|
||||
await this.restoreInteractiveModes(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find duplicate models
|
||||
|
||||
@@ -185,14 +185,14 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||||
);
|
||||
const successMessage = translate(
|
||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelUpdated' : 'uiHelpers.workflow.checkpointUpdated',
|
||||
'uiHelpers.workflow.modelUpdated',
|
||||
{},
|
||||
isDiffusionModel ? 'Diffusion model updated in workflow' : 'Checkpoint updated in workflow'
|
||||
'Model updated in workflow'
|
||||
);
|
||||
const failureMessage = translate(
|
||||
isDiffusionModel ? 'uiHelpers.workflow.diffusionModelFailed' : 'uiHelpers.workflow.checkpointFailed',
|
||||
'uiHelpers.workflow.modelFailed',
|
||||
{},
|
||||
isDiffusionModel ? 'Failed to update diffusion model node' : 'Failed to update checkpoint node'
|
||||
'Failed to update model node'
|
||||
);
|
||||
const missingNodesMessage = translate(
|
||||
'uiHelpers.workflow.noMatchingNodes',
|
||||
@@ -433,10 +433,11 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.usage_count = String(model.usage_count);
|
||||
card.dataset.notes = model.notes || '';
|
||||
card.dataset.base_model = model.base_model || 'Unknown';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
card.dataset.exclude = model.exclude ? 'true' : 'false';
|
||||
const hasUpdateAvailable = Boolean(model.update_available);
|
||||
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
|
||||
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
|
||||
|
||||
// To only show usage_count when sorting by usage.
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -487,6 +488,9 @@ export function createModelCard(model, modelType) {
|
||||
if (model.skip_metadata_refresh) {
|
||||
card.classList.add('skip-refresh');
|
||||
}
|
||||
if (model.exclude) {
|
||||
card.classList.add('excluded-model');
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||
@@ -619,6 +623,11 @@ export function createModelCard(model, modelType) {
|
||||
<i class="fas fa-ban"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
${model.exclude ? `
|
||||
<span class="model-excluded-badge" title="${translate('globalContextMenu.manageExcludedModels.label', {}, 'Excluded Models')}">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
${actionIcons}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
|
||||
import { showToast, openCivitai, sendLoraToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import {
|
||||
toggleShowcase,
|
||||
setupShowcaseScroll,
|
||||
@@ -18,7 +19,7 @@ import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, es
|
||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||
import { loadRecipesForLora } from './RecipeTab.js';
|
||||
import { loadRecipesForModel } from './RecipeTab.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
|
||||
@@ -294,6 +295,17 @@ export async function showModelModal(model, modelType) {
|
||||
].join('\n')
|
||||
: '';
|
||||
const headerActionItems = [];
|
||||
|
||||
// Add send to ComfyUI button for all model types
|
||||
const sendToWorkflowTitle = translate('modals.model.actions.sendToWorkflow', {}, 'Send to ComfyUI');
|
||||
const sendToWorkflowButton = `
|
||||
<button class="modal-send-btn" data-action="send-to-workflow" data-model-type="${modelType}" title="${sendToWorkflowTitle}">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<span>${translate('modals.model.actions.sendToWorkflowText', {}, 'Send to ComfyUI')}</span>
|
||||
</button>
|
||||
`.trim();
|
||||
headerActionItems.push(indentMarkup(sendToWorkflowButton, 20));
|
||||
|
||||
if (creatorActionsMarkup) {
|
||||
headerActionItems.push(creatorActionsMarkup);
|
||||
}
|
||||
@@ -343,7 +355,9 @@ export async function showModelModal(model, modelType) {
|
||||
${versionsTabBadge}
|
||||
</button>`.trim();
|
||||
|
||||
const tabsContent = modelType === 'loras' ?
|
||||
const supportsRecipesTab = modelType === 'loras' || modelType === 'checkpoints';
|
||||
|
||||
const tabsContent = supportsRecipesTab ?
|
||||
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
|
||||
<button class="tab-btn" data-tab="description">${descriptionText}</button>
|
||||
${versionsTabButton}
|
||||
@@ -373,7 +387,7 @@ export async function showModelModal(model, modelType) {
|
||||
</button>
|
||||
</div>`.trim();
|
||||
|
||||
const tabPanesContent = modelType === 'loras' ?
|
||||
const tabPanesContent = supportsRecipesTab ?
|
||||
`<div id="showcase-tab" class="tab-pane active">
|
||||
<div class="example-images-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
|
||||
@@ -615,6 +629,14 @@ export async function showModelModal(model, modelType) {
|
||||
const activeModalElement = document.getElementById(modalId);
|
||||
if (activeModalElement) {
|
||||
activeModalElement.dataset.filePath = modelWithFullData.file_path || '';
|
||||
// Store usage_tips for LoRA models
|
||||
if (modelType === 'loras' && modelWithFullData.usage_tips) {
|
||||
activeModalElement.dataset.usageTips = modelWithFullData.usage_tips;
|
||||
}
|
||||
// Store sub_type for checkpoint models
|
||||
if (modelType === 'checkpoints' && modelWithFullData.sub_type) {
|
||||
activeModalElement.dataset.subType = modelWithFullData.sub_type;
|
||||
}
|
||||
}
|
||||
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
|
||||
const versionsTabController = initVersionsTab({
|
||||
@@ -644,14 +666,23 @@ export async function showModelModal(model, modelType) {
|
||||
setupNavigationShortcuts(modelType);
|
||||
updateNavigationControls();
|
||||
|
||||
// LoRA specific setup
|
||||
// Model-specific setup
|
||||
if (modelType === 'loras' || modelType === 'embeddings') {
|
||||
setupTriggerWordsEditMode();
|
||||
}
|
||||
|
||||
if (modelType == 'loras') {
|
||||
// Load recipes for this LoRA
|
||||
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
|
||||
}
|
||||
if (modelType === 'loras') {
|
||||
loadRecipesForModel({
|
||||
modelKind: 'lora',
|
||||
displayName: modelWithFullData.model_name,
|
||||
sha256: modelWithFullData.sha256,
|
||||
});
|
||||
} else if (modelType === 'checkpoints') {
|
||||
loadRecipesForModel({
|
||||
modelKind: 'checkpoint',
|
||||
displayName: modelWithFullData.model_name,
|
||||
sha256: modelWithFullData.sha256,
|
||||
});
|
||||
}
|
||||
|
||||
// Load example images asynchronously - merge regular and custom images
|
||||
@@ -747,6 +778,9 @@ function setupEventHandlers(filePath, modelType) {
|
||||
case 'nav-next':
|
||||
handleDirectionalNavigation('next', modelType);
|
||||
break;
|
||||
case 'send-to-workflow':
|
||||
handleSendToWorkflow(target, modelType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1026,6 +1060,70 @@ async function openFileLocation(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendToWorkflow(target, modelType) {
|
||||
const filePath = getModalFilePath();
|
||||
if (!filePath) {
|
||||
showToast('modals.model.sendToWorkflow.noFilePath', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current model data from the modal
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
const currentFileName = modalElement?.querySelector('#file-name')?.textContent || '';
|
||||
|
||||
if (modelType === 'loras') {
|
||||
// For LoRA: Build syntax from usage tips and send
|
||||
const usageTipsData = modalElement?.dataset?.usageTips;
|
||||
const usageTips = usageTipsData ? JSON.parse(usageTipsData) : {};
|
||||
const loraSyntax = buildLoraSyntax(currentFileName, usageTips);
|
||||
await sendLoraToWorkflow(loraSyntax, false, 'lora');
|
||||
} else if (modelType === 'checkpoints') {
|
||||
// For Checkpoint: Send model path
|
||||
const subtype = (modalElement?.dataset?.subType || 'checkpoint').toLowerCase();
|
||||
const isDiffusionModel = subtype === 'diffusion_model';
|
||||
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
|
||||
const actionTypeText = translate(
|
||||
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
|
||||
{},
|
||||
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
||||
);
|
||||
const successMessage = translate(
|
||||
'uiHelpers.workflow.modelUpdated',
|
||||
{},
|
||||
'Model updated in workflow'
|
||||
);
|
||||
const failureMessage = translate(
|
||||
'uiHelpers.workflow.modelFailed',
|
||||
{},
|
||||
'Failed to update model node'
|
||||
);
|
||||
const missingNodesMessage = translate(
|
||||
'uiHelpers.workflow.noMatchingNodes',
|
||||
{},
|
||||
'No compatible nodes available in the current workflow'
|
||||
);
|
||||
const missingTargetMessage = translate(
|
||||
'uiHelpers.workflow.noTargetNodeSelected',
|
||||
{},
|
||||
'No target node selected'
|
||||
);
|
||||
|
||||
await sendModelPathToWorkflow(filePath, {
|
||||
widgetName,
|
||||
collectionType: MODEL_TYPES.CHECKPOINT,
|
||||
actionTypeText,
|
||||
successMessage,
|
||||
failureMessage,
|
||||
missingNodesMessage,
|
||||
missingTargetMessage,
|
||||
});
|
||||
} else if (modelType === 'embeddings') {
|
||||
// For Embedding: Send as LoRA syntax (embedding name only)
|
||||
const embeddingSyntax = `<embed:${currentFileName}:1>`;
|
||||
await sendLoraToWorkflow(embeddingSyntax, false, 'embedding');
|
||||
}
|
||||
}
|
||||
|
||||
// Export the model modal API
|
||||
const modelModal = {
|
||||
show: showModelModal,
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { openCivitaiUrl, showToast } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from './utils.js';
|
||||
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
||||
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
|
||||
|
||||
function buildCivitaiVersionUrl(modelId, versionId) {
|
||||
if (modelId == null || versionId == null) {
|
||||
return null;
|
||||
}
|
||||
const normalizedModelId = String(modelId).trim();
|
||||
const normalizedVersionId = String(versionId).trim();
|
||||
if (!normalizedModelId || !normalizedVersionId) {
|
||||
return null;
|
||||
}
|
||||
const encodedModelId = encodeURIComponent(normalizedModelId);
|
||||
const encodedVersionId = encodeURIComponent(normalizedVersionId);
|
||||
return `https://civitai.com/models/${encodedModelId}?modelVersionId=${encodedVersionId}`;
|
||||
return buildCivitaiModelUrl(
|
||||
modelId,
|
||||
versionId,
|
||||
state?.global?.settings?.civitai_host
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -220,8 +215,31 @@ function buildMetaMarkup(version, options = {}) {
|
||||
.join('<span class="version-meta-separator">•</span>');
|
||||
}
|
||||
|
||||
function buildBadge(label, tone) {
|
||||
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
||||
function buildBadge(label, tone, options = {}) {
|
||||
const attributes = [];
|
||||
if (options.title) {
|
||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||
}
|
||||
if (options.ariaLabel) {
|
||||
attributes.push(`aria-label="${escapeHtml(options.ariaLabel)}"`);
|
||||
}
|
||||
const suffix = attributes.length ? ` ${attributes.join(' ')}` : '';
|
||||
return `<span class="version-badge version-badge-${tone}"${suffix}>${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function buildActionButton(label, variant, action, options = {}) {
|
||||
const attributes = [
|
||||
`class="version-action ${variant}"`,
|
||||
`data-version-action="${escapeHtml(action)}"`,
|
||||
];
|
||||
if (options.title) {
|
||||
attributes.push(`title="${escapeHtml(options.title)}"`);
|
||||
attributes.push(`aria-label="${escapeHtml(options.title)}"`);
|
||||
}
|
||||
if (options.extraAttributes) {
|
||||
attributes.push(options.extraAttributes);
|
||||
}
|
||||
return `<button ${attributes.join(' ')}>${options.iconMarkup || ''}${escapeHtml(label)}</button>`;
|
||||
}
|
||||
|
||||
const DISPLAY_FILTER_MODES = Object.freeze({
|
||||
@@ -426,23 +444,72 @@ function renderRow(version, options) {
|
||||
version.versionId > latestLibraryVersionId;
|
||||
const isEarlyAccess = isEarlyAccessActive(version);
|
||||
const badges = [];
|
||||
const openedBadgeLabel = translate('modals.model.versions.badges.current', {}, 'Opened Version');
|
||||
const inLibraryBadgeLabel = translate('modals.model.versions.badges.inLibrary', {}, 'In Library');
|
||||
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
|
||||
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
|
||||
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access');
|
||||
const ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
|
||||
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
||||
|
||||
if (isCurrent) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current'));
|
||||
badges.push(buildBadge(openedBadgeLabel, 'current', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.currentTooltip',
|
||||
{},
|
||||
'This is the version you opened this modal from'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (version.isInLibrary) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success'));
|
||||
} else if (isNewer && !version.shouldIgnore) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
|
||||
badges.push(buildBadge(inLibraryBadgeLabel, 'success', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.inLibraryTooltip',
|
||||
{},
|
||||
'This version exists in your local library'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!version.isInLibrary && version.hasBeenDownloaded) {
|
||||
badges.push(buildBadge(downloadedBadgeLabel, 'info', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.downloadedTooltip',
|
||||
{},
|
||||
'This version was downloaded before, but is not currently in your library'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (!version.isInLibrary && isNewer && !version.shouldIgnore) {
|
||||
badges.push(buildBadge(newerBadgeLabel, 'info', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.newerTooltip',
|
||||
{},
|
||||
'This version is newer than your latest local version'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (isEarlyAccess) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'), 'early-access'));
|
||||
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.earlyAccessTooltip',
|
||||
{},
|
||||
'This version currently requires Civitai early access'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if (version.shouldIgnore) {
|
||||
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
|
||||
badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
|
||||
title: translate(
|
||||
'modals.model.versions.badges.ignoredTooltip',
|
||||
{},
|
||||
'Update notifications are disabled for this version'
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download');
|
||||
@@ -459,29 +526,82 @@ function renderRow(version, options) {
|
||||
if (!version.isInLibrary) {
|
||||
// Download button with optional EA bolt icon
|
||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||
actions.push(
|
||||
`<button class="version-action version-action-primary" data-version-action="download">${downloadIcon}${escapeHtml(downloadLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
downloadLabel,
|
||||
'version-action-primary',
|
||||
'download',
|
||||
{
|
||||
title: isEarlyAccess
|
||||
? translate(
|
||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||
{},
|
||||
'Download this early access version from Civitai'
|
||||
)
|
||||
: translate(
|
||||
'modals.model.versions.actions.downloadTooltip',
|
||||
{},
|
||||
'Download this version'
|
||||
),
|
||||
iconMarkup: downloadIcon,
|
||||
}
|
||||
));
|
||||
} else if (version.filePath) {
|
||||
actions.push(
|
||||
`<button class="version-action version-action-danger" data-version-action="delete">${escapeHtml(deleteLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
deleteLabel,
|
||||
'version-action-danger',
|
||||
'delete',
|
||||
{
|
||||
title: translate(
|
||||
'modals.model.versions.actions.deleteTooltip',
|
||||
{},
|
||||
'Delete this local version'
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
actions.push(
|
||||
`<button class="version-action version-action-ghost" data-version-action="toggle-ignore" data-ignore-state="${
|
||||
version.shouldIgnore ? 'ignored' : 'active'
|
||||
}">${escapeHtml(ignoreLabel)}</button>`
|
||||
);
|
||||
actions.push(buildActionButton(
|
||||
ignoreLabel,
|
||||
'version-action-ghost',
|
||||
'toggle-ignore',
|
||||
{
|
||||
title: version.shouldIgnore
|
||||
? translate(
|
||||
'modals.model.versions.actions.unignoreTooltip',
|
||||
{},
|
||||
'Resume update notifications for this version'
|
||||
)
|
||||
: translate(
|
||||
'modals.model.versions.actions.ignoreTooltip',
|
||||
{},
|
||||
'Ignore update notifications for this version'
|
||||
),
|
||||
extraAttributes: `data-ignore-state="${version.shouldIgnore ? 'ignored' : 'active'}"`,
|
||||
}
|
||||
));
|
||||
|
||||
const linkTarget = buildCivitaiVersionUrl(
|
||||
version.modelId || parentModelId,
|
||||
version.versionId
|
||||
);
|
||||
const civitaiTooltip = translate(
|
||||
'modals.model.actions.viewOnCivitai',
|
||||
'modals.model.versions.actions.viewVersionOnCivitai',
|
||||
{},
|
||||
'View on Civitai'
|
||||
'View version on Civitai'
|
||||
);
|
||||
const civitaiLinkMarkup = linkTarget
|
||||
? `
|
||||
<a
|
||||
class="version-civitai-link"
|
||||
href="${escapeHtml(linkTarget)}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="${escapeHtml(civitaiTooltip)}"
|
||||
aria-label="${escapeHtml(civitaiTooltip)}"
|
||||
>
|
||||
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
||||
</a>
|
||||
`
|
||||
: '';
|
||||
|
||||
const rowAttributes = [
|
||||
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
|
||||
@@ -489,7 +609,6 @@ function renderRow(version, options) {
|
||||
];
|
||||
if (linkTarget) {
|
||||
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
|
||||
rowAttributes.push(`title="${escapeHtml(civitaiTooltip)}"`);
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -497,7 +616,8 @@ function renderRow(version, options) {
|
||||
${renderMediaMarkup(version)}
|
||||
<div class="version-details">
|
||||
<div class="version-title">
|
||||
<span class="versions-tab-version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
|
||||
<span class="versions-tab-version-name">${escapeHtml(versionName)}</span>
|
||||
${civitaiLinkMarkup}
|
||||
</div>
|
||||
<div class="version-badges">${badges.join('')}</div>
|
||||
<div class="version-meta">
|
||||
@@ -1227,9 +1347,17 @@ export function initVersionsTab({
|
||||
}
|
||||
|
||||
const row = event.target.closest('.model-version-row.is-clickable');
|
||||
const civitaiLink = event.target.closest('.version-civitai-link');
|
||||
if (civitaiLink) {
|
||||
event.preventDefault();
|
||||
openCivitaiUrl(civitaiLink.href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
@@ -1245,7 +1373,7 @@ export function initVersionsTab({
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
||||
openCivitaiUrl(targetUrl);
|
||||
});
|
||||
|
||||
// Listen for extension-triggered refresh requests
|
||||
|
||||
@@ -1,38 +1,47 @@
|
||||
/**
|
||||
* RecipeTab - Handles the recipes tab in model modals (LoRA specific functionality)
|
||||
* Moved to shared directory for consistency
|
||||
* RecipeTab - Handles the recipes tab in model modals.
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
/**
|
||||
* Loads recipes that use the specified Lora and renders them in the tab
|
||||
* @param {string} loraName - The display name of the Lora
|
||||
* @param {string} sha256 - The SHA256 hash of the Lora
|
||||
* Loads recipes that use the specified model and renders them in the tab.
|
||||
* @param {Object} options
|
||||
* @param {'lora'|'checkpoint'} options.modelKind - Model kind for copy and endpoint selection
|
||||
* @param {string} options.displayName - The display name of the model
|
||||
* @param {string} options.sha256 - The SHA256 hash of the model
|
||||
*/
|
||||
export function loadRecipesForLora(loraName, sha256) {
|
||||
export function loadRecipesForModel({ modelKind, displayName, sha256 }) {
|
||||
const recipeTab = document.getElementById('recipes-tab');
|
||||
if (!recipeTab) return;
|
||||
|
||||
|
||||
const normalizedHash = sha256?.toLowerCase?.() || '';
|
||||
const modelLabel = getModelLabel(modelKind);
|
||||
|
||||
// Show loading state
|
||||
recipeTab.innerHTML = `
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fetch recipes that use this Lora by hash
|
||||
fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`)
|
||||
|
||||
// Fetch recipes that use this model by hash
|
||||
fetch(`${getRecipesEndpoint(modelKind)}?hash=${encodeURIComponent(normalizedHash)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load recipes');
|
||||
}
|
||||
|
||||
renderRecipes(recipeTab, data.recipes, loraName, sha256);
|
||||
|
||||
renderRecipes(recipeTab, data.recipes, {
|
||||
modelKind,
|
||||
displayName,
|
||||
modelHash: normalizedHash,
|
||||
modelLabel,
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading recipes for Lora:', error);
|
||||
console.error(`Error loading recipes for ${modelLabel}:`, error);
|
||||
recipeTab.innerHTML = `
|
||||
<div class="recipes-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
@@ -46,18 +55,24 @@ export function loadRecipesForLora(loraName, sha256) {
|
||||
* Renders the recipe cards in the tab
|
||||
* @param {HTMLElement} tabElement - The tab element to render into
|
||||
* @param {Array} recipes - Array of recipe objects
|
||||
* @param {string} loraName - The display name of the Lora
|
||||
* @param {string} loraHash - The hash of the Lora
|
||||
* @param {Object} options - Render options
|
||||
*/
|
||||
function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
||||
function renderRecipes(tabElement, recipes, options) {
|
||||
const {
|
||||
modelKind,
|
||||
displayName,
|
||||
modelHash,
|
||||
modelLabel,
|
||||
} = options;
|
||||
|
||||
if (!recipes || recipes.length === 0) {
|
||||
tabElement.innerHTML = `
|
||||
<div class="recipes-empty">
|
||||
<i class="fas fa-book-open"></i>
|
||||
<p>No recipes found that use this Lora.</p>
|
||||
<p>No recipes found that use this ${modelLabel}.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,13 +88,13 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
||||
headerText.appendChild(eyebrow);
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = `${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora`;
|
||||
title.textContent = `${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this ${modelLabel}`;
|
||||
headerText.appendChild(title);
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.className = 'recipes-header__description';
|
||||
description.textContent = loraName ?
|
||||
`Discover workflows crafted for ${loraName}.` :
|
||||
description.textContent = displayName ?
|
||||
`Discover workflows crafted for ${displayName}.` :
|
||||
'Discover workflows crafted for this model.';
|
||||
headerText.appendChild(description);
|
||||
|
||||
@@ -101,7 +116,11 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
||||
headerElement.appendChild(viewAllButton);
|
||||
|
||||
viewAllButton.addEventListener('click', () => {
|
||||
navigateToRecipesPage(loraName, loraHash);
|
||||
navigateToRecipesPage({
|
||||
modelKind,
|
||||
displayName,
|
||||
modelHash,
|
||||
});
|
||||
});
|
||||
|
||||
const cardGrid = document.createElement('div');
|
||||
@@ -280,26 +299,32 @@ function copyRecipeSyntax(recipeId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the recipes page with filter for the current Lora
|
||||
* @param {string} loraName - The Lora display name to filter by
|
||||
* @param {string} loraHash - The hash of the Lora to filter by
|
||||
* @param {boolean} createNew - Whether to open the create recipe dialog
|
||||
* Navigates to the recipes page with filter for the current model
|
||||
* @param {Object} options - Navigation options
|
||||
*/
|
||||
function navigateToRecipesPage(loraName, loraHash) {
|
||||
function navigateToRecipesPage({ modelKind, displayName, modelHash }) {
|
||||
// Close the current modal
|
||||
if (window.modalManager) {
|
||||
modalManager.closeModal('modelModal');
|
||||
}
|
||||
|
||||
|
||||
// Clear any previous filters first
|
||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||
removeSessionItem('checkpoint_to_recipe_filterCheckpointName');
|
||||
removeSessionItem('checkpoint_to_recipe_filterCheckpointHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the LoRA name and hash filter in sessionStorage
|
||||
setSessionItem('lora_to_recipe_filterLoraName', loraName);
|
||||
setSessionItem('lora_to_recipe_filterLoraHash', loraHash);
|
||||
|
||||
|
||||
if (modelKind === 'checkpoint') {
|
||||
// Store the checkpoint name and hash filter in sessionStorage
|
||||
setSessionItem('checkpoint_to_recipe_filterCheckpointName', displayName);
|
||||
setSessionItem('checkpoint_to_recipe_filterCheckpointHash', modelHash);
|
||||
} else {
|
||||
// Store the LoRA name and hash filter in sessionStorage
|
||||
setSessionItem('lora_to_recipe_filterLoraName', displayName);
|
||||
setSessionItem('lora_to_recipe_filterLoraHash', modelHash);
|
||||
}
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
@@ -321,7 +346,18 @@ function navigateToRecipeDetails(recipeId) {
|
||||
|
||||
// Store the recipe ID in sessionStorage to load on recipes page
|
||||
setSessionItem('viewRecipeId', recipeId);
|
||||
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
|
||||
function getRecipesEndpoint(modelKind) {
|
||||
if (modelKind === 'checkpoint') {
|
||||
return '/api/lm/recipes/for-checkpoint';
|
||||
}
|
||||
return '/api/lm/recipes/for-lora';
|
||||
}
|
||||
|
||||
function getModelLabel(modelKind) {
|
||||
return modelKind === 'checkpoint' ? 'checkpoint' : 'LoRA';
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js';
|
||||
import { bulkManager } from './managers/BulkManager.js';
|
||||
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||
import { helpManager } from './managers/HelpManager.js';
|
||||
import { doctorManager } from './managers/DoctorManager.js';
|
||||
import { bannerService } from './managers/BannerService.js';
|
||||
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
@@ -17,6 +18,8 @@ import { onboardingManager } from './managers/OnboardingManager.js';
|
||||
import { BulkContextMenu } from './components/ContextMenu/BulkContextMenu.js';
|
||||
import { createPageContextMenu, createGlobalContextMenu } from './components/ContextMenu/index.js';
|
||||
import { initializeEventManagement } from './utils/eventManagementInit.js';
|
||||
import { civitaiBaseModelApi } from './api/civitaiBaseModelApi.js';
|
||||
import { setDynamicBaseModels } from './utils/constants.js';
|
||||
|
||||
// Core application class
|
||||
export class AppCore {
|
||||
@@ -42,6 +45,10 @@ export class AppCore {
|
||||
await settingsManager.waitForInitialization();
|
||||
console.log('AppCore: Settings initialized');
|
||||
|
||||
// Initialize dynamic base models (async, non-blocking)
|
||||
console.log('AppCore: Initializing dynamic base models...');
|
||||
this.initializeDynamicBaseModels();
|
||||
|
||||
// Initialize managers
|
||||
state.loadingManager = new LoadingManager();
|
||||
modalManager.initialize();
|
||||
@@ -52,6 +59,7 @@ export class AppCore {
|
||||
const exampleImagesManager = new ExampleImagesManager();
|
||||
window.exampleImagesManager = exampleImagesManager;
|
||||
window.helpManager = helpManager;
|
||||
window.doctorManager = doctorManager;
|
||||
window.moveManager = moveManager;
|
||||
window.bulkManager = bulkManager;
|
||||
|
||||
@@ -71,6 +79,7 @@ export class AppCore {
|
||||
exampleImagesManager.initialize();
|
||||
// Initialize the help manager
|
||||
helpManager.initialize();
|
||||
doctorManager.initialize();
|
||||
|
||||
const cardInfoDisplay = state.global.settings.card_info_display || 'always';
|
||||
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
|
||||
@@ -116,6 +125,21 @@ export class AppCore {
|
||||
window.globalContextMenuInstance = createGlobalContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dynamic base models from Civitai API
|
||||
// This is non-blocking - runs in background
|
||||
async initializeDynamicBaseModels() {
|
||||
try {
|
||||
const result = await civitaiBaseModelApi.getBaseModels();
|
||||
if (result && result.models) {
|
||||
setDynamicBaseModels(result.models, result.last_updated);
|
||||
console.log(`AppCore: Loaded ${result.merged_count} base models (${result.hardcoded_count} hardcoded + ${result.remote_count} remote)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AppCore: Failed to load dynamic base models:', error);
|
||||
// Non-critical error - app continues with hardcoded models
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
|
||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
||||
|
||||
// Refresh recipes list to show newly imported recipes
|
||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||
window.recipeManager.loadRecipes();
|
||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
}
|
||||
|
||||
// Show results step
|
||||
|
||||
@@ -311,7 +311,7 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
// Refresh the recipes list to update LoRA status
|
||||
if (window.recipeManager) {
|
||||
window.recipeManager.loadRecipes();
|
||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
397
static/js/managers/DoctorManager.js
Normal file
397
static/js/managers/DoctorManager.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { escapeHtml } from '../components/shared/utils.js';
|
||||
|
||||
const MAX_CONSOLE_ENTRIES = 200;
|
||||
|
||||
function stringifyConsoleArg(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export class DoctorManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.lastDiagnostics = null;
|
||||
this.consoleEntries = [];
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerButton = document.getElementById('doctorTriggerBtn');
|
||||
this.badge = document.getElementById('doctorStatusBadge');
|
||||
this.modal = document.getElementById('doctorModal');
|
||||
this.issuesList = document.getElementById('doctorIssuesList');
|
||||
this.summaryText = document.getElementById('doctorSummaryText');
|
||||
this.summaryBadge = document.getElementById('doctorSummaryBadge');
|
||||
this.loadingState = document.getElementById('doctorLoadingState');
|
||||
this.refreshButton = document.getElementById('doctorRefreshBtn');
|
||||
this.exportButton = document.getElementById('doctorExportBtn');
|
||||
|
||||
this.installConsoleCapture();
|
||||
this.bindEvents();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (this.triggerButton) {
|
||||
this.triggerButton.addEventListener('click', async () => {
|
||||
modalManager.showModal('doctorModal');
|
||||
await this.refreshDiagnostics();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refreshButton) {
|
||||
this.refreshButton.addEventListener('click', async () => {
|
||||
await this.refreshDiagnostics();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.exportButton) {
|
||||
this.exportButton.addEventListener('click', async () => {
|
||||
await this.exportBundle();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
installConsoleCapture() {
|
||||
if (window.__lmDoctorConsolePatched) {
|
||||
this.consoleEntries = window.__lmDoctorConsoleEntries || [];
|
||||
return;
|
||||
}
|
||||
|
||||
const originalConsole = {};
|
||||
const levels = ['log', 'info', 'warn', 'error', 'debug'];
|
||||
window.__lmDoctorConsoleEntries = this.consoleEntries;
|
||||
|
||||
levels.forEach((level) => {
|
||||
const original = console[level]?.bind(console);
|
||||
originalConsole[level] = original;
|
||||
|
||||
console[level] = (...args) => {
|
||||
this.consoleEntries.push({
|
||||
level,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: args.map(stringifyConsoleArg).join(' '),
|
||||
});
|
||||
|
||||
if (this.consoleEntries.length > MAX_CONSOLE_ENTRIES) {
|
||||
this.consoleEntries.splice(0, this.consoleEntries.length - MAX_CONSOLE_ENTRIES);
|
||||
}
|
||||
|
||||
if (original) {
|
||||
original(...args);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
window.__lmDoctorConsolePatched = true;
|
||||
}
|
||||
|
||||
getClientVersion() {
|
||||
return document.body?.dataset?.appVersion || '';
|
||||
}
|
||||
|
||||
buildReloadUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('_lm_reload', Date.now().toString());
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
reloadUi() {
|
||||
window.location.replace(this.buildReloadUrl());
|
||||
}
|
||||
|
||||
setLoading(isLoading) {
|
||||
if (this.loadingState) {
|
||||
this.loadingState.classList.toggle('visible', isLoading);
|
||||
}
|
||||
if (this.refreshButton) {
|
||||
this.refreshButton.disabled = isLoading;
|
||||
}
|
||||
if (this.exportButton) {
|
||||
this.exportButton.disabled = isLoading;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshDiagnostics({ silent = false } = {}) {
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const clientVersion = encodeURIComponent(this.getClientVersion());
|
||||
const response = await fetch(`/api/lm/doctor/diagnostics?clientVersion=${clientVersion}`);
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || payload.success === false) {
|
||||
throw new Error(payload.error || 'Failed to load doctor diagnostics');
|
||||
}
|
||||
|
||||
this.lastDiagnostics = payload;
|
||||
this.updateTriggerState(payload.summary);
|
||||
this.renderDiagnostics(payload);
|
||||
} catch (error) {
|
||||
console.error('Doctor diagnostics failed:', error);
|
||||
if (!silent) {
|
||||
showToast('doctor.toast.loadFailed', { message: error.message }, 'error');
|
||||
}
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
updateTriggerState(summary = {}) {
|
||||
if (!this.badge || !this.triggerButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueCount = Number(summary.issue_count || 0);
|
||||
this.badge.textContent = issueCount > 9 ? '9+' : String(issueCount);
|
||||
this.badge.classList.toggle('hidden', issueCount === 0);
|
||||
|
||||
this.triggerButton.classList.remove('doctor-status-warning', 'doctor-status-error');
|
||||
if (summary.status === 'error') {
|
||||
this.triggerButton.classList.add('doctor-status-error');
|
||||
} else if (summary.status === 'warning') {
|
||||
this.triggerButton.classList.add('doctor-status-warning');
|
||||
}
|
||||
}
|
||||
|
||||
renderDiagnostics(payload) {
|
||||
if (!this.modal || !this.issuesList || !this.summaryText || !this.summaryBadge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { summary = {}, diagnostics = [] } = payload;
|
||||
this.summaryText.textContent = this.getSummaryText(summary);
|
||||
this.summaryBadge.className = `doctor-summary-badge ${this.getStatusClass(summary.status)}`;
|
||||
this.summaryBadge.innerHTML = `
|
||||
<i class="fas ${summary.status === 'error' ? 'fa-triangle-exclamation' : summary.status === 'warning' ? 'fa-stethoscope' : 'fa-heartbeat'}"></i>
|
||||
<span>${escapeHtml(this.getStatusLabel(summary.status))}</span>
|
||||
`;
|
||||
|
||||
this.issuesList.innerHTML = diagnostics.map((item) => this.renderIssueCard(item)).join('');
|
||||
this.attachIssueActions();
|
||||
}
|
||||
|
||||
getSummaryText(summary) {
|
||||
if (summary.status === 'error') {
|
||||
return translate(
|
||||
'doctor.summary.error',
|
||||
{ count: summary.issue_count || 0 },
|
||||
`${summary.issue_count || 0} issue(s) need attention before the app is fully healthy.`
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.status === 'warning') {
|
||||
return translate(
|
||||
'doctor.summary.warning',
|
||||
{ count: summary.issue_count || 0 },
|
||||
`${summary.issue_count || 0} issue(s) were found. Most can be fixed directly from this panel.`
|
||||
);
|
||||
}
|
||||
|
||||
return translate(
|
||||
'doctor.summary.ok',
|
||||
{},
|
||||
'No active issues were found in the current environment.'
|
||||
);
|
||||
}
|
||||
|
||||
getStatusClass(status) {
|
||||
if (status === 'error') {
|
||||
return 'doctor-status-error';
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return 'doctor-status-warning';
|
||||
}
|
||||
return 'doctor-status-ok';
|
||||
}
|
||||
|
||||
getStatusLabel(status) {
|
||||
return translate(`doctor.status.${status || 'ok'}`, {}, status || 'ok');
|
||||
}
|
||||
|
||||
renderIssueCard(item) {
|
||||
const status = item.status || 'ok';
|
||||
const tagLabel = this.getStatusLabel(status);
|
||||
const details = Array.isArray(item.details) ? item.details : [];
|
||||
const listItems = details
|
||||
.filter((detail) => typeof detail === 'string')
|
||||
.map((detail) => `<li>${escapeHtml(detail)}</li>`)
|
||||
.join('');
|
||||
const inlineDetails = details
|
||||
.filter((detail) => detail && typeof detail === 'object')
|
||||
.map((detail) => this.renderInlineDetail(detail))
|
||||
.join('');
|
||||
const actions = (item.actions || [])
|
||||
.map((action) => `
|
||||
<button class="${action.id === 'repair-cache' || action.id === 'reload-page' ? 'primary-btn' : 'secondary-btn'}" data-doctor-action="${escapeHtml(action.id)}">
|
||||
${escapeHtml(action.label)}
|
||||
</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>
|
||||
</div>
|
||||
<span class="doctor-issue-tag">${escapeHtml(tagLabel)}</span>
|
||||
</div>
|
||||
${inlineDetails ? `<div class="doctor-inline-detail-grid">${inlineDetails}</div>` : ''}
|
||||
${listItems ? `<ul class="doctor-issue-details">${listItems}</ul>` : ''}
|
||||
${actions ? `<div class="doctor-issue-actions">${actions}</div>` : ''}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
renderInlineDetail(detail) {
|
||||
if (detail.client_version || detail.server_version) {
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
<strong>${escapeHtml(translate('common.status.version', {}, 'Version'))}</strong>
|
||||
<div>${escapeHtml(`Client: ${detail.client_version || 'unknown'}`)}</div>
|
||||
<div>${escapeHtml(`Server: ${detail.server_version || 'unknown'}`)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const label = detail.label || detail.model_type || detail.client_version || detail.server_version || 'Detail';
|
||||
const message = detail.message
|
||||
|| detail.corruption_rate
|
||||
|| detail.server_version
|
||||
|| detail.client_version
|
||||
|| '';
|
||||
|
||||
if (detail.model_type) {
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
<strong>${escapeHtml(detail.label || detail.model_type)}</strong>
|
||||
<div>${escapeHtml(detail.message || '')}</div>
|
||||
${detail.corruption_rate ? `<div>${escapeHtml(detail.corruption_rate)} invalid</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="doctor-inline-detail">
|
||||
<strong>${escapeHtml(label)}</strong>
|
||||
<div>${escapeHtml(message)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachIssueActions() {
|
||||
this.issuesList.querySelectorAll('[data-doctor-action]').forEach((button) => {
|
||||
button.addEventListener('click', async (event) => {
|
||||
const action = event.currentTarget.dataset.doctorAction;
|
||||
await this.handleAction(action);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleAction(action) {
|
||||
switch (action) {
|
||||
case 'open-settings':
|
||||
modalManager.showModal('settingsModal');
|
||||
window.setTimeout(() => {
|
||||
const input = document.getElementById('civitaiApiKey');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
case 'repair-cache':
|
||||
await this.repairCache();
|
||||
break;
|
||||
case 'reload-page':
|
||||
this.reloadUi();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async repairCache() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
const response = await fetch('/api/lm/doctor/repair-cache', { method: 'POST' });
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || payload.success === false) {
|
||||
throw new Error(payload.error || translate('doctor.toast.repairFailed', {}, 'Cache rebuild failed.'));
|
||||
}
|
||||
|
||||
showToast('doctor.toast.repairSuccess', {}, 'success');
|
||||
await this.refreshDiagnostics({ silent: true });
|
||||
} catch (error) {
|
||||
console.error('Doctor cache repair failed:', error);
|
||||
showToast('doctor.toast.repairFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async exportBundle() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
const response = await fetch('/api/lm/doctor/export-bundle', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
summary: this.lastDiagnostics?.summary || null,
|
||||
diagnostics: this.lastDiagnostics?.diagnostics || [],
|
||||
frontend_logs: this.consoleEntries,
|
||||
client_context: {
|
||||
url: window.location.href,
|
||||
user_agent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
app_version: this.getClientVersion(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || 'Failed to export diagnostics bundle');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename=\"([^\"]+)\"/);
|
||||
const filename = match?.[1] || 'lora-manager-doctor.zip';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showToast('doctor.toast.exportSuccess', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Doctor export failed:', error);
|
||||
showToast('doctor.toast.exportFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const doctorManager = new DoctorManager();
|
||||
@@ -6,6 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
|
||||
|
||||
export class DownloadManager {
|
||||
constructor() {
|
||||
@@ -197,21 +198,22 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
extractModelId(url) {
|
||||
const versionMatch = url.match(/modelVersionId=(\d+)/i);
|
||||
this.modelVersionId = versionMatch ? versionMatch[1] : null;
|
||||
|
||||
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
|
||||
if (civarchiveMatch) {
|
||||
const versionMatch = url.match(/modelVersionId=(\d+)/i);
|
||||
this.modelVersionId = versionMatch ? versionMatch[1] : null;
|
||||
this.source = 'civarchive';
|
||||
return civarchiveMatch[1];
|
||||
}
|
||||
|
||||
const civitaiMatch = url.match(/https?:\/\/(?:www\.)?civitai\.com\/models\/(\d+)/i);
|
||||
if (civitaiMatch) {
|
||||
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
|
||||
if (modelId) {
|
||||
this.modelVersionId = modelVersionId;
|
||||
this.source = null;
|
||||
return civitaiMatch[1];
|
||||
return modelId;
|
||||
}
|
||||
|
||||
this.modelVersionId = null;
|
||||
this.source = null;
|
||||
return null;
|
||||
}
|
||||
@@ -250,6 +252,7 @@ export class DownloadManager {
|
||||
(version.files[0]?.sizeKB / 1024).toFixed(2);
|
||||
|
||||
const existsLocally = version.existsLocally;
|
||||
const hasBeenDownloaded = version.hasBeenDownloaded && !existsLocally;
|
||||
const localPath = version.localPath;
|
||||
const isEarlyAccess = version.availability === 'EarlyAccess';
|
||||
|
||||
@@ -262,11 +265,22 @@ export class DownloadManager {
|
||||
`;
|
||||
}
|
||||
|
||||
const localStatus = existsLocally ?
|
||||
`<div class="local-badge">
|
||||
let localStatus = '';
|
||||
if (existsLocally) {
|
||||
localStatus = `<div class="local-badge">
|
||||
<i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}
|
||||
<div class="local-path">${localPath || ''}</div>
|
||||
</div>` : '';
|
||||
</div>`;
|
||||
} else if (hasBeenDownloaded) {
|
||||
const downloadedTooltip = translate(
|
||||
'modals.download.downloadedTooltip',
|
||||
{},
|
||||
'Previously downloaded, but it is not currently in your library.'
|
||||
);
|
||||
localStatus = `<div class="downloaded-badge" title="${downloadedTooltip.replace(/"/g, '"')}">
|
||||
<i class="fas fa-history"></i> ${translate('modals.download.downloaded', {}, 'Downloaded')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
|
||||
|
||||
@@ -84,6 +84,18 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
const doctorModal = document.getElementById('doctorModal');
|
||||
if (doctorModal) {
|
||||
this.registerModal('doctorModal', {
|
||||
element: doctorModal,
|
||||
onClose: () => {
|
||||
this.getModal('doctorModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
// Add moveModal registration
|
||||
const moveModal = document.getElementById('moveModal');
|
||||
if (moveModal) {
|
||||
@@ -451,4 +463,4 @@ export class ModalManager {
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const modalManager = new ModalManager();
|
||||
export const modalManager = new ModalManager();
|
||||
|
||||
@@ -2,7 +2,14 @@ import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state, createDefaultSettings } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||
import {
|
||||
DOWNLOAD_PATH_TEMPLATES,
|
||||
MAPPABLE_BASE_MODELS,
|
||||
PATH_TEMPLATE_PLACEHOLDERS,
|
||||
DEFAULT_PATH_TEMPLATES,
|
||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||
getMappableBaseModelsDynamic
|
||||
} from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
import { configureModelCardVideo } from '../components/shared/ModelCard.js';
|
||||
@@ -139,6 +146,10 @@ export class SettingsManager {
|
||||
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
||||
);
|
||||
|
||||
merged.skip_previously_downloaded_model_versions =
|
||||
backendSettings?.skip_previously_downloaded_model_versions
|
||||
?? defaults.skip_previously_downloaded_model_versions;
|
||||
|
||||
merged.download_skip_base_models = this.normalizeDownloadSkipBaseModels(
|
||||
backendSettings?.download_skip_base_models ?? defaults.download_skip_base_models
|
||||
);
|
||||
@@ -184,7 +195,9 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
getAvailableDownloadSkipBaseModels() {
|
||||
return MAPPABLE_BASE_MODELS.filter(model => model !== 'Other');
|
||||
// Use dynamic base models if available, fallback to hardcoded
|
||||
const models = getMappableBaseModelsDynamic();
|
||||
return models.filter(model => model !== 'Other');
|
||||
}
|
||||
|
||||
normalizeDownloadSkipBaseModels(value) {
|
||||
@@ -348,6 +361,13 @@ export class SettingsManager {
|
||||
});
|
||||
}
|
||||
|
||||
const openBackupLocationButton = document.getElementById('backupOpenLocationBtn');
|
||||
if (openBackupLocationButton) {
|
||||
openBackupLocationButton.addEventListener('click', () => {
|
||||
this.openBackupLocation();
|
||||
});
|
||||
}
|
||||
|
||||
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||
const customInput = document.getElementById(`${modelType}CustomTemplate`);
|
||||
if (customInput) {
|
||||
@@ -729,6 +749,35 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
async openBackupLocation() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/backup/open-location', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.mode === 'clipboard' && data.path) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.path);
|
||||
showToast('settings.backup.locationCopied', { path: data.path }, 'success');
|
||||
} catch (clipboardErr) {
|
||||
console.warn('Clipboard API not available:', clipboardErr);
|
||||
showToast('settings.backup.locationClipboardFallback', { path: data.path }, 'info');
|
||||
}
|
||||
} else {
|
||||
showToast('settings.backup.openFolderSuccess', {}, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open backup folder:', error);
|
||||
showToast('settings.backup.openFolderFailed', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSettingsToUI() {
|
||||
// Set frontend settings from state
|
||||
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
|
||||
@@ -753,6 +802,16 @@ export class SettingsManager {
|
||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||
}
|
||||
|
||||
const civitaiHostSelect = document.getElementById('civitaiHost');
|
||||
if (civitaiHostSelect) {
|
||||
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
|
||||
}
|
||||
|
||||
const recipesPathInput = document.getElementById('recipesPath');
|
||||
if (recipesPathInput) {
|
||||
recipesPathInput.value = state.global.settings.recipes_path || '';
|
||||
}
|
||||
|
||||
const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions');
|
||||
if (autoOrganizeExclusionsInput) {
|
||||
const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
|
||||
@@ -827,6 +886,12 @@ export class SettingsManager {
|
||||
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
|
||||
}
|
||||
|
||||
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
|
||||
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
|
||||
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
|
||||
state.global.settings.skip_previously_downloaded_model_versions || false;
|
||||
}
|
||||
|
||||
// Set optimize example images setting
|
||||
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
|
||||
if (optimizeExampleImagesCheckbox) {
|
||||
@@ -854,6 +919,9 @@ export class SettingsManager {
|
||||
// Load metadata archive settings
|
||||
await this.loadMetadataArchiveSettings();
|
||||
|
||||
// Load backup settings
|
||||
await this.loadBackupSettings();
|
||||
|
||||
// Load base model path mappings
|
||||
this.loadBaseModelMappings();
|
||||
|
||||
@@ -1237,10 +1305,7 @@ export class SettingsManager {
|
||||
throw new Error('No LoRA roots found');
|
||||
}
|
||||
|
||||
// Clear existing options except the first one (No Default)
|
||||
const noDefaultOption = defaultLoraRootSelect.querySelector('option[value=""]');
|
||||
defaultLoraRootSelect.innerHTML = '';
|
||||
defaultLoraRootSelect.appendChild(noDefaultOption);
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1250,9 +1315,8 @@ export class SettingsManager {
|
||||
defaultLoraRootSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Set selected value from settings
|
||||
const defaultRoot = state.global.settings.default_lora_root || '';
|
||||
defaultLoraRootSelect.value = defaultRoot;
|
||||
defaultLoraRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading LoRA roots:', error);
|
||||
@@ -1276,10 +1340,7 @@ export class SettingsManager {
|
||||
throw new Error('No checkpoint roots found');
|
||||
}
|
||||
|
||||
// Clear existing options except first one (No Default)
|
||||
const noDefaultOption = defaultCheckpointRootSelect.querySelector('option[value=""]');
|
||||
defaultCheckpointRootSelect.innerHTML = '';
|
||||
defaultCheckpointRootSelect.appendChild(noDefaultOption);
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1289,9 +1350,8 @@ export class SettingsManager {
|
||||
defaultCheckpointRootSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Set selected value from settings
|
||||
const defaultRoot = state.global.settings.default_checkpoint_root || '';
|
||||
defaultCheckpointRootSelect.value = defaultRoot;
|
||||
defaultCheckpointRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading checkpoint roots:', error);
|
||||
@@ -1315,10 +1375,7 @@ export class SettingsManager {
|
||||
throw new Error('No diffusion model roots found');
|
||||
}
|
||||
|
||||
// Clear existing options except first one (No Default)
|
||||
const noDefaultOption = defaultUnetRootSelect.querySelector('option[value=""]');
|
||||
defaultUnetRootSelect.innerHTML = '';
|
||||
defaultUnetRootSelect.appendChild(noDefaultOption);
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1328,9 +1385,8 @@ export class SettingsManager {
|
||||
defaultUnetRootSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Set selected value from settings
|
||||
const defaultRoot = state.global.settings.default_unet_root || '';
|
||||
defaultUnetRootSelect.value = defaultRoot;
|
||||
defaultUnetRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading diffusion model roots:', error);
|
||||
@@ -1354,10 +1410,7 @@ export class SettingsManager {
|
||||
throw new Error('No embedding roots found');
|
||||
}
|
||||
|
||||
// Clear existing options except first one (No Default)
|
||||
const noDefaultOption = defaultEmbeddingRootSelect.querySelector('option[value=""]');
|
||||
defaultEmbeddingRootSelect.innerHTML = '';
|
||||
defaultEmbeddingRootSelect.appendChild(noDefaultOption);
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
@@ -1367,9 +1420,8 @@ export class SettingsManager {
|
||||
defaultEmbeddingRootSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Set selected value from settings
|
||||
const defaultRoot = state.global.settings.default_embedding_root || '';
|
||||
defaultEmbeddingRootSelect.value = defaultRoot;
|
||||
defaultEmbeddingRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading embedding roots:', error);
|
||||
@@ -1396,12 +1448,12 @@ export class SettingsManager {
|
||||
|
||||
// Add empty row for new path if no paths exist
|
||||
if (paths.length === 0) {
|
||||
this.addExtraFolderPathRow(modelType, '');
|
||||
this.addExtraFolderPathRow(modelType, '', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addExtraFolderPathRow(modelType, path = '') {
|
||||
addExtraFolderPathRow(modelType, path = '', shouldFocus = true) {
|
||||
const container = document.getElementById(`extraFolderPaths-${modelType}`);
|
||||
if (!container) return;
|
||||
|
||||
@@ -1425,7 +1477,7 @@ export class SettingsManager {
|
||||
container.appendChild(row);
|
||||
|
||||
// Focus the input if it's empty (new row)
|
||||
if (!path) {
|
||||
if (!path && shouldFocus) {
|
||||
const input = row.querySelector('.extra-folder-path-input');
|
||||
if (input) {
|
||||
setTimeout(() => input.focus(), 0);
|
||||
@@ -1468,7 +1520,7 @@ export class SettingsManager {
|
||||
try {
|
||||
// Save to backend - this triggers path validation
|
||||
await this.saveSetting('extra_folder_paths', extraFolderPaths);
|
||||
showToast('toast.settings.settingsUpdated', { setting: 'Extra Folder Paths' }, 'success');
|
||||
showToast('settings.extraFolderPaths.saveSuccess', {}, 'success');
|
||||
|
||||
// Add empty row if no valid paths exist for the changed type
|
||||
const container = document.getElementById(`extraFolderPaths-${changedModelType}`);
|
||||
@@ -1517,7 +1569,7 @@ export class SettingsManager {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mapping-row';
|
||||
|
||||
const availableModels = MAPPABLE_BASE_MODELS.filter(model => {
|
||||
const availableModels = getMappableBaseModelsDynamic().filter(model => {
|
||||
const existingMappings = state.global.settings.base_model_path_mappings || {};
|
||||
return !existingMappings.hasOwnProperty(model) || model === baseModel;
|
||||
});
|
||||
@@ -1619,7 +1671,7 @@ export class SettingsManager {
|
||||
const currentValue = select.value;
|
||||
|
||||
// Get available models (not already mapped, except current)
|
||||
const availableModels = MAPPABLE_BASE_MODELS.filter(model =>
|
||||
const availableModels = getMappableBaseModelsDynamic().filter(model =>
|
||||
!existingMappings.hasOwnProperty(model) || model === currentValue
|
||||
);
|
||||
|
||||
@@ -1849,6 +1901,10 @@ export class SettingsManager {
|
||||
await this.updateMetadataArchiveStatus();
|
||||
}
|
||||
|
||||
if (settingKey === 'backup_auto_enabled') {
|
||||
await this.updateBackupStatus();
|
||||
}
|
||||
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
|
||||
// Apply frontend settings immediately
|
||||
@@ -1937,6 +1993,163 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
async loadBackupSettings() {
|
||||
const backupAutoEnabledCheckbox = document.getElementById('backupAutoEnabled');
|
||||
if (backupAutoEnabledCheckbox) {
|
||||
backupAutoEnabledCheckbox.checked = state.global.settings.backup_auto_enabled ?? true;
|
||||
}
|
||||
|
||||
const backupRetentionCountInput = document.getElementById('backupRetentionCount');
|
||||
if (backupRetentionCountInput) {
|
||||
backupRetentionCountInput.value = state.global.settings.backup_retention_count ?? 5;
|
||||
}
|
||||
|
||||
await this.updateBackupStatus();
|
||||
}
|
||||
|
||||
async updateBackupStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/backup/status');
|
||||
const data = await response.json();
|
||||
|
||||
const statusContainer = document.getElementById('backupStatus');
|
||||
if (!statusContainer || !data.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = data.status || {};
|
||||
const latestAutoSnapshot = status.latestAutoSnapshot;
|
||||
const retentionCount = status.retentionCount ?? state.global.settings.backup_retention_count ?? 5;
|
||||
const enabled = status.enabled ?? state.global.settings.backup_auto_enabled ?? true;
|
||||
const backupDir = status.backupDir || '';
|
||||
const backupLocationPath = document.getElementById('backupLocationPath');
|
||||
if (backupLocationPath) {
|
||||
backupLocationPath.textContent = backupDir;
|
||||
backupLocationPath.title = backupDir;
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
if (!timestamp) {
|
||||
return translate('common.status.unknown', {}, 'Unknown');
|
||||
}
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
const renderSnapshotDetail = (snapshot) => {
|
||||
if (!snapshot) {
|
||||
return translate('settings.backup.noneAvailable', {}, 'No snapshots yet');
|
||||
}
|
||||
|
||||
const size = typeof snapshot.size === 'number' ? ` (${this.formatFileSize(snapshot.size)})` : '';
|
||||
return `${snapshot.name}${size}`;
|
||||
};
|
||||
|
||||
statusContainer.innerHTML = `
|
||||
<div class="backup-summary-grid">
|
||||
<div class="backup-summary-card">
|
||||
<div class="backup-summary-label">${translate('settings.backup.autoEnabled', {}, 'Automatic snapshots')}</div>
|
||||
<div class="backup-summary-value status-${enabled ? 'enabled' : 'disabled'}">
|
||||
${enabled ? translate('common.status.enabled') : translate('common.status.disabled')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-summary-card">
|
||||
<div class="backup-summary-label">${translate('settings.backup.retention', {}, 'Retention')}</div>
|
||||
<div class="backup-summary-value">${retentionCount}</div>
|
||||
</div>
|
||||
<div class="backup-summary-card">
|
||||
<div class="backup-summary-label">${translate('settings.backup.snapshotCount', {}, 'Saved snapshots')}</div>
|
||||
<div class="backup-summary-value">${status.snapshotCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-status-list">
|
||||
<div class="backup-status-row">
|
||||
<div class="backup-status-label">${translate('settings.backup.latestAutoSnapshot', {}, 'Latest auto snapshot')}</div>
|
||||
<div class="backup-status-content">
|
||||
<div class="backup-status-primary">${formatTimestamp(latestAutoSnapshot?.mtime)}</div>
|
||||
<div class="backup-status-secondary">${renderSnapshotDetail(latestAutoSnapshot)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Error updating backup status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async exportBackup() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/backup/export', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition') || '';
|
||||
const match = contentDisposition.match(/filename="([^"]+)"/);
|
||||
const filename = match?.[1] || `lora-manager-backup-${Date.now()}.zip`;
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showToast('settings.backup.exportSuccess', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to export backup:', error);
|
||||
showToast('settings.backup.exportFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
triggerBackupImport() {
|
||||
const input = document.getElementById('backupImportInput');
|
||||
input?.click();
|
||||
}
|
||||
|
||||
async handleBackupImportFile(input) {
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(translate('settings.backup.importConfirm', {}, 'Import this backup and overwrite local user state?'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('archive', file);
|
||||
|
||||
const response = await fetch('/api/lm/backup/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
showToast('settings.backup.importSuccess', {}, 'success');
|
||||
await this.updateBackupStatus();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to import backup:', error);
|
||||
showToast('settings.backup.importFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async updateMetadataArchiveStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/metadata-archive-status');
|
||||
@@ -2461,14 +2674,24 @@ export class SettingsManager {
|
||||
if (!element) return;
|
||||
|
||||
const value = element.value.trim(); // Trim whitespace
|
||||
const shouldShowLoading = settingKey === 'recipes_path';
|
||||
|
||||
try {
|
||||
// Check if value has changed from existing value
|
||||
const currentValue = state.global.settings[settingKey] || '';
|
||||
if (value === currentValue) {
|
||||
const currentValue = state.global.settings[settingKey];
|
||||
const normalizedCurrentValue = currentValue === undefined || currentValue === null
|
||||
? ''
|
||||
: String(currentValue).trim();
|
||||
if (value === normalizedCurrentValue) {
|
||||
return; // No change, exit early
|
||||
}
|
||||
|
||||
if (shouldShowLoading) {
|
||||
state.loadingManager?.showSimpleLoading(
|
||||
translate('settings.folderSettings.recipesPathMigrating', {}, 'Migrating recipes...')
|
||||
);
|
||||
}
|
||||
|
||||
// For username and password, handle empty values specially
|
||||
if ((settingKey === 'proxy_username' || settingKey === 'proxy_password') && value === '') {
|
||||
// Remove from state instead of setting to empty string
|
||||
@@ -2494,10 +2717,28 @@ export class SettingsManager {
|
||||
await this.saveSetting(settingKey, value);
|
||||
}
|
||||
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
if (shouldShowLoading) {
|
||||
state.loadingManager?.hide();
|
||||
}
|
||||
|
||||
if (settingKey === 'recipes_path') {
|
||||
showToast('toast.settings.recipesPathUpdated', {}, 'success');
|
||||
} else if (settingKey === 'backup_retention_count') {
|
||||
await this.updateBackupStatus();
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
} else {
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||
if (shouldShowLoading) {
|
||||
state.loadingManager?.hide();
|
||||
}
|
||||
if (settingKey === 'recipes_path') {
|
||||
showToast('toast.settings.recipesPathSaveFailed', { message: error.message }, 'error');
|
||||
} else {
|
||||
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user