Compare commits

...

14 Commits

Author SHA1 Message Date
Will Miao
d8e5fe1247 docs: add v1.0.1 release notes, bump version to 1.0.1 2026-04-02 11:54:04 +08:00
Will Miao
3e9210394a feat(settings): Improve Extra Folder Paths UX with restart indicators
- Replace tooltip with restart-required icon for better visibility
- Update descriptions to accurately reflect feature purpose
- Fix toast message to show correct restart notification
- Sync i18n keys across all supported languages
2026-04-02 08:57:04 +08:00
Will Miao
4dd2c0526f chore(supporters): Update supporters 2026-04-01 22:56:20 +08:00
Will Miao
9bdb337962 fix(settings): enforce valid default model roots 2026-04-01 20:36:37 +08:00
Will Miao
f93baf5fc0 chore(workflow): Update example workflows 2026-04-01 15:39:20 +08:00
Will Miao
14cb7fec47 feat(cycler): add preset strength scale (#865) 2026-04-01 11:05:38 +08:00
Will Miao
f3b3e0adad fix(randomizer): defer UI updates until workflow completion (fixes #824) 2026-04-01 10:29:27 +08:00
Will Miao
ba3f15dbc6 feat(checkpoints): add 'Send to Workflow' option in context menu
- Add 'Send to Workflow' menu item to checkpoint context menu (templates/checkpoints.html)
- Implement sendCheckpointToWorkflow() method in CheckpointContextMenu.js
- Use unified 'Model' terminology for toast messages instead of differentiating checkpoint/diffusion model
- Add translation keys: checkpoints.contextMenu.sendToWorkflow, uiHelpers.workflow.modelUpdated, modelFailed
- Complete translations for all 10 locales (en, zh-CN, zh-TW, ja, ko, de, fr, es, ru, he)
2026-03-31 19:52:20 +08:00
Will Miao
8dc2a2f76b fix(recipe): show checkpoint-linked recipes in model modal (#851) 2026-03-31 16:45:01 +08:00
Will Miao
316f17dd46 fix(recipe): Import LoRAs from Civitai image URLs using modelVersionIds (#868)
When importing recipes from Civitai image URLs, the API returns modelVersionIds
at the root level instead of inside the meta object. This caused LoRA information
to not be recognized and imported.

Changes:
- analysis_service.py: Merge modelVersionIds from image_info into metadata
- civitai_image.py: Add modelVersionIds field recognition and processing logic
- test_civitai_image_parser.py: Add test for modelVersionIds handling
2026-03-31 14:34:13 +08:00
Will Miao
3dc10b1404 feat(recipe): add editable prompts in recipe modal (#869) 2026-03-31 14:11:56 +08:00
Will Miao
331889d872 chore(i18n): improve recursive toggle button labels for clarity (#875)
Update translations for sidebar recursive toggle from 'Search subfolders'
to 'Include subfolders' / 'Current folder only' across all 10 languages.

This better describes the actual functionality - controlling whether
models/recipes from subfolders are included in the current view.

Related to #875
2026-03-30 15:26:15 +08:00
Will Miao
06f1a82d4c fix(tests): add missing MODEL_TYPES mock in ModelModal tests
Add mock for apiConfig.js MODEL_TYPES constant in test files to fix
'Cannot read properties of undefined' errors when running npm test.

- tests/frontend/components/modelMetadata.renamePath.test.js
- tests/frontend/components/modelModal.licenseIcons.test.js
2026-03-30 08:37:12 +08:00
Will Miao
267082c712 feat: add 'Send to ComfyUI' button to ModelModal and RecipeModal
- Add send button to ModelModal header for all model types (LoRA, Checkpoint, Embedding)
- Add send button to RecipeModal header for sending entire recipes
- Style buttons to match existing modal action buttons
- Add translations for all supported languages
2026-03-29 20:35:08 +08:00
64 changed files with 3123 additions and 793 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ model_cache/
# agent # agent
.opencode/ .opencode/
.claude/ .claude/
.codex
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
vue-widgets/node_modules/ vue-widgets/node_modules/

File diff suppressed because one or more lines are too long

View File

@@ -9,17 +9,17 @@
"Insomnia Art Designs", "Insomnia Art Designs",
"megakirbs", "megakirbs",
"Brennok", "Brennok",
"wackop",
"2018cfh", "2018cfh",
"W+K+White",
"wackop",
"Takkan", "Takkan",
"stone9k", "Carl G.",
"$MetaSamsara", "$MetaSamsara",
"itismyelement", "itismyelement",
"onesecondinosaur", "onesecondinosaur",
"Carl G.", "stone9k",
"Rosenthal", "Rosenthal",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Gooohokrbe", "Gooohokrbe",
@@ -29,18 +29,16 @@
"VantAI", "VantAI",
"runte3221", "runte3221",
"FreelancerZ", "FreelancerZ",
"Julian V",
"Edgar Tejeda", "Edgar Tejeda",
"Birdy",
"Liam MacDougal", "Liam MacDougal",
"Fraser Cross", "Fraser Cross",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Birdy",
"Marc Whiffen", "Marc Whiffen",
"Kiba",
"Jorge Hussni", "Jorge Hussni",
"Reno Lam", "Kiba",
"Skalabananen", "Skalabananen",
"esthe", "Reno Lam",
"sig", "sig",
"Christian Byrne", "Christian Byrne",
"DM", "DM",
@@ -49,24 +47,22 @@
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"Arlecchino Shion", "Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"ClockDaemon", "ClockDaemon",
"KD", "KD",
"Omnidex", "Omnidex",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"confiscated Zyra", "Tobi_Swagg",
"SG", "SG",
"carozzz", "carozzz",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack", "jmack",
"Adam Shaw",
"Tee Gee",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"Anthony Rizzo",
"tarek helmi",
"Cosmosis", "Cosmosis",
"iamresist", "iamresist",
"RedrockVP", "RedrockVP",
@@ -75,45 +71,34 @@
"James Todd", "James Todd",
"Steven Pfeiffer", "Steven Pfeiffer",
"Tim", "Tim",
"Timmy",
"Johnny",
"Lisster", "Lisster",
"Michael Wong", "Michael Wong",
"Illrigger", "Illrigger",
"whudunit",
"Tom Corrigan", "Tom Corrigan",
"JackieWang", "JackieWang",
"fnkylove", "fnkylove",
"Julian V",
"Steven Owens", "Steven Owens",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"lh qwe",
"Echo", "Echo",
"Lilleman", "Lilleman",
"Robert Stacey", "Robert Stacey",
"PM", "PM",
"Todd Keck", "Todd Keck",
"Briton Heilbrun",
"Mozzel", "Mozzel",
"Gingko Biloba", "Gingko Biloba",
"Felipe dos Santos",
"Penfore",
"BadassArabianMofo",
"Sterilized", "Sterilized",
"BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"Markus",
"quarz", "quarz",
"Greg", "Greg",
"Douglas Gaspar", "Penfore",
"JSST", "JSST",
"AlexDuKaNa", "esthe",
"George",
"lmsupporter", "lmsupporter",
"Phil",
"Charles Blakemore",
"IamAyam", "IamAyam",
"wfpearl", "wfpearl",
"Rob Williams",
"Baekdoosixt", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"Jack B Nimble", "Jack B Nimble",
@@ -125,127 +110,118 @@
"contrite831", "contrite831",
"Alex", "Alex",
"bh", "bh",
"confiscated Zyra",
"Marlon Daniels", "Marlon Daniels",
"Starkselle", "Starkselle",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"Graham Colehour", "greebles",
"Adam Shaw",
"Tee Gee",
"Anthony Rizzo",
"tarek helmi",
"M Postkasse", "M Postkasse",
"Tomohiro Baba",
"David Ortega",
"ASLPro3D", "ASLPro3D",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Weasyl", "Weasyl",
"Lex Song", "Timmy",
"Johnny",
"Cory Paza", "Cory Paza",
"Tak", "Tak",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Zach Gonser", "Zach Gonser",
"Big Red", "Big Red",
"Jimmy Ledbetter", "whudunit",
"Luc Job", "Luc Job",
"dl0901dm", "dl0901dm",
"Philip Hempel", "Philip Hempel",
"corde", "corde",
"Nick Walker", "Nick Walker",
"lh qwe",
"Bishoujoker", "Bishoujoker",
"conner", "conner",
"aai", "aai",
"Yaboi", "Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"Princess Bright Eyes", "Princess Bright Eyes",
"Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"AbstractAss", "AbstractAss",
"Felipe dos Santos",
"ViperC", "ViperC",
"jean jahren",
"Aleksander Wujczyk", "Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"jean jahren", "Markus",
"Ran C",
"tedcor",
"S Sang", "S Sang",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.", "Karl P.",
"Akira_HentAI",
"MagnaInsomnia",
"Gordon Cole", "Gordon Cole",
"yuxz69", "yuxz69",
"MadSpin", "Douglas Gaspar",
"AlexDuKaNa",
"George",
"andrew.tappan", "andrew.tappan",
"dw", "dw",
"N/A", "N/A",
"The Spawn", "The Spawn",
"Phil",
"graysock", "graysock",
"Greenmoustache", "Greenmoustache",
"zounic", "zounic",
"Gamalonia",
"fancypants", "fancypants",
"Vir",
"Joboshy",
"Digital", "Digital",
"JaxMax", "JaxMax",
"takyamtom", "takyamtom",
"Bohemian Corporal",
"奚明 刘", "奚明 刘",
"Dan",
"Seth Christensen",
"Jwk0205", "Jwk0205",
"Bro Xie", "Bro Xie",
"Draven T", "준희 김",
"yer fey",
"batblue", "batblue",
"carey6409", "carey6409",
"Olive", "Olive",
"太郎 ゲーム", "太郎 ゲーム",
"Some Guy Named Barry", "Some Guy Named Barry",
"jinxedx",
"Aquatic Coffee",
"Max Marklund", "Max Marklund",
"Tomohiro Baba",
"David Ortega",
"AELOX", "AELOX",
"Dankin",
"Nicfit23", "Nicfit23",
"Noora", "Noora",
"ethanfel",
"wamekukyouzin", "wamekukyouzin",
"drum matthieu", "drum matthieu",
"Dogmaster", "Dogmaster",
"Matt Wenzel", "Matt Wenzel",
"Mattssn", "Mattssn",
"Frank Nitty", "Lex Song",
"John Saveas", "John Saveas",
"Focuschannel",
"Christopher Michel", "Christopher Michel",
"Serge Bekenkamp", "Serge Bekenkamp",
"Jimmy Ledbetter",
"LeoZero", "LeoZero",
"Antonio Pontes", "Antonio Pontes",
"ApathyJones", "ApathyJones",
"nahinahi9", "nahinahi9",
"Anthony Faxlandez",
"Dustin Chen", "Dustin Chen",
"dan", "dan",
"Blackfish95", "Yaboi",
"Mouthlessman", "Mouthlessman",
"Steam Steam", "Steam Steam",
"Paul Kroll", "Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"otaku fra", "otaku fra",
"semicolon drainpipe", "Ran C",
"Thesharingbrother", "tedcor",
"Fotek Design", "Fotek Design",
"Bas Imagineer",
"Pat Hen",
"ResidentDeviant",
"Adam Taylor", "Adam Taylor",
"JC",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"Prompt Pirate", "MadSpin",
"Pozadine1", "Pozadine1",
"uwutismxd",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"inbijiburu", "inbijiburu",
"decoy",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
@@ -258,53 +234,54 @@
"thesoftwaredruid", "thesoftwaredruid",
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood",
"linnfrey", "linnfrey",
"zenobeus", "Gamalonia",
"Jackthemind", "Vir",
"Stryker",
"Pkrsky", "Pkrsky",
"raf8osz", "Joboshy",
"blikkies", "Bohemian Corporal",
"Dan",
"Josef Lanzl", "Josef Lanzl",
"Seth Christensen",
"Griffin Dahlberg", "Griffin Dahlberg",
"준희 김", "Draven T",
"yer fey",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"Gerald Welly", "Gerald Welly",
"Shock Shockor",
"Roslynd", "Roslynd",
"Geolog", "Geolog",
"Goldwaters", "jinxedx",
"Neco28", "Neco28",
"Zude", "Aquatic Coffee",
"Dankin",
"ethanfel",
"Cristian Vazquez", "Cristian Vazquez",
"Kyler", "Frank Nitty",
"Magic Noob", "Magic Noob",
"aRtFuL_DodGeR", "Focuschannel",
"X",
"DougPeterson", "DougPeterson",
"Jeff", "Jeff",
"Bruce", "Bruce",
"CrimsonDX",
"Kevin John Duck", "Kevin John Duck",
"Anthony Faxlandez",
"Kevin Christopher", "Kevin Christopher",
"Ouro Boros", "Ouro Boros",
"DarkSunset", "Blackfish95",
"dd", "dd",
"Billy Gladky", "Paul Kroll",
"Probis", "MiraiKuriyamaSy",
"shrshpp", "semicolon drainpipe",
"Dušan Ryban", "Thesharingbrother",
"ItsGeneralButtNaked", "Bas Imagineer",
"sjon kreutz", "Pat Hen",
"Nimess",
"John Statham", "John Statham",
"Youguang", "ResidentDeviant",
"Nihongasuki", "Nihongasuki",
"Metryman55", "JC",
"andrewzpong", "Prompt Pirate",
"FrxzenSnxw", "uwutismxd",
"BossGame", "decoy",
"Tyrswood",
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
@@ -316,7 +293,6 @@
"WRL_SPR", "WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
"lrdchs",
"Mirko Katzula", "Mirko Katzula",
"dan", "dan",
"Piccio08", "Piccio08",
@@ -326,51 +302,135 @@
"Moon Knight", "Moon Knight",
"몽타주", "몽타주",
"Kland", "Kland",
"Hailshem", "zenobeus",
"Jackthemind",
"ryoma", "ryoma",
"John Martin", "Stryker",
"raf8osz",
"ElitaSSJ4",
"blikkies",
"Chris", "Chris",
"Brian M", "Brian M",
"Nerezza", "Nerezza",
"sanborondon", "sanborondon",
"moranqianlong",
"Taylor Funk", "Taylor Funk",
"aezin", "aezin",
"Thought2Form", "Thought2Form",
"jcay015", "jcay015",
"Kevin Picco", "Kevin Picco",
"Erik Lopez", "Erik Lopez",
"Shock Shockor",
"Mateo Curić", "Mateo Curić",
"Haru Yotu", "Goldwaters",
"Zude",
"Eris3D", "Eris3D",
"m", "m",
"Pierce McBride", "Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Kyler",
"Mikko Hemilä", "Mikko Hemilä",
"Matura Arbeit", "aRtFuL_DodGeR",
"Jamie Ogletree", "Jamie Ogletree",
"TBitz33",
"Emil Bernhoff",
"a _", "a _",
"SendingRavens",
"James Coleman", "James Coleman",
"CrimsonDX",
"Martial", "Martial",
"battu", "battu",
"Emil Andersson", "Emil Andersson",
"Chad Idk", "Chad Idk",
"Michael Docherty", "DarkSunset",
"Billy Gladky",
"Yuji Kaneko", "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", "elitassj",
"Jacob Winter", "Jacob Winter",
"Jordan Shaw", "Distortik",
"Sam",
"Rops Alot",
"SRDB",
"g unit",
"Ace Ventura",
"David", "David",
"Meilo", "Meilo",
"Pen Bouryoung", "Pen Bouryoung",
"四糸凜音",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
"MaartenAlbers", "MaartenAlbers",
@@ -378,101 +438,104 @@
"xybrightsummer", "xybrightsummer",
"jreedatchison", "jreedatchison",
"PhilW", "PhilW",
"momokai", "Tree Tagger",
"Janik", "Janik",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken",
"Inversity",
"Crocket", "Crocket",
"AIVORY3D",
"epicgamer0020690",
"Joshua Porrata",
"Cruel", "Cruel",
"keemun",
"SuBu",
"RedPIXel",
"MRBlack", "MRBlack",
"Kevinj",
"Wind",
"Nexus",
"Mitchell Robson", "Mitchell Robson",
"Ramneek“Guy”Ashok",
"squid_actually",
"Nat_20",
"Kiyoe", "Kiyoe",
"Edward Weeks",
"kyoumei",
"RadStorm04",
"JohnDoe42054",
"BillyHill",
"humptynutz", "humptynutz",
"emyth",
"michael.isaza", "michael.isaza",
"Kalnei", "Kalnei",
"chriphost", "Whitepinetrader",
"KitKatM", "OrganicArtifact",
"socrasteeze",
"ResidentDeviant",
"Scott", "Scott",
"gzmzmvp", "MudkipMedkitz",
"Welkor", "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", "hayden",
"Richard",
"ahoystan", "ahoystan",
"Leland Saunders", "Leland Saunders",
"Andrew", "Wolfe7D1",
"Ink Temptation",
"Bob Barker", "Bob Barker",
"Robert Wegemund", "edk",
"Littlehuggy", "Kalli Core",
"Gregory Kozhemiak",
"mrjuan",
"Aeternyx", "Aeternyx",
"Brian Buie", "elleshar666",
"YOU SINWOO", "YOU SINWOO",
"Sadlip",
"ja s", "ja s",
"Eric Whitney",
"Doug Mason", "Doug Mason",
"Joey Callahan", "Kauffy",
"Ivan Tadic",
"y2Rxy7FdXzWo",
"Jeremy Townsend", "Jeremy Townsend",
"Mike Simone", "EpicElric",
"Sean voets", "Sean voets",
"Owen Gwosdz", "Owen Gwosdz",
"Morgandel", "John J Linehan",
"Elliot E",
"Thomas Wanner", "Thomas Wanner",
"Kyron Mahan",
"Theerat Jiramate", "Theerat Jiramate",
"Noah", "Edward Kennedy",
"Jacob McDaniel", "Justin Blaylock",
"Devil Lude",
"Nick Kage",
"kevin stoddard", "kevin stoddard",
"Sloan Steddy",
"Jack Dole", "Jack Dole",
"Vane Holzer",
"psytrax",
"Ezokewn", "Ezokewn",
"Temikus", "hexxish",
"Artokun", "CptNeo",
"Michael Taylor", "notedfakes",
"Derek Baker",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Maso", "Maso",
"Nathan", "Eric Ketchum",
"Decx _", "NICHOLAS BAXLEY",
"Michael Scott",
"Kevin Wallace", "Kevin Wallace",
"Matheus Couto", "Matheus Couto",
"Paul Hartsuyker", "Saya",
"ChicRic", "ChicRic",
"mercur", "mercur",
"J C", "J C",
"Distortik", "Ed Wang",
"Ryan Presley Ng",
"Wes Sims",
"Donor4115",
"Yves Poezevara", "Yves Poezevara",
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Вячеслав Маринин", "Вячеслав Маринин",
"Lyavph",
"Filippo Ferrari",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -505,117 +568,100 @@
"RevyHiep", "RevyHiep",
"Captain_Swag", "Captain_Swag",
"obkircher", "obkircher",
"Tree Tagger",
"gwyar", "gwyar",
"D", "D",
"edgecase", "edgecase",
"Neoxena", "Neoxena",
"mrmhalo", "mrmhalo",
"dg", "dg",
"Whitepinetrader",
"Maarten Harms", "Maarten Harms",
"OrganicArtifact",
"四糸凜音",
"MudkipMedkitz",
"Israel", "Israel",
"deanbrian",
"POPPIN",
"Muratoraccio", "Muratoraccio",
"SelfishMedic", "SelfishMedic",
"Ginnie", "Ginnie",
"Alex Wortman",
"Cody",
"adderleighn", "adderleighn",
"Raku",
"smart.edge5178",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"D",
"Pitpe11",
"TheD1rtyD03",
"EnragedAntelope", "EnragedAntelope",
"moonpetal", "Alan+Cano",
"SomeDude", "FeralOpticsAI",
"g9p0o", "Pavlaki",
"nanana", "generic404",
"TheHolySheep", "Mateusz+Kosela",
"Monte Won", "Doug+Rintoul",
"SpringBootisTrash", "Noor",
"carsten", "Yorunai",
"ikok", "Bula",
"Buecyb99", "quantenmecha",
"4IXplr0r3r", "abattoirblues",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"zounik",
"letzte",
"Nasty+Hobbit",
"SgtFluffles",
"lrdchs2",
"Duk3+Rand0m",
"KUJYAKU",
"NathenChoi",
"Thomas+Reck",
"Larses",
"cocona",
"Coeur+de+cochon", "Coeur+de+cochon",
"David Schenck", "David Schenck",
"han b", "han b",
"Nico", "Nico",
"Wolfe7D1",
"Banana Joe", "Banana Joe",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"Ink Temptation", "JBsuede",
"edk",
"Michael Eid", "Michael Eid",
"beersandbacon", "beersandbacon",
"Maximilian Pyko", "Maximilian Pyko",
"Invis", "Invis",
"Kalli Core",
"Justin Houston", "Justin Houston",
"Time Valentine",
"james", "james",
"elleshar666",
"OrochiNights", "OrochiNights",
"Michael Zhu", "Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo", "gonzalo",
"Seraphy", "Seraphy",
"Михал Михалыч",
"雨の心 落", "雨の心 落",
"Matt",
"AllTimeNoobie", "AllTimeNoobie",
"jumpd", "jumpd",
"John C", "John C",
"Kauffy",
"Rim", "Rim",
"Dismem", "Dismem",
"EpicElric", "Frogmilk",
"John J Linehan", "SPJ",
"Xan Dionysus", "Xan Dionysus",
"Nathan lee", "Nathan lee",
"Mewtora", "Mewtora",
"Elliot E",
"Middo", "Middo",
"Forbidden Atelier", "Forbidden Atelier",
"Edward Kennedy", "Bryan Rutkowski",
"Justin Blaylock",
"Adictedtohumping", "Adictedtohumping",
"Devil Lude",
"Nick Kage",
"Towelie", "Towelie",
"Vane Holzer",
"psytrax",
"Cyrus Fett", "Cyrus Fett",
"Jean-françois SEMA", "Jean-françois SEMA",
"Kurt", "Kurt",
"hexxish", "max blo",
"giani kidd", "Xenon Xue",
"CptNeo", "JackJohnnyJim",
"notedfakes", "Edward Ten Eyck",
"Chase Kwon", "Chase Kwon",
"Inyoshu",
"Goober719", "Goober719",
"Eric Ketchum",
"Chad Barnes", "Chad Barnes",
"NICHOLAS BAXLEY",
"Michael Scott",
"James Ming", "James Ming",
"vanditking", "vanditking",
"kripitonga", "kripitonga",
"Rizzi", "Rizzi",
"nimin", "nimin",
"OMAR LUCIANO", "OMAR LUCIANO",
"hannibal",
"Jo+Example", "Jo+Example",
"BrentBertram", "BrentBertram",
"eumelzocker", "eumelzocker",
@@ -623,5 +669,5 @@
"L C", "L C",
"Dude" "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

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Zusätzliche Ordnerpfade", "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": "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.",
"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.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRA-Pfade", "lora": "LoRA-Pfade",
"checkpoint": "Checkpoint-Pfade", "checkpoint": "Checkpoint-Pfade",
@@ -402,7 +402,7 @@
"embedding": "Embedding-Pfade" "embedding": "Embedding-Pfade"
}, },
"pathPlaceholder": "/pfad/zu/extra/modellen", "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}", "saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
"validation": { "validation": {
"duplicatePath": "Dieser Pfad ist bereits konfiguriert" "duplicatePath": "Dieser Pfad ist bereits konfiguriert"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben" "moveToOtherTypeFolder": "In {otherType}-Ordner verschieben",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "Sidebar lösen", "unpinSidebar": "Sidebar lösen",
"switchToListView": "Zur Listenansicht wechseln", "switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln", "switchToTreeView": "Zur Baumansicht wechseln",
"recursiveOn": "Unterordner durchsuchen", "recursiveOn": "Unterordner einbeziehen",
"recursiveOff": "Nur aktuellen Ordner durchsuchen", "recursiveOff": "Nur aktueller Ordner",
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar", "recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar", "collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "Auf Civitai anzeigen", "viewOnCivitai": "Auf Civitai anzeigen",
"viewOnCivitaiText": "Auf Civitai anzeigen", "viewOnCivitaiText": "Auf Civitai anzeigen",
"viewCreatorProfile": "Ersteller-Profil anzeigen", "viewCreatorProfile": "Ersteller-Profil anzeigen",
"openFileLocation": "Dateispeicherort öffnen" "openFileLocation": "Dateispeicherort öffnen",
"sendToWorkflow": "An ComfyUI senden",
"sendToWorkflowText": "An ComfyUI senden"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Dateispeicherort erfolgreich geöffnet", "success": "Dateispeicherort erfolgreich geöffnet",
@@ -1077,6 +1080,9 @@
"copied": "Pfad in die Zwischenablage kopiert: {{path}}", "copied": "Pfad in die Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{path}}" "clipboardFallback": "Pfad: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "Dateiname", "fileName": "Dateiname",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "Rezept im Workflow ersetzt", "recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow", "recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar", "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": { "nodeSelector": {
"recipe": "Rezept", "recipe": "Rezept",
@@ -1505,7 +1513,11 @@
"nameUpdated": "Rezeptname erfolgreich aktualisiert", "nameUpdated": "Rezeptname erfolgreich aktualisiert",
"tagsUpdated": "Rezept-Tags erfolgreich aktualisiert", "tagsUpdated": "Rezept-Tags erfolgreich aktualisiert",
"sourceUrlUpdated": "Quell-URL 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", "noRecipeId": "Keine Rezept-ID verfügbar",
"sendToWorkflowFailed": "Fehler beim Senden des Rezepts an den Workflow: {message}",
"copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}", "copyFailed": "Fehler beim Kopieren der Rezept-Syntax: {message}",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Extra Folder Paths", "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": "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.",
"description": "Configure additional folders to scan for models. These paths are specific to LoRA Manager and will be merged with ComfyUI's default paths.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRA Paths", "lora": "LoRA Paths",
"checkpoint": "Checkpoint Paths", "checkpoint": "Checkpoint Paths",
@@ -402,7 +402,7 @@
"embedding": "Embedding Paths" "embedding": "Embedding Paths"
}, },
"pathPlaceholder": "/path/to/extra/models", "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}", "saveError": "Failed to update extra folder paths: {message}",
"validation": { "validation": {
"duplicatePath": "This path is already configured" "duplicatePath": "This path is already configured"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Move to {otherType} Folder" "moveToOtherTypeFolder": "Move to {otherType} Folder",
"sendToWorkflow": "Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "Unpin Sidebar", "unpinSidebar": "Unpin Sidebar",
"switchToListView": "Switch to List View", "switchToListView": "Switch to List View",
"switchToTreeView": "Switch to Tree View", "switchToTreeView": "Switch to Tree View",
"recursiveOn": "Search subfolders", "recursiveOn": "Include subfolders",
"recursiveOff": "Search current folder only", "recursiveOff": "Current folder only",
"recursiveUnavailable": "Recursive search is available in tree view only", "recursiveUnavailable": "Recursive search is available in tree view only",
"collapseAllDisabled": "Not available in list view", "collapseAllDisabled": "Not available in list view",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "View on Civitai", "viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai", "viewOnCivitaiText": "View on Civitai",
"viewCreatorProfile": "View Creator Profile", "viewCreatorProfile": "View Creator Profile",
"openFileLocation": "Open File Location" "openFileLocation": "Open File Location",
"sendToWorkflow": "Send to ComfyUI",
"sendToWorkflowText": "Send to ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "File location opened successfully", "success": "File location opened successfully",
@@ -1077,6 +1080,9 @@
"copied": "Path copied to clipboard: {{path}}", "copied": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{path}}" "clipboardFallback": "Path: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Unable to send to ComfyUI: No file path available"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "File Name", "fileName": "File Name",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "Recipe replaced in workflow", "recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow", "recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current 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": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
@@ -1505,7 +1513,11 @@
"nameUpdated": "Recipe name updated successfully", "nameUpdated": "Recipe name updated successfully",
"tagsUpdated": "Recipe tags updated successfully", "tagsUpdated": "Recipe tags updated successfully",
"sourceUrlUpdated": "Source URL 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", "noRecipeId": "No recipe ID available",
"sendToWorkflowFailed": "Failed to send recipe to workflow: {message}",
"copyFailed": "Error copying recipe syntax: {message}", "copyFailed": "Error copying recipe syntax: {message}",
"noMissingLoras": "No missing LoRAs to download", "noMissingLoras": "No missing LoRAs to download",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs", "missingLorasInfoFailed": "Failed to get information for missing LoRAs",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Rutas de carpetas adicionales", "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": "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.",
"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.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "Rutas de LoRA", "lora": "Rutas de LoRA",
"checkpoint": "Rutas de Checkpoint", "checkpoint": "Rutas de Checkpoint",
@@ -402,7 +402,7 @@
"embedding": "Rutas de Embedding" "embedding": "Rutas de Embedding"
}, },
"pathPlaceholder": "/ruta/a/modelos/extra", "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}", "saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
"validation": { "validation": {
"duplicatePath": "Esta ruta ya está configurada" "duplicatePath": "Esta ruta ya está configurada"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}" "moveToOtherTypeFolder": "Mover a la carpeta {otherType}",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "Desfijar barra lateral", "unpinSidebar": "Desfijar barra lateral",
"switchToListView": "Cambiar a vista de lista", "switchToListView": "Cambiar a vista de lista",
"switchToTreeView": "Cambiar a vista de árbol", "switchToTreeView": "Cambiar a vista de árbol",
"recursiveOn": "Buscar en subcarpetas", "recursiveOn": "Incluir subcarpetas",
"recursiveOff": "Buscar solo en la carpeta actual", "recursiveOff": "Solo carpeta actual",
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol", "recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista", "collapseAllDisabled": "No disponible en vista de lista",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "Ver en Civitai", "viewOnCivitai": "Ver en Civitai",
"viewOnCivitaiText": "Ver en Civitai", "viewOnCivitaiText": "Ver en Civitai",
"viewCreatorProfile": "Ver perfil del creador", "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": { "openFileLocation": {
"success": "Ubicación del archivo abierta exitosamente", "success": "Ubicación del archivo abierta exitosamente",
@@ -1077,6 +1080,9 @@
"copied": "Ruta copiada al portapapeles: {{path}}", "copied": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{path}}" "clipboardFallback": "Ruta: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
},
"metadata": { "metadata": {
"version": "Versión", "version": "Versión",
"fileName": "Nombre de archivo", "fileName": "Nombre de archivo",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo", "recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo", "recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual", "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": { "nodeSelector": {
"recipe": "Receta", "recipe": "Receta",
@@ -1505,7 +1513,11 @@
"nameUpdated": "Nombre de receta actualizado exitosamente", "nameUpdated": "Nombre de receta actualizado exitosamente",
"tagsUpdated": "Etiquetas de receta actualizadas exitosamente", "tagsUpdated": "Etiquetas de receta actualizadas exitosamente",
"sourceUrlUpdated": "URL de origen actualizada 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", "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}", "copyFailed": "Error copiando sintaxis de receta: {message}",
"noMissingLoras": "No hay LoRAs faltantes para descargar", "noMissingLoras": "No hay LoRAs faltantes para descargar",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Chemins de dossiers supplémentaires", "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": "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.",
"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.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "Chemins LoRA", "lora": "Chemins LoRA",
"checkpoint": "Chemins Checkpoint", "checkpoint": "Chemins Checkpoint",
@@ -402,7 +402,7 @@
"embedding": "Chemins Embedding" "embedding": "Chemins Embedding"
}, },
"pathPlaceholder": "/chemin/vers/modèles/supplémentaires", "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}", "saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
"validation": { "validation": {
"duplicatePath": "Ce chemin est déjà configuré" "duplicatePath": "Ce chemin est déjà configuré"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}" "moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "Désépingler la barre latérale", "unpinSidebar": "Désépingler la barre latérale",
"switchToListView": "Passer en vue liste", "switchToListView": "Passer en vue liste",
"switchToTreeView": "Passer en vue arborescence", "switchToTreeView": "Passer en vue arborescence",
"recursiveOn": "Rechercher dans les sous-dossiers", "recursiveOn": "Inclure les sous-dossiers",
"recursiveOff": "Rechercher uniquement dans le dossier actuel", "recursiveOff": "Dossier actuel uniquement",
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente", "recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste", "collapseAllDisabled": "Non disponible en vue liste",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "Voir sur Civitai", "viewOnCivitai": "Voir sur Civitai",
"viewOnCivitaiText": "Voir sur Civitai", "viewOnCivitaiText": "Voir sur Civitai",
"viewCreatorProfile": "Voir le profil du créateur", "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": { "openFileLocation": {
"success": "Emplacement du fichier ouvert avec succès", "success": "Emplacement du fichier ouvert avec succès",
@@ -1077,6 +1080,9 @@
"copied": "Chemin copié dans le presse-papiers: {{path}}", "copied": "Chemin copié dans le presse-papiers: {{path}}",
"clipboardFallback": "Chemin: {{path}}" "clipboardFallback": "Chemin: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
},
"metadata": { "metadata": {
"version": "Version", "version": "Version",
"fileName": "Nom de fichier", "fileName": "Nom de fichier",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "Recipe remplacée dans le workflow", "recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow", "recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel", "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": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
@@ -1505,7 +1513,11 @@
"nameUpdated": "Nom de la recipe mis à jour avec succès", "nameUpdated": "Nom de la recipe mis à jour avec succès",
"tagsUpdated": "Tags 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", "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", "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}", "copyFailed": "Erreur lors de la copie de la syntaxe de la recipe : {message}",
"noMissingLoras": "Aucun LoRA manquant à télécharger", "noMissingLoras": "Aucun LoRA manquant à télécharger",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "נתיבי תיקיות נוספים", "title": "נתיבי תיקיות נוספים",
"help": "הוסף תיקיות מודלים נוספות מחוץ לנתיבים הסטנדרטיים של ComfyUI. נתיבים אלה נשמרים בנפרד ונסרקים לצד תיקיות ברירת המחדל.", "description": "נתיבי שורש מודלים נוספים בלעדיים ל-LoRA Manager. טען מודלים ממיקומים מחוץ לתיקיות הסטנדרטיות של ComfyUI - אידיאלי לספריות גדולות שאחרת יאטו את ComfyUI.",
"description": "הגדר תיקיות נוספות לסריקת מודלים. נתיבים אלה ספציפיים ל-LoRA Manager וימוזגו עם נתיבי ברירת המחדל של ComfyUI.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "נתיבי LoRA", "lora": "נתיבי LoRA",
"checkpoint": "נתיבי Checkpoint", "checkpoint": "נתיבי Checkpoint",
@@ -402,7 +402,7 @@
"embedding": "נתיבי Embedding" "embedding": "נתיבי Embedding"
}, },
"pathPlaceholder": "/נתיב/למודלים/נוספים", "pathPlaceholder": "/נתיב/למודלים/נוספים",
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.", "saveSuccess": "נתיבי תיקיות נוספים עודכנו. נדרשת הפעלה מחדש כדי להחיל את השינויים.",
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}", "saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
"validation": { "validation": {
"duplicatePath": "נתיב זה כבר מוגדר" "duplicatePath": "נתיב זה כבר מוגדר"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}" "moveToOtherTypeFolder": "העבר לתיקיית {otherType}",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "שחרר סרגל צד", "unpinSidebar": "שחרר סרגל צד",
"switchToListView": "עבור לתצוגת רשימה", "switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "תצוגת עץ", "switchToTreeView": "תצוגת עץ",
"recursiveOn": "חיפוש בתיקיות משנה", "recursiveOn": "כלול תיקיות משנה",
"recursiveOff": "חיפוש רק בתיקייה הנוכחית", "recursiveOff": "רק התיקייה הנוכחית",
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", "recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "הצג ב-Civitai", "viewOnCivitai": "הצג ב-Civitai",
"viewOnCivitaiText": "הצג ב-Civitai", "viewOnCivitaiText": "הצג ב-Civitai",
"viewCreatorProfile": "הצג פרופיל יוצר", "viewCreatorProfile": "הצג פרופיל יוצר",
"openFileLocation": "פתח מיקום קובץ" "openFileLocation": "פתח מיקום קובץ",
"sendToWorkflow": "שלח ל-ComfyUI",
"sendToWorkflowText": "שלח ל-ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "מיקום הקובץ נפתח בהצלחה", "success": "מיקום הקובץ נפתח בהצלחה",
@@ -1077,6 +1080,9 @@
"copied": "הנתיב הועתק ללוח העריכה: {{path}}", "copied": "הנתיב הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב: {{path}}" "clipboardFallback": "נתיב: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
},
"metadata": { "metadata": {
"version": "גרסה", "version": "גרסה",
"fileName": "שם קובץ", "fileName": "שם קובץ",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "מתכון הוחלף ב-workflow", "recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה", "recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי", "noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד" "noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "מתכון", "recipe": "מתכון",
@@ -1505,7 +1513,11 @@
"nameUpdated": "שם המתכון עודכן בהצלחה", "nameUpdated": "שם המתכון עודכן בהצלחה",
"tagsUpdated": "תגיות המתכון עודכנו בהצלחה", "tagsUpdated": "תגיות המתכון עודכנו בהצלחה",
"sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה", "sourceUrlUpdated": "כתובת ה-URL המקורית עודכנה בהצלחה",
"promptUpdated": "הפרומפט עודכן בהצלחה",
"negativePromptUpdated": "הפרומפט השלילי עודכן בהצלחה",
"promptEditorHint": "לחץ Enter לשמירה, Shift+Enter לשורה חדשה",
"noRecipeId": "אין מזהה מתכון זמין", "noRecipeId": "אין מזהה מתכון זמין",
"sendToWorkflowFailed": "נכשל שליחת המתכון ל-workflow: {message}",
"copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}", "copyFailed": "שגיאה בהעתקת תחביר המתכון: {message}",
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "追加フォルダーパス", "title": "追加フォルダーパス",
"help": "ComfyUIの標準パスの外部に追加のモデルフォルダを追加します。これらのパスは別々に保存され、デフォルトのフォルダと一緒にスキャンされます。", "description": "LoRA Manager専用の追加モデルルートパス。ComfyUIの標準フォルダー外の場所からモデルを読み込みます。ComfyUIの動作を低下させる可能性のある大規模ライブラリに最適です。",
"description": "モデルをスキャンするための追加フォルダを設定します。これらのパスはLoRA Manager固有であり、ComfyUIのデフォルトパスとマージされます。", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRAパス", "lora": "LoRAパス",
"checkpoint": "Checkpointパス", "checkpoint": "Checkpointパス",
@@ -402,7 +402,7 @@
"embedding": "Embeddingパス" "embedding": "Embeddingパス"
}, },
"pathPlaceholder": "/追加モデルへのパス", "pathPlaceholder": "/追加モデルへのパス",
"saveSuccess": "追加フォルダーパスを更新しました。", "saveSuccess": "追加フォルダーパスを更新しました。変更を適用するには再起動が必要です。",
"saveError": "追加フォルダーパスの更新に失敗しました: {message}", "saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": { "validation": {
"duplicatePath": "このパスはすでに設定されています" "duplicatePath": "このパスはすでに設定されています"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "{otherType} フォルダに移動" "moveToOtherTypeFolder": "{otherType} フォルダに移動",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "サイドバーの固定を解除", "unpinSidebar": "サイドバーの固定を解除",
"switchToListView": "リストビューに切り替え", "switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリー表示に切り替え", "switchToTreeView": "ツリー表示に切り替え",
"recursiveOn": "サブフォルダーを検索", "recursiveOn": "サブフォルダーを含める",
"recursiveOff": "現在のフォルダーのみを検索", "recursiveOff": "現在のフォルダーのみ",
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", "recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません", "collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "Civitaiで表示", "viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示", "viewOnCivitaiText": "Civitaiで表示",
"viewCreatorProfile": "作成者プロフィールを表示", "viewCreatorProfile": "作成者プロフィールを表示",
"openFileLocation": "ファイルの場所を開く" "openFileLocation": "ファイルの場所を開く",
"sendToWorkflow": "ComfyUI に送信",
"sendToWorkflowText": "ComfyUI に送信"
}, },
"openFileLocation": { "openFileLocation": {
"success": "ファイルの場所を正常に開きました", "success": "ファイルの場所を正常に開きました",
@@ -1077,6 +1080,9 @@
"copied": "パスをクリップボードにコピーしました: {{path}}", "copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}" "clipboardFallback": "パス: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
},
"metadata": { "metadata": {
"version": "バージョン", "version": "バージョン",
"fileName": "ファイル名", "fileName": "ファイル名",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "レシピがワークフローで置換されました", "recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした", "recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません", "noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません" "noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "レシピ", "recipe": "レシピ",
@@ -1505,7 +1513,11 @@
"nameUpdated": "レシピ名が正常に更新されました", "nameUpdated": "レシピ名が正常に更新されました",
"tagsUpdated": "レシピタグが正常に更新されました", "tagsUpdated": "レシピタグが正常に更新されました",
"sourceUrlUpdated": "ソースURLが正常に更新されました", "sourceUrlUpdated": "ソースURLが正常に更新されました",
"promptUpdated": "プロンプトが正常に更新されました",
"negativePromptUpdated": "ネガティブプロンプトが正常に更新されました",
"promptEditorHint": "Enterキーで保存、Shift+Enterで改行",
"noRecipeId": "レシピIDが利用できません", "noRecipeId": "レシピIDが利用できません",
"sendToWorkflowFailed": "ワークフローへのレシピ送信に失敗しました:{message}",
"copyFailed": "レシピ構文のコピーエラー:{message}", "copyFailed": "レシピ構文のコピーエラー:{message}",
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "추가 폴다 경로", "title": "추가 폴다 경로",
"help": "ComfyUI의 표준 경로 외부에 추가 모델 폴드를 추가하세요. 이러한 경로는 별도로 저장되며 기본 폴와 함께 스캔됩니다.", "description": "LoRA Manager 전용 추가 모델 루트 경로입니다. ComfyUI의 표준 폴더 외부 위치에서 모델을 로드하여 대규모 라이브러리로 인한 성능 저하를 방지합니다.",
"description": "모델을 스캔하기 위한 추가 폴를 설정하세요. 이러한 경로는 LoRA Manager 특유의 것이며 ComfyUI의 기본 경로와 병합됩니다.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRA 경로", "lora": "LoRA 경로",
"checkpoint": "Checkpoint 경로", "checkpoint": "Checkpoint 경로",
@@ -402,7 +402,7 @@
"embedding": "Embedding 경로" "embedding": "Embedding 경로"
}, },
"pathPlaceholder": "/추가/모델/경로", "pathPlaceholder": "/추가/모델/경로",
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.", "saveSuccess": "추가 폴다 경로가 업데이트되었습니다. 변경 사항을 적용하려면 재시작이 필요합니다.",
"saveError": "추가 폴다 경로 업데이트 실패: {message}", "saveError": "추가 폴다 경로 업데이트 실패: {message}",
"validation": { "validation": {
"duplicatePath": "이 경로는 이미 구성되어 있습니다" "duplicatePath": "이 경로는 이미 구성되어 있습니다"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "{otherType} 폴더로 이동" "moveToOtherTypeFolder": "{otherType} 폴더로 이동",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "사이드바 고정 해제", "unpinSidebar": "사이드바 고정 해제",
"switchToListView": "목록 보기로 전환", "switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환", "switchToTreeView": "트리 보기로 전환",
"recursiveOn": "하위 폴더 검색", "recursiveOn": "하위 폴더 포함",
"recursiveOff": "현재 폴더만 검색", "recursiveOff": "현재 폴더만",
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", "recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "Civitai에서 보기", "viewOnCivitai": "Civitai에서 보기",
"viewOnCivitaiText": "Civitai에서 보기", "viewOnCivitaiText": "Civitai에서 보기",
"viewCreatorProfile": "제작자 프로필 보기", "viewCreatorProfile": "제작자 프로필 보기",
"openFileLocation": "파일 위치 열기" "openFileLocation": "파일 위치 열기",
"sendToWorkflow": "ComfyUI로 보내기",
"sendToWorkflowText": "ComfyUI로 보내기"
}, },
"openFileLocation": { "openFileLocation": {
"success": "파일 위치가 성공적으로 열렸습니다", "success": "파일 위치가 성공적으로 열렸습니다",
@@ -1077,6 +1080,9 @@
"copied": "경로가 클립보드에 복사되었습니다: {{path}}", "copied": "경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "경로: {{path}}" "clipboardFallback": "경로: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
},
"metadata": { "metadata": {
"version": "버전", "version": "버전",
"fileName": "파일명", "fileName": "파일명",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다", "recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다", "recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다", "noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다" "noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "레시피", "recipe": "레시피",
@@ -1505,7 +1513,11 @@
"nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다", "nameUpdated": "레시피 이름이 성공적으로 업데이트되었습니다",
"tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다", "tagsUpdated": "레시피 태그가 성공적으로 업데이트되었습니다",
"sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다", "sourceUrlUpdated": "소스 URL이 성공적으로 업데이트되었습니다",
"promptUpdated": "프롬프트가 성공적으로 업데이트되었습니다",
"negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다",
"promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄",
"noRecipeId": "사용 가능한 레시피 ID가 없습니다", "noRecipeId": "사용 가능한 레시피 ID가 없습니다",
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
"copyFailed": "레시피 문법 복사 오류: {message}", "copyFailed": "레시피 문법 복사 오류: {message}",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "Дополнительные пути к папкам", "title": "Дополнительные пути к папкам",
"help": "Добавьте дополнительные папки моделей за пределами стандартных путей ComfyUI. Эти пути хранятся отдельно и сканируются вместе с папками по умолчанию.", "description": "Дополнительные корневые пути моделей, эксклюзивные для LoRA Manager. Загружайте модели из расположений за пределами стандартных папок ComfyUI — идеально подходит для больших библиотек, которые иначе замедлили бы ComfyUI.",
"description": "Настройте дополнительные папки для сканирования моделей. Эти пути специфичны для LoRA Manager и будут объединены с путями по умолчанию ComfyUI.", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "Пути LoRA", "lora": "Пути LoRA",
"checkpoint": "Пути Checkpoint", "checkpoint": "Пути Checkpoint",
@@ -402,7 +402,7 @@
"embedding": "Пути Embedding" "embedding": "Пути Embedding"
}, },
"pathPlaceholder": "/путь/к/дополнительным/моделям", "pathPlaceholder": "/путь/к/дополнительным/моделям",
"saveSuccess": "Дополнительные пути к папкам обновлены.", "saveSuccess": "Дополнительные пути к папкам обновлены. Требуется перезапуск для применения изменений.",
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}", "saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
"validation": { "validation": {
"duplicatePath": "Этот путь уже настроен" "duplicatePath": "Этот путь уже настроен"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "Переместить в папку {otherType}" "moveToOtherTypeFolder": "Переместить в папку {otherType}",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "Открепить боковую панель", "unpinSidebar": "Открепить боковую панель",
"switchToListView": "Переключить на вид списка", "switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид", "switchToTreeView": "Переключить на древовидный вид",
"recursiveOn": "Искать во вложенных папках", "recursiveOn": "Включать вложенные папки",
"recursiveOff": "Искать только в текущей папке", "recursiveOff": "Только текущая папка",
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", "recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка", "collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "Посмотреть на Civitai", "viewOnCivitai": "Посмотреть на Civitai",
"viewOnCivitaiText": "Посмотреть на Civitai", "viewOnCivitaiText": "Посмотреть на Civitai",
"viewCreatorProfile": "Посмотреть профиль создателя", "viewCreatorProfile": "Посмотреть профиль создателя",
"openFileLocation": "Открыть расположение файла" "openFileLocation": "Открыть расположение файла",
"sendToWorkflow": "Отправить в ComfyUI",
"sendToWorkflowText": "Отправить в ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Расположение файла успешно открыто", "success": "Расположение файла успешно открыто",
@@ -1077,6 +1080,9 @@
"copied": "Путь скопирован в буфер обмена: {{path}}", "copied": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}" "clipboardFallback": "Путь: {{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
},
"metadata": { "metadata": {
"version": "Версия", "version": "Версия",
"fileName": "Имя файла", "fileName": "Имя файла",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "Рецепт заменён в workflow", "recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow", "recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов", "noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран" "noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Рецепт", "recipe": "Рецепт",
@@ -1505,7 +1513,11 @@
"nameUpdated": "Название рецепта успешно обновлено", "nameUpdated": "Название рецепта успешно обновлено",
"tagsUpdated": "Теги рецепта успешно обновлены", "tagsUpdated": "Теги рецепта успешно обновлены",
"sourceUrlUpdated": "Исходный URL успешно обновлен", "sourceUrlUpdated": "Исходный URL успешно обновлен",
"promptUpdated": "Промпт успешно обновлён",
"negativePromptUpdated": "Негативный промпт успешно обновлён",
"promptEditorHint": "Нажмите Enter для сохранения, Shift+Enter для новой строки",
"noRecipeId": "ID рецепта недоступен", "noRecipeId": "ID рецепта недоступен",
"sendToWorkflowFailed": "Не удалось отправить рецепт в рабочий процесс: {message}",
"copyFailed": "Ошибка копирования синтаксиса рецепта: {message}", "copyFailed": "Ошибка копирования синтаксиса рецепта: {message}",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "额外文件夹路径", "title": "额外文件夹路径",
"help": "在 ComfyUI 标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。", "description": "LoRA Manager 专属的额外模型根目录。从 ComfyUI 标准文件夹之外的位置加载模型,特别适合管理大型模型库,避免影响 ComfyUI 性能。",
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。", "restartRequired": "需要重启才能生效",
"modelTypes": { "modelTypes": {
"lora": "LoRA 路径", "lora": "LoRA 路径",
"checkpoint": "Checkpoint 路径", "checkpoint": "Checkpoint 路径",
@@ -402,7 +402,7 @@
"embedding": "Embedding 路径" "embedding": "Embedding 路径"
}, },
"pathPlaceholder": "/额外/模型/路径", "pathPlaceholder": "/额外/模型/路径",
"saveSuccess": "额外文件夹路径已更新。", "saveSuccess": "额外文件夹路径已更新,需要重启才能生效。",
"saveError": "更新额外文件夹路径失败:{message}", "saveError": "更新额外文件夹路径失败:{message}",
"validation": { "validation": {
"duplicatePath": "此路径已配置" "duplicatePath": "此路径已配置"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹" "moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "取消固定侧边栏", "unpinSidebar": "取消固定侧边栏",
"switchToListView": "切换到列表视图", "switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图", "switchToTreeView": "切换到树状视图",
"recursiveOn": "搜索子文件夹", "recursiveOn": "包含子文件夹",
"recursiveOff": "仅搜索当前文件夹", "recursiveOff": "仅当前文件夹",
"recursiveUnavailable": "仅在树形视图中可使用递归搜索", "recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用", "collapseAllDisabled": "列表视图下不可用",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看创作者主页", "viewCreatorProfile": "查看创作者主页",
"openFileLocation": "打开文件位置" "openFileLocation": "打开文件位置",
"sendToWorkflow": "发送到 ComfyUI",
"sendToWorkflowText": "发送到 ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "文件位置已成功打开", "success": "文件位置已成功打开",
@@ -1077,6 +1080,9 @@
"copied": "路径已复制到剪贴板:{{path}}", "copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}" "clipboardFallback": "路径:{{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "无法发送到 ComfyUI没有可用的文件路径"
},
"metadata": { "metadata": {
"version": "版本", "version": "版本",
"fileName": "文件名", "fileName": "文件名",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "配方已替换到工作流", "recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败", "recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点", "noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点" "noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
@@ -1505,7 +1513,11 @@
"nameUpdated": "配方名称更新成功", "nameUpdated": "配方名称更新成功",
"tagsUpdated": "配方标签更新成功", "tagsUpdated": "配方标签更新成功",
"sourceUrlUpdated": "来源 URL 更新成功", "sourceUrlUpdated": "来源 URL 更新成功",
"promptUpdated": "提示词更新成功",
"negativePromptUpdated": "负面提示词更新成功",
"promptEditorHint": "按 Enter 保存Shift+Enter 换行",
"noRecipeId": "无配方 ID", "noRecipeId": "无配方 ID",
"sendToWorkflowFailed": "发送配方到工作流失败:{message}",
"copyFailed": "复制配方语法出错:{message}", "copyFailed": "复制配方语法出错:{message}",
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败",

View File

@@ -393,8 +393,8 @@
}, },
"extraFolderPaths": { "extraFolderPaths": {
"title": "額外資料夾路徑", "title": "額外資料夾路徑",
"help": "在 ComfyUI 標準路徑之外新增額外的模型資料夾。這些路徑單獨儲存,並與預設資料夾一起掃描。", "description": "LoRA Manager 專屬的額外模型根目錄。從 ComfyUI 標準資料夾之外的位置載入模型,特別適合管理大型模型庫,避免影響 ComfyUI 效能。",
"description": "設定額外的資料夾以掃描模型。這些路徑是 LoRA Manager 特有的,將與 ComfyUI 的預設路徑合併。", "restartRequired": "Requires restart to take effect",
"modelTypes": { "modelTypes": {
"lora": "LoRA 路徑", "lora": "LoRA 路徑",
"checkpoint": "Checkpoint 路徑", "checkpoint": "Checkpoint 路徑",
@@ -402,7 +402,7 @@
"embedding": "Embedding 路徑" "embedding": "Embedding 路徑"
}, },
"pathPlaceholder": "/額外/模型/路徑", "pathPlaceholder": "/額外/模型/路徑",
"saveSuccess": "額外資料夾路徑已更新。", "saveSuccess": "額外資料夾路徑已更新,需要重啟才能生效。",
"saveError": "更新額外資料夾路徑失敗:{message}", "saveError": "更新額外資料夾路徑失敗:{message}",
"validation": { "validation": {
"duplicatePath": "此路徑已設定" "duplicatePath": "此路徑已設定"
@@ -826,7 +826,8 @@
"diffusion_model": "Diffusion Model" "diffusion_model": "Diffusion Model"
}, },
"contextMenu": { "contextMenu": {
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾" "moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
"sendToWorkflow": "[TODO: Translate] Send to Workflow"
} }
}, },
"embeddings": { "embeddings": {
@@ -839,8 +840,8 @@
"unpinSidebar": "取消固定側邊欄", "unpinSidebar": "取消固定側邊欄",
"switchToListView": "切換至列表檢視", "switchToListView": "切換至列表檢視",
"switchToTreeView": "切換到樹狀檢視", "switchToTreeView": "切換到樹狀檢視",
"recursiveOn": "搜尋子資料夾", "recursiveOn": "包含子資料夾",
"recursiveOff": "僅搜尋目前資料夾", "recursiveOff": "僅目前資料夾",
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", "recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用", "collapseAllDisabled": "列表檢視下不可用",
"dragDrop": { "dragDrop": {
@@ -1069,7 +1070,9 @@
"viewOnCivitai": "在 Civitai 查看", "viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看", "viewOnCivitaiText": "在 Civitai 查看",
"viewCreatorProfile": "查看創作者個人檔案", "viewCreatorProfile": "查看創作者個人檔案",
"openFileLocation": "開啟檔案位置" "openFileLocation": "開啟檔案位置",
"sendToWorkflow": "傳送到 ComfyUI",
"sendToWorkflowText": "傳送到 ComfyUI"
}, },
"openFileLocation": { "openFileLocation": {
"success": "檔案位置已成功開啟", "success": "檔案位置已成功開啟",
@@ -1077,6 +1080,9 @@
"copied": "路徑已複製到剪貼簿:{{path}}", "copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}" "clipboardFallback": "路徑:{{path}}"
}, },
"sendToWorkflow": {
"noFilePath": "無法傳送到 ComfyUI沒有可用的檔案路徑"
},
"metadata": { "metadata": {
"version": "版本", "version": "版本",
"fileName": "檔案名稱", "fileName": "檔案名稱",
@@ -1334,7 +1340,9 @@
"recipeReplaced": "配方已取代於工作流", "recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗", "recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點", "noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點" "noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
@@ -1505,7 +1513,11 @@
"nameUpdated": "配方名稱已更新", "nameUpdated": "配方名稱已更新",
"tagsUpdated": "配方標籤已更新", "tagsUpdated": "配方標籤已更新",
"sourceUrlUpdated": "來源網址已更新", "sourceUrlUpdated": "來源網址已更新",
"promptUpdated": "提示詞更新成功",
"negativePromptUpdated": "負面提示詞更新成功",
"promptEditorHint": "按 Enter 儲存Shift+Enter 換行",
"noRecipeId": "無配方 ID", "noRecipeId": "無配方 ID",
"sendToWorkflowFailed": "傳送配方到工作流失敗:{message}",
"copyFailed": "複製配方語法錯誤:{message}", "copyFailed": "複製配方語法錯誤:{message}",
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",

View File

@@ -25,6 +25,31 @@ standalone_mode = (
logger = logging.getLogger(__name__) 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( def _normalize_folder_paths_for_comparison(
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
@@ -197,25 +222,23 @@ class Config:
"Failed to rename legacy 'default' library: %s", rename_error "Failed to rename legacy 'default' library: %s", rename_error
) )
default_lora_root = comfy_library.get("default_lora_root", "") default_lora_root = _resolve_valid_default_root(
if not default_lora_root and len(self.loras_roots) == 1: comfy_library.get("default_lora_root", ""),
default_lora_root = self.loras_roots[0] list(self.loras_roots or []),
"default_lora_root",
)
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "") default_checkpoint_root = _resolve_valid_default_root(
if ( comfy_library.get("default_checkpoint_root", ""),
not default_checkpoint_root list(self.checkpoints_roots or []),
and self.checkpoints_roots "default_checkpoint_root",
and len(self.checkpoints_roots) == 1 )
):
default_checkpoint_root = self.checkpoints_roots[0]
default_embedding_root = comfy_library.get("default_embedding_root", "") default_embedding_root = _resolve_valid_default_root(
if ( comfy_library.get("default_embedding_root", ""),
not default_embedding_root list(self.embeddings_roots or []),
and self.embeddings_roots "default_embedding_root",
and len(self.embeddings_roots) == 1 )
):
default_embedding_root = self.embeddings_roots[0]
metadata = dict(comfy_library.get("metadata", {})) metadata = dict(comfy_library.get("metadata", {}))
metadata.setdefault("display_name", "ComfyUI") metadata.setdefault("display_name", "ComfyUI")
@@ -706,7 +729,9 @@ class Config:
return unique_paths return unique_paths
@staticmethod @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.""" """Normalize a path for equality checks across platforms."""
candidate = os.path.realpath(path) if resolve_realpath else path candidate = os.path.realpath(path) if resolve_realpath else path
return os.path.normcase(os.path.normpath(candidate)).replace(os.sep, "/") return os.path.normcase(os.path.normpath(candidate)).replace(os.sep, "/")

View File

@@ -8,6 +8,7 @@ and tracks the cycle progress which persists across workflow save/load.
import logging import logging
import os import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,6 +55,9 @@ class LoraCyclerLM:
current_index = cycler_config.get("current_index", 1) # 1-based current_index = cycler_config.get("current_index", 1) # 1-based
model_strength = float(cycler_config.get("model_strength", 1.0)) model_strength = float(cycler_config.get("model_strength", 1.0))
clip_strength = float(cycler_config.get("clip_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" sort_by = "filename"
# Include "no lora" option # Include "no lora" option
@@ -131,6 +135,39 @@ class LoraCyclerLM:
else: else:
# Normalize path separators # Normalize path separators
lora_path = lora_path.replace("/", os.sep) 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)] lora_stack = [(lora_path, model_strength, clip_strength)]
# Calculate next index (wrap to 1 if at end) # Calculate next index (wrap to 1 if at end)

View File

@@ -42,6 +42,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
"height", "height",
"Model", "Model",
"Model hash", "Model hash",
"modelVersionIds",
) )
return any(key in payload for key in civitai_image_fields) return any(key in payload for key in civitai_image_fields)
@@ -429,6 +430,65 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry) 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 # If we found LoRA hashes in the metadata but haven't already
# populated entries for them, fall back to creating LoRAs from # populated entries for them, fall back to creating LoRAs from
# the hashes section. Some Civitai image responses only include # the hashes section. Some Civitai image responses only include

View File

@@ -81,6 +81,7 @@ class RecipeHandlerSet:
"bulk_delete": self.management.bulk_delete, "bulk_delete": self.management.bulk_delete,
"save_recipe_from_widget": self.management.save_recipe_from_widget, "save_recipe_from_widget": self.management.save_recipe_from_widget,
"get_recipes_for_lora": self.query.get_recipes_for_lora, "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, "scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe, "move_recipe": self.management.move_recipe,
"repair_recipes": self.management.repair_recipes, "repair_recipes": self.management.repair_recipes,
@@ -218,6 +219,7 @@ class RecipeListingHandler:
filters["tags"] = tag_filters filters["tags"] = tag_filters
lora_hash = request.query.get("lora_hash") lora_hash = request.query.get("lora_hash")
checkpoint_hash = request.query.get("checkpoint_hash")
result = await recipe_scanner.get_paginated_data( result = await recipe_scanner.get_paginated_data(
page=page, page=page,
@@ -227,6 +229,7 @@ class RecipeListingHandler:
filters=filters, filters=filters,
search_options=search_options, search_options=search_options,
lora_hash=lora_hash, lora_hash=lora_hash,
checkpoint_hash=checkpoint_hash,
folder=folder, folder=folder,
recursive=recursive, recursive=recursive,
) )
@@ -423,6 +426,28 @@ class RecipeQueryHandler:
self._logger.error("Error getting recipes for Lora: %s", exc) self._logger.error("Error getting recipes for Lora: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500) 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: async def scan_recipes(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()

View File

@@ -51,6 +51,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget" "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-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("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"), RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"), RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),

View File

@@ -1,5 +1,6 @@
import os
import logging import logging
import json
import os
from typing import Dict, List, Optional from typing import Dict, List, Optional
from .base_model_service import BaseModelService from .base_model_service import BaseModelService
@@ -278,6 +279,42 @@ class LoraService(BaseModelService):
return None 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: def find_duplicate_hashes(self) -> Dict:
"""Find LoRAs with duplicate SHA256 hashes""" """Find LoRAs with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes() return self.scanner._hash_index.get_duplicate_hashes()
@@ -328,34 +365,10 @@ class LoraService(BaseModelService):
List of LoRA dicts with randomized strengths List of LoRA dicts with randomized strengths
""" """
import random import random
import json
# Use a local Random instance to avoid affecting global random state # Use a local Random instance to avoid affecting global random state
# This ensures each execution with a different seed produces different results # This ensures each execution with a different seed produces different results
rng = random.Random(seed) 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: if locked_loras is None:
locked_loras = [] locked_loras = []
@@ -403,7 +416,9 @@ class LoraService(BaseModelService):
result_loras = [] result_loras = []
for lora in selected: for lora in selected:
if use_recommended_strength: 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: if recommended_strength is not None:
scale = rng.uniform( scale = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max recommended_strength_scale_min, recommended_strength_scale_max
@@ -421,7 +436,9 @@ class LoraService(BaseModelService):
if use_same_clip_strength: if use_same_clip_strength:
clip_str = model_str clip_str = model_str
elif use_recommended_strength: 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: if recommended_clip_strength is not None:
scale = rng.uniform( scale = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max recommended_strength_scale_min, recommended_strength_scale_max

View File

@@ -1615,6 +1615,9 @@ class RecipeScanner:
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Coerce legacy or malformed checkpoint entries into a dict.""" """Coerce legacy or malformed checkpoint entries into a dict."""
if checkpoint_raw is None:
return None
if isinstance(checkpoint_raw, dict): if isinstance(checkpoint_raw, dict):
return dict(checkpoint_raw) return dict(checkpoint_raw)
@@ -1632,9 +1635,6 @@ class RecipeScanner:
"file_name": file_name, "file_name": file_name,
} }
logger.warning(
"Unexpected checkpoint payload type %s", type(checkpoint_raw).__name__
)
return None return None
def _enrich_checkpoint_entry(self, checkpoint: Dict[str, Any]) -> Dict[str, Any]: def _enrich_checkpoint_entry(self, checkpoint: Dict[str, Any]) -> Dict[str, Any]:
@@ -1790,6 +1790,7 @@ class RecipeScanner:
filters: dict = None, filters: dict = None,
search_options: dict = None, search_options: dict = None,
lora_hash: str = None, lora_hash: str = None,
checkpoint_hash: str = None,
bypass_filters: bool = True, bypass_filters: bool = True,
folder: str | None = None, folder: str | None = None,
recursive: bool = True, recursive: bool = True,
@@ -1804,7 +1805,8 @@ class RecipeScanner:
filters: Dictionary of filters to apply filters: Dictionary of filters to apply
search_options: Dictionary of search options to apply search_options: Dictionary of search options to apply
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by 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 folder: Optional folder filter relative to recipes directory
recursive: Whether to include recipes in subfolders of the selected folder 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 # Skip other filters if bypass_filters is True
pass pass
# Otherwise continue with normal filtering after applying LoRA hash filter # 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 bypass_filters:
if not (lora_hash and 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 # Apply folder filter before other criteria
if folder is not None: if folder is not None:
normalized_folder = folder.strip("/") normalized_folder = folder.strip("/")
@@ -2334,6 +2350,38 @@ class RecipeScanner:
return matching_recipes 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]: async def get_recipe_syntax_tokens(self, recipe_id: str) -> List[str]:
"""Build LoRA syntax tokens for a recipe.""" """Build LoRA syntax tokens for a recipe."""

View File

@@ -143,6 +143,12 @@ class RecipeAnalysisService:
): ):
metadata = metadata["meta"] 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 # Validate that metadata contains meaningful recipe fields
# If not, treat as None to trigger EXIF extraction from downloaded image # If not, treat as None to trigger EXIF extraction from downloaded image
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata): if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):

View File

@@ -173,11 +173,23 @@ class RecipePersistenceService:
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult: async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
"""Update persisted metadata for a recipe.""" """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( 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) success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
if not success: if not success:
raise RecipeNotFoundError("Recipe not found or update failed") raise RecipeNotFoundError("Recipe not found or update failed")

View File

@@ -7,7 +7,17 @@ import logging
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from threading import Lock 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 from platformdirs import user_config_dir
@@ -17,7 +27,11 @@ from ..utils.constants import (
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
) )
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS 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 ( from ..utils.tag_priorities import (
PriorityTagEntry, PriorityTagEntry,
collect_canonical_tags, collect_canonical_tags,
@@ -94,7 +108,9 @@ class SettingsManager:
self._template_payload_cache_loaded = False self._template_payload_cache_loaded = False
self._original_disk_payload: Optional[Dict[str, Any]] = None self._original_disk_payload: Optional[Dict[str, Any]] = None
self._preserve_disk_template = False 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.settings = self._load_settings()
self._migrate_setting_keys() self._migrate_setting_keys()
self._ensure_default_settings() self._ensure_default_settings()
@@ -120,7 +136,7 @@ class SettingsManager:
"""Load settings from file""" """Load settings from file"""
if os.path.exists(self.settings_file): if os.path.exists(self.settings_file):
try: 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) data = json.load(f)
if isinstance(data, dict): if isinstance(data, dict):
self._original_disk_payload = copy.deepcopy(data) self._original_disk_payload = copy.deepcopy(data)
@@ -198,7 +214,9 @@ class SettingsManager:
return None return None
if not isinstance(data, dict): 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 return None
self._template_payload_cache = copy.deepcopy(data) self._template_payload_cache = copy.deepcopy(data)
@@ -274,7 +292,9 @@ class SettingsManager:
normalized_skip_paths = self.normalize_metadata_refresh_skip_paths( normalized_skip_paths = self.normalize_metadata_refresh_skip_paths(
self.settings.get("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 self.settings["metadata_refresh_skip_paths"] = normalized_skip_paths
updated_existing = True updated_existing = True
else: else:
@@ -288,9 +308,7 @@ class SettingsManager:
if normalized_skip_base_models != self.settings.get( if normalized_skip_base_models != self.settings.get(
"download_skip_base_models" "download_skip_base_models"
): ):
self.settings["download_skip_base_models"] = ( self.settings["download_skip_base_models"] = normalized_skip_base_models
normalized_skip_base_models
)
updated_existing = True updated_existing = True
else: else:
self.settings["download_skip_base_models"] = [] self.settings["download_skip_base_models"] = []
@@ -330,19 +348,19 @@ class SettingsManager:
raw_top_level_paths = self.settings.get("folder_paths", {}) raw_top_level_paths = self.settings.get("folder_paths", {})
normalized_top_level_paths: Dict[str, List[str]] = {} normalized_top_level_paths: Dict[str, List[str]] = {}
if isinstance(raw_top_level_paths, Mapping): 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: 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) top_level_has_paths = self._has_configured_paths(normalized_top_level_paths)
needs_library_bootstrap = not isinstance(libraries, dict) or not libraries needs_library_bootstrap = not isinstance(libraries, dict) or not libraries
if ( if not needs_library_bootstrap and top_level_has_paths and len(libraries) == 1:
not needs_library_bootstrap
and top_level_has_paths
and len(libraries) == 1
):
only_library_payload = next(iter(libraries.values())) only_library_payload = next(iter(libraries.values()))
if isinstance(only_library_payload, Mapping): if isinstance(only_library_payload, Mapping):
folder_payload = only_library_payload.get("folder_paths") folder_payload = only_library_payload.get("folder_paths")
@@ -354,7 +372,9 @@ class SettingsManager:
library_payload = self._build_library_payload( library_payload = self._build_library_payload(
folder_paths=normalized_top_level_paths, folder_paths=normalized_top_level_paths,
default_lora_root=self.settings.get("default_lora_root", ""), 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_unet_root=self.settings.get("default_unet_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""), default_embedding_root=self.settings.get("default_embedding_root", ""),
) )
@@ -376,7 +396,11 @@ class SettingsManager:
if target_name: if target_name:
candidate_payload = libraries.get(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 seed_library_name = target_name
sanitized_libraries: Dict[str, Dict[str, Any]] = {} sanitized_libraries: Dict[str, Dict[str, Any]] = {}
@@ -435,11 +459,17 @@ class SettingsManager:
active_library = libraries.get(active_name, {}) active_library = libraries.get(active_name, {})
folder_paths = copy.deepcopy(active_library.get("folder_paths", {})) folder_paths = copy.deepcopy(active_library.get("folder_paths", {}))
self.settings["folder_paths"] = 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_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_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: if save:
self._save_settings() self._save_settings()
@@ -468,7 +498,9 @@ class SettingsManager:
payload.setdefault("folder_paths", {}) payload.setdefault("folder_paths", {})
if extra_folder_paths is not None: 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: else:
payload.setdefault("extra_folder_paths", {}) payload.setdefault("extra_folder_paths", {})
@@ -577,7 +609,9 @@ class SettingsManager:
} }
overlap = existing.intersection(new_paths.keys()) overlap = existing.intersection(new_paths.keys())
if overlap: if overlap:
collisions = ", ".join(sorted(new_paths[value] for value in overlap)) collisions = ", ".join(
sorted(new_paths[value] for value in overlap)
)
raise ValueError( raise ValueError(
f"Folder path(s) {collisions} already assigned to library '{other_name}'" f"Folder path(s) {collisions} already assigned to library '{other_name}'"
) )
@@ -612,19 +646,31 @@ class SettingsManager:
library["extra_folder_paths"] = normalized_extra_paths library["extra_folder_paths"] = normalized_extra_paths
changed = True 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 library["default_lora_root"] = default_lora_root
changed = True 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 library["default_checkpoint_root"] = default_checkpoint_root
changed = True 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 library["default_unet_root"] = default_unet_root
changed = True 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 library["default_embedding_root"] = default_embedding_root
changed = True changed = True
@@ -637,16 +683,16 @@ class SettingsManager:
def _migrate_setting_keys(self) -> None: def _migrate_setting_keys(self) -> None:
"""Migrate legacy camelCase setting keys to snake_case""" """Migrate legacy camelCase setting keys to snake_case"""
key_migrations = { key_migrations = {
'optimizeExampleImages': 'optimize_example_images', "optimizeExampleImages": "optimize_example_images",
'autoDownloadExampleImages': 'auto_download_example_images', "autoDownloadExampleImages": "auto_download_example_images",
'blurMatureContent': 'blur_mature_content', "blurMatureContent": "blur_mature_content",
'matureBlurLevel': 'mature_blur_level', "matureBlurLevel": "mature_blur_level",
'autoplayOnHover': 'autoplay_on_hover', "autoplayOnHover": "autoplay_on_hover",
'displayDensity': 'display_density', "displayDensity": "display_density",
'cardInfoDisplay': 'card_info_display', "cardInfoDisplay": "card_info_display",
'includeTriggerWords': 'include_trigger_words', "includeTriggerWords": "include_trigger_words",
'compactMode': 'compact_mode', "compactMode": "compact_mode",
'modelCardFooterAction': 'model_card_footer_action', "modelCardFooterAction": "model_card_footer_action",
} }
updated = False updated = False
@@ -663,65 +709,77 @@ class SettingsManager:
def _migrate_download_path_template(self): def _migrate_download_path_template(self):
"""Migrate old download_path_template to new download_path_templates""" """Migrate old download_path_template to new download_path_templates"""
old_template = self.settings.get('download_path_template') old_template = self.settings.get("download_path_template")
templates = self.settings.get('download_path_templates') templates = self.settings.get("download_path_templates")
# If old template exists and new templates don't exist, migrate # If old template exists and new templates don't exist, migrate
if old_template is not None and not templates: if old_template is not None and not templates:
logger.info("Migrating download_path_template to download_path_templates") logger.info("Migrating download_path_template to download_path_templates")
self.settings['download_path_templates'] = { self.settings["download_path_templates"] = {
'lora': old_template, "lora": old_template,
'checkpoint': old_template, "checkpoint": old_template,
'embedding': old_template "embedding": old_template,
} }
# Remove old setting # Remove old setting
del self.settings['download_path_template'] del self.settings["download_path_template"]
self._save_settings() self._save_settings()
logger.info("Migration completed") logger.info("Migration completed")
def _auto_set_default_roots(self): 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. Empty or stale defaults are repaired to the first configured root.
For multi-path cases, only set if current default is empty or invalid. 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 updated = False
# loras
loras = folder_paths.get('loras', []) def _check_and_auto_set(key: str, setting_key: str) -> bool:
if isinstance(loras, list) and len(loras) == 1: """Repair default roots when empty or no longer present."""
current_lora_root = self.settings.get('default_lora_root') current = self.settings.get(setting_key, "")
if current_lora_root not in loras: candidates = folder_paths.get(key, [])
self.settings['default_lora_root'] = loras[0] if not isinstance(candidates, list) or not candidates:
updated = True return False
# checkpoints
checkpoints = folder_paths.get('checkpoints', []) # Filter valid path strings
if isinstance(checkpoints, list) and len(checkpoints) == 1: valid_paths = [p for p in candidates if isinstance(p, str) and p.strip()]
current_checkpoint_root = self.settings.get('default_checkpoint_root') if not valid_paths:
if current_checkpoint_root not in checkpoints: return False
self.settings['default_checkpoint_root'] = checkpoints[0]
updated = True if current in valid_paths:
# unet (diffusion models) - auto-set if empty or invalid return False
unet_paths = folder_paths.get('unet', [])
if isinstance(unet_paths, list) and len(unet_paths) >= 1: self.settings[setting_key] = valid_paths[0]
current_unet_root = self.settings.get('default_unet_root') if current:
# Set to first path if current is empty or not in the valid paths logger.info(
if not current_unet_root or current_unet_root not in unet_paths: "Repaired stale %s from '%s' to '%s'",
self.settings['default_unet_root'] = unet_paths[0] setting_key,
updated = True current,
# embeddings valid_paths[0],
embeddings = folder_paths.get('embeddings', []) )
if isinstance(embeddings, list) and len(embeddings) == 1: else:
current_embedding_root = self.settings.get('default_embedding_root') logger.info("Auto-set %s to '%s'", setting_key, valid_paths[0])
if current_embedding_root not in embeddings: return True
self.settings['default_embedding_root'] = embeddings[0]
updated = 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: if updated:
self._update_active_library_entry( self._update_active_library_entry(
default_lora_root=self.settings.get('default_lora_root'), 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_unet_root=self.settings.get("default_unet_root"),
default_embedding_root=self.settings.get('default_embedding_root'), default_embedding_root=self.settings.get("default_embedding_root"),
) )
if self._bootstrap_reason == "missing": if self._bootstrap_reason == "missing":
self._needs_initial_save = True self._needs_initial_save = True
@@ -730,11 +788,11 @@ class SettingsManager:
def _check_environment_variables(self) -> None: def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed""" """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 if env_api_key: # Check if the environment variable exists and is not empty
logger.info("Found CIVITAI_API_KEY environment variable") logger.info("Found CIVITAI_API_KEY environment variable")
# Always use the environment variable if it exists # 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() self._save_settings()
def _default_settings_actions(self) -> List[Dict[str, Any]]: def _default_settings_actions(self) -> List[Dict[str, Any]]:
@@ -799,7 +857,9 @@ class SettingsManager:
disk_value = self._original_disk_payload.get(key) disk_value = self._original_disk_payload.get(key)
default_value = defaults.get(key) default_value = defaults.get(key)
# Compare using JSON serialization for complex objects # 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) default_value_keys.add(key)
# Only cleanup if there are "many" default keys (indicating a bloated file) # Only cleanup if there are "many" default keys (indicating a bloated file)
@@ -807,7 +867,7 @@ class SettingsManager:
if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD: if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD:
logger.info( logger.info(
"Cleaning up %d default value(s) from settings.json to keep it minimal", "Cleaning up %d default value(s) from settings.json to keep it minimal",
len(default_value_keys) len(default_value_keys),
) )
self._save_settings() self._save_settings()
# Update original payload to match what we just saved # Update original payload to match what we just saved
@@ -817,8 +877,8 @@ class SettingsManager:
if not self._standalone_mode: if not self._standalone_mode:
return return
folder_paths = self.settings.get('folder_paths', {}) or {} folder_paths = self.settings.get("folder_paths", {}) or {}
monitored_keys = ('loras', 'checkpoints', 'embeddings') monitored_keys = ("loras", "checkpoints", "embeddings")
has_valid_paths = False has_valid_paths = False
for key in monitored_keys: for key in monitored_keys:
@@ -829,7 +889,10 @@ class SettingsManager:
iterator = list(raw_paths) iterator = list(raw_paths)
except TypeError: except TypeError:
continue 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 has_valid_paths = True
break break
@@ -860,13 +923,13 @@ class SettingsManager:
def _get_default_settings(self) -> Dict[str, Any]: def _get_default_settings(self) -> Dict[str, Any]:
"""Return default settings""" """Return default settings"""
defaults = copy.deepcopy(DEFAULT_SETTINGS) defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults['base_model_path_mappings'] = {} defaults["base_model_path_mappings"] = {}
defaults['download_path_templates'] = {} defaults["download_path_templates"] = {}
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault('folder_paths', {}) defaults.setdefault("folder_paths", {})
defaults.setdefault('extra_folder_paths', {}) defaults.setdefault("extra_folder_paths", {})
defaults['auto_organize_exclusions'] = [] defaults["auto_organize_exclusions"] = []
defaults['metadata_refresh_skip_paths'] = [] defaults["metadata_refresh_skip_paths"] = []
library_name = defaults.get("active_library") or "default" library_name = defaults.get("active_library") or "default"
default_library = self._build_library_payload( default_library = self._build_library_payload(
@@ -876,8 +939,8 @@ class SettingsManager:
default_checkpoint_root=defaults.get("default_checkpoint_root"), default_checkpoint_root=defaults.get("default_checkpoint_root"),
default_embedding_root=defaults.get("default_embedding_root"), default_embedding_root=defaults.get("default_embedding_root"),
) )
defaults['libraries'] = {library_name: default_library} defaults["libraries"] = {library_name: default_library}
defaults['active_library'] = library_name defaults["active_library"] = library_name
return defaults return defaults
def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]: def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]:
@@ -908,7 +971,9 @@ class SettingsManager:
candidates: Iterable[str] = ( candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",") 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 candidates = value
else: else:
return [] return []
@@ -954,7 +1019,9 @@ class SettingsManager:
candidates: Iterable[str] = ( candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",") 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 candidates = value
else: else:
return [] return []
@@ -1060,7 +1127,9 @@ class SettingsManager:
continue continue
normalized = os.path.normcase(os.path.normpath(stripped)) normalized = os.path.normcase(os.path.normpath(stripped))
if os.path.exists(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_real_paths.add(normalized)
primary_symlink_targets = set() primary_symlink_targets = set()
@@ -1096,8 +1165,13 @@ class SettingsManager:
continue continue
normalized = os.path.normcase(os.path.normpath(stripped)) normalized = os.path.normcase(os.path.normpath(stripped))
if os.path.exists(stripped): if os.path.exists(stripped):
normalized = os.path.normcase(os.path.normpath(os.path.realpath(stripped))) normalized = os.path.normcase(
if normalized in primary_real_paths or normalized in primary_symlink_targets: os.path.normpath(os.path.realpath(stripped))
)
if (
normalized in primary_real_paths
or normalized in primary_symlink_targets
):
overlapping_paths.append(stripped) overlapping_paths.append(stripped)
if overlapping_paths: if overlapping_paths:
@@ -1161,19 +1235,19 @@ class SettingsManager:
if key == "use_portable_settings" and isinstance(value, bool): if key == "use_portable_settings" and isinstance(value, bool):
portable_switch_pending = True portable_switch_pending = True
self._prepare_portable_switch(value) 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] 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] 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)) 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)) 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)) 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)) 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._notify_model_name_display_change(value)
self._save_settings() self._save_settings()
if portable_switch_pending: if portable_switch_pending:
@@ -1249,10 +1323,9 @@ class SettingsManager:
source_cache_dir = os.path.join(source_dir, "model_cache") source_cache_dir = os.path.join(source_dir, "model_cache")
target_cache_dir = os.path.join(target_dir, "model_cache") target_cache_dir = os.path.join(target_dir, "model_cache")
if ( if os.path.isdir(source_cache_dir) and os.path.abspath(
os.path.isdir(source_cache_dir) source_cache_dir
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir) ) != os.path.abspath(target_cache_dir):
):
try: try:
shutil.copytree( shutil.copytree(
source_cache_dir, source_cache_dir,
@@ -1270,10 +1343,9 @@ class SettingsManager:
source_cache_file = os.path.join(source_dir, "model_cache.sqlite") source_cache_file = os.path.join(source_dir, "model_cache.sqlite")
target_cache_file = os.path.join(target_dir, "model_cache.sqlite") target_cache_file = os.path.join(target_dir, "model_cache.sqlite")
if ( if os.path.isfile(source_cache_file) and os.path.abspath(
os.path.isfile(source_cache_file) source_cache_file
and os.path.abspath(source_cache_file) != os.path.abspath(target_cache_file) ) != os.path.abspath(target_cache_file):
):
try: try:
shutil.copy2(source_cache_file, target_cache_file) shutil.copy2(source_cache_file, target_cache_file)
except Exception as exc: except Exception as exc:
@@ -1299,7 +1371,9 @@ class SettingsManager:
try: try:
os.makedirs(config_dir, exist_ok=True) os.makedirs(config_dir, exist_ok=True)
except Exception as exc: 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 return config_dir
@@ -1359,7 +1433,9 @@ class SettingsManager:
try: try:
asyncio.run(coroutine) asyncio.run(coroutine)
except RuntimeError: 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 continue
if loop is not None and target_loop is loop: if loop is not None and target_loop is loop:
@@ -1382,7 +1458,7 @@ class SettingsManager:
"""Save settings to file""" """Save settings to file"""
try: try:
payload = self._serialize_settings_for_disk() 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) json.dump(payload, f, indent=2)
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.error(f"Error saving settings: {e}")
@@ -1423,7 +1499,9 @@ class SettingsManager:
minimal[key] = copy.deepcopy(value) minimal[key] = copy.deepcopy(value)
# Complex objects need deep comparison # Complex objects need deep comparison
elif isinstance(value, (dict, list)) and default_value is not None: 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) minimal[key] = copy.deepcopy(value)
# Simple values use direct comparison # Simple values use direct comparison
elif value != default_value: elif value != default_value:
@@ -1500,9 +1578,15 @@ class SettingsManager:
existing = libraries.get(name, {}) existing = libraries.get(name, {})
payload = self._build_library_payload( payload = self._build_library_payload(
folder_paths=folder_paths if folder_paths is not None else existing.get("folder_paths"), folder_paths=folder_paths
extra_folder_paths=extra_folder_paths if extra_folder_paths is not None else existing.get("extra_folder_paths"), if folder_paths is not None
default_lora_root=default_lora_root if default_lora_root is not None else existing.get("default_lora_root"), 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=(
default_checkpoint_root default_checkpoint_root
if default_checkpoint_root is not None if default_checkpoint_root is not None
@@ -1662,7 +1746,9 @@ class SettingsManager:
if service and hasattr(service, "on_library_changed"): if service and hasattr(service, "on_library_changed"):
try: try:
service.on_library_changed() 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( logger.debug(
"Service %s failed to handle library change: %s", "Service %s failed to handle library change: %s",
service_name, service_name,
@@ -1673,15 +1759,15 @@ class SettingsManager:
def get_download_path_template(self, model_type: str) -> str: def get_download_path_template(self, model_type: str) -> str:
"""Get download path template for specific model type """Get download path template for specific model type
Args: Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding') model_type: The type of model ('lora', 'checkpoint', 'embedding')
Returns: Returns:
Template string for the model type, defaults to '{base_model}/{first_tag}' 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 # Handle edge case where templates might be stored as JSON string
if isinstance(templates, str): if isinstance(templates, str):
try: try:
@@ -1689,36 +1775,40 @@ class SettingsManager:
parsed_templates = json.loads(templates) parsed_templates = json.loads(templates)
if isinstance(parsed_templates, dict): if isinstance(parsed_templates, dict):
# Update settings with parsed dictionary # Update settings with parsed dictionary
self.settings['download_path_templates'] = parsed_templates self.settings["download_path_templates"] = parsed_templates
self._save_settings() self._save_settings()
templates = parsed_templates templates = parsed_templates
logger.info("Successfully parsed download_path_templates from JSON string") logger.info(
"Successfully parsed download_path_templates from JSON string"
)
else: else:
raise ValueError("Parsed JSON is not a dictionary") raise ValueError("Parsed JSON is not a dictionary")
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
# If parsing fails, set default values # If parsing fails, set default values
logger.warning(f"Failed to parse download_path_templates JSON string: {e}. Setting default values.") logger.warning(
default_template = '{base_model}/{first_tag}' f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
)
default_template = "{base_model}/{first_tag}"
templates = { templates = {
'lora': default_template, "lora": default_template,
'checkpoint': default_template, "checkpoint": default_template,
'embedding': default_template "embedding": default_template,
} }
self.settings['download_path_templates'] = templates self.settings["download_path_templates"] = templates
self._save_settings() self._save_settings()
# Ensure templates is a dictionary # Ensure templates is a dictionary
if not isinstance(templates, dict): if not isinstance(templates, dict):
default_template = '{base_model}/{first_tag}' default_template = "{base_model}/{first_tag}"
templates = { templates = {
'lora': default_template, "lora": default_template,
'checkpoint': default_template, "checkpoint": default_template,
'embedding': default_template "embedding": default_template,
} }
self.settings['download_path_templates'] = templates self.settings["download_path_templates"] = templates
self._save_settings() 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 _SETTINGS_MANAGER: Optional["SettingsManager"] = None

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.0" version = "1.0.1"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -835,7 +835,8 @@
} }
[data-theme="dark"] .creator-info, [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); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border); border: 1px solid var(--lora-border);
} }
@@ -875,7 +876,8 @@
/* Add hover effect for creator info */ /* Add hover effect for creator info */
.creator-info:hover, .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); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent); border-color: var(--lora-accent);
transform: translateY(-1px); transform: translateY(-1px);
@@ -910,3 +912,42 @@
align-items: center; align-items: center;
justify-content: 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;
}

View File

@@ -424,6 +424,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 8px;
} }
.param-header label { .param-header label {
@@ -431,7 +432,14 @@
color: var(--text-color); color: var(--text-color);
} }
.copy-btn { .param-actions {
display: flex;
align-items: center;
gap: 4px;
}
.copy-btn,
.edit-btn {
background: none; background: none;
border: none; border: none;
color: var(--text-color); color: var(--text-color);
@@ -442,7 +450,8 @@
transition: all 0.2s; transition: all 0.2s;
} }
.copy-btn:hover { .copy-btn:hover,
.edit-btn:hover {
opacity: 1; opacity: 1;
background: var(--lora-surface); background: var(--lora-surface);
} }
@@ -461,6 +470,48 @@
word-break: break-word; 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 Parameters */
.other-params { .other-params {
display: flex; display: flex;
@@ -565,6 +616,26 @@
color: var(--lora-accent); 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 { #recipeLorasCount {
font-size: 0.9em; font-size: 0.9em;
color: var(--text-color); color: var(--text-color);

View File

@@ -83,6 +83,9 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) { if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
params.append('lora_hash', pageState.customFilter.loraHash); params.append('lora_hash', pageState.customFilter.loraHash);
params.append('bypass_filters', 'true'); 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 { } else {
// Normal filtering logic // Normal filtering logic

View File

@@ -4,6 +4,8 @@ import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
import { moveManager } from '../../managers/MoveManager.js'; import { moveManager } from '../../managers/MoveManager.js';
import { i18n } from '../../i18n/index.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 { export class CheckpointContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -60,6 +62,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
this.currentCard.querySelector('.fa-copy').click(); this.currentCard.querySelector('.fa-copy').click();
} }
break; break;
case 'sendworkflow':
// Send checkpoint to workflow (always replace mode)
this.sendCheckpointToWorkflow();
break;
case 'refresh-metadata': case 'refresh-metadata':
// Refresh metadata from CivitAI // Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
@@ -79,6 +85,52 @@ export class CheckpointContextMenu extends BaseContextMenu {
break; 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 // Mix in shared methods

View File

@@ -1,5 +1,5 @@
// Recipe Modal Component // 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 { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
@@ -9,11 +9,13 @@ import { MODEL_TYPES } from '../api/apiConfig.js';
class RecipeModal { class RecipeModal {
constructor() { constructor() {
this.promptEditorState = {};
this.init(); this.init();
} }
init() { init() {
this.setupCopyButtons(); this.setupCopyButtons();
this.setupPromptEditors();
// Set up tooltip positioning handlers after DOM is ready // Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
this.setupTooltipPositioning(); this.setupTooltipPositioning();
@@ -87,6 +89,7 @@ class RecipeModal {
showRecipeDetails(recipe) { showRecipeDetails(recipe) {
// Store the full recipe for editing // Store the full recipe for editing
this.currentRecipe = recipe; this.currentRecipe = recipe;
this.resetPromptEditors();
// Set modal title with edit icon // Set modal title with edit icon
const modalTitle = document.getElementById('recipeModalTitle'); const modalTitle = document.getElementById('recipeModalTitle');
@@ -300,20 +303,19 @@ class RecipeModal {
const promptElement = document.getElementById('recipePrompt'); const promptElement = document.getElementById('recipePrompt');
const negativePromptElement = document.getElementById('recipeNegativePrompt'); const negativePromptElement = document.getElementById('recipeNegativePrompt');
const otherParamsElement = document.getElementById('recipeOtherParams'); const otherParamsElement = document.getElementById('recipeOtherParams');
const promptInput = document.getElementById('recipePromptInput');
const negativePromptInput = document.getElementById('recipeNegativePromptInput');
if (recipe.gen_params) { if (recipe.gen_params) {
// Set prompt this.renderPromptContent(promptElement, recipe.gen_params.prompt, 'No prompt information available');
if (promptElement && recipe.gen_params.prompt) { this.renderPromptContent(negativePromptElement, recipe.gen_params.negative_prompt, 'No negative prompt information available');
promptElement.textContent = recipe.gen_params.prompt;
} else if (promptElement) { if (promptInput) {
promptElement.textContent = 'No prompt information available'; promptInput.value = recipe.gen_params.prompt || '';
} }
// Set negative prompt if (negativePromptInput) {
if (negativePromptElement && recipe.gen_params.negative_prompt) { negativePromptInput.value = recipe.gen_params.negative_prompt || '';
negativePromptElement.textContent = recipe.gen_params.negative_prompt;
} else if (negativePromptElement) {
negativePromptElement.textContent = 'No negative prompt information available';
} }
// Set other parameters // Set other parameters
@@ -343,8 +345,10 @@ class RecipeModal {
} }
} else { } else {
// No generation parameters available // No generation parameters available
if (promptElement) promptElement.textContent = 'No prompt information available'; this.renderPromptContent(promptElement, '', 'No prompt information available');
if (negativePromptElement) promptElement.textContent = 'No negative 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>'; 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 // Setup source URL handlers
setupSourceUrlHandlers() { setupSourceUrlHandlers() {
const sourceUrlContainer = document.querySelector('.source-url-container'); const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor'); const sourceUrlEditor = document.querySelector('.source-url-editor');
if (!sourceUrlContainer || !sourceUrlEditor) {
return;
}
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text'); const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn'); const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn'); const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn'); const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input'); const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
if (!sourceUrlText || !sourceUrlEditBtn || !sourceUrlCancelBtn || !sourceUrlSaveBtn || !sourceUrlInput) {
return;
}
// Show editor on edit button click // Show editor on edit button click
sourceUrlEditBtn.addEventListener('click', () => { sourceUrlEditBtn.addEventListener('click', () => {
sourceUrlContainer.classList.add('hide'); sourceUrlContainer.classList.add('hide');
@@ -778,17 +968,18 @@ class RecipeModal {
const copyPromptBtn = document.getElementById('copyPromptBtn'); const copyPromptBtn = document.getElementById('copyPromptBtn');
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn'); const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn'); const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
if (copyPromptBtn) { if (copyPromptBtn) {
copyPromptBtn.addEventListener('click', () => { copyPromptBtn.addEventListener('click', () => {
const promptText = document.getElementById('recipePrompt').textContent; const promptText = this.currentRecipe?.gen_params?.prompt || '';
this.copyToClipboard(promptText, 'Prompt copied to clipboard'); this.copyToClipboard(promptText, 'Prompt copied to clipboard');
}); });
} }
if (copyNegativePromptBtn) { if (copyNegativePromptBtn) {
copyNegativePromptBtn.addEventListener('click', () => { 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'); this.copyToClipboard(negativePromptText, 'Negative prompt copied to clipboard');
}); });
} }
@@ -799,6 +990,13 @@ class RecipeModal {
this.fetchAndCopyRecipeSyntax(); this.fetchAndCopyRecipeSyntax();
}); });
} }
if (sendRecipeBtn) {
sendRecipeBtn.addEventListener('click', () => {
// Send recipe to ComfyUI workflow
this.sendRecipeToWorkflow();
});
}
} }
// Fetch recipe syntax from backend and copy to clipboard // Fetch recipe syntax from backend and copy to clipboard
@@ -835,6 +1033,35 @@ class RecipeModal {
copyToClipboard(text, successMessage); 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 // Add new method to handle downloading missing LoRAs
async showDownloadMissingLorasModal() { async showDownloadMissingLorasModal() {
console.log("currentRecipe", this.currentRecipe); console.log("currentRecipe", this.currentRecipe);
@@ -1188,14 +1415,14 @@ class RecipeModal {
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint' isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
); );
const successMessage = translate( 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( 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( const missingNodesMessage = translate(
'uiHelpers.workflow.noMatchingNodes', 'uiHelpers.workflow.noMatchingNodes',

View File

@@ -185,14 +185,14 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint' isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
); );
const successMessage = translate( 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( 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( const missingNodesMessage = translate(
'uiHelpers.workflow.noMatchingNodes', 'uiHelpers.workflow.noMatchingNodes',

View File

@@ -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 { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { import {
toggleShowcase, toggleShowcase,
setupShowcaseScroll, setupShowcaseScroll,
@@ -18,7 +19,7 @@ import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, es
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js'; import { parsePresets, renderPresetTags } from './PresetTags.js';
import { initVersionsTab } from './ModelVersionsTab.js'; import { initVersionsTab } from './ModelVersionsTab.js';
import { loadRecipesForLora } from './RecipeTab.js'; import { loadRecipesForModel } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
@@ -294,6 +295,17 @@ export async function showModelModal(model, modelType) {
].join('\n') ].join('\n')
: ''; : '';
const headerActionItems = []; 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) { if (creatorActionsMarkup) {
headerActionItems.push(creatorActionsMarkup); headerActionItems.push(creatorActionsMarkup);
} }
@@ -343,7 +355,9 @@ export async function showModelModal(model, modelType) {
${versionsTabBadge} ${versionsTabBadge}
</button>`.trim(); </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 active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button> <button class="tab-btn" data-tab="description">${descriptionText}</button>
${versionsTabButton} ${versionsTabButton}
@@ -373,7 +387,7 @@ export async function showModelModal(model, modelType) {
</button> </button>
</div>`.trim(); </div>`.trim();
const tabPanesContent = modelType === 'loras' ? const tabPanesContent = supportsRecipesTab ?
`<div id="showcase-tab" class="tab-pane active"> `<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading"> <div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText} <i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
@@ -615,6 +629,14 @@ export async function showModelModal(model, modelType) {
const activeModalElement = document.getElementById(modalId); const activeModalElement = document.getElementById(modalId);
if (activeModalElement) { if (activeModalElement) {
activeModalElement.dataset.filePath = modelWithFullData.file_path || ''; 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); updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
const versionsTabController = initVersionsTab({ const versionsTabController = initVersionsTab({
@@ -644,14 +666,23 @@ export async function showModelModal(model, modelType) {
setupNavigationShortcuts(modelType); setupNavigationShortcuts(modelType);
updateNavigationControls(); updateNavigationControls();
// LoRA specific setup // Model-specific setup
if (modelType === 'loras' || modelType === 'embeddings') { if (modelType === 'loras' || modelType === 'embeddings') {
setupTriggerWordsEditMode(); setupTriggerWordsEditMode();
}
if (modelType == 'loras') { if (modelType === 'loras') {
// Load recipes for this LoRA loadRecipesForModel({
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256); 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 // Load example images asynchronously - merge regular and custom images
@@ -747,6 +778,9 @@ function setupEventHandlers(filePath, modelType) {
case 'nav-next': case 'nav-next':
handleDirectionalNavigation('next', modelType); handleDirectionalNavigation('next', modelType);
break; 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 // Export the model modal API
const modelModal = { const modelModal = {
show: showModelModal, show: showModelModal,

View File

@@ -1,38 +1,47 @@
/** /**
* RecipeTab - Handles the recipes tab in model modals (LoRA specific functionality) * RecipeTab - Handles the recipes tab in model modals.
* Moved to shared directory for consistency
*/ */
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
/** /**
* Loads recipes that use the specified Lora and renders them in the tab * Loads recipes that use the specified model and renders them in the tab.
* @param {string} loraName - The display name of the Lora * @param {Object} options
* @param {string} sha256 - The SHA256 hash of the Lora * @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'); const recipeTab = document.getElementById('recipes-tab');
if (!recipeTab) return; if (!recipeTab) return;
const normalizedHash = sha256?.toLowerCase?.() || '';
const modelLabel = getModelLabel(modelKind);
// Show loading state // Show loading state
recipeTab.innerHTML = ` recipeTab.innerHTML = `
<div class="recipes-loading"> <div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes... <i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div> </div>
`; `;
// Fetch recipes that use this Lora by hash // Fetch recipes that use this model by hash
fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`) fetch(`${getRecipesEndpoint(modelKind)}?hash=${encodeURIComponent(normalizedHash)}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (!data.success) { if (!data.success) {
throw new Error(data.error || 'Failed to load recipes'); 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 => { .catch(error => {
console.error('Error loading recipes for Lora:', error); console.error(`Error loading recipes for ${modelLabel}:`, error);
recipeTab.innerHTML = ` recipeTab.innerHTML = `
<div class="recipes-error"> <div class="recipes-error">
<i class="fas fa-exclamation-circle"></i> <i class="fas fa-exclamation-circle"></i>
@@ -46,18 +55,24 @@ export function loadRecipesForLora(loraName, sha256) {
* Renders the recipe cards in the tab * Renders the recipe cards in the tab
* @param {HTMLElement} tabElement - The tab element to render into * @param {HTMLElement} tabElement - The tab element to render into
* @param {Array} recipes - Array of recipe objects * @param {Array} recipes - Array of recipe objects
* @param {string} loraName - The display name of the Lora * @param {Object} options - Render options
* @param {string} loraHash - The hash of the Lora
*/ */
function renderRecipes(tabElement, recipes, loraName, loraHash) { function renderRecipes(tabElement, recipes, options) {
const {
modelKind,
displayName,
modelHash,
modelLabel,
} = options;
if (!recipes || recipes.length === 0) { if (!recipes || recipes.length === 0) {
tabElement.innerHTML = ` tabElement.innerHTML = `
<div class="recipes-empty"> <div class="recipes-empty">
<i class="fas fa-book-open"></i> <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> </div>
`; `;
return; return;
} }
@@ -73,13 +88,13 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
headerText.appendChild(eyebrow); headerText.appendChild(eyebrow);
const title = document.createElement('h3'); 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); headerText.appendChild(title);
const description = document.createElement('p'); const description = document.createElement('p');
description.className = 'recipes-header__description'; description.className = 'recipes-header__description';
description.textContent = loraName ? description.textContent = displayName ?
`Discover workflows crafted for ${loraName}.` : `Discover workflows crafted for ${displayName}.` :
'Discover workflows crafted for this model.'; 'Discover workflows crafted for this model.';
headerText.appendChild(description); headerText.appendChild(description);
@@ -101,7 +116,11 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
headerElement.appendChild(viewAllButton); headerElement.appendChild(viewAllButton);
viewAllButton.addEventListener('click', () => { viewAllButton.addEventListener('click', () => {
navigateToRecipesPage(loraName, loraHash); navigateToRecipesPage({
modelKind,
displayName,
modelHash,
});
}); });
const cardGrid = document.createElement('div'); const cardGrid = document.createElement('div');
@@ -280,26 +299,32 @@ function copyRecipeSyntax(recipeId) {
} }
/** /**
* Navigates to the recipes page with filter for the current Lora * Navigates to the recipes page with filter for the current model
* @param {string} loraName - The Lora display name to filter by * @param {Object} options - Navigation options
* @param {string} loraHash - The hash of the Lora to filter by
* @param {boolean} createNew - Whether to open the create recipe dialog
*/ */
function navigateToRecipesPage(loraName, loraHash) { function navigateToRecipesPage({ modelKind, displayName, modelHash }) {
// Close the current modal // Close the current modal
if (window.modalManager) { if (window.modalManager) {
modalManager.closeModal('modelModal'); modalManager.closeModal('modelModal');
} }
// Clear any previous filters first // Clear any previous filters first
removeSessionItem('lora_to_recipe_filterLoraName'); removeSessionItem('lora_to_recipe_filterLoraName');
removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('checkpoint_to_recipe_filterCheckpointName');
removeSessionItem('checkpoint_to_recipe_filterCheckpointHash');
removeSessionItem('viewRecipeId'); removeSessionItem('viewRecipeId');
// Store the LoRA name and hash filter in sessionStorage if (modelKind === 'checkpoint') {
setSessionItem('lora_to_recipe_filterLoraName', loraName); // Store the checkpoint name and hash filter in sessionStorage
setSessionItem('lora_to_recipe_filterLoraHash', loraHash); 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 // Directly navigate to recipes page
window.location.href = '/loras/recipes'; window.location.href = '/loras/recipes';
} }
@@ -321,7 +346,18 @@ function navigateToRecipeDetails(recipeId) {
// Store the recipe ID in sessionStorage to load on recipes page // Store the recipe ID in sessionStorage to load on recipes page
setSessionItem('viewRecipeId', recipeId); setSessionItem('viewRecipeId', recipeId);
// Directly navigate to recipes page // Directly navigate to recipes page
window.location.href = '/loras/recipes'; 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';
}

View File

@@ -1246,10 +1246,7 @@ export class SettingsManager {
throw new Error('No LoRA roots found'); 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.innerHTML = '';
defaultLoraRootSelect.appendChild(noDefaultOption);
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1259,9 +1256,8 @@ export class SettingsManager {
defaultLoraRootSelect.appendChild(option); defaultLoraRootSelect.appendChild(option);
}); });
// Set selected value from settings
const defaultRoot = state.global.settings.default_lora_root || ''; const defaultRoot = state.global.settings.default_lora_root || '';
defaultLoraRootSelect.value = defaultRoot; defaultLoraRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) { } catch (error) {
console.error('Error loading LoRA roots:', error); console.error('Error loading LoRA roots:', error);
@@ -1285,10 +1281,7 @@ export class SettingsManager {
throw new Error('No checkpoint roots found'); throw new Error('No checkpoint roots found');
} }
// Clear existing options except first one (No Default)
const noDefaultOption = defaultCheckpointRootSelect.querySelector('option[value=""]');
defaultCheckpointRootSelect.innerHTML = ''; defaultCheckpointRootSelect.innerHTML = '';
defaultCheckpointRootSelect.appendChild(noDefaultOption);
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1298,9 +1291,8 @@ export class SettingsManager {
defaultCheckpointRootSelect.appendChild(option); defaultCheckpointRootSelect.appendChild(option);
}); });
// Set selected value from settings
const defaultRoot = state.global.settings.default_checkpoint_root || ''; const defaultRoot = state.global.settings.default_checkpoint_root || '';
defaultCheckpointRootSelect.value = defaultRoot; defaultCheckpointRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) { } catch (error) {
console.error('Error loading checkpoint roots:', error); console.error('Error loading checkpoint roots:', error);
@@ -1324,10 +1316,7 @@ export class SettingsManager {
throw new Error('No diffusion model roots found'); 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.innerHTML = '';
defaultUnetRootSelect.appendChild(noDefaultOption);
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1337,9 +1326,8 @@ export class SettingsManager {
defaultUnetRootSelect.appendChild(option); defaultUnetRootSelect.appendChild(option);
}); });
// Set selected value from settings
const defaultRoot = state.global.settings.default_unet_root || ''; const defaultRoot = state.global.settings.default_unet_root || '';
defaultUnetRootSelect.value = defaultRoot; defaultUnetRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) { } catch (error) {
console.error('Error loading diffusion model roots:', error); console.error('Error loading diffusion model roots:', error);
@@ -1363,10 +1351,7 @@ export class SettingsManager {
throw new Error('No embedding roots found'); throw new Error('No embedding roots found');
} }
// Clear existing options except first one (No Default)
const noDefaultOption = defaultEmbeddingRootSelect.querySelector('option[value=""]');
defaultEmbeddingRootSelect.innerHTML = ''; defaultEmbeddingRootSelect.innerHTML = '';
defaultEmbeddingRootSelect.appendChild(noDefaultOption);
// Add options for each root // Add options for each root
data.roots.forEach(root => { data.roots.forEach(root => {
@@ -1376,9 +1361,8 @@ export class SettingsManager {
defaultEmbeddingRootSelect.appendChild(option); defaultEmbeddingRootSelect.appendChild(option);
}); });
// Set selected value from settings
const defaultRoot = state.global.settings.default_embedding_root || ''; const defaultRoot = state.global.settings.default_embedding_root || '';
defaultEmbeddingRootSelect.value = defaultRoot; defaultEmbeddingRootSelect.value = data.roots.includes(defaultRoot) ? defaultRoot : data.roots[0];
} catch (error) { } catch (error) {
console.error('Error loading embedding roots:', error); console.error('Error loading embedding roots:', error);
@@ -1477,7 +1461,7 @@ export class SettingsManager {
try { try {
// Save to backend - this triggers path validation // Save to backend - this triggers path validation
await this.saveSetting('extra_folder_paths', extraFolderPaths); 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 // Add empty row if no valid paths exist for the changed type
const container = document.getElementById(`extraFolderPaths-${changedModelType}`); const container = document.getElementById(`extraFolderPaths-${changedModelType}`);

View File

@@ -66,6 +66,8 @@ class RecipeManager {
active: false, active: false,
loraName: null, loraName: null,
loraHash: null, loraHash: null,
checkpointName: null,
checkpointHash: null,
recipeId: null recipeId: null
}; };
} }
@@ -127,16 +129,20 @@ class RecipeManager {
// Check for Lora filter // Check for Lora filter
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName'); const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash'); 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 // Check for specific recipe ID
const viewRecipeId = getSessionItem('viewRecipeId'); const viewRecipeId = getSessionItem('viewRecipeId');
// Set custom filter if any parameter is present // Set custom filter if any parameter is present
if (filterLoraName || filterLoraHash || viewRecipeId) { if (filterLoraName || filterLoraHash || filterCheckpointName || filterCheckpointHash || viewRecipeId) {
this.pageState.customFilter = { this.pageState.customFilter = {
active: true, active: true,
loraName: filterLoraName, loraName: filterLoraName,
loraHash: filterLoraHash, loraHash: filterLoraHash,
checkpointName: filterCheckpointName,
checkpointHash: filterCheckpointHash,
recipeId: viewRecipeId recipeId: viewRecipeId
}; };
@@ -164,6 +170,13 @@ class RecipeManager {
loraName; loraName;
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`; 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 { } else {
filterText = 'Filtered recipes'; filterText = 'Filtered recipes';
} }
@@ -173,6 +186,10 @@ class RecipeManager {
// Add title attribute to show the lora name as a tooltip // Add title attribute to show the lora name as a tooltip
if (this.pageState.customFilter.loraName) { if (this.pageState.customFilter.loraName) {
textElement.setAttribute('title', 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'); indicator.classList.remove('hidden');
@@ -199,6 +216,8 @@ class RecipeManager {
active: false, active: false,
loraName: null, loraName: null,
loraHash: null, loraHash: null,
checkpointName: null,
checkpointHash: null,
recipeId: null recipeId: null
}; };
@@ -211,6 +230,8 @@ class RecipeManager {
// Clear any session storage items // Clear any session storage items
removeSessionItem('lora_to_recipe_filterLoraName'); removeSessionItem('lora_to_recipe_filterLoraName');
removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('checkpoint_to_recipe_filterCheckpointName');
removeSessionItem('checkpoint_to_recipe_filterCheckpointHash');
removeSessionItem('viewRecipeId'); removeSessionItem('viewRecipeId');
// Reset and refresh the virtual scroller // Reset and refresh the virtual scroller

View File

@@ -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="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="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="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="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="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> <div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>

View File

@@ -484,9 +484,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')"> <select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')"></select>
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -500,9 +498,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')"> <select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')"></select>
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -516,9 +512,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="defaultUnetRoot" onchange="settingsManager.saveSelectSetting('defaultUnetRoot', 'default_unet_root')"> <select id="defaultUnetRoot" onchange="settingsManager.saveSelectSetting('defaultUnetRoot', 'default_unet_root')"></select>
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -532,9 +526,7 @@
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')"> <select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')"></select>
<option value="">{{ t('settings.folderSettings.noDefault') }}</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -545,7 +537,7 @@
<div class="settings-subsection-header"> <div class="settings-subsection-header">
<h4> <h4>
{{ t('settings.extraFolderPaths.title') }} {{ 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> </h4>
</div> </div>
<div class="setting-item"> <div class="setting-item">

View File

@@ -29,22 +29,52 @@
<div class="param-group info-item"> <div class="param-group info-item">
<div class="param-header"> <div class="param-header">
<label>Prompt</label> <label>Prompt</label>
<button class="copy-btn" id="copyPromptBtn" title="Copy Prompt"> <div class="param-actions">
<i class="fas fa-copy"></i> <button class="copy-btn" id="copyPromptBtn" title="Copy Prompt">
</button> <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>
<div class="param-content" id="recipePrompt"></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> </div>
<!-- Negative Prompt --> <!-- Negative Prompt -->
<div class="param-group info-item"> <div class="param-group info-item">
<div class="param-header"> <div class="param-header">
<label>Negative Prompt</label> <label>Negative Prompt</label>
<button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt"> <div class="param-actions">
<i class="fas fa-copy"></i> <button class="copy-btn" id="copyNegativePromptBtn" title="Copy Negative Prompt">
</button> <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>
<div class="param-content" id="recipeNegativePrompt"></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> </div>
<!-- Other Parameters --> <!-- Other Parameters -->
@@ -65,6 +95,9 @@
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax"> <button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </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> </div>
<div class="recipe-resources-list"> <div class="recipe-resources-list">

View File

@@ -131,6 +131,102 @@ def test_save_paths_logs_warning_when_upsert_fails(
assert "Failed to save folder paths: boom" in caplog.text 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): def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
folder_paths = _setup_config_environment(monkeypatch, tmp_path) folder_paths = _setup_config_environment(monkeypatch, tmp_path)

View File

@@ -245,16 +245,28 @@ describe('Interaction-level regression coverage', () => {
<div class="param-group info-item"> <div class="param-group info-item">
<div class="param-header"> <div class="param-header">
<label>Prompt</label> <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>
<div class="param-content" id="recipePrompt"></div> <div class="param-content" id="recipePrompt"></div>
<div class="param-editor" id="recipePromptEditor">
<textarea class="param-textarea" id="recipePromptInput"></textarea>
</div>
</div> </div>
<div class="param-group info-item"> <div class="param-group info-item">
<div class="param-header"> <div class="param-header">
<label>Negative Prompt</label> <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>
<div class="param-content" id="recipeNegativePrompt"></div> <div class="param-content" id="recipeNegativePrompt"></div>
<div class="param-editor" id="recipeNegativePromptEditor">
<textarea class="param-textarea" id="recipeNegativePromptInput"></textarea>
</div>
</div> </div>
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
@@ -324,6 +336,208 @@ describe('Interaction-level regression coverage', () => {
expect(recipeModal.currentRecipe.title).toBe('Updated Title'); 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 () => { it('processes global context menu actions for downloads and cleanup', async () => {
document.body.innerHTML = ` document.body.innerHTML = `
<div id="globalContextMenu" class="context-menu"> <div id="globalContextMenu" class="context-menu">

View File

@@ -82,24 +82,35 @@ vi.mock(MODEL_VERSIONS_MODULE, () => ({
})); }));
vi.mock(RECIPE_TAB_MODULE, () => ({ vi.mock(RECIPE_TAB_MODULE, () => ({
loadRecipesForLora: vi.fn(), loadRecipesForModel: vi.fn(),
})); }));
vi.mock(I18N_HELPERS_MODULE, () => ({ vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback || ''), translate: vi.fn((_, __, fallback) => fallback || ''),
})); }));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings'
}
}));
vi.mock(API_FACTORY, () => ({ vi.mock(API_FACTORY, () => ({
getModelApiClient: vi.fn(), getModelApiClient: vi.fn(),
})); }));
describe('Model metadata interactions keep file path in sync', () => { describe('Model metadata interactions keep file path in sync', () => {
let getModelApiClient; let getModelApiClient;
let loadRecipesForModel;
beforeEach(async () => { beforeEach(async () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
({ getModelApiClient } = await import(API_FACTORY)); ({ getModelApiClient } = await import(API_FACTORY));
({ loadRecipesForModel } = await import(RECIPE_TAB_MODULE));
getModelApiClient.mockReset(); getModelApiClient.mockReset();
loadRecipesForModel.mockReset();
}); });
afterEach(() => { afterEach(() => {
@@ -198,4 +209,33 @@ describe('Model metadata interactions keep file path in sync', () => {
expect(saveModelMetadata).toHaveBeenCalledWith('models/Qwen.testing.safetensors', { notes: 'Updated notes' }); 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',
});
});
}); });

View File

@@ -80,13 +80,21 @@ vi.mock(MODEL_VERSIONS_MODULE, () => ({
})); }));
vi.mock(RECIPE_TAB_MODULE, () => ({ vi.mock(RECIPE_TAB_MODULE, () => ({
loadRecipesForLora: vi.fn(), loadRecipesForModel: vi.fn(),
})); }));
vi.mock(I18N_HELPERS_MODULE, () => ({ vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback || ''), translate: vi.fn((_, __, fallback) => fallback || ''),
})); }));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings'
}
}));
vi.mock(API_FACTORY, () => ({ vi.mock(API_FACTORY, () => ({
getModelApiClient: vi.fn(), getModelApiClient: vi.fn(),
})); }));

View File

@@ -6,6 +6,7 @@ const initializePageFeaturesMock = vi.fn();
const getCurrentPageStateMock = vi.fn(); const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn(); const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn(); const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn();
const RecipeContextMenuMock = vi.fn(); const RecipeContextMenuMock = vi.fn();
const refreshVirtualScrollMock = vi.fn(); const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn(); const refreshRecipesMock = vi.fn();
@@ -51,6 +52,7 @@ vi.mock('../../../static/js/state/index.js', () => ({
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock, getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock, removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock,
})); }));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({ vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
@@ -117,11 +119,14 @@ describe('RecipeManager', () => {
const map = { const map = {
lora_to_recipe_filterLoraName: 'Flux Dream', lora_to_recipe_filterLoraName: 'Flux Dream',
lora_to_recipe_filterLoraHash: 'abc123', lora_to_recipe_filterLoraHash: 'abc123',
checkpoint_to_recipe_filterCheckpointName: null,
checkpoint_to_recipe_filterCheckpointHash: null,
viewRecipeId: '42', viewRecipeId: '42',
}; };
return map[key] ?? null; return map[key] ?? null;
}); });
removeSessionItemMock.mockImplementation(() => { }); removeSessionItemMock.mockImplementation(() => { });
getStorageItemMock.mockImplementation((_, defaultValue = null) => defaultValue);
renderRecipesPage(); renderRecipesPage();
@@ -166,6 +171,8 @@ describe('RecipeManager', () => {
active: true, active: true,
loraName: 'Flux Dream', loraName: 'Flux Dream',
loraHash: 'abc123', loraHash: 'abc123',
checkpointName: null,
checkpointHash: null,
recipeId: '42', recipeId: '42',
}); });
@@ -177,6 +184,8 @@ describe('RecipeManager', () => {
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName'); expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraName');
expect(removeSessionItemMock).toHaveBeenCalledWith('lora_to_recipe_filterLoraHash'); 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(removeSessionItemMock).toHaveBeenCalledWith('viewRecipeId');
expect(pageState.customFilter.active).toBe(false); expect(pageState.customFilter.active).toBe(false);
expect(indicator.classList.contains('hidden')).toBe(true); expect(indicator.classList.contains('hidden')).toBe(true);
@@ -227,4 +236,36 @@ describe('RecipeManager', () => {
await manager.refreshRecipes(); await manager.refreshRecipes();
expect(refreshRecipesMock).toHaveBeenCalledTimes(1); 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');
});
}); });

