Compare commits

...

6 Commits

Author SHA1 Message Date
Will Miao
3e303ab316 chore(release): bump version to v1.1.6 2026-06-28 22:17:02 +08:00
Will Miao
e9e8c31ad1 fix(registry): store nodes per-client to prevent multi-tab race condition
Move NodeRegistry from a single global _nodes dict to a per-client
(_tab_nodes) structure so that multiple ComfyUI browser tabs no
longer overwrite each other's workflow node data during a
lora_registry_refresh cycle.  The merged result is a union of all
known tabs' target nodes, eliminating the non-deterministic failure
where send-to-workflow could randomly target a tab lacking valid
targets.

- NodeRegistry.register_nodes(sid, nodes) replaces per-tab data
  without affecting other tabs.
- NodeRegistry.get_merged_registry() returns the union across all
  connected clients, together with tab_count / per-tab metadata.
- prepare_for_refresh() snapshots the current active sockets; caller
  re-reads before merging so that newly-connected tabs are not pruned.
- workflow_registry.js sends api.clientId in the POST body so the
  backend can identify which tab is registering.
2026-06-28 17:57:58 +08:00
Will Miao
703a6a4ea0 fix(import): request withMeta=true from CivitAI API, fix checkpoint type guard and CivArchive version lookup
- Add &withMeta=true to image info URL so API returns full generation
  metadata (resources with hash/type) instead of null meta
- Fix checkpoint assignment guard: check modelId instead of id so non-
  checkpoint types (upscaler) are not wrongly set as recipe checkpoint
- Skip modelVersionIds loop when resources/civitaiResources already
  provided LoRAs, preventing hash-resolved duplicates
- Fix int/str type comparison in CivArchive get_model_version so
  version ID matching works correctly
2026-06-27 22:22:48 +08:00
Will Miao
283730cf38 fix(import): discover LoRA + checkpoint from modelVersionIds when API meta is null
When CivitAI image API returns meta=null and modelVersionIds at root
level, the import flow now:

- Injects modelVersionIds + browsingLevel into a minimal metadata dict
  so the parser can discover LoRAs and checkpoints (both import-from-url
  and analyze-image paths)
- Adds checkpoint dedup + fallback in the parser's modelVersionIds
  handler to avoid duplicate API calls
- Runs EXIF extraction unconditionally in analyze-image path, then
  merges with API metadata (fixes gen params loss)
- Propagates preview_nsfw_level through all three import paths:
  import-from-url, analyze-image (UI Import), and batch-import,
  plus the frontend save flow
2026-06-27 17:05:38 +08:00
Will Miao
20417797e8 fix(download): accept UNet and Diffusion Model file types from CivitAI
- Prefer file type (UNet/Diffusion Model) over baseModel name when
  deciding whether a checkpoint routes to the unet folder
- Add UNet to backend primary file type whitelist
- Add Krea 2 to DIFFUSION_MODEL_BASE_MODELS
- Include UNet/Diffusion Model files in frontend file selection UI
- Use actual file type from CivitAI in download params instead of
  hardcoded 'Model'
2026-06-27 08:56:11 +08:00
Will Miao
004c69b9ef fix(marquee): use document coordinates, add auto-scroll, support VirtualScroller off-screen cards
- Convert marquee selection from viewport to document coordinates so
  scrolling during a drag no longer deselects off-screen cards.
- Add RAF-based auto-scroll when dragging near viewport edges.
- Compute off-screen card positions from VirtualScroller layout
  parameters instead of relying on DOM queries.
2026-06-27 08:21:21 +08:00
18 changed files with 709 additions and 255 deletions

View File

