mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 16:36:45 -03:00
Compare commits
40 Commits
76ee59cdb9
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6dd6938b0 | ||
|
|
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 | ||
|
|
1ed5eef985 | ||
|
|
a82f89d14a | ||
|
|
16e30ea689 | ||
|
|
ad3bdddb72 | ||
|
|
9121306b06 | ||
|
|
ca0baf9462 | ||
|
|
20e50156a2 | ||
|
|
0b66bf5479 | ||
|
|
1e8aca4787 |
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/
|
||||
|
||||
@@ -138,6 +138,13 @@ npm run test:coverage # Generate coverage report
|
||||
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||
- Symlinks require normalized paths
|
||||
|
||||
## Git / Commit Messages
|
||||
|
||||
- Follow the style of recent repository commits when writing commit messages
|
||||
- Prefer the repo's existing `feat(...)`, `fix(...)`, `chore:` style where applicable
|
||||
- If the user has provided a GitHub issue link or issue ID for the task, mention that issue in the commit message, for example `(#871)`
|
||||
- When unrelated local changes exist, stage and commit only the files relevant to the requested task
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
### 1. Standalone Web UI
|
||||
|
||||
@@ -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
@@ -341,6 +341,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": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,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"
|
||||
@@ -826,7 +830,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben"
|
||||
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben",
|
||||
"sendToWorkflow": "An Workflow senden"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +844,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": {
|
||||
@@ -1069,7 +1074,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 +1084,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",
|
||||
@@ -1334,7 +1344,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 +1517,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",
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
},
|
||||
"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 +341,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": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,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"
|
||||
@@ -826,7 +830,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Move to {otherType} Folder"
|
||||
"moveToOtherTypeFolder": "Move to {otherType} Folder",
|
||||
"sendToWorkflow": "Send to Workflow"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +844,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": {
|
||||
@@ -1069,7 +1074,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 +1084,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",
|
||||
@@ -1334,7 +1344,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 +1517,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",
|
||||
|
||||
@@ -341,6 +341,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": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,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"
|
||||
@@ -826,7 +830,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 +844,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": {
|
||||
@@ -1069,7 +1074,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 +1084,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",
|
||||
@@ -1334,7 +1344,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 +1517,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",
|
||||
|
||||
@@ -341,6 +341,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": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,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é"
|
||||
@@ -826,7 +830,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 +844,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": {
|
||||
@@ -1069,7 +1074,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 +1084,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",
|
||||
@@ -1334,7 +1344,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 +1517,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",
|
||||
|
||||
@@ -341,6 +341,10 @@
|
||||
"saveFailed": "לא ניתן לשמור את מודלי הבסיס המוחרגים: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "דלג על גרסאות מודלים שהורדו בעבר",
|
||||
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,7 @@
|
||||
"embedding": "נתיבי Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/נתיב/למודלים/נוספים",
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.",
|
||||
"saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
|
||||
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "נתיב זה כבר מוגדר"
|
||||
@@ -826,7 +830,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}"
|
||||
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}",
|
||||
"sendToWorkflow": "שלח ל-workflow"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +844,8 @@
|
||||
"unpinSidebar": "שחרר סרגל צד",
|
||||
"switchToListView": "עבור לתצוגת רשימה",
|
||||
"switchToTreeView": "תצוגת עץ",
|
||||
"recursiveOn": "חיפוש בתיקיות משנה",
|
||||
"recursiveOff": "חיפוש רק בתיקייה הנוכחית",
|
||||
"recursiveOn": "כלול תיקיות משנה",
|
||||
"recursiveOff": "רק התיקייה הנוכחית",
|
||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"dragDrop": {
|
||||
@@ -1069,7 +1074,9 @@
|
||||
"viewOnCivitai": "הצג ב-Civitai",
|
||||
"viewOnCivitaiText": "הצג ב-Civitai",
|
||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||
"openFileLocation": "פתח מיקום קובץ"
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendToWorkflowText": "שלח ל-ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "מיקום הקובץ נפתח בהצלחה",
|
||||
@@ -1077,6 +1084,9 @@
|
||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "גרסה",
|
||||
"fileName": "שם קובץ",
|
||||
@@ -1334,7 +1344,9 @@
|
||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד"
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
||||
"modelUpdated": "מודל עודכן ב-workflow",
|
||||
"modelFailed": "עדכון צומת המודל נכשל"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "מתכון",
|
||||
@@ -1505,7 +1517,11 @@
|
||||
"nameUpdated": "שם המתכון עודכן בהצלחה",
|
||||
"tagsUpdated": "תגיות המתכון עודכנו בהצלחה",
|
||||
"sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה",
|
||||
"promptUpdated": "הפרומפט עודכן בהצלחה",
|
||||
"negativePromptUpdated": "הפרומפט השלילי עודכן בהצלחה",
|
||||
"promptEditorHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
|
||||
"noRecipeId": "אין מזהה מתכון זמין",
|
||||
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
|
||||
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
|
||||
@@ -341,6 +341,10 @@
|
||||
"saveFailed": "除外するベースモデルを保存できませんでした: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "以前にダウンロードしたモデルバージョンをスキップ",
|
||||
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,7 @@
|
||||
"embedding": "Embeddingパス"
|
||||
},
|
||||
"pathPlaceholder": "/追加モデルへのパス",
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。",
|
||||
"saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
|
||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "このパスはすでに設定されています"
|
||||
@@ -826,7 +830,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "{otherType} フォルダに移動"
|
||||
"moveToOtherTypeFolder": "{otherType} フォルダに移動",
|
||||
"sendToWorkflow": "ワークフローに送信"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +844,8 @@
|
||||
"unpinSidebar": "サイドバーの固定を解除",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"recursiveOn": "サブフォルダーを検索",
|
||||
"recursiveOff": "現在のフォルダーのみを検索",
|
||||
"recursiveOn": "サブフォルダーを含める",
|
||||
"recursiveOff": "現在のフォルダーのみ",
|
||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"dragDrop": {
|
||||
@@ -1069,7 +1074,9 @@
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"viewOnCivitaiText": "Civitaiで表示",
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
"openFileLocation": "ファイルの場所を開く"
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
"sendToWorkflowText": "ComfyUI に送信"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "ファイルの場所を正常に開きました",
|
||||
@@ -1077,6 +1084,9 @@
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "バージョン",
|
||||
"fileName": "ファイル名",
|
||||
@@ -1334,7 +1344,9 @@
|
||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||
"modelUpdated": "モデルがワークフローで更新されました",
|
||||
"modelFailed": "モデルノードの更新に失敗しました"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
@@ -1505,7 +1517,11 @@
|
||||
"nameUpdated": "レシピ名が正常に更新されました",
|
||||
"tagsUpdated": "レシピタグが正常に更新されました",
|
||||
"sourceUrlUpdated": "ソースURLが正常に更新されました",
|
||||
"promptUpdated": "プロンプトが正常に更新されました",
|
||||
"negativePromptUpdated": "ネガティブプロンプトが正常に更新されました",
|
||||
"promptEditorHint": "Enterキーで保存、Shift+Enterで改行",
|
||||
"noRecipeId": "レシピIDが利用できません",
|
||||
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
|
||||
"copyFailed": "レシピ構文のコピーエラー:{message}",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
|
||||
@@ -341,6 +341,10 @@
|
||||
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "이전에 다운로드한 모델 버전 건너뛰기",
|
||||
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,7 @@
|
||||
"embedding": "Embedding 경로"
|
||||
},
|
||||
"pathPlaceholder": "/추가/모델/경로",
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.",
|
||||
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
|
||||
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
|
||||
@@ -826,7 +830,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "{otherType} 폴더로 이동"
|
||||
"moveToOtherTypeFolder": "{otherType} 폴더로 이동",
|
||||
"sendToWorkflow": "워크플로우로 전송"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +844,8 @@
|
||||
"unpinSidebar": "사이드바 고정 해제",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"recursiveOn": "하위 폴더 검색",
|
||||
"recursiveOff": "현재 폴더만 검색",
|
||||
"recursiveOn": "하위 폴더 포함",
|
||||
"recursiveOff": "현재 폴더만",
|
||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"dragDrop": {
|
||||
@@ -1069,7 +1074,9 @@
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"viewOnCivitaiText": "Civitai에서 보기",
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
"openFileLocation": "파일 위치 열기"
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
"sendToWorkflowText": "ComfyUI로 보내기"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||
@@ -1077,6 +1084,9 @@
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "버전",
|
||||
"fileName": "파일명",
|
||||
@@ -1334,7 +1344,9 @@
|
||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||
"modelFailed": "모델 노드 업데이트 실패"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
@@ -1505,7 +1517,11 @@
|
||||
"nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다",
|
||||
"tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다",
|
||||
"sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다",
|
||||
"promptUpdated": "프롬프트가 성공적으로 업데이트되었습니다",
|
||||
"negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다",
|
||||
"promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄",
|
||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
|
||||
@@ -341,6 +341,10 @@
|
||||
"saveFailed": "Не удалось сохранить исключённые базовые модели: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "Пропускать ранее загруженные версии моделей",
|
||||
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,7 @@
|
||||
"embedding": "Пути Embedding"
|
||||
},
|
||||
"pathPlaceholder": "/путь/к/дополнительным/моделям",
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены.",
|
||||
"saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
|
||||
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "Этот путь уже настроен"
|
||||
@@ -826,7 +830,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "Переместить в папку {otherType}"
|
||||
"moveToOtherTypeFolder": "Переместить в папку {otherType}",
|
||||
"sendToWorkflow": "Отправить в workflow"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +844,8 @@
|
||||
"unpinSidebar": "Открепить боковую панель",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"recursiveOn": "Искать во вложенных папках",
|
||||
"recursiveOff": "Искать только в текущей папке",
|
||||
"recursiveOn": "Включать вложенные папки",
|
||||
"recursiveOff": "Только текущая папка",
|
||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"dragDrop": {
|
||||
@@ -1069,7 +1074,9 @@
|
||||
"viewOnCivitai": "Посмотреть на Civitai",
|
||||
"viewOnCivitaiText": "Посмотреть на Civitai",
|
||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||
"openFileLocation": "Открыть расположение файла"
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
"sendToWorkflowText": "Отправить в ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Расположение файла успешно открыто",
|
||||
@@ -1077,6 +1084,9 @@
|
||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "Версия",
|
||||
"fileName": "Имя файла",
|
||||
@@ -1334,7 +1344,9 @@
|
||||
"recipeReplaced": "Рецепт заменён в workflow",
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||
"noTargetNodeSelected": "Целевой узел не выбран"
|
||||
"noTargetNodeSelected": "Целевой узел не выбран",
|
||||
"modelUpdated": "Модель обновлена в workflow",
|
||||
"modelFailed": "Не удалось обновить узел модели"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
@@ -1505,7 +1517,11 @@
|
||||
"nameUpdated": "Название рецепта успешно обновлено",
|
||||
"tagsUpdated": "Теги рецепта успешно обновлены",
|
||||
"sourceUrlUpdated": "Исходный URL успешно обновлен",
|
||||
"promptUpdated": "Промпт успешно обновлён",
|
||||
"negativePromptUpdated": "Негативный промпт успешно обновлён",
|
||||
"promptEditorHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
|
||||
"noRecipeId": "ID рецепта недоступен",
|
||||
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
|
||||
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
|
||||
@@ -341,6 +341,10 @@
|
||||
"saveFailed": "无法保存已排除的基础模型:{message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "跳过已下载的模型版本",
|
||||
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"extraFolderPaths": {
|
||||
"title": "额外文件夹路径",
|
||||
"help": "在 ComfyUI 的标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。",
|
||||
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。",
|
||||
"description": "LoRA Manager 专属的额外模型根目录。从 ComfyUI 标准文件夹之外的位置加载模型,特别适合管理大型模型库,避免影响 ComfyUI 性能。",
|
||||
"restartRequired": "需要重启才能生效",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 路径",
|
||||
"checkpoint": "Checkpoint 路径",
|
||||
@@ -402,7 +406,7 @@
|
||||
"embedding": "Embedding 路径"
|
||||
},
|
||||
"pathPlaceholder": "/额外/模型/路径",
|
||||
"saveSuccess": "额外文件夹路径已更新。",
|
||||
"saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
|
||||
"saveError": "更新额外文件夹路径失败:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路径已配置"
|
||||
@@ -826,7 +830,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹"
|
||||
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
|
||||
"sendToWorkflow": "发送到工作流"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +844,8 @@
|
||||
"unpinSidebar": "取消固定侧边栏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"recursiveOn": "搜索子文件夹",
|
||||
"recursiveOff": "仅搜索当前文件夹",
|
||||
"recursiveOn": "包含子文件夹",
|
||||
"recursiveOff": "仅当前文件夹",
|
||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"dragDrop": {
|
||||
@@ -1069,7 +1074,9 @@
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
"openFileLocation": "打开文件位置"
|
||||
"openFileLocation": "打开文件位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
"sendToWorkflowText": "发送到 ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "文件位置已成功打开",
|
||||
@@ -1077,6 +1084,9 @@
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "无法发送到 ComfyUI:没有可用的文件路径"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "版本",
|
||||
"fileName": "文件名",
|
||||
@@ -1334,7 +1344,9 @@
|
||||
"recipeReplaced": "配方已替换到工作流",
|
||||
"recipeFailedToSend": "发送配方到工作流失败",
|
||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||
"noTargetNodeSelected": "未选择目标节点"
|
||||
"noTargetNodeSelected": "未选择目标节点",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型节点失败"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
@@ -1505,7 +1517,11 @@
|
||||
"nameUpdated": "配方名称更新成功",
|
||||
"tagsUpdated": "配方标签更新成功",
|
||||
"sourceUrlUpdated": "来源 URL 更新成功",
|
||||
"promptUpdated": "提示词更新成功",
|
||||
"negativePromptUpdated": "负面提示词更新成功",
|
||||
"promptEditorHint": "按 Enter 保存,Shift+Enter 换行",
|
||||
"noRecipeId": "无配方 ID",
|
||||
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
|
||||
"copyFailed": "复制配方语法出错:{message}",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
|
||||
@@ -341,6 +341,10 @@
|
||||
"saveFailed": "無法儲存已排除的基礎模型:{message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
"label": "跳過已下載的模型版本",
|
||||
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
|
||||
},
|
||||
"layoutSettings": {
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
@@ -393,8 +397,8 @@
|
||||
},
|
||||
"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 +406,7 @@
|
||||
"embedding": "Embedding 路徑"
|
||||
},
|
||||
"pathPlaceholder": "/額外/模型/路徑",
|
||||
"saveSuccess": "額外資料夾路徑已更新。",
|
||||
"saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
|
||||
"saveError": "更新額外資料夾路徑失敗:{message}",
|
||||
"validation": {
|
||||
"duplicatePath": "此路徑已設定"
|
||||
@@ -826,7 +830,8 @@
|
||||
"diffusion_model": "Diffusion Model"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾"
|
||||
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
|
||||
"sendToWorkflow": "傳送到工作流"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -839,8 +844,8 @@
|
||||
"unpinSidebar": "取消固定側邊欄",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"recursiveOn": "搜尋子資料夾",
|
||||
"recursiveOff": "僅搜尋目前資料夾",
|
||||
"recursiveOn": "包含子資料夾",
|
||||
"recursiveOff": "僅目前資料夾",
|
||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"dragDrop": {
|
||||
@@ -1069,7 +1074,9 @@
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
"openFileLocation": "開啟檔案位置"
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
"sendToWorkflowText": "傳送到 ComfyUI"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "檔案位置已成功開啟",
|
||||
@@ -1077,6 +1084,9 @@
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑"
|
||||
},
|
||||
"metadata": {
|
||||
"version": "版本",
|
||||
"fileName": "檔案名稱",
|
||||
@@ -1334,7 +1344,9 @@
|
||||
"recipeReplaced": "配方已取代於工作流",
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||
"noTargetNodeSelected": "未選擇目標節點"
|
||||
"noTargetNodeSelected": "未選擇目標節點",
|
||||
"modelUpdated": "模型已更新到工作流",
|
||||
"modelFailed": "更新模型節點失敗"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
@@ -1505,7 +1517,11 @@
|
||||
"nameUpdated": "配方名稱已更新",
|
||||
"tagsUpdated": "配方標籤已更新",
|
||||
"sourceUrlUpdated": "來源網址已更新",
|
||||
"promptUpdated": "提示詞更新成功",
|
||||
"negativePromptUpdated": "負面提示詞更新成功",
|
||||
"promptEditorHint": "按 Enter 儲存,Shift+Enter 換行",
|
||||
"noRecipeId": "無配方 ID",
|
||||
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
|
||||
"copyFailed": "複製配方語法錯誤:{message}",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
|
||||
61
py/config.py
61
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]]:
|
||||
@@ -197,25 +222,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")
|
||||
@@ -706,7 +729,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, "/")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,22 +1,138 @@
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
import comfy.utils # type: ignore
|
||||
import comfy.sd # type: ignore
|
||||
|
||||
import comfy.sd # type: ignore
|
||||
import comfy.utils # type: ignore
|
||||
|
||||
from ..utils.utils import get_lora_info_absolute
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora
|
||||
from .utils import (
|
||||
FlexibleOptionalInputType,
|
||||
any_type,
|
||||
detect_nunchaku_model_kind,
|
||||
extract_lora_name,
|
||||
get_loras_list,
|
||||
nunchaku_load_lora,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_nunchaku_load_qwen_loras():
|
||||
try:
|
||||
module = importlib.import_module(".nunchaku_qwen", __package__)
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Qwen-Image LoRA loading requires the ComfyUI runtime with its torch dependency available."
|
||||
) from exc
|
||||
return module.nunchaku_load_qwen_loras
|
||||
|
||||
|
||||
def _collect_stack_entries(lora_stack):
|
||||
entries = []
|
||||
if not lora_stack:
|
||||
return entries
|
||||
|
||||
for lora_path, model_strength, clip_strength in lora_stack:
|
||||
lora_name = extract_lora_name(lora_path)
|
||||
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
entries.append({
|
||||
"name": lora_name,
|
||||
"absolute_path": absolute_lora_path,
|
||||
"input_path": lora_path,
|
||||
"model_strength": float(model_strength),
|
||||
"clip_strength": float(clip_strength),
|
||||
"trigger_words": trigger_words,
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def _collect_widget_entries(kwargs):
|
||||
entries = []
|
||||
for lora in get_loras_list(kwargs):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
lora_name = lora["name"]
|
||||
model_strength = float(lora["strength"])
|
||||
clip_strength = float(lora.get("clipStrength", model_strength))
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
entries.append({
|
||||
"name": lora_name,
|
||||
"absolute_path": lora_path,
|
||||
"input_path": lora_path,
|
||||
"model_strength": model_strength,
|
||||
"clip_strength": clip_strength,
|
||||
"trigger_words": trigger_words,
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def _format_loaded_loras(loaded_loras):
|
||||
formatted_loras = []
|
||||
for item in loaded_loras:
|
||||
if item["include_clip_strength"]:
|
||||
formatted_loras.append(
|
||||
f"<lora:{item['name']}:{item['model_strength']}:{item['clip_strength']}>"
|
||||
)
|
||||
else:
|
||||
formatted_loras.append(f"<lora:{item['name']}:{item['model_strength']}>")
|
||||
return " ".join(formatted_loras)
|
||||
|
||||
|
||||
def _apply_entries(model, clip, lora_entries, nunchaku_model_kind):
|
||||
loaded_loras = []
|
||||
all_trigger_words = []
|
||||
|
||||
if nunchaku_model_kind == "qwen_image":
|
||||
nunchaku_load_qwen_loras = _get_nunchaku_load_qwen_loras()
|
||||
qwen_lora_configs = []
|
||||
for entry in lora_entries:
|
||||
qwen_lora_configs.append((entry["absolute_path"], entry["model_strength"]))
|
||||
loaded_loras.append({
|
||||
"name": entry["name"],
|
||||
"model_strength": entry["model_strength"],
|
||||
"clip_strength": entry["model_strength"],
|
||||
"include_clip_strength": False,
|
||||
})
|
||||
all_trigger_words.extend(entry["trigger_words"])
|
||||
if qwen_lora_configs:
|
||||
model = nunchaku_load_qwen_loras(model, qwen_lora_configs)
|
||||
return model, clip, loaded_loras, all_trigger_words
|
||||
|
||||
for entry in lora_entries:
|
||||
if nunchaku_model_kind == "flux":
|
||||
model = nunchaku_load_lora(model, entry["input_path"], entry["model_strength"])
|
||||
else:
|
||||
lora = comfy.utils.load_torch_file(entry["absolute_path"], safe_load=True)
|
||||
model, clip = comfy.sd.load_lora_for_models(
|
||||
model,
|
||||
clip,
|
||||
lora,
|
||||
entry["model_strength"],
|
||||
entry["clip_strength"],
|
||||
)
|
||||
|
||||
include_clip_strength = nunchaku_model_kind is None and abs(entry["model_strength"] - entry["clip_strength"]) > 0.001
|
||||
loaded_loras.append({
|
||||
"name": entry["name"],
|
||||
"model_strength": entry["model_strength"],
|
||||
"clip_strength": entry["clip_strength"],
|
||||
"include_clip_strength": include_clip_strength,
|
||||
})
|
||||
all_trigger_words.extend(entry["trigger_words"])
|
||||
|
||||
return model, clip, loaded_loras, all_trigger_words
|
||||
|
||||
|
||||
class LoraLoaderLM:
|
||||
NAME = "Lora Loader (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
# "clip": ("CLIP",),
|
||||
"text": ("AUTOCOMPLETE_TEXT_LORAS", {
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
@@ -28,114 +144,30 @@ class LoraLoaderLM:
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
|
||||
def load_loras(self, model, text, **kwargs):
|
||||
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
|
||||
loaded_loras = []
|
||||
all_trigger_words = []
|
||||
|
||||
clip = kwargs.get('clip', None)
|
||||
lora_stack = kwargs.get('lora_stack', None)
|
||||
|
||||
# Check if model is a Nunchaku Flux model - simplified approach
|
||||
is_nunchaku_model = False
|
||||
|
||||
try:
|
||||
model_wrapper = model.model.diffusion_model
|
||||
# Check if model is a Nunchaku Flux model using only class name
|
||||
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper":
|
||||
is_nunchaku_model = True
|
||||
logger.info("Detected Nunchaku Flux model")
|
||||
except (AttributeError, TypeError):
|
||||
# Not a model with the expected structure
|
||||
pass
|
||||
|
||||
# First process lora_stack if available
|
||||
if lora_stack:
|
||||
for lora_path, model_strength, clip_strength in lora_stack:
|
||||
# Extract lora name and convert to absolute path
|
||||
# lora_stack stores relative paths, but load_torch_file needs absolute paths
|
||||
lora_name = extract_lora_name(lora_path)
|
||||
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
|
||||
# Apply the LoRA using the appropriate loader
|
||||
if is_nunchaku_model:
|
||||
# Use our custom function for Flux models
|
||||
model = nunchaku_load_lora(model, lora_path, model_strength)
|
||||
# clip remains unchanged for Nunchaku models
|
||||
else:
|
||||
# Use lower-level API to load LoRA directly without folder_paths validation
|
||||
lora = comfy.utils.load_torch_file(absolute_lora_path, safe_load=True)
|
||||
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
|
||||
|
||||
all_trigger_words.extend(trigger_words)
|
||||
# Add clip strength to output if different from model strength (except for Nunchaku models)
|
||||
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||
else:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
# Then process loras from kwargs with support for both old and new formats
|
||||
loras_list = get_loras_list(kwargs)
|
||||
for lora in loras_list:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
model_strength = float(lora['strength'])
|
||||
# Get clip strength - use model strength as default if not specified
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
|
||||
# Apply the LoRA using the appropriate loader
|
||||
if is_nunchaku_model:
|
||||
# For Nunchaku models, use our custom function
|
||||
model = nunchaku_load_lora(model, lora_path, model_strength)
|
||||
# clip remains unchanged
|
||||
else:
|
||||
# Use lower-level API to load LoRA directly without folder_paths validation
|
||||
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
|
||||
|
||||
# Include clip strength in output if different from model strength and not a Nunchaku model
|
||||
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||
else:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
# Add trigger words to collection
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# use ',, ' to separate trigger words for group mode
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
# Format loaded_loras with support for both formats
|
||||
formatted_loras = []
|
||||
for item in loaded_loras:
|
||||
parts = item.split(":")
|
||||
lora_name = parts[0]
|
||||
strength_parts = parts[1].strip().split(",")
|
||||
|
||||
if len(strength_parts) > 1:
|
||||
# Different model and clip strengths
|
||||
model_str = strength_parts[0].strip()
|
||||
clip_str = strength_parts[1].strip()
|
||||
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
|
||||
else:
|
||||
# Same strength for both
|
||||
model_str = strength_parts[0].strip()
|
||||
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
|
||||
|
||||
formatted_loras_text = " ".join(formatted_loras)
|
||||
del text
|
||||
clip = kwargs.get("clip", None)
|
||||
lora_entries = _collect_stack_entries(kwargs.get("lora_stack", None))
|
||||
lora_entries.extend(_collect_widget_entries(kwargs))
|
||||
|
||||
nunchaku_model_kind = detect_nunchaku_model_kind(model)
|
||||
if nunchaku_model_kind == "flux":
|
||||
logger.info("Detected Nunchaku Flux model")
|
||||
elif nunchaku_model_kind == "qwen_image":
|
||||
logger.info("Detected Nunchaku Qwen-Image model")
|
||||
|
||||
model, clip, loaded_loras, all_trigger_words = _apply_entries(model, clip, lora_entries, nunchaku_model_kind)
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
formatted_loras_text = _format_loaded_loras(loaded_loras)
|
||||
return (model, clip, trigger_words_text, formatted_loras_text)
|
||||
|
||||
|
||||
class LoraTextLoaderLM:
|
||||
NAME = "LoRA Text Loader (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
@@ -143,131 +175,55 @@ class LoraTextLoaderLM:
|
||||
"model": ("MODEL",),
|
||||
"lora_syntax": ("STRING", {
|
||||
"forceInput": True,
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"clip": ("CLIP",),
|
||||
"lora_stack": ("LORA_STACK",),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras_from_text"
|
||||
|
||||
|
||||
def parse_lora_syntax(self, text):
|
||||
"""Parse LoRA syntax from text input."""
|
||||
# Pattern to match <lora:name:strength> or <lora:name:model_strength:clip_strength>
|
||||
pattern = r'<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>'
|
||||
pattern = r"<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>"
|
||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||
|
||||
|
||||
loras = []
|
||||
for match in matches:
|
||||
lora_name = match[0]
|
||||
model_strength = float(match[1])
|
||||
clip_strength = float(match[2]) if match[2] else model_strength
|
||||
|
||||
loras.append({
|
||||
'name': lora_name,
|
||||
'model_strength': model_strength,
|
||||
'clip_strength': clip_strength
|
||||
"name": match[0],
|
||||
"model_strength": model_strength,
|
||||
"clip_strength": float(match[2]) if match[2] else model_strength,
|
||||
})
|
||||
|
||||
return loras
|
||||
|
||||
|
||||
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
|
||||
"""Load LoRAs based on text syntax input."""
|
||||
loaded_loras = []
|
||||
all_trigger_words = []
|
||||
|
||||
# Check if model is a Nunchaku Flux model - simplified approach
|
||||
is_nunchaku_model = False
|
||||
|
||||
try:
|
||||
model_wrapper = model.model.diffusion_model
|
||||
# Check if model is a Nunchaku Flux model using only class name
|
||||
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper":
|
||||
is_nunchaku_model = True
|
||||
logger.info("Detected Nunchaku Flux model")
|
||||
except (AttributeError, TypeError):
|
||||
# Not a model with the expected structure
|
||||
pass
|
||||
|
||||
# First process lora_stack if available
|
||||
if lora_stack:
|
||||
for lora_path, model_strength, clip_strength in lora_stack:
|
||||
# Extract lora name and convert to absolute path
|
||||
# lora_stack stores relative paths, but load_torch_file needs absolute paths
|
||||
lora_name = extract_lora_name(lora_path)
|
||||
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
|
||||
# Apply the LoRA using the appropriate loader
|
||||
if is_nunchaku_model:
|
||||
# Use our custom function for Flux models
|
||||
model = nunchaku_load_lora(model, lora_path, model_strength)
|
||||
# clip remains unchanged for Nunchaku models
|
||||
else:
|
||||
# Use lower-level API to load LoRA directly without folder_paths validation
|
||||
lora = comfy.utils.load_torch_file(absolute_lora_path, safe_load=True)
|
||||
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
|
||||
|
||||
all_trigger_words.extend(trigger_words)
|
||||
# Add clip strength to output if different from model strength (except for Nunchaku models)
|
||||
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||
else:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
# Parse and process LoRAs from text syntax
|
||||
parsed_loras = self.parse_lora_syntax(lora_syntax)
|
||||
for lora in parsed_loras:
|
||||
lora_name = lora['name']
|
||||
model_strength = lora['model_strength']
|
||||
clip_strength = lora['clip_strength']
|
||||
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora_name)
|
||||
|
||||
# Apply the LoRA using the appropriate loader
|
||||
if is_nunchaku_model:
|
||||
# For Nunchaku models, use our custom function
|
||||
model = nunchaku_load_lora(model, lora_path, model_strength)
|
||||
# clip remains unchanged
|
||||
else:
|
||||
# Use lower-level API to load LoRA directly without folder_paths validation
|
||||
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
|
||||
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
|
||||
|
||||
# Include clip strength in output if different from model strength and not a Nunchaku model
|
||||
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||
else:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
# Add trigger words to collection
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# use ',, ' to separate trigger words for group mode
|
||||
lora_entries = _collect_stack_entries(lora_stack)
|
||||
for lora in self.parse_lora_syntax(lora_syntax):
|
||||
lora_path, trigger_words = get_lora_info_absolute(lora["name"])
|
||||
lora_entries.append({
|
||||
"name": lora["name"],
|
||||
"absolute_path": lora_path,
|
||||
"input_path": lora_path,
|
||||
"model_strength": lora["model_strength"],
|
||||
"clip_strength": lora["clip_strength"],
|
||||
"trigger_words": trigger_words,
|
||||
})
|
||||
|
||||
nunchaku_model_kind = detect_nunchaku_model_kind(model)
|
||||
if nunchaku_model_kind == "flux":
|
||||
logger.info("Detected Nunchaku Flux model")
|
||||
elif nunchaku_model_kind == "qwen_image":
|
||||
logger.info("Detected Nunchaku Qwen-Image model")
|
||||
|
||||
model, clip, loaded_loras, all_trigger_words = _apply_entries(model, clip, lora_entries, nunchaku_model_kind)
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
# Format loaded_loras with support for both formats
|
||||
formatted_loras = []
|
||||
for item in loaded_loras:
|
||||
parts = item.split(":")
|
||||
lora_name = parts[0].strip()
|
||||
strength_parts = parts[1].strip().split(",")
|
||||
|
||||
if len(strength_parts) > 1:
|
||||
# Different model and clip strengths
|
||||
model_str = strength_parts[0].strip()
|
||||
clip_str = strength_parts[1].strip()
|
||||
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
|
||||
else:
|
||||
# Same strength for both
|
||||
model_str = strength_parts[0].strip()
|
||||
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
|
||||
|
||||
formatted_loras_text = " ".join(formatted_loras)
|
||||
|
||||
return (model, clip, trigger_words_text, formatted_loras_text)
|
||||
formatted_loras_text = _format_loaded_loras(loaded_loras)
|
||||
return (model, clip, trigger_words_text, formatted_loras_text)
|
||||
|
||||
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,)
|
||||
570
py/nodes/nunchaku_qwen.py
Normal file
570
py/nodes/nunchaku_qwen.py
Normal file
@@ -0,0 +1,570 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Qwen-Image LoRA support for Nunchaku models.
|
||||
|
||||
Portions of the LoRA mapping/application logic in this file are adapted from
|
||||
ComfyUI-QwenImageLoraLoader by GitHub user ussoewwin:
|
||||
https://github.com/ussoewwin/ComfyUI-QwenImageLoraLoader
|
||||
|
||||
The upstream project is licensed under Apache License 2.0.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import comfy.utils # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from safetensors import safe_open
|
||||
|
||||
from nunchaku.lora.flux.nunchaku_converter import (
|
||||
pack_lowrank_weight,
|
||||
unpack_lowrank_weight,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KEY_MAPPING = [
|
||||
(re.compile(r"^(layers)[._](\d+)[._]attention[._]to[._]([qkv])$"), r"\1.\2.attention.to_qkv", "qkv", lambda m: m.group(3).upper()),
|
||||
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._](w1|w3)$"), r"\1.\2.feed_forward.net.0.proj", "glu", lambda m: m.group(3)),
|
||||
(re.compile(r"^(layers)[._](\d+)[._]feed_forward[._]w2$"), r"\1.\2.feed_forward.net.2", "regular", None),
|
||||
(re.compile(r"^(layers)[._](\d+)[._](.*)$"), r"\1.\2.\3", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]to[._]([qkv])$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._](q|k|v)[._]proj$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]add[._](q|k|v)[._]proj$"), r"\1.\2.attn.add_qkv_proj", "add_qkv", lambda m: m.group(3).upper()),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]out[._]proj[._]context$"), r"\1.\2.attn.to_add_out", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]out[._]proj$"), r"\1.\2.attn.to_out.0", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]attn[._]to[._]out$"), r"\1.\2.attn.to_out.0", "regular", None),
|
||||
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]attn[._]to[._]([qkv])$"), r"\1.\2.attn.to_qkv", "qkv", lambda m: m.group(3).upper()),
|
||||
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]attn[._]to[._]out$"), r"\1.\2.attn.to_out", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff[._]net[._]0(?:[._]proj)?$"), r"\1.\2.mlp_fc1", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff[._]net[._]2$"), r"\1.\2.mlp_fc2", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff_context[._]net[._]0(?:[._]proj)?$"), r"\1.\2.mlp_context_fc1", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]ff_context[._]net[._]2$"), r"\1.\2.mlp_context_fc2", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mlp)[._](net)[._](0)[._](proj)$"), r"\1.\2.\3.\4.\5.\6", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mlp)[._](net)[._](2)$"), r"\1.\2.\3.\4.\5", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mlp)[._](net)[._](0)[._](proj)$"), r"\1.\2.\3.\4.\5.\6", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mlp)[._](net)[._](2)$"), r"\1.\2.\3.\4.\5", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._](img_mod)[._](1)$"), r"\1.\2.\3.\4", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._](txt_mod)[._](1)$"), r"\1.\2.\3.\4", "regular", None),
|
||||
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]proj[._]out$"), r"\1.\2.proj_out", "single_proj_out", None),
|
||||
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]proj[._]mlp$"), r"\1.\2.mlp_fc1", "regular", None),
|
||||
(re.compile(r"^(single_transformer_blocks)[._](\d+)[._]norm[._]linear$"), r"\1.\2.norm.linear", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]norm1[._]linear$"), r"\1.\2.norm1.linear", "regular", None),
|
||||
(re.compile(r"^(transformer_blocks)[._](\d+)[._]norm1_context[._]linear$"), r"\1.\2.norm1_context.linear", "regular", None),
|
||||
(re.compile(r"^(img_in)$"), r"\1", "regular", None),
|
||||
(re.compile(r"^(txt_in)$"), r"\1", "regular", None),
|
||||
(re.compile(r"^(proj_out)$"), r"\1", "regular", None),
|
||||
(re.compile(r"^(norm_out)[._](linear)$"), r"\1.\2", "regular", None),
|
||||
(re.compile(r"^(time_text_embed)[._](timestep_embedder)[._](linear_1)$"), r"\1.\2.\3", "regular", None),
|
||||
(re.compile(r"^(time_text_embed)[._](timestep_embedder)[._](linear_2)$"), r"\1.\2.\3", "regular", None),
|
||||
]
|
||||
|
||||
_RE_LORA_SUFFIX = re.compile(r"\.(?P<tag>lora(?:[._](?:A|B|down|up)))(?:\.[^.]+)*\.weight$")
|
||||
_RE_ALPHA_SUFFIX = re.compile(r"\.(?:alpha|lora_alpha)(?:\.[^.]+)*$")
|
||||
|
||||
|
||||
def _rename_layer_underscore_layer_name(old_name: str) -> str:
|
||||
rules = [
|
||||
(r"_(\d+)_attn_to_out_(\d+)", r".\1.attn.to_out.\2"),
|
||||
(r"_(\d+)_img_mlp_net_(\d+)_proj", r".\1.img_mlp.net.\2.proj"),
|
||||
(r"_(\d+)_txt_mlp_net_(\d+)_proj", r".\1.txt_mlp.net.\2.proj"),
|
||||
(r"_(\d+)_img_mlp_net_(\d+)", r".\1.img_mlp.net.\2"),
|
||||
(r"_(\d+)_txt_mlp_net_(\d+)", r".\1.txt_mlp.net.\2"),
|
||||
(r"_(\d+)_img_mod_(\d+)", r".\1.img_mod.\2"),
|
||||
(r"_(\d+)_txt_mod_(\d+)", r".\1.txt_mod.\2"),
|
||||
(r"_(\d+)_attn_", r".\1.attn."),
|
||||
]
|
||||
new_name = old_name
|
||||
for pattern, replacement in rules:
|
||||
new_name = re.sub(pattern, replacement, new_name)
|
||||
return new_name
|
||||
|
||||
|
||||
def _is_indexable_module(module):
|
||||
return isinstance(module, (nn.ModuleList, nn.Sequential, list, tuple))
|
||||
|
||||
|
||||
def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]:
|
||||
if not name:
|
||||
return model
|
||||
module = model
|
||||
for part in name.split("."):
|
||||
if not part:
|
||||
continue
|
||||
if hasattr(module, part):
|
||||
module = getattr(module, part)
|
||||
elif part.isdigit() and _is_indexable_module(module):
|
||||
try:
|
||||
module = module[int(part)]
|
||||
except (IndexError, TypeError):
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return module
|
||||
|
||||
|
||||
def _resolve_module_name(model: nn.Module, name: str) -> Tuple[str, Optional[nn.Module]]:
|
||||
module = _get_module_by_name(model, name)
|
||||
if module is not None:
|
||||
return name, module
|
||||
|
||||
replacements = [
|
||||
(".attn.to_out.0", ".attn.to_out"),
|
||||
(".attention.to_qkv", ".attention.qkv"),
|
||||
(".attention.to_out.0", ".attention.out"),
|
||||
(".feed_forward.net.0.proj", ".feed_forward.w13"),
|
||||
(".feed_forward.net.2", ".feed_forward.w2"),
|
||||
(".ff.net.0.proj", ".mlp_fc1"),
|
||||
(".ff.net.2", ".mlp_fc2"),
|
||||
(".ff_context.net.0.proj", ".mlp_context_fc1"),
|
||||
(".ff_context.net.2", ".mlp_context_fc2"),
|
||||
]
|
||||
for src, dst in replacements:
|
||||
if src in name:
|
||||
alt = name.replace(src, dst)
|
||||
module = _get_module_by_name(model, alt)
|
||||
if module is not None:
|
||||
return alt, module
|
||||
return name, None
|
||||
|
||||
|
||||
def _classify_and_map_key(key: str) -> Optional[Tuple[str, str, Optional[str], str]]:
|
||||
normalized = key
|
||||
if normalized.startswith("transformer."):
|
||||
normalized = normalized[len("transformer."):]
|
||||
if normalized.startswith("diffusion_model."):
|
||||
normalized = normalized[len("diffusion_model."):]
|
||||
if normalized.startswith("lora_unet_"):
|
||||
normalized = _rename_layer_underscore_layer_name(normalized[len("lora_unet_"):])
|
||||
|
||||
match = _RE_LORA_SUFFIX.search(normalized)
|
||||
if match:
|
||||
tag = match.group("tag")
|
||||
base = normalized[:match.start()]
|
||||
ab = "A" if ("lora_A" in tag or tag.endswith(".A") or "down" in tag) else "B"
|
||||
else:
|
||||
match = _RE_ALPHA_SUFFIX.search(normalized)
|
||||
if not match:
|
||||
return None
|
||||
base = normalized[:match.start()]
|
||||
ab = "alpha"
|
||||
|
||||
for pattern, template, group, comp_fn in KEY_MAPPING:
|
||||
key_match = pattern.match(base)
|
||||
if key_match:
|
||||
return group, key_match.expand(template), comp_fn(key_match) if comp_fn else None, ab
|
||||
return None
|
||||
|
||||
|
||||
def _detect_lora_format(lora_state_dict: Dict[str, torch.Tensor]) -> bool:
|
||||
standard_patterns = (
|
||||
".lora_up.",
|
||||
".lora_down.",
|
||||
".lora_A.",
|
||||
".lora_B.",
|
||||
".lora.up.",
|
||||
".lora.down.",
|
||||
".lora.A.",
|
||||
".lora.B.",
|
||||
)
|
||||
return any(pattern in key for key in lora_state_dict for pattern in standard_patterns)
|
||||
|
||||
|
||||
def _load_lora_state_dict(path_or_dict: Union[str, Path, Dict[str, torch.Tensor]]) -> Dict[str, torch.Tensor]:
|
||||
if isinstance(path_or_dict, dict):
|
||||
return path_or_dict
|
||||
path = Path(path_or_dict)
|
||||
if path.suffix == ".safetensors":
|
||||
state_dict: Dict[str, torch.Tensor] = {}
|
||||
with safe_open(path, framework="pt", device="cpu") as handle:
|
||||
for key in handle.keys():
|
||||
state_dict[key] = handle.get_tensor(key)
|
||||
return state_dict
|
||||
return comfy.utils.load_torch_file(str(path), safe_load=True)
|
||||
|
||||
|
||||
def _fuse_glu_lora(glu_weights: Dict[str, torch.Tensor]) -> Tuple[Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]:
|
||||
if "w1_A" not in glu_weights or "w3_A" not in glu_weights:
|
||||
return None, None, None
|
||||
a_w1, b_w1 = glu_weights["w1_A"], glu_weights["w1_B"]
|
||||
a_w3, b_w3 = glu_weights["w3_A"], glu_weights["w3_B"]
|
||||
if a_w1.shape[1] != a_w3.shape[1]:
|
||||
return None, None, None
|
||||
a_fused = torch.cat([a_w1, a_w3], dim=0)
|
||||
out1, out3 = b_w1.shape[0], b_w3.shape[0]
|
||||
rank1, rank3 = b_w1.shape[1], b_w3.shape[1]
|
||||
b_fused = torch.zeros(out1 + out3, rank1 + rank3, dtype=b_w1.dtype, device=b_w1.device)
|
||||
b_fused[:out1, :rank1] = b_w1
|
||||
b_fused[out1:, rank1:] = b_w3
|
||||
return a_fused, b_fused, glu_weights.get("w1_alpha")
|
||||
|
||||
|
||||
def _fuse_qkv_lora(qkv_weights: Dict[str, torch.Tensor], model: Optional[nn.Module] = None, base_key: Optional[str] = None) -> Tuple[Optional[torch.Tensor], Optional[torch.Tensor], Optional[torch.Tensor]]:
|
||||
required_keys = ["Q_A", "Q_B", "K_A", "K_B", "V_A", "V_B"]
|
||||
if not all(key in qkv_weights for key in required_keys):
|
||||
return None, None, None
|
||||
a_q, a_k, a_v = qkv_weights["Q_A"], qkv_weights["K_A"], qkv_weights["V_A"]
|
||||
b_q, b_k, b_v = qkv_weights["Q_B"], qkv_weights["K_B"], qkv_weights["V_B"]
|
||||
if not (a_q.shape == a_k.shape == a_v.shape):
|
||||
return None, None, None
|
||||
if not (b_q.shape[1] == b_k.shape[1] == b_v.shape[1]):
|
||||
return None, None, None
|
||||
|
||||
out_features = None
|
||||
if model is not None and base_key is not None:
|
||||
_, module = _resolve_module_name(model, base_key)
|
||||
out_features = getattr(module, "out_features", None) if module is not None else None
|
||||
|
||||
alpha_fused = None
|
||||
alpha_q = qkv_weights.get("Q_alpha")
|
||||
alpha_k = qkv_weights.get("K_alpha")
|
||||
alpha_v = qkv_weights.get("V_alpha")
|
||||
if alpha_q is not None and alpha_k is not None and alpha_v is not None and alpha_q.item() == alpha_k.item() == alpha_v.item():
|
||||
alpha_fused = alpha_q
|
||||
|
||||
a_fused = torch.cat([a_q, a_k, a_v], dim=0)
|
||||
rank = b_q.shape[1]
|
||||
out_q, out_k, out_v = b_q.shape[0], b_k.shape[0], b_v.shape[0]
|
||||
total_out = out_features if out_features is not None else out_q + out_k + out_v
|
||||
b_fused = torch.zeros(total_out, 3 * rank, dtype=b_q.dtype, device=b_q.device)
|
||||
b_fused[:out_q, :rank] = b_q
|
||||
b_fused[out_q:out_q + out_k, rank:2 * rank] = b_k
|
||||
b_fused[out_q + out_k:out_q + out_k + out_v, 2 * rank:] = b_v
|
||||
return a_fused, b_fused, alpha_fused
|
||||
|
||||
|
||||
def _handle_proj_out_split(lora_dict: Dict[str, Dict[str, torch.Tensor]], base_key: str, model: nn.Module) -> Tuple[Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]], List[str]]:
|
||||
result: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]] = {}
|
||||
consumed: List[str] = []
|
||||
match = re.search(r"single_transformer_blocks\.(\d+)", base_key)
|
||||
if not match or base_key not in lora_dict:
|
||||
return result, consumed
|
||||
block_idx = match.group(1)
|
||||
block = _get_module_by_name(model, f"single_transformer_blocks.{block_idx}")
|
||||
if block is None:
|
||||
return result, consumed
|
||||
a_full = lora_dict[base_key].get("A")
|
||||
b_full = lora_dict[base_key].get("B")
|
||||
alpha = lora_dict[base_key].get("alpha")
|
||||
attn_to_out = getattr(getattr(block, "attn", None), "to_out", None)
|
||||
mlp_fc2 = getattr(block, "mlp_fc2", None)
|
||||
if a_full is None or b_full is None or attn_to_out is None or mlp_fc2 is None:
|
||||
return result, consumed
|
||||
attn_in = getattr(attn_to_out, "in_features", None)
|
||||
mlp_in = getattr(mlp_fc2, "in_features", None)
|
||||
if attn_in is None or mlp_in is None or a_full.shape[1] != attn_in + mlp_in:
|
||||
return result, consumed
|
||||
result[f"single_transformer_blocks.{block_idx}.attn.to_out"] = (a_full[:, :attn_in], b_full.clone(), alpha)
|
||||
result[f"single_transformer_blocks.{block_idx}.mlp_fc2"] = (a_full[:, attn_in:], b_full.clone(), alpha)
|
||||
consumed.append(base_key)
|
||||
return result, consumed
|
||||
|
||||
|
||||
def _apply_lora_to_module(module: nn.Module, a_tensor: torch.Tensor, b_tensor: torch.Tensor, module_name: str, model: nn.Module) -> None:
|
||||
if not hasattr(module, "in_features") or not hasattr(module, "out_features"):
|
||||
raise ValueError(f"{module_name}: unsupported module without in/out features")
|
||||
if a_tensor.shape[1] != module.in_features or b_tensor.shape[0] != module.out_features:
|
||||
raise ValueError(f"{module_name}: LoRA shape mismatch")
|
||||
|
||||
if module.__class__.__name__ == "AWQW4A16Linear" and hasattr(module, "qweight"):
|
||||
if not hasattr(module, "_lora_original_forward"):
|
||||
module._lora_original_forward = module.forward
|
||||
if not hasattr(module, "_nunchaku_lora_bundle"):
|
||||
module._nunchaku_lora_bundle = []
|
||||
module._nunchaku_lora_bundle.append((a_tensor, b_tensor))
|
||||
|
||||
def _awq_lora_forward(x, *args, **kwargs):
|
||||
out = module._lora_original_forward(x, *args, **kwargs)
|
||||
x_flat = x.reshape(-1, module.in_features)
|
||||
for local_a, local_b in module._nunchaku_lora_bundle:
|
||||
local_a = local_a.to(device=out.device, dtype=out.dtype)
|
||||
local_b = local_b.to(device=out.device, dtype=out.dtype)
|
||||
lora_term = (x_flat @ local_a.transpose(0, 1)) @ local_b.transpose(0, 1)
|
||||
try:
|
||||
out = out + lora_term.reshape(out.shape)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
module.forward = _awq_lora_forward
|
||||
if not hasattr(model, "_lora_slots"):
|
||||
model._lora_slots = {}
|
||||
model._lora_slots[module_name] = {"type": "awq_w4a16"}
|
||||
return
|
||||
|
||||
if hasattr(module, "proj_down") and hasattr(module, "proj_up"):
|
||||
proj_down = unpack_lowrank_weight(module.proj_down.data, down=True)
|
||||
proj_up = unpack_lowrank_weight(module.proj_up.data, down=False)
|
||||
base_rank = proj_down.shape[0] if proj_down.shape[1] == module.in_features else proj_down.shape[1]
|
||||
if proj_down.shape[1] == module.in_features:
|
||||
updated_down = torch.cat([proj_down, a_tensor], dim=0)
|
||||
axis_down = 0
|
||||
else:
|
||||
updated_down = torch.cat([proj_down, a_tensor.T], dim=1)
|
||||
axis_down = 1
|
||||
updated_up = torch.cat([proj_up, b_tensor], dim=1)
|
||||
module.proj_down.data = pack_lowrank_weight(updated_down, down=True)
|
||||
module.proj_up.data = pack_lowrank_weight(updated_up, down=False)
|
||||
module.rank = base_rank + a_tensor.shape[0]
|
||||
if not hasattr(model, "_lora_slots"):
|
||||
model._lora_slots = {}
|
||||
model._lora_slots[module_name] = {
|
||||
"type": "nunchaku",
|
||||
"base_rank": base_rank,
|
||||
"axis_down": axis_down,
|
||||
}
|
||||
return
|
||||
|
||||
if isinstance(module, nn.Linear):
|
||||
if not hasattr(model, "_lora_slots"):
|
||||
model._lora_slots = {}
|
||||
if module_name not in model._lora_slots:
|
||||
model._lora_slots[module_name] = {
|
||||
"type": "linear",
|
||||
"original_weight": module.weight.detach().cpu().clone(),
|
||||
}
|
||||
module.weight.data.add_((b_tensor @ a_tensor).to(dtype=module.weight.dtype, device=module.weight.device))
|
||||
return
|
||||
|
||||
raise ValueError(f"{module_name}: unsupported module type {type(module)}")
|
||||
|
||||
|
||||
def reset_lora_v2(model: nn.Module) -> None:
|
||||
slots = getattr(model, "_lora_slots", None)
|
||||
if not slots:
|
||||
return
|
||||
for name, info in list(slots.items()):
|
||||
module = _get_module_by_name(model, name)
|
||||
if module is None:
|
||||
continue
|
||||
module_type = info.get("type", "nunchaku")
|
||||
if module_type == "nunchaku":
|
||||
base_rank = info["base_rank"]
|
||||
proj_down = unpack_lowrank_weight(module.proj_down.data, down=True)
|
||||
proj_up = unpack_lowrank_weight(module.proj_up.data, down=False)
|
||||
if info.get("axis_down", 0) == 0:
|
||||
proj_down = proj_down[:base_rank, :].clone()
|
||||
else:
|
||||
proj_down = proj_down[:, :base_rank].clone()
|
||||
proj_up = proj_up[:, :base_rank].clone()
|
||||
module.proj_down.data = pack_lowrank_weight(proj_down, down=True)
|
||||
module.proj_up.data = pack_lowrank_weight(proj_up, down=False)
|
||||
module.rank = base_rank
|
||||
elif module_type == "linear" and "original_weight" in info:
|
||||
module.weight.data.copy_(info["original_weight"].to(device=module.weight.device, dtype=module.weight.dtype))
|
||||
elif module_type == "awq_w4a16":
|
||||
if hasattr(module, "_lora_original_forward"):
|
||||
module.forward = module._lora_original_forward
|
||||
for attr in ("_lora_original_forward", "_nunchaku_lora_bundle"):
|
||||
if hasattr(module, attr):
|
||||
delattr(module, attr)
|
||||
model._lora_slots = {}
|
||||
|
||||
|
||||
def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]], apply_awq_mod: bool = True) -> bool:
|
||||
del apply_awq_mod # retained for interface compatibility
|
||||
reset_lora_v2(model)
|
||||
aggregated_weights: Dict[str, List[Dict[str, object]]] = defaultdict(list)
|
||||
saw_supported_format = False
|
||||
unresolved_targets = 0
|
||||
|
||||
for index, (path_or_dict, strength) in enumerate(lora_configs):
|
||||
if abs(strength) < 1e-5:
|
||||
continue
|
||||
lora_name = str(path_or_dict) if not isinstance(path_or_dict, dict) else f"lora_{index}"
|
||||
lora_state_dict = _load_lora_state_dict(path_or_dict)
|
||||
if not lora_state_dict or not _detect_lora_format(lora_state_dict):
|
||||
logger.warning("Skipping unsupported Qwen LoRA: %s", lora_name)
|
||||
continue
|
||||
saw_supported_format = True
|
||||
|
||||
grouped_weights: Dict[str, Dict[str, torch.Tensor]] = defaultdict(dict)
|
||||
for key, value in lora_state_dict.items():
|
||||
parsed = _classify_and_map_key(key)
|
||||
if parsed is None:
|
||||
continue
|
||||
group, base_key, component, ab = parsed
|
||||
if component and ab:
|
||||
grouped_weights[base_key][f"{component}_{ab}"] = value
|
||||
else:
|
||||
grouped_weights[base_key][ab] = value
|
||||
|
||||
processed_groups: Dict[str, Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]] = {}
|
||||
handled: set[str] = set()
|
||||
for base_key, weights in grouped_weights.items():
|
||||
if base_key in handled:
|
||||
continue
|
||||
a_tensor = b_tensor = alpha = None
|
||||
if "qkv" in base_key or "add_qkv_proj" in base_key:
|
||||
a_tensor, b_tensor, alpha = _fuse_qkv_lora(weights, model=model, base_key=base_key)
|
||||
elif "w1_A" in weights or "w3_A" in weights:
|
||||
a_tensor, b_tensor, alpha = _fuse_glu_lora(weights)
|
||||
elif ".proj_out" in base_key and "single_transformer_blocks" in base_key:
|
||||
split_map, consumed = _handle_proj_out_split(grouped_weights, base_key, model)
|
||||
processed_groups.update(split_map)
|
||||
handled.update(consumed)
|
||||
continue
|
||||
else:
|
||||
a_tensor, b_tensor, alpha = weights.get("A"), weights.get("B"), weights.get("alpha")
|
||||
if a_tensor is not None and b_tensor is not None:
|
||||
processed_groups[base_key] = (a_tensor, b_tensor, alpha)
|
||||
|
||||
for module_name, (a_tensor, b_tensor, alpha) in processed_groups.items():
|
||||
aggregated_weights[module_name].append({
|
||||
"A": a_tensor,
|
||||
"B": b_tensor,
|
||||
"alpha": alpha,
|
||||
"strength": strength,
|
||||
})
|
||||
|
||||
for module_name, weight_list in aggregated_weights.items():
|
||||
resolved_name, module = _resolve_module_name(model, module_name)
|
||||
if module is None:
|
||||
logger.warning("Skipping unresolved Qwen LoRA target: %s", module_name)
|
||||
unresolved_targets += 1
|
||||
continue
|
||||
all_a = []
|
||||
all_b_scaled = []
|
||||
for item in weight_list:
|
||||
a_tensor = item["A"]
|
||||
b_tensor = item["B"]
|
||||
alpha = item["alpha"]
|
||||
strength = float(item["strength"])
|
||||
rank = a_tensor.shape[0]
|
||||
scale = strength * ((alpha / rank) if alpha is not None else 1.0)
|
||||
if module.__class__.__name__ == "AWQW4A16Linear" and hasattr(module, "qweight"):
|
||||
target_dtype = torch.float16
|
||||
target_device = module.qweight.device
|
||||
elif hasattr(module, "proj_down"):
|
||||
target_dtype = module.proj_down.dtype
|
||||
target_device = module.proj_down.device
|
||||
elif hasattr(module, "weight"):
|
||||
target_dtype = module.weight.dtype
|
||||
target_device = module.weight.device
|
||||
else:
|
||||
target_dtype = torch.float16
|
||||
target_device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
all_a.append(a_tensor.to(dtype=target_dtype, device=target_device))
|
||||
all_b_scaled.append((b_tensor * scale).to(dtype=target_dtype, device=target_device))
|
||||
if not all_a:
|
||||
continue
|
||||
_apply_lora_to_module(module, torch.cat(all_a, dim=0), torch.cat(all_b_scaled, dim=1), resolved_name, model)
|
||||
|
||||
slot_count = len(getattr(model, "_lora_slots", {}) or {})
|
||||
logger.info(
|
||||
"Qwen LoRA composition finished: requested=%d supported=%s applied_targets=%d unresolved=%d",
|
||||
len(lora_configs),
|
||||
saw_supported_format,
|
||||
slot_count,
|
||||
unresolved_targets,
|
||||
)
|
||||
return saw_supported_format
|
||||
|
||||
|
||||
class ComfyQwenImageWrapperLM(nn.Module):
|
||||
def __init__(self, model: nn.Module, config=None, apply_awq_mod: bool = True):
|
||||
super().__init__()
|
||||
self.model = model
|
||||
self.config = {} if config is None else config
|
||||
self.dtype = next(model.parameters()).dtype
|
||||
self.loras: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]] = []
|
||||
self._applied_loras: Optional[List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]]] = None
|
||||
self.apply_awq_mod = apply_awq_mod
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
inner = object.__getattribute__(self, "_modules").get("model")
|
||||
except (AttributeError, KeyError):
|
||||
inner = None
|
||||
if inner is None:
|
||||
raise AttributeError(f"{type(self).__name__!s} has no attribute {name}")
|
||||
if name == "model":
|
||||
return inner
|
||||
return getattr(inner, name)
|
||||
|
||||
def process_img(self, *args, **kwargs):
|
||||
return self.model.process_img(*args, **kwargs)
|
||||
|
||||
def _ensure_composed(self):
|
||||
if self._applied_loras != self.loras or (not self.loras and getattr(self.model, "_lora_slots", None)):
|
||||
is_supported_format = compose_loras_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
|
||||
self._applied_loras = self.loras.copy()
|
||||
has_slots = bool(getattr(self.model, "_lora_slots", None))
|
||||
if self.loras and is_supported_format and not has_slots:
|
||||
logger.warning("Qwen LoRA compose produced 0 target modules. Resetting and retrying once.")
|
||||
reset_lora_v2(self.model)
|
||||
compose_loras_v2(self.model, self.loras, apply_awq_mod=self.apply_awq_mod)
|
||||
has_slots = bool(getattr(self.model, "_lora_slots", None))
|
||||
logger.info("Qwen LoRA retry result: applied_targets=%d", len(getattr(self.model, "_lora_slots", {}) or {}))
|
||||
|
||||
offload_manager = getattr(self.model, "offload_manager", None)
|
||||
if offload_manager is not None:
|
||||
offload_settings = {
|
||||
"num_blocks_on_gpu": getattr(offload_manager, "num_blocks_on_gpu", 1),
|
||||
"use_pin_memory": getattr(offload_manager, "use_pin_memory", False),
|
||||
}
|
||||
logger.info(
|
||||
"Rebuilding Qwen offload manager after LoRA compose: num_blocks_on_gpu=%s use_pin_memory=%s",
|
||||
offload_settings["num_blocks_on_gpu"],
|
||||
offload_settings["use_pin_memory"],
|
||||
)
|
||||
self.model.set_offload(False)
|
||||
self.model.set_offload(True, **offload_settings)
|
||||
|
||||
def forward(self, *args, **kwargs):
|
||||
self._ensure_composed()
|
||||
return self.model(*args, **kwargs)
|
||||
|
||||
|
||||
def _get_qwen_wrapper_and_transformer(model):
|
||||
model_wrapper = model.model.diffusion_model
|
||||
if hasattr(model_wrapper, "model") and hasattr(model_wrapper, "loras"):
|
||||
transformer = model_wrapper.model
|
||||
if transformer.__class__.__name__.endswith("NunchakuQwenImageTransformer2DModel"):
|
||||
return model_wrapper, transformer
|
||||
if model_wrapper.__class__.__name__.endswith("NunchakuQwenImageTransformer2DModel"):
|
||||
wrapped_model = ComfyQwenImageWrapperLM(model_wrapper, getattr(model_wrapper, "config", {}))
|
||||
model.model.diffusion_model = wrapped_model
|
||||
return wrapped_model, wrapped_model.model
|
||||
raise TypeError(f"This LoRA loader only works with Nunchaku Qwen Image models, but got {type(model_wrapper).__name__}.")
|
||||
|
||||
|
||||
def nunchaku_load_qwen_loras(model, lora_configs: List[Tuple[str, float]], apply_awq_mod: bool = True):
|
||||
model_wrapper, transformer = _get_qwen_wrapper_and_transformer(model)
|
||||
model_wrapper.apply_awq_mod = apply_awq_mod
|
||||
|
||||
saved_config = None
|
||||
if hasattr(model, "model") and hasattr(model.model, "model_config"):
|
||||
saved_config = model.model.model_config
|
||||
model.model.model_config = None
|
||||
|
||||
model_wrapper.model = None
|
||||
try:
|
||||
ret_model = copy.deepcopy(model)
|
||||
finally:
|
||||
if saved_config is not None:
|
||||
model.model.model_config = saved_config
|
||||
model_wrapper.model = transformer
|
||||
|
||||
ret_model_wrapper = ret_model.model.diffusion_model
|
||||
if saved_config is not None:
|
||||
ret_model.model.model_config = saved_config
|
||||
ret_model_wrapper.model = transformer
|
||||
ret_model_wrapper.apply_awq_mod = apply_awq_mod
|
||||
ret_model_wrapper.loras = list(getattr(model_wrapper, "loras", []))
|
||||
|
||||
for lora_name, lora_strength in lora_configs:
|
||||
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
|
||||
if not lora_path or not os.path.isfile(lora_path):
|
||||
logger.warning("Skipping Qwen LoRA '%s' because it could not be found", lora_name)
|
||||
continue
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
|
||||
return ret_model
|
||||
@@ -72,6 +72,13 @@ class SaveImageLM:
|
||||
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.",
|
||||
},
|
||||
),
|
||||
"save_with_metadata": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "When enabled, embeds generation parameters into the saved image metadata. Disable to skip writing generation metadata.",
|
||||
},
|
||||
),
|
||||
"add_counter_to_filename": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
@@ -350,6 +357,7 @@ class SaveImageLM:
|
||||
lossless_webp=True,
|
||||
quality=100,
|
||||
embed_workflow=False,
|
||||
save_with_metadata=True,
|
||||
add_counter_to_filename=True,
|
||||
):
|
||||
"""Save images with metadata"""
|
||||
@@ -421,7 +429,7 @@ class SaveImageLM:
|
||||
try:
|
||||
if file_format == "png":
|
||||
assert pnginfo is not None
|
||||
if metadata:
|
||||
if save_with_metadata and metadata:
|
||||
pnginfo.add_text("parameters", metadata)
|
||||
if embed_workflow and extra_pnginfo is not None:
|
||||
workflow_json = json.dumps(extra_pnginfo["workflow"])
|
||||
@@ -430,7 +438,7 @@ class SaveImageLM:
|
||||
img.save(file_path, format="PNG", **save_kwargs)
|
||||
elif file_format == "jpeg":
|
||||
# For JPEG, use piexif
|
||||
if metadata:
|
||||
if save_with_metadata and metadata:
|
||||
try:
|
||||
exif_dict = {
|
||||
"Exif": {
|
||||
@@ -448,7 +456,7 @@ class SaveImageLM:
|
||||
# For WebP, use piexif for metadata
|
||||
exif_dict = {}
|
||||
|
||||
if metadata:
|
||||
if save_with_metadata and metadata:
|
||||
exif_dict["Exif"] = {
|
||||
piexif.ExifIFD.UserComment: b"UNICODE\0"
|
||||
+ metadata.encode("utf-16be")
|
||||
@@ -489,6 +497,7 @@ class SaveImageLM:
|
||||
lossless_webp=True,
|
||||
quality=100,
|
||||
embed_workflow=False,
|
||||
save_with_metadata=True,
|
||||
add_counter_to_filename=True,
|
||||
):
|
||||
"""Process and save image with metadata"""
|
||||
@@ -516,7 +525,11 @@ class SaveImageLM:
|
||||
lossless_webp,
|
||||
quality,
|
||||
embed_workflow,
|
||||
save_with_metadata,
|
||||
add_counter_to_filename,
|
||||
)
|
||||
|
||||
return (images,)
|
||||
return {
|
||||
"result": (images,),
|
||||
"ui": {"images": results},
|
||||
}
|
||||
|
||||
@@ -158,3 +158,24 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
|
||||
ret_model.model.model_config.unet_config["in_channels"] = new_in_channels
|
||||
|
||||
return ret_model
|
||||
|
||||
|
||||
def detect_nunchaku_model_kind(model):
|
||||
"""Return the supported Nunchaku model kind for a Comfy model, if any."""
|
||||
try:
|
||||
model_wrapper = model.model.diffusion_model
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
wrapper_name = model_wrapper.__class__.__name__
|
||||
if wrapper_name == "ComfyFluxWrapper":
|
||||
return "flux"
|
||||
|
||||
inner_model = getattr(model_wrapper, "model", None)
|
||||
inner_name = inner_model.__class__.__name__ if inner_model is not None else ""
|
||||
if wrapper_name.endswith("NunchakuQwenImageTransformer2DModel"):
|
||||
return "qwen_image"
|
||||
if inner_name.endswith("NunchakuQwenImageTransformer2DModel"):
|
||||
return "qwen_image"
|
||||
|
||||
return None
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -40,6 +40,7 @@ from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import is_valid_example_images_root
|
||||
from ...utils.lora_metadata import extract_trained_words
|
||||
from ...utils.usage_stats import UsageStats
|
||||
from .base_model_handlers import BaseModelHandlerSet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -750,6 +751,7 @@ class ServiceRegistryAdapter:
|
||||
get_lora_scanner: Callable[[], Awaitable]
|
||||
get_checkpoint_scanner: Callable[[], Awaitable]
|
||||
get_embedding_scanner: Callable[[], Awaitable]
|
||||
get_downloaded_version_history_service: Callable[[], Awaitable]
|
||||
|
||||
|
||||
class ModelLibraryHandler:
|
||||
@@ -763,6 +765,41 @@ class ModelLibraryHandler:
|
||||
self._service_registry = service_registry
|
||||
self._metadata_provider_factory = metadata_provider_factory
|
||||
|
||||
@staticmethod
|
||||
def _normalize_model_type(model_type: str | None) -> str | None:
|
||||
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
|
||||
|
||||
async def _get_scanner_for_type(self, model_type: str | None):
|
||||
normalized_type = self._normalize_model_type(model_type)
|
||||
if normalized_type == "lora":
|
||||
return normalized_type, await self._service_registry.get_lora_scanner()
|
||||
if normalized_type == "checkpoint":
|
||||
return normalized_type, await self._service_registry.get_checkpoint_scanner()
|
||||
if normalized_type == "embedding":
|
||||
return normalized_type, await self._service_registry.get_embedding_scanner()
|
||||
return None, None
|
||||
|
||||
async def _get_download_history_service(self):
|
||||
return await self._service_registry.get_downloaded_version_history_service()
|
||||
|
||||
@staticmethod
|
||||
def _with_downloaded_flag(versions: list[dict]) -> list[dict]:
|
||||
enriched: list[dict] = []
|
||||
for version in versions:
|
||||
entry = dict(version)
|
||||
entry.setdefault("hasBeenDownloaded", True)
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
|
||||
async def check_model_exists(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
model_id_str = request.query.get("modelId")
|
||||
@@ -818,11 +855,30 @@ class ModelLibraryHandler:
|
||||
exists = True
|
||||
model_type = "embedding"
|
||||
|
||||
history_service = await self._get_download_history_service()
|
||||
has_been_downloaded = False
|
||||
history_type = model_type
|
||||
if history_type:
|
||||
has_been_downloaded = await history_service.has_been_downloaded(
|
||||
history_type,
|
||||
model_version_id,
|
||||
)
|
||||
else:
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
if await history_service.has_been_downloaded(
|
||||
candidate_type,
|
||||
model_version_id,
|
||||
):
|
||||
has_been_downloaded = True
|
||||
history_type = candidate_type
|
||||
break
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"exists": exists,
|
||||
"modelType": model_type if exists else None,
|
||||
"modelType": model_type if exists else history_type,
|
||||
"hasBeenDownloaded": has_been_downloaded,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -840,23 +896,166 @@ class ModelLibraryHandler:
|
||||
|
||||
model_type = None
|
||||
versions = []
|
||||
downloaded_version_ids = []
|
||||
history_service = await self._get_download_history_service()
|
||||
if lora_versions:
|
||||
model_type = "lora"
|
||||
versions = lora_versions
|
||||
versions = self._with_downloaded_flag(lora_versions)
|
||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||
model_type,
|
||||
model_id,
|
||||
)
|
||||
elif checkpoint_versions:
|
||||
model_type = "checkpoint"
|
||||
versions = checkpoint_versions
|
||||
versions = self._with_downloaded_flag(checkpoint_versions)
|
||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||
model_type,
|
||||
model_id,
|
||||
)
|
||||
elif embedding_versions:
|
||||
model_type = "embedding"
|
||||
versions = embedding_versions
|
||||
versions = self._with_downloaded_flag(embedding_versions)
|
||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||
model_type,
|
||||
model_id,
|
||||
)
|
||||
else:
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
candidate_downloaded_version_ids = (
|
||||
await history_service.get_downloaded_version_ids(
|
||||
candidate_type,
|
||||
model_id,
|
||||
)
|
||||
)
|
||||
if candidate_downloaded_version_ids:
|
||||
model_type = candidate_type
|
||||
downloaded_version_ids = candidate_downloaded_version_ids
|
||||
break
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "modelType": model_type, "versions": versions}
|
||||
{
|
||||
"success": True,
|
||||
"modelType": model_type,
|
||||
"versions": versions,
|
||||
"downloadedVersionIds": downloaded_version_ids,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to check model existence: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_model_version_download_status(
|
||||
self, request: web.Request
|
||||
) -> web.Response:
|
||||
try:
|
||||
model_type, _ = await self._get_scanner_for_type(request.query.get("modelType"))
|
||||
if not model_type:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Parameter modelType is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
model_version_id_str = request.query.get("modelVersionId")
|
||||
if not model_version_id_str:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing required parameter: modelVersionId"},
|
||||
status=400,
|
||||
)
|
||||
try:
|
||||
model_version_id = int(model_version_id_str)
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
history_service = await self._get_download_history_service()
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"modelType": model_type,
|
||||
"modelVersionId": model_version_id,
|
||||
"hasBeenDownloaded": await history_service.has_been_downloaded(
|
||||
model_type,
|
||||
model_version_id,
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to get model version download status: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def set_model_version_download_status(
|
||||
self, request: web.Request
|
||||
) -> web.Response:
|
||||
try:
|
||||
if request.method == "GET":
|
||||
data = request.query
|
||||
else:
|
||||
data = await request.json()
|
||||
model_type, _ = await self._get_scanner_for_type(data.get("modelType"))
|
||||
if not model_type:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Parameter modelType is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
model_version_id = int(data.get("modelVersionId"))
|
||||
except (TypeError, ValueError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
downloaded = data.get("downloaded")
|
||||
if isinstance(downloaded, str):
|
||||
normalized_downloaded = downloaded.strip().lower()
|
||||
if normalized_downloaded in {"true", "1"}:
|
||||
downloaded = True
|
||||
elif normalized_downloaded in {"false", "0"}:
|
||||
downloaded = False
|
||||
|
||||
if not isinstance(downloaded, bool):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Parameter downloaded must be a boolean"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
history_service = await self._get_download_history_service()
|
||||
if downloaded:
|
||||
model_id = data.get("modelId")
|
||||
file_path = data.get("filePath")
|
||||
await history_service.mark_downloaded(
|
||||
model_type,
|
||||
model_version_id,
|
||||
model_id=model_id,
|
||||
source="manual",
|
||||
file_path=file_path if isinstance(file_path, str) else None,
|
||||
)
|
||||
else:
|
||||
await history_service.mark_not_downloaded(model_type, model_version_id)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"modelType": model_type,
|
||||
"modelVersionId": model_version_id,
|
||||
"hasBeenDownloaded": downloaded,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Failed to set model version download status: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_model_versions_status(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
model_id_str = request.query.get("modelId")
|
||||
@@ -895,18 +1094,8 @@ class ModelLibraryHandler:
|
||||
model_name = response.get("name", "")
|
||||
model_type = response.get("type", "").lower()
|
||||
|
||||
scanner = None
|
||||
normalized_type = None
|
||||
if model_type in {"lora", "locon", "dora"}:
|
||||
scanner = await self._service_registry.get_lora_scanner()
|
||||
normalized_type = "lora"
|
||||
elif model_type == "checkpoint":
|
||||
scanner = await self._service_registry.get_checkpoint_scanner()
|
||||
normalized_type = "checkpoint"
|
||||
elif model_type == "textualinversion":
|
||||
scanner = await self._service_registry.get_embedding_scanner()
|
||||
normalized_type = "embedding"
|
||||
else:
|
||||
normalized_type, scanner = await self._get_scanner_for_type(model_type)
|
||||
if not normalized_type:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
@@ -924,8 +1113,14 @@ class ModelLibraryHandler:
|
||||
status=503,
|
||||
)
|
||||
|
||||
history_service = await self._get_download_history_service()
|
||||
local_versions = await scanner.get_model_versions_by_id(model_id)
|
||||
local_version_ids = {version["versionId"] for version in local_versions}
|
||||
downloaded_version_ids = await history_service.get_downloaded_version_ids(
|
||||
normalized_type,
|
||||
model_id,
|
||||
)
|
||||
downloaded_version_id_set = set(downloaded_version_ids)
|
||||
|
||||
enriched_versions = []
|
||||
for version in versions:
|
||||
@@ -938,6 +1133,7 @@ class ModelLibraryHandler:
|
||||
if version.get("images")
|
||||
else None,
|
||||
"inLibrary": version_id in local_version_ids,
|
||||
"hasBeenDownloaded": version_id in downloaded_version_id_set,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1006,6 +1202,33 @@ class ModelLibraryHandler:
|
||||
}
|
||||
|
||||
versions: list[dict] = []
|
||||
history_service = await self._get_download_history_service()
|
||||
model_ids: list[int] = []
|
||||
for model in models:
|
||||
try:
|
||||
model_ids.append(int(model.get("id")))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
lora_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||
"lora",
|
||||
model_ids,
|
||||
)
|
||||
checkpoint_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||
"checkpoint",
|
||||
model_ids,
|
||||
)
|
||||
embedding_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||
"embedding",
|
||||
model_ids,
|
||||
)
|
||||
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
||||
"lora": lora_downloaded,
|
||||
"locon": lora_downloaded,
|
||||
"dora": lora_downloaded,
|
||||
"checkpoint": checkpoint_downloaded,
|
||||
"textualinversion": embedding_downloaded,
|
||||
}
|
||||
for model in models:
|
||||
if not isinstance(model, dict):
|
||||
continue
|
||||
@@ -1060,6 +1283,8 @@ class ModelLibraryHandler:
|
||||
in_library = await scanner.check_model_version_exists(
|
||||
version_id_int
|
||||
)
|
||||
downloaded_versions = downloaded_version_map.get(model_type, {})
|
||||
downloaded_version_ids = downloaded_versions.get(model_id_int, set())
|
||||
|
||||
versions.append(
|
||||
{
|
||||
@@ -1072,6 +1297,7 @@ class ModelLibraryHandler:
|
||||
"baseModel": version.get("baseModel"),
|
||||
"thumbnailUrl": thumbnail_url,
|
||||
"inLibrary": in_library,
|
||||
"hasBeenDownloaded": version_id_int in downloaded_version_ids,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1618,6 +1844,7 @@ class MiscHandlerSet:
|
||||
custom_words: CustomWordsHandler,
|
||||
supporters: SupportersHandler,
|
||||
example_workflows: ExampleWorkflowsHandler,
|
||||
base_model: BaseModelHandlerSet,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -1632,6 +1859,7 @@ class MiscHandlerSet:
|
||||
self.custom_words = custom_words
|
||||
self.supporters = supporters
|
||||
self.example_workflows = example_workflows
|
||||
self.base_model = base_model
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -1652,6 +1880,8 @@ class MiscHandlerSet:
|
||||
"update_node_widget": self.node_registry.update_node_widget,
|
||||
"get_registry": self.node_registry.get_registry,
|
||||
"check_model_exists": self.model_library.check_model_exists,
|
||||
"get_model_version_download_status": self.model_library.get_model_version_download_status,
|
||||
"set_model_version_download_status": self.model_library.set_model_version_download_status,
|
||||
"get_civitai_user_models": self.model_library.get_civitai_user_models,
|
||||
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
|
||||
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
|
||||
@@ -1663,6 +1893,11 @@ class MiscHandlerSet:
|
||||
"get_supporters": self.supporters.get_supporters,
|
||||
"get_example_workflows": self.example_workflows.get_example_workflows,
|
||||
"get_example_workflow": self.example_workflows.get_example_workflow,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
"get_base_model_categories": self.base_model.get_base_model_categories,
|
||||
"get_base_model_cache_status": self.base_model.get_base_model_cache_status,
|
||||
}
|
||||
|
||||
|
||||
@@ -1671,4 +1906,5 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
|
||||
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
||||
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
||||
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
||||
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -37,6 +37,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"
|
||||
@@ -56,6 +71,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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from .handlers.misc_handlers import (
|
||||
UsageStatsHandler,
|
||||
build_service_registry_adapter,
|
||||
)
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -128,6 +129,7 @@ class MiscRoutes:
|
||||
custom_words = CustomWordsHandler()
|
||||
supporters = SupportersHandler()
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
base_model = BaseModelHandlerSet()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -143,6 +145,7 @@ class MiscRoutes:
|
||||
custom_words=custom_words,
|
||||
supporters=supporters,
|
||||
example_workflows=example_workflows,
|
||||
base_model=base_model,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
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.com/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()
|
||||
@@ -64,6 +64,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 +368,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 (
|
||||
@@ -640,6 +704,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 +740,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,
|
||||
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
@@ -278,6 +279,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 +365,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 +416,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 +436,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
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -1615,6 +1615,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 +1635,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 +1790,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 +1805,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 +1854,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("/")
|
||||
@@ -2334,6 +2350,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."""
|
||||
|
||||
|
||||
@@ -143,6 +143,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):
|
||||
|
||||
@@ -173,11 +173,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")
|
||||
|
||||
@@ -167,6 +167,28 @@ class ServiceRegistry:
|
||||
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_civarchive_client(cls):
|
||||
"""Get or create CivArchive client instance"""
|
||||
@@ -255,4 +277,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")
|
||||
|
||||
@@ -7,7 +7,17 @@ import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from threading import Lock
|
||||
from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
@@ -17,7 +27,11 @@ from ..utils.constants import (
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
)
|
||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
|
||||
from ..utils.settings_paths import (
|
||||
APP_NAME,
|
||||
ensure_settings_file,
|
||||
get_legacy_settings_path,
|
||||
)
|
||||
from ..utils.tag_priorities import (
|
||||
PriorityTagEntry,
|
||||
collect_canonical_tags,
|
||||
@@ -77,6 +91,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"update_flag_strategy": "same_base",
|
||||
"auto_organize_exclusions": [],
|
||||
"metadata_refresh_skip_paths": [],
|
||||
"skip_previously_downloaded_model_versions": False,
|
||||
"download_skip_base_models": [],
|
||||
}
|
||||
|
||||
@@ -94,7 +109,9 @@ class SettingsManager:
|
||||
self._template_payload_cache_loaded = False
|
||||
self._original_disk_payload: Optional[Dict[str, Any]] = None
|
||||
self._preserve_disk_template = False
|
||||
self._template_path = Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||
self._template_path = (
|
||||
Path(__file__).resolve().parents[2] / "settings.json.example"
|
||||
)
|
||||
self.settings = self._load_settings()
|
||||
self._migrate_setting_keys()
|
||||
self._ensure_default_settings()
|
||||
@@ -120,7 +137,7 @@ class SettingsManager:
|
||||
"""Load settings from file"""
|
||||
if os.path.exists(self.settings_file):
|
||||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
with open(self.settings_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
self._original_disk_payload = copy.deepcopy(data)
|
||||
@@ -198,7 +215,9 @@ class SettingsManager:
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.debug("settings.json.example is not a JSON object; ignoring template")
|
||||
logger.debug(
|
||||
"settings.json.example is not a JSON object; ignoring template"
|
||||
)
|
||||
return None
|
||||
|
||||
self._template_payload_cache = copy.deepcopy(data)
|
||||
@@ -274,7 +293,9 @@ class SettingsManager:
|
||||
normalized_skip_paths = self.normalize_metadata_refresh_skip_paths(
|
||||
self.settings.get("metadata_refresh_skip_paths")
|
||||
)
|
||||
if normalized_skip_paths != self.settings.get("metadata_refresh_skip_paths"):
|
||||
if normalized_skip_paths != self.settings.get(
|
||||
"metadata_refresh_skip_paths"
|
||||
):
|
||||
self.settings["metadata_refresh_skip_paths"] = normalized_skip_paths
|
||||
updated_existing = True
|
||||
else:
|
||||
@@ -288,14 +309,16 @@ class SettingsManager:
|
||||
if normalized_skip_base_models != self.settings.get(
|
||||
"download_skip_base_models"
|
||||
):
|
||||
self.settings["download_skip_base_models"] = (
|
||||
normalized_skip_base_models
|
||||
)
|
||||
self.settings["download_skip_base_models"] = normalized_skip_base_models
|
||||
updated_existing = True
|
||||
else:
|
||||
self.settings["download_skip_base_models"] = []
|
||||
inserted_defaults = True
|
||||
|
||||
if "skip_previously_downloaded_model_versions" not in self.settings:
|
||||
self.settings["skip_previously_downloaded_model_versions"] = False
|
||||
inserted_defaults = True
|
||||
|
||||
had_mature_level = "mature_blur_level" in self.settings
|
||||
raw_mature_level = self.settings.get("mature_blur_level")
|
||||
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
|
||||
@@ -330,19 +353,19 @@ class SettingsManager:
|
||||
raw_top_level_paths = self.settings.get("folder_paths", {})
|
||||
normalized_top_level_paths: Dict[str, List[str]] = {}
|
||||
if isinstance(raw_top_level_paths, Mapping):
|
||||
normalized_top_level_paths = self._normalize_folder_paths(raw_top_level_paths)
|
||||
normalized_top_level_paths = self._normalize_folder_paths(
|
||||
raw_top_level_paths
|
||||
)
|
||||
if normalized_top_level_paths != raw_top_level_paths:
|
||||
self.settings["folder_paths"] = copy.deepcopy(normalized_top_level_paths)
|
||||
self.settings["folder_paths"] = copy.deepcopy(
|
||||
normalized_top_level_paths
|
||||
)
|
||||
|
||||
top_level_has_paths = self._has_configured_paths(normalized_top_level_paths)
|
||||
|
||||
needs_library_bootstrap = not isinstance(libraries, dict) or not libraries
|
||||
|
||||
if (
|
||||
not needs_library_bootstrap
|
||||
and top_level_has_paths
|
||||
and len(libraries) == 1
|
||||
):
|
||||
if not needs_library_bootstrap and top_level_has_paths and len(libraries) == 1:
|
||||
only_library_payload = next(iter(libraries.values()))
|
||||
if isinstance(only_library_payload, Mapping):
|
||||
folder_payload = only_library_payload.get("folder_paths")
|
||||
@@ -354,7 +377,9 @@ class SettingsManager:
|
||||
library_payload = self._build_library_payload(
|
||||
folder_paths=normalized_top_level_paths,
|
||||
default_lora_root=self.settings.get("default_lora_root", ""),
|
||||
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""),
|
||||
default_checkpoint_root=self.settings.get(
|
||||
"default_checkpoint_root", ""
|
||||
),
|
||||
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||
)
|
||||
@@ -376,7 +401,11 @@ class SettingsManager:
|
||||
|
||||
if target_name:
|
||||
candidate_payload = libraries.get(target_name)
|
||||
if isinstance(candidate_payload, Mapping) and not self._has_configured_paths(candidate_payload.get("folder_paths")):
|
||||
if isinstance(
|
||||
candidate_payload, Mapping
|
||||
) and not self._has_configured_paths(
|
||||
candidate_payload.get("folder_paths")
|
||||
):
|
||||
seed_library_name = target_name
|
||||
|
||||
sanitized_libraries: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -435,11 +464,17 @@ class SettingsManager:
|
||||
active_library = libraries.get(active_name, {})
|
||||
folder_paths = copy.deepcopy(active_library.get("folder_paths", {}))
|
||||
self.settings["folder_paths"] = folder_paths
|
||||
self.settings["extra_folder_paths"] = copy.deepcopy(active_library.get("extra_folder_paths", {}))
|
||||
self.settings["extra_folder_paths"] = copy.deepcopy(
|
||||
active_library.get("extra_folder_paths", {})
|
||||
)
|
||||
self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
|
||||
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "")
|
||||
self.settings["default_checkpoint_root"] = active_library.get(
|
||||
"default_checkpoint_root", ""
|
||||
)
|
||||
self.settings["default_unet_root"] = active_library.get("default_unet_root", "")
|
||||
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "")
|
||||
self.settings["default_embedding_root"] = active_library.get(
|
||||
"default_embedding_root", ""
|
||||
)
|
||||
|
||||
if save:
|
||||
self._save_settings()
|
||||
@@ -468,7 +503,9 @@ class SettingsManager:
|
||||
payload.setdefault("folder_paths", {})
|
||||
|
||||
if extra_folder_paths is not None:
|
||||
payload["extra_folder_paths"] = self._normalize_folder_paths(extra_folder_paths)
|
||||
payload["extra_folder_paths"] = self._normalize_folder_paths(
|
||||
extra_folder_paths
|
||||
)
|
||||
else:
|
||||
payload.setdefault("extra_folder_paths", {})
|
||||
|
||||
@@ -577,7 +614,9 @@ class SettingsManager:
|
||||
}
|
||||
overlap = existing.intersection(new_paths.keys())
|
||||
if overlap:
|
||||
collisions = ", ".join(sorted(new_paths[value] for value in overlap))
|
||||
collisions = ", ".join(
|
||||
sorted(new_paths[value] for value in overlap)
|
||||
)
|
||||
raise ValueError(
|
||||
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
|
||||
)
|
||||
@@ -612,19 +651,31 @@ class SettingsManager:
|
||||
library["extra_folder_paths"] = normalized_extra_paths
|
||||
changed = True
|
||||
|
||||
if default_lora_root is not None and library.get("default_lora_root") != default_lora_root:
|
||||
if (
|
||||
default_lora_root is not None
|
||||
and library.get("default_lora_root") != default_lora_root
|
||||
):
|
||||
library["default_lora_root"] = default_lora_root
|
||||
changed = True
|
||||
|
||||
if default_checkpoint_root is not None and library.get("default_checkpoint_root") != default_checkpoint_root:
|
||||
if (
|
||||
default_checkpoint_root is not None
|
||||
and library.get("default_checkpoint_root") != default_checkpoint_root
|
||||
):
|
||||
library["default_checkpoint_root"] = default_checkpoint_root
|
||||
changed = True
|
||||
|
||||
if default_unet_root is not None and library.get("default_unet_root") != default_unet_root:
|
||||
if (
|
||||
default_unet_root is not None
|
||||
and library.get("default_unet_root") != default_unet_root
|
||||
):
|
||||
library["default_unet_root"] = default_unet_root
|
||||
changed = True
|
||||
|
||||
if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root:
|
||||
if (
|
||||
default_embedding_root is not None
|
||||
and library.get("default_embedding_root") != default_embedding_root
|
||||
):
|
||||
library["default_embedding_root"] = default_embedding_root
|
||||
changed = True
|
||||
|
||||
@@ -637,16 +688,16 @@ class SettingsManager:
|
||||
def _migrate_setting_keys(self) -> None:
|
||||
"""Migrate legacy camelCase setting keys to snake_case"""
|
||||
key_migrations = {
|
||||
'optimizeExampleImages': 'optimize_example_images',
|
||||
'autoDownloadExampleImages': 'auto_download_example_images',
|
||||
'blurMatureContent': 'blur_mature_content',
|
||||
'matureBlurLevel': 'mature_blur_level',
|
||||
'autoplayOnHover': 'autoplay_on_hover',
|
||||
'displayDensity': 'display_density',
|
||||
'cardInfoDisplay': 'card_info_display',
|
||||
'includeTriggerWords': 'include_trigger_words',
|
||||
'compactMode': 'compact_mode',
|
||||
'modelCardFooterAction': 'model_card_footer_action',
|
||||
"optimizeExampleImages": "optimize_example_images",
|
||||
"autoDownloadExampleImages": "auto_download_example_images",
|
||||
"blurMatureContent": "blur_mature_content",
|
||||
"matureBlurLevel": "mature_blur_level",
|
||||
"autoplayOnHover": "autoplay_on_hover",
|
||||
"displayDensity": "display_density",
|
||||
"cardInfoDisplay": "card_info_display",
|
||||
"includeTriggerWords": "include_trigger_words",
|
||||
"compactMode": "compact_mode",
|
||||
"modelCardFooterAction": "model_card_footer_action",
|
||||
}
|
||||
|
||||
updated = False
|
||||
@@ -663,65 +714,77 @@ class SettingsManager:
|
||||
|
||||
def _migrate_download_path_template(self):
|
||||
"""Migrate old download_path_template to new download_path_templates"""
|
||||
old_template = self.settings.get('download_path_template')
|
||||
templates = self.settings.get('download_path_templates')
|
||||
old_template = self.settings.get("download_path_template")
|
||||
templates = self.settings.get("download_path_templates")
|
||||
|
||||
# If old template exists and new templates don't exist, migrate
|
||||
if old_template is not None and not templates:
|
||||
logger.info("Migrating download_path_template to download_path_templates")
|
||||
self.settings['download_path_templates'] = {
|
||||
'lora': old_template,
|
||||
'checkpoint': old_template,
|
||||
'embedding': old_template
|
||||
self.settings["download_path_templates"] = {
|
||||
"lora": old_template,
|
||||
"checkpoint": old_template,
|
||||
"embedding": old_template,
|
||||
}
|
||||
# Remove old setting
|
||||
del self.settings['download_path_template']
|
||||
del self.settings["download_path_template"]
|
||||
self._save_settings()
|
||||
logger.info("Migration completed")
|
||||
|
||||
def _auto_set_default_roots(self):
|
||||
"""Auto set default root paths when the current default is unset or not among the options.
|
||||
"""Ensure default root paths always point at a current valid root.
|
||||
|
||||
For single-path cases, always use that path.
|
||||
For multi-path cases, only set if current default is empty or invalid.
|
||||
Empty or stale defaults are repaired to the first configured root.
|
||||
Skips auto-setting when the settings file matches the template
|
||||
(user hasn't customized yet).
|
||||
"""
|
||||
folder_paths = self.settings.get('folder_paths', {})
|
||||
# Skip auto-setting if the user hasn't customized settings yet (template preserved)
|
||||
if self._preserve_disk_template:
|
||||
return
|
||||
|
||||
folder_paths = self.settings.get("folder_paths", {})
|
||||
updated = False
|
||||
# loras
|
||||
loras = folder_paths.get('loras', [])
|
||||
if isinstance(loras, list) and len(loras) == 1:
|
||||
current_lora_root = self.settings.get('default_lora_root')
|
||||
if current_lora_root not in loras:
|
||||
self.settings['default_lora_root'] = loras[0]
|
||||
updated = True
|
||||
# checkpoints
|
||||
checkpoints = folder_paths.get('checkpoints', [])
|
||||
if isinstance(checkpoints, list) and len(checkpoints) == 1:
|
||||
current_checkpoint_root = self.settings.get('default_checkpoint_root')
|
||||
if current_checkpoint_root not in checkpoints:
|
||||
self.settings['default_checkpoint_root'] = checkpoints[0]
|
||||
updated = True
|
||||
# unet (diffusion models) - auto-set if empty or invalid
|
||||
unet_paths = folder_paths.get('unet', [])
|
||||
if isinstance(unet_paths, list) and len(unet_paths) >= 1:
|
||||
current_unet_root = self.settings.get('default_unet_root')
|
||||
# Set to first path if current is empty or not in the valid paths
|
||||
if not current_unet_root or current_unet_root not in unet_paths:
|
||||
self.settings['default_unet_root'] = unet_paths[0]
|
||||
updated = True
|
||||
# embeddings
|
||||
embeddings = folder_paths.get('embeddings', [])
|
||||
if isinstance(embeddings, list) and len(embeddings) == 1:
|
||||
current_embedding_root = self.settings.get('default_embedding_root')
|
||||
if current_embedding_root not in embeddings:
|
||||
self.settings['default_embedding_root'] = embeddings[0]
|
||||
updated = True
|
||||
|
||||
def _check_and_auto_set(key: str, setting_key: str) -> bool:
|
||||
"""Repair default roots when empty or no longer present."""
|
||||
current = self.settings.get(setting_key, "")
|
||||
candidates = folder_paths.get(key, [])
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
return False
|
||||
|
||||
# Filter valid path strings
|
||||
valid_paths = [p for p in candidates if isinstance(p, str) and p.strip()]
|
||||
if not valid_paths:
|
||||
return False
|
||||
|
||||
if current in valid_paths:
|
||||
return False
|
||||
|
||||
self.settings[setting_key] = valid_paths[0]
|
||||
if current:
|
||||
logger.info(
|
||||
"Repaired stale %s from '%s' to '%s'",
|
||||
setting_key,
|
||||
current,
|
||||
valid_paths[0],
|
||||
)
|
||||
else:
|
||||
logger.info("Auto-set %s to '%s'", setting_key, valid_paths[0])
|
||||
return True
|
||||
|
||||
# Process all model types
|
||||
updated = _check_and_auto_set("loras", "default_lora_root") or updated
|
||||
updated = (
|
||||
_check_and_auto_set("checkpoints", "default_checkpoint_root") or updated
|
||||
)
|
||||
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
||||
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
|
||||
|
||||
if updated:
|
||||
self._update_active_library_entry(
|
||||
default_lora_root=self.settings.get('default_lora_root'),
|
||||
default_checkpoint_root=self.settings.get('default_checkpoint_root'),
|
||||
default_unet_root=self.settings.get('default_unet_root'),
|
||||
default_embedding_root=self.settings.get('default_embedding_root'),
|
||||
default_lora_root=self.settings.get("default_lora_root"),
|
||||
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
||||
default_unet_root=self.settings.get("default_unet_root"),
|
||||
default_embedding_root=self.settings.get("default_embedding_root"),
|
||||
)
|
||||
if self._bootstrap_reason == "missing":
|
||||
self._needs_initial_save = True
|
||||
@@ -730,11 +793,11 @@ class SettingsManager:
|
||||
|
||||
def _check_environment_variables(self) -> None:
|
||||
"""Check for environment variables and update settings if needed"""
|
||||
env_api_key = os.environ.get('CIVITAI_API_KEY')
|
||||
env_api_key = os.environ.get("CIVITAI_API_KEY")
|
||||
if env_api_key: # Check if the environment variable exists and is not empty
|
||||
logger.info("Found CIVITAI_API_KEY environment variable")
|
||||
# Always use the environment variable if it exists
|
||||
self.settings['civitai_api_key'] = env_api_key
|
||||
self.settings["civitai_api_key"] = env_api_key
|
||||
self._save_settings()
|
||||
|
||||
def _default_settings_actions(self) -> List[Dict[str, Any]]:
|
||||
@@ -799,7 +862,9 @@ class SettingsManager:
|
||||
disk_value = self._original_disk_payload.get(key)
|
||||
default_value = defaults.get(key)
|
||||
# Compare using JSON serialization for complex objects
|
||||
if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(default_value, sort_keys=True, default=str):
|
||||
if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(
|
||||
default_value, sort_keys=True, default=str
|
||||
):
|
||||
default_value_keys.add(key)
|
||||
|
||||
# Only cleanup if there are "many" default keys (indicating a bloated file)
|
||||
@@ -807,7 +872,7 @@ class SettingsManager:
|
||||
if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD:
|
||||
logger.info(
|
||||
"Cleaning up %d default value(s) from settings.json to keep it minimal",
|
||||
len(default_value_keys)
|
||||
len(default_value_keys),
|
||||
)
|
||||
self._save_settings()
|
||||
# Update original payload to match what we just saved
|
||||
@@ -817,8 +882,8 @@ class SettingsManager:
|
||||
if not self._standalone_mode:
|
||||
return
|
||||
|
||||
folder_paths = self.settings.get('folder_paths', {}) or {}
|
||||
monitored_keys = ('loras', 'checkpoints', 'embeddings')
|
||||
folder_paths = self.settings.get("folder_paths", {}) or {}
|
||||
monitored_keys = ("loras", "checkpoints", "embeddings")
|
||||
|
||||
has_valid_paths = False
|
||||
for key in monitored_keys:
|
||||
@@ -829,7 +894,10 @@ class SettingsManager:
|
||||
iterator = list(raw_paths)
|
||||
except TypeError:
|
||||
continue
|
||||
if any(isinstance(path, str) and path and os.path.exists(path) for path in iterator):
|
||||
if any(
|
||||
isinstance(path, str) and path and os.path.exists(path)
|
||||
for path in iterator
|
||||
):
|
||||
has_valid_paths = True
|
||||
break
|
||||
|
||||
@@ -860,13 +928,13 @@ class SettingsManager:
|
||||
def _get_default_settings(self) -> Dict[str, Any]:
|
||||
"""Return default settings"""
|
||||
defaults = copy.deepcopy(DEFAULT_SETTINGS)
|
||||
defaults['base_model_path_mappings'] = {}
|
||||
defaults['download_path_templates'] = {}
|
||||
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
||||
defaults.setdefault('folder_paths', {})
|
||||
defaults.setdefault('extra_folder_paths', {})
|
||||
defaults['auto_organize_exclusions'] = []
|
||||
defaults['metadata_refresh_skip_paths'] = []
|
||||
defaults["base_model_path_mappings"] = {}
|
||||
defaults["download_path_templates"] = {}
|
||||
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
||||
defaults.setdefault("folder_paths", {})
|
||||
defaults.setdefault("extra_folder_paths", {})
|
||||
defaults["auto_organize_exclusions"] = []
|
||||
defaults["metadata_refresh_skip_paths"] = []
|
||||
|
||||
library_name = defaults.get("active_library") or "default"
|
||||
default_library = self._build_library_payload(
|
||||
@@ -876,8 +944,8 @@ class SettingsManager:
|
||||
default_checkpoint_root=defaults.get("default_checkpoint_root"),
|
||||
default_embedding_root=defaults.get("default_embedding_root"),
|
||||
)
|
||||
defaults['libraries'] = {library_name: default_library}
|
||||
defaults['active_library'] = library_name
|
||||
defaults["libraries"] = {library_name: default_library}
|
||||
defaults["active_library"] = library_name
|
||||
return defaults
|
||||
|
||||
def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]:
|
||||
@@ -908,7 +976,9 @@ class SettingsManager:
|
||||
candidates: Iterable[str] = (
|
||||
value.replace("\n", ",").replace(";", ",").split(",")
|
||||
)
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)):
|
||||
elif isinstance(value, Sequence) and not isinstance(
|
||||
value, (bytes, bytearray, str)
|
||||
):
|
||||
candidates = value
|
||||
else:
|
||||
return []
|
||||
@@ -954,7 +1024,9 @@ class SettingsManager:
|
||||
candidates: Iterable[str] = (
|
||||
value.replace("\n", ",").replace(";", ",").split(",")
|
||||
)
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)):
|
||||
elif isinstance(value, Sequence) and not isinstance(
|
||||
value, (bytes, bytearray, str)
|
||||
):
|
||||
candidates = value
|
||||
else:
|
||||
return []
|
||||
@@ -1023,6 +1095,17 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
return base_models
|
||||
|
||||
def get_skip_previously_downloaded_model_versions(self) -> bool:
|
||||
value = self.settings.get("skip_previously_downloaded_model_versions", False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
normalized = False
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
self.settings["skip_previously_downloaded_model_versions"] = normalized
|
||||
self._save_settings()
|
||||
return normalized
|
||||
|
||||
def get_extra_folder_paths(self) -> Dict[str, List[str]]:
|
||||
"""Get extra folder paths for the active library.
|
||||
|
||||
@@ -1060,7 +1143,9 @@ class SettingsManager:
|
||||
continue
|
||||
normalized = os.path.normcase(os.path.normpath(stripped))
|
||||
if os.path.exists(stripped):
|
||||
normalized = os.path.normcase(os.path.normpath(os.path.realpath(stripped)))
|
||||
normalized = os.path.normcase(
|
||||
os.path.normpath(os.path.realpath(stripped))
|
||||
)
|
||||
primary_real_paths.add(normalized)
|
||||
|
||||
primary_symlink_targets = set()
|
||||
@@ -1096,8 +1181,13 @@ class SettingsManager:
|
||||
continue
|
||||
normalized = os.path.normcase(os.path.normpath(stripped))
|
||||
if os.path.exists(stripped):
|
||||
normalized = os.path.normcase(os.path.normpath(os.path.realpath(stripped)))
|
||||
if normalized in primary_real_paths or normalized in primary_symlink_targets:
|
||||
normalized = os.path.normcase(
|
||||
os.path.normpath(os.path.realpath(stripped))
|
||||
)
|
||||
if (
|
||||
normalized in primary_real_paths
|
||||
or normalized in primary_symlink_targets
|
||||
):
|
||||
overlapping_paths.append(stripped)
|
||||
|
||||
if overlapping_paths:
|
||||
@@ -1161,19 +1251,19 @@ class SettingsManager:
|
||||
if key == "use_portable_settings" and isinstance(value, bool):
|
||||
portable_switch_pending = True
|
||||
self._prepare_portable_switch(value)
|
||||
if key == 'folder_paths' and isinstance(value, Mapping):
|
||||
if key == "folder_paths" and isinstance(value, Mapping):
|
||||
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
|
||||
elif key == 'extra_folder_paths' and isinstance(value, Mapping):
|
||||
elif key == "extra_folder_paths" and isinstance(value, Mapping):
|
||||
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
|
||||
elif key == 'default_lora_root':
|
||||
elif key == "default_lora_root":
|
||||
self._update_active_library_entry(default_lora_root=str(value))
|
||||
elif key == 'default_checkpoint_root':
|
||||
elif key == "default_checkpoint_root":
|
||||
self._update_active_library_entry(default_checkpoint_root=str(value))
|
||||
elif key == 'default_unet_root':
|
||||
elif key == "default_unet_root":
|
||||
self._update_active_library_entry(default_unet_root=str(value))
|
||||
elif key == 'default_embedding_root':
|
||||
elif key == "default_embedding_root":
|
||||
self._update_active_library_entry(default_embedding_root=str(value))
|
||||
elif key == 'model_name_display':
|
||||
elif key == "model_name_display":
|
||||
self._notify_model_name_display_change(value)
|
||||
self._save_settings()
|
||||
if portable_switch_pending:
|
||||
@@ -1249,10 +1339,9 @@ class SettingsManager:
|
||||
|
||||
source_cache_dir = os.path.join(source_dir, "model_cache")
|
||||
target_cache_dir = os.path.join(target_dir, "model_cache")
|
||||
if (
|
||||
os.path.isdir(source_cache_dir)
|
||||
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir)
|
||||
):
|
||||
if os.path.isdir(source_cache_dir) and os.path.abspath(
|
||||
source_cache_dir
|
||||
) != os.path.abspath(target_cache_dir):
|
||||
try:
|
||||
shutil.copytree(
|
||||
source_cache_dir,
|
||||
@@ -1270,10 +1359,9 @@ class SettingsManager:
|
||||
|
||||
source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
|
||||
target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
|
||||
if (
|
||||
os.path.isfile(source_cache_file)
|
||||
and os.path.abspath(source_cache_file) != os.path.abspath(target_cache_file)
|
||||
):
|
||||
if os.path.isfile(source_cache_file) and os.path.abspath(
|
||||
source_cache_file
|
||||
) != os.path.abspath(target_cache_file):
|
||||
try:
|
||||
shutil.copy2(source_cache_file, target_cache_file)
|
||||
except Exception as exc:
|
||||
@@ -1299,7 +1387,9 @@ class SettingsManager:
|
||||
try:
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to create user config directory %s: %s", config_dir, exc)
|
||||
logger.warning(
|
||||
"Failed to create user config directory %s: %s", config_dir, exc
|
||||
)
|
||||
|
||||
return config_dir
|
||||
|
||||
@@ -1359,7 +1449,9 @@ class SettingsManager:
|
||||
try:
|
||||
asyncio.run(coroutine)
|
||||
except RuntimeError:
|
||||
logger.debug("Skipping name display update due to missing event loop")
|
||||
logger.debug(
|
||||
"Skipping name display update due to missing event loop"
|
||||
)
|
||||
continue
|
||||
|
||||
if loop is not None and target_loop is loop:
|
||||
@@ -1382,7 +1474,7 @@ class SettingsManager:
|
||||
"""Save settings to file"""
|
||||
try:
|
||||
payload = self._serialize_settings_for_disk()
|
||||
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||
with open(self.settings_file, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
@@ -1423,7 +1515,9 @@ class SettingsManager:
|
||||
minimal[key] = copy.deepcopy(value)
|
||||
# Complex objects need deep comparison
|
||||
elif isinstance(value, (dict, list)) and default_value is not None:
|
||||
if json.dumps(value, sort_keys=True, default=str) != json.dumps(default_value, sort_keys=True, default=str):
|
||||
if json.dumps(value, sort_keys=True, default=str) != json.dumps(
|
||||
default_value, sort_keys=True, default=str
|
||||
):
|
||||
minimal[key] = copy.deepcopy(value)
|
||||
# Simple values use direct comparison
|
||||
elif value != default_value:
|
||||
@@ -1500,9 +1594,15 @@ class SettingsManager:
|
||||
existing = libraries.get(name, {})
|
||||
|
||||
payload = self._build_library_payload(
|
||||
folder_paths=folder_paths if folder_paths is not None else existing.get("folder_paths"),
|
||||
extra_folder_paths=extra_folder_paths if extra_folder_paths is not None else existing.get("extra_folder_paths"),
|
||||
default_lora_root=default_lora_root if default_lora_root is not None else existing.get("default_lora_root"),
|
||||
folder_paths=folder_paths
|
||||
if folder_paths is not None
|
||||
else existing.get("folder_paths"),
|
||||
extra_folder_paths=extra_folder_paths
|
||||
if extra_folder_paths is not None
|
||||
else existing.get("extra_folder_paths"),
|
||||
default_lora_root=default_lora_root
|
||||
if default_lora_root is not None
|
||||
else existing.get("default_lora_root"),
|
||||
default_checkpoint_root=(
|
||||
default_checkpoint_root
|
||||
if default_checkpoint_root is not None
|
||||
@@ -1662,7 +1762,9 @@ class SettingsManager:
|
||||
if service and hasattr(service, "on_library_changed"):
|
||||
try:
|
||||
service.on_library_changed()
|
||||
except Exception as service_exc: # pragma: no cover - defensive logging
|
||||
except (
|
||||
Exception
|
||||
) as service_exc: # pragma: no cover - defensive logging
|
||||
logger.debug(
|
||||
"Service %s failed to handle library change: %s",
|
||||
service_name,
|
||||
@@ -1673,15 +1775,15 @@ class SettingsManager:
|
||||
|
||||
def get_download_path_template(self, model_type: str) -> str:
|
||||
"""Get download path template for specific model type
|
||||
|
||||
|
||||
Args:
|
||||
model_type: The type of model ('lora', 'checkpoint', 'embedding')
|
||||
|
||||
|
||||
Returns:
|
||||
Template string for the model type, defaults to '{base_model}/{first_tag}'
|
||||
"""
|
||||
templates = self.settings.get('download_path_templates', {})
|
||||
|
||||
templates = self.settings.get("download_path_templates", {})
|
||||
|
||||
# Handle edge case where templates might be stored as JSON string
|
||||
if isinstance(templates, str):
|
||||
try:
|
||||
@@ -1689,36 +1791,40 @@ class SettingsManager:
|
||||
parsed_templates = json.loads(templates)
|
||||
if isinstance(parsed_templates, dict):
|
||||
# Update settings with parsed dictionary
|
||||
self.settings['download_path_templates'] = parsed_templates
|
||||
self.settings["download_path_templates"] = parsed_templates
|
||||
self._save_settings()
|
||||
templates = parsed_templates
|
||||
logger.info("Successfully parsed download_path_templates from JSON string")
|
||||
logger.info(
|
||||
"Successfully parsed download_path_templates from JSON string"
|
||||
)
|
||||
else:
|
||||
raise ValueError("Parsed JSON is not a dictionary")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
# If parsing fails, set default values
|
||||
logger.warning(f"Failed to parse download_path_templates JSON string: {e}. Setting default values.")
|
||||
default_template = '{base_model}/{first_tag}'
|
||||
logger.warning(
|
||||
f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
|
||||
)
|
||||
default_template = "{base_model}/{first_tag}"
|
||||
templates = {
|
||||
'lora': default_template,
|
||||
'checkpoint': default_template,
|
||||
'embedding': default_template
|
||||
"lora": default_template,
|
||||
"checkpoint": default_template,
|
||||
"embedding": default_template,
|
||||
}
|
||||
self.settings['download_path_templates'] = templates
|
||||
self.settings["download_path_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
|
||||
# Ensure templates is a dictionary
|
||||
if not isinstance(templates, dict):
|
||||
default_template = '{base_model}/{first_tag}'
|
||||
default_template = "{base_model}/{first_tag}"
|
||||
templates = {
|
||||
'lora': default_template,
|
||||
'checkpoint': default_template,
|
||||
'embedding': default_template
|
||||
"lora": default_template,
|
||||
"checkpoint": default_template,
|
||||
"embedding": default_template,
|
||||
}
|
||||
self.settings['download_path_templates'] = templates
|
||||
self.settings["download_path_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
return templates.get(model_type, '{base_model}/{first_tag}')
|
||||
|
||||
return templates.get(model_type, "{base_model}/{first_tag}")
|
||||
|
||||
|
||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||
|
||||
@@ -22,7 +22,9 @@ def _normalize_commercial_values(value: Any) -> Sequence[str]:
|
||||
|
||||
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 +143,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 +174,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"
|
||||
|
||||
@@ -110,6 +110,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 +153,7 @@ SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
"NoobAI",
|
||||
"Illustrious",
|
||||
"Pony",
|
||||
"Pony V7",
|
||||
"HiDream",
|
||||
"Qwen",
|
||||
"ZImageTurbo",
|
||||
@@ -158,6 +161,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 +172,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",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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.2"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
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();
|
||||
@@ -83,6 +83,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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
@@ -60,6 +62,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);
|
||||
@@ -79,6 +85,52 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Recipe Modal Component
|
||||
import { showToast, copyToClipboard, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||
@@ -9,11 +9,13 @@ import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
|
||||
class RecipeModal {
|
||||
constructor() {
|
||||
this.promptEditorState = {};
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupCopyButtons();
|
||||
this.setupPromptEditors();
|
||||
// Set up tooltip positioning handlers after DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.setupTooltipPositioning();
|
||||
@@ -87,6 +89,7 @@ class RecipeModal {
|
||||
showRecipeDetails(recipe) {
|
||||
// Store the full recipe for editing
|
||||
this.currentRecipe = recipe;
|
||||
this.resetPromptEditors();
|
||||
|
||||
// Set modal title with edit icon
|
||||
const modalTitle = document.getElementById('recipeModalTitle');
|
||||
@@ -300,20 +303,19 @@ class RecipeModal {
|
||||
const promptElement = document.getElementById('recipePrompt');
|
||||
const negativePromptElement = document.getElementById('recipeNegativePrompt');
|
||||
const otherParamsElement = document.getElementById('recipeOtherParams');
|
||||
const promptInput = document.getElementById('recipePromptInput');
|
||||
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
|
||||
|
||||
if (recipe.gen_params) {
|
||||
// Set prompt
|
||||
if (promptElement && recipe.gen_params.prompt) {
|
||||
promptElement.textContent = recipe.gen_params.prompt;
|
||||
} else if (promptElement) {
|
||||
promptElement.textContent = 'No prompt information available';
|
||||
this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
|
||||
this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
|
||||
|
||||
if (promptInput) {
|
||||
promptInput.value = recipe.gen_params.prompt || '';
|
||||
}
|
||||
|
||||
// Set negative prompt
|
||||
if (negativePromptElement && recipe.gen_params.negative_prompt) {
|
||||
negativePromptElement.textContent = recipe.gen_params.negative_prompt;
|
||||
} else if (negativePromptElement) {
|
||||
negativePromptElement.textContent = 'No negative prompt information available';
|
||||
if (negativePromptInput) {
|
||||
negativePromptInput.value = recipe.gen_params.negative_prompt || '';
|
||||
}
|
||||
|
||||
// Set other parameters
|
||||
@@ -343,8 +345,10 @@ class RecipeModal {
|
||||
}
|
||||
} else {
|
||||
// No generation parameters available
|
||||
if (promptElement) promptElement.textContent = 'No prompt information available';
|
||||
if (negativePromptElement) promptElement.textContent = 'No negative prompt information available';
|
||||
this.renderPromptContent(promptElement, '', 'No prompt information available');
|
||||
this.renderPromptContent(negativePromptElement, '', 'No negative prompt information available');
|
||||
if (promptInput) promptInput.value = '';
|
||||
if (negativePromptInput) negativePromptInput.value = '';
|
||||
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||
}
|
||||
|
||||
@@ -711,16 +715,202 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
setupPromptEditors() {
|
||||
const promptConfigs = [
|
||||
{
|
||||
editButtonId: 'editPromptBtn',
|
||||
contentId: 'recipePrompt',
|
||||
editorId: 'recipePromptEditor',
|
||||
inputId: 'recipePromptInput',
|
||||
field: 'prompt',
|
||||
placeholder: 'No prompt information available',
|
||||
successKey: 'toast.recipes.promptUpdated',
|
||||
successFallback: 'Prompt updated successfully',
|
||||
},
|
||||
{
|
||||
editButtonId: 'editNegativePromptBtn',
|
||||
contentId: 'recipeNegativePrompt',
|
||||
editorId: 'recipeNegativePromptEditor',
|
||||
inputId: 'recipeNegativePromptInput',
|
||||
field: 'negative_prompt',
|
||||
placeholder: 'No negative prompt information available',
|
||||
successKey: 'toast.recipes.negativePromptUpdated',
|
||||
successFallback: 'Negative prompt updated successfully',
|
||||
}
|
||||
];
|
||||
|
||||
promptConfigs.forEach((config) => {
|
||||
const editButton = document.getElementById(config.editButtonId);
|
||||
const input = document.getElementById(config.inputId);
|
||||
|
||||
if (editButton) {
|
||||
editButton.addEventListener('click', () => this.showPromptEditor(config));
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cancelPromptEdit(config);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.promptEditorState[config.field] = {
|
||||
...(this.promptEditorState[config.field] || {}),
|
||||
skipBlurSave: true,
|
||||
};
|
||||
this.savePromptEdit(config);
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
const promptState = this.promptEditorState[config.field] || {};
|
||||
if (promptState.skipBlurSave) {
|
||||
this.promptEditorState[config.field] = {
|
||||
...promptState,
|
||||
skipBlurSave: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this.savePromptEdit(config);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderPromptContent(element, value, placeholder) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = value || '';
|
||||
if (text) {
|
||||
element.textContent = text;
|
||||
element.classList.remove('is-placeholder');
|
||||
} else {
|
||||
element.textContent = placeholder;
|
||||
element.classList.add('is-placeholder');
|
||||
}
|
||||
}
|
||||
|
||||
resetPromptEditors() {
|
||||
this.hidePromptEditor({ contentId: 'recipePrompt', editorId: 'recipePromptEditor' });
|
||||
this.hidePromptEditor({ contentId: 'recipeNegativePrompt', editorId: 'recipeNegativePromptEditor' });
|
||||
}
|
||||
|
||||
showPromptEditor(config) {
|
||||
const content = document.getElementById(config.contentId);
|
||||
const editor = document.getElementById(config.editorId);
|
||||
const input = document.getElementById(config.inputId);
|
||||
|
||||
if (!content || !editor || !input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = this.currentRecipe?.gen_params?.[config.field] || '';
|
||||
input.value = currentValue;
|
||||
this.promptEditorState[config.field] = {
|
||||
initialValue: currentValue,
|
||||
skipBlurSave: false,
|
||||
isSaving: false,
|
||||
};
|
||||
content.classList.add('hide');
|
||||
editor.classList.add('active');
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
|
||||
async savePromptEdit(config) {
|
||||
const content = document.getElementById(config.contentId);
|
||||
const editor = document.getElementById(config.editorId);
|
||||
const input = document.getElementById(config.inputId);
|
||||
|
||||
if (!content || !editor || !input || !this.currentRecipe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promptState = this.promptEditorState[config.field] || {};
|
||||
if (promptState.isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentGenParams = this.currentRecipe.gen_params || {};
|
||||
const nextValue = input.value.trim() === '' ? '' : input.value;
|
||||
const currentValue = currentGenParams[config.field] || '';
|
||||
|
||||
if (nextValue === currentValue) {
|
||||
this.hidePromptEditor(config);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextGenParams = {
|
||||
...currentGenParams,
|
||||
[config.field]: nextValue,
|
||||
};
|
||||
|
||||
try {
|
||||
this.promptEditorState[config.field] = {
|
||||
...promptState,
|
||||
isSaving: true,
|
||||
};
|
||||
await updateRecipeMetadata(this.filePath, { gen_params: nextGenParams });
|
||||
this.currentRecipe.gen_params = nextGenParams;
|
||||
this.renderPromptContent(content, nextValue, config.placeholder);
|
||||
showToast(config.successKey, {}, 'success', config.successFallback);
|
||||
} catch (error) {
|
||||
this.renderPromptContent(content, currentValue, config.placeholder);
|
||||
input.value = currentValue;
|
||||
} finally {
|
||||
this.hidePromptEditor(config);
|
||||
}
|
||||
}
|
||||
|
||||
cancelPromptEdit(config) {
|
||||
const input = document.getElementById(config.inputId);
|
||||
if (input) {
|
||||
const initialValue = this.promptEditorState[config.field]?.initialValue;
|
||||
input.value = initialValue ?? (this.currentRecipe?.gen_params?.[config.field] || '');
|
||||
}
|
||||
|
||||
this.hidePromptEditor(config);
|
||||
}
|
||||
|
||||
hidePromptEditor(config) {
|
||||
const content = document.getElementById(config.contentId);
|
||||
const editor = document.getElementById(config.editorId);
|
||||
|
||||
if (content) {
|
||||
content.classList.remove('hide');
|
||||
}
|
||||
|
||||
if (editor) {
|
||||
editor.classList.remove('active');
|
||||
}
|
||||
|
||||
delete this.promptEditorState[config.field];
|
||||
}
|
||||
|
||||
// Setup source URL handlers
|
||||
setupSourceUrlHandlers() {
|
||||
const sourceUrlContainer = document.querySelector('.source-url-container');
|
||||
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
||||
if (!sourceUrlContainer || !sourceUrlEditor) {
|
||||
return;
|
||||
}
|
||||
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
|
||||
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
|
||||
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
|
||||
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
|
||||
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
|
||||
|
||||
if (!sourceUrlText || !sourceUrlEditBtn || !sourceUrlCancelBtn || !sourceUrlSaveBtn || !sourceUrlInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show editor on edit button click
|
||||
sourceUrlEditBtn.addEventListener('click', () => {
|
||||
sourceUrlContainer.classList.add('hide');
|
||||
@@ -778,17 +968,18 @@ class RecipeModal {
|
||||
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||||
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
||||
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
||||
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
|
||||
|
||||
if (copyPromptBtn) {
|
||||
copyPromptBtn.addEventListener('click', () => {
|
||||
const promptText = document.getElementById('recipePrompt').textContent;
|
||||
const promptText = this.currentRecipe?.gen_params?.prompt || '';
|
||||
this.copyToClipboard(promptText, 'Prompt copied to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
if (copyNegativePromptBtn) {
|
||||
copyNegativePromptBtn.addEventListener('click', () => {
|
||||
const negativePromptText = document.getElementById('recipeNegativePrompt').textContent;
|
||||
const negativePromptText = this.currentRecipe?.gen_params?.negative_prompt || '';
|
||||
this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
|
||||
});
|
||||
}
|
||||
@@ -799,6 +990,13 @@ class RecipeModal {
|
||||
this.fetchAndCopyRecipeSyntax();
|
||||
});
|
||||
}
|
||||
|
||||
if (sendRecipeBtn) {
|
||||
sendRecipeBtn.addEventListener('click', () => {
|
||||
// Send recipe to ComfyUI workflow
|
||||
this.sendRecipeToWorkflow();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipe syntax from backend and copy to clipboard
|
||||
@@ -835,6 +1033,35 @@ class RecipeModal {
|
||||
copyToClipboard(text, successMessage);
|
||||
}
|
||||
|
||||
// Send recipe to ComfyUI workflow
|
||||
async sendRecipeToWorkflow() {
|
||||
if (!this.recipeId) {
|
||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch recipe syntax from backend
|
||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.syntax) {
|
||||
// Send the recipe syntax to ComfyUI workflow
|
||||
await sendLoraToWorkflow(data.syntax, false, 'recipe');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned from server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending recipe to workflow:', error);
|
||||
showToast('toast.recipes.sendToWorkflowFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Add new method to handle downloading missing LoRAs
|
||||
async showDownloadMissingLorasModal() {
|
||||
console.log("currentRecipe", this.currentRecipe);
|
||||
@@ -1188,14 +1415,14 @@ class RecipeModal {
|
||||
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',
|
||||
@@ -1259,7 +1486,7 @@ class RecipeModal {
|
||||
const versionId = checkpoint.id || checkpoint.modelVersionId;
|
||||
const modelName = checkpoint.name || checkpoint.modelName || checkpoint.file_name;
|
||||
|
||||
if (modelId || modelName) {
|
||||
if (modelId || versionId || modelName) {
|
||||
openCivitaiByMetadata(modelId, versionId, modelName);
|
||||
return;
|
||||
}
|
||||
@@ -1317,7 +1544,7 @@ class RecipeModal {
|
||||
const versionId = lora.id || lora.modelVersionId;
|
||||
const modelName = lora.modelName || lora.name || lora.file_name;
|
||||
|
||||
if (modelId || modelName) {
|
||||
if (modelId || versionId || modelName) {
|
||||
openCivitaiByMetadata(modelId, versionId, modelName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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';
|
||||
}
|
||||
|
||||
@@ -17,6 +17,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 +44,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();
|
||||
@@ -116,6 +122,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
|
||||
|
||||
@@ -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) {
|
||||
@@ -827,6 +840,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) {
|
||||
@@ -1237,10 +1256,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 +1266,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 +1291,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 +1301,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 +1326,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 +1336,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 +1361,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 +1371,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);
|
||||
@@ -1468,7 +1471,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 +1520,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 +1622,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
|
||||
);
|
||||
|
||||
|
||||
@@ -6,8 +6,31 @@ export class RecipeDataManager {
|
||||
this.importManager = importManager;
|
||||
}
|
||||
|
||||
setupTagInputEnterHandler() {
|
||||
const tagInput = document.getElementById('tagInput');
|
||||
if (!tagInput || tagInput.hasEnterAddTagHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
tagInput.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.addTag();
|
||||
});
|
||||
|
||||
tagInput.hasEnterAddTagHandler = true;
|
||||
}
|
||||
|
||||
showRecipeDetailsStep() {
|
||||
this.importManager.stepManager.showStep('detailsStep');
|
||||
this.setupTagInputEnterHandler();
|
||||
|
||||
// Set default recipe name from prompt or image filename
|
||||
const recipeName = document.getElementById('recipeName');
|
||||
|
||||
@@ -66,6 +66,8 @@ class RecipeManager {
|
||||
active: false,
|
||||
loraName: null,
|
||||
loraHash: null,
|
||||
checkpointName: null,
|
||||
checkpointHash: null,
|
||||
recipeId: null
|
||||
};
|
||||
}
|
||||
@@ -127,16 +129,20 @@ class RecipeManager {
|
||||
// Check for Lora filter
|
||||
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
|
||||
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
|
||||
const filterCheckpointName = getSessionItem('checkpoint_to_recipe_filterCheckpointName');
|
||||
const filterCheckpointHash = getSessionItem('checkpoint_to_recipe_filterCheckpointHash');
|
||||
|
||||
// Check for specific recipe ID
|
||||
const viewRecipeId = getSessionItem('viewRecipeId');
|
||||
|
||||
// Set custom filter if any parameter is present
|
||||
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
||||
if (filterLoraName || filterLoraHash || filterCheckpointName || filterCheckpointHash || viewRecipeId) {
|
||||
this.pageState.customFilter = {
|
||||
active: true,
|
||||
loraName: filterLoraName,
|
||||
loraHash: filterLoraHash,
|
||||
checkpointName: filterCheckpointName,
|
||||
checkpointHash: filterCheckpointHash,
|
||||
recipeId: viewRecipeId
|
||||
};
|
||||
|
||||
@@ -164,6 +170,13 @@ class RecipeManager {
|
||||
loraName;
|
||||
|
||||
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
|
||||
} else if (this.pageState.customFilter.checkpointName) {
|
||||
const checkpointName = this.pageState.customFilter.checkpointName;
|
||||
const displayName = checkpointName.length > 25 ?
|
||||
checkpointName.substring(0, 22) + '...' :
|
||||
checkpointName;
|
||||
|
||||
filterText = `<span>Recipes using checkpoint: <span class="lora-name">${displayName}</span></span>`;
|
||||
} else {
|
||||
filterText = 'Filtered recipes';
|
||||
}
|
||||
@@ -173,6 +186,10 @@ class RecipeManager {
|
||||
// Add title attribute to show the lora name as a tooltip
|
||||
if (this.pageState.customFilter.loraName) {
|
||||
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
||||
} else if (this.pageState.customFilter.checkpointName) {
|
||||
textElement.setAttribute('title', this.pageState.customFilter.checkpointName);
|
||||
} else {
|
||||
textElement.removeAttribute('title');
|
||||
}
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
@@ -199,6 +216,8 @@ class RecipeManager {
|
||||
active: false,
|
||||
loraName: null,
|
||||
loraHash: null,
|
||||
checkpointName: null,
|
||||
checkpointHash: null,
|
||||
recipeId: null
|
||||
};
|
||||
|
||||
@@ -211,6 +230,8 @@ class RecipeManager {
|
||||
// Clear any session storage items
|
||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||
removeSessionItem('checkpoint_to_recipe_filterCheckpointName');
|
||||
removeSessionItem('checkpoint_to_recipe_filterCheckpointHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Reset and refresh the virtual scroller
|
||||
|
||||
@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
hide_early_access_updates: false,
|
||||
auto_organize_exclusions: [],
|
||||
metadata_refresh_skip_paths: [],
|
||||
skip_previously_downloaded_model_versions: false,
|
||||
download_skip_base_models: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@ export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = Optimizati
|
||||
try {
|
||||
const url = new URL(sourceUrl);
|
||||
|
||||
// Check if it's a CivitAI image domain
|
||||
if (url.hostname.toLowerCase() !== 'image.civitai.com') {
|
||||
// Check if it's a CivitAI CDN domain (supports all subdomains like image-b2.civitai.com)
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (hostname === 'civitai.com' || !hostname.endsWith('.civitai.com')) {
|
||||
return [sourceUrl, false];
|
||||
}
|
||||
|
||||
@@ -112,7 +113,8 @@ export function isCivitaiUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname.toLowerCase() === 'image.civitai.com';
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
return hostname.endsWith('.civitai.com') && hostname !== 'civitai.com';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ export const BASE_MODELS = {
|
||||
SVD: "SVD",
|
||||
LTXV: "LTXV",
|
||||
LTXV2: "LTXV2",
|
||||
LTXV_2_3: "LTXV 2.3",
|
||||
COGVIDE_X: "CogVideoX",
|
||||
MOCHI: "Mochi",
|
||||
WAN_VIDEO: "Wan Video",
|
||||
WAN_VIDEO_1_3B_T2V: "Wan Video 1.3B t2v",
|
||||
WAN_VIDEO_14B_T2V: "Wan Video 14B t2v",
|
||||
@@ -58,7 +61,12 @@ export const BASE_MODELS = {
|
||||
WAN_VIDEO_2_2_TI2V_5B: "Wan Video 2.2 TI2V-5B",
|
||||
WAN_VIDEO_2_2_T2V_A14B: "Wan Video 2.2 T2V-A14B",
|
||||
WAN_VIDEO_2_2_I2V_A14B: "Wan Video 2.2 I2V-A14B",
|
||||
WAN_VIDEO_2_5_T2V: "Wan Video 2.5 T2V",
|
||||
WAN_VIDEO_2_5_I2V: "Wan Video 2.5 I2V",
|
||||
HUNYUAN_VIDEO: "Hunyuan Video",
|
||||
// Other models
|
||||
ANIMA: "Anima",
|
||||
PONY_V7: "Pony V7",
|
||||
// Default
|
||||
UNKNOWN: "Other"
|
||||
};
|
||||
@@ -151,6 +159,9 @@ export const BASE_MODEL_ABBREVIATIONS = {
|
||||
[BASE_MODELS.SVD]: 'SVD',
|
||||
[BASE_MODELS.LTXV]: 'LTXV',
|
||||
[BASE_MODELS.LTXV2]: 'LTV2',
|
||||
[BASE_MODELS.LTXV_2_3]: 'LTX',
|
||||
[BASE_MODELS.COGVIDE_X]: 'CVX',
|
||||
[BASE_MODELS.MOCHI]: 'MCHI',
|
||||
[BASE_MODELS.WAN_VIDEO]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_1_3B_T2V]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_14B_T2V]: 'WAN',
|
||||
@@ -159,8 +170,28 @@ export const BASE_MODEL_ABBREVIATIONS = {
|
||||
[BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_2_5_T2V]: 'WAN',
|
||||
[BASE_MODELS.WAN_VIDEO_2_5_I2V]: 'WAN',
|
||||
[BASE_MODELS.HUNYUAN_VIDEO]: 'HYV',
|
||||
|
||||
// Other diffusion models
|
||||
[BASE_MODELS.AURAFLOW]: 'AF',
|
||||
[BASE_MODELS.CHROMA]: 'CHR',
|
||||
[BASE_MODELS.PIXART_A]: 'PXA',
|
||||
[BASE_MODELS.PIXART_E]: 'PXE',
|
||||
[BASE_MODELS.HUNYUAN_1]: 'HY',
|
||||
[BASE_MODELS.LUMINA]: 'L',
|
||||
[BASE_MODELS.KOLORS]: 'KLR',
|
||||
[BASE_MODELS.NOOBAI]: 'NAI',
|
||||
[BASE_MODELS.ILLUSTRIOUS]: 'IL',
|
||||
[BASE_MODELS.PONY]: 'PONY',
|
||||
[BASE_MODELS.PONY_V7]: 'PNY7',
|
||||
[BASE_MODELS.HIDREAM]: 'HID',
|
||||
[BASE_MODELS.QWEN]: 'QWEN',
|
||||
[BASE_MODELS.ZIMAGE_TURBO]: 'ZIT',
|
||||
[BASE_MODELS.ZIMAGE_BASE]: 'ZIB',
|
||||
[BASE_MODELS.ANIMA]: 'ANI',
|
||||
|
||||
// Default
|
||||
[BASE_MODELS.UNKNOWN]: 'OTH'
|
||||
};
|
||||
@@ -349,18 +380,20 @@ export const BASE_MODEL_CATEGORIES = {
|
||||
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
|
||||
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
|
||||
'Video Models': [
|
||||
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.LTXV2, BASE_MODELS.HUNYUAN_VIDEO, BASE_MODELS.WAN_VIDEO,
|
||||
BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
|
||||
BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.LTXV2, BASE_MODELS.LTXV_2_3,
|
||||
BASE_MODELS.COGVIDE_X, BASE_MODELS.MOCHI, BASE_MODELS.HUNYUAN_VIDEO,
|
||||
BASE_MODELS.WAN_VIDEO, BASE_MODELS.WAN_VIDEO_1_3B_T2V, BASE_MODELS.WAN_VIDEO_14B_T2V,
|
||||
BASE_MODELS.WAN_VIDEO_14B_I2V_480P, BASE_MODELS.WAN_VIDEO_14B_I2V_720P,
|
||||
BASE_MODELS.WAN_VIDEO_2_2_TI2V_5B, BASE_MODELS.WAN_VIDEO_2_2_T2V_A14B,
|
||||
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B
|
||||
BASE_MODELS.WAN_VIDEO_2_2_I2V_A14B, BASE_MODELS.WAN_VIDEO_2_5_T2V,
|
||||
BASE_MODELS.WAN_VIDEO_2_5_I2V
|
||||
],
|
||||
'Flux Models': [BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.FLUX_1_KREA, BASE_MODELS.FLUX_2_D, BASE_MODELS.FLUX_2_KLEIN_9B, BASE_MODELS.FLUX_2_KLEIN_9B_BASE, BASE_MODELS.FLUX_2_KLEIN_4B, BASE_MODELS.FLUX_2_KLEIN_4B_BASE],
|
||||
'Other Models': [
|
||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
|
||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.PONY_V7, BASE_MODELS.HIDREAM,
|
||||
BASE_MODELS.QWEN, BASE_MODELS.AURAFLOW, BASE_MODELS.CHROMA, BASE_MODELS.ZIMAGE_TURBO, BASE_MODELS.ZIMAGE_BASE,
|
||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.ANIMA,
|
||||
BASE_MODELS.UNKNOWN
|
||||
]
|
||||
};
|
||||
@@ -378,3 +411,94 @@ export const DEFAULT_PRIORITY_TAG_CONFIG = {
|
||||
checkpoint: DEFAULT_PRIORITY_TAG_ENTRIES.join(', '),
|
||||
embedding: DEFAULT_PRIORITY_TAG_ENTRIES.join(', ')
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Dynamic Base Model Support
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Dynamic base model cache
|
||||
* Stores models fetched from Civitai API
|
||||
*/
|
||||
let dynamicBaseModels = null;
|
||||
let dynamicBaseModelsTimestamp = null;
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Set dynamic base models (called after fetching from API)
|
||||
* @param {Array} models - Array of base model names
|
||||
* @param {string} timestamp - ISO timestamp of fetch
|
||||
*/
|
||||
export function setDynamicBaseModels(models, timestamp) {
|
||||
dynamicBaseModels = models;
|
||||
dynamicBaseModelsTimestamp = timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dynamic base models
|
||||
* @returns {Object|null} { models, timestamp } or null if not set
|
||||
*/
|
||||
export function getDynamicBaseModels() {
|
||||
if (!dynamicBaseModels) return null;
|
||||
|
||||
// Check if cache is expired
|
||||
if (dynamicBaseModelsTimestamp) {
|
||||
const age = Date.now() - new Date(dynamicBaseModelsTimestamp).getTime();
|
||||
if (age > CACHE_TTL_MS) {
|
||||
dynamicBaseModels = null;
|
||||
dynamicBaseModelsTimestamp = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models: dynamicBaseModels,
|
||||
timestamp: dynamicBaseModelsTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get merged base models (hardcoded + dynamic)
|
||||
* Returns unique sorted list of all available base models
|
||||
* @returns {Array} Sorted array of base model names
|
||||
*/
|
||||
export function getMergedBaseModels() {
|
||||
const hardcoded = Object.values(BASE_MODELS);
|
||||
const dynamic = getDynamicBaseModels();
|
||||
|
||||
if (!dynamic || !dynamic.models) {
|
||||
return hardcoded.sort();
|
||||
}
|
||||
|
||||
// Merge and deduplicate
|
||||
const merged = new Set([...hardcoded, ...dynamic.models]);
|
||||
return Array.from(merged).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mappable base models (for UI selection)
|
||||
* Excludes 'Other' value
|
||||
* @returns {Array} Sorted array of base model names (excluding 'Other')
|
||||
*/
|
||||
export function getMappableBaseModelsDynamic() {
|
||||
const merged = getMergedBaseModels();
|
||||
return merged.filter(model => model !== 'Other');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear dynamic base models cache
|
||||
*/
|
||||
export function clearDynamicBaseModels() {
|
||||
dynamicBaseModels = null;
|
||||
dynamicBaseModelsTimestamp = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dynamic base models cache is valid
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDynamicBaseModelsCacheValid() {
|
||||
if (!dynamicBaseModels || !dynamicBaseModelsTimestamp) return false;
|
||||
const age = Date.now() - new Date(dynamicBaseModelsTimestamp).getTime();
|
||||
return age <= CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
@@ -184,14 +184,13 @@ function filterByFolder(folderPath) {
|
||||
}
|
||||
|
||||
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
|
||||
if (civitaiId) {
|
||||
let url = `https://civitai.com/models/${civitaiId}`;
|
||||
if (versionId) {
|
||||
url += `?modelVersionId=${versionId}`;
|
||||
}
|
||||
window.open(url, '_blank');
|
||||
if (versionId) {
|
||||
// Use model-versions endpoint which auto-redirects to correct model page
|
||||
window.open(`https://civitai.com/model-versions/${versionId}`, '_blank');
|
||||
} else if (civitaiId) {
|
||||
window.open(`https://civitai.com/models/${civitaiId}`, '_blank');
|
||||
} else if (modelName) {
|
||||
// 如果没有ID,尝试使用名称搜索
|
||||
// Fallback: search by name
|
||||
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
|
||||
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> {{ t('loras.contextMenu.relinkCivitai') }}</div>
|
||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
||||
<div class="context-menu-item" data-action="sendworkflow"><i class="fas fa-paper-plane"></i> {{ t('checkpoints.contextMenu.sendToWorkflow') }}</div>
|
||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
|
||||
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
|
||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
|
||||
|
||||
@@ -484,9 +484,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')">
|
||||
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
|
||||
</select>
|
||||
<select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,9 +498,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')">
|
||||
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
|
||||
</select>
|
||||
<select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -516,9 +512,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="defaultUnetRoot" onchange="settingsManager.saveSelectSetting('defaultUnetRoot', 'default_unet_root')">
|
||||
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
|
||||
</select>
|
||||
<select id="defaultUnetRoot" onchange="settingsManager.saveSelectSetting('defaultUnetRoot', 'default_unet_root')"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -532,9 +526,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')">
|
||||
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
|
||||
</select>
|
||||
<select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,7 +537,7 @@
|
||||
<div class="settings-subsection-header">
|
||||
<h4>
|
||||
{{ t('settings.extraFolderPaths.title') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.extraFolderPaths.help') }}"></i>
|
||||
<i class="fas fa-sync-alt restart-required-icon" title="{{ t('settings.extraFolderPaths.restartRequired') }}"></i>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
@@ -743,6 +735,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="skipPreviouslyDownloadedModelVersions">
|
||||
{{ t('settings.skipPreviouslyDownloadedModelVersions.label') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.skipPreviouslyDownloadedModelVersions.help') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="skipPreviouslyDownloadedModelVersions"
|
||||
onchange="settingsManager.saveToggleSetting('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
|
||||
@@ -29,22 +29,52 @@
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Prompt</label>
|
||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-content" id="recipePrompt"></div>
|
||||
<div class="param-editor" id="recipePromptEditor">
|
||||
<textarea
|
||||
class="param-textarea"
|
||||
id="recipePromptInput"
|
||||
placeholder="Enter prompt"
|
||||
></textarea>
|
||||
<div class="param-editor-hint">
|
||||
{{ t('toast.recipes.promptEditorHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Negative Prompt -->
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Negative Prompt</label>
|
||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||
<textarea
|
||||
class="param-textarea"
|
||||
id="recipeNegativePromptInput"
|
||||
placeholder="Enter negative prompt"
|
||||
></textarea>
|
||||
<div class="param-editor-hint">
|
||||
{{ t('toast.recipes.promptEditorHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other Parameters -->
|
||||
@@ -65,6 +95,9 @@
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="action-btn send-recipe-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-resources-list">
|
||||
|
||||
@@ -131,6 +131,102 @@ def test_save_paths_logs_warning_when_upsert_fails(
|
||||
assert "Failed to save folder paths: boom" in caplog.text
|
||||
|
||||
|
||||
def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||
|
||||
class FakeSettingsService:
|
||||
def get_libraries(self):
|
||||
return {
|
||||
"comfyui": {
|
||||
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
|
||||
"default_lora_root": "",
|
||||
"default_checkpoint_root": "",
|
||||
"default_embedding_root": "",
|
||||
}
|
||||
}
|
||||
|
||||
def rename_library(self, *_):
|
||||
raise AssertionError("rename_library should not be invoked")
|
||||
|
||||
def upsert_library(self, name: str, **payload):
|
||||
self.name = name
|
||||
self.payload = payload
|
||||
|
||||
fake_settings = FakeSettingsService()
|
||||
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||
|
||||
config_module.Config()
|
||||
|
||||
assert fake_settings.name == "comfyui"
|
||||
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
|
||||
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
|
||||
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
|
||||
|
||||
|
||||
def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||
|
||||
class FakeSettingsService:
|
||||
def get_libraries(self):
|
||||
return {
|
||||
"comfyui": {
|
||||
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
|
||||
"default_lora_root": "/stale/loras",
|
||||
"default_checkpoint_root": "/stale/checkpoints",
|
||||
"default_embedding_root": "/stale/embeddings",
|
||||
}
|
||||
}
|
||||
|
||||
def rename_library(self, *_):
|
||||
raise AssertionError("rename_library should not be invoked")
|
||||
|
||||
def upsert_library(self, name: str, **payload):
|
||||
self.name = name
|
||||
self.payload = payload
|
||||
|
||||
fake_settings = FakeSettingsService()
|
||||
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||
|
||||
config_module.Config()
|
||||
|
||||
assert fake_settings.name == "comfyui"
|
||||
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
|
||||
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
|
||||
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
|
||||
|
||||
|
||||
def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||
|
||||
class FakeSettingsService:
|
||||
def get_libraries(self):
|
||||
return {
|
||||
"comfyui": {
|
||||
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
|
||||
"default_lora_root": folder_paths["loras"][0],
|
||||
"default_checkpoint_root": folder_paths["checkpoints"][0],
|
||||
"default_embedding_root": folder_paths["embeddings"][0],
|
||||
}
|
||||
}
|
||||
|
||||
def rename_library(self, *_):
|
||||
raise AssertionError("rename_library should not be invoked")
|
||||
|
||||
def upsert_library(self, name: str, **payload):
|
||||
self.name = name
|
||||
self.payload = payload
|
||||
|
||||
fake_settings = FakeSettingsService()
|
||||
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
|
||||
|
||||
config_module.Config()
|
||||
|
||||
assert fake_settings.name == "comfyui"
|
||||
assert fake_settings.payload["default_lora_root"] == folder_paths["loras"][0].replace("\\", "/")
|
||||
assert fake_settings.payload["default_checkpoint_root"] == folder_paths["checkpoints"][0].replace("\\", "/")
|
||||
assert fake_settings.payload["default_embedding_root"] == folder_paths["embeddings"][0].replace("\\", "/")
|
||||
|
||||
|
||||
def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
|
||||
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ const {
|
||||
}));
|
||||
|
||||
const fetchApiMock = vi.fn();
|
||||
const settingGetMock = vi.fn();
|
||||
const settingSetMock = vi.fn();
|
||||
const caretHelperInstance = {
|
||||
getBeforeCursor: vi.fn(() => ''),
|
||||
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
|
||||
@@ -37,6 +39,12 @@ vi.mock(APP_MODULE, () => ({
|
||||
canvas: {
|
||||
ds: { scale: 1 },
|
||||
},
|
||||
extensionManager: {
|
||||
setting: {
|
||||
get: settingGetMock,
|
||||
set: settingSetMock,
|
||||
},
|
||||
},
|
||||
registerExtension: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -55,6 +63,23 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
fetchApiMock.mockReset();
|
||||
settingGetMock.mockReset();
|
||||
settingSetMock.mockReset();
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.autocomplete_accept_key') {
|
||||
return 'both';
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
caretHelperInstance.getBeforeCursor.mockReset();
|
||||
caretHelperInstance.getCursorOffset.mockReset();
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('');
|
||||
@@ -82,7 +107,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false });
|
||||
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
|
||||
|
||||
input.value = 'example';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
@@ -125,25 +150,251 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false });
|
||||
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
|
||||
|
||||
await autoComplete.insertSelection('models/example.safetensors');
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||
'/lm/loras/usage-tips-by-path?relative_path=models%2Fexample.safetensors',
|
||||
);
|
||||
expect(input.value).toContain('<lora:example:1.5:0.9>, ');
|
||||
expect(input.value).toContain('<lora:example:1.5:0.9>,');
|
||||
expect(autoComplete.dropdown.style.display).toBe('none');
|
||||
expect(input.focus).toHaveBeenCalled();
|
||||
expect(input.setSelectionRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the selected suggestion with Tab', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['example_completion'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(tabEvent);
|
||||
|
||||
expect(tabEvent.defaultPrevented).toBe(true);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
||||
});
|
||||
|
||||
it('accepts the selected suggestion with Enter', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['example_completion'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(enterEvent);
|
||||
|
||||
expect(enterEvent.defaultPrevented).toBe(true);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
||||
});
|
||||
|
||||
it('prefers the latest best match when Tab is pressed before debounced suggestions fully refresh', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'loop';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.items = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
|
||||
{ tag_name: 'loop', category: 0, post_count: 500 },
|
||||
];
|
||||
autoComplete.currentSearchTerm = 'loo';
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(tabEvent);
|
||||
|
||||
expect(tabEvent.defaultPrevented).toBe(true);
|
||||
expect(autoComplete.selectedIndex).toBe(1);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||
});
|
||||
|
||||
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'loop';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['loop'];
|
||||
autoComplete.selectedIndex = -1;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(tabEvent);
|
||||
|
||||
expect(tabEvent.defaultPrevented).toBe(true);
|
||||
expect(autoComplete.selectedIndex).toBe(0);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
|
||||
});
|
||||
|
||||
it('only accepts with Tab when autocomplete accept key is set to tab_only', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.autocomplete_accept_key') {
|
||||
return 'tab_only';
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['example_completion'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(enterEvent);
|
||||
|
||||
expect(enterEvent.defaultPrevented).toBe(false);
|
||||
expect(insertSelectionSpy).not.toHaveBeenCalled();
|
||||
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(tabEvent);
|
||||
|
||||
expect(tabEvent.defaultPrevented).toBe(true);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
||||
});
|
||||
|
||||
it('only accepts with Enter when autocomplete accept key is set to enter_only', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.autocomplete_accept_key') {
|
||||
return 'enter_only';
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['example_completion'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = true;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(tabEvent);
|
||||
|
||||
expect(tabEvent.defaultPrevented).toBe(false);
|
||||
expect(insertSelectionSpy).not.toHaveBeenCalled();
|
||||
|
||||
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(enterEvent);
|
||||
|
||||
expect(enterEvent.defaultPrevented).toBe(true);
|
||||
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
|
||||
});
|
||||
|
||||
it('does not intercept Tab when the dropdown is not visible', async () => {
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
|
||||
|
||||
autoComplete.items = ['example_completion'];
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.isVisible = false;
|
||||
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
|
||||
|
||||
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
|
||||
input.dispatchEvent(tabEvent);
|
||||
|
||||
expect(tabEvent.defaultPrevented).toBe(false);
|
||||
expect(insertSelectionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('highlights multiple include tokens while ignoring excluded ones', async () => {
|
||||
const input = document.createElement('textarea');
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false });
|
||||
const autoComplete = new AutoComplete(input,'loras', { showPreview: false });
|
||||
|
||||
const highlighted = autoComplete.highlightMatch(
|
||||
'models/flux/beta-detail.safetensors',
|
||||
@@ -160,7 +411,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
it('handles arrow key navigation with virtual scrolling', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2, '0')}.safetensors`);
|
||||
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2,'0')}.safetensors`);
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
||||
@@ -173,7 +424,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', {
|
||||
const autoComplete = new AutoComplete(input,'loras', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
enableVirtualScroll: true,
|
||||
@@ -216,7 +467,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
it('maintains selection when scrolling to invisible items', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3, '0')}.safetensors`);
|
||||
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3,'0')}.safetensors`);
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
||||
@@ -231,7 +482,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', {
|
||||
const autoComplete = new AutoComplete(input,'loras', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
enableVirtualScroll: true,
|
||||
@@ -289,7 +540,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -302,7 +553,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
expect(autoComplete.dropdown.style.display).toBe('none');
|
||||
expect(input.focus).toHaveBeenCalled();
|
||||
});
|
||||
@@ -328,7 +579,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -342,7 +593,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
await autoComplete.insertSelection('1girl');
|
||||
|
||||
expect(input.value).toBe('hello 1girl, ');
|
||||
expect(input.value).toBe('hello 1girl,');
|
||||
});
|
||||
|
||||
it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => {
|
||||
@@ -366,7 +617,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -380,7 +631,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
expect(input.value).toBe('blue_hair,');
|
||||
});
|
||||
|
||||
it('handles multi-word phrase with preceding text correctly', async () => {
|
||||
@@ -403,7 +654,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -417,7 +668,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
expect(input.value).toBe('1girl, looking_to_the_side, ');
|
||||
expect(input.value).toBe('1girl, looking_to_the_side,');
|
||||
});
|
||||
|
||||
it('replaces entire command and search term when using command mode with multi-word phrase', async () => {
|
||||
@@ -442,7 +693,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -458,7 +709,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Command part should be replaced along with search term
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
});
|
||||
|
||||
it('replaces only last token when multi-word query does not exactly match selected tag', async () => {
|
||||
@@ -483,7 +734,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -498,7 +749,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// Only "blue" should be replaced, not the entire phrase
|
||||
expect(input.value).toBe('looking to the blue_hair, ');
|
||||
expect(input.value).toBe('looking to the blue_hair,');
|
||||
});
|
||||
|
||||
it('handles multiple consecutive spaces in multi-word phrase correctly', async () => {
|
||||
@@ -522,7 +773,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -537,7 +788,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Multiple spaces should be normalized to single underscores for matching
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
});
|
||||
|
||||
it('handles command mode with partial match replacing only last token', async () => {
|
||||
@@ -561,7 +812,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -577,7 +828,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// In command mode, the entire command + search term should be replaced
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
expect(input.value).toBe('blue_hair,');
|
||||
});
|
||||
|
||||
it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => {
|
||||
@@ -601,7 +852,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -616,7 +867,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Entire phrase should be replaced with selected tag (with underscores)
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
});
|
||||
|
||||
it('inserts tag with underscores regardless of space replacement setting', async () => {
|
||||
@@ -639,7 +890,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -653,7 +904,287 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// Tag should be inserted with underscores, not spaces
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
expect(input.value).toBe('blue_hair,');
|
||||
});
|
||||
|
||||
it('omits the trailing comma when the append comma setting is disabled', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||
];
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'blue hair';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'blue hair';
|
||||
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
expect(input.value).toBe('blue_hair ');
|
||||
});
|
||||
|
||||
it('uses persisted autocomplete metadata as the next search start when comma append is disabled', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: [{ tag_name: 'cat_ears', category: 0, post_count: 1234 }] }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl cat');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1girl cat';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
input._autocompleteMetadataWidget = {
|
||||
value: {
|
||||
version: 1,
|
||||
textWidgetName: 'text',
|
||||
lastAccepted: {
|
||||
start: 0,
|
||||
end: 6,
|
||||
insertedText: '1girl ',
|
||||
textSnapshot: '1girl ',
|
||||
},
|
||||
},
|
||||
};
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
expect(autoComplete.getSearchTerm(input.value)).toBe('cat');
|
||||
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
|
||||
});
|
||||
|
||||
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1boy cat');
|
||||
|
||||
const metadataWidget = {
|
||||
value: {
|
||||
version: 1,
|
||||
textWidgetName: 'text',
|
||||
lastAccepted: {
|
||||
start: 0,
|
||||
end: 6,
|
||||
insertedText: '1girl ',
|
||||
textSnapshot: '1girl ',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1boy cat';
|
||||
input.selectionStart = input.value.length;
|
||||
input._autocompleteMetadataWidget = metadataWidget;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
expect(autoComplete.getSearchTerm(input.value)).toBe('1boy cat');
|
||||
expect(metadataWidget.value.lastAccepted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not duplicate the first character when accepting a suggestion after a trailing space', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{ tag_name: '1girl', category: 4, post_count: 500000 },
|
||||
];
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl ');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1girl ';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
|
||||
await autoComplete.insertSelection('1girl');
|
||||
|
||||
expect(input.value).toBe('1girl ');
|
||||
});
|
||||
|
||||
it('treats a newline as a hard boundary after dismissing autocomplete', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: [{ tag_name: '1girl', category: 4, post_count: 500000 }] }),
|
||||
});
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1gi\n';
|
||||
input.selectionStart = input.value.length;
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi');
|
||||
autoComplete.handleInput('1gi');
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
expect(fetchApiMock).toHaveBeenCalled();
|
||||
|
||||
fetchApiMock.mockClear();
|
||||
autoComplete.hide();
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi\n');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.getSearchTerm(input.value)).toBe('');
|
||||
expect(fetchApiMock).not.toHaveBeenCalled();
|
||||
expect(autoComplete.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('omits the trailing comma for LoRA insertions when the setting is disabled', async () => {
|
||||
settingGetMock.mockImplementation((key) => {
|
||||
if (key === 'loramanager.autocomplete_append_comma') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'loramanager.prompt_tag_autocomplete') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'loramanager.tag_space_replacement') {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
fetchApiMock.mockImplementation((url) => {
|
||||
if (url.includes('usage-tips-by-path')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
usage_tips: JSON.stringify({ strength: '1.2' }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
|
||||
});
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'alpha, example';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input,'loras', { debounceDelay: 0, showPreview: false });
|
||||
|
||||
await autoComplete.insertSelection('models/example.safetensors');
|
||||
|
||||
expect(input.value).toContain('<lora:example:1.2>');
|
||||
expect(input.value).not.toContain('<lora:example:1.2>,');
|
||||
});
|
||||
|
||||
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
|
||||
@@ -677,7 +1208,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
const autoComplete = new AutoComplete(input,'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
@@ -692,6 +1223,6 @@ describe('AutoComplete widget interactions', () => {
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Entire phrase should be replaced with selected tag
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(input.value).toBe('looking_to_the_side,');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,16 +245,28 @@ describe('Interaction-level regression coverage', () => {
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Prompt</label>
|
||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
|
||||
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-content" id="recipePrompt"></div>
|
||||
<div class="param-editor" id="recipePromptEditor">
|
||||
<textarea class="param-textarea" id="recipePromptInput"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Negative Prompt</label>
|
||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
|
||||
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
@@ -324,6 +336,208 @@ describe('Interaction-level regression coverage', () => {
|
||||
expect(recipeModal.currentRecipe.title).toBe('Updated Title');
|
||||
});
|
||||
|
||||
it('saves prompt edits on Enter while preserving Shift+Enter for new lines', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="recipeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Prompt</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
|
||||
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-content" id="recipePrompt"></div>
|
||||
<div class="param-editor" id="recipePromptEditor">
|
||||
<textarea class="param-textarea" id="recipePromptInput"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Negative Prompt</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
|
||||
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
<div class="recipe-section-actions">
|
||||
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||
const recipeModal = new RecipeModal();
|
||||
|
||||
recipeModal.showRecipeDetails({
|
||||
id: 'recipe-2',
|
||||
file_path: '/recipes/prompt.json',
|
||||
title: 'Prompt Recipe',
|
||||
tags: [],
|
||||
file_url: '',
|
||||
preview_url: '',
|
||||
source_path: '',
|
||||
gen_params: {
|
||||
prompt: 'old prompt',
|
||||
negative_prompt: 'keep negative',
|
||||
steps: 30,
|
||||
cfg_scale: 7,
|
||||
},
|
||||
loras: [],
|
||||
});
|
||||
|
||||
document.getElementById('editPromptBtn').click();
|
||||
const textarea = document.getElementById('recipePromptInput');
|
||||
textarea.value = 'new prompt text';
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true }));
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(updateRecipeMetadataMock).not.toHaveBeenCalled();
|
||||
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await updateRecipeMetadataMock.mock.results[0].value;
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(updateRecipeMetadataMock).toHaveBeenCalledWith('/recipes/prompt.json', {
|
||||
gen_params: {
|
||||
prompt: 'new prompt text',
|
||||
negative_prompt: 'keep negative',
|
||||
steps: 30,
|
||||
cfg_scale: 7,
|
||||
},
|
||||
});
|
||||
expect(document.getElementById('recipePrompt').textContent).toBe('new prompt text');
|
||||
expect(recipeModal.currentRecipe.gen_params.prompt).toBe('new prompt text');
|
||||
});
|
||||
|
||||
it('cancels negative prompt edits on Escape without saving', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="recipeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div class="recipe-tags-container">
|
||||
<div class="recipe-tags-compact" id="recipeTagsCompact"></div>
|
||||
<div class="recipe-tags-tooltip" id="recipeTagsTooltip">
|
||||
<div class="tooltip-content" id="recipeTagsTooltipContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-top-section">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
<div class="info-section recipe-gen-params">
|
||||
<div class="gen-params-container">
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Prompt</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"><i class="fas fa-copy"></i></button>
|
||||
<button class="edit-btn" id="editPromptBtn" title="Edit Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-content" id="recipePrompt"></div>
|
||||
<div class="param-editor" id="recipePromptEditor">
|
||||
<textarea class="param-textarea" id="recipePromptInput"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group info-item">
|
||||
<div class="param-header">
|
||||
<label>Negative Prompt</label>
|
||||
<div class="param-actions">
|
||||
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"><i class="fas fa-copy"></i></button>
|
||||
<button class="edit-btn" id="editNegativePromptBtn" title="Edit Negative Prompt"><i class="fas fa-pencil-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-content" id="recipeNegativePrompt"></div>
|
||||
<div class="param-editor" id="recipeNegativePromptEditor">
|
||||
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="other-params" id="recipeOtherParams"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-header">
|
||||
<h3>Resources</h3>
|
||||
<div class="recipe-section-actions">
|
||||
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||
const recipeModal = new RecipeModal();
|
||||
|
||||
recipeModal.showRecipeDetails({
|
||||
id: 'recipe-3',
|
||||
file_path: '/recipes/negative.json',
|
||||
title: 'Negative Recipe',
|
||||
tags: [],
|
||||
file_url: '',
|
||||
preview_url: '',
|
||||
source_path: '',
|
||||
gen_params: {
|
||||
prompt: '',
|
||||
negative_prompt: 'existing negative',
|
||||
steps: 20,
|
||||
},
|
||||
loras: [],
|
||||
});
|
||||
|
||||
document.getElementById('editNegativePromptBtn').click();
|
||||
const textarea = document.getElementById('recipeNegativePromptInput');
|
||||
textarea.value = 'changed negative';
|
||||
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(updateRecipeMetadataMock).not.toHaveBeenCalled();
|
||||
expect(modalManagerMock.closeModal).not.toHaveBeenCalled();
|
||||
expect(document.getElementById('recipeNegativePrompt').textContent).toBe('existing negative');
|
||||
expect(document.getElementById('recipeNegativePromptEditor').classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('processes global context menu actions for downloads and cleanup', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
|
||||
@@ -37,6 +37,13 @@ const updateConnectedTriggerWords = vi.fn();
|
||||
const mergeLoras = vi.fn();
|
||||
const getAllGraphNodes = vi.fn();
|
||||
const getNodeFromGraph = vi.fn();
|
||||
const getWidgetByName = vi.fn((node, name) =>
|
||||
node?.widgets?.find((widget) => widget?.name === name) ?? null
|
||||
);
|
||||
const getWidgetSerializedValue = vi.fn((node, name) => {
|
||||
const index = node?.widgets?.findIndex((widget) => widget?.name === name) ?? -1;
|
||||
return index >= 0 ? node.widgets_values?.[index] : undefined;
|
||||
});
|
||||
|
||||
vi.mock(UTILS_MODULE, () => ({
|
||||
collectActiveLorasFromChain,
|
||||
@@ -47,6 +54,8 @@ vi.mock(UTILS_MODULE, () => ({
|
||||
},
|
||||
getAllGraphNodes,
|
||||
getNodeFromGraph,
|
||||
getWidgetByName,
|
||||
getWidgetSerializedValue,
|
||||
LORA_PATTERN: /<lora:([^:]+):([-\d.]+)(?::([-\d.]+))?>/g,
|
||||
}));
|
||||
|
||||
@@ -71,6 +80,9 @@ describe("Lora Loader trigger word updates", () => {
|
||||
mergeLoras.mockClear();
|
||||
mergeLoras.mockImplementation(() => [{ name: "Alpha", active: true }]);
|
||||
|
||||
getWidgetByName.mockClear();
|
||||
getWidgetSerializedValue.mockClear();
|
||||
|
||||
addLorasWidget.mockClear();
|
||||
addLorasWidget.mockImplementation((_node, _name, _opts, callback) => ({
|
||||
widget: { value: [], callback },
|
||||
@@ -89,14 +101,21 @@ describe("Lora Loader trigger word updates", () => {
|
||||
|
||||
// Create mock widget (AUTOCOMPLETE_TEXT_LORAS type created by Vue widgets)
|
||||
const inputWidget = {
|
||||
name: "text",
|
||||
value: "",
|
||||
options: {},
|
||||
callback: null, // Will be set by onNodeCreated
|
||||
};
|
||||
|
||||
const metadataWidget = {
|
||||
name: "__autocomplete_metadata_text",
|
||||
value: { version: 1, textWidgetName: "text" },
|
||||
options: {},
|
||||
};
|
||||
|
||||
const node = {
|
||||
comfyClass: "Lora Loader (LoraManager)",
|
||||
widgets: [inputWidget],
|
||||
widgets: [metadataWidget, inputWidget],
|
||||
addInput: vi.fn(),
|
||||
graph: {},
|
||||
};
|
||||
@@ -106,6 +125,7 @@ describe("Lora Loader trigger word updates", () => {
|
||||
// The widget is now the AUTOCOMPLETE_TEXT_LORAS type, created automatically by Vue widgets
|
||||
expect(node.inputWidget).toBe(inputWidget);
|
||||
expect(node.lorasWidget).toBeDefined();
|
||||
expect(getWidgetByName).toHaveBeenCalledWith(node, "text");
|
||||
|
||||
// The callback should have been set up by onNodeCreated
|
||||
const inputCallback = inputWidget.callback;
|
||||
|
||||
@@ -82,24 +82,35 @@ vi.mock(MODEL_VERSIONS_MODULE, () => ({
|
||||
}));
|
||||
|
||||
vi.mock(RECIPE_TAB_MODULE, () => ({
|
||||
loadRecipesForLora: vi.fn(),
|
||||
loadRecipesForModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback || ''),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Model metadata interactions keep file path in sync', () => {
|
||||
let getModelApiClient;
|
||||
let loadRecipesForModel;
|
||||
|
||||
beforeEach(async () => {
|
||||
document.body.innerHTML = '';
|
||||
({ getModelApiClient } = await import(API_FACTORY));
|
||||
({ loadRecipesForModel } = await import(RECIPE_TAB_MODULE));
|
||||
getModelApiClient.mockReset();
|
||||
loadRecipesForModel.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -198,4 +209,33 @@ describe('Model metadata interactions keep file path in sync', () => {
|
||||
expect(saveModelMetadata).toHaveBeenCalledWith('models/Qwen.testing.safetensors', { notes: 'Updated notes' });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows recipes tab for checkpoint modals and loads linked recipes by hash', async () => {
|
||||
const fetchModelMetadata = vi.fn().mockResolvedValue(null);
|
||||
|
||||
getModelApiClient.mockReturnValue({
|
||||
fetchModelMetadata,
|
||||
saveModelMetadata: vi.fn(),
|
||||
});
|
||||
|
||||
const { showModelModal } = await import(MODAL_MODULE);
|
||||
|
||||
await showModelModal(
|
||||
{
|
||||
model_name: 'Flux Base',
|
||||
file_path: 'models/checkpoints/flux-base.safetensors',
|
||||
file_name: 'flux-base.safetensors',
|
||||
sha256: 'ABC123',
|
||||
civitai: {},
|
||||
},
|
||||
'checkpoints',
|
||||
);
|
||||
|
||||
expect(document.querySelector('.tab-btn[data-tab="recipes"]')).not.toBeNull();
|
||||
expect(loadRecipesForModel).toHaveBeenCalledWith({
|
||||
modelKind: 'checkpoint',
|
||||
displayName: 'Flux Base',
|
||||
sha256: 'ABC123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,13 +80,21 @@ vi.mock(MODEL_VERSIONS_MODULE, () => ({
|
||||
}));
|
||||
|
||||
vi.mock(RECIPE_TAB_MODULE, () => ({
|
||||
loadRecipesForLora: vi.fn(),
|
||||
loadRecipesForModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||
translate: vi.fn((_, __, fallback) => fallback || ''),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY, () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -50,6 +50,13 @@ const getAllGraphNodes = vi.fn();
|
||||
const getNodeFromGraph = vi.fn();
|
||||
const getNodeKey = vi.fn();
|
||||
const getLinkFromGraph = vi.fn();
|
||||
const getWidgetByName = vi.fn((node, name) =>
|
||||
node?.widgets?.find((widget) => widget?.name === name) ?? null
|
||||
);
|
||||
const getWidgetSerializedValue = vi.fn((node, name) => {
|
||||
const index = node?.widgets?.findIndex((widget) => widget?.name === name) ?? -1;
|
||||
return index >= 0 ? node.widgets_values?.[index] : undefined;
|
||||
});
|
||||
const chainCallback = vi.fn((proto, property, callback) => {
|
||||
proto[property] = callback;
|
||||
});
|
||||
@@ -68,6 +75,8 @@ vi.mock(UTILS_MODULE, async (importOriginal) => {
|
||||
getNodeFromGraph,
|
||||
getNodeKey,
|
||||
getLinkFromGraph,
|
||||
getWidgetByName,
|
||||
getWidgetSerializedValue,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -98,6 +107,9 @@ describe("Node mode change handling", () => {
|
||||
mergeLoras.mockClear();
|
||||
mergeLoras.mockImplementation(() => [{ name: "Alpha", active: true }]);
|
||||
|
||||
getWidgetByName.mockClear();
|
||||
getWidgetSerializedValue.mockClear();
|
||||
|
||||
addLorasWidget.mockClear();
|
||||
addLorasWidget.mockImplementation((_node, _name, _opts, callback) => ({
|
||||
widget: { value: [], callback },
|
||||
@@ -119,8 +131,13 @@ describe("Node mode change handling", () => {
|
||||
|
||||
await extension.beforeRegisterNodeDef(nodeType, nodeData, {});
|
||||
|
||||
// Create widgets with proper structure for lora_stacker.js
|
||||
// Widget at index 0 is the AUTOCOMPLETE_TEXT_LORAS widget (created by Vue widgets)
|
||||
// Include a hidden metadata widget ahead of the actual text widget to match runtime ordering.
|
||||
const metadataWidget = {
|
||||
name: "__autocomplete_metadata_text",
|
||||
value: { version: 1, textWidgetName: "text" },
|
||||
options: {},
|
||||
};
|
||||
|
||||
const inputWidget = {
|
||||
name: "text",
|
||||
value: "",
|
||||
@@ -139,7 +156,7 @@ describe("Node mode change handling", () => {
|
||||
|
||||
node = {
|
||||
comfyClass: "Lora Stacker (LoraManager)",
|
||||
widgets: [inputWidget, lorasWidget],
|
||||
widgets: [metadataWidget, inputWidget, lorasWidget],
|
||||
lorasWidget,
|
||||
addInput: vi.fn(),
|
||||
mode: 0, // Initial mode
|
||||
@@ -189,11 +206,18 @@ describe("Node mode change handling", () => {
|
||||
const nodeType = { comfyClass: "Lora Loader (LoraManager)", prototype: {} };
|
||||
await extension.beforeRegisterNodeDef(nodeType, {}, {});
|
||||
|
||||
// Widget at index 0 is the AUTOCOMPLETE_TEXT_LORAS widget (created by Vue widgets)
|
||||
const metadataWidget = {
|
||||
name: "__autocomplete_metadata_text",
|
||||
value: { version: 1, textWidgetName: "text" },
|
||||
options: {},
|
||||
};
|
||||
|
||||
node = {
|
||||
comfyClass: "Lora Loader (LoraManager)",
|
||||
widgets: [
|
||||
metadataWidget,
|
||||
{
|
||||
name: "text",
|
||||
value: "",
|
||||
options: {},
|
||||
callback: null, // Will be set by onNodeCreated
|
||||
|
||||
106
tests/frontend/managers/RecipeDataManager.tagInput.test.js
Normal file
106
tests/frontend/managers/RecipeDataManager.tagInput.test.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (_key, _params, fallback) => fallback ?? '',
|
||||
}));
|
||||
|
||||
describe('RecipeDataManager tag input Enter behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
document.body.innerHTML = `
|
||||
<input id="tagInput" type="text" />
|
||||
<div id="tagsContainer"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it('adds a tag when pressing Enter in tag input', async () => {
|
||||
const { RecipeDataManager } = await import('../../../static/js/managers/import/RecipeDataManager.js');
|
||||
const importManager = {
|
||||
recipeTags: [],
|
||||
stepManager: { showStep: vi.fn() },
|
||||
};
|
||||
const manager = new RecipeDataManager(importManager);
|
||||
|
||||
manager.setupTagInputEnterHandler();
|
||||
|
||||
const tagInput = document.getElementById('tagInput');
|
||||
tagInput.value = 'portrait';
|
||||
tagInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
|
||||
expect(importManager.recipeTags).toEqual(['portrait']);
|
||||
expect(tagInput.value).toBe('');
|
||||
expect(document.getElementById('tagsContainer').textContent).toContain('portrait');
|
||||
});
|
||||
|
||||
it('does not register duplicate Enter handlers when setup runs multiple times', async () => {
|
||||
const { RecipeDataManager } = await import('../../../static/js/managers/import/RecipeDataManager.js');
|
||||
const importManager = {
|
||||
recipeTags: [],
|
||||
stepManager: { showStep: vi.fn() },
|
||||
};
|
||||
const manager = new RecipeDataManager(importManager);
|
||||
|
||||
manager.setupTagInputEnterHandler();
|
||||
manager.setupTagInputEnterHandler();
|
||||
|
||||
const tagInput = document.getElementById('tagInput');
|
||||
tagInput.value = 'anime';
|
||||
tagInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
|
||||
expect(importManager.recipeTags).toEqual(['anime']);
|
||||
});
|
||||
|
||||
it('ignores Enter while IME composition is active', async () => {
|
||||
const { RecipeDataManager } = await import('../../../static/js/managers/import/RecipeDataManager.js');
|
||||
const importManager = {
|
||||
recipeTags: [],
|
||||
stepManager: { showStep: vi.fn() },
|
||||
};
|
||||
const manager = new RecipeDataManager(importManager);
|
||||
|
||||
manager.setupTagInputEnterHandler();
|
||||
|
||||
const tagInput = document.getElementById('tagInput');
|
||||
tagInput.value = '未確定';
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(event, 'isComposing', { value: true });
|
||||
tagInput.dispatchEvent(event);
|
||||
|
||||
expect(importManager.recipeTags).toEqual([]);
|
||||
expect(tagInput.value).toBe('未確定');
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores keyCode 229 fallback during composition', async () => {
|
||||
const { RecipeDataManager } = await import('../../../static/js/managers/import/RecipeDataManager.js');
|
||||
const importManager = {
|
||||
recipeTags: [],
|
||||
stepManager: { showStep: vi.fn() },
|
||||
};
|
||||
const manager = new RecipeDataManager(importManager);
|
||||
|
||||
manager.setupTagInputEnterHandler();
|
||||
|
||||
const tagInput = document.getElementById('tagInput');
|
||||
tagInput.value = '候補';
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(event, 'keyCode', { value: 229 });
|
||||
tagInput.dispatchEvent(event);
|
||||
|
||||
expect(importManager.recipeTags).toEqual([]);
|
||||
expect(tagInput.value).toBe('候補');
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ vi.mock('../../../static/js/state/index.js', () => {
|
||||
},
|
||||
createDefaultSettings: () => ({
|
||||
language: 'en',
|
||||
skip_previously_downloaded_model_versions: false,
|
||||
download_skip_base_models: [],
|
||||
}),
|
||||
};
|
||||
@@ -39,6 +40,7 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
checkpoint: 'base, guide',
|
||||
embedding: 'hint',
|
||||
},
|
||||
getMappableBaseModelsDynamic: () => ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
@@ -116,6 +118,7 @@ describe('SettingsManager download skip base models UI', () => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
state.global.settings = {
|
||||
skip_previously_downloaded_model_versions: false,
|
||||
download_skip_base_models: [],
|
||||
};
|
||||
});
|
||||
@@ -149,4 +152,31 @@ describe('SettingsManager download skip base models UI', () => {
|
||||
expect(document.querySelectorAll('#downloadSkipBaseModelsContainer input')).toHaveLength(0);
|
||||
expect(document.getElementById('downloadSkipBaseModelsEmpty').hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('initializes the previously-downloaded-version toggle from settings', () => {
|
||||
document.body.innerHTML = '<input id="skipPreviouslyDownloadedModelVersions" type="checkbox" />';
|
||||
state.global.settings.skip_previously_downloaded_model_versions = true;
|
||||
const manager = createManager();
|
||||
|
||||
manager.loadSettingsToUI();
|
||||
|
||||
expect(document.getElementById('skipPreviouslyDownloadedModelVersions').checked).toBe(true);
|
||||
});
|
||||
|
||||
it('saves the previously-downloaded-version toggle with the expected setting key', async () => {
|
||||
document.body.innerHTML = '<input id="skipPreviouslyDownloadedModelVersions" type="checkbox" checked />';
|
||||
const manager = createManager();
|
||||
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||
manager.applyFrontendSettings = vi.fn();
|
||||
|
||||
await manager.saveToggleSetting(
|
||||
'skipPreviouslyDownloadedModelVersions',
|
||||
'skip_previously_downloaded_model_versions',
|
||||
);
|
||||
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith(
|
||||
'skip_previously_downloaded_model_versions',
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
checkpoint: 'base, guide',
|
||||
embedding: 'hint',
|
||||
},
|
||||
getMappableBaseModelsDynamic: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
|
||||
@@ -6,6 +6,7 @@ const initializePageFeaturesMock = vi.fn();
|
||||
const getCurrentPageStateMock = vi.fn();
|
||||
const getSessionItemMock = vi.fn();
|
||||
const removeSessionItemMock = vi.fn();
|
||||
const getStorageItemMock = vi.fn();
|
||||
const RecipeContextMenuMock = vi.fn();
|
||||
const refreshVirtualScrollMock = vi.fn();
|
||||
const refreshRecipesMock = vi.fn();
|
||||
@@ -51,6 +52,7 @@ vi.mock('../../../static/js/state/index.js', () => ({
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getSessionItem: getSessionItemMock,
|
||||
removeSessionItem: removeSessionItemMock,
|
||||
getStorageItem: getStorageItemMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
|
||||
@@ -117,11 +119,14 @@ describe('RecipeManager', () => {
|
||||
const map = {
|
||||
lora_to_recipe_filterLoraName: 'Flux Dream',
|
||||
lora_to_recipe_filterLoraHash: 'abc123',
|
||||
checkpoint_to_recipe_filterCheckpointName: null,
|
||||
checkpoint_to_recipe_filterCheckpointHash: null,
|
||||
viewRecipeId: '42',
|
||||
};
|
||||
return map[key] ?? null;
|
||||
});
|
||||
removeSessionItemMock.mockImplementation(() => { });
|
||||
getStorageItemMock.mockImplementation((_, defaultValue = null) => defaultValue);
|
||||
|
||||
renderRecipesPage();
|
||||
|
||||
@@ -166,6 +171,8 @@ describe('RecipeManager', () => {
|
||||
active: true,
|
||||
loraName: 'Flux Dream',
|
||||
loraHash: 'abc123',
|
||||
checkpointName: null,
|
||||
checkpointHash: null,
|
||||
recipeId: '42',
|
||||
});
|
||||
|
||||
@@ -177,6 +184,8 @@ describe('RecipeManager', () => {
|
||||
|
||||
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName');
|
||||
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraHash');
|
||||
expect(removeSessionItemMock).toHaveBeenCalledWith('checkpoint_to_recipe_filterCheckpointName');
|
||||
expect(removeSessionItemMock).toHaveBeenCalledWith('checkpoint_to_recipe_filterCheckpointHash');
|
||||
expect(removeSessionItemMock).toHaveBeenCalledWith('viewRecipeId');
|
||||
expect(pageState.customFilter.active).toBe(false);
|
||||
expect(indicator.classList.contains('hidden')).toBe(true);
|
||||
@@ -227,4 +236,36 @@ describe('RecipeManager', () => {
|
||||
await manager.refreshRecipes();
|
||||
expect(refreshRecipesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores checkpoint recipe filter state and indicator text', async () => {
|
||||
getSessionItemMock.mockImplementation((key) => {
|
||||
const map = {
|
||||
lora_to_recipe_filterLoraName: null,
|
||||
lora_to_recipe_filterLoraHash: null,
|
||||
checkpoint_to_recipe_filterCheckpointName: 'Flux Base',
|
||||
checkpoint_to_recipe_filterCheckpointHash: 'ckpt123',
|
||||
viewRecipeId: null,
|
||||
};
|
||||
return map[key] ?? null;
|
||||
});
|
||||
|
||||
const manager = new RecipeManager();
|
||||
await manager.initialize();
|
||||
|
||||
expect(pageState.customFilter).toEqual({
|
||||
active: true,
|
||||
loraName: null,
|
||||
loraHash: null,
|
||||
checkpointName: 'Flux Base',
|
||||
checkpointHash: 'ckpt123',
|
||||
recipeId: null,
|
||||
});
|
||||
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const filterText = indicator.querySelector('#customFilterText');
|
||||
|
||||
expect(filterText.innerHTML).toContain('Recipes using checkpoint:');
|
||||
expect(filterText.innerHTML).toContain('Flux Base');
|
||||
expect(filterText.getAttribute('title')).toBe('Flux Base');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,37 @@ describe('civitaiUtils', () => {
|
||||
expect(wasRewritten).toBe(false);
|
||||
expect(rewritten).toBe('not-a-valid-url');
|
||||
});
|
||||
|
||||
it('should rewrite URLs from CivitAI CDN subdomains', () => {
|
||||
const originalUrl = 'https://image-b2.civitai.com/file/civitai-media-cache/original=true/sample.png';
|
||||
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
|
||||
|
||||
expect(wasRewritten).toBe(true);
|
||||
expect(rewritten).toBe('https://image-b2.civitai.com/file/civitai-media-cache/width=450,optimized=true/sample.png');
|
||||
});
|
||||
|
||||
it('should handle URLs with explicit port numbers', () => {
|
||||
const originalUrl = 'https://image.civitai.com:443/checkpoints/original=true/test.png';
|
||||
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
|
||||
|
||||
expect(wasRewritten).toBe(true);
|
||||
// JavaScript URL.toString() removes default HTTPS port (443)
|
||||
expect(rewritten).toBe('https://image.civitai.com/checkpoints/width=450,optimized=true/test.png');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive hostnames', () => {
|
||||
const testCases = [
|
||||
'https://IMAGE.CIVITAI.COM/original=true/test.png',
|
||||
'https://Image.Civitai.Com/original=true/test.png',
|
||||
'https://image-b2.CIVITAI.com/original=true/test.png',
|
||||
];
|
||||
|
||||
for (const url of testCases) {
|
||||
const [rewritten, wasRewritten] = rewriteCivitaiUrl(url, 'image', OptimizationMode.THUMBNAIL);
|
||||
expect(wasRewritten).toBe(true);
|
||||
expect(rewritten).toContain('width=450,optimized=true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOptimizedUrl', () => {
|
||||
@@ -157,6 +188,23 @@ describe('civitaiUtils', () => {
|
||||
expect(isCivitaiUrl('https://image.civitai.com/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for CivitAI CDN subdomains', () => {
|
||||
expect(isCivitaiUrl('https://image-b2.civitai.com/file/test.png')).toBe(true);
|
||||
expect(isCivitaiUrl('https://image-b3.civitai.com/test.jpg')).toBe(true);
|
||||
expect(isCivitaiUrl('https://cdn.civitai.com/test.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for CivitAI URLs with explicit ports', () => {
|
||||
expect(isCivitaiUrl('https://image.civitai.com:443/test.png')).toBe(true);
|
||||
expect(isCivitaiUrl('https://image-b2.civitai.com:443/file/test.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive hostnames', () => {
|
||||
expect(isCivitaiUrl('https://IMAGE.CIVITAI.COM/test.png')).toBe(true);
|
||||
expect(isCivitaiUrl('https://Image.Civitai.Com/test.png')).toBe(true);
|
||||
expect(isCivitaiUrl('https://image-b2.CIVITAI.com/test.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-CivitAI URLs', () => {
|
||||
expect(isCivitaiUrl('https://example.com/image.jpg')).toBe(false);
|
||||
expect(isCivitaiUrl('https://civitai.com/image.jpg')).toBe(false);
|
||||
|
||||
151
tests/frontend/utils/loraChainTraversal.test.js
Normal file
151
tests/frontend/utils/loraChainTraversal.test.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { APP_MODULE, UTILS_MODULE } = vi.hoisted(() => ({
|
||||
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
|
||||
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
vi.mock(APP_MODULE, () => ({
|
||||
app: {
|
||||
graph: null,
|
||||
registerExtension: vi.fn(),
|
||||
ui: {
|
||||
settings: {
|
||||
getSettingValue: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("LoRA chain traversal", () => {
|
||||
let collectActiveLorasFromChain;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ collectActiveLorasFromChain } = await import(UTILS_MODULE));
|
||||
});
|
||||
|
||||
function createGraph(nodes, links) {
|
||||
const graph = {
|
||||
_nodes: nodes,
|
||||
links,
|
||||
getNodeById(id) {
|
||||
return nodes.find((node) => node.id === id) ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.graph = graph;
|
||||
});
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
it("aggregates active LoRAs through a combiner with multiple LORA_STACK inputs", () => {
|
||||
const randomizerA = {
|
||||
id: 1,
|
||||
comfyClass: "Lora Randomizer (LoraManager)",
|
||||
mode: 0,
|
||||
widgets: [
|
||||
{
|
||||
name: "loras",
|
||||
value: [
|
||||
{ name: "Alpha", active: true },
|
||||
{ name: "Ignored", active: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
};
|
||||
const randomizerB = {
|
||||
id: 2,
|
||||
comfyClass: "Lora Randomizer (LoraManager)",
|
||||
mode: 0,
|
||||
widgets: [
|
||||
{
|
||||
name: "loras",
|
||||
value: [{ name: "Beta", active: true }],
|
||||
},
|
||||
],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
};
|
||||
const combiner = {
|
||||
id: 3,
|
||||
comfyClass: "Lora Stack Combiner (LoraManager)",
|
||||
mode: 0,
|
||||
widgets: [],
|
||||
inputs: [
|
||||
{ name: "lora_stack_a", type: "LORA_STACK", link: 11 },
|
||||
{ name: "lora_stack_b", type: "LORA_STACK", link: 12 },
|
||||
],
|
||||
outputs: [],
|
||||
};
|
||||
const loader = {
|
||||
id: 4,
|
||||
comfyClass: "Lora Loader (LoraManager)",
|
||||
mode: 0,
|
||||
widgets: [],
|
||||
inputs: [{ name: "lora_stack", type: "LORA_STACK", link: 13 }],
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
createGraph(
|
||||
[randomizerA, randomizerB, combiner, loader],
|
||||
{
|
||||
11: { origin_id: 1, target_id: 3 },
|
||||
12: { origin_id: 2, target_id: 3 },
|
||||
13: { origin_id: 3, target_id: 4 },
|
||||
}
|
||||
);
|
||||
|
||||
const result = collectActiveLorasFromChain(loader);
|
||||
|
||||
expect([...result]).toEqual(["Alpha", "Beta"]);
|
||||
});
|
||||
|
||||
it("stops propagation when the combiner is inactive", () => {
|
||||
const randomizer = {
|
||||
id: 1,
|
||||
comfyClass: "Lora Randomizer (LoraManager)",
|
||||
mode: 0,
|
||||
widgets: [
|
||||
{
|
||||
name: "loras",
|
||||
value: [{ name: "Alpha", active: true }],
|
||||
},
|
||||
],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
};
|
||||
const combiner = {
|
||||
id: 2,
|
||||
comfyClass: "Lora Stack Combiner (LoraManager)",
|
||||
mode: 2,
|
||||
widgets: [],
|
||||
inputs: [{ name: "lora_stack_a", type: "LORA_STACK", link: 21 }],
|
||||
outputs: [],
|
||||
};
|
||||
const loader = {
|
||||
id: 3,
|
||||
comfyClass: "Lora Loader (LoraManager)",
|
||||
mode: 0,
|
||||
widgets: [],
|
||||
inputs: [{ name: "lora_stack", type: "LORA_STACK", link: 22 }],
|
||||
outputs: [],
|
||||
};
|
||||
|
||||
createGraph(
|
||||
[randomizer, combiner, loader],
|
||||
{
|
||||
21: { origin_id: 1, target_id: 2 },
|
||||
22: { origin_id: 2, target_id: 3 },
|
||||
}
|
||||
);
|
||||
|
||||
const result = collectActiveLorasFromChain(loader);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from py.middleware.csp_middleware import REMOTE_MEDIA_SOURCES, relax_csp_for_remote_media
|
||||
from py.middleware.csp_middleware import (
|
||||
REMOTE_MEDIA_SOURCES,
|
||||
relax_csp_for_remote_media,
|
||||
)
|
||||
|
||||
DEFAULT_CSP = (
|
||||
"default-src 'self'; "
|
||||
@@ -40,7 +43,9 @@ async def _invoke_middleware(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relax_csp_appends_remote_sources_and_preserves_existing_directives() -> None:
|
||||
async def test_relax_csp_appends_remote_sources_and_preserves_existing_directives() -> (
|
||||
None
|
||||
):
|
||||
response = await _invoke_middleware("/some-path", web.Response())
|
||||
header_value = response.headers.get("Content-Security-Policy")
|
||||
assert header_value is not None
|
||||
@@ -48,16 +53,17 @@ async def test_relax_csp_appends_remote_sources_and_preserves_existing_directive
|
||||
directives = _parse_directives(header_value)
|
||||
|
||||
# Existing directives remain intact
|
||||
assert directives["script-src"] == ["'self'", "'unsafe-inline'", "'unsafe-eval'", "blob:"]
|
||||
assert directives["script-src"] == [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"'unsafe-eval'",
|
||||
"blob:",
|
||||
]
|
||||
assert directives["img-src"][:3] == ["'self'", "data:", "blob:"]
|
||||
|
||||
# Remote media hosts are added once to the relevant directives
|
||||
for source in REMOTE_MEDIA_SOURCES:
|
||||
assert source in directives["img-src"]
|
||||
|
||||
assert "media-src" in directives
|
||||
assert directives["media-src"][0] == "'self'"
|
||||
for source in REMOTE_MEDIA_SOURCES:
|
||||
assert source in directives["media-src"]
|
||||
|
||||
|
||||
|
||||
109
tests/nodes/test_lora_cycler.py
Normal file
109
tests/nodes/test_lora_cycler.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Tests for preset strength behavior in LoraCyclerLM."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.nodes.lora_cycler import LoraCyclerLM
|
||||
from py.services import service_registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycler_node():
|
||||
return LoraCyclerLM()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycler_config():
|
||||
return {
|
||||
"current_index": 1,
|
||||
"model_strength": 0.8,
|
||||
"clip_strength": 0.6,
|
||||
"use_same_clip_strength": False,
|
||||
"use_preset_strength": True,
|
||||
"preset_strength_scale": 1.5,
|
||||
"include_no_lora": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cycler_uses_scaled_preset_strength_when_available(
|
||||
cycler_node, cycler_config, mock_scanner, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "preset_lora.safetensors",
|
||||
"file_path": "/models/loras/preset_lora.safetensors",
|
||||
"folder": "",
|
||||
"usage_tips": '{"strength": 0.7, "clipStrength": 0.5}',
|
||||
}
|
||||
]
|
||||
|
||||
result = await cycler_node.cycle(cycler_config)
|
||||
|
||||
assert result["result"][0] == [
|
||||
("/models/loras/preset_lora.safetensors", 1.05, 0.75)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cycler_falls_back_to_manual_strength_when_preset_missing(
|
||||
cycler_node, cycler_config, mock_scanner, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "manual_lora.safetensors",
|
||||
"file_path": "/models/loras/manual_lora.safetensors",
|
||||
"folder": "",
|
||||
"usage_tips": "",
|
||||
}
|
||||
]
|
||||
|
||||
result = await cycler_node.cycle(cycler_config)
|
||||
|
||||
assert result["result"][0] == [
|
||||
("/models/loras/manual_lora.safetensors", 0.8, 0.6)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cycler_syncs_clip_to_model_when_same_clip_strength_enabled(
|
||||
cycler_node, cycler_config, mock_scanner, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "preset_lora.safetensors",
|
||||
"file_path": "/models/loras/preset_lora.safetensors",
|
||||
"folder": "",
|
||||
"usage_tips": '{"strength": 0.7, "clipStrength": 0.3}',
|
||||
}
|
||||
]
|
||||
|
||||
result = await cycler_node.cycle(
|
||||
{
|
||||
**cycler_config,
|
||||
"use_same_clip_strength": True,
|
||||
}
|
||||
)
|
||||
|
||||
assert result["result"][0] == [
|
||||
("/models/loras/preset_lora.safetensors", 1.05, 1.05)
|
||||
]
|
||||
201
tests/nodes/test_lora_loader.py
Normal file
201
tests/nodes/test_lora_loader.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
|
||||
|
||||
|
||||
class _ModelContainer:
|
||||
def __init__(self, diffusion_model):
|
||||
self.diffusion_model = diffusion_model
|
||||
|
||||
|
||||
class _Model:
|
||||
def __init__(self, diffusion_model):
|
||||
self.model = _ModelContainer(diffusion_model)
|
||||
|
||||
|
||||
def test_lora_loader_standard_model_uses_comfy_loader(monkeypatch):
|
||||
loader = LoraLoaderLM()
|
||||
model = _Model(object())
|
||||
clip = object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.lora_loader.get_lora_info_absolute",
|
||||
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
|
||||
)
|
||||
|
||||
load_calls = []
|
||||
|
||||
def mock_load_torch_file(path, safe_load=True):
|
||||
load_calls.append((path, safe_load))
|
||||
return {"path": path}
|
||||
|
||||
def mock_load_lora_for_models(model_arg, clip_arg, lora_arg, model_strength, clip_strength):
|
||||
return model_arg, clip_arg
|
||||
|
||||
monkeypatch.setattr("comfy.utils.load_torch_file", mock_load_torch_file)
|
||||
monkeypatch.setattr("comfy.sd.load_lora_for_models", mock_load_lora_for_models)
|
||||
|
||||
result_model, result_clip, trigger_words, loaded_loras = loader.load_loras(
|
||||
model,
|
||||
"",
|
||||
clip=clip,
|
||||
loras={
|
||||
"__value__": [
|
||||
{"active": True, "name": "demo", "strength": 0.75, "clipStrength": 0.5},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert result_model is model
|
||||
assert result_clip is clip
|
||||
assert load_calls == [("/abs/demo.safetensors", True)]
|
||||
assert trigger_words == "demo_trigger"
|
||||
assert loaded_loras == "<lora:demo:0.75:0.5>"
|
||||
|
||||
|
||||
def test_lora_loader_formats_widget_lora_names_with_colons(monkeypatch):
|
||||
loader = LoraLoaderLM()
|
||||
model = _Model(object())
|
||||
clip = object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.lora_loader.get_lora_info_absolute",
|
||||
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
|
||||
)
|
||||
monkeypatch.setattr("comfy.utils.load_torch_file", lambda path, safe_load=True: {"path": path})
|
||||
monkeypatch.setattr(
|
||||
"comfy.sd.load_lora_for_models",
|
||||
lambda model_arg, clip_arg, lora_arg, model_strength, clip_strength: (model_arg, clip_arg),
|
||||
)
|
||||
|
||||
_, _, trigger_words, loaded_loras = loader.load_loras(
|
||||
model,
|
||||
"",
|
||||
clip=clip,
|
||||
loras={
|
||||
"__value__": [
|
||||
{"active": True, "name": "demo:variant", "strength": 0.75, "clipStrength": 0.5},
|
||||
{"active": True, "name": "demo:single", "strength": 0.3},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert trigger_words == "demo:variant_trigger,, demo:single_trigger"
|
||||
assert loaded_loras == "<lora:demo:variant:0.75:0.5> <lora:demo:single:0.3>"
|
||||
|
||||
|
||||
def test_lora_loader_flux_model_uses_flux_helper(monkeypatch):
|
||||
flux_model = _Model(type("ComfyFluxWrapper", (), {})())
|
||||
loader = LoraLoaderLM()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.lora_loader.get_lora_info_absolute",
|
||||
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
def mock_nunchaku_load_lora(model_arg, lora_name, strength):
|
||||
calls.append((lora_name, strength))
|
||||
return model_arg
|
||||
|
||||
monkeypatch.setattr("py.nodes.lora_loader.nunchaku_load_lora", mock_nunchaku_load_lora)
|
||||
|
||||
_, _, trigger_words, loaded_loras = loader.load_loras(
|
||||
flux_model,
|
||||
"",
|
||||
lora_stack=[("stack_lora.safetensors", 0.4, 0.2)],
|
||||
loras={"__value__": [{"active": True, "name": "widget_lora", "strength": 0.8}]},
|
||||
)
|
||||
|
||||
assert calls == [("stack_lora.safetensors", 0.4), ("/abs/widget_lora.safetensors", 0.8)]
|
||||
assert trigger_words == "stack_lora_trigger,, widget_lora_trigger"
|
||||
assert loaded_loras == "<lora:stack_lora:0.4> <lora:widget_lora:0.8>"
|
||||
|
||||
|
||||
def test_lora_loader_qwen_model_batches_loras(monkeypatch):
|
||||
qwen_model = _Model(type("NunchakuQwenImageTransformer2DModel", (), {})())
|
||||
loader = LoraLoaderLM()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.lora_loader.get_lora_info_absolute",
|
||||
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
|
||||
)
|
||||
|
||||
batched_calls = []
|
||||
|
||||
def mock_nunchaku_load_qwen_loras(model_arg, lora_configs):
|
||||
batched_calls.append((model_arg, lora_configs))
|
||||
return model_arg
|
||||
|
||||
monkeypatch.setattr("py.nodes.lora_loader._get_nunchaku_load_qwen_loras", lambda: mock_nunchaku_load_qwen_loras)
|
||||
|
||||
_, result_clip, trigger_words, loaded_loras = loader.load_loras(
|
||||
qwen_model,
|
||||
"",
|
||||
clip="clip",
|
||||
lora_stack=[("stack_qwen.safetensors", 0.6, 0.1)],
|
||||
loras={"__value__": [{"active": True, "name": "widget_qwen", "strength": 0.9, "clipStrength": 0.3}]},
|
||||
)
|
||||
|
||||
assert result_clip == "clip"
|
||||
assert len(batched_calls) == 1
|
||||
assert batched_calls[0][0] is qwen_model
|
||||
assert batched_calls[0][1] == [
|
||||
("/abs/stack_qwen.safetensors", 0.6),
|
||||
("/abs/widget_qwen.safetensors", 0.9),
|
||||
]
|
||||
assert trigger_words == "stack_qwen_trigger,, widget_qwen_trigger"
|
||||
assert loaded_loras == "<lora:stack_qwen:0.6> <lora:widget_qwen:0.9>"
|
||||
|
||||
|
||||
def test_lora_text_loader_qwen_batches_text_and_stack(monkeypatch):
|
||||
qwen_model = _Model(type("NunchakuQwenImageTransformer2DModel", (), {})())
|
||||
loader = LoraTextLoaderLM()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.lora_loader.get_lora_info_absolute",
|
||||
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
|
||||
)
|
||||
|
||||
batched_calls = []
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.lora_loader._get_nunchaku_load_qwen_loras",
|
||||
lambda: (lambda model_arg, lora_configs: batched_calls.append(lora_configs) or model_arg),
|
||||
)
|
||||
|
||||
_, _, trigger_words, loaded_loras = loader.load_loras_from_text(
|
||||
qwen_model,
|
||||
"<lora:text_qwen:1.2:0.4>",
|
||||
clip="clip",
|
||||
lora_stack=[("stack_qwen.safetensors", 0.6, 0.1)],
|
||||
)
|
||||
|
||||
assert batched_calls == [[("/abs/stack_qwen.safetensors", 0.6), ("/abs/text_qwen.safetensors", 1.2)]]
|
||||
assert trigger_words == "stack_qwen_trigger,, text_qwen_trigger"
|
||||
assert loaded_loras == "<lora:stack_qwen:0.6> <lora:text_qwen:1.2>"
|
||||
|
||||
|
||||
def test_lora_loader_qwen_model_raises_clear_error_when_helper_import_fails(monkeypatch):
|
||||
qwen_model = _Model(type("NunchakuQwenImageTransformer2DModel", (), {})())
|
||||
loader = LoraLoaderLM()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.lora_loader.get_lora_info_absolute",
|
||||
lambda name: (f"/abs/{name}.safetensors", [f"{name}_trigger"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.lora_loader._get_nunchaku_load_qwen_loras",
|
||||
lambda: (_ for _ in ()).throw( # pragma: no branch
|
||||
RuntimeError("Qwen-Image LoRA loading requires the ComfyUI runtime with its torch dependency available.")
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Qwen-Image LoRA loading requires the ComfyUI runtime"):
|
||||
loader.load_loras(
|
||||
qwen_model,
|
||||
"",
|
||||
lora_stack=[("stack_qwen.safetensors", 0.6, 0.1)],
|
||||
)
|
||||
51
tests/nodes/test_lora_stack_combiner.py
Normal file
51
tests/nodes/test_lora_stack_combiner.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from py.nodes.lora_stack_combiner import LoraStackCombinerLM
|
||||
|
||||
|
||||
def test_combine_stacks_preserves_order():
|
||||
node = LoraStackCombinerLM()
|
||||
stack_a = [
|
||||
("folder/a.safetensors", 0.7, 0.6),
|
||||
("folder/b.safetensors", 0.8, 0.8),
|
||||
]
|
||||
stack_b = [
|
||||
("folder/c.safetensors", 1.0, 0.9),
|
||||
]
|
||||
|
||||
(combined_stack,) = node.combine_stacks(stack_a, stack_b)
|
||||
|
||||
assert combined_stack == stack_a + stack_b
|
||||
|
||||
|
||||
def test_combine_stacks_returns_second_when_first_empty():
|
||||
node = LoraStackCombinerLM()
|
||||
stack_b = [("folder/c.safetensors", 1.0, 0.9)]
|
||||
|
||||
(combined_stack,) = node.combine_stacks([], stack_b)
|
||||
|
||||
assert combined_stack == stack_b
|
||||
|
||||
|
||||
def test_combine_stacks_returns_first_when_second_empty():
|
||||
node = LoraStackCombinerLM()
|
||||
stack_a = [("folder/a.safetensors", 0.7, 0.6)]
|
||||
|
||||
(combined_stack,) = node.combine_stacks(stack_a, [])
|
||||
|
||||
assert combined_stack == stack_a
|
||||
|
||||
|
||||
def test_combine_stacks_returns_empty_when_both_empty():
|
||||
node = LoraStackCombinerLM()
|
||||
|
||||
(combined_stack,) = node.combine_stacks([], [])
|
||||
|
||||
assert combined_stack == []
|
||||
|
||||
|
||||
def test_combine_stacks_allows_duplicate_entries():
|
||||
node = LoraStackCombinerLM()
|
||||
duplicate_entry = ("folder/shared.safetensors", 0.9, 0.5)
|
||||
|
||||
(combined_stack,) = node.combine_stacks([duplicate_entry], [duplicate_entry])
|
||||
|
||||
assert combined_stack == [duplicate_entry, duplicate_entry]
|
||||
153
tests/nodes/test_save_image.py
Normal file
153
tests/nodes/test_save_image.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
from py.nodes.save_image import SaveImageLM
|
||||
|
||||
|
||||
class _DummyTensor:
|
||||
def __init__(self, array):
|
||||
self._array = array
|
||||
self.shape = array.shape
|
||||
|
||||
def cpu(self):
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
return self._array
|
||||
|
||||
|
||||
def _make_image():
|
||||
return _DummyTensor(
|
||||
np.array(
|
||||
[
|
||||
[[0.0, 0.1, 0.2], [0.3, 0.4, 0.5]],
|
||||
[[0.6, 0.7, 0.8], [0.9, 1.0, 0.0]],
|
||||
],
|
||||
dtype="float32",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _configure_save_paths(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("folder_paths.get_output_directory", lambda: str(tmp_path), raising=False)
|
||||
monkeypatch.setattr(
|
||||
"folder_paths.get_save_image_path",
|
||||
lambda *_args, **_kwargs: (str(tmp_path), "sample", 1, "", "sample"),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _configure_metadata(monkeypatch, metadata_dict):
|
||||
monkeypatch.setattr("py.nodes.save_image.get_metadata", lambda: {"raw": "metadata"})
|
||||
monkeypatch.setattr(
|
||||
"py.nodes.save_image.MetadataProcessor.to_dict",
|
||||
lambda raw_metadata, node_id: metadata_dict,
|
||||
)
|
||||
|
||||
|
||||
def test_save_image_defaults_to_writing_png_metadata(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||
|
||||
node = SaveImageLM()
|
||||
node.save_images([_make_image()], "ComfyUI", "png", id="node-1")
|
||||
|
||||
image_path = tmp_path / "sample_00001_.png"
|
||||
with Image.open(image_path) as img:
|
||||
assert img.info["parameters"] == "prompt text\nSeed: 123"
|
||||
|
||||
|
||||
def test_save_image_skips_png_parameters_when_metadata_disabled_and_keeps_workflow(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||
|
||||
node = SaveImageLM()
|
||||
workflow = {"nodes": [{"id": 1}]}
|
||||
node.save_images(
|
||||
[_make_image()],
|
||||
"ComfyUI",
|
||||
"png",
|
||||
id="node-1",
|
||||
embed_workflow=True,
|
||||
extra_pnginfo={"workflow": workflow},
|
||||
save_with_metadata=False,
|
||||
)
|
||||
|
||||
image_path = tmp_path / "sample_00001_.png"
|
||||
with Image.open(image_path) as img:
|
||||
assert "parameters" not in img.info
|
||||
assert img.info["workflow"] == json.dumps(workflow)
|
||||
|
||||
|
||||
def test_save_image_skips_jpeg_metadata_when_disabled(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||
|
||||
node = SaveImageLM()
|
||||
node.save_images(
|
||||
[_make_image()],
|
||||
"ComfyUI",
|
||||
"jpeg",
|
||||
id="node-1",
|
||||
save_with_metadata=False,
|
||||
)
|
||||
|
||||
image_path = tmp_path / "sample_00001_.jpg"
|
||||
exif_dict = piexif.load(str(image_path))
|
||||
assert piexif.ExifIFD.UserComment not in exif_dict.get("Exif", {})
|
||||
|
||||
|
||||
def test_save_image_skips_webp_metadata_when_disabled(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||
|
||||
node = SaveImageLM()
|
||||
node.save_images(
|
||||
[_make_image()],
|
||||
"ComfyUI",
|
||||
"webp",
|
||||
id="node-1",
|
||||
save_with_metadata=False,
|
||||
)
|
||||
|
||||
image_path = tmp_path / "sample_00001_.webp"
|
||||
exif_dict = piexif.load(str(image_path))
|
||||
assert piexif.ExifIFD.UserComment not in exif_dict.get("Exif", {})
|
||||
|
||||
|
||||
def test_process_image_returns_passthrough_result_and_ui_images(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||
|
||||
images = [_make_image()]
|
||||
node = SaveImageLM()
|
||||
|
||||
result = node.process_image(images, id="node-1")
|
||||
|
||||
assert result["result"] == (images,)
|
||||
assert result["ui"] == {
|
||||
"images": [{"filename": "sample_00001_.png", "subfolder": "", "type": "output"}]
|
||||
}
|
||||
|
||||
|
||||
def test_process_image_returns_empty_ui_images_when_save_fails(monkeypatch, tmp_path):
|
||||
_configure_save_paths(monkeypatch, tmp_path)
|
||||
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
|
||||
|
||||
def _raise_save_error(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(Image.Image, "save", _raise_save_error)
|
||||
|
||||
images = [_make_image()]
|
||||
node = SaveImageLM()
|
||||
|
||||
result = node.process_image(images, id="node-1")
|
||||
|
||||
assert result["result"] == (images,)
|
||||
assert result["ui"] == {"images": []}
|
||||
@@ -1,6 +1,8 @@
|
||||
# serializer version: 1
|
||||
# name: TestModelLibraryHandlerSnapshots.test_check_model_exists_empty_response
|
||||
dict({
|
||||
'downloadedVersionIds': list([
|
||||
]),
|
||||
'modelType': None,
|
||||
'success': True,
|
||||
'versions': list([
|
||||
|
||||
@@ -66,6 +66,27 @@ class FakePromptServer:
|
||||
instance = Instance()
|
||||
|
||||
|
||||
class FakeDownloadHistoryService:
|
||||
async def has_been_downloaded(self, _model_type, _version_id):
|
||||
return False
|
||||
|
||||
async def get_downloaded_version_ids(self, _model_type, _model_id):
|
||||
return []
|
||||
|
||||
async def get_downloaded_version_ids_bulk(self, _model_type, _model_ids):
|
||||
return {}
|
||||
|
||||
async def mark_downloaded(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def mark_not_downloaded(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
async def fake_download_history_service_factory():
|
||||
return FakeDownloadHistoryService()
|
||||
|
||||
|
||||
class TestSettingsHandlerSnapshots:
|
||||
"""Snapshot tests for SettingsHandler responses."""
|
||||
|
||||
@@ -223,6 +244,7 @@ class TestModelLibraryHandlerSnapshots:
|
||||
get_lora_scanner=scanner_factory,
|
||||
get_checkpoint_scanner=scanner_factory,
|
||||
get_embedding_scanner=scanner_factory,
|
||||
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||
),
|
||||
metadata_provider_factory=lambda: None,
|
||||
)
|
||||
|
||||
@@ -23,9 +23,10 @@ from py.routes.misc_routes import MiscRoutes
|
||||
|
||||
|
||||
class FakeRequest:
|
||||
def __init__(self, *, json_data=None, query=None):
|
||||
def __init__(self, *, json_data=None, query=None, method="POST"):
|
||||
self._json_data = json_data or {}
|
||||
self.query = query or {}
|
||||
self.method = method
|
||||
|
||||
async def json(self):
|
||||
return self._json_data
|
||||
@@ -438,6 +439,46 @@ async def fake_metadata_archive_manager_factory():
|
||||
return FakeMetadataArchiveManager()
|
||||
|
||||
|
||||
class FakeDownloadHistoryService:
|
||||
def __init__(self, downloaded_by_type=None):
|
||||
self.downloaded_by_type = downloaded_by_type or {}
|
||||
self.marked_downloaded: list[tuple] = []
|
||||
self.marked_not_downloaded: list[tuple] = []
|
||||
|
||||
async def has_been_downloaded(self, model_type, version_id):
|
||||
return version_id in self.downloaded_by_type.get(model_type, set())
|
||||
|
||||
async def get_downloaded_version_ids(self, model_type, model_id):
|
||||
entries = self.downloaded_by_type.get(model_type, {})
|
||||
if isinstance(entries, dict):
|
||||
return sorted(entries.get(model_id, set()))
|
||||
return []
|
||||
|
||||
async def get_downloaded_version_ids_bulk(self, model_type, model_ids):
|
||||
entries = self.downloaded_by_type.get(model_type, {})
|
||||
if not isinstance(entries, dict):
|
||||
return {}
|
||||
return {
|
||||
model_id: set(entries.get(model_id, set()))
|
||||
for model_id in model_ids
|
||||
if model_id in entries
|
||||
}
|
||||
|
||||
async def mark_downloaded(
|
||||
self, model_type, version_id, *, model_id=None, source="manual", file_path=None
|
||||
):
|
||||
self.marked_downloaded.append(
|
||||
(model_type, version_id, model_id, source, file_path)
|
||||
)
|
||||
|
||||
async def mark_not_downloaded(self, model_type, version_id):
|
||||
self.marked_not_downloaded.append((model_type, version_id))
|
||||
|
||||
|
||||
async def fake_download_history_service_factory():
|
||||
return FakeDownloadHistoryService()
|
||||
|
||||
|
||||
class RecordingRegistrar:
|
||||
def __init__(self, _app):
|
||||
self.registered_mapping = None
|
||||
@@ -452,6 +493,7 @@ async def test_misc_routes_bind_produces_expected_handlers():
|
||||
get_lora_scanner=fake_scanner_factory,
|
||||
get_checkpoint_scanner=fake_scanner_factory,
|
||||
get_embedding_scanner=fake_scanner_factory,
|
||||
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||
)
|
||||
|
||||
recorded_registrars = []
|
||||
@@ -578,6 +620,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
||||
get_lora_scanner=lora_factory,
|
||||
get_checkpoint_scanner=checkpoint_factory,
|
||||
get_embedding_scanner=embedding_factory,
|
||||
get_downloaded_version_history_service=lambda: fake_download_history_service_factory(),
|
||||
),
|
||||
metadata_provider_factory=provider_factory,
|
||||
)
|
||||
@@ -600,6 +643,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
||||
"baseModel": "Flux.1",
|
||||
"thumbnailUrl": "http://example.com/a1.jpg",
|
||||
"inLibrary": False,
|
||||
"hasBeenDownloaded": False,
|
||||
},
|
||||
{
|
||||
"modelId": 1,
|
||||
@@ -611,6 +655,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
||||
"baseModel": "Flux.1",
|
||||
"thumbnailUrl": "http://example.com/a2.jpg",
|
||||
"inLibrary": True,
|
||||
"hasBeenDownloaded": False,
|
||||
},
|
||||
{
|
||||
"modelId": 2,
|
||||
@@ -622,6 +667,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
||||
"baseModel": None,
|
||||
"thumbnailUrl": "http://example.com/e1.jpg",
|
||||
"inLibrary": False,
|
||||
"hasBeenDownloaded": False,
|
||||
},
|
||||
{
|
||||
"modelId": 2,
|
||||
@@ -633,6 +679,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
||||
"baseModel": None,
|
||||
"thumbnailUrl": None,
|
||||
"inLibrary": True,
|
||||
"hasBeenDownloaded": False,
|
||||
},
|
||||
{
|
||||
"modelId": 3,
|
||||
@@ -644,6 +691,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
||||
"baseModel": "SDXL",
|
||||
"thumbnailUrl": None,
|
||||
"inLibrary": False,
|
||||
"hasBeenDownloaded": False,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -692,6 +740,7 @@ async def test_get_civitai_user_models_rewrites_civitai_previews():
|
||||
get_lora_scanner=fake_scanner_factory,
|
||||
get_checkpoint_scanner=fake_scanner_factory,
|
||||
get_embedding_scanner=fake_scanner_factory,
|
||||
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||
),
|
||||
metadata_provider_factory=provider_factory,
|
||||
)
|
||||
@@ -727,6 +776,7 @@ async def test_get_civitai_user_models_requires_username():
|
||||
get_lora_scanner=fake_scanner_factory,
|
||||
get_checkpoint_scanner=fake_scanner_factory,
|
||||
get_embedding_scanner=fake_scanner_factory,
|
||||
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||
),
|
||||
metadata_provider_factory=provider_factory,
|
||||
)
|
||||
@@ -760,6 +810,7 @@ def test_ensure_handler_mapping_caches_result():
|
||||
get_lora_scanner=fake_scanner_factory,
|
||||
get_checkpoint_scanner=fake_scanner_factory,
|
||||
get_embedding_scanner=fake_scanner_factory,
|
||||
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||
),
|
||||
metadata_provider_factory=fake_metadata_provider_factory,
|
||||
metadata_archive_manager_factory=fake_metadata_archive_manager_factory,
|
||||
@@ -802,6 +853,7 @@ async def test_check_model_exists_returns_local_versions():
|
||||
get_lora_scanner=lora_factory,
|
||||
get_checkpoint_scanner=checkpoint_factory,
|
||||
get_embedding_scanner=embedding_factory,
|
||||
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||
),
|
||||
metadata_provider_factory=fake_metadata_provider_factory,
|
||||
)
|
||||
@@ -811,10 +863,139 @@ async def test_check_model_exists_returns_local_versions():
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["modelType"] == "lora"
|
||||
assert payload["versions"] == versions
|
||||
assert payload["versions"] == [
|
||||
{"versionId": 11, "name": "v1", "fileName": "model-one", "hasBeenDownloaded": True},
|
||||
{"versionId": 12, "name": "v2", "fileName": "model-two", "hasBeenDownloaded": True},
|
||||
]
|
||||
assert lora_scanner.version_calls == [5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_model_exists_model_id_only_does_not_call_metadata_provider():
|
||||
async def metadata_provider_factory():
|
||||
raise AssertionError("metadata provider should not be called for modelId-only checks")
|
||||
|
||||
handler = ModelLibraryHandler(
|
||||
ServiceRegistryAdapter(
|
||||
get_lora_scanner=fake_scanner_factory,
|
||||
get_checkpoint_scanner=fake_scanner_factory,
|
||||
get_embedding_scanner=fake_scanner_factory,
|
||||
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||
),
|
||||
metadata_provider_factory=metadata_provider_factory,
|
||||
)
|
||||
|
||||
response = await handler.check_model_exists(FakeRequest(query={"modelId": "5"}))
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload == {
|
||||
"success": True,
|
||||
"modelType": None,
|
||||
"versions": [],
|
||||
"downloadedVersionIds": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_model_exists_returns_download_history_when_file_missing():
|
||||
history_service = FakeDownloadHistoryService({"checkpoint": {999}})
|
||||
|
||||
async def history_factory():
|
||||
return history_service
|
||||
|
||||
handler = ModelLibraryHandler(
|
||||
ServiceRegistryAdapter(
|
||||
get_lora_scanner=fake_scanner_factory,
|
||||
get_checkpoint_scanner=fake_scanner_factory,
|
||||
get_embedding_scanner=fake_scanner_factory,
|
||||
get_downloaded_version_history_service=history_factory,
|
||||
),
|
||||
metadata_provider_factory=fake_metadata_provider_factory,
|
||||
)
|
||||
|
||||
response = await handler.check_model_exists(
|
||||
FakeRequest(query={"modelId": "5", "modelVersionId": "999"})
|
||||
)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert payload == {
|
||||
"success": True,
|
||||
"exists": False,
|
||||
"modelType": "checkpoint",
|
||||
"hasBeenDownloaded": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_version_download_status_endpoints():
|
||||
history_service = FakeDownloadHistoryService({"lora": {123}})
|
||||
|
||||
async def history_factory():
|
||||
return history_service
|
||||
|
||||
handler = ModelLibraryHandler(
|
||||
ServiceRegistryAdapter(
|
||||
get_lora_scanner=fake_scanner_factory,
|
||||
get_checkpoint_scanner=fake_scanner_factory,
|
||||
get_embedding_scanner=fake_scanner_factory,
|
||||
get_downloaded_version_history_service=history_factory,
|
||||
),
|
||||
metadata_provider_factory=fake_metadata_provider_factory,
|
||||
)
|
||||
|
||||
get_response = await handler.get_model_version_download_status(
|
||||
FakeRequest(query={"modelType": "lora", "modelVersionId": "123"})
|
||||
)
|
||||
get_payload = json.loads(get_response.text)
|
||||
assert get_payload == {
|
||||
"success": True,
|
||||
"modelType": "lora",
|
||||
"modelVersionId": 123,
|
||||
"hasBeenDownloaded": True,
|
||||
}
|
||||
|
||||
set_response = await handler.set_model_version_download_status(
|
||||
FakeRequest(
|
||||
json_data={
|
||||
"modelType": "checkpoint",
|
||||
"modelVersionId": 456,
|
||||
"modelId": 78,
|
||||
"downloaded": True,
|
||||
"filePath": "/tmp/model.safetensors",
|
||||
}
|
||||
)
|
||||
)
|
||||
set_payload = json.loads(set_response.text)
|
||||
assert set_payload == {
|
||||
"success": True,
|
||||
"modelType": "checkpoint",
|
||||
"modelVersionId": 456,
|
||||
"hasBeenDownloaded": True,
|
||||
}
|
||||
assert history_service.marked_downloaded == [
|
||||
("checkpoint", 456, 78, "manual", "/tmp/model.safetensors")
|
||||
]
|
||||
|
||||
set_get_response = await handler.set_model_version_download_status(
|
||||
FakeRequest(
|
||||
method="GET",
|
||||
query={
|
||||
"modelType": "embedding",
|
||||
"modelVersionId": "789",
|
||||
"modelId": "12",
|
||||
"downloaded": "false",
|
||||
},
|
||||
)
|
||||
)
|
||||
set_get_payload = json.loads(set_get_response.text)
|
||||
assert set_get_payload == {
|
||||
"success": True,
|
||||
"modelType": "embedding",
|
||||
"modelVersionId": 789,
|
||||
"hasBeenDownloaded": False,
|
||||
}
|
||||
|
||||
|
||||
def test_create_handler_set_uses_provided_dependencies():
|
||||
recorded_handlers: list[dict] = []
|
||||
|
||||
@@ -845,6 +1026,7 @@ def test_create_handler_set_uses_provided_dependencies():
|
||||
get_lora_scanner=fake_scanner_factory,
|
||||
get_checkpoint_scanner=fake_scanner_factory,
|
||||
get_embedding_scanner=fake_scanner_factory,
|
||||
get_downloaded_version_history_service=fake_download_history_service_factory,
|
||||
),
|
||||
metadata_provider_factory=fake_metadata_provider_factory,
|
||||
metadata_archive_manager_factory=fake_metadata_archive_manager_factory,
|
||||
|
||||
@@ -43,6 +43,9 @@ class StubRecipeScanner:
|
||||
self.cached_raw: List[Dict[str, Any]] = []
|
||||
self.recipes: Dict[str, Dict[str, Any]] = {}
|
||||
self.removed: List[str] = []
|
||||
self.last_paginated_params: Dict[str, Any] | None = None
|
||||
self.lora_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
|
||||
return None
|
||||
@@ -56,6 +59,7 @@ class StubRecipeScanner:
|
||||
return SimpleNamespace(raw_data=list(self.cached_raw))
|
||||
|
||||
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]:
|
||||
self.last_paginated_params = params
|
||||
items = [dict(item) for item in self.listing_items]
|
||||
page = int(params.get("page", 1))
|
||||
page_size = int(params.get("page_size", 20))
|
||||
@@ -70,6 +74,14 @@ class StubRecipeScanner:
|
||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
return self.recipes.get(recipe_id)
|
||||
|
||||
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
|
||||
return list(self.lora_lookup.get(lora_hash.lower(), []))
|
||||
|
||||
async def get_recipes_for_checkpoint(
|
||||
self, checkpoint_hash: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
return list(self.checkpoint_lookup.get(checkpoint_hash.lower(), []))
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
||||
return str(candidate) if candidate.exists() else None
|
||||
@@ -132,6 +144,7 @@ class StubPersistenceService:
|
||||
self.save_calls: List[Dict[str, Any]] = []
|
||||
self.delete_calls: List[str] = []
|
||||
self.move_calls: List[Dict[str, str]] = []
|
||||
self.update_calls: List[Dict[str, Any]] = []
|
||||
self.save_result = SimpleNamespace(
|
||||
payload={"success": True, "recipe_id": "stub-id"}, status=200
|
||||
)
|
||||
@@ -182,7 +195,14 @@ class StubPersistenceService:
|
||||
|
||||
async def update_recipe(
|
||||
self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]
|
||||
) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
||||
) -> SimpleNamespace:
|
||||
self.update_calls.append(
|
||||
{
|
||||
"recipe_scanner": recipe_scanner,
|
||||
"recipe_id": recipe_id,
|
||||
"updates": updates,
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={"success": True, "recipe_id": recipe_id, "updates": updates},
|
||||
status=200,
|
||||
@@ -342,6 +362,47 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N
|
||||
assert payload["items"][0]["loras"] == []
|
||||
|
||||
|
||||
async def test_list_recipes_passes_checkpoint_hash_filter(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get("/api/lm/recipes?checkpoint_hash=ckpt123")
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["items"] == []
|
||||
assert harness.scanner.last_paginated_params["checkpoint_hash"] == "ckpt123"
|
||||
|
||||
|
||||
async def test_get_recipes_for_checkpoint(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.checkpoint_lookup["abc123"] = [
|
||||
{"id": "recipe-1", "title": "Linked recipe"}
|
||||
]
|
||||
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipes/for-checkpoint?hash=ABC123"
|
||||
)
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert payload == {
|
||||
"success": True,
|
||||
"recipes": [{"id": "recipe-1", "title": "Linked recipe"}],
|
||||
}
|
||||
|
||||
|
||||
async def test_get_recipes_for_checkpoint_requires_hash(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get("/api/lm/recipes/for-checkpoint")
|
||||
payload = await response.json()
|
||||
|
||||
assert response.status == 400
|
||||
assert payload["success"] is False
|
||||
|
||||
|
||||
async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
form = FormData()
|
||||
@@ -509,6 +570,33 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(
|
||||
assert provider_calls == ["77"]
|
||||
|
||||
|
||||
async def test_update_recipe_accepts_gen_params(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
payload = {
|
||||
"gen_params": {
|
||||
"prompt": "updated prompt",
|
||||
"negative_prompt": "updated negative",
|
||||
"steps": 30,
|
||||
}
|
||||
}
|
||||
|
||||
response = await harness.client.put(
|
||||
"/api/lm/recipe/recipe-42/update",
|
||||
json=payload,
|
||||
)
|
||||
data = await response.json()
|
||||
|
||||
assert response.status == 200
|
||||
assert data["success"] is True
|
||||
assert harness.persistence.update_calls == [
|
||||
{
|
||||
"recipe_scanner": harness.scanner,
|
||||
"recipe_id": "recipe-42",
|
||||
"updates": payload,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||
async def fake_get_default_metadata_provider():
|
||||
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
||||
|
||||
133
tests/services/test_civitai_base_model_service.py
Normal file
133
tests/services/test_civitai_base_model_service.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Tests for CivitaiBaseModelService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from py.services.civitai_base_model_service import CivitaiBaseModelService
|
||||
|
||||
|
||||
class TestCivitaiBaseModelService:
|
||||
"""Test suite for CivitaiBaseModelService."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_service(self):
|
||||
"""Create a fresh service instance for each test."""
|
||||
self.service = CivitaiBaseModelService()
|
||||
# Reset cache
|
||||
self.service._cache = None
|
||||
self.service._cache_timestamp = None
|
||||
yield
|
||||
|
||||
def test_generate_abbreviation_known_models(self):
|
||||
"""Test abbreviation generation for known models."""
|
||||
test_cases = [
|
||||
("SD 1.5", "SD1"),
|
||||
("SDXL 1.0", "XL"),
|
||||
("Flux.1 D", "F1D"),
|
||||
("Wan Video 2.5 T2V", "WAN"),
|
||||
("Pony V7", "PNY7"),
|
||||
("CogVideoX", "CVX"),
|
||||
("Mochi", "MCHI"),
|
||||
("Anima", "ANI"),
|
||||
]
|
||||
|
||||
for model_name, expected in test_cases:
|
||||
result = self.service.generate_abbreviation(model_name)
|
||||
assert result == expected, (
|
||||
f"Failed for {model_name}: got {result}, expected {expected}"
|
||||
)
|
||||
|
||||
def test_generate_abbreviation_unknown_models(self):
|
||||
"""Test abbreviation generation for unknown models."""
|
||||
result = self.service.generate_abbreviation("New Model 2.0")
|
||||
assert len(result) <= 4
|
||||
assert result.isupper()
|
||||
|
||||
def test_generate_abbreviation_edge_cases(self):
|
||||
"""Test abbreviation generation edge cases."""
|
||||
assert self.service.generate_abbreviation("") == "OTH"
|
||||
assert self.service.generate_abbreviation(None) == "OTH"
|
||||
|
||||
def test_cache_status_no_cache(self):
|
||||
"""Test cache status when no cache exists."""
|
||||
status = self.service.get_cache_status()
|
||||
|
||||
assert status["has_cache"] is False
|
||||
assert status["last_updated"] is None
|
||||
assert status["is_expired"] is True
|
||||
assert status["age_seconds"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_base_models_fallback(self):
|
||||
"""Test that fallback to hardcoded models works."""
|
||||
with patch.object(self.service, "_fetch_from_civitai", return_value=None):
|
||||
result = await self.service.get_base_models()
|
||||
|
||||
assert result["source"] == "fallback"
|
||||
assert len(result["models"]) > 0
|
||||
assert result["hardcoded_count"] > 0
|
||||
assert result["remote_count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_base_models_from_api(self):
|
||||
"""Test fetching models from API."""
|
||||
mock_models = {"SD 1.5", "SDXL 1.0", "New Model"}
|
||||
|
||||
with patch.object(
|
||||
self.service, "_fetch_from_civitai", return_value=mock_models
|
||||
):
|
||||
result = await self.service.get_base_models()
|
||||
|
||||
assert result["source"] == "api"
|
||||
assert result["remote_count"] == 3
|
||||
assert "New Model" in result["models"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_base_models_uses_cache(self):
|
||||
"""Test that cached data is used when available and not expired."""
|
||||
# First call - populate cache
|
||||
mock_models = {"SD 1.5", "SDXL 1.0"}
|
||||
with patch.object(
|
||||
self.service, "_fetch_from_civitai", return_value=mock_models
|
||||
):
|
||||
await self.service.get_base_models()
|
||||
|
||||
# Second call - should use cache
|
||||
with patch.object(self.service, "_fetch_from_civitai") as mock_fetch:
|
||||
result = await self.service.get_base_models()
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
assert result["source"] == "cache"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_cache(self):
|
||||
"""Test force refresh clears cache and fetches fresh data."""
|
||||
# Populate cache
|
||||
mock_models = {"SD 1.5"}
|
||||
with patch.object(
|
||||
self.service, "_fetch_from_civitai", return_value=mock_models
|
||||
):
|
||||
await self.service.get_base_models()
|
||||
|
||||
# Force refresh with different data
|
||||
new_models = {"SD 1.5", "SDXL 1.0", "New Model"}
|
||||
with patch.object(self.service, "_fetch_from_civitai", return_value=new_models):
|
||||
result = await self.service.refresh_cache()
|
||||
|
||||
assert result["source"] == "api"
|
||||
assert result["remote_count"] == 3
|
||||
|
||||
def test_get_model_categories(self):
|
||||
"""Test model categories are returned."""
|
||||
categories = self.service.get_model_categories()
|
||||
|
||||
assert "Stable Diffusion 1.x" in categories
|
||||
assert "Video Models" in categories
|
||||
assert "Flux Models" in categories
|
||||
assert "Other Models" in categories
|
||||
|
||||
# Check that video models include new additions
|
||||
video_models = categories["Video Models"]
|
||||
assert "CogVideoX" in video_models
|
||||
assert "Mochi" in video_models
|
||||
assert "Wan Video 2.5 T2V" in video_models
|
||||
@@ -184,7 +184,10 @@ async def test_parse_metadata_populates_checkpoint_and_rewrites_thumbnails(monke
|
||||
assert result["model"] is not None
|
||||
assert result["model"]["name"] == "Checkpoint Example"
|
||||
assert result["model"]["type"] == "checkpoint"
|
||||
assert result["model"]["thumbnailUrl"] == "https://image.civitai.com/checkpoints/width=450,optimized=true"
|
||||
assert (
|
||||
result["model"]["thumbnailUrl"]
|
||||
== "https://image.civitai.com/checkpoints/width=450,optimized=true"
|
||||
)
|
||||
assert result["model"]["modelId"] == 111
|
||||
assert result["model"]["size"] == 1024 * 1024
|
||||
assert result["model"]["hash"] == "ffaa0011"
|
||||
@@ -192,5 +195,106 @@ async def test_parse_metadata_populates_checkpoint_and_rewrites_thumbnails(monke
|
||||
|
||||
assert result["loras"]
|
||||
assert result["loras"][0]["name"] == "Example Lora Model"
|
||||
assert result["loras"][0]["thumbnailUrl"] == "https://image.civitai.com/loras/width=450,optimized=true"
|
||||
assert (
|
||||
result["loras"][0]["thumbnailUrl"]
|
||||
== "https://image.civitai.com/loras/width=450,optimized=true"
|
||||
)
|
||||
assert result["loras"][0]["hash"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_handles_modelVersionIds(monkeypatch):
|
||||
"""Test that modelVersionIds from Civitai image API are properly processed."""
|
||||
lora_info_1 = {
|
||||
"id": 2398829,
|
||||
"modelId": 123456,
|
||||
"model": {"name": "Dance LoRA 1", "type": "lora"},
|
||||
"name": "Version 1.0",
|
||||
"images": [{"url": "https://image.civitai.com/lora1/original=true"}],
|
||||
"baseModel": "SDXL",
|
||||
"downloadUrl": "https://civitai.com/lora1/download",
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"sizeKB": 10240,
|
||||
"name": "dance_lora_1.safetensors",
|
||||
"hashes": {"SHA256": "aabbccdd0011"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
lora_info_2 = {
|
||||
"id": 2398838,
|
||||
"modelId": 123457,
|
||||
"model": {"name": "Style LoRA 2", "type": "lora"},
|
||||
"name": "Version 2.0",
|
||||
"images": [{"url": "https://image.civitai.com/lora2/original=true"}],
|
||||
"baseModel": "SDXL",
|
||||
"downloadUrl": "https://civitai.com/lora2/download",
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"sizeKB": 20480,
|
||||
"name": "style_lora_2.safetensors",
|
||||
"hashes": {"SHA256": "aabbccdd0022"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def fake_metadata_provider():
|
||||
class Provider:
|
||||
async def get_model_version_info(self, version_id):
|
||||
if version_id == "2398829":
|
||||
return lora_info_1, None
|
||||
if version_id == "2398838":
|
||||
return lora_info_2, None
|
||||
return None, "Model not found"
|
||||
|
||||
return Provider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.recipes.parsers.civitai_image.get_default_metadata_provider",
|
||||
fake_metadata_provider,
|
||||
)
|
||||
|
||||
parser = CivitaiApiMetadataParser()
|
||||
|
||||
# This simulates the metadata from Civitai image API where modelVersionIds
|
||||
# is at the root level and meta only contains basic prompt info
|
||||
metadata = {
|
||||
"id": 109882763,
|
||||
"meta": {
|
||||
"id": 109882763,
|
||||
"meta": {"prompt": "A woman does the hip bump dance."},
|
||||
},
|
||||
"modelVersionIds": [2398829, 2398838],
|
||||
}
|
||||
|
||||
assert parser.is_metadata_matching(metadata)
|
||||
|
||||
result = await parser.parse_metadata(metadata)
|
||||
|
||||
# Verify both LoRAs were created from modelVersionIds
|
||||
assert len(result["loras"]) == 2
|
||||
|
||||
# Check first LoRA
|
||||
lora1 = result["loras"][0]
|
||||
assert lora1["id"] == 2398829
|
||||
assert lora1["name"] == "Dance LoRA 1"
|
||||
assert lora1["type"] == "lora"
|
||||
assert lora1["hash"] == "aabbccdd0011"
|
||||
assert lora1["baseModel"] == "SDXL"
|
||||
assert (
|
||||
lora1["thumbnailUrl"]
|
||||
== "https://image.civitai.com/lora1/width=450,optimized=true"
|
||||
)
|
||||
|
||||
# Check second LoRA
|
||||
lora2 = result["loras"][1]
|
||||
assert lora2["id"] == 2398838
|
||||
assert lora2["name"] == "Style LoRA 2"
|
||||
assert lora2["type"] == "lora"
|
||||
assert lora2["hash"] == "aabbccdd0022"
|
||||
assert lora2["baseModel"] == "SDXL"
|
||||
|
||||
@@ -38,6 +38,7 @@ def isolate_settings(monkeypatch, tmp_path):
|
||||
"embedding": "{base_model}/{first_tag}",
|
||||
},
|
||||
"base_model_path_mappings": {"BaseModel": "MappedModel"},
|
||||
"skip_previously_downloaded_model_versions": False,
|
||||
"download_skip_base_models": [],
|
||||
}
|
||||
)
|
||||
@@ -454,7 +455,7 @@ async def test_download_skips_excluded_base_model(monkeypatch, scanners, metadat
|
||||
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value={
|
||||
"id": 42,
|
||||
"id": 99,
|
||||
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||
"baseModel": "SDXL 1.0",
|
||||
"creator": {"username": "Author"},
|
||||
@@ -490,3 +491,104 @@ async def test_download_skips_excluded_base_model(monkeypatch, scanners, metadat
|
||||
assert "file.safetensors" in result["message"]
|
||||
execute_download.assert_not_called()
|
||||
assert manager._active_downloads[result["download_id"]]["status"] == "skipped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_skips_previously_downloaded_version(monkeypatch, scanners, metadata_provider):
|
||||
manager = DownloadManager()
|
||||
get_settings_manager().settings["skip_previously_downloaded_model_versions"] = True
|
||||
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value={
|
||||
"id": 42,
|
||||
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||
"baseModel": "SDXL 1.0",
|
||||
"creator": {"username": "Author"},
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||
"name": "file.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
history_service = AsyncMock()
|
||||
history_service.has_been_downloaded = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_downloaded_version_history_service",
|
||||
AsyncMock(return_value=history_service),
|
||||
)
|
||||
|
||||
execute_download = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=99,
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["skipped"] is True
|
||||
assert result["status"] == "skipped"
|
||||
assert result["reason"] == "previously_downloaded_version"
|
||||
assert result["model_version_id"] == 99
|
||||
assert result["file_name"] == "file.safetensors"
|
||||
history_service.has_been_downloaded.assert_awaited_once_with("lora", 99)
|
||||
execute_download.assert_not_called()
|
||||
assert manager._active_downloads[result["download_id"]]["status"] == "skipped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_proceeds_when_history_skip_disabled(monkeypatch, scanners, metadata_provider):
|
||||
manager = DownloadManager()
|
||||
get_settings_manager().settings["skip_previously_downloaded_model_versions"] = False
|
||||
|
||||
metadata_provider.get_model_version = AsyncMock(
|
||||
return_value={
|
||||
"id": 42,
|
||||
"model": {"type": "LoRA", "tags": ["fantasy"]},
|
||||
"baseModel": "SDXL 1.0",
|
||||
"creator": {"username": "Author"},
|
||||
"files": [
|
||||
{
|
||||
"type": "Model",
|
||||
"primary": True,
|
||||
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||
"name": "file.safetensors",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
history_service = AsyncMock()
|
||||
history_service.has_been_downloaded = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
ServiceRegistry,
|
||||
"get_downloaded_version_history_service",
|
||||
AsyncMock(return_value=history_service),
|
||||
)
|
||||
|
||||
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
|
||||
monkeypatch.setattr(
|
||||
DownloadManager, "_execute_download", execute_download, raising=False
|
||||
)
|
||||
|
||||
result = await manager.download_from_civitai(
|
||||
model_version_id=99,
|
||||
use_default_paths=True,
|
||||
progress_callback=None,
|
||||
source=None,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result.get("skipped") is not True
|
||||
history_service.has_been_downloaded.assert_not_called()
|
||||
execute_download.assert_awaited_once()
|
||||
|
||||
70
tests/services/test_downloaded_version_history_service.py
Normal file
70
tests/services/test_downloaded_version_history_service.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.downloaded_version_history_service import (
|
||||
DownloadedVersionHistoryService,
|
||||
)
|
||||
|
||||
|
||||
class DummySettings:
|
||||
def get_active_library_name(self) -> str:
|
||||
return "alpha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_history_roundtrip_and_manual_override(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "download-history.sqlite"
|
||||
service = DownloadedVersionHistoryService(
|
||||
str(db_path),
|
||||
settings_manager=DummySettings(),
|
||||
)
|
||||
|
||||
await service.mark_downloaded(
|
||||
"lora",
|
||||
101,
|
||||
model_id=11,
|
||||
source="scan",
|
||||
file_path="/models/a.safetensors",
|
||||
)
|
||||
assert await service.has_been_downloaded("lora", 101) is True
|
||||
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||
|
||||
await service.mark_not_downloaded("lora", 101)
|
||||
assert await service.has_been_downloaded("lora", 101) is False
|
||||
assert await service.get_downloaded_version_ids("lora", 11) == []
|
||||
|
||||
await service.mark_downloaded(
|
||||
"lora",
|
||||
101,
|
||||
model_id=11,
|
||||
source="download",
|
||||
file_path="/models/a.safetensors",
|
||||
)
|
||||
assert await service.has_been_downloaded("lora", 101) is True
|
||||
assert await service.get_downloaded_version_ids("lora", 11) == [101]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_history_bulk_lookup(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "download-history.sqlite"
|
||||
service = DownloadedVersionHistoryService(
|
||||
str(db_path),
|
||||
settings_manager=DummySettings(),
|
||||
)
|
||||
|
||||
await service.mark_downloaded_bulk(
|
||||
"checkpoint",
|
||||
[
|
||||
{"model_id": 5, "version_id": 501, "file_path": "/m/one.safetensors"},
|
||||
{"model_id": 5, "version_id": 502, "file_path": "/m/two.safetensors"},
|
||||
{"model_id": 6, "version_id": 601, "file_path": "/m/three.safetensors"},
|
||||
],
|
||||
source="scan",
|
||||
)
|
||||
|
||||
assert await service.get_downloaded_version_ids("checkpoint", 5) == [501, 502]
|
||||
assert await service.get_downloaded_version_ids_bulk("checkpoint", [5, 6, 7]) == {
|
||||
5: {501, 502},
|
||||
6: {601},
|
||||
}
|
||||
@@ -313,6 +313,75 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
|
||||
assert recipe["checkpoint"]["file_name"] == "by-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
image_path = Path(config.loras_roots[0]) / "checkpoint-filter.webp"
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "checkpoint-match",
|
||||
"file_path": str(image_path),
|
||||
"title": "Checkpoint Match",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"loras": [],
|
||||
"checkpoint": {
|
||||
"name": "flux-base.safetensors",
|
||||
"hash": "ABC123",
|
||||
},
|
||||
}
|
||||
)
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "checkpoint-miss",
|
||||
"file_path": str(Path(config.loras_roots[0]) / "checkpoint-miss.webp"),
|
||||
"title": "Checkpoint Miss",
|
||||
"modified": 1.0,
|
||||
"created_date": 1.0,
|
||||
"loras": [],
|
||||
"checkpoint": {
|
||||
"name": "other.safetensors",
|
||||
"hash": "zzz999",
|
||||
},
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
result = await scanner.get_paginated_data(
|
||||
page=1,
|
||||
page_size=10,
|
||||
checkpoint_hash="abc123",
|
||||
)
|
||||
|
||||
assert [item["id"] for item in result["items"]] == ["checkpoint-match"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
image_path = Path(config.loras_roots[0]) / "checkpoint-linked.webp"
|
||||
await scanner.add_recipe(
|
||||
{
|
||||
"id": "checkpoint-linked",
|
||||
"file_path": str(image_path),
|
||||
"title": "Checkpoint Linked",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"loras": [],
|
||||
"checkpoint": {
|
||||
"name": "flux-base.safetensors",
|
||||
"hash": "ABC123",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
recipes = await scanner.get_recipes_for_checkpoint("abc123")
|
||||
|
||||
assert len(recipes) == 1
|
||||
assert recipes[0]["id"] == "checkpoint-linked"
|
||||
assert recipes[0]["checkpoint"]["hash"] == "ABC123"
|
||||
|
||||
|
||||
def test_enrich_uses_version_index_when_hash_missing(recipe_scanner):
|
||||
scanner, stub = recipe_scanner
|
||||
version_id = 77
|
||||
|
||||
@@ -7,7 +7,11 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from py.services.recipes.analysis_service import RecipeAnalysisService
|
||||
from py.services.recipes.errors import RecipeDownloadError, RecipeNotFoundError
|
||||
from py.services.recipes.errors import (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
RecipeValidationError,
|
||||
)
|
||||
from py.services.recipes.persistence_service import RecipePersistenceService
|
||||
|
||||
|
||||
@@ -486,6 +490,50 @@ async def test_move_recipe_updates_paths(tmp_path):
|
||||
assert stored["file_path"] == result.payload["new_file_path"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_recipe_accepts_gen_params() -> None:
|
||||
class DummyScanner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def update_recipe_metadata(self, recipe_id: str, updates: dict[str, object]):
|
||||
self.calls.append((recipe_id, updates))
|
||||
return True
|
||||
|
||||
scanner = DummyScanner()
|
||||
service = RecipePersistenceService(
|
||||
exif_utils=DummyExifUtils(),
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
updates = {"gen_params": {"prompt": "updated prompt", "steps": 28}}
|
||||
result = await service.update_recipe(
|
||||
recipe_scanner=scanner,
|
||||
recipe_id="recipe-1",
|
||||
updates=updates,
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert scanner.calls == [("recipe-1", updates)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_recipe_rejects_non_object_gen_params() -> None:
|
||||
service = RecipePersistenceService(
|
||||
exif_utils=DummyExifUtils(),
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
with pytest.raises(RecipeValidationError, match="gen_params must be an object"):
|
||||
await service.update_recipe(
|
||||
recipe_scanner=SimpleNamespace(),
|
||||
recipe_id="recipe-1",
|
||||
updates={"gen_params": "invalid"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_remote_video(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
|
||||
@@ -17,7 +17,9 @@ def test_portable_settings_use_project_root(tmp_path, monkeypatch):
|
||||
from importlib import reload
|
||||
|
||||
settings_paths_module = reload(settings_paths)
|
||||
monkeypatch.setattr(settings_paths_module, "get_project_root", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(
|
||||
settings_paths_module, "get_project_root", lambda: str(tmp_path)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings_paths_module,
|
||||
"user_config_dir",
|
||||
@@ -25,7 +27,9 @@ def test_portable_settings_use_project_root(tmp_path, monkeypatch):
|
||||
)
|
||||
|
||||
portable_settings = {"use_portable_settings": True}
|
||||
(tmp_path / "settings.json").write_text(json.dumps(portable_settings), encoding="utf-8")
|
||||
(tmp_path / "settings.json").write_text(
|
||||
json.dumps(portable_settings), encoding="utf-8"
|
||||
)
|
||||
|
||||
config_dir = settings_paths_module.get_settings_dir(create=True)
|
||||
assert config_dir == str(tmp_path)
|
||||
@@ -74,7 +78,9 @@ def test_initial_save_persists_minimal_template(tmp_path, monkeypatch):
|
||||
self._seed_template = copy.deepcopy(template)
|
||||
return copy.deepcopy(template)
|
||||
|
||||
monkeypatch.setattr(SettingsManager, "_load_settings_template", fake_template_loader)
|
||||
monkeypatch.setattr(
|
||||
SettingsManager, "_load_settings_template", fake_template_loader
|
||||
)
|
||||
|
||||
manager = SettingsManager()
|
||||
|
||||
@@ -118,7 +124,10 @@ def test_existing_folder_paths_seed_default_library(tmp_path, monkeypatch):
|
||||
assert "default" in libraries
|
||||
assert libraries["default"]["folder_paths"]["loras"] == [str(lora_dir)]
|
||||
assert libraries["default"]["folder_paths"]["checkpoints"] == [str(checkpoint_dir)]
|
||||
assert libraries["default"]["folder_paths"]["unet"] == [str(diffusion_dir), str(unet_dir)]
|
||||
assert libraries["default"]["folder_paths"]["unet"] == [
|
||||
str(diffusion_dir),
|
||||
str(unet_dir),
|
||||
]
|
||||
assert libraries["default"]["folder_paths"]["embeddings"] == [str(embedding_dir)]
|
||||
|
||||
assert manager.get_startup_messages() == []
|
||||
@@ -138,7 +147,9 @@ def test_environment_variable_overrides_settings(tmp_path, monkeypatch):
|
||||
assert mgr.get("civitai_api_key") == "secret"
|
||||
|
||||
|
||||
def _create_manager_with_settings(tmp_path, monkeypatch, initial_settings, *, save_spy=None):
|
||||
def _create_manager_with_settings(
|
||||
tmp_path, monkeypatch, initial_settings, *, save_spy=None
|
||||
):
|
||||
"""Helper to instantiate SettingsManager with predefined settings."""
|
||||
|
||||
fake_settings_path = tmp_path / "settings.json"
|
||||
@@ -203,7 +214,9 @@ def test_switch_to_portable_mode_copies_cache(tmp_path, monkeypatch):
|
||||
assert manager.settings_file == str(project_root / "settings.json")
|
||||
marker_copy = project_root / "model_cache" / "user_marker.txt"
|
||||
assert marker_copy.read_text(encoding="utf-8") == "user_marker.txt"
|
||||
assert (project_root / "model_cache.sqlite").read_text(encoding="utf-8") == "user_db"
|
||||
assert (project_root / "model_cache.sqlite").read_text(
|
||||
encoding="utf-8"
|
||||
) == "user_db"
|
||||
assert user_settings.exists()
|
||||
|
||||
|
||||
@@ -216,13 +229,17 @@ def test_switching_back_to_user_config_moves_cache(tmp_path, monkeypatch):
|
||||
|
||||
project_cache_dir = project_root / "model_cache"
|
||||
project_cache_dir.mkdir(exist_ok=True)
|
||||
(project_cache_dir / "project_marker.txt").write_text("project_marker", encoding="utf-8")
|
||||
(project_cache_dir / "project_marker.txt").write_text(
|
||||
"project_marker", encoding="utf-8"
|
||||
)
|
||||
(project_root / "model_cache.sqlite").write_text("project_db", encoding="utf-8")
|
||||
|
||||
manager.set("use_portable_settings", False)
|
||||
|
||||
assert manager.settings_file == str(user_settings)
|
||||
assert (user_dir / "model_cache" / "project_marker.txt").read_text(encoding="utf-8") == "project_marker"
|
||||
assert (user_dir / "model_cache" / "project_marker.txt").read_text(
|
||||
encoding="utf-8"
|
||||
) == "project_marker"
|
||||
assert (user_dir / "model_cache.sqlite").read_text(encoding="utf-8") == "project_db"
|
||||
|
||||
|
||||
@@ -242,10 +259,19 @@ def test_download_path_template_invalid_json(manager):
|
||||
template = manager.get_download_path_template("checkpoint")
|
||||
|
||||
assert template == "{base_model}/{first_tag}"
|
||||
assert manager.settings["download_path_templates"]["lora"] == "{base_model}/{first_tag}"
|
||||
assert (
|
||||
manager.settings["download_path_templates"]["lora"]
|
||||
== "{base_model}/{first_tag}"
|
||||
)
|
||||
|
||||
|
||||
def test_auto_set_default_roots(manager):
|
||||
# Clear any previously auto-set values to test fresh behavior
|
||||
manager.settings["default_lora_root"] = ""
|
||||
manager.settings["default_checkpoint_root"] = ""
|
||||
manager.settings["default_embedding_root"] = ""
|
||||
manager.settings["default_unet_root"] = ""
|
||||
|
||||
manager.settings["folder_paths"] = {
|
||||
"loras": ["/loras"],
|
||||
"checkpoints": ["/checkpoints"],
|
||||
@@ -259,6 +285,48 @@ def test_auto_set_default_roots(manager):
|
||||
assert manager.get("default_embedding_root") == "/embeddings"
|
||||
|
||||
|
||||
def test_auto_set_default_roots_repairs_stale_values(manager):
|
||||
manager.settings["default_lora_root"] = "/stale-lora"
|
||||
manager.settings["default_checkpoint_root"] = "/stale-checkpoint"
|
||||
manager.settings["default_embedding_root"] = "/stale-embedding"
|
||||
manager.settings["default_unet_root"] = "/stale-unet"
|
||||
|
||||
manager.settings["folder_paths"] = {
|
||||
"loras": ["/loras"],
|
||||
"checkpoints": ["/checkpoints"],
|
||||
"unet": ["/unet"],
|
||||
"embeddings": ["/embeddings"],
|
||||
}
|
||||
|
||||
manager._auto_set_default_roots()
|
||||
|
||||
assert manager.get("default_lora_root") == "/loras"
|
||||
assert manager.get("default_checkpoint_root") == "/checkpoints"
|
||||
assert manager.get("default_unet_root") == "/unet"
|
||||
assert manager.get("default_embedding_root") == "/embeddings"
|
||||
|
||||
|
||||
def test_auto_set_default_roots_keeps_valid_values(manager):
|
||||
manager.settings["default_lora_root"] = "/loras"
|
||||
manager.settings["default_checkpoint_root"] = "/checkpoints"
|
||||
manager.settings["default_embedding_root"] = "/embeddings"
|
||||
manager.settings["default_unet_root"] = "/unet"
|
||||
|
||||
manager.settings["folder_paths"] = {
|
||||
"loras": ["/loras", "/other-loras"],
|
||||
"checkpoints": ["/checkpoints"],
|
||||
"unet": ["/unet", "/other-unet"],
|
||||
"embeddings": ["/embeddings"],
|
||||
}
|
||||
|
||||
manager._auto_set_default_roots()
|
||||
|
||||
assert manager.get("default_lora_root") == "/loras"
|
||||
assert manager.get("default_checkpoint_root") == "/checkpoints"
|
||||
assert manager.get("default_unet_root") == "/unet"
|
||||
assert manager.get("default_embedding_root") == "/embeddings"
|
||||
|
||||
|
||||
def test_delete_setting(manager):
|
||||
manager.set("example", 1)
|
||||
manager.delete("example")
|
||||
@@ -293,7 +361,14 @@ def test_invalid_mature_blur_level_is_normalized_to_r(tmp_path, monkeypatch):
|
||||
|
||||
def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
|
||||
initial = {
|
||||
"libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}},
|
||||
"libraries": {
|
||||
"default": {
|
||||
"folder_paths": {},
|
||||
"default_lora_root": "",
|
||||
"default_checkpoint_root": "",
|
||||
"default_embedding_root": "",
|
||||
}
|
||||
},
|
||||
"active_library": "default",
|
||||
"model_name_display": "model_name",
|
||||
}
|
||||
@@ -315,6 +390,7 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
|
||||
|
||||
dispatched_loops = []
|
||||
futures = []
|
||||
|
||||
def tracking_run_coroutine_threadsafe(coro, target_loop):
|
||||
dispatched_loops.append(target_loop)
|
||||
future = Future()
|
||||
@@ -335,7 +411,9 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
|
||||
"get_service_sync",
|
||||
classmethod(fake_get_service_sync),
|
||||
)
|
||||
monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", tracking_run_coroutine_threadsafe)
|
||||
monkeypatch.setattr(
|
||||
asyncio, "run_coroutine_threadsafe", tracking_run_coroutine_threadsafe
|
||||
)
|
||||
|
||||
try:
|
||||
manager.set("model_name_display", "file_name")
|
||||
@@ -354,12 +432,14 @@ def test_migrates_legacy_settings_file(tmp_path, monkeypatch):
|
||||
legacy_root = tmp_path / "legacy"
|
||||
legacy_root.mkdir()
|
||||
legacy_file = legacy_root / "settings.json"
|
||||
legacy_file.write_text("{\"value\": 1}", encoding="utf-8")
|
||||
legacy_file.write_text('{"value": 1}', encoding="utf-8")
|
||||
|
||||
target_dir = tmp_path / "config"
|
||||
|
||||
monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(legacy_root))
|
||||
monkeypatch.setattr(settings_paths, "user_config_dir", lambda *_, **__: str(target_dir))
|
||||
monkeypatch.setattr(
|
||||
settings_paths, "user_config_dir", lambda *_, **__: str(target_dir)
|
||||
)
|
||||
|
||||
migrated_path = settings_paths.ensure_settings_file()
|
||||
|
||||
@@ -380,7 +460,9 @@ def test_uses_portable_settings_file_when_enabled(tmp_path, monkeypatch):
|
||||
user_dir = tmp_path / "user"
|
||||
|
||||
monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(repo_root))
|
||||
monkeypatch.setattr(settings_paths, "user_config_dir", lambda *_, **__: str(user_dir))
|
||||
monkeypatch.setattr(
|
||||
settings_paths, "user_config_dir", lambda *_, **__: str(user_dir)
|
||||
)
|
||||
|
||||
resolved = settings_paths.ensure_settings_file()
|
||||
|
||||
@@ -393,7 +475,9 @@ def test_migrate_creates_default_library(manager):
|
||||
libraries = manager.get_libraries()
|
||||
assert "default" in libraries
|
||||
assert manager.get_active_library_name() == "default"
|
||||
assert libraries["default"].get("folder_paths", {}) == manager.settings.get("folder_paths", {})
|
||||
assert libraries["default"].get("folder_paths", {}) == manager.settings.get(
|
||||
"folder_paths", {}
|
||||
)
|
||||
|
||||
|
||||
def test_migrate_sanitizes_legacy_libraries(tmp_path, monkeypatch):
|
||||
@@ -464,12 +548,21 @@ def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatc
|
||||
|
||||
initial = {
|
||||
"civitai_api_key": "stale",
|
||||
"libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}},
|
||||
"libraries": {
|
||||
"default": {
|
||||
"folder_paths": {},
|
||||
"default_lora_root": "",
|
||||
"default_checkpoint_root": "",
|
||||
"default_embedding_root": "",
|
||||
}
|
||||
},
|
||||
"active_library": "default",
|
||||
}
|
||||
|
||||
monkeypatch.setenv("CIVITAI_API_KEY", "from-init")
|
||||
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial, save_spy=save_spy)
|
||||
manager = _create_manager_with_settings(
|
||||
tmp_path, monkeypatch, initial, save_spy=save_spy
|
||||
)
|
||||
|
||||
assert calls[-1] == "from-init"
|
||||
|
||||
@@ -590,7 +683,9 @@ def test_extra_paths_validation_no_overlap_with_other_libraries(manager, tmp_pat
|
||||
manager.update_extra_folder_paths({"loras": [str(lora_dir1)]})
|
||||
|
||||
|
||||
def test_extra_paths_validation_no_overlap_with_active_primary_lora_root(manager, tmp_path):
|
||||
def test_extra_paths_validation_no_overlap_with_active_primary_lora_root(
|
||||
manager, tmp_path
|
||||
):
|
||||
"""Test that extra LoRA paths cannot overlap the active library primary LoRA roots."""
|
||||
real_lora_dir = tmp_path / "loras_real"
|
||||
real_lora_dir.mkdir()
|
||||
@@ -603,7 +698,9 @@ def test_extra_paths_validation_no_overlap_with_active_primary_lora_root(manager
|
||||
activate=True,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="overlap with the active library's primary LoRA roots"):
|
||||
with pytest.raises(
|
||||
ValueError, match="overlap with the active library's primary LoRA roots"
|
||||
):
|
||||
manager.update_extra_folder_paths({"loras": [str(real_lora_dir)]})
|
||||
|
||||
|
||||
@@ -627,7 +724,10 @@ def test_extra_paths_validation_no_overlap_with_active_primary_lora_root_case_in
|
||||
original_normcase = settings_manager_module.os.path.normcase
|
||||
|
||||
def fake_exists(path):
|
||||
if isinstance(path, str) and path.lower() in {str(lora_link).lower(), str(real_lora_dir).lower()}:
|
||||
if isinstance(path, str) and path.lower() in {
|
||||
str(lora_link).lower(),
|
||||
str(real_lora_dir).lower(),
|
||||
}:
|
||||
return True
|
||||
return original_exists(path)
|
||||
|
||||
@@ -638,13 +738,21 @@ def test_extra_paths_validation_no_overlap_with_active_primary_lora_root_case_in
|
||||
|
||||
monkeypatch.setattr(settings_manager_module.os.path, "exists", fake_exists)
|
||||
monkeypatch.setattr(settings_manager_module.os.path, "realpath", fake_realpath)
|
||||
monkeypatch.setattr(settings_manager_module.os.path, "normcase", lambda value: original_normcase(value).lower())
|
||||
monkeypatch.setattr(
|
||||
settings_manager_module.os.path,
|
||||
"normcase",
|
||||
lambda value: original_normcase(value).lower(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="overlap with the active library's primary LoRA roots"):
|
||||
with pytest.raises(
|
||||
ValueError, match="overlap with the active library's primary LoRA roots"
|
||||
):
|
||||
manager.update_extra_folder_paths({"loras": [str(real_lora_dir).upper()]})
|
||||
|
||||
|
||||
def test_extra_paths_validation_allows_missing_non_overlapping_lora_root(manager, tmp_path):
|
||||
def test_extra_paths_validation_allows_missing_non_overlapping_lora_root(
|
||||
manager, tmp_path
|
||||
):
|
||||
"""Missing non-overlapping extra LoRA paths should not be rejected."""
|
||||
lora_dir = tmp_path / "loras"
|
||||
lora_dir.mkdir()
|
||||
@@ -662,7 +770,9 @@ def test_extra_paths_validation_allows_missing_non_overlapping_lora_root(manager
|
||||
assert extra_paths["loras"] == [str(missing_extra)]
|
||||
|
||||
|
||||
def test_extra_paths_validation_rejects_primary_root_first_level_symlink_target(manager, tmp_path):
|
||||
def test_extra_paths_validation_rejects_primary_root_first_level_symlink_target(
|
||||
manager, tmp_path
|
||||
):
|
||||
"""Extra LoRA paths should be rejected when already reachable via a first-level symlink under the primary root."""
|
||||
lora_dir = tmp_path / "loras"
|
||||
lora_dir.mkdir()
|
||||
@@ -677,7 +787,9 @@ def test_extra_paths_validation_rejects_primary_root_first_level_symlink_target(
|
||||
activate=True,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="overlap with the active library's primary LoRA roots"):
|
||||
with pytest.raises(
|
||||
ValueError, match="overlap with the active library's primary LoRA roots"
|
||||
):
|
||||
manager.update_extra_folder_paths({"loras": [str(external_dir)]})
|
||||
|
||||
|
||||
@@ -698,7 +810,6 @@ def test_delete_library_switches_active(manager, tmp_path):
|
||||
assert manager.get_active_library_name() == "default"
|
||||
|
||||
|
||||
|
||||
def test_download_skip_base_models_are_normalized(manager):
|
||||
manager.settings["download_skip_base_models"] = [
|
||||
"SDXL 1.0",
|
||||
@@ -715,9 +826,17 @@ def test_download_skip_base_models_are_normalized(manager):
|
||||
|
||||
|
||||
def test_setting_download_skip_base_models_normalizes_string_input(manager):
|
||||
manager.set(
|
||||
"download_skip_base_models",
|
||||
"SDXL 1.0, Pony; Invalid\nSDXL 1.0"
|
||||
)
|
||||
manager.set("download_skip_base_models", "SDXL 1.0, Pony; Invalid\nSDXL 1.0")
|
||||
|
||||
assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"]
|
||||
|
||||
|
||||
def test_skip_previously_downloaded_model_versions_defaults_false(manager):
|
||||
assert manager.get_skip_previously_downloaded_model_versions() is False
|
||||
|
||||
|
||||
def test_skip_previously_downloaded_model_versions_coerces_string_input(manager):
|
||||
manager.settings["skip_previously_downloaded_model_versions"] = "true"
|
||||
|
||||
assert manager.get_skip_previously_downloaded_model_versions() is True
|
||||
assert manager.settings["skip_previously_downloaded_model_versions"] is True
|
||||
|
||||
144
tests/utils/test_civitai_utils_rewrite.py
Normal file
144
tests/utils/test_civitai_utils_rewrite.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Tests for CivitAI URL utilities."""
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.civitai_utils import rewrite_preview_url
|
||||
|
||||
|
||||
class TestRewritePreviewUrl:
|
||||
"""Test cases for rewrite_preview_url function."""
|
||||
|
||||
def test_handles_none_input(self):
|
||||
"""Should return (None, False) for None input."""
|
||||
result, was_rewritten = rewrite_preview_url(None)
|
||||
assert result is None
|
||||
assert was_rewritten is False
|
||||
|
||||
def test_handles_empty_string(self):
|
||||
"""Should return (empty_string, False) for empty input."""
|
||||
result, was_rewritten = rewrite_preview_url("")
|
||||
assert result == ""
|
||||
assert was_rewritten is False
|
||||
|
||||
def test_handles_invalid_url(self):
|
||||
"""Should return original URL and False for invalid URLs."""
|
||||
invalid_url = "not-a-valid-url"
|
||||
result, was_rewritten = rewrite_preview_url(invalid_url)
|
||||
assert result == invalid_url
|
||||
assert was_rewritten is False
|
||||
|
||||
def test_handles_url_without_scheme(self):
|
||||
"""Should return original URL and False for URLs without scheme."""
|
||||
url = "image.civitai.com/something"
|
||||
result, was_rewritten = rewrite_preview_url(url)
|
||||
assert result == url
|
||||
assert was_rewritten is False
|
||||
|
||||
def test_returns_false_for_non_civitai_domains(self):
|
||||
"""Should not rewrite URLs from other domains."""
|
||||
url = "https://example.com/image.jpg"
|
||||
result, was_rewritten = rewrite_preview_url(url)
|
||||
assert result == url
|
||||
assert was_rewritten is False
|
||||
|
||||
def test_returns_false_for_main_civitai_domain(self):
|
||||
"""Should not rewrite URLs from main civitai.com domain."""
|
||||
url = "https://civitai.com/images/123"
|
||||
result, was_rewritten = rewrite_preview_url(url)
|
||||
assert result == url
|
||||
assert was_rewritten is False
|
||||
|
||||
def test_rewrites_image_civitai_com_urls(self):
|
||||
"""Should rewrite URLs from image.civitai.com."""
|
||||
url = "https://image.civitai.com/checkpoints/original=true"
|
||||
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||
assert (
|
||||
result == "https://image.civitai.com/checkpoints/width=450,optimized=true"
|
||||
)
|
||||
assert was_rewritten is True
|
||||
|
||||
def test_rewrites_subdomain_civitai_urls(self):
|
||||
"""Should rewrite URLs from CivitAI CDN subdomains like image-b2.civitai.com."""
|
||||
url = "https://image-b2.civitai.com/file/civitai-media-cache/original=true/sample.png"
|
||||
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||
assert (
|
||||
result
|
||||
== "https://image-b2.civitai.com/file/civitai-media-cache/width=450,optimized=true/sample.png"
|
||||
)
|
||||
assert was_rewritten is True
|
||||
|
||||
def test_rewrites_multiple_subdomains(self):
|
||||
"""Should rewrite URLs from various CivitAI subdomains."""
|
||||
test_cases = [
|
||||
"https://image-b3.civitai.com/original=true/test.jpg",
|
||||
"https://cdn.civitai.com/original=true/test.png",
|
||||
]
|
||||
for url in test_cases:
|
||||
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||
assert was_rewritten is True
|
||||
assert "width=450,optimized=true" in result
|
||||
|
||||
def test_handles_urls_with_explicit_port(self):
|
||||
"""Should correctly handle URLs with explicit port numbers."""
|
||||
url = "https://image.civitai.com:443/checkpoints/original=true"
|
||||
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||
assert was_rewritten is True
|
||||
assert "width=450,optimized=true" in result
|
||||
# Port is preserved in the URL (this is acceptable behavior)
|
||||
assert ":443" in result
|
||||
|
||||
def test_rewrites_video_urls_with_transcode(self):
|
||||
"""Should rewrite video URLs with transcode parameter."""
|
||||
url = "https://image.civitai.com/videos/original=true/sample.mp4"
|
||||
result, was_rewritten = rewrite_preview_url(url, "video")
|
||||
assert (
|
||||
result
|
||||
== "https://image.civitai.com/videos/transcode=true,width=450,optimized=true/sample.mp4"
|
||||
)
|
||||
assert was_rewritten is True
|
||||
|
||||
def test_video_rewrite_uses_case_insensitive_type(self):
|
||||
"""Should handle video type case-insensitively."""
|
||||
url = "https://image.civitai.com/original=true/test.mp4"
|
||||
result1, was1 = rewrite_preview_url(url, "VIDEO")
|
||||
result2, was2 = rewrite_preview_url(url, "Video")
|
||||
assert was1 is True
|
||||
assert was2 is True
|
||||
assert "transcode=true" in result1
|
||||
assert "transcode=true" in result2
|
||||
|
||||
def test_returns_original_when_no_original_true_in_path(self):
|
||||
"""Should not rewrite URLs that don't contain /original=true."""
|
||||
url = "https://image.civitai.com/checkpoints/optimized=true"
|
||||
result, was_rewritten = rewrite_preview_url(url)
|
||||
assert result == url
|
||||
assert was_rewritten is False
|
||||
|
||||
def test_preserves_path_structure_after_rewrite(self):
|
||||
"""Should maintain path structure after rewriting."""
|
||||
url = "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.png"
|
||||
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||
assert was_rewritten is True
|
||||
assert result.startswith(
|
||||
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/"
|
||||
)
|
||||
assert result.endswith("/12345.png")
|
||||
|
||||
def test_defaults_to_image_mode_when_media_type_is_none(self):
|
||||
"""Should use image optimization when media_type is None."""
|
||||
url = "https://image.civitai.com/original=true/test.png"
|
||||
result, was_rewritten = rewrite_preview_url(url, None)
|
||||
assert was_rewritten is True
|
||||
assert "transcode=true" not in result
|
||||
assert "width=450,optimized=true" in result
|
||||
|
||||
def test_case_insensitive_hostname_matching(self):
|
||||
"""Should handle case-insensitive hostname matching."""
|
||||
test_cases = [
|
||||
"https://IMAGE.CIVITAI.COM/original=true/test.png",
|
||||
"https://Image.Civitai.Com/original=true/test.png",
|
||||
"https://image-b2.CIVITAI.com/original=true/test.png",
|
||||
]
|
||||
for url in test_cases:
|
||||
result, was_rewritten = rewrite_preview_url(url, "image")
|
||||
assert was_rewritten is True, f"Failed for URL: {url}"
|
||||
@@ -36,6 +36,8 @@ export interface AutocompleteTextWidgetInterface {
|
||||
inputEl?: HTMLTextAreaElement
|
||||
callback?: (v: string) => void
|
||||
onSetValue?: (v: string) => void
|
||||
metadataWidget?: { value?: unknown }
|
||||
name?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -137,7 +139,7 @@ const onWheel = (event: WheelEvent) => {
|
||||
}
|
||||
|
||||
// Handle external value changes (e.g., from "send lora to workflow")
|
||||
const onExternalValueChange = (event: CustomEvent<{ value: string }>) => {
|
||||
const onExternalValueChange = () => {
|
||||
updateHasTextState()
|
||||
}
|
||||
|
||||
@@ -171,6 +173,9 @@ onMounted(() => {
|
||||
// Register textarea reference with widget
|
||||
if (textareaRef.value) {
|
||||
props.widget.inputEl = textareaRef.value
|
||||
;(textareaRef.value as any)._autocompleteHostWidget = props.widget
|
||||
;(textareaRef.value as any)._autocompleteMetadataWidget = props.widget.metadataWidget
|
||||
;(textareaRef.value as any)._autocompleteTextWidgetName = props.widget.name ?? 'text'
|
||||
|
||||
// Also store on the container element for cloned widgets (subgraph promotion)
|
||||
// When widgets are promoted to subgraph nodes, the cloned widget shares the same
|
||||
@@ -208,6 +213,9 @@ onUnmounted(() => {
|
||||
|
||||
// Remove external value change event listener
|
||||
if (textareaRef.value) {
|
||||
delete (textareaRef.value as any)._autocompleteHostWidget
|
||||
delete (textareaRef.value as any)._autocompleteMetadataWidget
|
||||
delete (textareaRef.value as any)._autocompleteTextWidgetName
|
||||
textareaRef.value.removeEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="lora-cycler-widget">
|
||||
<div class="lora-cycler-widget" @wheel="onWheel">
|
||||
<LoraCyclerSettingsView
|
||||
:current-index="state.currentIndex.value"
|
||||
:total-count="displayTotalCount"
|
||||
@@ -8,6 +8,8 @@
|
||||
:model-strength="state.modelStrength.value"
|
||||
:clip-strength="state.clipStrength.value"
|
||||
:use-custom-clip-range="state.useCustomClipRange.value"
|
||||
:use-preset-strength="state.usePresetStrength.value"
|
||||
:preset-strength-scale="state.presetStrengthScale.value"
|
||||
:is-clip-strength-disabled="state.isClipStrengthDisabled.value"
|
||||
:is-loading="state.isLoading.value"
|
||||
:repeat-count="state.repeatCount.value"
|
||||
@@ -22,6 +24,8 @@
|
||||
@update:model-strength="state.modelStrength.value = $event"
|
||||
@update:clip-strength="state.clipStrength.value = $event"
|
||||
@update:use-custom-clip-range="handleUseCustomClipRangeChange"
|
||||
@update:use-preset-strength="state.usePresetStrength.value = $event"
|
||||
@update:preset-strength-scale="state.presetStrengthScale.value = $event"
|
||||
@update:repeat-count="handleRepeatCountChange"
|
||||
@update:include-no-lora="handleIncludeNoLoraChange"
|
||||
@toggle-pause="handleTogglePause"
|
||||
@@ -257,6 +261,53 @@ const handleResetIndex = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel events on the widget.
|
||||
* Forwards the event to the ComfyUI canvas for zooming when appropriate.
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
// Check if the event originated from a slider component
|
||||
// Sliders have data-capture-wheel="true" attribute
|
||||
const target = event.target as HTMLElement
|
||||
if (target?.closest('[data-capture-wheel="true"]')) {
|
||||
// Event is from a slider, slider already handled it
|
||||
// Just stop propagation to prevent canvas zoom
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// Access ComfyUI app from global window
|
||||
const app = (window as any).app
|
||||
if (!app || !app.canvas || typeof app.canvas.processMouseWheel !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
|
||||
|
||||
// 1. Handle pinch-to-zoom (ctrlKey is true for pinch-to-zoom on most browsers)
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Horizontal scroll: pass to canvas (widgets usually don't scroll horizontally)
|
||||
if (isHorizontal) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Vertical scrolling: forward to canvas
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
}
|
||||
|
||||
// Check for pool config changes
|
||||
const checkPoolConfigChanges = async () => {
|
||||
if (!isMounted.value) return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="lora-pool-widget">
|
||||
<div class="lora-pool-widget" @wheel="onWheel">
|
||||
<!-- Summary View -->
|
||||
<LoraPoolSummaryView
|
||||
:selected-base-models="state.selectedBaseModels.value"
|
||||
@@ -99,6 +99,53 @@ const openModal = (modal: ModalType) => {
|
||||
modalState.openModal(modal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel events on the widget.
|
||||
* Forwards the event to the ComfyUI canvas for zooming when appropriate.
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
// Check if the event originated from a slider component
|
||||
// Sliders have data-capture-wheel="true" attribute
|
||||
const target = event.target as HTMLElement
|
||||
if (target?.closest('[data-capture-wheel="true"]')) {
|
||||
// Event is from a slider, slider already handled it
|
||||
// Just stop propagation to prevent canvas zoom
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// Access ComfyUI app from global window
|
||||
const app = (window as any).app
|
||||
if (!app || !app.canvas || typeof app.canvas.processMouseWheel !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
|
||||
|
||||
// 1. Handle pinch-to-zoom (ctrlKey is true for pinch-to-zoom on most browsers)
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Horizontal scroll: pass to canvas (widgets usually don't scroll horizontally)
|
||||
if (isHorizontal) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Vertical scrolling: forward to canvas
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Setup callback for external value updates (e.g., workflow load, undo/redo)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="lora-randomizer-widget">
|
||||
<div class="lora-randomizer-widget" @wheel="onWheel">
|
||||
<LoraRandomizerSettingsView
|
||||
:count-mode="state.countMode.value"
|
||||
:count-fixed="state.countFixed.value"
|
||||
@@ -51,6 +51,7 @@ type RandomizerWidget = ComponentWidget<RandomizerConfig>
|
||||
const props = defineProps<{
|
||||
widget: RandomizerWidget
|
||||
node: { id: number; inputs?: any[]; widgets?: any[]; graph?: any }
|
||||
api?: any
|
||||
}>()
|
||||
|
||||
// State management
|
||||
@@ -65,6 +66,13 @@ const currentLoras = ref<LoraEntry[]>([])
|
||||
// Track if component is mounted to avoid early watch triggers
|
||||
const isMounted = ref(false)
|
||||
|
||||
interface PendingExecution {
|
||||
loras?: LoraEntry[]
|
||||
lastUsed?: LoraEntry[] | null
|
||||
}
|
||||
|
||||
const pendingExecutions: PendingExecution[] = []
|
||||
|
||||
// Computed property to check if we can reuse last
|
||||
const canReuseLast = computed(() => {
|
||||
const lastUsed = state.lastUsed.value
|
||||
@@ -154,6 +162,53 @@ const handleReuseLast = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel events on the widget.
|
||||
* Forwards the event to the ComfyUI canvas for zooming when appropriate.
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
// Check if the event originated from a slider component
|
||||
// Sliders have data-capture-wheel="true" attribute
|
||||
const target = event.target as HTMLElement
|
||||
if (target?.closest('[data-capture-wheel="true"]')) {
|
||||
// Event is from a slider, slider already handled it
|
||||
// Just stop propagation to prevent canvas zoom
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// Access ComfyUI app from global window
|
||||
const app = (window as any).app
|
||||
if (!app || !app.canvas || typeof app.canvas.processMouseWheel !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
|
||||
|
||||
// 1. Handle pinch-to-zoom (ctrlKey is true for pinch-to-zoom on most browsers)
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Horizontal scroll: pass to canvas (widgets usually don't scroll horizontally)
|
||||
if (isHorizontal) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Vertical scrolling: forward to canvas
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
}
|
||||
|
||||
// Watch for changes to the loras widget to track current loras
|
||||
watch(() => props.node.widgets?.find((w: any) => w.name === 'loras')?.value, (newVal) => {
|
||||
// Only update after component is mounted
|
||||
@@ -218,18 +273,20 @@ onMounted(async () => {
|
||||
;(props.node as any).onExecuted = function(output: any) {
|
||||
console.log("[LoraRandomizerWidget] Node executed with output:", output)
|
||||
|
||||
// Update last_used from backend
|
||||
const pendingUpdate: PendingExecution = {}
|
||||
|
||||
if (output?.last_used !== undefined) {
|
||||
state.lastUsed.value = output.last_used
|
||||
console.log(`[LoraRandomizerWidget] Updated last_used: ${output.last_used ? output.last_used.length : 0} LoRAs`)
|
||||
pendingUpdate.lastUsed = output.last_used
|
||||
console.log(`[LoraRandomizerWidget] Queued last_used update: ${output.last_used ? output.last_used.length : 0} LoRAs`)
|
||||
}
|
||||
|
||||
// Update loras widget if backend provided new loras
|
||||
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras')
|
||||
if (lorasWidget && output?.loras && Array.isArray(output.loras)) {
|
||||
console.log("[LoraRandomizerWidget] Received loras data from backend:", output.loras)
|
||||
lorasWidget.value = output.loras
|
||||
currentLoras.value = output.loras
|
||||
if (output?.loras && Array.isArray(output.loras)) {
|
||||
pendingUpdate.loras = output.loras
|
||||
console.log("[LoraRandomizerWidget] Queued loras data from backend:", output.loras)
|
||||
}
|
||||
|
||||
if (pendingUpdate.lastUsed !== undefined || pendingUpdate.loras !== undefined) {
|
||||
pendingExecutions.push(pendingUpdate)
|
||||
}
|
||||
|
||||
// Call original onExecuted if it exists
|
||||
@@ -237,6 +294,44 @@ onMounted(async () => {
|
||||
return originalOnExecuted(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.api) {
|
||||
const handleExecutionComplete = () => {
|
||||
if (pendingExecutions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingExecutions.shift()!
|
||||
|
||||
if (pending.lastUsed !== undefined) {
|
||||
state.lastUsed.value = pending.lastUsed
|
||||
}
|
||||
|
||||
if (pending.loras !== undefined) {
|
||||
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras')
|
||||
if (lorasWidget) {
|
||||
lorasWidget.value = pending.loras
|
||||
}
|
||||
currentLoras.value = pending.loras
|
||||
}
|
||||
}
|
||||
|
||||
props.api.addEventListener('execution_success', handleExecutionComplete)
|
||||
props.api.addEventListener('execution_error', handleExecutionComplete)
|
||||
props.api.addEventListener('execution_interrupted', handleExecutionComplete)
|
||||
|
||||
const apiCleanup = () => {
|
||||
props.api.removeEventListener('execution_success', handleExecutionComplete)
|
||||
props.api.removeEventListener('execution_error', handleExecutionComplete)
|
||||
props.api.removeEventListener('execution_interrupted', handleExecutionComplete)
|
||||
}
|
||||
|
||||
const existingCleanup = (props.widget as any).onRemoveCleanup
|
||||
;(props.widget as any).onRemoveCleanup = () => {
|
||||
existingCleanup?.()
|
||||
apiCleanup()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user