Multi-user collaboration for the current ComfyUI frontend, continued from ComfyUI-Nexus by daxcay (MIT). - Login page before ComfyUI loads, first-run admin setup, JWT sessions - Personal API keys; server-side permission checks (edit, queue, manager, admin) - Every workflow shared as a live room, with snapshots and history - Per-workflow access lists (view / edit per person) - Conflict-free concurrent edits (id remapping, per-id link merges) - Presence: who is online, cursors, selections, follow mode - Chat with unread badge, blinking icon and sound - Server monitor (CPU, RAM, GPUs, queue) with a floating widget - Admin options to hide the Comfy.org login and API templates - Docs, changelog, screenshots and icon
211 lines
8.5 KiB
Python
211 lines
8.5 KiB
Python
"""Workflow document patches: apply, conflict remapping and link repair.
|
|
|
|
``web/rigshare/graphdoc.js`` mirrors ``apply_patch`` and ``reconcile`` exactly;
|
|
clients and server must reach the same document from the same patches.
|
|
|
|
A patch (all keys optional)::
|
|
|
|
{
|
|
"nodes": [node, ...], # upserted by id
|
|
"removed": [node_id, ...],
|
|
"added": [node_id, ...], # ids the sender believes are new
|
|
"links": {"upsert": [...], "removed": [...], "added": [...]},
|
|
"groups": {"upsert": [...], "removed": [...]},
|
|
"set": {key: value, ...}, # any other top-level key
|
|
"unset": [key, ...],
|
|
"full": doc # replace everything
|
|
}
|
|
|
|
Links and groups merge by id, so two people connecting things at the same
|
|
time keep both changes. ``reconcile`` then rebuilds every node's slot link
|
|
references from the links list, dropping dangling links and keeping only
|
|
the newest link on an input slot.
|
|
"""
|
|
|
|
import copy
|
|
|
|
KEYED = ("links", "groups")
|
|
MAX_KEYS = ("last_node_id", "last_link_id")
|
|
|
|
|
|
def link_id(link):
|
|
return link[0] if isinstance(link, list) else (link or {}).get("id")
|
|
|
|
|
|
def link_ends(link):
|
|
"""(origin_id, origin_slot, target_id, target_slot)"""
|
|
if isinstance(link, list):
|
|
return link[1], link[2], link[3], link[4]
|
|
return link.get("origin_id"), link.get("origin_slot"), link.get("target_id"), link.get("target_slot")
|
|
|
|
|
|
def _set_link(link, **values):
|
|
if isinstance(link, list):
|
|
link = list(link)
|
|
for key, value in values.items():
|
|
link[{"id": 0, "origin_id": 1, "target_id": 3}[key]] = value
|
|
return link
|
|
return {**link, **values}
|
|
|
|
|
|
def item_id(key, item):
|
|
return link_id(item) if key == "links" else (item or {}).get("id")
|
|
|
|
|
|
def _num(value):
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return float("-inf")
|
|
|
|
|
|
def _merge_keyed(key, current, change):
|
|
items = list(current or [])
|
|
removed = set(change.get("removed") or [])
|
|
index = {item_id(key, it): i for i, it in enumerate(items)}
|
|
for it in change.get("upsert") or []:
|
|
i = index.get(item_id(key, it))
|
|
if i is None:
|
|
index[item_id(key, it)] = len(items)
|
|
items.append(it)
|
|
else:
|
|
items[i] = it
|
|
return [it for it in items if item_id(key, it) not in removed]
|
|
|
|
|
|
def reconcile(doc):
|
|
"""Make node slot references agree with the links list."""
|
|
nodes = doc.get("nodes") or []
|
|
node_ids = {n.get("id") for n in nodes}
|
|
links = doc.get("links")
|
|
if not isinstance(links, list):
|
|
return doc
|
|
by_target = {}
|
|
for link in sorted(links, key=lambda l: _num(link_id(l))):
|
|
origin, _, target, target_slot = link_ends(link)
|
|
if origin in node_ids and target in node_ids:
|
|
by_target[(target, target_slot)] = link
|
|
keep = {link_id(l) for l in by_target.values()}
|
|
doc["links"] = [l for l in links if link_id(l) in keep]
|
|
for node in nodes:
|
|
nid = node.get("id")
|
|
for i, slot in enumerate(node.get("inputs") or []):
|
|
if isinstance(slot, dict):
|
|
link = by_target.get((nid, i))
|
|
slot["link"] = link_id(link) if link else None
|
|
for j, slot in enumerate(node.get("outputs") or []):
|
|
if isinstance(slot, dict):
|
|
ids = [link_id(l) for l in doc["links"] if link_ends(l)[0] == nid and link_ends(l)[1] == j]
|
|
slot["links"] = ids if ids or slot.get("links") is not None else None
|
|
return doc
|
|
|
|
|
|
def apply_patch(doc, patch):
|
|
if "full" in patch:
|
|
return copy.deepcopy(patch["full"])
|
|
doc = copy.deepcopy(doc or {})
|
|
nodes = list(doc.get("nodes") or [])
|
|
removed = set(patch.get("removed") or [])
|
|
index = {n.get("id"): i for i, n in enumerate(nodes)}
|
|
for node in patch.get("nodes") or []:
|
|
node = copy.deepcopy(node)
|
|
i = index.get(node.get("id"))
|
|
if i is None:
|
|
index[node.get("id")] = len(nodes)
|
|
nodes.append(node)
|
|
else:
|
|
nodes[i] = node
|
|
doc["nodes"] = [n for n in nodes if n.get("id") not in removed]
|
|
for key in KEYED:
|
|
if isinstance(patch.get(key), dict):
|
|
doc[key] = _merge_keyed(key, doc.get(key), copy.deepcopy(patch[key]))
|
|
for key, value in (patch.get("set") or {}).items():
|
|
if key == "nodes":
|
|
continue
|
|
if key in MAX_KEYS and isinstance(value, (int, float)) and isinstance(doc.get(key), (int, float)):
|
|
doc[key] = max(doc[key], value)
|
|
else:
|
|
doc[key] = copy.deepcopy(value)
|
|
for key in patch.get("unset") or []:
|
|
doc.pop(key, None)
|
|
links_change = patch.get("links") if isinstance(patch.get("links"), dict) else {}
|
|
if links_change.get("upsert") or links_change.get("removed") or patch.get("nodes") or patch.get("removed"):
|
|
reconcile(doc)
|
|
return doc
|
|
|
|
|
|
def remap_conflicts(doc, patch):
|
|
"""Renumber nodes/links the sender added that already exist in ``doc``.
|
|
|
|
Returns ``(patch, changed)``. Only ids listed in ``added`` are considered:
|
|
those are new for the sender, so an existing id means someone else took
|
|
it in the meantime.
|
|
"""
|
|
doc = doc or {}
|
|
existing_nodes = {n.get("id") for n in doc.get("nodes") or []}
|
|
existing_links = {link_id(l) for l in doc.get("links") or []}
|
|
links_change = patch.get("links") if isinstance(patch.get("links"), dict) else {}
|
|
|
|
def next_free(start, taken):
|
|
value = start
|
|
while value in taken:
|
|
value += 1
|
|
return value
|
|
|
|
node_numbers = [i for i in existing_nodes if isinstance(i, int)] + [doc.get("last_node_id") or 0]
|
|
link_numbers = [i for i in existing_links if isinstance(i, int)] + [doc.get("last_link_id") or 0]
|
|
node_map, link_map = {}, {}
|
|
taken_nodes = set(existing_nodes) | {n.get("id") for n in patch.get("nodes") or []}
|
|
taken_links = set(existing_links) | {link_id(l) for l in links_change.get("upsert") or []}
|
|
candidate = max(n for n in node_numbers if isinstance(n, (int, float))) + 1
|
|
for nid in patch.get("added") or []:
|
|
if nid in existing_nodes and isinstance(nid, int):
|
|
candidate = next_free(int(candidate), taken_nodes)
|
|
node_map[nid] = candidate
|
|
taken_nodes.add(candidate)
|
|
candidate = max(n for n in link_numbers if isinstance(n, (int, float))) + 1
|
|
for lid in links_change.get("added") or []:
|
|
if lid in existing_links and isinstance(lid, int):
|
|
candidate = next_free(int(candidate), taken_links)
|
|
link_map[lid] = candidate
|
|
taken_links.add(candidate)
|
|
if not node_map and not link_map:
|
|
return patch, False
|
|
|
|
patch = copy.deepcopy(patch)
|
|
added_nodes = set(patch.get("added") or [])
|
|
added_links = set(links_change.get("added") or [])
|
|
for node in patch.get("nodes") or []:
|
|
# Only the sender's new node carries the conflicting id; an existing
|
|
# node with the same id in the patch is an update of someone else's.
|
|
if node.get("id") in node_map and node.get("id") in added_nodes:
|
|
node["id"] = node_map[node["id"]]
|
|
for slot in node.get("inputs") or []:
|
|
if isinstance(slot, dict) and slot.get("link") in link_map:
|
|
slot["link"] = link_map[slot["link"]]
|
|
for slot in node.get("outputs") or []:
|
|
if isinstance(slot, dict) and slot.get("links"):
|
|
slot["links"] = [link_map.get(l, l) for l in slot["links"]]
|
|
if isinstance(patch.get("links"), dict):
|
|
fixed = []
|
|
for link in patch["links"].get("upsert") or []:
|
|
lid = link_id(link)
|
|
origin, _, target, _ = link_ends(link)
|
|
values = {}
|
|
if lid in link_map and lid in added_links:
|
|
values["id"] = link_map[lid]
|
|
if origin in node_map:
|
|
values["origin_id"] = node_map[origin]
|
|
if target in node_map:
|
|
values["target_id"] = node_map[target]
|
|
fixed.append(_set_link(link, **values) if values else link)
|
|
patch["links"]["upsert"] = fixed
|
|
patch["links"]["added"] = [link_map.get(l, l) for l in links_change.get("added") or []]
|
|
patch["added"] = [node_map.get(n, n) for n in patch.get("added") or []]
|
|
patch.setdefault("set", {})
|
|
if node_map:
|
|
patch["set"]["last_node_id"] = max([patch["set"].get("last_node_id") or 0, *node_map.values()])
|
|
if link_map:
|
|
patch["set"]["last_link_id"] = max([patch["set"].get("last_link_id") or 0, *link_map.values()])
|
|
return patch, True
|