@@ -11,8 +11,8 @@
"Insomnia Art Designs", "Insomnia Art Designs",
"2018cfh", "2018cfh",
"Arlecchino Shion", "Arlecchino Shion",
"Rob Williams",
"Charles Blakemore", "Charles Blakemore",
"Rob Williams",
"W+K+White", "W+K+White",
"$MetaSamsara", "$MetaSamsara",
"wackop", "wackop",
@@ -21,6 +21,8 @@
"stone9k", "stone9k",
"Rosenthal", "Rosenthal",
"Francisco Tatis", "Francisco Tatis",
"JongWon Han",
"FreelancerZ",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Skalabananen", "Skalabananen",
"Marc Whiffen", "Marc Whiffen",
@@ -38,24 +40,32 @@
"Estragon", "Estragon",
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"ClockDaemon", "ClockDaemon",
"KD",
"Omnidex",
"Tyler Trebuchon",
"Release Cabrakan",
"Tobi_Swagg", "Tobi_Swagg",
"SG", "SG",
"James Dooley",
"zenbound", "zenbound",
"Buzzard",
"jmack", "jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Mark Corneglio",
"Ricky Carter", "Ricky Carter",
"James Todd", "James Todd",
"JongWon Han", "Steven Pfeiffer",
"VantAI", "VantAI",
"レプサイ", "レプサイ",
"Lisster",
"Michael Wong", "Michael Wong",
"runte3221", "runte3221",
"Illrigger", "Illrigger",
"Tom Corrigan", "Tom Corrigan",
"JackieWang", "JackieWang",
"FreelancerZ",
"fnkylove", "fnkylove",
"Yushio",
"Vik71it", "Vik71it",
"Echo", "Echo",
"Lilleman", "Lilleman",
@@ -65,50 +75,48 @@
"Fraser Cross", "Fraser Cross",
"Liam MacDougal", "Liam MacDougal",
"Sterilized", "Sterilized",
"BadassArabianMofo",
"JORGE+LUIZ+HUSSNI+MESSIAS", "JORGE+LUIZ+HUSSNI+MESSIAS",
"quarz", "quarz",
"Greg", "Greg",
"jean jahren",
"JSST", "JSST",
"Snaggwort", "Snaggwort",
"lmsupporter",
"Takkan", "Takkan",
"wfpearl", "wfpearl",
"Matt+J", "Matt+J",
"Baekdoosixt", "Baekdoosixt",
"Jonathan Ross", "Jonathan Ross",
"KD", "Jack B Nimble",
"Omnidex",
"Nazono_hito", "Nazono_hito",
"Melville Parrish", "Melville Parrish",
"daniel dove", "daniel dove",
"Lustre", "Lustre",
"Tyler Trebuchon",
"Release Cabrakan",
"JW Sin", "JW Sin",
"Alex", "Alex",
"bh", "bh",
"carozzz", "carozzz",
"Marlon Daniels", "Marlon Daniels",
"James Dooley", "Starkselle",
"Buzzard",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"greebles",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"Anthony Rizzo", "Anthony Rizzo",
"iamresist", "iamresist",
"M Postkasse",
"RedrockVP", "RedrockVP",
"Wolffen", "Wolffen",
"Wicked Choices by ASLPro3D", "Wicked Choices by ASLPro3D",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Weasyl", "Weasyl",
"Steven Pfeiffer",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Cory Paza", "Cory Paza",
"Tak", "Tak",
"Lisster",
"Big Red", "Big Red",
"whudunit", "whudunit",
"Luc Job", "Luc Job",
@@ -116,37 +124,36 @@
"Philip Hempel", "Philip Hempel",
"corde", "corde",
"nwalker94", "nwalker94",
"Yushio",
"Bishoujoker", "Bishoujoker",
"aai",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"Aleksander Wujczyk", "Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"Sangheili460", "Sangheili460",
"MagnaInsomnia", "MagnaInsomnia",
"Akira_HentAI", "Akira_HentAI",
"Karl P.", "Karl P.",
"lmsupporter",
"andrew.tappan", "andrew.tappan",
"N/A", "N/A",
"The Spawn",
"graysock", "graysock",
"Greenmoustache", "Greenmoustache",
"zounic", "zounic",
"fancypants", "fancypants",
"Eldithor", "Digital",
"Jack B Nimble",
"JaxMax", "JaxMax",
"contrite831", "contrite831",
"Jwk0205", "Jwk0205",
"Starkselle", "Bro Xie",
"batblue",
"carey6409",
"Olive", "Olive",
"greebles", "太郎 ゲーム",
"Some Guy Named Barry", "Some Guy Named Barry",
"M Postkasse",
"AELOX", "AELOX",
"Gooohokrbe", "Gooohokrbe",
"Nicfit23", "Nicfit23",
@@ -162,12 +169,12 @@
"Serge Bekenkamp", "Serge Bekenkamp",
"Jimmy Ledbetter", "Jimmy Ledbetter",
"LeoZero", "LeoZero",
"Antonio Pontes",
"nahinahi9",
"Dustin Chen", "Dustin Chen",
"dan", "dan",
"aai",
"Mouthlessman", "Mouthlessman",
"otaku fra", "otaku fra",
"jean jahren",
"MiraiKuriyamaSy", "MiraiKuriyamaSy",
"Ran C", "Ran C",
"ViperC", "ViperC",
@@ -176,7 +183,6 @@
"Adam Taylor", "Adam Taylor",
"AbstractAss", "AbstractAss",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"The Spawn",
"Pozadine1", "Pozadine1",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
@@ -187,18 +193,16 @@
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
"Ubivis", "Ubivis",
"CloudValley",
"linnfrey",
"Jackthemind", "Jackthemind",
"griffin+dahlberg",
"Joboshy", "Joboshy",
"Digital",
"takyamtom", "takyamtom",
"Bohemian Corporal", "Bohemian Corporal",
"Dan", "Dan",
"Bro Xie",
"yer fey", "yer fey",
"batblue",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"carey6409",
"太郎 ゲーム",
"Roslynd", "Roslynd",
"jinxedx", "jinxedx",
"Neco28", "Neco28",
@@ -210,22 +214,21 @@
"Frank Nitty", "Frank Nitty",
"Magic Noob", "Magic Noob",
"DougPeterson", "DougPeterson",
"Antonio Pontes",
"ApathyJones", "ApathyJones",
"Jeff",
"Bruce", "Bruce",
"Julian V",
"Steven Owens", "Steven Owens",
"nahinahi9",
"Kevin John Duck", "Kevin John Duck",
"Kevin Christopher", "Kevin Christopher",
"Blackfish95", "Blackfish95",
"dd",
"Paul Kroll", "Paul Kroll",
"Bas Imagineer", "Bas Imagineer",
"John Statham", "John Statham",
"yuxz69", "yuxz69",
"esthe", "esthe",
"AlexDuKaNa",
"decoy", "decoy",
"CloudValley",
"thesoftwaredruid", "thesoftwaredruid",
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
@@ -233,15 +236,20 @@
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
"地獄の禄",
"MJG", "MJG",
"David LaVallee", "David LaVallee",
"linnfrey",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"capn", "capn",
"Joseph", "Joseph",
"Mirko Katzula",
"dan",
"Piccio08",
"kumakichi",
"cppbel",
"IamAyam", "IamAyam",
"skaterb949", "jeaness",
"Brian M", "Brian M",
"Josef Lanzl", "Josef Lanzl",
"Nerezza", "Nerezza",
@@ -261,37 +269,32 @@
"Eris3D", "Eris3D",
"Max Marklund", "Max Marklund",
"m", "m",
"Pierce McBride",
"Pronredn", "Pronredn",
"Mikko Hemilä",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "a _",
"Jeff",
"lh qwe", "lh qwe",
"James Coleman", "James Coleman",
"Martial",
"conner", "conner",
"Ouro Boros", "Ouro Boros",
"Chad Idk", "Chad Idk",
"dd",
"Princess Bright Eyes", "Princess Bright Eyes",
"Yuji Kaneko", "Yuji Kaneko",
"Dušan Ryban", "Dušan Ryban",
"Felipe dos Santos", "Felipe dos Santos",
"Rops Alot",
"Sam", "Sam",
"sjon kreutz", "sjon kreutz",
"Ace Ventura", "Ace Ventura",
"Douglas Gaspar", "Douglas Gaspar",
"Metryman55", "Metryman55",
"AlexDuKaNa",
"George", "George",
"dw", "dw",
"地獄の禄",
"Gamalonia", "Gamalonia",
"WRL_SPR", "WRL_SPR",
"momokai", "momokai",
"Mirko Katzula",
"dan",
"Piccio08",
"kumakichi",
"cppbel",
"Moon Knight", "Moon Knight",
"몽타주", "몽타주",
"Kland", "Kland",
@@ -313,12 +316,15 @@
"kyoumei", "kyoumei",
"RadStorm04", "RadStorm04",
"JohnDoe42054", "JohnDoe42054",
"BillyHill",
"emyth",
"gzmzmvp", "gzmzmvp",
"Richard", "Richard",
"奚明 刘", "奚明 刘",
"Andrew", "Andrew",
"Robert Wegemund", "Robert Wegemund",
"Littlehuggy", "Littlehuggy",
"Gregory Kozhemiak",
"준희 김", "준희 김",
"Brian Buie", "Brian Buie",
"Sadlip", "Sadlip",
@@ -328,16 +334,16 @@
"Tomohiro Baba", "Tomohiro Baba",
"Mike Simone", "Mike Simone",
"Noora", "Noora",
"Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Mattssn", "Mattssn",
"Mikko Hemilä", "Morgandel",
"Jacob McDaniel", "Jacob McDaniel",
"X",
"Sloan Steddy",
"Temikus", "Temikus",
"Artokun", "Artokun",
"Michael Taylor", "Michael Taylor",
"Derek Baker", "Derek Baker",
"Martial",
"Michael Anthony Scott", "Michael Anthony Scott",
"Emil Andersson", "Emil Andersson",
"Atilla Berke Pekduyar", "Atilla Berke Pekduyar",
@@ -345,7 +351,6 @@
"CryptoTraderJK", "CryptoTraderJK",
"Decx _", "Decx _",
"Davaitamin", "Davaitamin",
"Rops Alot",
"tedcor", "tedcor",
"Fotek Design", "Fotek Design",
"四糸凜音", "四糸凜音",
@@ -358,8 +363,6 @@
"starbugx", "starbugx",
"dc7431", "dc7431",
"Crocket", "Crocket",
"BillyHill",
"emyth",
"chriphost", "chriphost",
"KitKatM", "KitKatM",
"socrasteeze", "socrasteeze",
@@ -383,35 +386,40 @@
"g9p0o", "g9p0o",
"TheHolySheep", "TheHolySheep",
"Monte Won", "Monte Won",
"Gregory Kozhemiak", "SpringBootisTrash",
"carsten",
"ikok",
"Wolfe7D1",
"Draven T", "Draven T",
"mrjuan",
"elleshar666", "elleshar666",
"ACTUALLY_the_Real_Willem_Dafoe",
"Aquatic Coffee", "Aquatic Coffee",
"Kauffy",
"John J Linehan", "John J Linehan",
"ethanfel", "ethanfel",
"Elliot E", "Elliot E",
"Morgandel",
"Theerat Jiramate", "Theerat Jiramate",
"Focuschannel", "Focuschannel",
"Edward Kennedy", "Edward Kennedy",
"Noah", "Noah",
"X",
"Sloan Steddy",
"Vane Holzer", "Vane Holzer",
"psytrax",
"hexxish", "hexxish",
"Anthony Faxlandez", "Anthony Faxlandez",
"battu", "battu",
"notedfakes", "notedfakes",
"Nathan", "Nathan",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"Michael Scott",
"Pat Hen", "Pat Hen",
"Xeeosat", "Xeeosat",
"Saya", "Saya",
"Ed Wang", "Ed Wang",
"Jordan Shaw", "Jordan Shaw",
"Wes Sims",
"g unit", "g unit",
"Srdb", "Srdb",
"Filippo Ferrari",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
"uwutismxd", "uwutismxd",
@@ -427,9 +435,8 @@
"Pkrsky", "Pkrsky",
"nanana", "nanana",
"raf8osz", "raf8osz",
"SpringBootisTrash", "FeralOpticsAI",
"carsten", "Pavlaki",
"ikok",
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
@@ -446,36 +453,34 @@
"cocona", "cocona",
"ElitaSSJ4", "ElitaSSJ4",
"David Schenck", "David Schenck",
"Wolfe7D1",
"blikkies", "blikkies",
"Chris", "Chris",
"Time Valentine", "Time Valentine",
"Shock Shockor", "Shock Shockor",
"ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч", "Михал Михалыч",
"Matt", "Matt",
"Goldwaters", "Goldwaters",
"Kauffy",
"Zude", "Zude",
"Frogmilk",
"SPJ", "SPJ",
"Kyler", "Kyler",
"Bryan Rutkowski",
"Justin Blaylock", "Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Nick Kage", "Nick Kage",
"psytrax",
"Cyrus Fett", "Cyrus Fett",
"Xenon Xue", "Xenon Xue",
"Edward Ten Eyck", "Edward Ten Eyck",
"Billy Gladky", "Billy Gladky",
"Michael Scott",
"Probis", "Probis",
"Solixer", "Solixer",
"Wes Sims",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"Donor4115", "Donor4115",
"jinksta187",
"Distortik", "Distortik",
"Manu Thetug", "Manu Thetug",
"Filippo Ferrari", "Karlanx",
"operationancut",
"Youguang", "Youguang",
"andrewzpong", "andrewzpong",
"BossGame", "BossGame",
@@ -486,11 +491,18 @@
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"POPPIN", "POPPIN",
"YassineKhaled",
"Y",
"MatteKey",
"Flob",
"ShiroSenpai",
"Inkognito",
"G", "G",
"Tan+Huynh", "Tan+Huynh",
"Bob+Barker", "Bob+Barker",
"D", "D",
"Dark_Pest", "Dark_Pest",
"Eldithor",
"Alex", "Alex",
"Karru", "Karru",
"ChaChanoKo", "ChaChanoKo",
@@ -503,13 +515,10 @@
"g", "g",
"J", "J",
"Alan+Cano", "Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"BillyBoy84", "BillyBoy84",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
"John Martin", "John Martin",
"Ink Temptation",
"JBsuede", "JBsuede",
"moranqianlong", "moranqianlong",
"Kalli Core", "Kalli Core",
@@ -519,13 +528,12 @@
"Dave Abraham", "Dave Abraham",
"Joaquin Hierrezuelo", "Joaquin Hierrezuelo",
"Locrospiel", "Locrospiel",
"Frogmilk",
"Sean voets", "Sean voets",
"Jarrid Lee",
"Kor", "Kor",
"Joseph Hanson", "Joseph Hanson",
"John Rednoulf", "John Rednoulf",
"Kyron Mahan", "Kyron Mahan",
"Bryan Rutkowski",
"Boba Smith", "Boba Smith",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
@@ -544,15 +552,12 @@
"Pete Pain", "Pete Pain",
"Jacob Winter", "Jacob Winter",
"Ryan Presley Ng", "Ryan Presley Ng",
"jinksta187",
"RHopkirk", "RHopkirk",
"Andrew Wilkinson", "Andrew Wilkinson",
"Karlanx",
"Lyavph", "Lyavph",
"Maxim", "Maxim",
"David", "David",
"Meilo", "Meilo",
"operationancut",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
"MaartenAlbers", "MaartenAlbers",
@@ -571,6 +576,8 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"Akkas+Haque",
"Kachac",
"SAVEagleBasement", "SAVEagleBasement",
"Kevin+Isom", "Kevin+Isom",
"Rune+Osnes", "Rune+Osnes",
@@ -600,12 +607,6 @@
"Lev+Lanevskiy", "Lev+Lanevskiy",
"low9", "low9",
"Winged", "Winged",
"YassineKhaled",
"Y",
"MatteKey",
"Flob",
"ShiroSenpai",
"Inkognito",
"Jacky+Ho", "Jacky+Ho",
"generic404", "generic404",
"abattoirblues", "abattoirblues",
@@ -614,29 +615,34 @@
"hayden", "hayden",
"Obsidian.Studios", "Obsidian.Studios",
"ahoystan", "ahoystan",
"Zomba Mann",
"edk", "edk",
"Tú Nguyễn Lý Hoàng", "Tú Nguyễn Lý Hoàng",
"shira1011", "shira1011",
"Neko Desco",
"Ben D", "Ben D",
"G", "G",
"Vinarus",
"ja s", "ja s",
"Leslie Andrew Ridings", "Leslie Andrew Ridings",
"Doug Mason", "Doug Mason",
"scoreswazey", "scoreswazey",
"Owen Gwosdz", "Owen Gwosdz",
"Jarrid Lee",
"Poophead27 Blyat", "Poophead27 Blyat",
"Mythspire", "Mythspire",
"AZ Party Oasis", "AZ Party Oasis",
"Devil Lude", "Devil Lude",
"David Murcko", "David Murcko",
"TheFusion",
"MR.Bear", "MR.Bear",
"Jack Dole", "Jack Dole",
"matt", "matt",
"somethingtosay8", "somethingtosay8",
"3zS4QNQ4",
"Terminuz", "Terminuz",
"ivistorm", "ivistorm",
"max blo", "max blo",
"Ivan Imes",
"CptNeo", "CptNeo",
"Jack Lawfield", "Jack Lawfield",
"Borte", "Borte",
@@ -645,7 +651,7 @@
"Sage Himeros", "Sage Himeros",
"Eric Ketchum", "Eric Ketchum",
"Kevin Wallace", "Kevin Wallace",
"David Spearing", "Zeeble",
"ChicRic", "ChicRic",
"Tigon", "Tigon",
"BastardSama", "BastardSama",
@@ -706,6 +712,9 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Somebody",
"Jasper",
"megameganck",
"thomasand01", "thomasand01",
"Shiba+Sama", "Shiba+Sama",
"miduzza", "miduzza",
@@ -730,8 +739,6 @@
"matter", "matter",
"SRCRCOSS", "SRCRCOSS",
"imer", "imer",
"Akkas+Haque",
"Kachac",
"jcx29", "jcx29",
"Drizzly", "Drizzly",
"Nebuleux", "Nebuleux",
@@ -751,24 +758,23 @@
"KUJYAKU", "KUJYAKU",
"Coeur+de+cochon", "Coeur+de+cochon",
"han b", "han b",
"Zomba Mann",
"Aquaneo", "Aquaneo",
"Nico", "Nico",
"Maximilian Krischan", "Maximilian Krischan",
"Banana Joe", "Banana Joe",
"proto merp", "proto merp",
"_ G3n", "_ G3n",
"Brandon Thomas",
"Donovan Jenkins", "Donovan Jenkins",
"Hans Meier", "Hans Meier",
"sicarius", "sicarius",
"Michael Eid", "Michael Eid",
"Wolf and Fox Legends", "Wolf and Fox Legends",
"beersandbacon", "beersandbacon",
"Neko Desco",
"Bob barker", "Bob barker",
"Ninja Tom", "Ninja Tom",
"karim ben brik", "karim ben brik",
"Vinarus", "Elemnt",
"Josh Snyder", "Josh Snyder",
"Michael Zhu", "Michael Zhu",
"Nemisu", "Nemisu",
@@ -792,16 +798,15 @@
"Forbidden Atelier", "Forbidden Atelier",
"Thomas Sankowski", "Thomas Sankowski",
"DrB", "DrB",
"Nimhloth",
"Adictedtohumping", "Adictedtohumping",
"Snorklebort", "Snorklebort",
"vinter", "vinter",
"Towelie", "Towelie",
"TheFusion",
"Jean-françois SEMA", "Jean-françois SEMA",
"3zS4QNQ4",
"Kurt", "Kurt",
"Andrew Ly",
"Matt M.", "Matt M.",
"Ivan Imes",
"J M", "J M",
"Slacks", "Slacks",
"Bouya shaka", "Bouya shaka",
@@ -837,5 +842,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 834 "totalCount": 839
} }

