Compare commits

...

3 Commits

Author SHA1 Message Date
willmiao
402d8b07cf docs: auto-update supporters list in README 2026-06-28 14:17:19 +00:00
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
7 changed files with 305 additions and 186 deletions

File diff suppressed because one or more lines are too long

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

@@ -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

@@ -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

@@ -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

@@ -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) {