View 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)
]

View File

@@ -43,6 +43,9 @@ class StubRecipeScanner:
self.cached_raw: List[Dict[str, Any]] = [] self.cached_raw: List[Dict[str, Any]] = []
self.recipes: Dict[str, Dict[str, Any]] = {} self.recipes: Dict[str, Dict[str, Any]] = {}
self.removed: List[str] = [] 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 async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
return None return None
@@ -56,6 +59,7 @@ class StubRecipeScanner:
return SimpleNamespace(raw_data=list(self.cached_raw)) return SimpleNamespace(raw_data=list(self.cached_raw))
async def get_paginated_data(self, **params: Any) -> Dict[str, Any]: 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] items = [dict(item) for item in self.listing_items]
page = int(params.get("page", 1)) page = int(params.get("page", 1))
page_size = int(params.get("page_size", 20)) 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]]: async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
return self.recipes.get(recipe_id) 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]: async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json" candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
return str(candidate) if candidate.exists() else None return str(candidate) if candidate.exists() else None
@@ -132,6 +144,7 @@ class StubPersistenceService:
self.save_calls: List[Dict[str, Any]] = [] self.save_calls: List[Dict[str, Any]] = []
self.delete_calls: List[str] = [] self.delete_calls: List[str] = []
self.move_calls: List[Dict[str, str]] = [] self.move_calls: List[Dict[str, str]] = []
self.update_calls: List[Dict[str, Any]] = []
self.save_result = SimpleNamespace( self.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "stub-id"}, status=200 payload={"success": True, "recipe_id": "stub-id"}, status=200
) )
@@ -182,7 +195,14 @@ class StubPersistenceService:
async def update_recipe( async def update_recipe(
self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any] 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( return SimpleNamespace(
payload={"success": True, "recipe_id": recipe_id, "updates": updates}, payload={"success": True, "recipe_id": recipe_id, "updates": updates},
status=200, status=200,
@@ -342,6 +362,47 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N
assert payload["items"][0]["loras"] == [] 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 def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
form = FormData() form = FormData()
@@ -509,6 +570,33 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(
assert provider_calls == ["77"] 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 test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider(): async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None)) return SimpleNamespace(get_model_version_info=lambda id: ({}, None))

View File

@@ -184,7 +184,10 @@ async def test_parse_metadata_populates_checkpoint_and_rewrites_thumbnails(monke
assert result["model"] is not None assert result["model"] is not None
assert result["model"]["name"] == "Checkpoint Example" assert result["model"]["name"] == "Checkpoint Example"
assert result["model"]["type"] == "checkpoint" 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"]["modelId"] == 111
assert result["model"]["size"] == 1024 * 1024 assert result["model"]["size"] == 1024 * 1024
assert result["model"]["hash"] == "ffaa0011" 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"]
assert result["loras"][0]["name"] == "Example Lora Model" 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" 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"

View File

@@ -313,6 +313,75 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
assert recipe["checkpoint"]["file_name"] == "by-id" 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): def test_enrich_uses_version_index_when_hash_missing(recipe_scanner):
scanner, stub = recipe_scanner scanner, stub = recipe_scanner
version_id = 77 version_id = 77

View File

@@ -7,7 +7,11 @@ from types import SimpleNamespace
import pytest import pytest
from py.services.recipes.analysis_service import RecipeAnalysisService 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 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"] 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 @pytest.mark.asyncio
async def test_analyze_remote_video(tmp_path): async def test_analyze_remote_video(tmp_path):
exif_utils = DummyExifUtils() exif_utils = DummyExifUtils()

View File

@@ -17,7 +17,9 @@ def test_portable_settings_use_project_root(tmp_path, monkeypatch):
from importlib import reload from importlib import reload
settings_paths_module = reload(settings_paths) 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( monkeypatch.setattr(
settings_paths_module, settings_paths_module,
"user_config_dir", "user_config_dir",
@@ -25,7 +27,9 @@ def test_portable_settings_use_project_root(tmp_path, monkeypatch):
) )
portable_settings = {"use_portable_settings": True} 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) config_dir = settings_paths_module.get_settings_dir(create=True)
assert config_dir == str(tmp_path) 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) self._seed_template = copy.deepcopy(template)
return 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() manager = SettingsManager()
@@ -118,7 +124,10 @@ def test_existing_folder_paths_seed_default_library(tmp_path, monkeypatch):
assert "default" in libraries assert "default" in libraries
assert libraries["default"]["folder_paths"]["loras"] == [str(lora_dir)] assert libraries["default"]["folder_paths"]["loras"] == [str(lora_dir)]
assert libraries["default"]["folder_paths"]["checkpoints"] == [str(checkpoint_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 libraries["default"]["folder_paths"]["embeddings"] == [str(embedding_dir)]
assert manager.get_startup_messages() == [] 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" 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.""" """Helper to instantiate SettingsManager with predefined settings."""
fake_settings_path = tmp_path / "settings.json" 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") assert manager.settings_file == str(project_root / "settings.json")
marker_copy = project_root / "model_cache" / "user_marker.txt" marker_copy = project_root / "model_cache" / "user_marker.txt"
assert marker_copy.read_text(encoding="utf-8") == "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() 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 = project_root / "model_cache"
project_cache_dir.mkdir(exist_ok=True) 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") (project_root / "model_cache.sqlite").write_text("project_db", encoding="utf-8")
manager.set("use_portable_settings", False) manager.set("use_portable_settings", False)
assert manager.settings_file == str(user_settings) 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" 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") template = manager.get_download_path_template("checkpoint")
assert template == "{base_model}/{first_tag}" 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): 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"] = { manager.settings["folder_paths"] = {
"loras": ["/loras"], "loras": ["/loras"],
"checkpoints": ["/checkpoints"], "checkpoints": ["/checkpoints"],
@@ -259,6 +285,48 @@ def test_auto_set_default_roots(manager):
assert manager.get("default_embedding_root") == "/embeddings" 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): def test_delete_setting(manager):
manager.set("example", 1) manager.set("example", 1)
manager.delete("example") 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): def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
initial = { 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", "active_library": "default",
"model_name_display": "model_name", "model_name_display": "model_name",
} }
@@ -315,6 +390,7 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
dispatched_loops = [] dispatched_loops = []
futures = [] futures = []
def tracking_run_coroutine_threadsafe(coro, target_loop): def tracking_run_coroutine_threadsafe(coro, target_loop):
dispatched_loops.append(target_loop) dispatched_loops.append(target_loop)
future = Future() future = Future()
@@ -335,7 +411,9 @@ def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
"get_service_sync", "get_service_sync",
classmethod(fake_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: try:
manager.set("model_name_display", "file_name") 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 = tmp_path / "legacy"
legacy_root.mkdir() legacy_root.mkdir()
legacy_file = legacy_root / "settings.json" 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" target_dir = tmp_path / "config"
monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(legacy_root)) 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() 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" user_dir = tmp_path / "user"
monkeypatch.setattr(settings_paths, "get_project_root", lambda: str(repo_root)) 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() resolved = settings_paths.ensure_settings_file()
@@ -393,7 +475,9 @@ def test_migrate_creates_default_library(manager):
libraries = manager.get_libraries() libraries = manager.get_libraries()
assert "default" in libraries assert "default" in libraries
assert manager.get_active_library_name() == "default" 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): 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 = { initial = {
"civitai_api_key": "stale", "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", "active_library": "default",
} }
monkeypatch.setenv("CIVITAI_API_KEY", "from-init") 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" 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)]}) 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.""" """Test that extra LoRA paths cannot overlap the active library primary LoRA roots."""
real_lora_dir = tmp_path / "loras_real" real_lora_dir = tmp_path / "loras_real"
real_lora_dir.mkdir() real_lora_dir.mkdir()
@@ -603,7 +698,9 @@ def test_extra_paths_validation_no_overlap_with_active_primary_lora_root(manager
activate=True, 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)]}) 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 original_normcase = settings_manager_module.os.path.normcase
def fake_exists(path): 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 True
return original_exists(path) 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, "exists", fake_exists)
monkeypatch.setattr(settings_manager_module.os.path, "realpath", fake_realpath) 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()]}) 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.""" """Missing non-overlapping extra LoRA paths should not be rejected."""
lora_dir = tmp_path / "loras" lora_dir = tmp_path / "loras"
lora_dir.mkdir() 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)] 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.""" """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 = tmp_path / "loras"
lora_dir.mkdir() lora_dir.mkdir()
@@ -677,7 +787,9 @@ def test_extra_paths_validation_rejects_primary_root_first_level_symlink_target(
activate=True, 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)]}) 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" assert manager.get_active_library_name() == "default"
def test_download_skip_base_models_are_normalized(manager): def test_download_skip_base_models_are_normalized(manager):
manager.settings["download_skip_base_models"] = [ manager.settings["download_skip_base_models"] = [
"SDXL 1.0", "SDXL 1.0",
@@ -715,9 +826,6 @@ def test_download_skip_base_models_are_normalized(manager):
def test_setting_download_skip_base_models_normalizes_string_input(manager): def test_setting_download_skip_base_models_normalizes_string_input(manager):
manager.set( manager.set("download_skip_base_models", "SDXL 1.0, Pony; Invalid\nSDXL 1.0")
"download_skip_base_models",
"SDXL 1.0, Pony; Invalid\nSDXL 1.0"
)
assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"] assert manager.get("download_skip_base_models") == ["SDXL 1.0", "Pony"]

View File

@@ -8,6 +8,8 @@
:model-strength="state.modelStrength.value" :model-strength="state.modelStrength.value"
:clip-strength="state.clipStrength.value" :clip-strength="state.clipStrength.value"
:use-custom-clip-range="state.useCustomClipRange.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-clip-strength-disabled="state.isClipStrengthDisabled.value"
:is-loading="state.isLoading.value" :is-loading="state.isLoading.value"
:repeat-count="state.repeatCount.value" :repeat-count="state.repeatCount.value"
@@ -22,6 +24,8 @@
@update:model-strength="state.modelStrength.value = $event" @update:model-strength="state.modelStrength.value = $event"
@update:clip-strength="state.clipStrength.value = $event" @update:clip-strength="state.clipStrength.value = $event"
@update:use-custom-clip-range="handleUseCustomClipRangeChange" @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:repeat-count="handleRepeatCountChange"
@update:include-no-lora="handleIncludeNoLoraChange" @update:include-no-lora="handleIncludeNoLoraChange"
@toggle-pause="handleTogglePause" @toggle-pause="handleTogglePause"

View File

@@ -51,6 +51,7 @@ type RandomizerWidget = ComponentWidget<RandomizerConfig>
const props = defineProps<{ const props = defineProps<{
widget: RandomizerWidget widget: RandomizerWidget
node: { id: number; inputs?: any[]; widgets?: any[]; graph?: any } node: { id: number; inputs?: any[]; widgets?: any[]; graph?: any }
api?: any
}>() }>()
// State management // State management
@@ -65,6 +66,13 @@ const currentLoras = ref<LoraEntry[]>([])
// Track if component is mounted to avoid early watch triggers // Track if component is mounted to avoid early watch triggers
const isMounted = ref(false) const isMounted = ref(false)
interface PendingExecution {
loras?: LoraEntry[]
lastUsed?: LoraEntry[] | null
}
const pendingExecutions: PendingExecution[] = []
// Computed property to check if we can reuse last // Computed property to check if we can reuse last
const canReuseLast = computed(() => { const canReuseLast = computed(() => {
const lastUsed = state.lastUsed.value const lastUsed = state.lastUsed.value
@@ -265,18 +273,20 @@ onMounted(async () => {
;(props.node as any).onExecuted = function(output: any) { ;(props.node as any).onExecuted = function(output: any) {
console.log("[LoraRandomizerWidget] Node executed with output:", output) console.log("[LoraRandomizerWidget] Node executed with output:", output)
// Update last_used from backend const pendingUpdate: PendingExecution = {}
if (output?.last_used !== undefined) { if (output?.last_used !== undefined) {
state.lastUsed.value = output.last_used pendingUpdate.lastUsed = output.last_used
console.log(`[LoraRandomizerWidget] Updated last_used: ${output.last_used ? output.last_used.length : 0} LoRAs`) console.log(`[LoraRandomizerWidget] Queued last_used update: ${output.last_used ? output.last_used.length : 0} LoRAs`)
} }
// Update loras widget if backend provided new loras if (output?.loras && Array.isArray(output.loras)) {
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras') pendingUpdate.loras = output.loras
if (lorasWidget && output?.loras && Array.isArray(output.loras)) { console.log("[LoraRandomizerWidget] Queued loras data from backend:", output.loras)
console.log("[LoraRandomizerWidget] Received loras data from backend:", output.loras) }
lorasWidget.value = output.loras
currentLoras.value = output.loras if (pendingUpdate.lastUsed !== undefined || pendingUpdate.loras !== undefined) {
pendingExecutions.push(pendingUpdate)
} }
// Call original onExecuted if it exists // Call original onExecuted if it exists
@@ -284,6 +294,44 @@ onMounted(async () => {
return originalOnExecuted(output) 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> </script>

View File

@@ -131,6 +131,38 @@
</div> </div>
</div> </div>
<!-- Preset Strength Scale -->
<div class="setting-section">
<div class="section-header-with-toggle">
<label class="setting-label">
Preset Strength Scale
</label>
<button
type="button"
class="toggle-switch"
:class="{ 'toggle-switch--active': usePresetStrength }"
@click="$emit('update:usePresetStrength', !usePresetStrength)"
role="switch"
:aria-checked="usePresetStrength"
title="Use scaled preset strength when enabled"
>
<span class="toggle-switch__track"></span>
<span class="toggle-switch__thumb"></span>
</button>
</div>
<div class="slider-container" :class="{ 'slider-container--disabled': !usePresetStrength }">
<SingleSlider
:min="0"
:max="2"
:value="presetStrengthScale"
:step="0.1"
:default-range="{ min: 0.5, max: 1.0 }"
:disabled="!usePresetStrength"
@update:value="$emit('update:presetStrengthScale', $event)"
/>
</div>
</div>
<!-- Clip Strength --> <!-- Clip Strength -->
<div class="setting-section"> <div class="setting-section">
<div class="section-header-with-toggle"> <div class="section-header-with-toggle">
@@ -198,6 +230,8 @@ const props = defineProps<{
modelStrength: number modelStrength: number
clipStrength: number clipStrength: number
useCustomClipRange: boolean useCustomClipRange: boolean
usePresetStrength: boolean
presetStrengthScale: number
isClipStrengthDisabled: boolean isClipStrengthDisabled: boolean
repeatCount: number repeatCount: number
repeatUsed: number repeatUsed: number
@@ -214,6 +248,8 @@ const emit = defineEmits<{
'update:modelStrength': [value: number] 'update:modelStrength': [value: number]
'update:clipStrength': [value: number] 'update:clipStrength': [value: number]
'update:useCustomClipRange': [value: boolean] 'update:useCustomClipRange': [value: boolean]
'update:usePresetStrength': [value: boolean]
'update:presetStrengthScale': [value: number]
'update:repeatCount': [value: number] 'update:repeatCount': [value: number]
'update:includeNoLora': [value: boolean] 'update:includeNoLora': [value: boolean]
'toggle-pause': [] 'toggle-pause': []

View File

@@ -80,6 +80,8 @@ export interface CyclerConfig {
model_strength: number model_strength: number
clip_strength: number clip_strength: number
use_same_clip_strength: boolean use_same_clip_strength: boolean
use_preset_strength: boolean
preset_strength_scale: number
sort_by: 'filename' | 'model_name' sort_by: 'filename' | 'model_name'
current_lora_name: string // For display current_lora_name: string // For display
current_lora_filename: string current_lora_filename: string

View File

@@ -19,6 +19,8 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
const modelStrength = ref(1.0) const modelStrength = ref(1.0)
const clipStrength = ref(1.0) const clipStrength = ref(1.0)
const useCustomClipRange = ref(false) const useCustomClipRange = ref(false)
const usePresetStrength = ref(false)
const presetStrengthScale = ref(1.0)
const sortBy = ref<'filename' | 'model_name'>('filename') const sortBy = ref<'filename' | 'model_name'>('filename')
const currentLoraName = ref('') const currentLoraName = ref('')
const currentLoraFilename = ref('') const currentLoraFilename = ref('')
@@ -52,6 +54,8 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
model_strength: modelStrength.value, model_strength: modelStrength.value,
clip_strength: clipStrength.value, clip_strength: clipStrength.value,
use_same_clip_strength: !useCustomClipRange.value, use_same_clip_strength: !useCustomClipRange.value,
use_preset_strength: usePresetStrength.value,
preset_strength_scale: presetStrengthScale.value,
sort_by: sortBy.value, sort_by: sortBy.value,
current_lora_name: currentLoraName.value, current_lora_name: currentLoraName.value,
current_lora_filename: currentLoraFilename.value, current_lora_filename: currentLoraFilename.value,
@@ -70,6 +74,8 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
model_strength: modelStrength.value, model_strength: modelStrength.value,
clip_strength: clipStrength.value, clip_strength: clipStrength.value,
use_same_clip_strength: !useCustomClipRange.value, use_same_clip_strength: !useCustomClipRange.value,
use_preset_strength: usePresetStrength.value,
preset_strength_scale: presetStrengthScale.value,
sort_by: sortBy.value, sort_by: sortBy.value,
current_lora_name: currentLoraName.value, current_lora_name: currentLoraName.value,
current_lora_filename: currentLoraFilename.value, current_lora_filename: currentLoraFilename.value,
@@ -94,6 +100,8 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
modelStrength.value = config.model_strength ?? 1.0 modelStrength.value = config.model_strength ?? 1.0
clipStrength.value = config.clip_strength ?? 1.0 clipStrength.value = config.clip_strength ?? 1.0
useCustomClipRange.value = !(config.use_same_clip_strength ?? true) useCustomClipRange.value = !(config.use_same_clip_strength ?? true)
usePresetStrength.value = config.use_preset_strength ?? false
presetStrengthScale.value = config.preset_strength_scale ?? 1.0
sortBy.value = config.sort_by || 'filename' sortBy.value = config.sort_by || 'filename'
currentLoraName.value = config.current_lora_name || '' currentLoraName.value = config.current_lora_name || ''
currentLoraFilename.value = config.current_lora_filename || '' currentLoraFilename.value = config.current_lora_filename || ''
@@ -277,6 +285,8 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
modelStrength, modelStrength,
clipStrength, clipStrength,
useCustomClipRange, useCustomClipRange,
usePresetStrength,
presetStrengthScale,
sortBy, sortBy,
currentLoraName, currentLoraName,
currentLoraFilename, currentLoraFilename,
@@ -296,6 +306,8 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
modelStrength, modelStrength,
clipStrength, clipStrength,
useCustomClipRange, useCustomClipRange,
usePresetStrength,
presetStrengthScale,
sortBy, sortBy,
currentLoraName, currentLoraName,
currentLoraFilename, currentLoraFilename,

View File

@@ -18,7 +18,7 @@ const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448 const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
const LORA_CYCLER_WIDGET_MIN_WIDTH = 380 const LORA_CYCLER_WIDGET_MIN_WIDTH = 380
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 344 const LORA_CYCLER_WIDGET_MIN_HEIGHT = 408
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300 const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200 const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
@@ -201,7 +201,8 @@ function createLoraRandomizerWidget(node) {
const vueApp = createApp(LoraRandomizerWidget, { const vueApp = createApp(LoraRandomizerWidget, {
widget, widget,
node node,
api
}) })
vueApp.use(PrimeVue, { vueApp.use(PrimeVue, {
@@ -243,7 +244,24 @@ function createLoraCyclerWidget(node) {
forwardMiddleMouseToCanvas(container) forwardMiddleMouseToCanvas(container)
let internalValue: CyclerConfig | undefined const defaultConfig: CyclerConfig = {
current_index: 1,
total_count: 0,
pool_config_hash: '',
model_strength: 1.0,
clip_strength: 1.0,
use_same_clip_strength: true,
use_preset_strength: false,
preset_strength_scale: 1.0,
sort_by: 'filename',
current_lora_name: '',
current_lora_filename: '',
repeat_count: 1,
repeat_used: 0,
is_paused: false,
include_no_lora: false,
}
let internalValue: CyclerConfig | undefined = defaultConfig
const widget = node.addDOMWidget( const widget = node.addDOMWidget(
'cycler_config', 'cycler_config',

View File

@@ -79,6 +79,8 @@ describe('useLoraCyclerState', () => {
model_strength: 1.0, model_strength: 1.0,
clip_strength: 1.0, clip_strength: 1.0,
use_same_clip_strength: true, use_same_clip_strength: true,
use_preset_strength: false,
preset_strength_scale: 1.0,
sort_by: 'filename', sort_by: 'filename',
current_lora_name: '', current_lora_name: '',
current_lora_filename: '', current_lora_filename: '',
@@ -340,7 +342,8 @@ describe('useLoraCyclerState', () => {
baseModels: ['SD 1.5'], baseModels: ['SD 1.5'],
tags: { include: [], exclude: [] }, tags: { include: [], exclude: [] },
folders: { include: [], exclude: [] }, folders: { include: [], exclude: [] },
license: { noCreditRequired: false, allowSelling: false } license: { noCreditRequired: false, allowSelling: false },
namePatterns: { include: [], exclude: [], useRegex: false }
} }
}) })
@@ -349,7 +352,8 @@ describe('useLoraCyclerState', () => {
baseModels: ['SDXL'], baseModels: ['SDXL'],
tags: { include: [], exclude: [] }, tags: { include: [], exclude: [] },
folders: { include: [], exclude: [] }, folders: { include: [], exclude: [] },
license: { noCreditRequired: false, allowSelling: false } license: { noCreditRequired: false, allowSelling: false },
namePatterns: { include: [], exclude: [], useRegex: false }
} }
}) })
@@ -540,7 +544,8 @@ describe('useLoraCyclerState', () => {
baseModels: ['SDXL'], baseModels: ['SDXL'],
tags: { include: [], exclude: [] }, tags: { include: [], exclude: [] },
folders: { include: [], exclude: [] }, folders: { include: [], exclude: [] },
license: { noCreditRequired: false, allowSelling: false } license: { noCreditRequired: false, allowSelling: false },
namePatterns: { include: [], exclude: [], useRegex: false }
} }
}) })

View File

@@ -16,6 +16,8 @@ export function createMockCyclerConfig(overrides: Partial<CyclerConfig> = {}): C
model_strength: 1.0, model_strength: 1.0,
clip_strength: 1.0, clip_strength: 1.0,
use_same_clip_strength: true, use_same_clip_strength: true,
use_preset_strength: false,
preset_strength_scale: 1.0,
sort_by: 'filename', sort_by: 'filename',
current_lora_name: 'lora1.safetensors', current_lora_name: 'lora1.safetensors',
current_lora_filename: 'lora1.safetensors', current_lora_filename: 'lora1.safetensors',
@@ -26,7 +28,7 @@ export function createMockCyclerConfig(overrides: Partial<CyclerConfig> = {}): C
is_paused: false, is_paused: false,
include_no_lora: false, include_no_lora: false,
...overrides ...overrides
} } as CyclerConfig
} }
/** /**
@@ -42,7 +44,8 @@ export function createMockPoolConfig(overrides: Partial<LoraPoolConfig> = {}): L
license: { license: {
noCreditRequired: false, noCreditRequired: false,
allowSelling: false allowSelling: false
} },
namePatterns: { include: [], exclude: [], useRegex: false }
}, },
preview: { matchCount: 10, lastUpdated: Date.now() }, preview: { matchCount: 10, lastUpdated: Date.now() },
...overrides ...overrides
@@ -148,7 +151,8 @@ export const SAMPLE_POOL_CONFIGS = {
baseModels: ['SD 1.5'], baseModels: ['SD 1.5'],
tags: { include: [], exclude: [] }, tags: { include: [], exclude: [] },
folders: { include: [], exclude: [] }, folders: { include: [], exclude: [] },
license: { noCreditRequired: false, allowSelling: false } license: { noCreditRequired: false, allowSelling: false },
namePatterns: { include: [], exclude: [], useRegex: false }
} }
}), }),
@@ -158,7 +162,8 @@ export const SAMPLE_POOL_CONFIGS = {
baseModels: ['SDXL'], baseModels: ['SDXL'],
tags: { include: [], exclude: [] }, tags: { include: [], exclude: [] },
folders: { include: [], exclude: [] }, folders: { include: [], exclude: [] },
license: { noCreditRequired: false, allowSelling: false } license: { noCreditRequired: false, allowSelling: false },
namePatterns: { include: [], exclude: [], useRegex: false }
} }
}), }),
@@ -168,7 +173,8 @@ export const SAMPLE_POOL_CONFIGS = {
baseModels: ['SD 1.5'], baseModels: ['SD 1.5'],
tags: { include: ['anime', 'style'], exclude: ['realistic'] }, tags: { include: ['anime', 'style'], exclude: ['realistic'] },
folders: { include: [], exclude: [] }, folders: { include: [], exclude: [] },
license: { noCreditRequired: false, allowSelling: false } license: { noCreditRequired: false, allowSelling: false },
namePatterns: { include: [], exclude: [], useRegex: false }
} }
}), }),

View File

@@ -4,17 +4,13 @@
* These tests simulate ComfyUI's execution modes to verify correct LoRA cycling behavior. * These tests simulate ComfyUI's execution modes to verify correct LoRA cycling behavior.
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { useLoraCyclerState } from '@/composables/useLoraCyclerState' import { useLoraCyclerState } from '@/composables/useLoraCyclerState'
import type { CyclerConfig } from '@/composables/types'
import { import {
createMockWidget, createMockWidget,
createMockCyclerConfig,
createMockLoraList,
createMockPoolConfig
} from '../fixtures/mockConfigs' } from '../fixtures/mockConfigs'
import { setupFetchMock, resetFetchMock } from '../setup' import { resetFetchMock } from '../setup'
import { BatchQueueSimulator, IndexTracker } from '../utils/BatchQueueSimulator' import { BatchQueueSimulator } from '../utils/BatchQueueSimulator'
/** /**
* Creates a test harness that mimics the LoraCyclerWidget's behavior * Creates a test harness that mimics the LoraCyclerWidget's behavior

View File

@@ -0,0 +1,96 @@
import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
import type { LoraEntry, RandomizerConfig } from '@/composables/types'
function createApiMock() {
const target = new EventTarget()
return {
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
dispatchEvent: target.dispatchEvent.bind(target)
}
}
function createDefaultConfig(): RandomizerConfig {
return {
count_mode: 'range',
count_fixed: 3,
count_min: 2,
count_max: 5,
model_strength_min: 0,
model_strength_max: 1,
use_same_clip_strength: true,
clip_strength_min: 0,
clip_strength_max: 1,
roll_mode: 'always',
use_recommended_strength: false,
recommended_strength_scale_min: 0.5,
recommended_strength_scale_max: 1
}
}
describe('LoraRandomizerWidget deferred execution updates', () => {
it('applies backend loras and last_used only after workflow completion', async () => {
const initialLoras: LoraEntry[] = [
{
name: 'initial.safetensors',
strength: 0.8,
clipStrength: 0.8,
active: true,
expanded: false,
locked: false
}
]
const deferredLoras: LoraEntry[] = [
{
name: 'deferred.safetensors',
strength: 1,
clipStrength: 1,
active: true,
expanded: false,
locked: false
}
]
const lorasWidget = { name: 'loras', value: initialLoras }
const node = {
id: 101,
widgets: [lorasWidget],
onExecuted: vi.fn()
}
const widget = {
value: createDefaultConfig()
}
const api = createApiMock()
const wrapper = shallowMount(LoraRandomizerWidget, {
props: {
widget,
node,
api
}
})
await nextTick()
const settingsView = wrapper.findComponent({ name: 'LoraRandomizerSettingsView' })
expect(settingsView.exists()).toBe(true)
expect(settingsView.props('lastUsed')).toBeNull()
;(node as any).onExecuted({
loras: deferredLoras,
last_used: deferredLoras
})
await nextTick()
expect(lorasWidget.value).toEqual(initialLoras)
expect(settingsView.props('lastUsed')).toBeNull()
api.dispatchEvent(new Event('execution_success'))
await nextTick()
expect(lorasWidget.value).toEqual(deferredLoras)
expect(settingsView.props('lastUsed')).toEqual(deferredLoras)
})
})

View File

@@ -27,7 +27,7 @@ export interface SimulatorOptions {
/** /**
* Creates execution output based on the current state * Creates execution output based on the current state
*/ */
function defaultGenerateOutput(executionIndex: number, config: CyclerConfig) { function defaultGenerateOutput(_executionIndex: number, config: CyclerConfig) {
// Calculate what the next index would be after this execution // Calculate what the next index would be after this execution
let nextIdx = (config.execution_index ?? config.current_index) + 1 let nextIdx = (config.execution_index ?? config.current_index) + 1
if (nextIdx > config.total_count) { if (nextIdx > config.total_count) {

View File

@@ -1577,7 +1577,7 @@ to { transform: rotate(360deg);
transform: translateY(4px); transform: translateY(4px);
} }
.lora-randomizer-widget[data-v-94d3fca2] { .lora-randomizer-widget[data-v-ca6e8cec] {
padding: 6px; padding: 6px;
background: rgba(40, 44, 52, 0.6); background: rgba(40, 44, 52, 0.6);
border-radius: 6px; border-radius: 6px;
@@ -1588,16 +1588,16 @@ to { transform: rotate(360deg);
box-sizing: border-box; box-sizing: border-box;
} }
.cycler-settings[data-v-f65566fd] { .cycler-settings[data-v-f0663be4] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #e4e4e7; color: #e4e4e7;
} }
.settings-header[data-v-f65566fd] { .settings-header[data-v-f0663be4] {
margin-bottom: 8px; margin-bottom: 8px;
} }
.settings-title[data-v-f65566fd] { .settings-title[data-v-f0663be4] {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -1606,10 +1606,10 @@ to { transform: rotate(360deg);
margin: 0; margin: 0;
text-transform: uppercase; text-transform: uppercase;
} }
.setting-section[data-v-f65566fd] { .setting-section[data-v-f0663be4] {
margin-bottom: 8px; margin-bottom: 8px;
} }
.setting-label[data-v-f65566fd] { .setting-label[data-v-f0663be4] {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: rgba(226, 232, 240, 0.8); color: rgba(226, 232, 240, 0.8);
@@ -1618,10 +1618,10 @@ to { transform: rotate(360deg);
} }
/* Progress Display */ /* Progress Display */
.progress-section[data-v-f65566fd] { .progress-section[data-v-f0663be4] {
margin-bottom: 12px; margin-bottom: 12px;
} }
.progress-display[data-v-f65566fd] { .progress-display[data-v-f0663be4] {
background: rgba(26, 32, 44, 0.9); background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2); border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px; border-radius: 6px;
@@ -1631,31 +1631,31 @@ to { transform: rotate(360deg);
align-items: center; align-items: center;
transition: border-color 0.3s ease; transition: border-color 0.3s ease;
} }
.progress-display.executing[data-v-f65566fd] { .progress-display.executing[data-v-f0663be4] {
border-color: rgba(66, 153, 225, 0.5); border-color: rgba(66, 153, 225, 0.5);
animation: pulse-f65566fd 2s ease-in-out infinite; animation: pulse-f0663be4 2s ease-in-out infinite;
} }
@keyframes pulse-f65566fd { @keyframes pulse-f0663be4 {
0%, 100% { border-color: rgba(66, 153, 225, 0.3); 0%, 100% { border-color: rgba(66, 153, 225, 0.3);
} }
50% { border-color: rgba(66, 153, 225, 0.7); 50% { border-color: rgba(66, 153, 225, 0.7);
} }
} }
.progress-info[data-v-f65566fd] { .progress-info[data-v-f0663be4] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
.progress-label[data-v-f65566fd] { .progress-label[data-v-f0663be4] {
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
color: rgba(226, 232, 240, 0.5); color: rgba(226, 232, 240, 0.5);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.progress-name[data-v-f65566fd] { .progress-name[data-v-f0663be4] {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: rgba(191, 219, 254, 1); color: rgba(191, 219, 254, 1);
@@ -1663,7 +1663,7 @@ to { transform: rotate(360deg);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.progress-name.clickable[data-v-f65566fd] { .progress-name.clickable[data-v-f0663be4] {
cursor: pointer; cursor: pointer;
padding: 2px 6px; padding: 2px 6px;
margin: -2px -6px; margin: -2px -6px;
@@ -1673,42 +1673,42 @@ to { transform: rotate(360deg);
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.progress-name.clickable[data-v-f65566fd]:hover:not(.disabled) { .progress-name.clickable[data-v-f0663be4]:hover:not(.disabled) {
background: rgba(66, 153, 225, 0.2); background: rgba(66, 153, 225, 0.2);
color: rgba(191, 219, 254, 1); color: rgba(191, 219, 254, 1);
} }
.progress-name.no-lora[data-v-f65566fd] { .progress-name.no-lora[data-v-f0663be4] {
font-style: italic; font-style: italic;
color: rgba(226, 232, 240, 0.6); color: rgba(226, 232, 240, 0.6);
} }
.progress-name.clickable.no-lora[data-v-f65566fd]:hover:not(.disabled) { .progress-name.clickable.no-lora[data-v-f0663be4]:hover:not(.disabled) {
background: rgba(160, 174, 192, 0.2); background: rgba(160, 174, 192, 0.2);
color: rgba(226, 232, 240, 0.8); color: rgba(226, 232, 240, 0.8);
} }
.progress-name.clickable.disabled[data-v-f65566fd] { .progress-name.clickable.disabled[data-v-f0663be4] {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
.progress-info.disabled[data-v-f65566fd] { .progress-info.disabled[data-v-f0663be4] {
cursor: not-allowed; cursor: not-allowed;
} }
.selector-icon[data-v-f65566fd] { .selector-icon[data-v-f0663be4] {
width: 16px; width: 16px;
height: 16px; height: 16px;
opacity: 0.5; opacity: 0.5;
flex-shrink: 0; flex-shrink: 0;
} }
.progress-name.clickable:hover .selector-icon[data-v-f65566fd] { .progress-name.clickable:hover .selector-icon[data-v-f0663be4] {
opacity: 0.8; opacity: 0.8;
} }
.progress-counter[data-v-f65566fd] { .progress-counter[data-v-f0663be4] {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding-left: 12px; padding-left: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
.progress-index[data-v-f65566fd] { .progress-index[data-v-f0663be4] {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: rgba(66, 153, 225, 1); color: rgba(66, 153, 225, 1);
@@ -1717,12 +1717,12 @@ to { transform: rotate(360deg);
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.progress-separator[data-v-f65566fd] { .progress-separator[data-v-f0663be4] {
font-size: 14px; font-size: 14px;
color: rgba(226, 232, 240, 0.4); color: rgba(226, 232, 240, 0.4);
margin: 0 2px; margin: 0 2px;
} }
.progress-total[data-v-f65566fd] { .progress-total[data-v-f0663be4] {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: rgba(226, 232, 240, 0.6); color: rgba(226, 232, 240, 0.6);
@@ -1733,7 +1733,7 @@ to { transform: rotate(360deg);
} }
/* Repeat Progress */ /* Repeat Progress */
.repeat-progress[data-v-f65566fd] { .repeat-progress[data-v-f0663be4] {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -1743,23 +1743,23 @@ to { transform: rotate(360deg);
border: 1px solid rgba(226, 232, 240, 0.1); border: 1px solid rgba(226, 232, 240, 0.1);
border-radius: 4px; border-radius: 4px;
} }
.repeat-progress-track[data-v-f65566fd] { .repeat-progress-track[data-v-f0663be4] {
width: 32px; width: 32px;
height: 4px; height: 4px;
background: rgba(226, 232, 240, 0.15); background: rgba(226, 232, 240, 0.15);
border-radius: 2px; border-radius: 2px;
overflow: hidden; overflow: hidden;
} }
.repeat-progress-fill[data-v-f65566fd] { .repeat-progress-fill[data-v-f0663be4] {
height: 100%; height: 100%;
background: linear-gradient(90deg, #f59e0b, #fbbf24); background: linear-gradient(90deg, #f59e0b, #fbbf24);
border-radius: 2px; border-radius: 2px;
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.repeat-progress-fill.is-complete[data-v-f65566fd] { .repeat-progress-fill.is-complete[data-v-f0663be4] {
background: linear-gradient(90deg, #10b981, #34d399); background: linear-gradient(90deg, #10b981, #34d399);
} }
.repeat-progress-text[data-v-f65566fd] { .repeat-progress-text[data-v-f0663be4] {
font-size: 10px; font-size: 10px;
font-family: 'SF Mono', 'Roboto Mono', monospace; font-family: 'SF Mono', 'Roboto Mono', monospace;
color: rgba(253, 230, 138, 0.9); color: rgba(253, 230, 138, 0.9);
@@ -1768,19 +1768,19 @@ to { transform: rotate(360deg);
} }
/* Index Controls Row - Grouped Layout */ /* Index Controls Row - Grouped Layout */
.index-controls-row[data-v-f65566fd] { .index-controls-row[data-v-f0663be4] {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 16px; gap: 16px;
} }
/* Control Group */ /* Control Group */
.control-group[data-v-f65566fd] { .control-group[data-v-f0663be4] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
} }
.control-group-label[data-v-f65566fd] { .control-group-label[data-v-f0663be4] {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: rgba(226, 232, 240, 0.5); color: rgba(226, 232, 240, 0.5);
@@ -1788,13 +1788,13 @@ to { transform: rotate(360deg);
letter-spacing: 0.03em; letter-spacing: 0.03em;
line-height: 1; line-height: 1;
} }
.control-group-content[data-v-f65566fd] { .control-group-content[data-v-f0663be4] {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 4px; gap: 4px;
height: 32px; height: 32px;
} }
.index-input[data-v-f65566fd] { .index-input[data-v-f0663be4] {
width: 50px; width: 50px;
height: 32px; height: 32px;
padding: 0 8px; padding: 0 8px;
@@ -1807,15 +1807,15 @@ to { transform: rotate(360deg);
line-height: 32px; line-height: 32px;
box-sizing: border-box; box-sizing: border-box;
} }
.index-input[data-v-f65566fd]:focus { .index-input[data-v-f0663be4]:focus {
outline: none; outline: none;
border-color: rgba(66, 153, 225, 0.6); border-color: rgba(66, 153, 225, 0.6);
} }
.index-input[data-v-f65566fd]:disabled { .index-input[data-v-f0663be4]:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.index-hint[data-v-f65566fd] { .index-hint[data-v-f0663be4] {
font-size: 12px; font-size: 12px;
color: rgba(226, 232, 240, 0.4); color: rgba(226, 232, 240, 0.4);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -1823,7 +1823,7 @@ to { transform: rotate(360deg);
} }
/* Repeat Controls */ /* Repeat Controls */
.repeat-input[data-v-f65566fd] { .repeat-input[data-v-f0663be4] {
width: 50px; width: 50px;
height: 32px; height: 32px;
padding: 0 6px; padding: 0 6px;
@@ -1837,11 +1837,11 @@ to { transform: rotate(360deg);
line-height: 32px; line-height: 32px;
box-sizing: border-box; box-sizing: border-box;
} }
.repeat-input[data-v-f65566fd]:focus { .repeat-input[data-v-f0663be4]:focus {
outline: none; outline: none;
border-color: rgba(66, 153, 225, 0.6); border-color: rgba(66, 153, 225, 0.6);
} }
.repeat-suffix[data-v-f65566fd] { .repeat-suffix[data-v-f0663be4] {
font-size: 13px; font-size: 13px;
color: rgba(226, 232, 240, 0.4); color: rgba(226, 232, 240, 0.4);
font-weight: 500; font-weight: 500;
@@ -1849,7 +1849,7 @@ to { transform: rotate(360deg);
} }
/* Action Buttons */ /* Action Buttons */
.action-buttons[data-v-f65566fd] { .action-buttons[data-v-f0663be4] {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -1857,7 +1857,7 @@ to { transform: rotate(360deg);
} }
/* Control Buttons */ /* Control Buttons */
.control-btn[data-v-f65566fd] { .control-btn[data-v-f0663be4] {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1871,52 +1871,52 @@ to { transform: rotate(360deg);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.control-btn[data-v-f65566fd]:hover:not(:disabled) { .control-btn[data-v-f0663be4]:hover:not(:disabled) {
background: rgba(66, 153, 225, 0.2); background: rgba(66, 153, 225, 0.2);
border-color: rgba(66, 153, 225, 0.4); border-color: rgba(66, 153, 225, 0.4);
color: rgba(191, 219, 254, 1); color: rgba(191, 219, 254, 1);
} }
.control-btn[data-v-f65566fd]:disabled { .control-btn[data-v-f0663be4]:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.control-btn.active[data-v-f65566fd] { .control-btn.active[data-v-f0663be4] {
background: rgba(245, 158, 11, 0.2); background: rgba(245, 158, 11, 0.2);
border-color: rgba(245, 158, 11, 0.5); border-color: rgba(245, 158, 11, 0.5);
color: rgba(253, 230, 138, 1); color: rgba(253, 230, 138, 1);
} }
.control-btn.active[data-v-f65566fd]:hover { .control-btn.active[data-v-f0663be4]:hover {
background: rgba(245, 158, 11, 0.3); background: rgba(245, 158, 11, 0.3);
border-color: rgba(245, 158, 11, 0.6); border-color: rgba(245, 158, 11, 0.6);
} }
.control-icon[data-v-f65566fd] { .control-icon[data-v-f0663be4] {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
/* Slider Container */ /* Slider Container */
.slider-container[data-v-f65566fd] { .slider-container[data-v-f0663be4] {
background: rgba(26, 32, 44, 0.9); background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2); border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 6px; border-radius: 6px;
padding: 6px; padding: 6px;
} }
.slider-container--disabled[data-v-f65566fd] { .slider-container--disabled[data-v-f0663be4] {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
} }
.section-header-with-toggle[data-v-f65566fd] { .section-header-with-toggle[data-v-f0663be4] {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 8px; margin-bottom: 8px;
} }
.section-header-with-toggle .setting-label[data-v-f65566fd] { .section-header-with-toggle .setting-label[data-v-f0663be4] {
margin-bottom: 4px; margin-bottom: 4px;
} }
/* Toggle Switch */ /* Toggle Switch */
.toggle-switch[data-v-f65566fd] { .toggle-switch[data-v-f0663be4] {
position: relative; position: relative;
width: 36px; width: 36px;
height: 20px; height: 20px;
@@ -1925,7 +1925,7 @@ to { transform: rotate(360deg);
border: none; border: none;
cursor: pointer; cursor: pointer;
} }
.toggle-switch__track[data-v-f65566fd] { .toggle-switch__track[data-v-f0663be4] {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: var(--comfy-input-bg, #333); background: var(--comfy-input-bg, #333);
@@ -1933,11 +1933,11 @@ to { transform: rotate(360deg);
border-radius: 10px; border-radius: 10px;
transition: all 0.2s; transition: all 0.2s;
} }
.toggle-switch--active .toggle-switch__track[data-v-f65566fd] { .toggle-switch--active .toggle-switch__track[data-v-f0663be4] {
background: rgba(66, 153, 225, 0.3); background: rgba(66, 153, 225, 0.3);
border-color: rgba(66, 153, 225, 0.6); border-color: rgba(66, 153, 225, 0.6);
} }
.toggle-switch__thumb[data-v-f65566fd] { .toggle-switch__thumb[data-v-f0663be4] {
position: absolute; position: absolute;
top: 3px; top: 3px;
left: 2px; left: 2px;
@@ -1948,12 +1948,12 @@ to { transform: rotate(360deg);
transition: all 0.2s; transition: all 0.2s;
opacity: 0.6; opacity: 0.6;
} }
.toggle-switch--active .toggle-switch__thumb[data-v-f65566fd] { .toggle-switch--active .toggle-switch__thumb[data-v-f0663be4] {
transform: translateX(16px); transform: translateX(16px);
background: #4299e1; background: #4299e1;
opacity: 1; opacity: 1;
} }
.toggle-switch:hover .toggle-switch__thumb[data-v-f65566fd] { .toggle-switch:hover .toggle-switch__thumb[data-v-f0663be4] {
opacity: 1; opacity: 1;
} }
@@ -2077,7 +2077,7 @@ to { transform: rotate(360deg);
font-size: 13px; font-size: 13px;
} }
.lora-cycler-widget[data-v-b97187b9] { .lora-cycler-widget[data-v-3f46897a] {
padding: 6px; padding: 6px;
background: rgba(40, 44, 52, 0.6); background: rgba(40, 44, 52, 0.6);
border-radius: 6px; border-radius: 6px;
@@ -13240,7 +13240,8 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
__name: "LoraRandomizerWidget", __name: "LoraRandomizerWidget",
props: { props: {
widget: {}, widget: {},
node: {} node: {},
api: {}
}, },
setup(__props) { setup(__props) {
const props = __props; const props = __props;
@@ -13248,6 +13249,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
const HAS_EXECUTED = Symbol("HAS_EXECUTED"); const HAS_EXECUTED = Symbol("HAS_EXECUTED");
const currentLoras = ref([]); const currentLoras = ref([]);
const isMounted = ref(false); const isMounted = ref(false);
const pendingExecutions = [];
const canReuseLast = computed(() => { const canReuseLast = computed(() => {
const lastUsed = state.lastUsed.value; const lastUsed = state.lastUsed.value;
if (!lastUsed || lastUsed.length === 0) return false; if (!lastUsed || lastUsed.length === 0) return false;
@@ -13377,22 +13379,55 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
}; };
const originalOnExecuted = (_b = props.node.onExecuted) == null ? void 0 : _b.bind(props.node); const originalOnExecuted = (_b = props.node.onExecuted) == null ? void 0 : _b.bind(props.node);
props.node.onExecuted = function(output) { props.node.onExecuted = function(output) {
var _a3;
console.log("[LoraRandomizerWidget] Node executed with output:", output); console.log("[LoraRandomizerWidget] Node executed with output:", output);
const pendingUpdate = {};
if ((output == null ? void 0 : output.last_used) !== void 0) { if ((output == null ? void 0 : output.last_used) !== void 0) {
state.lastUsed.value = output.last_used; pendingUpdate.lastUsed = output.last_used;
console.log(`[LoraRandomizerWidget] Updated last_used: ${output.last_used ? output.last_used.length : 0} LoRAs`); console.log(`[LoraRandomizerWidget] Queued last_used update: ${output.last_used ? output.last_used.length : 0} LoRAs`);
} }
const lorasWidget2 = (_a3 = props.node.widgets) == null ? void 0 : _a3.find((w2) => w2.name === "loras"); if ((output == null ? void 0 : output.loras) && Array.isArray(output.loras)) {
if (lorasWidget2 && (output == null ? void 0 : output.loras) && Array.isArray(output.loras)) { pendingUpdate.loras = output.loras;
console.log("[LoraRandomizerWidget] Received loras data from backend:", output.loras); console.log("[LoraRandomizerWidget] Queued loras data from backend:", output.loras);
lorasWidget2.value = output.loras; }
currentLoras.value = output.loras; if (pendingUpdate.lastUsed !== void 0 || pendingUpdate.loras !== void 0) {
pendingExecutions.push(pendingUpdate);
} }
if (originalOnExecuted) { if (originalOnExecuted) {
return originalOnExecuted(output); return originalOnExecuted(output);
} }
}; };
if (props.api) {
const handleExecutionComplete = () => {
var _a3;
if (pendingExecutions.length === 0) {
return;
}
const pending = pendingExecutions.shift();
if (pending.lastUsed !== void 0) {
state.lastUsed.value = pending.lastUsed;
}
if (pending.loras !== void 0) {
const lorasWidget2 = (_a3 = props.node.widgets) == null ? void 0 : _a3.find((w2) => w2.name === "loras");
if (lorasWidget2) {
lorasWidget2.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.onRemoveCleanup;
props.widget.onRemoveCleanup = () => {
existingCleanup == null ? void 0 : existingCleanup();
apiCleanup();
};
}
}); });
return (_ctx, _cache) => { return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", { return openBlock(), createElementBlock("div", {
@@ -13439,7 +13474,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
}; };
} }
}); });
const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-94d3fca2"]]); const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-ca6e8cec"]]);
const _hoisted_1$3 = { class: "cycler-settings" }; const _hoisted_1$3 = { class: "cycler-settings" };
const _hoisted_2$3 = { class: "setting-section progress-section" }; const _hoisted_2$3 = { class: "setting-section progress-section" };
const _hoisted_3$3 = { class: "progress-label" }; const _hoisted_3$3 = { class: "progress-label" };
@@ -13480,11 +13515,14 @@ const _hoisted_24 = { class: "setting-section" };
const _hoisted_25 = { class: "slider-container" }; const _hoisted_25 = { class: "slider-container" };
const _hoisted_26 = { class: "setting-section" }; const _hoisted_26 = { class: "setting-section" };
const _hoisted_27 = { class: "section-header-with-toggle" }; const _hoisted_27 = { class: "section-header-with-toggle" };
const _hoisted_28 = { class: "setting-label" }; const _hoisted_28 = ["aria-checked"];
const _hoisted_29 = ["aria-checked"]; const _hoisted_29 = { class: "setting-section" };
const _hoisted_30 = { class: "setting-section" }; const _hoisted_30 = { class: "section-header-with-toggle" };
const _hoisted_31 = { class: "section-header-with-toggle" }; const _hoisted_31 = { class: "setting-label" };
const _hoisted_32 = ["aria-checked"]; const _hoisted_32 = ["aria-checked"];
const _hoisted_33 = { class: "setting-section" };
const _hoisted_34 = { class: "section-header-with-toggle" };
const _hoisted_35 = ["aria-checked"];
const _sfc_main$4 = /* @__PURE__ */ defineComponent({ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
__name: "LoraCyclerSettingsView", __name: "LoraCyclerSettingsView",
props: { props: {
@@ -13495,6 +13533,8 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
modelStrength: {}, modelStrength: {},
clipStrength: {}, clipStrength: {},
useCustomClipRange: { type: Boolean }, useCustomClipRange: { type: Boolean },
usePresetStrength: { type: Boolean },
presetStrengthScale: {},
isClipStrengthDisabled: { type: Boolean }, isClipStrengthDisabled: { type: Boolean },
repeatCount: {}, repeatCount: {},
repeatUsed: {}, repeatUsed: {},
@@ -13505,7 +13545,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
includeNoLora: { type: Boolean }, includeNoLora: { type: Boolean },
isNoLora: { type: Boolean } isNoLora: { type: Boolean }
}, },
emits: ["update:currentIndex", "update:modelStrength", "update:clipStrength", "update:useCustomClipRange", "update:repeatCount", "update:includeNoLora", "toggle-pause", "reset-index", "open-lora-selector"], emits: ["update:currentIndex", "update:modelStrength", "update:clipStrength", "update:useCustomClipRange", "update:usePresetStrength", "update:presetStrengthScale", "update:repeatCount", "update:includeNoLora", "toggle-pause", "reset-index", "open-lora-selector"],
setup(__props, { emit: __emit }) { setup(__props, { emit: __emit }) {
const props = __props; const props = __props;
const emit2 = __emit; const emit2 = __emit;
@@ -13551,7 +13591,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
}; };
return (_ctx, _cache) => { return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$3, [ return openBlock(), createElementBlock("div", _hoisted_1$3, [
_cache[24] || (_cache[24] = createBaseVNode("div", { class: "settings-header" }, [ _cache[28] || (_cache[28] = createBaseVNode("div", { class: "settings-header" }, [
createBaseVNode("h3", { class: "settings-title" }, "CYCLER SETTINGS") createBaseVNode("h3", { class: "settings-title" }, "CYCLER SETTINGS")
], -1)), ], -1)),
createBaseVNode("div", _hoisted_2$3, [ createBaseVNode("div", _hoisted_2$3, [
@@ -13568,7 +13608,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
title: __props.currentLoraFilename title: __props.currentLoraFilename
}, [ }, [
createTextVNode(toDisplayString(__props.currentLoraName || "None") + " ", 1), createTextVNode(toDisplayString(__props.currentLoraName || "None") + " ", 1),
_cache[12] || (_cache[12] = createBaseVNode("svg", { _cache[14] || (_cache[14] = createBaseVNode("svg", {
class: "selector-icon", class: "selector-icon",
viewBox: "0 0 24 24", viewBox: "0 0 24 24",
fill: "currentColor" fill: "currentColor"
@@ -13579,7 +13619,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
], 2), ], 2),
createBaseVNode("div", _hoisted_5$1, [ createBaseVNode("div", _hoisted_5$1, [
createBaseVNode("span", _hoisted_6$1, toDisplayString(__props.currentIndex), 1), createBaseVNode("span", _hoisted_6$1, toDisplayString(__props.currentIndex), 1),
_cache[13] || (_cache[13] = createBaseVNode("span", { class: "progress-separator" }, "/", -1)), _cache[15] || (_cache[15] = createBaseVNode("span", { class: "progress-separator" }, "/", -1)),
createBaseVNode("span", _hoisted_7$1, toDisplayString(__props.totalCount), 1), createBaseVNode("span", _hoisted_7$1, toDisplayString(__props.totalCount), 1),
__props.repeatCount > 1 ? (openBlock(), createElementBlock("div", _hoisted_8, [ __props.repeatCount > 1 ? (openBlock(), createElementBlock("div", _hoisted_8, [
createBaseVNode("div", _hoisted_9, [ createBaseVNode("div", _hoisted_9, [
@@ -13596,7 +13636,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
createBaseVNode("div", _hoisted_11, [ createBaseVNode("div", _hoisted_11, [
createBaseVNode("div", _hoisted_12, [ createBaseVNode("div", _hoisted_12, [
createBaseVNode("div", _hoisted_13, [ createBaseVNode("div", _hoisted_13, [
_cache[14] || (_cache[14] = createBaseVNode("label", { class: "control-group-label" }, "Starting Index", -1)), _cache[16] || (_cache[16] = createBaseVNode("label", { class: "control-group-label" }, "Starting Index", -1)),
createBaseVNode("div", _hoisted_14, [ createBaseVNode("div", _hoisted_14, [
createBaseVNode("input", { createBaseVNode("input", {
type: "number", type: "number",
@@ -13618,7 +13658,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
]) ])
]), ]),
createBaseVNode("div", _hoisted_17, [ createBaseVNode("div", _hoisted_17, [
_cache[16] || (_cache[16] = createBaseVNode("label", { class: "control-group-label" }, "Repeat", -1)), _cache[18] || (_cache[18] = createBaseVNode("label", { class: "control-group-label" }, "Repeat", -1)),
createBaseVNode("div", _hoisted_18, [ createBaseVNode("div", _hoisted_18, [
createBaseVNode("input", { createBaseVNode("input", {
type: "number", type: "number",
@@ -13636,7 +13676,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
}, ["stop"])), }, ["stop"])),
title: "Each LoRA will be used this many times before moving to the next" title: "Each LoRA will be used this many times before moving to the next"
}, null, 40, _hoisted_19), }, null, 40, _hoisted_19),
_cache[15] || (_cache[15] = createBaseVNode("span", { class: "repeat-suffix" }, "×", -1)) _cache[17] || (_cache[17] = createBaseVNode("span", { class: "repeat-suffix" }, "×", -1))
]) ])
]), ]),
createBaseVNode("div", _hoisted_20, [ createBaseVNode("div", _hoisted_20, [
@@ -13646,9 +13686,9 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
onClick: _cache[6] || (_cache[6] = ($event) => _ctx.$emit("toggle-pause")), onClick: _cache[6] || (_cache[6] = ($event) => _ctx.$emit("toggle-pause")),
title: __props.isPauseDisabled ? "Cannot pause while prompts are queued" : __props.isPaused ? "Continue iteration" : "Pause iteration" title: __props.isPauseDisabled ? "Cannot pause while prompts are queued" : __props.isPaused ? "Continue iteration" : "Pause iteration"
}, [ }, [
__props.isPaused ? (openBlock(), createElementBlock("svg", _hoisted_22, [..._cache[17] || (_cache[17] = [ __props.isPaused ? (openBlock(), createElementBlock("svg", _hoisted_22, [..._cache[19] || (_cache[19] = [
createBaseVNode("path", { d: "M8 5v14l11-7z" }, null, -1) createBaseVNode("path", { d: "M8 5v14l11-7z" }, null, -1)
])])) : (openBlock(), createElementBlock("svg", _hoisted_23, [..._cache[18] || (_cache[18] = [ ])])) : (openBlock(), createElementBlock("svg", _hoisted_23, [..._cache[20] || (_cache[20] = [
createBaseVNode("path", { d: "M6 4h4v16H6zm8 0h4v16h-4z" }, null, -1) createBaseVNode("path", { d: "M6 4h4v16H6zm8 0h4v16h-4z" }, null, -1)
])])) ])]))
], 10, _hoisted_21), ], 10, _hoisted_21),
@@ -13656,7 +13696,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
class: "control-btn", class: "control-btn",
onClick: _cache[7] || (_cache[7] = ($event) => _ctx.$emit("reset-index")), onClick: _cache[7] || (_cache[7] = ($event) => _ctx.$emit("reset-index")),
title: "Reset to index 1" title: "Reset to index 1"
}, [..._cache[19] || (_cache[19] = [ }, [..._cache[21] || (_cache[21] = [
createBaseVNode("svg", { createBaseVNode("svg", {
viewBox: "0 0 24 24", viewBox: "0 0 24 24",
fill: "currentColor", fill: "currentColor",
@@ -13669,7 +13709,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
]) ])
]), ]),
createBaseVNode("div", _hoisted_24, [ createBaseVNode("div", _hoisted_24, [
_cache[20] || (_cache[20] = createBaseVNode("label", { class: "setting-label" }, "Model Strength", -1)), _cache[22] || (_cache[22] = createBaseVNode("label", { class: "setting-label" }, "Model Strength", -1)),
createBaseVNode("div", _hoisted_25, [ createBaseVNode("div", _hoisted_25, [
createVNode(SingleSlider, { createVNode(SingleSlider, {
min: -10, min: -10,
@@ -13683,18 +13723,47 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
]), ]),
createBaseVNode("div", _hoisted_26, [ createBaseVNode("div", _hoisted_26, [
createBaseVNode("div", _hoisted_27, [ createBaseVNode("div", _hoisted_27, [
createBaseVNode("label", _hoisted_28, " Clip Strength - " + toDisplayString(__props.useCustomClipRange ? "Custom Value" : "Use Model Strength"), 1), _cache[24] || (_cache[24] = createBaseVNode("label", { class: "setting-label" }, " Preset Strength Scale ", -1)),
createBaseVNode("button", {
type: "button",
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.usePresetStrength }]),
onClick: _cache[9] || (_cache[9] = ($event) => _ctx.$emit("update:usePresetStrength", !__props.usePresetStrength)),
role: "switch",
"aria-checked": __props.usePresetStrength,
title: "Use scaled preset strength when enabled"
}, [..._cache[23] || (_cache[23] = [
createBaseVNode("span", { class: "toggle-switch__track" }, null, -1),
createBaseVNode("span", { class: "toggle-switch__thumb" }, null, -1)
])], 10, _hoisted_28)
]),
createBaseVNode("div", {
class: normalizeClass(["slider-container", { "slider-container--disabled": !__props.usePresetStrength }])
}, [
createVNode(SingleSlider, {
min: 0,
max: 2,
value: __props.presetStrengthScale,
step: 0.1,
"default-range": { min: 0.5, max: 1 },
disabled: !__props.usePresetStrength,
"onUpdate:value": _cache[10] || (_cache[10] = ($event) => _ctx.$emit("update:presetStrengthScale", $event))
}, null, 8, ["value", "disabled"])
], 2)
]),
createBaseVNode("div", _hoisted_29, [
createBaseVNode("div", _hoisted_30, [
createBaseVNode("label", _hoisted_31, " Clip Strength - " + toDisplayString(__props.useCustomClipRange ? "Custom Value" : "Use Model Strength"), 1),
createBaseVNode("button", { createBaseVNode("button", {
type: "button", type: "button",
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.useCustomClipRange }]), class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.useCustomClipRange }]),
onClick: _cache[9] || (_cache[9] = ($event) => _ctx.$emit("update:useCustomClipRange", !__props.useCustomClipRange)), onClick: _cache[11] || (_cache[11] = ($event) => _ctx.$emit("update:useCustomClipRange", !__props.useCustomClipRange)),
role: "switch", role: "switch",
"aria-checked": __props.useCustomClipRange, "aria-checked": __props.useCustomClipRange,
title: "Use custom clip strength when enabled, otherwise use model strength" title: "Use custom clip strength when enabled, otherwise use model strength"
}, [..._cache[21] || (_cache[21] = [ }, [..._cache[25] || (_cache[25] = [
createBaseVNode("span", { class: "toggle-switch__track" }, null, -1), createBaseVNode("span", { class: "toggle-switch__track" }, null, -1),
createBaseVNode("span", { class: "toggle-switch__thumb" }, null, -1) createBaseVNode("span", { class: "toggle-switch__thumb" }, null, -1)
])], 10, _hoisted_29) ])], 10, _hoisted_32)
]), ]),
createBaseVNode("div", { createBaseVNode("div", {
class: normalizeClass(["slider-container", { "slider-container--disabled": __props.isClipStrengthDisabled }]) class: normalizeClass(["slider-container", { "slider-container--disabled": __props.isClipStrengthDisabled }])
@@ -13706,31 +13775,31 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
step: 0.1, step: 0.1,
"default-range": { min: 0.5, max: 1.5 }, "default-range": { min: 0.5, max: 1.5 },
disabled: __props.isClipStrengthDisabled, disabled: __props.isClipStrengthDisabled,
"onUpdate:value": _cache[10] || (_cache[10] = ($event) => _ctx.$emit("update:clipStrength", $event)) "onUpdate:value": _cache[12] || (_cache[12] = ($event) => _ctx.$emit("update:clipStrength", $event))
}, null, 8, ["value", "disabled"]) }, null, 8, ["value", "disabled"])
], 2) ], 2)
]), ]),
createBaseVNode("div", _hoisted_30, [ createBaseVNode("div", _hoisted_33, [
createBaseVNode("div", _hoisted_31, [ createBaseVNode("div", _hoisted_34, [
_cache[23] || (_cache[23] = createBaseVNode("label", { class: "setting-label" }, ' Add "No LoRA" step ', -1)), _cache[27] || (_cache[27] = createBaseVNode("label", { class: "setting-label" }, ' Add "No LoRA" step ', -1)),
createBaseVNode("button", { createBaseVNode("button", {
type: "button", type: "button",
class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.includeNoLora }]), class: normalizeClass(["toggle-switch", { "toggle-switch--active": __props.includeNoLora }]),
onClick: _cache[11] || (_cache[11] = ($event) => _ctx.$emit("update:includeNoLora", !__props.includeNoLora)), onClick: _cache[13] || (_cache[13] = ($event) => _ctx.$emit("update:includeNoLora", !__props.includeNoLora)),
role: "switch", role: "switch",
"aria-checked": __props.includeNoLora, "aria-checked": __props.includeNoLora,
title: "Add an iteration without LoRA for comparison" title: "Add an iteration without LoRA for comparison"
}, [..._cache[22] || (_cache[22] = [ }, [..._cache[26] || (_cache[26] = [
createBaseVNode("span", { class: "toggle-switch__track" }, null, -1), createBaseVNode("span", { class: "toggle-switch__track" }, null, -1),
createBaseVNode("span", { class: "toggle-switch__thumb" }, null, -1) createBaseVNode("span", { class: "toggle-switch__thumb" }, null, -1)
])], 10, _hoisted_32) ])], 10, _hoisted_35)
]) ])
]) ])
]); ]);
}; };
} }
}); });
const LoraCyclerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-f65566fd"]]); const LoraCyclerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-f0663be4"]]);
const _hoisted_1$2 = { class: "search-container" }; const _hoisted_1$2 = { class: "search-container" };
const _hoisted_2$2 = { class: "lora-list" }; const _hoisted_2$2 = { class: "lora-list" };
const _hoisted_3$2 = ["onMouseenter", "onClick"]; const _hoisted_3$2 = ["onMouseenter", "onClick"];
@@ -13933,6 +14002,8 @@ function useLoraCyclerState(widget) {
const modelStrength = ref(1); const modelStrength = ref(1);
const clipStrength = ref(1); const clipStrength = ref(1);
const useCustomClipRange = ref(false); const useCustomClipRange = ref(false);
const usePresetStrength = ref(false);
const presetStrengthScale = ref(1);
const sortBy = ref("filename"); const sortBy = ref("filename");
const currentLoraName = ref(""); const currentLoraName = ref("");
const currentLoraFilename = ref(""); const currentLoraFilename = ref("");
@@ -13955,6 +14026,8 @@ function useLoraCyclerState(widget) {
model_strength: modelStrength.value, model_strength: modelStrength.value,
clip_strength: clipStrength.value, clip_strength: clipStrength.value,
use_same_clip_strength: !useCustomClipRange.value, use_same_clip_strength: !useCustomClipRange.value,
use_preset_strength: usePresetStrength.value,
preset_strength_scale: presetStrengthScale.value,
sort_by: sortBy.value, sort_by: sortBy.value,
current_lora_name: currentLoraName.value, current_lora_name: currentLoraName.value,
current_lora_filename: currentLoraFilename.value, current_lora_filename: currentLoraFilename.value,
@@ -13973,6 +14046,8 @@ function useLoraCyclerState(widget) {
model_strength: modelStrength.value, model_strength: modelStrength.value,
clip_strength: clipStrength.value, clip_strength: clipStrength.value,
use_same_clip_strength: !useCustomClipRange.value, use_same_clip_strength: !useCustomClipRange.value,
use_preset_strength: usePresetStrength.value,
preset_strength_scale: presetStrengthScale.value,
sort_by: sortBy.value, sort_by: sortBy.value,
current_lora_name: currentLoraName.value, current_lora_name: currentLoraName.value,
current_lora_filename: currentLoraFilename.value, current_lora_filename: currentLoraFilename.value,
@@ -13993,6 +14068,8 @@ function useLoraCyclerState(widget) {
modelStrength.value = config.model_strength ?? 1; modelStrength.value = config.model_strength ?? 1;
clipStrength.value = config.clip_strength ?? 1; clipStrength.value = config.clip_strength ?? 1;
useCustomClipRange.value = !(config.use_same_clip_strength ?? true); useCustomClipRange.value = !(config.use_same_clip_strength ?? true);
usePresetStrength.value = config.use_preset_strength ?? false;
presetStrengthScale.value = config.preset_strength_scale ?? 1;
sortBy.value = config.sort_by || "filename"; sortBy.value = config.sort_by || "filename";
currentLoraName.value = config.current_lora_name || ""; currentLoraName.value = config.current_lora_name || "";
currentLoraFilename.value = config.current_lora_filename || ""; currentLoraFilename.value = config.current_lora_filename || "";
@@ -14122,6 +14199,8 @@ function useLoraCyclerState(widget) {
modelStrength, modelStrength,
clipStrength, clipStrength,
useCustomClipRange, useCustomClipRange,
usePresetStrength,
presetStrengthScale,
sortBy, sortBy,
currentLoraName, currentLoraName,
currentLoraFilename, currentLoraFilename,
@@ -14140,6 +14219,8 @@ function useLoraCyclerState(widget) {
modelStrength, modelStrength,
clipStrength, clipStrength,
useCustomClipRange, useCustomClipRange,
usePresetStrength,
presetStrengthScale,
sortBy, sortBy,
currentLoraName, currentLoraName,
currentLoraFilename, currentLoraFilename,
@@ -14476,6 +14557,8 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
"model-strength": unref(state).modelStrength.value, "model-strength": unref(state).modelStrength.value,
"clip-strength": unref(state).clipStrength.value, "clip-strength": unref(state).clipStrength.value,
"use-custom-clip-range": unref(state).useCustomClipRange.value, "use-custom-clip-range": unref(state).useCustomClipRange.value,
"use-preset-strength": unref(state).usePresetStrength.value,
"preset-strength-scale": unref(state).presetStrengthScale.value,
"is-clip-strength-disabled": unref(state).isClipStrengthDisabled.value, "is-clip-strength-disabled": unref(state).isClipStrengthDisabled.value,
"is-loading": unref(state).isLoading.value, "is-loading": unref(state).isLoading.value,
"repeat-count": unref(state).repeatCount.value, "repeat-count": unref(state).repeatCount.value,
@@ -14490,25 +14573,27 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
"onUpdate:modelStrength": _cache[0] || (_cache[0] = ($event) => unref(state).modelStrength.value = $event), "onUpdate:modelStrength": _cache[0] || (_cache[0] = ($event) => unref(state).modelStrength.value = $event),
"onUpdate:clipStrength": _cache[1] || (_cache[1] = ($event) => unref(state).clipStrength.value = $event), "onUpdate:clipStrength": _cache[1] || (_cache[1] = ($event) => unref(state).clipStrength.value = $event),
"onUpdate:useCustomClipRange": handleUseCustomClipRangeChange, "onUpdate:useCustomClipRange": handleUseCustomClipRangeChange,
"onUpdate:usePresetStrength": _cache[2] || (_cache[2] = ($event) => unref(state).usePresetStrength.value = $event),
"onUpdate:presetStrengthScale": _cache[3] || (_cache[3] = ($event) => unref(state).presetStrengthScale.value = $event),
"onUpdate:repeatCount": handleRepeatCountChange, "onUpdate:repeatCount": handleRepeatCountChange,
"onUpdate:includeNoLora": handleIncludeNoLoraChange, "onUpdate:includeNoLora": handleIncludeNoLoraChange,
onTogglePause: handleTogglePause, onTogglePause: handleTogglePause,
onResetIndex: handleResetIndex, onResetIndex: handleResetIndex,
onOpenLoraSelector: _cache[2] || (_cache[2] = ($event) => isModalOpen.value = true) onOpenLoraSelector: _cache[4] || (_cache[4] = ($event) => isModalOpen.value = true)
}, null, 8, ["current-index", "total-count", "current-lora-name", "current-lora-filename", "model-strength", "clip-strength", "use-custom-clip-range", "is-clip-strength-disabled", "is-loading", "repeat-count", "repeat-used", "is-paused", "is-pause-disabled", "is-workflow-executing", "executing-repeat-step", "include-no-lora", "is-no-lora"]), }, null, 8, ["current-index", "total-count", "current-lora-name", "current-lora-filename", "model-strength", "clip-strength", "use-custom-clip-range", "use-preset-strength", "preset-strength-scale", "is-clip-strength-disabled", "is-loading", "repeat-count", "repeat-used", "is-paused", "is-pause-disabled", "is-workflow-executing", "executing-repeat-step", "include-no-lora", "is-no-lora"]),
createVNode(LoraListModal, { createVNode(LoraListModal, {
visible: isModalOpen.value, visible: isModalOpen.value,
"lora-list": cachedLoraList.value, "lora-list": cachedLoraList.value,
"current-index": unref(state).currentIndex.value, "current-index": unref(state).currentIndex.value,
"include-no-lora": unref(state).includeNoLora.value, "include-no-lora": unref(state).includeNoLora.value,
onClose: _cache[3] || (_cache[3] = ($event) => isModalOpen.value = false), onClose: _cache[5] || (_cache[5] = ($event) => isModalOpen.value = false),
onSelect: handleModalSelect onSelect: handleModalSelect
}, null, 8, ["visible", "lora-list", "current-index", "include-no-lora"]) }, null, 8, ["visible", "lora-list", "current-index", "include-no-lora"])
], 32); ], 32);
}; };
} }
}); });
const LoraCyclerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["__scopeId", "data-v-b97187b9"]]); const LoraCyclerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["__scopeId", "data-v-3f46897a"]]);
const _hoisted_1$1 = { class: "json-display-widget" }; const _hoisted_1$1 = { class: "json-display-widget" };
const _hoisted_2$1 = { const _hoisted_2$1 = {
class: "json-content", class: "json-content",
@@ -15121,7 +15206,7 @@ const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500;
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448; const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448;
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT; const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT;
const LORA_CYCLER_WIDGET_MIN_WIDTH = 380; const LORA_CYCLER_WIDGET_MIN_WIDTH = 380;
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 344; const LORA_CYCLER_WIDGET_MIN_HEIGHT = 408;
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT; const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT;
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300; const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300;
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200; const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200;
@@ -15262,7 +15347,8 @@ function createLoraRandomizerWidget(node) {
}; };
const vueApp = createApp(LoraRandomizerWidget, { const vueApp = createApp(LoraRandomizerWidget, {
widget, widget,
node node,
api
}); });
vueApp.use(PrimeVue, { vueApp.use(PrimeVue, {
unstyled: true, unstyled: true,
@@ -15294,7 +15380,24 @@ function createLoraCyclerWidget(node) {
container.style.flexDirection = "column"; container.style.flexDirection = "column";
container.style.overflow = "hidden"; container.style.overflow = "hidden";
forwardMiddleMouseToCanvas(container); forwardMiddleMouseToCanvas(container);
let internalValue; const defaultConfig = {
current_index: 1,
total_count: 0,
pool_config_hash: "",
model_strength: 1,
clip_strength: 1,
use_same_clip_strength: true,
use_preset_strength: false,
preset_strength_scale: 1,
sort_by: "filename",
current_lora_name: "",
current_lora_filename: "",
repeat_count: 1,
repeat_used: 0,
is_paused: false,
include_no_lora: false
};
let internalValue = defaultConfig;
const widget = node.addDOMWidget( const widget = node.addDOMWidget(
"cycler_config", "cycler_config",
"CYCLER_CONFIG", "CYCLER_CONFIG",

File diff suppressed because one or more lines are too long