View File

@@ -514,11 +514,21 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Process modelVersionIds from Civitai image API # Process modelVersionIds from Civitai image API.
# These are model version IDs returned at root level when meta doesn't contain resources # These are version IDs returned at root level of the API response.
if "modelVersionIds" in metadata and isinstance( # When resources or civitaiResources are already present in metadata
metadata["modelVersionIds"], list # (which they are when ?withMeta=true is passed), those sections have
# complete hash/type information — modelVersionIds is a fallback for
# when meta is null and only the flat ID list is available. Skipping
# it here avoids duplicates: the same file hash often resolves to
# different version IDs via hash lookup (resources) vs the original
# version ID in modelVersionIds, and both paths would create entries.
if (
"modelVersionIds" in metadata
and isinstance(metadata["modelVersionIds"], list)
and not result.get("loras")
): ):
for version_id in metadata["modelVersionIds"]: for version_id in metadata["modelVersionIds"]:
version_id_str = str(version_id) version_id_str = str(version_id)
@@ -526,6 +536,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if version_id_str in added_loras: if version_id_str in added_loras:
continue continue
# Skip if this version ID is already the recipe's checkpoint
# (resolved earlier from embedded resources/Model hash,
# avoiding a duplicate CivitAI API call).
existing_model = result.get("model")
if existing_model and str(existing_model.get("id")) == version_id_str:
continue
# Initialize lora entry with version ID # Initialize lora entry with version ID
lora_entry = { lora_entry = {
"id": version_id, "id": version_id,
@@ -559,9 +576,40 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
) )
if populated_entry is None: if populated_entry is None:
continue # Skip invalid LoRA types # Not a LoRA — try as checkpoint (only if we
# don't already have one). Reuses the same
# civitai_info from the API call above so no
# extra query is made.
if result["model"] is None:
checkpoint_entry = {
"id": version_id,
"modelId": 0,
"name": "Unknown Model",
"version": "",
"type": "checkpoint",
"existsLocally": False,
"localPath": None,
"file_name": "",
"hash": "",
"thumbnailUrl": (
"/loras_static/images/no-preview.png"
),
"baseModel": "",
"size": 0,
"downloadUrl": "",
"isDeleted": False,
}
cp_populated = await (
self.populate_checkpoint_from_civitai(
checkpoint_entry, civitai_info
)
)
if cp_populated.get("modelId"):
result["model"] = cp_populated
continue # Not a LoRA, don't add to loras
lora_entry = populated_entry lora_entry = populated_entry
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error fetching Civitai info for model version {version_id}: {e}" f"Error fetching Civitai info for model version {version_id}: {e}"

View File

