Files
xiaodoudou-ComfyUI-RigShare/rigshare_core/graphdoc.py
T
Doudou d7b96ecd4d ComfyUI RigShare 1.0.0
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
2026-09-25 13:24:09 +00:00

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