@@ -414,9 +414,10 @@ class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers.""" """Subset of PromptServer used by the handlers."""
instance: "PromptServerProtocol" instance: "PromptServerProtocol"
sockets: dict # maps clientId (sid) → WebSocketResponse
def send_sync( def send_sync(
self, event: str, payload: dict self, event: str, payload: dict | None = None, sid: str | None = None
) -> None: # pragma: no cover - protocol ) -> None: # pragma: no cover - protocol
... ...
@@ -471,90 +472,154 @@ class BackupServiceProtocol(Protocol):
class NodeRegistry: class NodeRegistry:
"""Thread-safe registry for tracking LoRA nodes in active workflows.""" """Thread-safe registry for tracking LoRA nodes across ComfyUI tabs.
Each connected ComfyUI browser tab (identified by its ``sid`` / ``clientId``)
registers its own set of workflow nodes. Queries merge all known tabs into
a single result so that the calling LM panel always sees *every* available
target node, regardless of which tab responded fastest.
"""
def __init__(self) -> None: def __init__(self) -> None:
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._nodes: Dict[str, dict] = {} # sid → {unique_id → node_info}
self._registry_updated = asyncio.Event() self._tab_nodes: Dict[str, Dict[str, dict]] = {}
self._ready = asyncio.Event()
self._waiting_clients: set[str] = set()
@property
def pending_client_count(self) -> int:
"""Number of clients that have not yet responded in the current refresh cycle."""
return len(self._waiting_clients)
# ------------------------------------------------------------------
# Helpers to build one node dict (extracted so it's reused for each tab)
# ------------------------------------------------------------------
@staticmethod
def _build_node_dict(node: dict) -> dict:
node_id = node["node_id"]
graph_id = str(node["graph_id"])
unique_id = f"{graph_id}:{node_id}"
node_type = node.get("type", "")
type_id = NODE_TYPES.get(node_type, 0)
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
raw_capabilities = node.get("capabilities")
capabilities: dict = {}
if isinstance(raw_capabilities, dict):
capabilities = dict(raw_capabilities)
raw_widget_names: list | None = node.get("widget_names")
if not isinstance(raw_widget_names, list):
capability_widget_names = capabilities.get("widget_names")
raw_widget_names = (
capability_widget_names
if isinstance(capability_widget_names, list)
else None
)
widget_names: list[str] = []
if isinstance(raw_widget_names, list):
widget_names = [
str(widget_name)
for widget_name in raw_widget_names
if isinstance(widget_name, str) and widget_name
]
if widget_names:
capabilities["widget_names"] = widget_names
else:
capabilities.pop("widget_names", None)
if "supports_lora" in capabilities:
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
comfy_class = node.get("comfy_class")
if not isinstance(comfy_class, str) or not comfy_class:
comfy_class = node_type if isinstance(node_type, str) else None
return {
"id": node_id,
"graph_id": graph_id,
"graph_name": node.get("graph_name"),
"unique_id": unique_id,
"bgcolor": bgcolor,
"title": node.get("title"),
"type": type_id,
"type_name": node_type,
"comfy_class": comfy_class,
"capabilities": capabilities,
"widget_names": widget_names,
"mode": node.get("mode"),
"marker_role": node.get("marker_role"),
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def register_nodes(self, sid: str, nodes: list[dict]) -> None:
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
tab_nodes: dict[str, dict] = {}
for node in nodes:
nd = self._build_node_dict(node)
tab_nodes[nd["unique_id"]] = nd
async def register_nodes(self, nodes: list[dict]) -> None:
async with self._lock: async with self._lock:
self._nodes.clear() self._tab_nodes[sid] = tab_nodes
for node in nodes: self._waiting_clients.discard(sid)
node_id = node["node_id"] if not self._waiting_clients:
graph_id = str(node["graph_id"]) self._ready.set()
unique_id = f"{graph_id}:{node_id}"
node_type = node.get("type", "")
type_id = NODE_TYPES.get(node_type, 0)
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
raw_capabilities = node.get("capabilities")
capabilities: dict = {}
if isinstance(raw_capabilities, dict):
capabilities = dict(raw_capabilities)
raw_widget_names: list | None = node.get("widget_names") logger.debug("Registered %s nodes from client %s", len(nodes), sid)
if not isinstance(raw_widget_names, list):
capability_widget_names = capabilities.get("widget_names")
raw_widget_names = (
capability_widget_names
if isinstance(capability_widget_names, list)
else None
)
widget_names: list[str] = [] def prepare_for_refresh(self, active_sids: list[str]) -> None:
if isinstance(raw_widget_names, list): """Set the list of client IDs we expect to hear from during the next refresh cycle."""
widget_names = [ self._ready.clear()
str(widget_name) self._waiting_clients = set(active_sids)
for widget_name in raw_widget_names
if isinstance(widget_name, str) and widget_name
]
if widget_names: async def wait_for_all(self, timeout: float = 2.0) -> bool:
capabilities["widget_names"] = widget_names """Block until every client in the current waiting set has responded
else: (or *timeout* seconds elapse). Returns ``True`` if all responded."""
capabilities.pop("widget_names", None) if not self._waiting_clients:
return True
if "supports_lora" in capabilities:
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
comfy_class = node.get("comfy_class")
if not isinstance(comfy_class, str) or not comfy_class:
comfy_class = node_type if isinstance(node_type, str) else None
self._nodes[unique_id] = {
"id": node_id,
"graph_id": graph_id,
"graph_name": node.get("graph_name"),
"unique_id": unique_id,
"bgcolor": bgcolor,
"title": node.get("title"),
"type": type_id,
"type_name": node_type,
"comfy_class": comfy_class,
"capabilities": capabilities,
"widget_names": widget_names,
"mode": node.get("mode"),
"marker_role": node.get("marker_role"),
}
logger.debug("Registered %s nodes in registry", len(nodes))
self._registry_updated.set()
async def get_registry(self) -> dict:
async with self._lock:
return {
"nodes": dict(self._nodes),
"node_count": len(self._nodes),
}
async def wait_for_update(self, timeout: float = 1.0) -> bool:
self._registry_updated.clear()
try: try:
await asyncio.wait_for(self._registry_updated.wait(), timeout=timeout) await asyncio.wait_for(self._ready.wait(), timeout=timeout)
return True return True
except asyncio.TimeoutError: except asyncio.TimeoutError:
return False return False
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict:
"""Return the union of all known tab nodes, pruning any tab that is no
longer connected."""
async with self._lock:
# Garbage-collect stale entries (disconnected tabs)
if active_sids is not None:
for sid in list(self._tab_nodes):
if sid not in active_sids:
del self._tab_nodes[sid]
merged: dict[str, dict] = {}
tab_info: dict[str, dict] = {}
for sid, nodes in self._tab_nodes.items():
tab_info[sid] = {
"node_count": len(nodes),
"graph_names": list(
{
n.get("graph_name")
for n in nodes.values()
if n.get("graph_name")
}
),
}
merged.update(nodes)
return {
"nodes": merged,
"node_count": len(merged),
"tab_count": len(self._tab_nodes),
"tabs": tab_info,
}
class HealthCheckHandler: class HealthCheckHandler:
async def health_check(self, request: web.Request) -> web.Response: async def health_check(self, request: web.Request) -> web.Response:
@@ -2995,10 +3060,21 @@ class NodeRegistryHandler:
try: try:
data = await request.json() data = await request.json()
nodes = data.get("nodes", []) nodes = data.get("nodes", [])
client_id = data.get("client_id")
if not isinstance(nodes, list): if not isinstance(nodes, list):
return web.json_response( return web.json_response(
{"success": False, "error": "nodes must be a list"}, status=400 {"success": False, "error": "nodes must be a list"}, status=400
) )
if not isinstance(client_id, str) or not client_id:
return web.json_response(
{
"success": False,
"error": "Missing client_id parameter",
},
status=400,
)
for index, node in enumerate(nodes): for index, node in enumerate(nodes):
if not isinstance(node, dict): if not isinstance(node, dict):
return web.json_response( return web.json_response(
@@ -3042,7 +3118,7 @@ class NodeRegistryHandler:
else: else:
node["graph_name"] = str(graph_name) node["graph_name"] = str(graph_name)
await self._node_registry.register_nodes(nodes) await self._node_registry.register_nodes(client_id, nodes)
return web.json_response( return web.json_response(
{ {
"success": True, "success": True,
@@ -3066,9 +3142,15 @@ class NodeRegistryHandler:
status=503, status=503,
) )
# Snapshot of currently-connected ComfyUI tabs
active_sids = list(self._prompt_server.instance.sockets.keys())
self._node_registry.prepare_for_refresh(active_sids)
try: try:
self._prompt_server.instance.send_sync("lora_registry_refresh", {}) self._prompt_server.instance.send_sync("lora_registry_refresh", {})
logger.debug("Sent registry refresh request to frontend") logger.debug(
"Sent registry refresh request (expecting %s clients)", len(active_sids)
)
except Exception as exc: except Exception as exc:
logger.error("Failed to send registry refresh message: %s", exc) logger.error("Failed to send registry refresh message: %s", exc)
return web.json_response( return web.json_response(
@@ -3080,19 +3162,31 @@ class NodeRegistryHandler:
status=500, status=500,
) )
registry_updated = await self._node_registry.wait_for_update(timeout=1.0) if not await self._node_registry.wait_for_all(timeout=2.0):
if not registry_updated: logger.warning(
logger.warning("Registry refresh timeout after 1 second") "Registry refresh timeout after 2s (%s/%s clients responded)",
len(active_sids) - self._node_registry.pending_client_count,
len(active_sids),
)
# Re-read current sockets after the wait: a tab may have connected
# while we were waiting, and we don't want to garbage-collect it.
current_sids = set(self._prompt_server.instance.sockets.keys())
registry_info = await self._node_registry.get_merged_registry(
active_sids=current_sids
)
if registry_info["node_count"] == 0:
logger.warning("No nodes registered after refresh")
return web.json_response( return web.json_response(
{ {
"success": False, "success": False,
"error": "Timeout Error", "error": "Empty Registry",
"message": "Registry refresh timeout - ComfyUI frontend may not be responsive", "message": "No workflow nodes found — ensure ComfyUI is open and the extension is loaded.",
}, },
status=408, status=408,
) )
registry_info = await self._node_registry.get_registry()
return web.json_response({"success": True, "data": registry_info}) return web.json_response({"success": True, "data": registry_info})
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get registry: %s", exc, exc_info=True) logger.error("Failed to get registry: %s", exc, exc_info=True)

View File

@@ -32,6 +32,7 @@ from ...utils.civitai_utils import (
extract_civitai_image_id_from_cdn_url, extract_civitai_image_id_from_cdn_url,
rewrite_preview_url, rewrite_preview_url,
) )
from ...utils.constants import NSFW_LEVELS
from ...utils.exif_utils import ExifUtils from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
@@ -1120,6 +1121,13 @@ class RecipeManagementHandler:
if parsed_embedded.get("base_model") and not metadata.get("base_model"): if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"] metadata["base_model"] = parsed_embedded["base_model"]
# Extract preview_nsfw_level from the CivitAI API response
# (injected into civitai_meta_raw by _download_remote_media).
if isinstance(civitai_meta_raw, dict):
bl = civitai_meta_raw.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
metadata["preview_nsfw_level"] = bl
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(
recipe=metadata, recipe=metadata,
@@ -1515,8 +1523,31 @@ class RecipeManagementHandler:
# CivitAI API returns modelVersionIds at the root level of # CivitAI API returns modelVersionIds at the root level of
# the image response, NOT inside the meta object. # the image response, NOT inside the meta object.
mvids = image_info.get("modelVersionIds") mvids = image_info.get("modelVersionIds")
if mvids and isinstance(civitai_meta_raw, dict): if mvids:
civitai_meta_raw["modelVersionIds"] = mvids if isinstance(civitai_meta_raw, dict):
civitai_meta_raw["modelVersionIds"] = mvids
else:
# meta is null but modelVersionIds exists — create a
# minimal dict so downstream parsers can discover
# LoRAs and checkpoints from the API response.
civitai_meta_raw = {"modelVersionIds": mvids}
# Inject browsingLevel (canonical integer) so the recipe's
# preview_nsfw_level can be set, enabling proper NSFW blur
# of the preview image. Fall back to nsfwLevel (string)
# when browsingLevel is absent.
if isinstance(civitai_meta_raw, dict):
browsing_level = image_info.get("browsingLevel")
nsfw_level_str = image_info.get("nsfwLevel")
if isinstance(browsing_level, int) and browsing_level > 0:
civitai_meta_raw["browsingLevel"] = browsing_level
elif (
isinstance(nsfw_level_str, str)
and nsfw_level_str in NSFW_LEVELS
):
civitai_meta_raw["browsingLevel"] = NSFW_LEVELS[
nsfw_level_str
]
original_url = ( original_url = (
image_info.get("url") if civitai_image_id and image_info else None image_info.get("url") if civitai_image_id and image_info else None
@@ -1796,6 +1827,13 @@ class RecipeManagementHandler:
"source_path": image_url, "source_path": image_url,
} }
# Extract preview_nsfw_level from the CivitAI API response
# (injected into civitai_meta_raw by _download_remote_media).
if isinstance(civitai_meta_raw, dict):
bl = civitai_meta_raw.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
metadata["preview_nsfw_level"] = bl
if civitai_parsed: if civitai_parsed:
civitai_loras = civitai_parsed.get("loras", []) civitai_loras = civitai_parsed.get("loras", [])
if civitai_loras and not metadata.get("loras"): if civitai_loras and not metadata.get("loras"):

View File

@@ -523,6 +523,10 @@ class BatchImportService:
if payload.get("checkpoint"): if payload.get("checkpoint"):
metadata["checkpoint"] = payload["checkpoint"] metadata["checkpoint"] = payload["checkpoint"]
nsfw = payload.get("preview_nsfw_level")
if isinstance(nsfw, int) and nsfw > 0:
metadata["preview_nsfw_level"] = nsfw
image_bytes = None image_bytes = None
image_base64 = payload.get("image_base64") image_base64 = payload.get("image_base64")

View File

@@ -417,7 +417,7 @@ class CivArchiveClient:
if version_id is not None: if version_id is not None:
raw_id = version_data.get("id") raw_id = version_data.get("id")
if raw_id != version_id: if raw_id is not None and str(raw_id) != str(version_id):
logger.warning( logger.warning(
"Requested version %s doesn't match default version %s for model %s", "Requested version %s doesn't match default version %s for model %s",
version_id, version_id,

View File

@@ -56,7 +56,7 @@ class CivitaiClient:
self._MAX_CACHE_ENTRIES = 500 self._MAX_CACHE_ENTRIES = 500
def _build_image_info_url(self, image_id: str) -> str: def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X" return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
async def _make_request( async def _make_request(
self, self,

View File

@@ -1288,10 +1288,24 @@ class DownloadManager:
"download_id": download_id, "download_id": download_id,
} }
# Check if this checkpoint should be treated as a diffusion model based on baseModel # Check if this checkpoint should be treated as a diffusion model
# Priority: (1) any file has type "UNet" or "Diffusion Model",
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
is_diffusion_model = False is_diffusion_model = False
if model_type == "checkpoint": if model_type == "checkpoint":
if base_model_value in DIFFUSION_MODEL_BASE_MODELS: # Check file types first (more direct signal from CivitAI)
version_files = version_info.get("files", [])
for f in version_files:
f_type = f.get("type", "")
if f_type in ("UNet", "Diffusion Model"):
is_diffusion_model = True
logger.info(
f"File type '{f_type}' detected, routing checkpoint to unet folder"
)
break
# Fallback to baseModel name check
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True is_diffusion_model = True
logger.info( logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder" f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
@@ -1420,7 +1434,7 @@ class DownloadManager:
f f
for f in files for f in files
if f.get("primary") if f.get("primary")
and f.get("type") in ("Model", "Negative", "Diffusion Model") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
), ),
None, None,
) )
@@ -1451,7 +1465,7 @@ class DownloadManager:
( (
f f
for f in files for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model") if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
), ),
None, None,
) )

View File

@@ -146,11 +146,38 @@ class RecipeAnalysisService:
): ):
metadata = metadata["meta"] metadata = metadata["meta"]
# Include modelVersionIds from root level if available # Include modelVersionIds from root level if available.
# Civitai API returns modelVersionIds at root level, not in meta # CivitAI API returns modelVersionIds at root level, not in meta.
# When meta is null (None), create a minimal dict so downstream
# parsers can still discover LoRAs and checkpoints.
model_version_ids = image_info.get("modelVersionIds") model_version_ids = image_info.get("modelVersionIds")
if model_version_ids and isinstance(metadata, dict): if model_version_ids:
metadata["modelVersionIds"] = model_version_ids if isinstance(metadata, dict):
metadata["modelVersionIds"] = model_version_ids
else:
metadata = {"modelVersionIds": model_version_ids}
# Inject browsingLevel (canonical integer) so the recipe's
# preview_nsfw_level can be set, enabling proper NSFW blur
# of the preview image. Fall back to nsfwLevel (string)
# when browsingLevel is absent.
if isinstance(metadata, dict):
browsing_level = image_info.get("browsingLevel")
nsfw_level_str = image_info.get("nsfwLevel")
if isinstance(browsing_level, int) and browsing_level > 0:
metadata["browsingLevel"] = browsing_level
elif (
isinstance(nsfw_level_str, str)
and nsfw_level_str
in (
"PG", "PG13", "R", "X", "XXX", "Blocked",
)
):
from ...utils.constants import NSFW_LEVELS
metadata["browsingLevel"] = NSFW_LEVELS.get(
nsfw_level_str, 0
)
# 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
@@ -171,12 +198,19 @@ class RecipeAnalysisService:
temp_path = self._create_temp_path(suffix=extension) temp_path = self._create_temp_path(suffix=extension)
await self._download_image(url, temp_path) await self._download_image(url, temp_path)
if metadata is None and not is_video: # Always extract EXIF from the downloaded image for generation
metadata = await asyncio.to_thread( # params (prompt, negative prompt, sampler, steps, etc.).
# Previously this was gated on ``metadata is None``, but that
# skipped EXIF entirely when API metadata (modelVersionIds,
# browsingLevel) is present, losing all generation parameters.
exif_metadata = None
if not is_video:
exif_metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, temp_path self._exif_utils.extract_image_metadata, temp_path
) )
if not metadata and civitai_image_id and image_info: # Fallback: try the original (non-optimized) image for EXIF data
if not exif_metadata and civitai_image_id and image_info:
original_url = image_info.get("url") original_url = image_info.get("url")
if original_url: if original_url:
self._logger.debug( self._logger.debug(
@@ -187,15 +221,38 @@ class RecipeAnalysisService:
orig_temp_path = self._create_temp_path(suffix=".png") orig_temp_path = self._create_temp_path(suffix=".png")
try: try:
await self._download_image(original_url, orig_temp_path) await self._download_image(original_url, orig_temp_path)
metadata = await asyncio.to_thread( exif_metadata = await asyncio.to_thread(
self._exif_utils.extract_image_metadata, self._exif_utils.extract_image_metadata,
orig_temp_path, orig_temp_path,
) )
finally: finally:
self._safe_cleanup(orig_temp_path) self._safe_cleanup(orig_temp_path)
# Parse EXIF data (typically a string like parameters/prompt/workflow)
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
# This mirrors the two-pass approach in _do_import_from_url.
exif_parsed_result = None
if isinstance(exif_metadata, str):
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
if exif_parser:
exif_data = await exif_parser.parse_metadata(
exif_metadata, recipe_scanner=recipe_scanner,
)
if exif_data and not exif_data.get("error"):
exif_parsed_result = exif_data
# Merge API metadata (dict) with EXIF data (if dict) for the
# CivitaiApiMetadataParser. If EXIF data is a string it was
# parsed above — don't try to merge a string into a dict.
merged = {}
if isinstance(exif_metadata, dict):
merged.update(exif_metadata)
if isinstance(metadata, dict):
merged.update(metadata)
result = await self._parse_metadata( result = await self._parse_metadata(
metadata or {}, merged,
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
image_path=temp_path, image_path=temp_path,
include_image_base64=True, include_image_base64=True,
@@ -203,13 +260,23 @@ class RecipeAnalysisService:
extension=extension, extension=extension,
) )
if civitai_image_id and image_info and not result.payload.get("error"): # Merge EXIF string-parsed gen_params into the API result.
mvid = image_info.get("modelVersionId") # API gen_params take priority (they come later via update).
if not mvid: if exif_parsed_result and not result.payload.get("error"):
mvids = image_info.get("modelVersionIds") exif_gp = exif_parsed_result.get("gen_params") or {}
if isinstance(mvids, list) and mvids: result_gp = result.payload.get("gen_params") or {}
mvid = mvids[0] merged_gp = {**exif_gp, **result_gp}
if merged_gp:
result.payload["gen_params"] = merged_gp
if civitai_image_id and image_info and not result.payload.get("error"):
# Use the metadata dict we built (may contain modelVersionIds
# and browsingLevel from the API root level). Do NOT pass
# image_info.get("meta") — it is null for images whose meta
# lives at the root level only. Also do NOT derive
# model_version_id from modelVersionIds[0] — that array mixes
# checkpoints, LoRAs, and other types without ordering
# guarantees; the parser already resolved them correctly.
recipe_for_enrich = { recipe_for_enrich = {
"gen_params": result.payload.get("gen_params", {}), "gen_params": result.payload.get("gen_params", {}),
"loras": result.payload.get("loras", []), "loras": result.payload.get("loras", []),
@@ -222,8 +289,10 @@ class RecipeAnalysisService:
recipe=recipe_for_enrich, recipe=recipe_for_enrich,
civitai_client=civitai_client, civitai_client=civitai_client,
request_params=None, request_params=None,
prefetched_civitai_meta_raw=image_info.get("meta"), prefetched_civitai_meta_raw=(
prefetched_model_version_id=mvid, metadata if isinstance(metadata, dict) else None
),
prefetched_model_version_id=None,
) )
result.payload["gen_params"] = recipe_for_enrich["gen_params"] result.payload["gen_params"] = recipe_for_enrich["gen_params"]
@@ -232,6 +301,12 @@ class RecipeAnalysisService:
if recipe_for_enrich.get("base_model"): if recipe_for_enrich.get("base_model"):
result.payload["base_model"] = recipe_for_enrich["base_model"] result.payload["base_model"] = recipe_for_enrich["base_model"]
# Extract browsingLevel from our constructed metadata for NSFW blur
if isinstance(metadata, dict):
bl = metadata.get("browsingLevel")
if isinstance(bl, int) and bl > 0:
result.payload["preview_nsfw_level"] = bl
return result return result
finally: finally:
if temp_path: if temp_path:
@@ -314,6 +389,10 @@ class RecipeAnalysisService:
"prompt_type", "prompt_type",
"positive", "positive",
"negative", "negative",
# modelVersionIds is injected at the root level by CivitAI's image
# API when meta is null. It carries the version IDs of ALL models
# (checkpoint + LoRAs) used to generate the image.
"modelVersionIds",
} }
return any(field in metadata for field in recipe_fields) return any(field in metadata for field in recipe_fields)

View File

@@ -147,6 +147,8 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Qwen", "Qwen",
"ZImageBase", "ZImageBase",
"ZImageTurbo", "ZImageTurbo",
# Krea 2 — loaded via UNETLoader in ComfyUI
"Krea 2",
] ]
) )

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.1.5" version = "1.1.6"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -21,6 +21,7 @@ export class BulkManager {
this.isMarqueeActive = false; this.isMarqueeActive = false;
this.isDragging = false; this.isDragging = false;
this.marqueeStart = { x: 0, y: 0 }; this.marqueeStart = { x: 0, y: 0 };
this.marqueeStartDoc = { x: 0, y: 0 }; // Marquee start in document coordinates
this.marqueeElement = null; this.marqueeElement = null;
this.initialSelectedModels = new Set(); this.initialSelectedModels = new Set();
@@ -29,6 +30,11 @@ export class BulkManager {
this.mouseDownTime = 0; this.mouseDownTime = 0;
this.mouseDownPosition = { x: 0, y: 0 }; this.mouseDownPosition = { x: 0, y: 0 };
// Auto-scroll properties for marquee
this.lastClientX = 0;
this.lastClientY = 0;
this.autoScrollRaf = null;
// Model type specific action configurations // Model type specific action configurations
this.actionConfig = { this.actionConfig = {
[MODEL_TYPES.LORA]: { [MODEL_TYPES.LORA]: {
@@ -168,7 +174,10 @@ export class BulkManager {
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => { eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
if (this.isMarqueeActive) { if (this.isMarqueeActive) {
this.lastClientX = e.clientX;
this.lastClientY = e.clientY;
this.updateMarqueeSelection(e); this.updateMarqueeSelection(e);
this.startAutoScroll();
} else if (this.mouseDownTime && !this.isDragging) { } else if (this.mouseDownTime && !this.isDragging) {
// Check if we've moved enough to consider it a drag // Check if we've moved enough to consider it a drag
const dx = e.clientX - this.mouseDownPosition.x; const dx = e.clientX - this.mouseDownPosition.x;
@@ -237,6 +246,7 @@ export class BulkManager {
* Clean up event handlers * Clean up event handlers
*/ */
cleanup() { cleanup() {
this.stopAutoScroll();
eventManager.removeAllHandlersForSource('bulkManager-keyboard'); eventManager.removeAllHandlersForSource('bulkManager-keyboard');
eventManager.removeAllHandlersForSource('bulkManager-marquee-start'); eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
eventManager.removeAllHandlersForSource('bulkManager-marquee-move'); eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
@@ -1727,10 +1737,15 @@ export class BulkManager {
* @param {boolean} isDragging - Whether this is triggered from a drag operation * @param {boolean} isDragging - Whether this is triggered from a drag operation
*/ */
startMarqueeSelection(e, isDragging = false) { startMarqueeSelection(e, isDragging = false) {
// Store initial mouse position // Store initial mouse position (viewport coordinates for visual element)
this.marqueeStart.x = this.mouseDownPosition.x; this.marqueeStart.x = this.mouseDownPosition.x;
this.marqueeStart.y = this.mouseDownPosition.y; this.marqueeStart.y = this.mouseDownPosition.y;
// Store initial mouse position in document coordinates (for logical selection)
const container = document.querySelector('.page-content');
this.marqueeStartDoc.x = this.mouseDownPosition.x + (container?.scrollLeft || 0);
this.marqueeStartDoc.y = this.mouseDownPosition.y + (container?.scrollTop || 0);
// Store initial selection state // Store initial selection state
this.initialSelectedModels = new Set(state.selectedModels); this.initialSelectedModels = new Set(state.selectedModels);
@@ -1776,46 +1791,67 @@ export class BulkManager {
*/ */
updateMarqueeSelection(e) { updateMarqueeSelection(e) {
if (!this.marqueeElement) return; if (!this.marqueeElement) return;
this.updateMarqueeSelectionFromPosition(e.clientX, e.clientY);
const currentX = e.clientX;
const currentY = e.clientY;
// Calculate rectangle bounds
const left = Math.min(this.marqueeStart.x, currentX);
const top = Math.min(this.marqueeStart.y, currentY);
const width = Math.abs(currentX - this.marqueeStart.x);
const height = Math.abs(currentY - this.marqueeStart.y);
// Update marquee element position and size
this.marqueeElement.style.left = left + 'px';
this.marqueeElement.style.top = top + 'px';
this.marqueeElement.style.width = width + 'px';
this.marqueeElement.style.height = height + 'px';
// Check which cards intersect with marquee
this.updateCardSelection(left, top, left + width, top + height);
} }
/** /**
* Update card selection based on marquee bounds * Update marquee from raw client coordinates (used by both mousemove and auto-scroll loop)
*/ */
updateCardSelection(left, top, right, bottom) { updateMarqueeSelectionFromPosition(clientX, clientY) {
const cards = document.querySelectorAll('.model-card'); if (!this.marqueeElement) return;
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
// Current position in document coordinates
const currentDocX = clientX + scrollX;
const currentDocY = clientY + scrollY;
// Calculate marquee rectangle in document coordinates
const docLeft = Math.min(this.marqueeStartDoc.x, currentDocX);
const docTop = Math.min(this.marqueeStartDoc.y, currentDocY);
const docRight = Math.max(this.marqueeStartDoc.x, currentDocX);
const docBottom = Math.max(this.marqueeStartDoc.y, currentDocY);
// Update visual marquee element (position: fixed, so subtract scroll offset)
this.marqueeElement.style.left = (docLeft - scrollX) + 'px';
this.marqueeElement.style.top = (docTop - scrollY) + 'px';
this.marqueeElement.style.width = (docRight - docLeft) + 'px';
this.marqueeElement.style.height = (docBottom - docTop) + 'px';
// Check which cards intersect with marquee
this.updateCardSelection(docLeft, docTop, docRight, docBottom);
}
/**
* Update card selection based on marquee bounds (document coordinates).
* Uses dual detection: DOM cards for visible ones + VirtualScroller layout for off-screen cards.
*/
updateCardSelection(docLeft, docTop, docRight, docBottom) {
const vs = state.virtualScroller;
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
const newSelection = new Set(this.initialSelectedModels); const newSelection = new Set(this.initialSelectedModels);
const visibleFilepaths = new Set();
cards.forEach(card => { // Step 1: Process visible DOM cards using getBoundingClientRect + scroll offset
const rect = card.getBoundingClientRect(); document.querySelectorAll('.model-card').forEach(card => {
// Check if card intersects with marquee rectangle
const intersects = !(rect.right < left ||
rect.left > right ||
rect.bottom < top ||
rect.top > bottom);
const filepath = card.dataset.filepath; const filepath = card.dataset.filepath;
if (!filepath) return;
visibleFilepaths.add(filepath);
const rect = card.getBoundingClientRect();
const cardLeft = rect.left + scrollX;
const cardTop = rect.top + scrollY;
const cardRight = rect.right + scrollX;
const cardBottom = rect.bottom + scrollY;
const intersects = !(cardRight < docLeft || cardLeft > docRight ||
cardBottom < docTop || cardTop > docBottom);
if (intersects) { if (intersects) {
// Add to selection if intersecting
newSelection.add(filepath); newSelection.add(filepath);
card.classList.add('selected'); card.classList.add('selected');
@@ -1825,12 +1861,43 @@ export class BulkManager {
this.updateMetadataCacheFromCard(filepath, card); this.updateMetadataCacheFromCard(filepath, card);
} }
} else if (!this.initialSelectedModels.has(filepath)) { } else if (!this.initialSelectedModels.has(filepath)) {
// Remove from selection if not intersecting and wasn't initially selected
newSelection.delete(filepath); newSelection.delete(filepath);
card.classList.remove('selected'); card.classList.remove('selected');
} }
}); });
// Step 2: Process off-screen cards via VirtualScroller layout calculation.
// Since VirtualScroller removes off-screen DOM elements, we compute
// each card's position from its index and the VS layout parameters.
if (vs?.gridElement && vs.items && vs.columnsCount > 0) {
const gridRect = vs.gridElement.getBoundingClientRect();
// Grid origin in scroll-container content coordinates
const originX = gridRect.left + scrollX;
const originY = gridRect.top + scrollY;
for (let i = 0; i < vs.items.length; i++) {
const filepath = vs.items[i]?.file_path;
if (!filepath || visibleFilepaths.has(filepath)) continue;
const row = Math.floor(i / vs.columnsCount);
const col = i % vs.columnsCount;
const cLeft = originX + col * (vs.itemWidth + vs.columnGap);
const cTop = originY + (vs.containerPaddingTop || 0) + row * (vs.itemHeight + (vs.rowGap || 0));
const cRight = cLeft + vs.itemWidth;
const cBottom = cTop + vs.itemHeight;
const intersects = !(cRight < docLeft || cLeft > docRight ||
cBottom < docTop || cTop > docBottom);
if (intersects) {
newSelection.add(filepath);
} else if (!this.initialSelectedModels.has(filepath)) {
newSelection.delete(filepath);
}
}
}
// Update global selection state // Update global selection state
state.selectedModels = newSelection; state.selectedModels = newSelection;
@@ -1849,6 +1916,9 @@ export class BulkManager {
this.isDragging = false; this.isDragging = false;
this.mouseDownTime = 0; this.mouseDownTime = 0;
// Stop any active auto-scroll
this.stopAutoScroll();
// Update event manager state // Update event manager state
eventManager.setState('marqueeActive', false); eventManager.setState('marqueeActive', false);
@@ -1874,6 +1944,79 @@ export class BulkManager {
// Clear initial selection state // Clear initial selection state
this.initialSelectedModels.clear(); this.initialSelectedModels.clear();
} }
/**
* Start auto-scroll loop when mouse approaches viewport edge during marquee
*/
startAutoScroll() {
if (this.autoScrollRaf) return;
this.autoScrollLoop();
}
/**
* Stop auto-scroll loop
*/
stopAutoScroll() {
if (this.autoScrollRaf) {
cancelAnimationFrame(this.autoScrollRaf);
this.autoScrollRaf = null;
}
}
/**
* Auto-scroll loop: scrolls the page when mouse is near viewport edges
* and re-evaluates marquee selection after each scroll.
*/
autoScrollLoop() {
if (!this.isMarqueeActive) {
this.autoScrollRaf = null;
return;
}
const container = document.querySelector('.page-content');
if (!container) {
this.autoScrollRaf = null;
return;
}
const MARGIN = 30; // Px from edge to trigger scroll
const BASE_SPEED = 12; // Pixels per frame at edge boundary
const MAX_SPEED = 40; // Maximum scroll speed
const rect = container.getBoundingClientRect();
let dx = 0;
let dy = 0;
// Vertical auto-scroll - speed increases the further the cursor is past the edge
if (this.lastClientY !== undefined) {
if (this.lastClientY < rect.top + MARGIN) {
const dist = Math.max(0, (rect.top + MARGIN) - this.lastClientY);
dy = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
} else if (this.lastClientY > rect.bottom - MARGIN) {
const dist = Math.max(0, this.lastClientY - (rect.bottom - MARGIN));
dy = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
}
}
// Horizontal auto-scroll
if (this.lastClientX !== undefined) {
if (this.lastClientX < rect.left + MARGIN) {
const dist = Math.max(0, (rect.left + MARGIN) - this.lastClientX);
dx = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
} else if (this.lastClientX > rect.right - MARGIN) {
const dist = Math.max(0, this.lastClientX - (rect.right - MARGIN));
dx = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
}
}
if (dx !== 0 || dy !== 0) {
container.scrollBy(dx, dy);
// Re-evaluate marquee selection with the new scroll position
this.updateMarqueeSelectionFromPosition(this.lastClientX, this.lastClientY);
this.autoScrollRaf = requestAnimationFrame(() => this.autoScrollLoop());
} else {
this.autoScrollRaf = null;
}
}
} }
export const bulkManager = new BulkManager(); export const bulkManager = new BulkManager();

View File

@@ -351,7 +351,7 @@ export class DownloadManager {
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png'; const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
// Count model-type files per version // Count model-type files per version
const modelFiles = (version.files || []).filter(f => f.type === 'Model'); const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {}; const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
const fileSize = version.modelSizeKB ? const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) : (version.modelSizeKB / 1024).toFixed(2) :
@@ -478,7 +478,7 @@ export class DownloadManager {
if (!version) return; if (!version) return;
this.currentVersion = version; this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => f.type === 'Model'); const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
document.getElementById('versionStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'none';
document.getElementById('fileSelectionStep').style.display = 'block'; document.getElementById('fileSelectionStep').style.display = 'block';
@@ -534,7 +534,7 @@ export class DownloadManager {
const version = this.currentVersion; const version = this.currentVersion;
if (!version) return; if (!version) return;
const modelFiles = (version.files || []).filter(f => f.type === 'Model'); const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value); this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
document.getElementById('fileSelectionStep').style.display = 'none'; document.getElementById('fileSelectionStep').style.display = 'none';
@@ -954,7 +954,7 @@ export class DownloadManager {
} }
if (!this.isBatchMode) { if (!this.isBatchMode) {
const fileParams = this.selectedFile ? { const fileParams = this.selectedFile ? {
type: 'Model', type: this.selectedFile.type || 'Model',
format: this.selectedFile.metadata?.format || 'SafeTensor', format: this.selectedFile.metadata?.format || 'SafeTensor',
size: this.selectedFile.metadata?.size || 'full', size: this.selectedFile.metadata?.size || 'full',
fp: this.selectedFile.metadata?.fp, fp: this.selectedFile.metadata?.fp,

View File

@@ -57,9 +57,16 @@ export class DownloadManager {
base_model: this.importManager.recipeData.base_model || "", base_model: this.importManager.recipeData.base_model || "",
loras: this.importManager.recipeData.loras || [], loras: this.importManager.recipeData.loras || [],
gen_params: this.importManager.recipeData.gen_params || {}, gen_params: this.importManager.recipeData.gen_params || {},
raw_metadata: this.importManager.recipeData.raw_metadata || {} raw_metadata: this.importManager.recipeData.raw_metadata || {},
}; };
// Preserve preview_nsfw_level from analysis so the saved
// recipe applies the correct NSFW blur on the preview image.
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
if (nsfwLevel !== undefined && nsfwLevel !== null) {
completeMetadata.preview_nsfw_level = nsfwLevel;
}
const checkpointMetadata = const checkpointMetadata =
this.importManager.recipeData.checkpoint || this.importManager.recipeData.checkpoint ||
this.importManager.recipeData.model || this.importManager.recipeData.model ||

View File

@@ -60,7 +60,9 @@ class FakePromptServer:
sent = [] sent = []
class Instance: class Instance:
def send_sync(self, event, payload): sockets: dict = {}
def send_sync(self, event, payload, sid=None):
FakePromptServer.sent.append((event, payload)) FakePromptServer.sent.append((event, payload))
instance = Instance() instance = Instance()
@@ -148,7 +150,8 @@ class TestNodeRegistryHandlerSnapshots:
"type": "Lora Loader (LoraManager)", "type": "Lora Loader (LoraManager)",
"title": "Test Loader", "title": "Test Loader",
} }
] ],
"client_id": "test-client-1",
} }
) )
@@ -167,7 +170,7 @@ class TestNodeRegistryHandlerSnapshots:
standalone_mode=False, standalone_mode=False,
) )
request = FakeRequest(json_data={"nodes": []}) request = FakeRequest(json_data={"nodes": [], "client_id": "test-client-1"})
response = await handler.register_nodes(request) response = await handler.register_nodes(request)
payload = json.loads(response.text) payload = json.loads(response.text)

View File

@@ -586,7 +586,9 @@ class FakePromptServer:
sent = [] sent = []
class Instance: class Instance:
def send_sync(self, event, payload): sockets: dict = {}
def send_sync(self, event, payload, sid=None):
FakePromptServer.sent.append((event, payload)) FakePromptServer.sent.append((event, payload))
instance = Instance() instance = Instance()
@@ -601,7 +603,12 @@ async def test_register_nodes_requires_graph_id():
standalone_mode=False, standalone_mode=False,
) )
request = FakeRequest(json_data={"nodes": [{"node_id": 1}]}) request = FakeRequest(
json_data={
"nodes": [{"node_id": 1}],
"client_id": "test-client-1",
}
)
response = await handler.register_nodes(request) response = await handler.register_nodes(request)
payload = json.loads(response.text) payload = json.loads(response.text)
@@ -629,7 +636,8 @@ async def test_register_nodes_stores_graph_identifier():
"type": "Lora Loader (LoraManager)", "type": "Lora Loader (LoraManager)",
"title": "Loader", "title": "Loader",
} }
] ],
"client_id": "test-client-1",
} }
) )
@@ -638,7 +646,7 @@ async def test_register_nodes_stores_graph_identifier():
assert payload["success"] is True assert payload["success"] is True
registry = await node_registry.get_registry() registry = await node_registry.get_merged_registry()
assert registry["node_count"] == 1 assert registry["node_count"] == 1
stored_node = next(iter(registry["nodes"].values())) stored_node = next(iter(registry["nodes"].values()))
assert stored_node["graph_id"] == "graph-123" assert stored_node["graph_id"] == "graph-123"
@@ -664,7 +672,8 @@ async def test_register_nodes_defaults_graph_name_to_none():
"type": "Lora Loader (LoraManager)", "type": "Lora Loader (LoraManager)",
"title": "Root Loader", "title": "Root Loader",
} }
] ],
"client_id": "test-client-1",
} }
) )
@@ -673,7 +682,7 @@ async def test_register_nodes_defaults_graph_name_to_none():
assert payload["success"] is True assert payload["success"] is True
registry = await node_registry.get_registry() registry = await node_registry.get_merged_registry()
stored_node = next(iter(registry["nodes"].values())) stored_node = next(iter(registry["nodes"].values()))
assert stored_node["graph_name"] is None assert stored_node["graph_name"] is None
@@ -700,7 +709,8 @@ async def test_register_nodes_includes_capabilities():
"widget_names": ["ckpt_name", "", 42], "widget_names": ["ckpt_name", "", 42],
}, },
} }
] ],
"client_id": "test-client-1",
} }
) )
@@ -709,7 +719,7 @@ async def test_register_nodes_includes_capabilities():
assert payload["success"] is True assert payload["success"] is True
registry = await node_registry.get_registry() registry = await node_registry.get_merged_registry()
stored_node = next(iter(registry["nodes"].values())) stored_node = next(iter(registry["nodes"].values()))
assert stored_node["capabilities"] == { assert stored_node["capabilities"] == {
"supports_lora": False, "supports_lora": False,
@@ -724,7 +734,9 @@ async def test_update_node_widget_sends_payload():
class RecordingPromptServer: class RecordingPromptServer:
class Instance: class Instance:
def send_sync(self, event, payload): sockets: dict = {}
def send_sync(self, event, payload, sid=None):
send_calls.append((event, payload)) send_calls.append((event, payload))
instance = Instance() instance = Instance()
@@ -768,7 +780,9 @@ async def test_update_lora_code_includes_graph_identifier():
class RecordingPromptServer: class RecordingPromptServer:
class Instance: class Instance:
def send_sync(self, event, payload): sockets: dict = {}
def send_sync(self, event, payload, sid=None):
send_calls.append((event, payload)) send_calls.append((event, payload))
instance = Instance() instance = Instance()

View File

@@ -568,7 +568,7 @@ async def test_get_image_info_prefers_red_host_for_red_source(monkeypatch, downl
assert result == {"id": 124950237, "name": "target"} assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [ assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X" "https://civitai.red/api/v1/images?imageId=124950237&nsfw=X&withMeta=true"
] ]
@@ -589,7 +589,7 @@ async def test_get_image_info_uses_red_host_even_for_red_source(monkeypatch, dow
assert result == {"id": 124950237, "name": "target"} assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [ assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X", "https://civitai.red/api/v1/images?imageId=124950237&nsfw=X&withMeta=true",
] ]
@@ -610,7 +610,7 @@ async def test_get_image_info_does_not_fall_back_after_request_failure(monkeypat
assert result is None assert result is None
assert requested_urls == [ assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X", "https://civitai.red/api/v1/images?imageId=124950237&nsfw=X&withMeta=true",
] ]

View File

@@ -151,7 +151,10 @@ app.registerExtension({
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ nodes: workflowNodes }), body: JSON.stringify({
nodes: workflowNodes,
client_id: api.clientId ?? api.initialClientId ?? "",
}),
}); });
if (!response.ok) { if (!response.ok) {