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
This commit is contained in:
Doudou
2026-09-25 13:24:09 +00:00
parent e5a34b7494
commit d7b96ecd4d
56 changed files with 4996 additions and 4190 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary
+40
View File
@@ -0,0 +1,40 @@
# Changelog
## 1.0.0
First release of RigShare, a rewrite of [ComfyUI-Nexus](https://github.com/daxcay/ComfyUI-Nexus) 1.0.2 for the current ComfyUI frontend.
### Added
- Login page before ComfyUI loads, with first-run creation of the admin account
- JWT sessions (HS256, 12 h, renewed while active), bound to revocable server-side sessions
- Personal API keys for scripts and other apps
- Every workflow shared as its own room: saved files automatically, unsaved tabs once edited
- Shared workflows list, *Share this tab*, *Stop sharing*, removal of idle rooms
- Per-workflow snapshots: automatic, named, restore for everyone, open as a copy
- Per-workflow access lists: restrict a workflow to chosen people, each with view or edit
- *Manager* permission: only accounts with it can use ComfyUI Manager (install, update, snapshots, restart). Enforced server-side; the Manager button is hidden without it
- Admin option to hide ComfyUI's Comfy.org account login (top-bar button and sign-in dialog)
- Admin option to hide templates that need paid API nodes from the template browser
- Conflict-free concurrent edits: links and groups merge by id, clashing node and link ids are renumbered, dangling links repaired
- Live cursors and selections, drawn above image previews and DOM widgets
- Follow mode that jumps to the other person's tab and view
- Chat with unread badge, blinking sidebar icon and sound
- Server monitor (CPU, RAM, per-GPU load, VRAM, temperature, power, queue) with a floating widget
- Execution progress and previews sent to everyone
- Admin panel: accounts, permissions, display names, usernames, passwords, server settings
- Server-side permission checks on ComfyUI's queue, interrupt, free and upload endpoints
- Warning when a reverse proxy drops WebSocket upgrades
- Login page that follows the ComfyUI palette last used in the browser
### Changed
- The sidebar panel replaces the floating chat window and keyboard shortcuts
- The server is authoritative: identity and permissions are checked on every message
- Graph sync uses the frontend's change tracker and per-node patches instead of patching litegraph
### Removed
- `/login`, `/logout` and `/nick` chat commands
- Plaintext `admins.json` and the shared default admin password
- Hiding ComfyUI's menus with CSS, and clearing the user's workflow on load
+2 -1
View File
@@ -1,6 +1,7 @@
MIT License
Copyright (c) 2024 daxcay
Copyright (c) 2024 daxcay (ComfyUI-Nexus)
Copyright (c) 2026 Doudou (ComfyUI RigShare)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+93 -310
View File
@@ -1,336 +1,119 @@
<div align="center">
![COMFYUI-NEXUS](https://github.com/user-attachments/assets/6548c010-649b-4e6c-8ae1-f05e3f523f31)
<img src="web/rigshare/assets/icon.svg" width="96" alt="RigShare icon">
# ComfyUI-Nexus
# ComfyUI RigShare
![Version](https://img.shields.io/badge/version-1.0.2-green) ![Last Update](https://img.shields.io/badge/last_update-Sept_2024-green)
> One ComfyUI rig, a whole team: accounts, live co-editing of every workflow, chat and a GPU monitor
A ComfyUI node designed to enable seamless multi-user workflow collaboration.
[![GitHub Release](https://img.shields.io/github/v/release/xiaodoudou/ComfyUI-RigShare?style=flat-square&color=e94560)](https://github.com/xiaodoudou/ComfyUI-RigShare/releases/latest) [![Stars](https://img.shields.io/github/stars/xiaodoudou/ComfyUI-RigShare?style=flat-square&color=yellow)](https://github.com/xiaodoudou/ComfyUI-RigShare/stargazers) [![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](LICENSE) [![ComfyUI](https://img.shields.io/badge/ComfyUI%20frontend-1.53%2B-7c3aed?style=flat-square)](docs/install.md) [![Python](https://img.shields.io/badge/Python-3.10%2B-3776ab?style=flat-square)](docs/install.md) [![Dependencies](https://img.shields.io/badge/Dependencies-none-brightgreen?style=flat-square)](docs/install.md) [![Auth](https://img.shields.io/badge/Auth-JWT%20%7C%20API%20keys-orange?style=flat-square)](docs/security.md)
![Untitled design (2)](https://github.com/user-attachments/assets/1d1f4b2b-0999-461e-b43c-719d107c54df)
A custom node that turns a single ComfyUI server into a shared workspace. Everyone signs in, opens any workflow, and edits it together in real time while seeing each other's cursors. Fork of
[daxcay/ComfyUI-Nexus](https://github.com/daxcay/ComfyUI-Nexus), rewritten for the current ComfyUI frontend.
**Features Video**: https://www.youtube.com/watch?v=RnYIUG59oTM
</div>
<br>
---
# Important Notes
## What it does
<br>
- asks everyone to sign in before ComfyUI loads, and creates the admin account on first run
- shares every workflow: people who open the same saved file edit it together, and unsaved tabs join as soon as you edit them
- lets you switch tabs freely, because only the tab on screen syncs
- shows who is online and which workflow they are on, plus live cursors and selections
- lets you follow someone, jumping to their tab and view
- keeps a chat with an unread badge, a blinking icon and a sound
- keeps per-workflow history: snapshots every few minutes, named snapshots, one-click restore
- gives each account permissions to edit, queue, use ComfyUI Manager or administer, enforced by the server and not just hidden in the UI
- lets you restrict a workflow to chosen people, each able to view or edit
- keeps simultaneous edits apart: two people adding or connecting nodes at the same moment both keep their work
- issues personal API keys, so scripts and other apps can call ComfyUI with their owner's permissions
- shows CPU, RAM, and for each GPU the load, VRAM, temperature and power, plus the queue, in a panel tab or a floating widget
- sends execution progress and previews to everyone, not only whoever queued
- **Install/Maintain on Server Only**: This node should only be installed on the server machine.
- **No Installation Needed for Others**: Other users don’t need to install this node.
- **URL for Connection**: Other users only need the URL to connect locally/remotely.
What this fork adds over Nexus:
- **Security**:
- ComfyUI menu and features are for admins only.
- ComfyUI shortcuts are for admins only.
- Prompt Queue shortcut `CTRL+Enter` is for users with queue permission only.
- **Built for the new frontend.** It lives in a sidebar tab and works with workflow tabs and subgraphs. It needs no legacy menu and no litegraph patching.
- **Real accounts.** A login page, hashed passwords, JWT sessions and API keys replace the shared `/login account password` chat command and the plaintext `admins.json`.
- **The server is in charge.** It checks permissions and identity on every message, so a browser cannot impersonate someone or edit without the right to.
- **Every workflow is shared**, not a single canvas that everybody fights over.
- **Editor Permissions**:
- Editors can only edit the graph (create/update/delete/move).
- If an editor has queue permission, they can queue prompts in the workflow.
## Screenshots
- **All Admin Server (not recommended)**:
- One can create a server with all admins to resolve permission issues.
- Refer to the `Admin Account Setup` section for more details.
<table>
<tr>
<td align="center"><img src="docs/images/history.png" width="260" alt="Workflows tab: this tab, history and shared workflows"><br><sub>Shared workflows and history</sub></td>
<td align="center"><img src="docs/images/people.png" width="260" alt="People tab"><br><sub>Who is online, and on which workflow</sub></td>
<td align="center"><img src="docs/images/chat.png" width="260" alt="Chat tab"><br><sub>Chat</sub></td>
</tr>
<tr>
<td align="center"><img src="docs/images/server.png" width="260" alt="Server tab"><br><sub>Server monitor</sub></td>
<td align="center"><img src="docs/images/account.png" width="260" alt="Account and API keys"><br><sub>Account and API keys</sub></td>
<td align="center"><img src="docs/images/admin.png" width="260" alt="Admin tab"><br><sub>Accounts and server settings</sub></td>
</tr>
<tr>
<td align="center"><img src="docs/images/access.png" width="260" alt="Access list of a workflow"><br><sub>Who can open a workflow</sub></td>
<td></td>
<td></td>
</tr>
</table>
<br>
<p align="center">
<img src="docs/images/floating.png" width="820" alt="Floating server monitor over the canvas"><br><sub>Floating server monitor</sub>
</p>
> [!WARNING]
> When opening the ComfyUI workspace for the first time, it will be locked. Login as admin to enable editing.
<p align="center">
<img src="docs/images/login.png" width="560" alt="Login page"><br><sub>Login page</sub>
</p>
> [!WARNING]
> Move or disable the ComfyUI-Nexus nodes from the custom nodes folder if you want to return to your normal ComfyUI setup.
## Getting started
<br>
Clone into `ComfyUI/custom_nodes` and restart ComfyUI:
> [!CAUTION]
> Enable the old `litegraph(legacy)` node search box. (New node search box is under development and has bugs)
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/xiaodoudou/ComfyUI-RigShare.git
```
![Untitled design (4)](https://github.com/user-attachments/assets/336a29e8-f6fb-4730-bd6d-f6b94947941b)
Open ComfyUI in a browser. There are no accounts yet, so the login page asks you to create the administrator. Sign in, open the **RigShare** tab in the sidebar and add your team under **Admin → Accounts**. That is the whole setup.
<br>
Everyone then signs in at the same address. To use the API from a script, create a key under your avatar → **API keys**:
### Location of Nexus folder
```bash
curl -H "Authorization: Bearer rs_yourkey" http://your-server:8188/api/queue
```
#### ComfyUI Folder
- `Drive:/ComfyUI_windows_portable/nexus`
Behind a reverse proxy, turn on WebSocket support, or nothing will be live. See [Reverse proxy](docs/reverse-proxy.md).
#### Stable Matrix
- **Full Version**: `Drive:/StabilityMatrix/Packages/ComfyUI/nexus`
- **Portable Version**: `Drive:/StabilityMatrix/Data/Packages/ComfyUI/nexus`
## Documentation
<br>
## Disabling ComfyUI-Nexus
<br>
- Stop ComfyUI and go to `ComfyUI\custom_nodes` folder
- Rename `ComfyUI-Nexus` like this `ComfyUI-Nexus.disabled` to disable.
- Restart ComfyUI again.
<br>
## Key Features
<br>
- **Multiuser collaboration**: enable multiple users to work on the same workflow simultaneously.
- **Local and Remote access**: use tools like ngrok or other tunneling software to facilitate remote collaboration. A local IP address on WiFi will also work 😎.
- **Enhanced teamwork**: streamline your team's workflow management and collaboration process.
- **Real-time chat**: communicate directly within the platform to ensure smooth and efficient collaboration.
- **Spectate mode**: allow team members to observe the workflow in real-time without interfering—perfect for training or monitoring progress.
- **Admin permissions**: admins can control who can edit the workflow and who can queue prompts, ensuring the right level of access for each team member.
- **Workflow backup**: in case of any mishap, you can reload an old backup. The node saves 5 workflows, each 60 seconds apart.
<br>
## Key Binds
- **Activate chat**: press **`t`**
- **Show/hide users panel**: press **`LAlt + p`**
- **Show/hide backups panel**: press **`LAlt + o`** (for user with editor permission only)
- **Queue promt**: press **`CTRL+Enter`** (for user with queue permission only)
<br>
## Chat Commands
- `/nick <name>`: changes your nickname
- `/login account password`: this command is used to become admin.
- `/logout`: logout the admin.
<br>
# Node Installation
- ### Installing Using `comfy-cli`
- `comfy node registry-install ComfyUI-Nexus`
- https://registry.comfy.org/publishers/daxcay/nodes/comfyui-nexus
- ### Manual Method
- Go to your `ComfyUI\custom_nodes` and Run CMD.
- Copy and paste this command: `git clone https://github.com/daxcay/ComfyUI-Nexus.git`
- ### Automatic Method with [Comfy Manager](https://github.com/ltdrdata/ComfyUI-Manager)
- Inside ComfyUI > Click the Manager Button on the side.
- Click `Custom Nodes Manager` and search for `ComfyUI-Nexus`, then install this node.
<br>
>[!IMPORTANT]
> #### **Restart ComfyUI before proceeding to next step**
<br>
# Server Setup
<br>
### Knowing ComfyUI Port
- Open Comfyui in your browser:
![image](https://github.com/user-attachments/assets/b430d5b7-dcb9-4a7f-948f-d257147b597a)
- In your url tab, digits after colon (:) is your port.
**Example:**
![image](https://github.com/user-attachments/assets/82ff2d9e-9eb6-4846-97c6-e3e321101fef)
The port for the above URL will be **8188**
<br>
### Admin Account Setup
- Open the file `ComfyUI\nexus\admins.json` in notepad.
![image](https://github.com/user-attachments/assets/2c0f3e6b-8bea-4378-8390-1bb377514e0c)
- **"epic"** is the account name and **"comfynexus"** is password
- Replace account and password with your own liking, but make sure not to use spaces.
### More than 1 Admin Account Setup
- Open the file `ComfyUI\nexus\admins.json` in notepad. add another account(s) and password(s) like this.
![image](https://github.com/user-attachments/assets/35461ce1-b1a6-4ddb-8333-5dcf7d6acf55)
- Make sure every password is different, and make sure not to use spaces.
<br>
### Setting run_nvidia_gpu.bat (Local setup if not using Tunneling Software)
- Open *run_nvidia_gpu.bat* and write the following and save it:
```.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --disable-auto-launch --enable-cors-header "*"```
<br>
- For a host machine in another IP Address write and save it:
```.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --disable-auto-launch --enable-cors-header "*" --listen 0.0.0.0```
<br>
>[!IMPORTANT]
>Don't leave the password as "comfynexus" as anyone can login.
>[!NOTE]
>**DO NOT SHARE ACCOUNT AND PASSWORD IN PUBLIC**
>[!IMPORTANT]
> #### Save file and **Restart ComfyUI before proceeding to next step**
<br>
# Hosting Setup
- One can use Ngrok or any other tunneling software supporting http/https to host a comfyui server remotely.
- Also you can host locally over WiFi/Lan.
### Using Ngrok:
- Go to this https://dashboard.ngrok.com/signup?ref=home-hero to sign up.
- After signing up and logging in, go to this https://dashboard.ngrok.com/get-started/setup/windows to set up ngrok.
- After installing and setting up ngrok,
- Run CMD and enter this command: `ngrok http <port>`
![Ngrok Output Example](https://github.com/user-attachments/assets/66f9b4a4-1d63-4756-8d57-64420fdc151a)
![image](https://github.com/user-attachments/assets/e3ca3d23-a388-4879-8b45-23591a05833c)
- **Forwarding** is the Remote URL, Share this URL with your peers.
<br>
### Using Local IP
- Open a cmd and write `ipconfig`.
![image](https://github.com/user-attachments/assets/56c4c17d-b1dc-40e1-acbc-1e62e8e15b70)
- Now copy IPv4 address ad add comfy port to it. For example, if it's `http://192.168.1.45:<comfy_port>`, the final URL will be: `http://192.168.1.45:5000`
- Share this URL with your peers.
<br>
>[!NOTE]
> **Ngrok and WiFi address might change if you restart the machine. Follow above steps again to get the new URL.**
<br>
## Permissions in ComfyUI-Nexus
- **viewer**: default permission given to a person joining the server.
- **editor**: person having editor permission cad edit the workflow.
- **queue prompt**: person having queue permission can queue the workflow.
<br>
>[!NOTE]
> Admin has all permissions by default.
<br>
## Real-Time Chat Window
When you join ComfyUI for the first time, you will see this chat window in the top left corner:
![Chat Message Example](https://github.com/user-attachments/assets/6b908ade-cd01-43d4-831c-6af2c6c461cf)
<br>
To chat, press `t`, then write the message and press 'Enter'.
<br>
### Chat Commands
- `/nick <name>`: changes your nickname
- `/login <account> <password>`: this command is used to become admin. **( account name and password saved in `admins.json` above )**
- `/logout`: logout the admin.
<br>
## User Panel
To show/hide the user panel, press `LShift+ LAlt + p`.
**For users, the user panel will look like this:**
![User Panel Example](https://github.com/user-attachments/assets/eae8791c-40a8-48d6-b72d-f4f7875d1653)
Users can perform the following actions on a joined user:
![image](https://github.com/user-attachments/assets/94ae776c-b96e-4d3e-8cc3-f01ec9cb4ee2)
- **mouse**: show/hide the mouse of other players.
- **spectate**: enable/disable spectate mode. Main use case: when you want to see or learn something from another user.
**For admins, the user panel will look like this:**
![Admin Panel Example](https://github.com/user-attachments/assets/0ce11918-4890-4202-a2d0-2df6f3a1fae0)
Admins can perform the following actions on a joined user:
![Admin Panel Actions Example](https://github.com/user-attachments/assets/ff147ca6-a51c-4eea-9e87-6c8db6322311)
- **spectate**: enable/disable spectate mode. Main use case: when you want to see or learn something from another user.
- **editor**: give/revoke editor permission to/from that user. Anyone with this permission can edit the workflow.
- **queue**: give/revoke queue permission to/from that user. Anyone with this permission can queue the workflow.
- **mouse**: show/hide the mouse of other players.
<br>
## Backup Panel (For Admins and Editors Only)
To show/hide the backup panel, press `LShift + LAlt + o`.
**The backup panel looks like this:**
## OLD
![Backup Panel Example](https://github.com/user-attachments/assets/27af5386-b848-4081-a88d-e8d4967a72f0)
## NEW
![image](https://github.com/user-attachments/assets/423c9e23-b13a-4b50-998c-712ec7f08f51)
- **load**: load the backup on ComfyUI. If the admin presses it, it will load for all users.
<br>
Backups are now divided into **'Short Term'** and **'Long Term'**
**Short Term**: These are only 5 backups and are saved 60 seconds apart.
**Long Term**: These backups are created every reload and they are never overwritten.
> Backups are saved 60 seconds apart. To load a workflow dragged by an admin, the admin will have to wait 60 seconds to let the server make a backup, then load it for all users.
<br>
## Inspiration
- Greatly Inspired by https://multitheftauto.com/ a GTA:SA multiplayer mod I spent years playing ❤️.
## Future Updates
- Based on feedback, I will add/update features.
- Multi-room collaboration.
- Users can set their own color for names and mouse cursors.
<br>
### Daxton Caylor - ComfyUI Node Developer
- ### Contact
- **Email** - daxtoncaylor+Github@gmail.com
- **Discord Server**: https://discord.gg/UyGkJycvyW
- ### Support
- **Patreon**: https://patreon.com/daxtoncaylor
- **Buy me a coffee**: https://buymeacoffee.com/daxtoncaylor
| | |
|---|---|
| [Installing](docs/install.md) | Custom node, Docker/compose, first-run setup, upgrading from Nexus |
| [Sharing workflows](docs/sharing.md) | Rooms, saved vs unsaved tabs, stop sharing, snapshots, follow |
| [Accounts and permissions](docs/accounts.md) | Edit, Queue, Admin, renaming, what viewers can do |
| [API access](docs/api.md) | API keys, websockets, trusted IPs, other apps such as Open WebUI |
| [Settings](docs/settings.md) | Panel settings, ComfyUI settings, every key in `config.json` |
| [Reverse proxy](docs/reverse-proxy.md) | Nginx Proxy Manager, plain nginx, HTTPS |
| [Security](docs/security.md) | How passwords, sessions and keys are protected |
| [Changelog](CHANGELOG.md) | What changed, and when |
## Why this exists
I run one ComfyUI box for several people. [ComfyUI-Nexus](https://github.com/daxcay/ComfyUI-Nexus) had the right idea, but it was written for the old UI:
- it hid menus with CSS and monkeypatched litegraph internals that no longer exist
- anyone could become admin with a shared password typed into chat
- everybody worked on one canvas, and opening ComfyUI wiped your own workflow
## TODO
- [x] Login page, JWT sessions, first-run admin setup
- [x] Per-user API keys
- [x] Every workflow shared, per-workflow snapshots
- [x] Cursors, selections, follow mode
- [x] Chat with badge, blink and sound
- [x] Server monitor with floating widget
- [x] Conflict-free node ids when two people add nodes at the same instant
- [x] Per-workflow access lists (share with specific people)
- [ ] Publish to the Comfy registry
+23 -461
View File
@@ -1,469 +1,31 @@
import logging
import json
import os
import shutil
import datetime
import time
import uuid
from aiohttp import web
from server import PromptServer
import folder_paths
from server import PromptServer
from .classes.WebSocketManager import WebSocketManager
DATA_FOLDER = f"{folder_paths.base_path}/nexus"
PERMISSIONS_FILE = os.path.join(DATA_FOLDER, 'permissions.json')
ADMINS_FILE = os.path.join(DATA_FOLDER, 'admins.json')
TOKENS_FILE = os.path.join(DATA_FOLDER, 'tokens.json')
NAMES_FILE = os.path.join(DATA_FOLDER, 'names.json')
SERVER_FILE = os.path.join(DATA_FOLDER, 'server.json')
WORKFLOWS_DIR = os.path.join(DATA_FOLDER, 'workflows')
if not os.path.exists(DATA_FOLDER):
os.makedirs(DATA_FOLDER)
if not os.path.exists(ADMINS_FILE):
with open(ADMINS_FILE, 'w') as f:
admins_data = {'epic': 'comfynexus'}
json.dump(admins_data, f, indent=4)
for file_path in [PERMISSIONS_FILE, ADMINS_FILE, TOKENS_FILE, NAMES_FILE]:
if not os.path.exists(file_path):
with open(file_path, 'w') as f:
json.dump({}, f)
if not os.path.exists(WORKFLOWS_DIR):
os.makedirs(WORKFLOWS_DIR)
if not os.path.exists(SERVER_FILE):
with open(SERVER_FILE, 'w') as f:
default_config = {
"name": "Default Nexus Server",
"max_user": 15,
"config": {
"last_saved_user_id": "id"
}
}
json.dump(default_config, f, indent=4)
if hasattr(PromptServer, 'instance'):
server = PromptServer.instance
routes = web.RouteTableDef()
socket_manager = WebSocketManager()
async def broadcast_message(message, sender):
for id, socket in socket_manager.sockets.items():
if id != sender:
try:
await socket.send_str(json.dumps(message))
except Exception as e:
logging.error(f"[ComfyUI-Nexus]: Error sending message to {id}: {e}")
async def broadcast_message_except(message, excluder):
for id, socket in socket_manager.sockets.items():
if id != excluder:
try:
await socket.send_str(json.dumps(message))
except Exception as e:
logging.error(f"[ComfyUI-Nexus]: Error sending message to {id}: {e}")
async def broadcast_message_all(message):
for id, socket in socket_manager.sockets.items():
try:
await socket.send_str(json.dumps(message))
except Exception as e:
logging.error(f"[ComfyUI-Nexus]: Error sending message to {id}: {e}")
async def send_message(message, sender, receiver):
for id, socket in socket_manager.sockets.items():
if id == receiver and sender != receiver:
try:
await socket.send_str(json.dumps(message))
except Exception as e:
logging.error(f"[ComfyUI-Nexus]: Error sending message to {id}: {e}")
@routes.get("/nexus")
async def websocket_handler(request):
id = request.query.get("id")
socket = web.WebSocketResponse()
await socket.prepare(request)
socket_manager.set(id, socket)
logging.info(f"[ComfyUI-Nexus]: Socket {id} connected")
try:
async for msg in socket:
if msg.type == web.WSMsgType.TEXT:
data = json.loads(msg.data)
event_name = data.get('name')
event_data = data.get('data')
sender = data.get("from")
all = data.get('all')
receiver = data.get('receiver')
exclude = data.get('exclude')
# logging.info(f"[ComfyUI-Nexus]: Received {sender} {all} {receiver} {exclude}")
if event_name == "chat-join":
user_id = data.get("from")
user_name = event_data.get("name")
with open(NAMES_FILE, 'r+') as f:
names = json.load(f)
names[user_id] = user_name
f.seek(0)
json.dump(names, f, indent=4)
f.truncate()
if event_name == "nick":
user_id = data.get("from")
user_name = event_data.get("new_name")
with open(NAMES_FILE, 'r+') as f:
names = json.load(f)
names[user_id] = user_name
f.seek(0)
json.dump(names, f, indent=4)
f.truncate()
if all:
await broadcast_message_all(data)
elif receiver:
await send_message(data, sender, receiver)
elif exclude:
await broadcast_message_except(data, exclude)
else:
await broadcast_message(data, sender)
elif msg.type == web.WSMsgType.ERROR:
logging.error(f"[ComfyUI-Nexus]: WebSocket error: {msg.exception()}")
except Exception as e:
logging.error(f"[ComfyUI-Nexus]: WebSocket error: {e}")
finally:
logging.info(f"[ComfyUI-Nexus]: Socket {id} disconnected")
socket_manager.delete(id)
return socket
@routes.post("/nexus/workflows")
async def get_user_specific_workflows(request):
user_id = (await request.json()).get('user_id')
if not user_id:
return web.json_response({'error': 'User ID required'}, status=400)
with open(PERMISSIONS_FILE, 'r') as f:
permissions = json.load(f)
user_permissions = permissions.get(user_id, {})
if not user_permissions.get('editor', False):
return web.json_response({'error': 'Forbidden'}, status=403)
user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id)
if not os.path.exists(user_workflow_dir):
return web.json_response({'error': 'User not found'}, status=404)
workflows = {}
for file_name in sorted([f for f in os.listdir(user_workflow_dir) if f.isdigit()], key=int):
file_path = os.path.join(user_workflow_dir, file_name)
with open(file_path, 'r') as f:
workflows[file_name] = json.load(f)
for file_name in sorted([f for f in os.listdir(user_workflow_dir) if not f.isdigit()]):
file_path = os.path.join(user_workflow_dir, file_name)
with open(file_path, 'r') as f:
workflows[file_name] = json.load(f)
return web.json_response(workflows)
@routes.post("/nexus/workflows/meta")
async def get_user_specific_workflows_meta(request):
user_id = (await request.json()).get('user_id')
if not user_id:
return web.json_response({'error': 'User ID required'}, status=400)
with open(PERMISSIONS_FILE, 'r') as f:
permissions = json.load(f)
user_permissions = permissions.get(user_id, {})
if not user_permissions.get('editor', False):
return web.json_response({'error': 'Forbidden'}, status=403)
user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id)
if not os.path.exists(user_workflow_dir):
return web.json_response({'error': 'User not found'}, status=404)
meta_data = []
for file_name in sorted([f for f in os.listdir(user_workflow_dir) if f.isdigit()], key=int):
file_path = os.path.join(user_workflow_dir, file_name)
last_modified_time = os.path.getmtime(file_path)
last_modified_readable = datetime.datetime.fromtimestamp(last_modified_time).strftime('%Y-%m-%d %H:%M:%S')
meta_data.append({
'file_name': file_name,
'last_saved': last_modified_readable
})
for file_name in sorted([f for f in os.listdir(user_workflow_dir) if not f.isdigit()]):
file_path = os.path.join(user_workflow_dir, file_name)
last_modified_time = os.path.getmtime(file_path)
last_modified_readable = datetime.datetime.fromtimestamp(last_modified_time).strftime('%Y-%m-%d %H:%M:%S')
meta_data.append({
'file_name': file_name,
'last_saved': last_modified_readable
})
return web.json_response(meta_data)
@routes.post("/nexus/workflows/{id}")
async def get_user_specific_workflow(request):
user_id = (await request.json()).get('user_id')
workflow_id = request.match_info.get('id')
if not user_id:
return web.json_response({'error': 'User ID required'}, status=400)
if not workflow_id:
return web.json_response({'error': 'Workflow ID required'}, status=400)
with open(PERMISSIONS_FILE, 'r') as f:
permissions = json.load(f)
user_permissions = permissions.get(user_id, {})
if not user_permissions.get('editor', False):
return web.json_response({'error': 'Forbidden'}, status=403)
user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id)
if not os.path.exists(user_workflow_dir):
return web.json_response({'error': 'User not found'}, status=404)
workflow_file_path = os.path.join(user_workflow_dir, workflow_id)
if not os.path.exists(workflow_file_path):
return web.json_response({'error': 'Workflow not found'}, status=404)
with open(workflow_file_path, 'r') as f:
workflow = json.load(f)
return web.json_response(workflow)
@routes.post("/nexus/workflow")
async def post_workflow(request):
user_id = (await request.json()).get('user_id')
if not user_id:
return web.json_response({'error': 'User ID required'}, status=400)
with open(PERMISSIONS_FILE, 'r') as f:
permissions = json.load(f)
user_permissions = permissions.get(user_id, {})
if not user_permissions.get('editor', False):
return web.json_response({'error': 'Forbidden'}, status=403)
new_data = await request.json()
user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id)
if not os.path.exists(user_workflow_dir):
os.makedirs(user_workflow_dir)
existing_files = sorted([f for f in os.listdir(user_workflow_dir) if f.isdigit()], key=int)
if len(existing_files) == 5:
os.remove(os.path.join(user_workflow_dir, existing_files[0]))
existing_files.pop(0)
for i, file_name in enumerate(existing_files):
new_name = str(i + 1)
shutil.move(os.path.join(user_workflow_dir, file_name), os.path.join(user_workflow_dir, new_name))
new_file_path = os.path.join(user_workflow_dir, '5')
with open(new_file_path, 'w') as f:
json.dump(new_data, f, indent=4)
# Update the server.json file with the last saved user_id
with open(SERVER_FILE, 'r+') as f:
server_config = json.load(f)
server_config['config']['last_saved_user_id'] = user_id
f.seek(0)
json.dump(server_config, f, indent=4)
f.truncate()
return web.json_response({'status': 'success'}, status=200)
@routes.post("/nexus/workflow/backup")
async def post_workflow(request):
user_id = (await request.json()).get('user_id')
if not user_id:
return web.json_response({'error': 'User ID required'}, status=400)
with open(PERMISSIONS_FILE, 'r') as f:
permissions = json.load(f)
user_permissions = permissions.get(user_id, {})
if not user_permissions.get('editor', False):
return web.json_response({'error': 'Forbidden'}, status=403)
new_data = await request.json()
user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id)
if not os.path.exists(user_workflow_dir):
os.makedirs(user_workflow_dir)
new_file_path = os.path.join(user_workflow_dir, f'lt_{round(time.time() * 1000)}')
with open(new_file_path, 'w') as f:
json.dump(new_data, f, indent=4)
return web.json_response({'status': 'success'}, status=200)
@routes.get("/nexus/workflow")
async def get_latest_workflow(request):
with open(SERVER_FILE, 'r') as f:
server_config = json.load(f)
last_saved_user_id = server_config['config'].get('last_saved_user_id')
if not last_saved_user_id:
return web.json_response({'error': 'No workflow saved'}, status=404)
user_workflow_dir = os.path.join(WORKFLOWS_DIR, last_saved_user_id)
if not os.path.exists(user_workflow_dir):
return web.json_response({'error': 'User not found'}, status=404)
last_file_path = os.path.join(user_workflow_dir, '5')
if not os.path.exists(last_file_path):
return web.json_response({'error': 'Workflow not found'}, status=404)
with open(last_file_path, 'r') as f:
workflow_data = json.load(f)
return web.json_response(workflow_data)
@routes.get("/nexus/permission/{id}")
async def get_permission_by_id(request):
user_id = request.match_info['id']
with open(PERMISSIONS_FILE, 'r') as f:
data = json.load(f)
user_permission = data.get(user_id)
if user_permission:
return web.json_response(user_permission)
else:
with open(PERMISSIONS_FILE, 'r+') as f:
permissions = json.load(f)
permissions[user_id] = {'queue': False, 'editor': False, 'admin': False}
f.seek(0)
json.dump(permissions, f)
f.truncate()
user_permission = permissions[user_id]
return web.json_response(user_permission)
@routes.post("/nexus/permission/{id}")
async def post_permission_by_id(request):
user_id = request.match_info['id']
data = await request.json()
admin_id = data.get('admin_id')
if not admin_id:
return web.json_response({'error': 'Admin ID required'}, status=400)
token = request.headers.get('Authorization')
if not token:
return web.json_response({'error': 'Authorization token required'}, status=400)
with open(TOKENS_FILE, 'r') as f:
tokens = json.load(f)
if tokens.get(token) != admin_id:
return web.json_response({'error': 'Invalid or expired token'}, status=403)
new_permission = data.get('data')
if not new_permission:
return web.json_response({'error': 'Permission data required'}, status=400)
with open(PERMISSIONS_FILE, 'r+') as f:
permissions = json.load(f)
if not permissions.get(admin_id, {}).get('admin', False):
return web.json_response({'error': 'Forbidden'}, status=403)
# Merge the new permissions with the existing ones
existing_permissions = permissions.get(user_id, {})
existing_permissions.update(new_permission)
# Save the updated permissions
permissions[user_id] = existing_permissions
f.seek(0)
json.dump(permissions, f, indent=4)
f.truncate()
return web.json_response({'status': 'success'}, status=200)
@routes.post("/nexus/login")
async def post_login(request):
credentials = await request.json()
user_id = credentials.get('uuid')
account = credentials.get('account')
password = credentials.get('password')
with open(ADMINS_FILE, 'r') as f:
admins = json.load(f)
if admins.get(account) == password:
token = str(uuid.uuid4())
with open(TOKENS_FILE, 'r+') as f:
tokens = json.load(f)
tokens[token] = user_id
f.seek(0)
json.dump(tokens, f, indent=4)
f.truncate()
with open(PERMISSIONS_FILE, 'r+') as f:
permissions = json.load(f)
permissions[user_id] = {'editor': True, 'admin': True}
f.seek(0)
json.dump(permissions, f, indent=4)
f.truncate()
return web.json_response({'token': token}, status=200)
else:
return web.json_response({'error': 'Invalid credentials'}, status=401)
@routes.post("/nexus/verify")
async def post_verify(request):
credentials = await request.json()
token = credentials.get('token')
with open(TOKENS_FILE, 'r') as f:
tokens = json.load(f)
user_id = tokens.get(token)
if user_id:
with open(PERMISSIONS_FILE, 'r') as f:
permissions = json.load(f)
user_permissions = permissions.get(user_id, {})
return web.json_response({'user_id': user_id, 'permissions': user_permissions}, status=200)
else:
return web.json_response({'error': 'Invalid token'}, status=401)
@routes.get("/nexus/name/{id}")
async def get_name_by_id(request):
user_id = request.match_info['id']
with open(NAMES_FILE, 'r') as f:
names = json.load(f)
user_name = names.get(user_id)
if user_name:
return web.json_response({'name': user_name})
else:
return web.json_response({'error': 'User not found'}, status=404)
server.app.add_routes(routes)
from .rigshare_core.store import Store
from .rigshare_core.hub import Hub
from .rigshare_core import routes
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
WEB_DIRECTORY = "./web"
def data_folder():
return os.environ.get("RIGSHARE_DATA_DIR") or os.path.join(folder_paths.base_path, "rigshare")
if hasattr(PromptServer, "instance"):
try:
folder = data_folder()
os.makedirs(folder, exist_ok=True)
store = Store(folder)
hub = Hub(store, PromptServer.instance)
routes.setup(PromptServer.instance, store, hub)
logging.info(f"[RigShare] ready, data in {folder}")
except Exception as e:
logging.exception(f"[RigShare] failed to start: {e}")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
-13
View File
@@ -1,13 +0,0 @@
class WebSocketManager:
def __init__(self):
self.sockets = {}
def get(self, id):
return self.sockets.get(id)
def set(self, id, ws):
self.sockets[id] = ws
def delete(self, id):
if id in self.sockets:
del self.sockets[id]
+43
View File
@@ -0,0 +1,43 @@
# Accounts and permissions
## Permissions
| Permission | Allows |
|---|---|
| *(none)*, a viewer | Watch shared workflows, chat, follow people, open snapshots as private copies |
| **Edit** | Change shared workflows, save or rename workflow files, take and restore snapshots, upload files |
| **Queue** | Queue prompts, interrupt, free memory |
| **Manager** | Open ComfyUI Manager: install, update or remove custom nodes and models, snapshots, restart |
| **Admin** | Everything, plus deleting workflow files, removing shared workflows, accounts and server settings |
Queueing, uploads, and saving or deleting workflow files are checked by the server too, when *Enforce permissions on the ComfyUI API* is on (the default). A viewer cannot get around it with a script.
## Managing accounts
Admins use **Admin → Accounts** in the RigShare panel:
- **New account**: username, optional display name, password, permissions
- toggle **Edit / Queue / Manager / Admin** per account; the change applies immediately, with no re-login needed
- 🪪 change the display name shown to others
- ✏️ change the login username, your own included. Sessions and API keys keep working.
- 🔑 set a new password, which signs that user out everywhere
- 🗑 delete the account
Admins can also toggle Edit, Queue and Manager straight from the **People** tab.
A shared workflow can also be restricted to chosen people, with *Can view* or *Can edit* each. See [Who can open it](sharing.md#who-can-open-it).
The last admin cannot be demoted or deleted.
## Your own account
Click your avatar at the top of the panel:
- change your display name
- change your password; your other sessions are signed out, this one stays signed in
- create and revoke [API keys](api.md)
- log out
## Guests
With **Require login** off, *Allow guests* lets people in without an account. They appear as *Guest-xxxx* and get the guest permissions from `config.json`. That suits a trusted LAN only; the default is to require a login.
+55
View File
@@ -0,0 +1,55 @@
# API access
With RigShare installed, the ComfyUI API requires authentication too. Calls without it get:
```json
{"error": "RigShare: login required. API clients: send 'Authorization: Bearer <api key>'."}
```
## API keys
Create one under your avatar → **API keys**. The key (`rs_…`) is shown once, so copy it then. It acts with your permissions: a key from an account without *Queue* cannot queue.
Send it as a header:
```bash
curl -H "Authorization: Bearer rs_yourkey" http://your-server:8188/api/queue
curl -X POST http://your-server:8188/api/prompt \
-H "Authorization: Bearer rs_yourkey" \
-H "Content-Type: application/json" \
-d @prompt.json
```
`X-RigShare-Token: rs_yourkey` works too. For websockets, where some clients cannot set headers, add it to the URL:
```
ws://your-server:8188/ws?clientId=my-script&token=rs_yourkey
```
The key list shows when each key was last used. Revoke keys you no longer need.
## Other apps
Apps that talk to ComfyUI need a key once RigShare is installed. Recent versions of Open WebUI, for example, have a *ComfyUI API Key* field in their image settings and send it as `Authorization: Bearer`. Create a key for it, ideally on an account with only the *Queue* permission.
## Trusted IPs
Requests from **trusted IPs** skip the login and the permission checks entirely. The default is `127.0.0.1` and `::1`. You can add addresses or networks (`172.17.0.0/16`) under **Admin → Server settings**, but prefer API keys: a trusted IP is trusted for everything.
## RigShare's own endpoints
The panel uses these; they take the same session cookie or API key.
| Endpoint | |
|---|---|
| `GET /rigshare/api/status` | Server name and whether setup is needed (public) |
| `GET /rigshare/api/me` | The current account |
| `GET/POST /rigshare/api/me/keys`, `DELETE /rigshare/api/me/keys/{id}` | Your API keys |
| `GET /rigshare/api/rooms` | Shared workflows |
| `GET /rigshare/api/room?key=…` | One room, with its workflow JSON |
| `GET /rigshare/api/snapshots?room=…` | History of a room |
| `GET/PUT /rigshare/api/room/acl?key=…` | Who can open a room (owner or admin) |
| `GET /rigshare/api/people` | Accounts that can be added to an access list |
| `GET/POST/PATCH/DELETE /rigshare/api/users…` | Accounts (admin) |
| `GET/PATCH /rigshare/api/config` | Server settings (admin) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+64
View File
@@ -0,0 +1,64 @@
# Installing
## As a custom node
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/xiaodoudou/ComfyUI-RigShare.git
```
Restart ComfyUI. The console prints:
```
[RigShare] No accounts yet: open ComfyUI in a browser to create the admin account.
[RigShare] ready, data in /path/to/ComfyUI/rigshare
```
## First run
Open ComfyUI in a browser. While no account exists, the login page shows **Create admin account**. Pick a username and a password of at least 8 characters, and you are signed in as the administrator. The server accepts this only while there are no accounts, so once the first admin exists, the form is gone for good.
Do this right after installing. Until an admin exists, whoever reaches the page first gets to create one.
For scripted installs you can skip the form by setting these before the first start:
```bash
RIGSHARE_ADMIN_USER=admin
RIGSHARE_ADMIN_PASSWORD=a-long-password
```
They are only read when there are no accounts, so remove them afterwards.
## Docker / compose
Mount the node and a data folder:
```yaml
services:
comfyui:
volumes:
- ./custom_nodes/ComfyUI-RigShare:/app/custom_nodes/ComfyUI-RigShare
- ./rigshare:/app/rigshare
```
The data folder is `<ComfyUI base path>/rigshare`; point it elsewhere with `RIGSHARE_DATA_DIR`. It holds:
| File | What it is |
|---|---|
| `users.json` | Accounts: password hashes, permissions, API key hashes |
| `sessions.json` | Active logins (session ids, not tokens) |
| `secret.key` | Key that signs session tokens. Keep it private |
| `config.json` | Server settings, see [Settings](settings.md) |
| `rooms/` | Current state of every shared workflow |
| `snapshots/` | Workflow history |
| `chat.json` | Recent chat |
Back up the folder to keep accounts and history. Deleting `secret.key` signs everyone out.
## Upgrading from ComfyUI-Nexus
Remove or disable `custom_nodes/ComfyUI-Nexus` and install RigShare. Nexus kept plaintext passwords in `nexus/admins.json`, so nothing is imported: create the admin on first run, then delete the old `nexus` folder.
## Removing
Delete `custom_nodes/ComfyUI-RigShare` and restart. ComfyUI goes back to having no login. The `rigshare` data folder can be deleted too.
+43
View File
@@ -0,0 +1,43 @@
# Reverse proxy
RigShare works behind a reverse proxy on its own (sub)domain. Two things matter.
## 1. WebSockets
The panel, the live sync and ComfyUI's own progress all run over WebSockets. Without them the panel sits on *Connecting… / Offline*, and the server log says:
```
[RigShare] WebSocket request from … arrived without 'Upgrade: websocket'. If ComfyUI is behind a reverse proxy, enable WebSocket support there
```
**Nginx Proxy Manager**: edit the proxy host and turn on **Websockets Support**.
**nginx**:
```nginx
location / {
proxy_pass http://comfyui:8188;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
client_max_body_size 500M;
}
```
## 2. HTTPS
Serve it over HTTPS. On plain HTTP, passwords and session cookies cross the network unencrypted. When the proxy sends `X-Forwarded-Proto: https`, the session cookie gets the `Secure` flag automatically.
Once the proxy is in place, stop publishing ComfyUI's port directly, so all traffic goes through HTTPS.
## Sub-paths
RigShare expects to be served at the root of its host (`https://comfy.example.com/`), not under a path like `/comfy/`.
## Trusted IPs behind a proxy
Behind a proxy, every request comes from the proxy's address. Do not add the proxy's IP to the trusted IPs, or everyone would skip the login.
+42
View File
@@ -0,0 +1,42 @@
# Security
What RigShare does to keep the rig yours, and what it leaves to you.
## Passwords
- Stored hashed (PBKDF2-SHA256, salted), never in plain text.
- At least 8 characters.
- Unknown usernames take as long to reject as wrong passwords, so accounts cannot be discovered by timing.
- Five failed logins from one address in a minute blocks that address for a minute.
- There are no default accounts. The first admin is created on first run, and only while no account exists.
## Sessions
- Signing in issues a **JWT** (HS256), signed with a 64-byte random key generated on first start (`secret.key`, file mode 600).
- Tokens live 12 hours and are silently renewed while in use. A login lasts at most 30 days.
- Each token names a server-side session. Logging out, changing a password or deleting an account ends access immediately, instead of whenever the token expires.
- Only HS256 is accepted, so `alg: none` and algorithm-swapping tokens are rejected. Signature, expiry, not-before and issuer are all checked.
- The token travels in an `HttpOnly`, `SameSite=Lax` cookie, `Secure` over HTTPS. Page scripts cannot read it, and other sites cannot make your browser use it for changes.
## API keys
- Random 256-bit keys, stored only as SHA-256 hashes and shown once when created.
- They carry their owner's permissions, can be revoked at any time, and record when they were last used.
## Permissions
- The server checks identity and permissions on every websocket message and API call; the UI only mirrors them. A modified browser cannot impersonate someone else or edit without *Edit*.
- ComfyUI's queue, interrupt, free and upload endpoints are checked server-side, and so are saving (Edit) and deleting (Admin) workflow files (`api_protection`).
- Without *Admin*, account and server settings are refused.
- Without *Manager*, ComfyUI Manager's API is refused and its button hidden. Only a few read-only lookups the frontend makes for everyone stay open.
- A restricted workflow is refused to anyone not on its list: joining it, reading it, its snapshots, and its file through ComfyUI's API.
## What is up to you
- **Use HTTPS.** Over plain HTTP, the login and cookie cross the network in the clear. See [Reverse proxy](reverse-proxy.md).
- **Finish setup right away.** Until the first admin exists, anyone who reaches the page can create it.
- **Keep trusted IPs short.** They skip every check.
- **Custom nodes run code on the server.** RigShare controls who uses ComfyUI, but anyone who can queue can run whatever nodes are installed. Give *Queue* to people you trust.
- **Protect the data folder.** `secret.key` signs sessions and `users.json` holds the hashes.
Found a problem? Please open an issue, or for anything sensitive contact the maintainer privately first.
+45
View File
@@ -0,0 +1,45 @@
# Settings
## Your settings
In ComfyUI's settings dialog, under **RigShare**. They apply to your browser only.
| Setting | Default |
|---|---|
| Share saved workflows automatically | on |
| Share unsaved tabs automatically once edited | on |
| Show other people's cursors and selections | on |
| Show names next to cursors | on |
| Play a sound for new chat messages | on |
| Show a notification for new chat messages | off |
Commands, bindable under *Keybindings*: **RigShare: Open chat** and **RigShare: Share this tab**.
The **Server** tab's *Pop out* button toggles the floating monitor. Drag it by its title; its position is remembered.
## Server settings
Admins change these under **Admin → Server settings**. They are stored in `rigshare/config.json`.
| Key | Default | |
|---|---|---|
| `server_name` | `RigShare` | Shown on the login page and in the panel |
| `require_login` | `true` | Sign in before ComfyUI loads; API clients use keys |
| `allow_guests` | `false` | Only with `require_login` off: allow visitors without an account |
| `guest_permissions` | `{"edit": false, "queue": false}` | What guests may do |
| `broadcast_execution` | `true` | Send progress and previews to everyone, not only whoever queued |
| `hide_comfy_account` | `false` | Hide ComfyUI's Comfy.org sign-in button and dialog |
| `hide_api_templates` | `false` | Remove templates that need paid API nodes from the template browser |
| `api_protection.enabled` | `true` | Server-side checks on queue, interrupt, free and upload calls |
| `api_protection.trusted_ips` | `["127.0.0.1", "::1"]` | Addresses or networks that skip login and checks |
| `room_expiry_days` | `14` | Forget shared workflows nobody opened for this long |
| `snapshot_interval_sec` | `300` | Automatic snapshot interval while editing; `0` turns it off |
| `snapshot_keep` | `30` | Automatic snapshots kept per workflow; named ones are always kept |
| `chat_history` | `200` | Chat messages kept |
## Environment variables
| Variable | |
|---|---|
| `RIGSHARE_DATA_DIR` | Data folder, instead of `<ComfyUI>/rigshare` |
| `RIGSHARE_ADMIN_USER`, `RIGSHARE_ADMIN_PASSWORD` | Create the first admin without the setup page; only read while no account exists |
+64
View File
@@ -0,0 +1,64 @@
# Sharing workflows
Every shared workflow is a *room* on the server. The server keeps the current version of each room and relays edits between everyone who has it open.
## Which tabs are shared
| Tab | Shared? |
|---|---|
| A saved workflow file | Yes, automatically. Everyone who opens the same file lands in the same room. |
| An unsaved tab | As soon as you edit it, or when you click **Share this tab**. Untouched tabs, such as the default workflow or tabs restored from your last visit, stay private. |
| A snapshot opened as a copy | No, it is a private copy. |
Both automatic behaviours can be turned off in ComfyUI's settings under *RigShare*.
Only the tab on screen is live. Switch to another tab and you leave the room; switch back and you rejoin. If the room changed meanwhile, you get the current version.
## The panel
**Workflows** shows the tab on screen: whether it is live, who else is in it, and buttons for **Snapshot**, **History** and **Stop sharing**. Below that is the list of shared workflows, with the people in each one. Open any of them with the arrow.
**People** lists who is online and which workflow they are on. The eye button *follows* someone: it jumps to their tab and keeps your view on theirs until you click or scroll the canvas.
## What syncs, and how
- Moving and resizing nodes, widget values, titles, colours and bypass/mute apply in place for everyone.
- Adding or removing nodes, links, groups or subgraphs reloads the tab for the others. Their viewport is kept.
- A node someone is typing into is left alone until they are done.
- Two people editing the same node at once: the last change wins.
- Two people adding or connecting nodes at the same moment both keep their work. If both picked the same node or link id, the server renumbers the later one and quietly resyncs that person. Links and groups merge one by one, so a connection made by one person never erases another's.
Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back.
## Stop sharing
**Stop sharing** takes the tab out of its room and keeps working on a private copy.
- For an **admin**, if nobody else is in the room, it is also removed from the shared list, and a *Before unshare* snapshot is kept in its history.
- Otherwise, including for everyone who is not an admin, you just leave. It stays shared for the others.
Only admins can remove a shared workflow. Workflows nobody has open show a 🗑 button for them in the list. For a saved file, stopping sharing only applies to you. Anyone else who opens the file shares it again, unless they turned off automatic sharing.
Rooms nobody opens for 14 days are forgotten (`room_expiry_days`).
## Who can open it
By default everyone with an account can open a shared workflow, with their usual permissions. The **Access** button under *This tab* lets its owner, whoever shared it first, or any admin choose *Only people I choose* and give each person **Can view** or **Can edit**.
- The owner and admins always have access.
- A role can only narrow someone's account permissions: *Can edit* does nothing for an account without *Edit*.
- People who lose access are taken out of the workflow straight away; their tab stays open as a private copy.
- Without access, the workflow is gone from their list and from the People tab, and they cannot open it, its snapshots, or its file through ComfyUI.
- A 🔒 marks restricted workflows in the list.
Saved workflow files still show up by name in ComfyUI's workflow browser; opening one without access is refused.
## Snapshots
- Automatic every 5 minutes while a workflow is being edited (`snapshot_interval_sec`). The last 30 are kept (`snapshot_keep`).
- **Snapshot** saves a named one, which is never rotated out.
- **History** lists them. ↗ opens a private copy; ⟲ restores it for everyone, after first saving the current state as a snapshot.
## Saving
Shared state lives on the server, so an edited workflow survives even if nobody saves it. Saving (Ctrl+S) still writes the file as usual. The tab may look modified to others until they save or reload.
+11 -8
View File
@@ -1,15 +1,18 @@
[project]
name = "comfyui-nexus"
description = "Node to enable seamless multiuser workflow collaboration"
version = "1.0.2"
name = "comfyui-rigshare"
description = "Multi-user collaboration for ComfyUI: accounts and login, live-shared workflows, cursors, chat, snapshots and a server monitor."
version = "1.0.0"
license = { file = "LICENSE" }
dependencies = []
# Continued from https://github.com/daxcay/ComfyUI-Nexus (MIT).
[project.urls]
Repository = "https://github.com/daxcay/ComfyUI-Nexus"
# Used by Comfy Registry https://comfyregistry.org
Repository = "https://github.com/xiaodoudou/ComfyUI-RigShare"
Documentation = "https://github.com/xiaodoudou/ComfyUI-RigShare#documentation"
"Bug Tracker" = "https://github.com/xiaodoudou/ComfyUI-RigShare/issues"
[tool.comfy]
PublisherId = "daxcay"
DisplayName = "ComfyUI-Nexus"
Icon = "https://raw.githubusercontent.com/daxcay/ComfyUI-Nexus/master/web/assets/img/logo.png"
PublisherId = ""
DisplayName = "ComfyUI RigShare"
Icon = "https://raw.githubusercontent.com/xiaodoudou/ComfyUI-RigShare/master/web/rigshare/assets/icon.png"
+210
View File
@@ -0,0 +1,210 @@
"""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
+526
View File
@@ -0,0 +1,526 @@
"""Real-time hub: presence, chat, cursors, server stats and shared rooms.
Every workflow tab that is shared maps to a *room* (a saved workflow file is
the room ``file:<path>``; a shared unsaved tab is ``tmp:<id>``). The server
holds the authoritative document of each room and relays patches between the
browsers that currently have that room on screen.
Clients identify once (session token or guest id); from then on the server
stamps relayed messages with the sender identity and checks permissions.
"""
import asyncio
import hashlib
import json
import logging
import secrets
import time
from collections import deque
from aiohttp import web, WSMsgType
from .graphdoc import apply_patch, remap_conflicts
from .stats import collect_stats
log = logging.getLogger("ComfyUI-RigShare")
COLORS = ["#f87171", "#fb923c", "#facc15", "#4ade80", "#2dd4bf", "#38bdf8",
"#818cf8", "#c084fc", "#f472b6", "#a3e635", "#fbbf24", "#22d3ee"]
MAX_MESSAGE = 16 * 1024 * 1024
def clean_text(text, limit):
text = "".join(ch for ch in str(text or "") if ch == "\n" or ch >= " ")
return text.strip()[:limit]
ROLES = ("view", "edit")
class Room:
def __init__(self, key, name, kind, doc, version=0, updated=None, owner=None, acl=None):
self.key = key
# Username of whoever first shared it; may manage its access list.
self.owner = owner
# None: everyone with an account can open it. Otherwise
# {"members": {username: "view" | "edit"}}: only those people (plus
# the owner and admins). A room role can only narrow account permissions.
self.acl = acl
self.name = name
self.kind = kind
self.doc = doc
self.version = version
self.updated = updated or time.time()
self.dirty = False
self.snapshot_dirty = False
self.last_snapshot = time.time()
self.last_editor = None
def to_file(self):
return {"key": self.key, "name": self.name, "kind": self.kind, "doc": self.doc,
"version": self.version, "updated": self.updated, "owner": self.owner, "acl": self.acl}
class Client:
def __init__(self, ws):
self.ws = ws
self.id = secrets.token_hex(6)
self.kind = None # "user" | "guest"
self.key = None # username or guest id
self.token = None
self.guest_id = None
self.guest_name = None
self.name = "Guest"
self.color = COLORS[0]
self.perms = {"edit": False, "queue": False, "manager": False, "admin": False}
self.joined = time.time()
self.idle = False
self.room = None
def public(self):
return {"id": self.id, "kind": self.kind, "key": self.key if self.kind == "user" else None,
"guest_id": self.key if self.kind == "guest" else None,
"name": self.name, "color": self.color, "perms": self.perms,
"joined": int(self.joined), "idle": self.idle, "room": self.room}
class Hub:
def __init__(self, store, server=None):
self.store = store
self.server = server
self.clients = {}
self.rooms = {}
for data in store.load_rooms():
try:
self.rooms[data["key"]] = Room(data["key"], data.get("name") or data["key"], data.get("kind", "file"),
data.get("doc"), data.get("version", 0), data.get("updated"),
data.get("owner"), data.get("acl"))
except (KeyError, TypeError):
continue
self.chat = deque(maxlen=int(store.config.get("chat_history", 200)))
for msg in store._read("chat.json", []):
self.chat.append(msg)
self.chat_dirty = False
self.stats = None
self._task = None
# ----- lifecycle ----------------------------------------------------
def start(self, loop):
if not self._task:
self._task = loop.create_task(self._housekeeping())
loop.create_task(self._stats_loop())
async def _housekeeping(self):
while True:
await asyncio.sleep(2)
try:
now = time.time()
interval = int(self.store.config.get("snapshot_interval_sec", 300))
for room in list(self.rooms.values()):
if room.dirty:
self.store.save_room(room.to_file())
room.dirty = False
if room.snapshot_dirty and interval > 0 and now - room.last_snapshot >= interval:
self.snapshot(room, room.last_editor or "auto")
expiry = float(self.store.config.get("room_expiry_days", 14)) * 86400
for room in list(self.rooms.values()):
if expiry > 0 and now - room.updated > expiry and not self.members(room.key):
self.rooms.pop(room.key, None)
self.store.delete_room(room.key)
if self.chat_dirty:
self.store._write("chat.json", list(self.chat))
self.chat_dirty = False
except Exception as e:
log.error(f"[RigShare] housekeeping error: {e}")
async def _stats_loop(self):
loop = asyncio.get_running_loop()
while True:
await asyncio.sleep(2)
if not self.clients:
continue
try:
self.stats = await loop.run_in_executor(None, collect_stats, self.server)
await self.broadcast({"type": "stats", "stats": self.stats})
except Exception as e:
log.debug(f"[RigShare] stats error: {e}")
def snapshot(self, room, author, label=None):
if room.doc is None:
return None
room.last_snapshot = time.time()
room.snapshot_dirty = False
return self.store.add_snapshot(room.doc, author, room.key, room.name, label)
# ----- identity -----------------------------------------------------
def _color_for(self, key):
return COLORS[int(hashlib.md5(key.encode()).hexdigest(), 16) % len(COLORS)]
def identify(self, client, token, guest_id, name):
client.guest_id = clean_text(guest_id, 64) or client.guest_id or secrets.token_hex(8)
client.guest_name = clean_text(name, 24) or client.guest_name
user = self.store.user_for_token(token)
if not user and getattr(client, "cookie_token", None):
token = client.cookie_token
user = self.store.user_for_token(token)
if user:
client.kind, client.key, client.token = "user", user["username"], token
client.name = user.get("display_name") or user["username"]
client.perms = dict(user["perms"])
if client.perms.get("admin"):
client.perms.update(edit=True, queue=True, manager=True)
elif self.store.config.get("allow_guests", True) and not self.store.config.get("require_login"):
client.kind, client.key, client.token = "guest", client.guest_id, None
client.name = client.guest_name or f"Guest-{client.key[:4]}"
client.perms = self.store.guest_perms(client.key)
else:
return False
client.color = self._color_for(f"{client.kind}:{client.key}")
return True
def refresh_identities(self, key=None):
"""Re-read accounts/guest perms after an admin change and notify clients."""
return asyncio.gather(*[self._refresh(c) for c in list(self.clients.values())
if key is None or c.key == key])
async def _refresh(self, client):
before = (client.kind, client.name, json.dumps(client.perms, sort_keys=True))
if client.kind == "user" and not self.store.user_for_token(client.token):
await self.send(client, {"type": "logged_out"})
if not self.identify(client, client.token, None, None):
await self.send(client, {"type": "denied", "reason": "Please log in.", "login": True})
await client.ws.close()
return
after = (client.kind, client.name, json.dumps(client.perms, sort_keys=True))
if before != after:
await self.send(client, {"type": "self", "self": client.public()})
room = self.rooms.get(client.room) if client.room else None
if room and self.client_access(room, client) is None:
client.room = None
await self.send(client, {"type": "room_denied", "room": room.key, "name": room.name})
elif room:
await self.send(client, {"type": "room_info", "room": room.key, **self.room_info(room, client)})
await self.broadcast_presence()
# ----- messaging ----------------------------------------------------
async def send(self, client, message):
try:
await client.ws.send_str(json.dumps(message))
except Exception as e:
log.debug(f"[RigShare] send to {client.id} failed: {e}")
async def broadcast(self, message, exclude=None, room=None):
data = json.dumps(message)
for client in list(self.clients.values()):
if client is exclude or client.kind is None:
continue
if room is not None and client.room != room:
continue
try:
await client.ws.send_str(data)
except Exception:
pass
def drop_if_unused(self, key):
"""Forget an unsaved-tab room nobody edited once its last member leaves."""
room = self.rooms.get(key)
if room and room.kind == "tmp" and room.version == 0 and not self.members(key):
self.rooms.pop(key, None)
self.store.delete_room(key)
def remove_room(self, key, by):
"""Stop sharing a workflow nobody has open. Snapshots are kept."""
room = self.rooms.get(key)
if room is None or self.members(key):
return False
if room.doc is not None and room.version > 0:
self.snapshot(room, by, "Before unshare")
self.rooms.pop(key, None)
self.store.delete_room(key)
return True
def members(self, key):
return [c for c in self.clients.values() if c.kind and c.room == key]
# ----- access lists -------------------------------------------------
@staticmethod
def access(room, kind, key, perms):
"""What someone may do in a room: None (no access), "view" or "edit"."""
can_edit = bool(perms.get("edit") or perms.get("admin"))
if perms.get("admin") or not room.acl:
return "edit" if can_edit else "view"
if kind == "user" and key == room.owner:
return "edit" if can_edit else "view"
role = (room.acl.get("members") or {}).get(key) if kind == "user" else None
if role not in ROLES:
return None
return "edit" if role == "edit" and can_edit else "view"
@staticmethod
def can_manage(room, kind, key, perms):
return bool(perms.get("admin")) or (kind == "user" and key is not None and key == room.owner)
def client_access(self, room, client):
return self.access(room, client.kind, client.key, client.perms)
def room_info(self, room, client):
return {"role": self.client_access(room, client),
"manage": self.can_manage(room, client.kind, client.key, client.perms),
"restricted": bool(room.acl), "owner": room.owner}
def room_list(self, client=None):
cutoff = time.time() - 7 * 86400
out = []
for room in self.rooms.values():
if client is not None and self.client_access(room, client) is None:
continue
members = [c.id for c in self.members(room.key)]
if members or room.updated > cutoff:
out.append({"key": room.key, "name": room.name, "kind": room.kind, "version": room.version,
"updated": int(room.updated), "members": members,
"nodes": len((room.doc or {}).get("nodes") or []),
"restricted": bool(room.acl), "owner": room.owner})
out.sort(key=lambda r: (-len(r["members"]), -r["updated"]))
return out
def users_for(self, viewer):
"""Presence as ``viewer`` may see it: rooms they cannot access are hidden."""
out = []
for c in self.clients.values():
if not c.kind:
continue
info = c.public()
room = self.rooms.get(c.room) if c.room else None
if room and self.client_access(room, viewer) is None:
info["room"] = None
out.append(info)
return out
async def broadcast_presence(self):
for client in list(self.clients.values()):
if client.kind:
await self.send(client, {"type": "presence", "users": self.users_for(client),
"rooms": self.room_list(client)})
async def set_acl(self, room, acl):
"""Apply a new access list and remove people who lost access."""
room.acl = acl
room.dirty = True
for client in self.members(room.key):
role = self.client_access(room, client)
if role is None:
client.room = None
await self.broadcast({"type": "leave", "id": client.id}, room=room.key)
await self.send(client, {"type": "room_denied", "room": room.key, "name": room.name})
else:
await self.send(client, {"type": "room_info", "room": room.key, **self.room_info(room, client)})
await self.broadcast_presence()
def rename_member(self, old, new):
for room in self.rooms.values():
changed = False
if room.owner == old:
room.owner, changed = new, True
members = (room.acl or {}).get("members") or {}
if old in members:
members[new] = members.pop(old)
changed = True
room.dirty = room.dirty or changed
def forget_member(self, username):
for room in self.rooms.values():
members = (room.acl or {}).get("members") or {}
if members.pop(username, None) is not None:
room.dirty = True
def server_info(self):
cfg = self.store.config
return {"name": cfg.get("server_name"), "allow_guests": cfg.get("allow_guests"),
"hide_comfy_account": bool(cfg.get("hide_comfy_account")),
"hide_api_templates": bool(cfg.get("hide_api_templates"))}
async def broadcast_server(self):
await self.broadcast({"type": "server", "server": self.server_info()})
async def system_chat(self, text):
msg = {"id": secrets.token_hex(6), "system": True, "text": text, "ts": int(time.time() * 1000)}
self.chat.append(msg)
self.chat_dirty = True
await self.broadcast({"type": "chat", "message": msg})
# ----- websocket ----------------------------------------------------
async def handle(self, request):
ws = web.WebSocketResponse(heartbeat=25, max_msg_size=MAX_MESSAGE)
await ws.prepare(request)
client = Client(ws)
client.cookie_token = request.cookies.get("rigshare_session")
self.clients[client.id] = client
try:
async for msg in ws:
if msg.type != WSMsgType.TEXT:
continue
try:
data = json.loads(msg.data)
except ValueError:
continue
if client.kind is None and data.get("type") != "hello":
continue
try:
await self.dispatch(client, data)
except Exception as e:
log.exception(f"[RigShare] error handling {data.get('type')}: {e}")
except Exception as e:
log.error(f"[RigShare] websocket error: {e}")
finally:
self.clients.pop(client.id, None)
if client.room:
self.drop_if_unused(client.room)
if client.kind:
await self.broadcast({"type": "leave", "id": client.id})
await self.broadcast_presence()
if not any(c.key == client.key for c in self.clients.values()):
await self.system_chat(f"{client.name} left")
return ws
async def dispatch(self, client, data):
kind = data.get("type")
if kind == "hello":
if not self.identify(client, data.get("token"), data.get("guest_id"), data.get("name")):
await self.send(client, {"type": "denied", "reason": "Please log in.", "login": True})
return
first = not any(c.key == client.key and c is not client for c in self.clients.values())
await self.send(client, {
"type": "welcome",
"self": client.public(),
"users": self.users_for(client),
"rooms": self.room_list(client),
"chat": list(self.chat),
"stats": self.stats,
"server": self.server_info(),
})
await self.broadcast_presence()
if first:
await self.system_chat(f"{client.name} joined")
elif kind == "join":
key = clean_text(data.get("room"), 400)
previous = client.room
if not key:
client.room = None
else:
room = self.rooms.get(key)
created = False
if room is None:
doc = data.get("doc") if isinstance(data.get("doc"), dict) else {}
room_kind = "file" if key.startswith("file:") else "tmp"
room = Room(key, clean_text(data.get("name"), 120) or key, room_kind, doc,
owner=client.key if client.kind == "user" else None)
room.dirty = True
self.rooms[key] = room
created = True
if self.client_access(room, client) is None:
client.room = None
await self.send(client, {"type": "room_denied", "room": key, "name": room.name})
else:
client.room = key
room.updated = time.time()
await self.send(client, {"type": "room", "room": key, "name": room.name, "version": room.version,
"doc": None if created else room.doc, "created": created,
**self.room_info(room, client)})
if previous != client.room:
if previous:
await self.broadcast({"type": "leave", "id": client.id}, room=previous)
self.drop_if_unused(previous)
await self.broadcast_presence()
elif kind == "unshare":
# Leave the room; an admin also removes it from the shared list if nobody else is in it.
key = data.get("room")
room = self.rooms.get(key)
if room is None:
return
if client.room == key:
client.room = None
await self.broadcast({"type": "leave", "id": client.id}, room=key)
others = self.members(key)
# Only admins remove a workflow from the shared list; others just leave.
removed = not others and client.perms.get("admin") and self.remove_room(key, client.name)
await self.send(client, {"type": "unshared", "room": key, "name": room.name, "removed": removed,
"others": [c.name for c in others]})
await self.broadcast_presence()
elif kind == "cursor":
if not client.room:
return
await self.broadcast({
"type": "cursor", "id": client.id, "room": client.room,
"x": data.get("x"), "y": data.get("y"),
"graph": data.get("graph"), "sel": (data.get("sel") or [])[:200],
"vp": data.get("vp"),
}, exclude=client, room=client.room)
elif kind == "chat":
text = clean_text(data.get("text"), 1000)
if not text:
return
msg = {"id": secrets.token_hex(6), "from": client.id, "name": client.name,
"color": client.color, "text": text, "ts": int(time.time() * 1000)}
self.chat.append(msg)
self.chat_dirty = True
await self.broadcast({"type": "chat", "message": msg})
elif kind == "rename":
name = clean_text(data.get("name"), 24)
if not name or client.kind != "guest" or name == client.name:
return
old, client.name, client.guest_name = client.name, name, name
await self.send(client, {"type": "self", "self": client.public()})
await self.broadcast_presence()
await self.system_chat(f"{old} is now known as {name}")
elif kind == "idle":
client.idle = bool(data.get("idle"))
await self.broadcast_presence()
elif kind == "graph":
key = data.get("room")
patch = data.get("patch")
room = self.rooms.get(key)
if not isinstance(patch, dict) or room is None or client.room != key:
return
if self.client_access(room, client) != "edit":
await self.send(client, {"type": "doc", "room": key, "doc": room.doc, "version": room.version,
"reason": "You can view this workflow but not edit it."})
return
# Someone else may have taken the ids this client just used.
patch, remapped = remap_conflicts(room.doc, patch)
room.doc = apply_patch(room.doc, patch)
room.version += 1
room.updated = time.time()
room.last_editor = client.name
room.dirty = room.snapshot_dirty = True
await self.broadcast({"type": "graph", "room": key, "from": client.id, "name": client.name,
"version": room.version, "patch": patch}, exclude=client, room=key)
if remapped:
# The sender's copy still has the clashing ids: resync it quietly.
await self.send(client, {"type": "doc", "room": key, "doc": room.doc, "version": room.version})
await self.send(client, {"type": "ack", "room": key, "version": room.version})
elif kind == "request_doc":
room = self.rooms.get(data.get("room"))
if room and client.room == room.key:
await self.send(client, {"type": "doc", "room": room.key, "doc": room.doc, "version": room.version})
async def replace_doc(self, room, doc, by, reason):
room.doc = doc
room.version += 1
room.updated = time.time()
room.dirty = room.snapshot_dirty = True
await self.broadcast({"type": "doc", "room": room.key, "doc": doc, "version": room.version,
"reason": reason, "by": by}, room=room.key)
+58
View File
@@ -0,0 +1,58 @@
"""Minimal HS256 JSON Web Tokens (RFC 7519) using only the standard library.
Only HS256 is produced or accepted: the header's ``alg`` is checked against a
fixed value (no ``none``/algorithm-confusion), signatures are compared in
constant time, and ``exp``/``nbf``/``iss`` are validated.
"""
import base64
import hashlib
import hmac
import json
import time
HEADER = {"alg": "HS256", "typ": "JWT"}
LEEWAY = 30
class InvalidToken(Exception):
pass
def _b64e(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def _b64d(text):
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def encode(claims, secret):
signing_input = f"{_b64e(json.dumps(HEADER, separators=(',', ':')).encode())}." \
f"{_b64e(json.dumps(claims, separators=(',', ':')).encode())}"
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{_b64e(signature)}"
def decode(token, secret, issuer, verify_exp=True):
try:
header_b64, claims_b64, sig_b64 = token.split(".")
header = json.loads(_b64d(header_b64))
if header.get("alg") != "HS256" or header.get("typ", "JWT") != "JWT":
raise InvalidToken("unsupported algorithm")
expected = hmac.new(secret, f"{header_b64}.{claims_b64}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, _b64d(sig_b64)):
raise InvalidToken("bad signature")
claims = json.loads(_b64d(claims_b64))
except InvalidToken:
raise
except Exception as e:
raise InvalidToken(f"malformed token: {e}")
now = time.time()
if claims.get("iss") != issuer:
raise InvalidToken("wrong issuer")
if verify_exp and now > float(claims.get("exp", 0)) + LEEWAY:
raise InvalidToken("expired")
if now + LEEWAY < float(claims.get("nbf", 0)):
raise InvalidToken("not yet valid")
return claims
+124
View File
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,{{ICON_B64}}">
<title>Sign in · {{SERVER_NAME}}</title>
<style>
:root {
--bg: #111214; --card: #1b1c1f; --fg: #e6e6e6; --muted: #9a9ca3; --border: #2e3036;
--input: #141518; --accent: #3b82f6; --accent-fg: #fff; --error: #f87171;
}
@media (prefers-color-scheme: light) {
:root { --bg: #f3f4f6; --card: #fff; --fg: #17181a; --muted: #6b6f76; --border: #dcdfe4; --input: #f8f9fb; }
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 16px;
background: radial-gradient(1200px 600px at 50% -10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent), var(--bg);
color: var(--fg); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.card {
width: 100%; max-width: 360px; background: var(--card); border: 1px solid var(--border);
border-radius: 14px; padding: 28px 24px 22px; box-shadow: 0 20px 60px rgb(0 0 0 / .25);
}
.brand { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; }
.logo { width: 34px; height: 34px; flex: none; }
.logo svg { width: 100%; height: 100%; display: block; }
h1 { font-size: 18px; margin: 0; }
p.sub { color: var(--muted); margin: 0 0 20px; font-size: 13px; }
label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 5px; }
input {
width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border);
background: var(--input); color: var(--fg); font: inherit;
}
input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); }
button {
width: 100%; margin-top: 18px; padding: 10px; border: 0; border-radius: 8px;
background: var(--accent); color: var(--accent-fg); font: inherit; font-weight: 600; cursor: pointer;
}
button:disabled { opacity: .6; cursor: default; }
.error { color: var(--error); font-size: 13px; margin-top: 12px; min-height: 1em; }
.notice { font-size: 13px; padding: 10px 12px; border-radius: 8px; margin-bottom: 4px;
background: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
.hint { color: var(--muted); font-size: 11.5px; margin-top: 6px; }
code { font-size: 11px; }
[hidden] { display: none !important; }
.foot { margin-top: 18px; color: var(--muted); font-size: 11.5px; text-align: center; }
</style>
<script>
// Colors of the ComfyUI palette last used in this browser (saved by the plugin).
try {
const t = JSON.parse(localStorage.getItem("rigshare.theme") || "null");
if (t) {
const map = { "--bg": "--bg-color", "--card": "--comfy-menu-bg", "--input": "--comfy-input-bg", "--fg": "--fg-color",
"--muted": "--descrip-text", "--border": "--border-color", "--accent": "--p-primary-color", "--accent-fg": "--p-primary-contrast-color" };
const root = document.documentElement.style;
for (const [ours, theirs] of Object.entries(map)) if (t[theirs]) root.setProperty(ours, t[theirs]);
}
} catch {}
</script>
</head>
<body>
<form class="card" id="form" autocomplete="on">
<div class="brand"><div class="logo">{{ICON_SVG}}</div><h1>{{SERVER_NAME}}</h1></div>
<p class="sub" id="sub">Sign in to open the shared ComfyUI workspace.</p>
<div class="notice" id="notice" hidden>Welcome! No account exists yet. Create the <b>administrator</b> account; you can add other users afterwards from the RigShare panel.</div>
<label for="u">Username</label>
<input id="u" name="username" autocomplete="username" required autofocus maxlength="32">
<label for="p">Password</label>
<input id="p" name="password" type="password" autocomplete="current-password" required>
<div id="setup-fields" hidden>
<label for="c">Confirm password</label>
<input id="c" name="confirm" type="password" autocomplete="new-password">
<div class="hint">At least 8 characters. Username: letters, digits, <code>_ . -</code></div>
</div>
<button id="b" type="submit">Sign in</button>
<div class="error" id="e" role="alert"></div>
<div class="foot" id="foot">No account? Ask an administrator.</div>
</form>
<script>
const SETUP = {{NEEDS_SETUP}};
const form = document.getElementById("form");
const err = document.getElementById("e");
const btn = document.getElementById("b");
const next = new URLSearchParams(location.search).get("next") || "/";
if (SETUP) {
document.title = "Set up · {{SERVER_NAME}}";
document.getElementById("sub").textContent = "First-time setup";
document.getElementById("notice").hidden = false;
document.getElementById("setup-fields").hidden = false;
document.getElementById("foot").hidden = true;
form.password.autocomplete = "new-password";
form.confirm.required = true;
btn.textContent = "Create admin account";
}
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
err.textContent = "";
if (SETUP) {
if (form.password.value.length < 8) { err.textContent = "Password must be at least 8 characters"; return; }
if (form.password.value !== form.confirm.value) { err.textContent = "Passwords do not match"; return; }
}
btn.disabled = true;
try {
const res = await fetch(SETUP ? "/rigshare/api/setup" : "/rigshare/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: form.username.value.trim(), password: form.password.value }),
});
const data = await res.json().catch(() => ({}));
if (res.status === 409) { location.reload(); return; } // someone finished setup meanwhile
if (!res.ok) throw new Error(data.error || "Sign in failed");
try { localStorage.setItem("rigshare.token", data.token); } catch {}
location.replace(next.startsWith("/") && !next.startsWith("//") ? next : "/");
} catch (e) {
err.textContent = e.message;
btn.disabled = false;
form.password.select();
}
});
</script>
</body>
</html>
+561
View File
@@ -0,0 +1,561 @@
"""HTTP API, API-protection middleware and execution-event broadcasting."""
import asyncio
import base64
import html
import ipaddress
import json
import logging
import os
import time
from collections import defaultdict
from aiohttp import web
log = logging.getLogger("ComfyUI-RigShare")
# (method, path) prefixes that need a permission when api_protection is on.
PROTECTED = [
("POST", "/prompt", "queue"),
("POST", "/queue", "queue"),
("POST", "/interrupt", "queue"),
("POST", "/free", "queue"),
("POST", "/upload/", "edit|queue"),
("POST", "/api/jobs", "queue"),
# Saved workflow files: only admins delete, editors save/overwrite/rename.
("DELETE", "/userdata/workflows/", "admin"),
("POST", "/userdata/workflows/", "edit"),
]
# ComfyUI Manager: everything needs the "manager" permission except a few
# read-only lookups the ComfyUI frontend makes for everyone (missing nodes...).
MANAGER_PREFIXES = ("/manager/", "/customnode/", "/snapshot/", "/externalmodel/", "/comfyui_manager/")
MANAGER_READONLY = {"/manager/version", "/manager/notice", "/manager/startup_alerts", "/manager/is_legacy_manager_ui",
"/customnode/getmappings", "/customnode/installed", "/customnode/import_fail_info",
"/customnode/get_node_types_in_workflows", "/customnode/alternatives"}
def is_api_template(template):
"""Templates that need paid API nodes (Comfy.org partner nodes)."""
return ("API" in (template.get("tags") or []) or template.get("openSource") is False
or str(template.get("name", "")).startswith("api_"))
def filter_templates(index):
out = []
for category in index:
if not isinstance(category, dict) or not isinstance(category.get("templates"), list):
out.append(category)
continue
kept = [t for t in category["templates"] if not is_api_template(t)]
if kept:
out.append({**category, "templates": kept})
return out
def needs_manager(method, path):
if path.startswith("/v2/"):
path = path[3:]
if not path.startswith(MANAGER_PREFIXES):
return False
return not (method in ("GET", "HEAD") and path in MANAGER_READONLY)
# Execution events ComfyUI normally sends only to the prompt's owner.
BROADCAST_EVENTS = {"execution_start", "execution_cached", "executing", "executed", "progress",
"progress_state", "execution_success", "execution_error", "execution_interrupted",
"progress_text"}
COOKIE = "rigshare_session"
LOGIN_PAGE = os.path.join(os.path.dirname(__file__), "login.html")
ICON_SVG = os.path.join(os.path.dirname(os.path.dirname(__file__)), "web", "rigshare", "assets", "icon.svg")
# Reachable without logging in.
PUBLIC_PATHS = {"/rigshare/login", "/rigshare/api/login", "/rigshare/api/logout", "/rigshare/api/setup",
"/rigshare/api/status", "/favicon.ico"}
def token_from(request):
"""Session token or API key from header, cookie or ?token= (for websockets)."""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
return (request.headers.get("X-RigShare-Token") or request.cookies.get(COOKIE)
or request.query.get("token"))
def setup(server, store, hub):
routes = web.RouteTableDef()
failures = defaultdict(list)
def current_user(request):
return store.user_for_token(token_from(request))
def perms_for(request):
user = current_user(request)
if user:
perms = dict(user["perms"])
if perms.get("admin"):
perms.update(edit=True, queue=True, manager=True)
return perms
guest = request.headers.get("X-RigShare-Guest")
if guest and store.config.get("allow_guests") and not store.config.get("require_login"):
return store.guest_perms(guest)
return {"edit": False, "queue": False, "manager": False, "admin": False}
def who(request):
"""(kind, key, perms) of the caller, for room access checks."""
user = current_user(request)
if user:
return "user", user["username"], perms_for(request)
return "guest", request.headers.get("X-RigShare-Guest"), perms_for(request)
def room_access(request, room):
return hub.access(room, *who(request))
def require_admin(request):
user = current_user(request)
if not user or not user["perms"].get("admin"):
raise web.HTTPForbidden(text='{"error": "Admin only"}', content_type="application/json")
return user
def error(message, status=400):
return web.json_response({"error": message}, status=status)
def set_cookie(request, response, token):
secure = request.secure or request.headers.get("X-Forwarded-Proto") == "https"
response.set_cookie(COOKIE, token, max_age=30 * 24 * 3600, path="/", httponly=True,
samesite="Lax", secure=secure)
# ----- websocket + session -------------------------------------------
@routes.get("/rigshare/ws")
async def ws_handler(request):
hub.start(asyncio.get_running_loop())
if request.headers.get("Upgrade", "").lower() != "websocket":
log.warning(f"[RigShare] WebSocket request from {request.headers.get('X-Forwarded-For', request.remote)} "
"arrived without 'Upgrade: websocket'. If ComfyUI is behind a reverse proxy, enable "
"WebSocket support there (Nginx Proxy Manager: 'Websockets Support').")
return error("WebSocket upgrade headers missing: enable WebSocket support in your reverse proxy", 400)
return await hub.handle(request)
@routes.get("/rigshare/api/status")
async def status(request):
return web.json_response({"server_name": store.config.get("server_name"),
"require_login": store.config.get("require_login"),
"needs_setup": store.needs_setup,
"allow_guests": store.config.get("allow_guests"),
"online": len(hub.clients)})
@routes.get("/rigshare/login")
async def login_page(request):
with open(LOGIN_PAGE, "r", encoding="utf-8") as f:
page = f.read().replace("{{SERVER_NAME}}", html.escape(store.config.get("server_name") or "RigShare"))
page = page.replace("{{NEEDS_SETUP}}", "true" if store.needs_setup else "false")
try:
with open(ICON_SVG, "r", encoding="utf-8") as f:
icon = f.read()
except OSError:
icon = ""
page = page.replace("{{ICON_SVG}}", icon).replace("{{ICON_B64}}", base64.b64encode(icon.encode()).decode())
return web.Response(text=page, content_type="text/html", headers={"Cache-Control": "no-store"})
@routes.post("/rigshare/api/login")
async def login(request):
ip = request.remote or "?"
now = time.time()
failures[ip] = [t for t in failures[ip] if now - t < 60]
if len(failures[ip]) >= 5:
return error("Too many attempts, wait a minute", 429)
body = await request.json()
user = store.authenticate(str(body.get("username", "")).strip(), str(body.get("password", "")))
if not user:
failures[ip].append(now)
return error("Invalid username or password", 401)
token = store.create_session(user["username"])
response = web.json_response({"token": token, "user": store.public_user(user)})
set_cookie(request, response, token)
return response
@routes.post("/rigshare/api/setup")
async def setup_admin(request):
if not store.needs_setup:
return error("Setup is already complete", 409)
body = await request.json()
try:
user = store.setup_admin(str(body.get("username", "")).strip(), str(body.get("password", "")),
body.get("display_name"))
except PermissionError as e:
return error(str(e), 409)
except ValueError as e:
return error(str(e))
log.warning(f"[RigShare] First-run setup: admin '{user['username']}' created from {request.remote}")
token = store.create_session(user["username"])
response = web.json_response({"token": token, "user": store.public_user(user)})
set_cookie(request, response, token)
return response
@routes.post("/rigshare/api/logout")
async def logout(request):
token = token_from(request)
if token:
store.revoke_session(token)
response = web.json_response({"ok": True})
response.del_cookie(COOKIE, path="/")
return response
@routes.get("/rigshare/api/me")
async def me(request):
user = current_user(request)
if not user:
return error("Not logged in", 401)
return web.json_response(store.public_user(user))
@routes.post("/rigshare/api/me")
async def update_me(request):
user = current_user(request)
if not user:
return error("Not logged in", 401)
body = await request.json()
try:
if body.get("new_password"):
if not store.authenticate(user["username"], body.get("current_password", "")):
return error("Current password is wrong", 403)
store.update_user(user["username"], password=body["new_password"])
# Changing password revokes sessions; keep the caller signed in.
token = store.create_session(user["username"])
else:
token = None
if "display_name" in body:
store.update_user(user["username"], display_name=str(body["display_name"]))
except ValueError as e:
return error(str(e))
await hub.refresh_identities(user["username"])
response = web.json_response({"user": store.public_user(store.users[user["username"]]), "token": token})
if token:
set_cookie(request, response, token)
return response
@routes.get("/rigshare/api/me/keys")
async def list_keys(request):
user = current_user(request)
if not user:
return error("Not logged in", 401)
return web.json_response(store.list_api_keys(user["username"]))
@routes.post("/rigshare/api/me/keys")
async def create_key(request):
user = current_user(request)
if not user:
return error("Not logged in", 401)
body = await request.json()
key, meta = store.create_api_key(user["username"], body.get("name"))
return web.json_response({"key": key, **meta})
@routes.delete("/rigshare/api/me/keys/{key_id}")
async def revoke_key(request):
user = current_user(request)
if not user:
return error("Not logged in", 401)
if not store.revoke_api_key(user["username"], request.match_info["key_id"]):
return error("No such key", 404)
return web.json_response({"ok": True})
# ----- admin: accounts, guests, config -------------------------------
@routes.get("/rigshare/api/users")
async def list_users(request):
require_admin(request)
return web.json_response([store.public_user(u) for u in store.users.values()])
@routes.post("/rigshare/api/users")
async def create_user(request):
require_admin(request)
body = await request.json()
try:
user = store.create_user(str(body.get("username", "")).strip(), str(body.get("password", "")),
body.get("display_name"), body.get("perms") or {})
except ValueError as e:
return error(str(e))
return web.json_response(store.public_user(user))
@routes.patch("/rigshare/api/users/{username}")
async def update_user(request):
admin = require_admin(request)
username = request.match_info["username"]
body = await request.json()
try:
user = store.update_user(username, body.get("display_name"), body.get("perms"), body.get("password") or None)
if body.get("username"):
user = store.rename_user(username, str(body["username"]))
except KeyError:
return error("No such user", 404)
except ValueError as e:
return error(str(e))
if user["username"] != username:
hub.rename_member(username, user["username"])
log.info(f"[RigShare] {admin['username']} renamed @{username} to @{user['username']}")
await hub.refresh_identities()
else:
await hub.refresh_identities(username)
return web.json_response(store.public_user(user))
@routes.delete("/rigshare/api/users/{username}")
async def delete_user(request):
require_admin(request)
username = request.match_info["username"]
try:
store.delete_user(username)
hub.forget_member(username)
except KeyError:
return error("No such user", 404)
except ValueError as e:
return error(str(e))
await hub.refresh_identities(username)
return web.json_response({"ok": True})
@routes.post("/rigshare/api/guests/{guest_id}")
async def guest_perms(request):
require_admin(request)
store.set_guest_perms(request.match_info["guest_id"], await request.json())
await hub.refresh_identities(request.match_info["guest_id"])
return web.json_response({"ok": True})
@routes.get("/rigshare/api/config")
async def get_config(request):
require_admin(request)
return web.json_response(store.config)
@routes.patch("/rigshare/api/config")
async def patch_config(request):
require_admin(request)
config = store.update_config(await request.json())
await hub.refresh_identities()
await hub.broadcast_server()
return web.json_response(config)
# ----- rooms + snapshots ---------------------------------------------
def display(request):
user = current_user(request)
if user:
return user.get("display_name") or user["username"]
return "guest"
@routes.get("/rigshare/api/rooms")
async def list_rooms(request):
return web.json_response([r for r in hub.room_list() if room_access(request, hub.rooms[r["key"]])])
@routes.get("/rigshare/api/people")
async def people(request):
"""Accounts that can be added to a workflow's access list."""
if not current_user(request):
return error("Not logged in", 401)
return web.json_response([{"username": u["username"], "display_name": u.get("display_name") or u["username"],
"admin": bool(u["perms"].get("admin")), "edit": bool(u["perms"].get("edit"))}
for u in store.users.values()])
@routes.get("/rigshare/api/room/acl")
async def get_acl(request):
room = hub.rooms.get(request.query.get("key", ""))
if not room:
return error("No such room", 404)
if not hub.can_manage(room, *who(request)):
return error("Only the owner or an admin can manage access", 403)
return web.json_response({"key": room.key, "owner": room.owner, "restricted": bool(room.acl),
"members": (room.acl or {}).get("members") or {}})
@routes.put("/rigshare/api/room/acl")
async def put_acl(request):
body = await request.json()
room = hub.rooms.get(body.get("key", ""))
if not room:
return error("No such room", 404)
if not hub.can_manage(room, *who(request)):
return error("Only the owner or an admin can manage access", 403)
acl = None
if body.get("restricted"):
members = {}
for username, role in (body.get("members") or {}).items():
if username in store.users and role in ("view", "edit") and username != room.owner:
members[username] = role
acl = {"members": members}
if room.owner is None:
# Someone must stay in charge besides admins: the person restricting it.
kind, key, _ = who(request)
if kind == "user":
room.owner = key
await hub.set_acl(room, acl)
log.info(f"[RigShare] {display(request)} set access for {room.name}: "
f"{'everyone' if acl is None else ', '.join(f'{u}={r}' for u, r in acl['members'].items()) or 'owner only'}")
return web.json_response({"ok": True, "owner": room.owner, "restricted": bool(acl)})
@routes.delete("/rigshare/api/room")
async def delete_room(request):
require_admin(request)
key = request.query.get("key", "")
room = hub.rooms.get(key)
if not room:
return error("No such room", 404)
if hub.members(key):
names = ", ".join(c.name for c in hub.members(key))
return error(f"Still open by {names}", 409)
hub.remove_room(key, display(request))
await hub.broadcast_presence()
return web.json_response({"ok": True})
@routes.get("/rigshare/api/room")
async def get_room(request):
room = hub.rooms.get(request.query.get("key", ""))
if not room or not room_access(request, room):
return error("No such room", 404)
return web.json_response({"key": room.key, "name": room.name, "kind": room.kind,
"version": room.version, "doc": room.doc})
@routes.get("/rigshare/api/snapshots")
async def list_snapshots(request):
key = request.query.get("room")
if not key:
require_admin(request)
return web.json_response(store.list_snapshots())
room = hub.rooms.get(key)
if room and not room_access(request, room):
return error("No such room", 404)
return web.json_response(store.list_snapshots(key))
@routes.post("/rigshare/api/snapshots")
async def create_snapshot(request):
if not perms_for(request).get("edit"):
return error("You do not have permission to edit", 403)
body = await request.json()
room = hub.rooms.get(body.get("room", ""))
if not room:
return error("This tab is not shared")
if room_access(request, room) != "edit":
return error("You can view this workflow but not edit it", 403)
snap = hub.snapshot(room, display(request), body.get("label") or "Manual")
if not snap:
return error("Nothing to snapshot yet")
return web.json_response(snap)
@routes.get("/rigshare/api/snapshots/{snap_id}")
async def get_snapshot(request):
snap = store.get_snapshot(request.match_info["snap_id"])
if not snap:
return error("Not found", 404)
room = hub.rooms.get(snap["meta"].get("room", ""))
if room and not room_access(request, room):
return error("Not found", 404)
return web.json_response(snap)
@routes.post("/rigshare/api/snapshots/{snap_id}/restore")
async def restore_snapshot(request):
if not perms_for(request).get("edit"):
return error("You do not have permission to edit", 403)
snap = store.get_snapshot(request.match_info["snap_id"])
if not snap:
return error("Not found", 404)
room = hub.rooms.get(snap["meta"].get("room", ""))
if not room:
return error("That workflow is no longer shared; open the snapshot as a copy instead", 404)
if room_access(request, room) != "edit":
return error("You can view this workflow but not edit it", 403)
by = display(request)
hub.snapshot(room, by, "Before restore")
await hub.replace_doc(room, snap["doc"], by, f"{by} restored a snapshot of {room.name}")
return web.json_response({"ok": True})
server.app.add_routes(routes)
# ----- API protection middleware -------------------------------------
def trusted(ip):
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
for entry in store.config["api_protection"].get("trusted_ips", []):
try:
if addr in ipaddress.ip_network(entry, strict=False):
return True
except ValueError:
continue
return False
@web.middleware
async def protect(request, handler):
path = request.path
if store.config.get("require_login") and path not in PUBLIC_PATHS and not trusted(request.remote or ""):
if not current_user(request):
wants_page = request.method == "GET" and (
path in ("/", "/index.html") or "text/html" in request.headers.get("Accept", ""))
if wants_page:
raise web.HTTPFound(f"/rigshare/login?next={path}")
return web.json_response({"error": "RigShare: login required. API clients: send "
"'Authorization: Bearer <api key>'."}, status=401)
if path.startswith(("/userdata/workflows/", "/api/userdata/workflows/")) and not trusted(request.remote or ""):
# A restricted shared workflow's file follows its access list too.
rel = path.split("/userdata/", 1)[1].split("/move/", 1)[0]
room = hub.rooms.get("file:" + rel)
if room and room.acl:
role = room_access(request, room)
need = "view" if request.method in ("GET", "HEAD") else "edit"
if role is None or (need == "edit" and role != "edit"):
return web.json_response({"error": "RigShare: this workflow is restricted"}, status=403)
cfg = store.config.get("api_protection", {})
if cfg.get("enabled"):
path = request.path[4:] if request.path.startswith("/api/") else request.path
if needs_manager(request.method, path) and not trusted(request.remote or ""):
if not perms_for(request).get("manager"):
return web.json_response({"error": "RigShare: you need the 'manager' permission to use ComfyUI Manager"},
status=403)
for method, prefix, need in PROTECTED:
if (not method or request.method == method) and (path == prefix or path.startswith(prefix)):
if trusted(request.remote or ""):
break
perms = perms_for(request)
if not any(perms.get(p) for p in need.split("|")):
return web.json_response(
{"error": {"type": "rigshare_forbidden",
"message": f"RigShare: you need '{need.replace('|', ' or ')}' permission",
"details": "", "extra_info": {}}, "node_errors": {}},
status=403)
break
response = await handler(request)
# Hide API templates: rewrite the template index ComfyUI serves.
if (store.config.get("hide_api_templates") and request.method == "GET"
and path.startswith("/templates/index") and path.endswith(".json")):
try:
file_path = getattr(response, "_path", None)
if file_path:
with open(file_path, "r", encoding="utf-8") as f:
index = json.load(f)
else:
index = json.loads(response.body)
if isinstance(index, list):
response = web.json_response(filter_templates(index), headers={"Cache-Control": "no-store"})
except Exception as e:
log.debug(f"[RigShare] could not filter templates: {e}")
# Sliding session: renew the cookie JWT when it is getting old.
cookie = request.cookies.get(COOKIE)
if cookie and isinstance(response, web.Response) and not response.prepared:
fresh = store.refresh_token(cookie)
if fresh:
set_cookie(request, response, fresh)
return response
try:
server.app.middlewares.append(protect)
except RuntimeError as e:
log.error(f"[ComfyUI-RigShare] Could not install API protection middleware: {e}")
# ----- relay execution events to everyone ----------------------------
original_send_sync = server.send_sync
def send_sync(event, data, sid=None):
if sid is not None and store.config.get("broadcast_execution", True):
if event in BROADCAST_EVENTS or isinstance(event, int):
sid = None
return original_send_sync(event, data, sid)
server.send_sync = send_sync
+79
View File
@@ -0,0 +1,79 @@
"""Server resource usage for the panel: CPU, RAM, GPUs and the prompt queue.
psutil and pynvml ship with most ComfyUI installs (ComfyUI-Crystools and many
others depend on them); every metric is optional and skipped if unavailable.
"""
import logging
import warnings
log = logging.getLogger("ComfyUI-RigShare")
try:
import psutil
except ImportError:
psutil = None
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
import pynvml
pynvml.nvmlInit()
except Exception:
pynvml = None
GB = 1024 ** 3
def _gpus():
if not pynvml:
return []
out = []
for i in range(pynvml.nvmlDeviceGetCount()):
h = pynvml.nvmlDeviceGetHandleByIndex(i)
name = pynvml.nvmlDeviceGetName(h)
if isinstance(name, bytes):
name = name.decode()
mem = pynvml.nvmlDeviceGetMemoryInfo(h)
gpu = {"index": i, "name": name.replace("NVIDIA ", "").replace("GeForce ", ""),
"util": pynvml.nvmlDeviceGetUtilizationRates(h).gpu,
"vram_used": round(mem.used / GB, 2), "vram_total": round(mem.total / GB, 2)}
try:
gpu["temp"] = pynvml.nvmlDeviceGetTemperature(h, pynvml.NVML_TEMPERATURE_GPU)
except Exception:
pass
try:
gpu["power"] = round(pynvml.nvmlDeviceGetPowerUsage(h) / 1000)
gpu["power_limit"] = round(pynvml.nvmlDeviceGetEnforcedPowerLimit(h) / 1000)
except Exception:
pass
out.append(gpu)
return out
def _queue(server):
try:
running, pending = server.prompt_queue.get_current_queue_volatile()
return {"running": len(running), "pending": len(pending)}
except Exception:
try:
return {"running": 0, "pending": server.prompt_queue.get_tasks_remaining()}
except Exception:
return None
def collect_stats(server=None):
stats = {}
if psutil:
stats["cpu"] = psutil.cpu_percent(interval=None)
vm = psutil.virtual_memory()
stats["ram_used"] = round((vm.total - vm.available) / GB, 1)
stats["ram_total"] = round(vm.total / GB, 1)
try:
stats["gpus"] = _gpus()
except Exception as e:
log.debug(f"[RigShare] GPU stats failed: {e}")
stats["gpus"] = []
if server is not None:
stats["queue"] = _queue(server)
return stats
+477
View File
@@ -0,0 +1,477 @@
"""Persistent state for RigShare: accounts, sessions, guests, config, rooms
and snapshots.
Everything lives as small JSON files in ``<ComfyUI>/rigshare``. Writes are atomic
(write to a temp file then rename) so a crash never leaves a half-written file.
"""
import hashlib
import hmac
import json
import logging
import os
import re
import secrets
import threading
import time
from . import jwt
log = logging.getLogger("ComfyUI-RigShare")
PBKDF2_ITERATIONS = 240_000
SESSION_TTL = 30 * 24 * 3600 # a login lasts at most this long
TOKEN_TTL = 12 * 3600 # each JWT is short-lived...
TOKEN_REFRESH = 6 * 3600 # ...and re-issued while in use once older than this
ISSUER = "rigshare"
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{2,32}$")
PERMS = ("edit", "queue", "manager", "admin")
API_KEY_PREFIX = "rs_"
DEFAULT_CONFIG = {
"server_name": "RigShare",
# Everyone must log in before ComfyUI loads (login page + session cookie).
# API clients authenticate with a personal API key.
"require_login": True,
# Only when require_login is off: visitors without an account can watch and chat.
"allow_guests": False,
"guest_permissions": {"edit": False, "queue": False},
# Relay execution progress/previews to every connected browser, not only
# the one that queued the prompt.
"broadcast_execution": True,
# Reject queue/interrupt/upload HTTP calls from users lacking permission.
# Requests from trusted IPs skip both this and the login requirement.
"api_protection": {
"enabled": True,
"trusted_ips": ["127.0.0.1", "::1"],
},
# Hide ComfyUI's own Comfy.org account sign-in (top-bar button and dialog).
"hide_comfy_account": False,
# Remove templates that need paid API nodes from the template browser.
"hide_api_templates": False,
# Rooms nobody opened for this many days are forgotten.
"room_expiry_days": 14,
"snapshot_interval_sec": 300,
"snapshot_keep": 30,
"chat_history": 200,
}
def _hash_password(password, salt=None):
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
def _verify_password(password, stored):
try:
_, iterations, salt, digest = stored.split("$")
candidate = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iterations))
return hmac.compare_digest(candidate.hex(), digest)
except (ValueError, AttributeError):
return False
def _deep_merge(base, override):
out = dict(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(out.get(key), dict):
out[key] = _deep_merge(out[key], value)
else:
out[key] = value
return out
class Store:
def __init__(self, folder):
self.folder = folder
self.snapshot_dir = os.path.join(folder, "snapshots")
self.room_dir = os.path.join(folder, "rooms")
os.makedirs(self.snapshot_dir, exist_ok=True)
os.makedirs(self.room_dir, exist_ok=True)
self._lock = threading.RLock()
self.secret = self._load_secret()
self.config = _deep_merge(DEFAULT_CONFIG, self._read("config.json", {}))
self._write("config.json", self.config)
self.users = self._read("users.json", {})
self.sessions = self._read("sessions.json", {})
self.guests = self._read("guests.json", {})
self._provision_admin()
self._prune_sessions()
# ----- file helpers -------------------------------------------------
def _path(self, name):
return os.path.join(self.folder, name)
def _read(self, name, default):
try:
with open(self._path(name), "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return default
except (OSError, ValueError) as e:
log.error(f"[RigShare] Could not read {name}: {e}")
return default
def _write(self, name, data):
with self._lock:
path = self._path(name)
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
os.replace(tmp, path)
# ----- setup --------------------------------------------------------
def _load_secret(self):
"""JWT signing key, generated once and kept private to the server."""
path = self._path("secret.key")
try:
with open(path, "rb") as f:
secret = f.read()
if len(secret) >= 32:
return secret
except FileNotFoundError:
pass
secret = secrets.token_bytes(64)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(secret)
return secret
def _provision_admin(self):
"""Optional scripted install: create the first admin from env vars.
Otherwise there are no accounts until someone completes the first-run
setup on the login page.
"""
username = os.environ.get("RIGSHARE_ADMIN_USER")
password = os.environ.get("RIGSHARE_ADMIN_PASSWORD")
if self.users or not (username and password):
if not self.users:
log.warning("[RigShare] No accounts yet: open ComfyUI in a browser to create the admin account.")
return
self.create_user(username, password, perms={"edit": True, "queue": True, "admin": True})
log.info(f"[RigShare] Created admin '{username}' from environment variables.")
@property
def needs_setup(self):
return not self.users
def setup_admin(self, username, password, display_name=None):
"""First-run setup: only allowed while no account exists."""
with self._lock:
if self.users:
raise PermissionError("Setup is already complete")
return self.create_user(username, password, display_name, {"edit": True, "queue": True, "admin": True})
def _new_user(self, username, password, perms):
return {
"username": username,
"display_name": username,
"password": _hash_password(password),
"perms": {p: bool(perms.get(p)) for p in PERMS},
"created": int(time.time()),
}
def _prune_sessions(self):
now = time.time()
alive = {t: s for t, s in self.sessions.items() if s["expires"] > now and s["username"] in self.users}
if len(alive) != len(self.sessions):
self.sessions = alive
self._write("sessions.json", self.sessions)
# ----- config -------------------------------------------------------
def update_config(self, patch):
allowed = set(DEFAULT_CONFIG)
clean = {k: v for k, v in patch.items() if k in allowed}
self.config = _deep_merge(self.config, clean)
self._write("config.json", self.config)
return self.config
# ----- accounts -----------------------------------------------------
@staticmethod
def public_user(user):
return {
"username": user["username"],
"display_name": user.get("display_name") or user["username"],
"perms": user["perms"],
"created": user.get("created"),
}
def authenticate(self, username, password):
user = self.users.get(username)
if user and _verify_password(password, user["password"]):
return user
# Burn comparable time so unknown usernames are not distinguishable.
_verify_password(password, _hash_password("x", b"0" * 16))
return None
def create_user(self, username, password, display_name=None, perms=None):
if not USERNAME_RE.match(username or ""):
raise ValueError("Username must be 2-32 characters: letters, digits, _ . -")
if username in self.users:
raise ValueError("Username already exists")
if len(password or "") < 8:
raise ValueError("Password must be at least 8 characters")
user = self._new_user(username, password, perms or {})
if display_name:
user["display_name"] = display_name[:32]
self.users[username] = user
self._write("users.json", self.users)
return user
def rename_user(self, username, new_username):
"""Change a login name; sessions and API keys follow the account."""
new_username = (new_username or "").strip()
if new_username == username:
return self.users[username]
if username not in self.users:
raise KeyError(username)
if not USERNAME_RE.match(new_username):
raise ValueError("Username must be 2-32 characters: letters, digits, _ . -")
if new_username in self.users:
raise ValueError("Username already exists")
with self._lock:
user = self.users.pop(username)
user["username"] = new_username
if user.get("display_name") in (None, "", username):
user["display_name"] = new_username
self.users[new_username] = user
for session in self.sessions.values():
if session["username"] == username:
session["username"] = new_username
self._write("users.json", self.users)
self._write("sessions.json", self.sessions)
return user
def update_user(self, username, display_name=None, perms=None, password=None):
user = self.users.get(username)
if not user:
raise KeyError(username)
if display_name is not None:
user["display_name"] = display_name.strip()[:32] or username
if perms is not None:
new_perms = {p: bool(perms.get(p, user["perms"].get(p))) for p in PERMS}
if user["perms"].get("admin") and not new_perms["admin"] and self._admin_count() <= 1:
raise ValueError("Cannot remove the last admin")
user["perms"] = new_perms
if password is not None:
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
user["password"] = _hash_password(password)
self.revoke_sessions(username)
self._write("users.json", self.users)
return user
def delete_user(self, username):
user = self.users.get(username)
if not user:
raise KeyError(username)
if user["perms"].get("admin") and self._admin_count() <= 1:
raise ValueError("Cannot delete the last admin")
del self.users[username]
self._write("users.json", self.users)
self.revoke_sessions(username)
def _admin_count(self):
return sum(1 for u in self.users.values() if u["perms"].get("admin"))
# ----- sessions -----------------------------------------------------
# Browser logins are JWTs (HS256, 12 h) that reference a server-side
# session id. The session makes logout, password changes and account
# deletion take effect immediately instead of waiting for expiry.
def _issue(self, sid, username):
now = int(time.time())
return jwt.encode({"iss": ISSUER, "sub": username, "sid": sid, "iat": now, "nbf": now,
"exp": now + TOKEN_TTL}, self.secret)
def create_session(self, username):
sid = secrets.token_urlsafe(16)
self.sessions[sid] = {"username": username, "expires": time.time() + SESSION_TTL, "created": int(time.time())}
self._write("sessions.json", self.sessions)
return self._issue(sid, username)
def _session_claims(self, token, verify_exp=True):
try:
claims = jwt.decode(token, self.secret, ISSUER, verify_exp=verify_exp)
except jwt.InvalidToken:
return None
session = self.sessions.get(claims.get("sid", ""))
if not session or session["expires"] < time.time():
return None
# The session (not the token) says who this is, so renames keep sessions valid.
return {**claims, "sub": session["username"]}
def user_for_token(self, token):
"""Resolve a session JWT or a personal API key to a user."""
if not token:
return None
if token.startswith(API_KEY_PREFIX):
return self.user_for_api_key(token)
claims = self._session_claims(token)
return self.users.get(claims["sub"]) if claims else None
def refresh_token(self, token):
"""Return a fresh JWT for the same session when this one is getting old."""
if not token or token.startswith(API_KEY_PREFIX):
return None
claims = self._session_claims(token)
if not claims or time.time() - claims.get("iat", 0) < TOKEN_REFRESH:
return None
return self._issue(claims["sid"], claims["sub"])
# ----- API keys -----------------------------------------------------
@staticmethod
def _key_hash(key):
return hashlib.sha256(key.encode()).hexdigest()
def list_api_keys(self, username):
user = self.users.get(username) or {}
return [{k: v for k, v in key.items() if k != "hash"} for key in user.get("api_keys", [])]
def create_api_key(self, username, name):
user = self.users[username]
key = API_KEY_PREFIX + secrets.token_urlsafe(32)
entry = {"id": secrets.token_hex(4), "name": (name or "API key").strip()[:40],
"hash": self._key_hash(key), "hint": key[:len(API_KEY_PREFIX) + 4] + "..." + key[-4:],
"created": int(time.time()), "last_used": None}
user.setdefault("api_keys", []).append(entry)
self._write("users.json", self.users)
return key, {k: v for k, v in entry.items() if k != "hash"}
def revoke_api_key(self, username, key_id):
user = self.users[username]
before = len(user.get("api_keys", []))
user["api_keys"] = [k for k in user.get("api_keys", []) if k["id"] != key_id]
if len(user["api_keys"]) != before:
self._write("users.json", self.users)
return True
return False
def user_for_api_key(self, key):
digest = self._key_hash(key)
for user in self.users.values():
for entry in user.get("api_keys", []):
if hmac.compare_digest(entry["hash"], digest):
now = int(time.time())
if not entry.get("last_used") or now - entry["last_used"] > 300:
entry["last_used"] = now
self._write("users.json", self.users)
return user
return None
def revoke_session(self, token):
claims = self._session_claims(token, verify_exp=False) if token else None
if claims and self.sessions.pop(claims["sid"], None):
self._write("sessions.json", self.sessions)
def revoke_sessions(self, username):
before = len(self.sessions)
self.sessions = {t: s for t, s in self.sessions.items() if s["username"] != username}
if len(self.sessions) != before:
self._write("sessions.json", self.sessions)
# ----- guests -------------------------------------------------------
def guest_perms(self, guest_id):
base = self.config["guest_permissions"]
override = self.guests.get(guest_id, {})
return {"edit": bool(override.get("edit", base.get("edit"))),
"queue": bool(override.get("queue", base.get("queue"))),
"admin": False}
def set_guest_perms(self, guest_id, perms):
entry = self.guests.setdefault(guest_id, {})
for p in ("edit", "queue"):
if p in perms:
entry[p] = bool(perms[p])
self._write("guests.json", self.guests)
# ----- rooms ----------------------------------------------------------
def _room_path(self, key):
return os.path.join(self.room_dir, hashlib.sha1(key.encode()).hexdigest()[:20] + ".json")
def load_rooms(self):
rooms = []
for name in os.listdir(self.room_dir):
if name.endswith(".json"):
try:
with open(os.path.join(self.room_dir, name), "r", encoding="utf-8") as f:
rooms.append(json.load(f))
except (OSError, ValueError):
pass
return rooms
def save_room(self, room):
path = self._room_path(room["key"])
with self._lock:
with open(path + ".tmp", "w", encoding="utf-8") as f:
json.dump(room, f)
os.replace(path + ".tmp", path)
def delete_room(self, key):
try:
os.remove(self._room_path(key))
except OSError:
pass
# ----- snapshots ------------------------------------------------------
def list_snapshots(self, room=None):
items = []
for name in sorted(os.listdir(self.snapshot_dir), reverse=True):
if not name.endswith(".json"):
continue
path = os.path.join(self.snapshot_dir, name)
try:
with open(path, "r", encoding="utf-8") as f:
meta = json.load(f).get("meta", {})
except (OSError, ValueError):
continue
if room is None or meta.get("room") == room:
items.append({"id": name[:-5], **meta})
return items
def add_snapshot(self, doc, author, room, room_name, label=None):
snap_id = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
meta = {
"time": int(time.time()),
"author": author,
"label": (label or "")[:80],
"room": room,
"room_name": room_name,
"nodes": len(doc.get("nodes", [])) if isinstance(doc, dict) else 0,
}
with open(os.path.join(self.snapshot_dir, snap_id + ".json"), "w", encoding="utf-8") as f:
json.dump({"meta": meta, "doc": doc}, f)
self._prune_snapshots(room)
return {"id": snap_id, **meta}
def get_snapshot(self, snap_id):
if not re.match(r"^[\w-]+$", snap_id or ""):
return None
try:
with open(os.path.join(self.snapshot_dir, snap_id + ".json"), "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return None
def _prune_snapshots(self, room):
keep = max(1, int(self.config.get("snapshot_keep", 30)))
# Labelled (manual) snapshots are kept; only automatic ones rotate.
auto = [s for s in self.list_snapshots(room) if not s.get("label")]
for snap in auto[keep:]:
try:
os.remove(os.path.join(self.snapshot_dir, snap["id"] + ".json"))
except OSError:
pass
Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

-115
View File
@@ -1,115 +0,0 @@
const BASE_URL = 'nexus';
async function fetchWithHandling(url, options = {}) {
try {
// console.info("Calling: ", url)
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Something went wrong');
}
return data;
} catch (error) {
console.error(`Fetch error: ${error.message}`);
return { error: error.message };
}
}
export async function getUserSpecificWorkflows(userId) {
const url = `${BASE_URL}/workflows`;
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId }),
};
return await fetchWithHandling(url, options);
}
export async function getUserSpecificWorkflow(userId, workflowId) {
const url = `${BASE_URL}/workflows/${workflowId}`;
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId }),
};
return await fetchWithHandling(url, options);
}
export async function getUserSpecificWorkflowsMeta(userId) {
const url = `${BASE_URL}/workflows/meta`;
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId }),
};
return await fetchWithHandling(url, options);
}
export async function postWorkflow(userId, workflowData) {
const url = `${BASE_URL}/workflow`;
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, ...workflowData }),
};
return await fetchWithHandling(url, options);
}
export async function postBackupWorkflow(userId, workflowData) {
const url = `${BASE_URL}/workflow/backup`;
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, ...workflowData }),
};
return await fetchWithHandling(url, options);
}
export async function getLatestWorkflow() {
const url = `${BASE_URL}/workflow`;
return await fetchWithHandling(url);
}
export async function getPermissionById(userId) {
const url = `${BASE_URL}/permission/${userId}`;
return await fetchWithHandling(url);
}
export async function postPermissionById(userId, adminId, data, token) {
const url = `${BASE_URL}/permission/${userId}`;
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token,
},
body: JSON.stringify({ admin_id: adminId, data }),
};
return await fetchWithHandling(url, options);
}
export async function nexusLogin(uuid, account, password) {
const url = `${BASE_URL}/login`;
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uuid, account, password }),
};
return await fetchWithHandling(url, options);
}
export async function nexusVerify(token) {
const url = `${BASE_URL}/verify`;
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
};
return await fetchWithHandling(url, options);
}
export async function getNameById(userId) {
const url = `${BASE_URL}/name/${userId}`;
return await fetchWithHandling(url);
}
-160
View File
@@ -1,160 +0,0 @@
import { DynamicElement } from "./nexus.elements.js"
import { StyleInjector } from "./nexus.styles.js";
let chatMain = new DynamicElement({
id: 'nexus-chat-main',
tag: 'div',
visible: true,
styles: {
width: '512px',
minHeight: '170px',
maxHeight: '170px',
overflow: 'hidden',
padding: '16px',
borderRadius: '8px',
border: '1px solid rgba(94,94,94,0.5)'
},
hoverStyles: {
overflowY: 'scroll'
},
afterChildrenAppend: function (childrens, me) {
if (childrens.length > 50) {
childrens[0].removeElement()
childrens.shift()
}
me.el.scrollTop = me.el.scrollHeight
}
})
let inputMain = new DynamicElement({
id: 'nexus-input-main',
tag: 'div',
visible: true,
styles: {
width: '100%',
height: '32px',
display: 'flex',
alignItems: 'center',
},
childrens: [
new DynamicElement({
id: 'nexus-input-label',
tag: 'span',
visible: false,
styles: {
fontFamily: 'Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
display: 'block',
fontSize: '14px',
lineHeight: '14px',
fontWeight: 600,
color: 'white',
padding: '0',
margin: '0',
textShadow: '1pt 1pt #000',
},
childrens: ['Say:']
}),
new DynamicElement({
id: 'nexus-input',
tag: 'input',
visible: false,
styles: {
fontFamily: 'Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontSize: '14px',
lineHeight: '14px',
fontWeight: 600,
width: '100%',
color: 'white',
padding: '0',
margin: '0',
border: 'none',
background: "transparent",
flexGrow: 1,
outline: 'none',
textIndent: '4pt',
caretColor: 'transparent',
textShadow: '1pt 1pt #000',
}
})
]
})
let chatWindow = new DynamicElement({
id: 'nexus-chat-window',
tag: 'div',
styles: {
position: 'absolute',
left: "16px",
top: '100px',
display: 'flex',
flexDirection: 'column',
zIndex: 1000
},
childrens: [
new DynamicElement({
id: "nexus-total-players",
tag: "div",
visible: true,
styles: {
fontFamily: 'Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
fontSize: '14px',
lineHeight: '14px',
fontWeight: 600,
left: "16px",
top: '80px',
color: "#a5a5a5",
width: "100%",
paddingBottom: "12px",
textAlign: "right"
},
childrens: ['Users Online: ', new DynamicElement({ id: "nexus-total-players-count", visible: true, tag: "span", childrens:['0'] })]
}),
chatMain,
inputMain,
new StyleInjector({
styles: {
'#nexus-chat-main::-webkit-scrollbar': {
'display': 'none',
'scrollbar-width': 'none',
'-ms-overflow-style': 'none',
},
"#nexus-chat-window" : {
// for pointer events
}
}
})
]
})
let addChatWindowMessage = function (subject, message, subject_color, message_color, merge) {
let msg = ""
if (merge) {
msg = `<span style="color: ${subject_color ? subject_color : "#fff"};">${subject}</span> <span style="color: ${message_color ? message_color : "#fff"};">${message}</span>`
}
else {
msg = `<span style="color: ${subject_color ? subject_color : "#fff"};">${subject}</span>: <span style="color: ${message_color ? message_color : "#fff"};">${message}</span>`
}
let chatElement = new DynamicElement({
tag: 'span',
visible: true,
styles: {
fontFamily: 'Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
display: 'block',
fontSize: '14px',
lineHeight: '14px',
fontWeight: 600,
width: '100%',
color: 'white',
padding: '0',
margin: '0',
textShadow: '1pt 1pt #000',
marginBottom: '1pt',
lineBreak: 'anywhere'
},
childrens: [msg],
})
chatMain.addChild(chatElement)
}
export { chatMain, inputMain, chatWindow, addChatWindowMessage }
-737
View File
@@ -1,737 +0,0 @@
import { nexusLogin, nexusVerify, getPermissionById, postPermissionById, getUserSpecificWorkflowsMeta, getUserSpecificWorkflow } from "./nexus.api.js"
import { addChatWindowMessage, inputMain, chatWindow } from "./nexus.chat.js"
import { UserPanel } from "./nexus.panel.js"
import { WorkflowPanel } from "./nexus.workflow.panel.js"
export class NexusCommands {
constructor(app, api, nexusSocket, color, mouseManger, workflowManager, promptControl) {
this.color = color
this.colors = {
"join": "#ff6e4d",
"info": "#fba510",
"personal": "#fba510",
"name": "#00fa00",
"none": "#ffffff"
}
this.inputMain = inputMain
this.nexusSocket = nexusSocket
this.app = app
this.api = api
this.workflowManager = workflowManager
this.promptControl = promptControl
this.mouseManger = mouseManger
this.nexusSocket.onConnected.push(this.handleConnect.bind(this))
this.nexusSocket.onMessageReceive.push(this.handleMessageReceive.bind(this))
this.inputMain.addEventListener('message', this.handleMessage.bind(this))
document.addEventListener('keydown', this.handleChatWindow.bind(this));
this.disableComfyThings = this.disableComfyThings.bind(this);
this.enableComfyThings = this.enableComfyThings.bind(this);
this.disableGraphSelectionWithExtremePrejudice = this.disableGraphSelectionWithExtremePrejudice.bind(this);
this.comfyThings = [
'#extraOptions',
'#queue-button',
'#comfy-save-button',
'#comfy-file-input',
'#comfy-clear-button',
'#comfy-load-default-button',
'.queue-tab-button.side-bar-button',
'.queue-tab-button.side-bar-button',
'#comfy-refresh-button',
'#queue-front-button',
'#comfy-view-queue-button',
'#comfy-view-history-button'
];
chatWindow.childrens[3].softUpdate('#nexus-chat-window', 'pointer-events', 'none')
chatWindow.show()
setInterval((chatWindow) => {
chatWindow.childrens[0].childrens[1].el.textContent = Object.keys(this.nexusSocket.userManager.users).length
}, 1000, chatWindow);
this.up = new UserPanel()
this.up.onCommandReceived = this.onPanelCommandReceived.bind(this)
this.wp = new WorkflowPanel()
this.wp.onCommandReceived = this.onWFPanelCommandReceived.bind(this)
this.spectating = null
this.lastkey = null
}
timeAgo(timeString) {
const now = new Date(); // current time
const time = new Date(timeString); // input time
const diff = Math.floor((now - time) / 1000); // difference in seconds
const minutes = Math.floor(diff / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return "just now";
if (minutes === 1) return "1 minute ago";
if (minutes < 60) return `${minutes} minutes ago`;
if (hours === 1) return "1 hour ago";
if (hours < 24) return `${hours} hours ago`;
if (days === 1) return "1 day ago";
return `${days} days ago`;
}
async verifyLogin() {
this.disableComfyThings()
let token = localStorage.getItem('nexus-socket-token');
if (token) {
let user = await nexusVerify(token)
if (user && this.nexusSocket.uuid == user.user_id) {
this.enableComfyThings()
this.promptControl.handleControl(true)
this.promptControl.handleEditor(true)
this.workflowManager.handleWorkflowEvents(true)
this.nexusSocket.admin = true
addChatWindowMessage("", "You are now an admin!", this.color.info, this.colors.personal, true)
this.workflowManager.saveManager(60)
}
else {
this.promptControl.handleControl(false)
this.promptControl.handleEditor(false)
this.workflowManager.handleWorkflowEvents(false)
this.workflowManager.removeSaveManager()
addChatWindowMessage("", "You are a viewer!", this.color.info, this.colors.info, true)
}
}
else {
let permissions = await getPermissionById(this.nexusSocket.uuid)
if (permissions) {
let editor = permissions.editor ? permissions.editor : false
let queue = permissions.queue ? permissions.queue : false
this.workflowManager.handleWorkflowEvents(editor)
this.promptControl.handleEditor(editor)
this.promptControl.handleControl(queue)
this.nexusSocket.queue = queue
this.nexusSocket.editor = editor
if (editor) {
this.workflowManager.saveManager(60)
}
else {
this.workflowManager.removeSaveManager()
}
addChatWindowMessage("", `Permission: Workflow Editor ${editor ? "Yes" : "No"} | Queue Prompt ${queue ? "Yes" : "No"} `, this.color.info, this.colors.personal, true)
if (queue) {
addChatWindowMessage("", `You can now queue prompt press CTRL+Enter`, this.color.info, this.colors.personal, true)
}
}
}
this.workflowManager.loadLatestWorkflow()
}
handleConnect() {
let name = this.nexusSocket.userManager.get(this.nexusSocket.uuid, "name")
this.nexusSocket.sendMessage('chat-join', { name }, false)
this.verifyLogin()
}
findChildrenAfterHr(parentClass) {
const parentDiv = document.querySelector(`.${parentClass}`);
const childElementsAfterHr = [];
if (parentDiv) {
const hr = parentDiv.querySelector('hr');
if (hr) {
let sibling = hr.nextElementSibling;
while (sibling) {
childElementsAfterHr.push(sibling);
sibling = sibling.nextElementSibling;
}
}
}
return childElementsAfterHr;
}
disableGraphSelectionWithExtremePrejudice() {
if (!this.nexusSocket.admin && !this.nexusSocket.editor) {
if (this.app.graph && this.app.graph.list_of_graphcanvas && this.app.graph.list_of_graphcanvas.length > 0) {
this.app.graph.list_of_graphcanvas[0].deselectAllNodes()
}
}
}
disableComfyThings() {
this.comfyThings.forEach((selector) => {
const elements = document.querySelectorAll(selector);
elements.forEach((element) => {
element.disabled = true;
element.style.pointerEvents = 'none';
});
});
document.querySelectorAll('.comfy-settings-btn').forEach(button => {
button.disabled = true;
button.style.pointerEvents = 'none';
});
const children = this.findChildrenAfterHr('comfy-menu');
children.forEach(element => {
if (element.tagName === 'BUTTON' || element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') {
element.disabled = true;
}
});
document.querySelector(`.comfy-menu`).style.display = 'none';
document.querySelector(`.comfy-menu`).style.pointerEvents = 'none';
clearInterval(this.disableComfyThingsTimer)
this.disableComfyThingsTimer = setInterval(() => {
this.comfyThings.forEach((selector) => {
const elements = document.querySelectorAll(selector);
elements.forEach((element) => {
element.disabled = true;
element.style.pointerEvents = 'none';
});
});
document.querySelectorAll('.comfy-settings-btn').forEach(button => {
button.disabled = true;
button.style.pointerEvents = 'none';
});
const children = this.findChildrenAfterHr('comfy-menu');
children.forEach(element => {
if (element.tagName === 'BUTTON' || element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') {
element.disabled = true;
}
});
document.querySelector(`.comfy-menu`).style.display = 'none';
document.querySelector(`.comfy-menu`).style.pointerEvents = 'none';
this.promptControl.handleEditor(this.nexusSocket.editor ? this.nexusSocket.editor : false)
this.promptControl.handleControl(this.nexusSocket.queue ? this.nexusSocket.queue : false)
}, 500);
this.disableGraphSelectionWithExtremePrejudiceTimer = setInterval(this.disableGraphSelectionWithExtremePrejudice, 50)
}
enableComfyThings() {
clearInterval(this.disableComfyThingsTimer)
clearInterval(this.disableGraphSelectionWithExtremePrejudiceTimer)
this.comfyThings.forEach((selector) => {
const elements = document.querySelectorAll(selector);
elements.forEach((element) => {
element.disabled = false;
element.style.pointerEvents = 'all';
});
});
document.querySelectorAll('.comfy-settings-btn').forEach(button => {
button.disabled = false;
button.style.pointerEvents = 'all';
});
const children = this.findChildrenAfterHr('comfy-menu');
children.forEach(element => {
if (element.tagName === 'BUTTON' || element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') {
element.disabled = false;
}
});
document.querySelector(`.comfy-menu`).style.display = 'flex';
document.querySelector(`.comfy-menu`).style.pointerEvents = 'all';
}
handleChatWindow(event) {
let key = event.key
let label = inputMain.childrens[0]
let input = inputMain.childrens[1]
let active = {
tag: document.activeElement.tagName,
el: document.activeElement,
chat: document.activeElement == input.el
}
function activateChat() {
let allowedTags = ['INPUT', 'TEXTAREA']
if (!allowedTags.includes(active.tag) && input.visible == false) {
event.preventDefault();
label.show()
input.show()
input.el.focus()
input.el.value = ""
input.focusTimer = setInterval((parent, input) => {
if (document.activeElement != input && parent.visible == true)
input.focus()
}, 500, input, input.el);
chatWindow.childrens[3].softUpdate('#nexus-chat-window', 'pointer-events', 'all')
}
}
function deactivateChat() {
if (!active.chat) return;
label.hide()
input.el.blur();
input.el.value = ""
input.hide()
clearTimeout(input.focusTimer)
chatWindow.childrens[3].softUpdate('#nexus-chat-window', 'pointer-events', 'none')
}
function sendMessage() {
if (!active.chat) return;
let message = input.el.value
inputMain.dispatchEvent(new CustomEvent('message', { detail: { message } }))
deactivateChat()
}
switch (key) {
case 't':
case 'T':
activateChat()
this.togglePlayerTab(true)
this.toggleWFTab(true)
break;
case 'Escape':
deactivateChat()
break;
case 'Enter':
sendMessage()
break;
case 'p':
case 'P':
if (event.altKey && event.shiftKey) {
if (!active.chat)
this.togglePlayerTab()
}
break;
case 'o':
case 'O':
if (event.altKey && event.shiftKey) {
if (!active.chat)
this.toggleWFTab()
}
break;
}
return active
}
toggleWFTab(forceHide, forceReload) {
if (this.nexusSocket.admin || this.nexusSocket.editor) {
if (forceHide) {
this.wp.active = 0
this.wp.panel.hide()
} else if (forceReload) {
this.prepareWFPanel()
}
else {
if (this.wp.active == 0) {
this.wp.active = 1
this.wp.panel.show()
this.prepareWFPanel()
} else if (this.wp.active == 1) {
this.wp.active = 0
this.wp.panel.hide()
}
}
if (this.wp.active == 1) {
this.workflowManager.removeSaveManager()
}
else {
this.workflowManager.saveManager(this.workflowManager.saveTime || 60)
}
}
}
togglePlayerTab(forceHide, forceReload) {
if (forceHide) {
this.up.active = 0
this.up.panel.hide()
} else if (forceReload) {
this.preparePanel(this.nexusSocket.admin)
} else {
if (this.up.active == 0) {
this.up.active = 1
this.up.panel.show()
this.preparePanel(this.nexusSocket.admin)
} else if (this.up.active == 1) {
this.up.active = 0
this.up.panel.hide()
}
}
}
async onWFPanelCommandReceived(kind, label, data, active) {
switch (kind) {
case 'button':
if (label == "load") {
let wfid = data // workflowid is data
let wf = await getUserSpecificWorkflow(this.nexusSocket.uuid, wfid)
if (wf) {
wf.clear = true
let message = {
name: "workflow",
from: this.nexusSocket.uuid,
data: { workflow: wf }
}
this.nexusSocket.onMessageReceive.forEach(f => { f(message); });
if (this.nexusSocket.admin) {
this.nexusSocket.sendMessage('workflow', { workflow: wf }, false)
}
}
}
break;
}
}
async onPanelCommandReceived(kind, label, data, active) {
switch (kind) {
case 'button':
if (label == "spectate") {
this.nexusSocket.sendMessage("spectate", { on: active, receiver: data }, false)
if (active == 1) {
this.spectating = data // userid is data
}
else {
this.spectating = null
}
} else if (label == "editor") {
if (this.nexusSocket.admin) {
let token = localStorage.getItem('nexus-socket-token');
await postPermissionById(data, this.nexusSocket.uuid, { editor: active ? true : false }, token) // userid is data
this.nexusSocket.sendMessage("permission", { receiver: data }, false)
}
} else if (label == "queue") {
if (this.nexusSocket.admin) {
let token = localStorage.getItem('nexus-socket-token');
await postPermissionById(data, this.nexusSocket.uuid, { queue: active ? true : false }, token) // userid is data
this.nexusSocket.sendMessage("permission", { receiver: data }, false)
}
}
else if (label == "mouse") {
this.nexusSocket.sendMessage("mouse_view_toggle", { on: active, receiver: data }, false)
this.nexusSocket.userManager.set(data, 'hide_mouse', active)
}
break;
}
}
async prepareWFPanel() {
const columns = ['Name', 'Created', 'Action'];
this.wp.addColumnsToPanel(columns);
let meta = await getUserSpecificWorkflowsMeta(this.nexusSocket.uuid)
const rows = [];
const callbacks = [];
const values = [];
const data = [];
for (let id = 0; id < meta.length; id++) {
const file = meta[id];
let fn = file.file_name + ""
let name = (fn.includes('lt') ? 'Long Term ' : 'Short Term ')
// let time = file.last_saved.split(':')
// time = time.join("|")
// console.log(time)
let time = this.timeAgo(file.last_saved)
rows.push([name, time, 'button:load'])
callbacks.push([null, null, this.wp.onclick])
values.push([null, null, '0'])
data.push([null, null, file.file_name])
}
this.wp.addRowsToPanel(rows, callbacks, values, data);
}
async preparePanel(admin) {
const columns = ['Name', 'Actions', 'Online'];
this.up.addColumnsToPanel(columns);
const users = this.nexusSocket.userManager.users;
const usersIds = Object.keys(users);
const rows = [];
const callbacks = [];
const values = [];
const data = [];
for (const id of usersIds) {
const user = users[id];
const name = id === this.nexusSocket.uuid ? `${user.name} (You)` : user.name;
const self = id === this.nexusSocket.uuid;
const status = user.status === "online" || self ? '🟢' : '🔴';
const hide_mouse = user.hide_mouse ? 0 : 1;
const permissions = await getPermissionById(id);
const spectating = id == this.spectating ? 1 : 0
callbacks.push([null, self ? 'none' : this.up.onclick, null]);
let powers = 'button:spectate,button:editor,button:queue,button:mouse'
if (!admin) {
powers = 'button:spectate,button:mouse'
}
rows.push([name, self ? 'button:none' : powers, status]);
let row_values = `${spectating},${permissions.editor ? 1 : 0},${permissions.queue ? 1 : 0},${hide_mouse}`
if (!admin) {
row_values = `${spectating},${hide_mouse}`
}
values.push([null, row_values, null]);
let row_data = `${id},${id},${id},${id}`
if (!admin) {
row_data = `${id},${id}`
}
data.push([null, row_data, null]);
}
this.up.addRowsToPanel(rows, callbacks, values, data);
}
handleMessageReceive(message) {
let name = message.name;
let from = message.from;
let data = message.data
// console.log(name, data, from)
if (name == "chat") {
let user_name = this.nexusSocket.userManager.get(from, "name") || "User";
let mouse = this.nexusSocket.userManager.get(from, "mouse") || "#0f0";
let message = data.message
addChatWindowMessage(user_name, message, mouse[3], this.colors.none)
} else if (name == "nick") {
let old_name = data.old_name
let new_name = data.new_name
addChatWindowMessage(old_name + " is now known as ", new_name, this.colors.personal, this.colors.personal, true)
this.nexusSocket.userManager.set(from, "name", new_name)
} else if (name == "chat-join") {
let name = data.name
addChatWindowMessage(name + " joined the server", "", this.colors.personal, this.colors.personal, true)
this.nexusSocket.userManager.set(from, "name", name)
name = this.nexusSocket.userManager.get(this.nexusSocket.uuid, "name")
this.nexusSocket.sendMessage('name', { name }, false)
if (this.up.active) {
this.togglePlayerTab(false, true)
}
} else if (name == "name") {
let name = data.name
this.nexusSocket.userManager.set(from, "name", name)
//console.log("Received name:", name)
if (this.up.active) {
this.togglePlayerTab(false, true)
}
} else if (name == 'afk' || name == 'online') {
this.nexusSocket.userManager.set(from, "status", name)
if (this.up.active) {
this.togglePlayerTab(false, true)
}
} else if (name == 'permission') {
// window.location.reload()
this.verifyLogin()
if (this.up.active) {
this.togglePlayerTab(false, true)
}
}
}
async handleMessage(evt) {
let message = evt.detail.message
message = this.cleanMessage(message)
if (message) {
let startsWithCommand = this.startsWithCommand(message)
if (startsWithCommand) {
let parts = this.extractCommandAndValue(message)
let cmds = { 'nick': true, 'login': true, 'logout': true }
let cmd = parts.command
let values = parts.values
if (cmds[cmd]) {
switch (cmd) {
case 'nick':
let new_name = this.cleanMessage(values[0], 16)
let old_name = this.nexusSocket.userManager.get(this.nexusSocket.uuid, "name") || "User"
if (!new_name) {
addChatWindowMessage("", "Name is invalid", this.colors.info, this.colors.info, true)
}
else {
localStorage.setItem('nexus-socket-name', new_name);
this.nexusSocket.sendMessage('nick', { old_name, new_name })
}
break;
case 'login':
let account = this.cleanMessage(values[0], 16)
let password = this.cleanMessage(values[1], 16)
let uuid = this.nexusSocket.uuid
let token = await nexusLogin(uuid, account, password)
if (token) {
localStorage.setItem('nexus-socket-token', token.token);
// addChatWindowMessage("", "You are now an admin!", this.color.info, this.colors.info, true)
this.verifyLogin()
// window.location.reload()
}
else {
addChatWindowMessage("", "Admin account/password invalid!", this.color.info, this.colors.info, true)
}
break;
case 'logout':
let usertoken = localStorage.getItem('nexus-socket-token')
if (this.nexusSocket.admin) {
await postPermissionById(this.nexusSocket.uuid, this.nexusSocket.uuid, { admin: false, queue: false, editor: false }, usertoken)
localStorage.removeItem('nexus-socket-token');
window.location.reload()
}
break;
default:
break;
}
}
}
else {
addChatWindowMessage("You", message, this.color, this.colors.none)
this.nexusSocket.sendMessage('chat', { message }, false)
}
}
}
cleanMessage(message, length = 512) {
message = message.substr(0, length);
if (message.length === 0) {
return null;
} else {
let msg = "";
const nonAsciiRe = /[^\x00-\x7F]/;
const emojiRe = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F700}-\u{1F77F}]|[\u{1F780}-\u{1F7FF}]|[\u{1F800}-\u{1F8FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA00}-\u{1FA6F}]|[\u{1FA70}-\u{1FAFF}]/u;
const htmlTagRe = /<\/?[^>]+(>|$)/g;
message = message.replace(htmlTagRe, '');
for (let char of message) {
if (!(nonAsciiRe.test(char) && !emojiRe.test(char))) {
msg += char;
}
}
return msg;
}
}
startsWithCommand(str) {
const commandPattern = /^\/\w+(\s+\S+)?/;
return commandPattern.test(str);
}
extractCommandAndValue(str) {
const commandPattern = /^\/(\w+)\s*(.*)/;
const match = str.match(commandPattern);
if (match) {
const command = match[1];
const values = match[2].trim() ? match[2].split(/\s+/) : [];
return {
command,
values
};
} else {
return null;
}
}
}
-261
View File
@@ -1,261 +0,0 @@
export class NexusPromptControl extends EventTarget {
constructor(api, app, nexusSocket) {
super()
this.app = app
this.api = api
this.runningNodeId = null
this.nexusSocket = nexusSocket
this.comfy_events = ['b_preview', 'progress', 'executing', 'executed', 'execution_start', 'execution_error', 'execution_cached'];
let oqp = api.queuePrompt;
this.apiWrapper = {
oqp: oqp,
enableApi: false,
api: api,
qp: async function (number, data) {
if (!this.enableApi) {
return {
"node_errors": {}
};
}
return this.oqp.call(this.api, number, { output: data.output, workflow: data.workflow });
}
};
this.api.queuePrompt = this.apiWrapper.qp.bind(this.apiWrapper);
this.nexusSocket.onMessageReceive.push(this.handleMessageReceive.bind(this))
this.addEventListeners();
this.comfy_events.forEach((event) => {
this.api.addEventListener(event, this.handleComfyEvents.bind(this));
});
this.handleProgressDraw()
this.handleControl(false)
this.handleEditor(false)
}
handleProgressDraw() {
const origDrawNodeShape = LGraphCanvas.prototype.drawNodeShape;
const self = this.app;
const self2 = this;
LGraphCanvas.prototype.drawNodeShape = function (node, ctx, size, fgcolor, bgcolor, selected, mouse_over) {
const res = origDrawNodeShape.apply(this, arguments);
const nodeErrors = self.lastNodeErrors?.[node.id];
let color = null;
let lineWidth = 1;
if (node.id === +self2.runningNodeId) {
color = "#0f0";
} else if (self.dragOverNode && node.id === self.dragOverNode.id) {
color = "dodgerblue";
}
else if (nodeErrors?.errors) {
color = "red";
lineWidth = 2;
}
else if (self.lastExecutionError && self.lastExecutionError.node_id === node.id) {
color = "#f0f";
lineWidth = 2;
}
if (color) {
const shape = node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE;
ctx.lineWidth = lineWidth;
ctx.globalAlpha = 0.8;
ctx.beginPath();
if (shape == LiteGraph.BOX_SHAPE)
ctx.rect(-6, -6 - LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT);
else if (shape == LiteGraph.ROUND_SHAPE || (shape == LiteGraph.CARD_SHAPE && node.flags.collapsed))
ctx.roundRect(
-6,
-6 - LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
this.round_radius * 2
);
else if (shape == LiteGraph.CARD_SHAPE)
ctx.roundRect(
-6,
-6 - LiteGraph.NODE_TITLE_HEIGHT,
12 + size[0] + 1,
12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT,
[this.round_radius * 2, this.round_radius * 2, 2, 2]
);
else if (shape == LiteGraph.CIRCLE_SHAPE)
ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.stroke();
ctx.strokeStyle = fgcolor;
ctx.globalAlpha = 1;
}
if (self.progress && node.id === +self2.runningNodeId) {
ctx.fillStyle = "green";
ctx.fillRect(0, 0, size[0] * (self.progress.value / self.progress.max), 6);
ctx.fillStyle = bgcolor;
}
// Highlight inputs that failed validation
if (nodeErrors) {
ctx.lineWidth = 2;
ctx.strokeStyle = "red";
for (const error of nodeErrors.errors) {
if (error.extra_info && error.extra_info.input_name) {
const inputIndex = node.findInputSlot(error.extra_info.input_name)
if (inputIndex !== -1) {
let pos = node.getConnectionPos(true, inputIndex);
ctx.beginPath();
ctx.arc(pos[0] - node.pos[0], pos[1] - node.pos[1], 12, 0, 2 * Math.PI, false)
ctx.stroke();
}
}
}
}
return res;
}
}
handleMessageReceive(message) {
try {
this.dispatchEvent(new CustomEvent(message.name, { detail: message }));
} catch (e) {
console.error('Error handling message:', e);
}
}
handleComfyEvents(evt) {
if (evt.type === "b_preview") {
this.blobToBase64(evt.detail).then((result) => {
this.nexusSocket.sendMessage(evt.type, { detail: result }, false)
});
} else {
this.nexusSocket.sendMessage(evt.type, { detail: evt.detail }, false)
}
}
addEventListeners() {
this.addEventListener("progress", ({ detail }) => {
detail = detail.data.detail
this.app.progress = detail;
this.app.graph.setDirtyCanvas(true, false);
});
this.addEventListener("executing", ({ detail }) => {
detail = detail.data.detail
this.app.progress = null;
this.runningNodeId = detail;
this.app.graph.setDirtyCanvas(true, false);
delete this.app.nodePreviewImages[this.runningNodeId];
});
this.addEventListener("executed", ({ detail }) => {
detail = detail.data.detail
const output = this.app.nodeOutputs[detail.node];
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
const v = output[k];
if (v instanceof Array) {
output[k] = v.concat(detail.output[k]);
} else {
output[k] = detail.output[k];
}
}
} else {
this.app.nodeOutputs[detail.node] = detail.output;
}
const node = this.app.graph.getNodeById(detail.node);
if (node && node.onExecuted) {
node.onExecuted(detail.output);
}
});
this.addEventListener("execution_start", ({ detail }) => {
detail = detail.data.detail
this.runningNodeId = null;
this.app.lastExecutionError = null;
this.app.graph._nodes.forEach((node) => {
if (node.onExecutionStart) {
node.onExecutionStart();
}
});
});
this.addEventListener("execution_error", ({ detail }) => {
detail = detail.data.detail
this.app.lastExecutionError = detail;
const formattedError = this.formatExecutionError(detail);
this.app.ui.dialog.show(formattedError);
this.app.canvas.draw(true, true);
});
this.addEventListener("b_preview", ({ detail }) => {
detail = detail.data.detail
const id = this.runningNodeId;
if (id == null) return;
const blob = this.base64ToBlob(detail);
const blobUrl = URL.createObjectURL(blob);
this.app.nodePreviewImages[id] = [blobUrl];
});
}
blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
base64ToBlob(dataUrl) {
const [header, base64] = dataUrl.split(',');
const mime = header.match(/:(.*?);/)[1];
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: mime });
}
formatExecutionError(error) {
if (error == null) {
return "(unknown error)";
}
const traceback = error.traceback.join("");
const nodeId = error.node_id;
const nodeType = error.node_type;
return `Error occurred when executing ${nodeType}:\n\n${error.exception_message}\n\n${traceback}`;
}
handleEditor(oN) {
this.app.canvas.allow_dragnodes = oN;
this.app.canvas.allow_interaction = oN;
this.app.canvas.allow_reconnect_links = oN;
this.app.canvas.allow_searchbox = oN;
}
handleControl(oN) {
this.apiWrapper.enableApi = oN;
}
}
-217
View File
@@ -1,217 +0,0 @@
import { StyleInjector } from "./nexus.styles.js";
export class DynamicElement extends EventTarget {
id = '';
tag = '';
styles = {};
hoverStyles = {};
focusStyles = {};
activeStyles = {};
attrs = {};
childrens = [];
events = {};
appendTo = document.body;
visible = false;
afterChildrenAppend = null;
afterCreated = null;
defaultDisplay = 'initial';
el = null;
constructor({
id,
tag,
styles = {},
hoverStyles = {},
focusStyles = {},
activeStyles = {},
attrs = {},
childrens = [],
events = {},
appendTo = document.body,
afterChildrenAppend = null,
afterCreated = null,
visible = false
} = {}) {
super()
this.id = id;
this.tag = tag;
this.styles = styles;
this.hoverStyles = hoverStyles;
this.focusStyles = focusStyles;
this.activeStyles = activeStyles;
this.attrs = attrs;
this.childrens = childrens;
this.events = events;
this.appendTo = appendTo;
this.afterChildrenAppend = afterChildrenAppend;
this.afterCreated = afterCreated;
this.visible = visible
this.createElement();
}
createElement() {
if (!this.tag) {
throw new Error('Tag is required to create an element.');
}
this.el = document.createElement(this.tag);
if (this.id) this.el.id = this.id;
this.appendTo.appendChild(this.el);
this.addStyles();
this.addAttrs();
this.addChildrens();
this.addEvents();
this.addStateStyles();
if (!this.visible) {
this.hide()
}
if (this.afterCreated) {
this.afterCreated(this)
}
}
removeElement() {
if (!this.el) return;
this.removeAttrs();
this.removeEvents();
this.removeStyles();
this.removeChildrens();
this.el.remove();
this.el = null;
}
updateStyles(styles = {}) {
this.styles = { ...this.styles, ...styles };
this.addStyles();
}
addStyles() {
if (!this.el) return;
Object.entries(this.styles).forEach(([style, value]) => {
if (style === 'display') {
this.defaultDisplay = value;
}
this.el.style[style] = value;
});
}
removeStyles() {
if (!this.el) return;
Object.keys(this.styles).forEach(style => {
this.el.style[style] = '';
});
this.styles = {};
}
updateAttrs(attrs = {}) {
this.attrs = { ...this.attrs, ...attrs };
this.addAttrs();
}
addAttrs() {
if (!this.el) return;
Object.entries(this.attrs).forEach(([attr, value]) => {
this.el.setAttribute(attr, value);
});
}
removeAttrs() {
if (!this.el) return;
Object.keys(this.attrs).forEach(attr => {
this.el.removeAttribute(attr);
});
this.attrs = {};
}
addChildrens() {
if (!this.el) return;
this.childrens.forEach(child => {
if (typeof child === 'string') {
this.el.insertAdjacentHTML('beforeend', child);
} else if (child instanceof Node) {
this.el.appendChild(child);
} else if (child instanceof StyleInjector) {
this.el.appendChild(child.el);
} else if (child instanceof DynamicElement) {
this.el.appendChild(child.el);
}
});
if (this.afterChildrenAppend) {
requestAnimationFrame(() => this.afterChildrenAppend(this.childrens, this));
}
}
addChild(child) {
if (!this.el) return;
if (typeof child === 'string') {
this.el.insertAdjacentHTML('beforeend', child);
} else if (child instanceof Node) {
this.el.appendChild(child);
} else if (child instanceof DynamicElement) {
this.el.appendChild(child.el);
}
if(!this.childrens.includes(child)) {
this.childrens.push(child)
}
if (this.afterChildrenAppend) {
this.afterChildrenAppend(this.childrens, this);
}
}
removeChildrens() {
if (!this.el) return;
this.el.innerHTML = '';
this.childrens = [];
}
addEvents() {
if (!this.el) return;
Object.entries(this.events).forEach(([event, handler]) => {
this.el.addEventListener(event, (e) => { handler(e, this.parent) });
});
}
removeEvents() {
if (!this.el) return;
Object.entries(this.events).forEach(([event, handler]) => {
this.el.removeEventListener(event, handler);
});
this.events = {};
}
show() {
if (!this.el) return;
this.el.style.display = this.defaultDisplay;
this.visible = true;
}
hide() {
if (!this.el) return;
this.el.style.display = 'none';
this.visible = false;
}
addStateStyles() {
if (!this.el) return;
this.el.addEventListener('mouseenter', () => this.applyStyles(this.hoverStyles));
this.el.addEventListener('mouseleave', () => this.applyStyles(this.styles));
this.el.addEventListener('focus', () => this.applyStyles(this.focusStyles), true);
this.el.addEventListener('blur', () => this.applyStyles(this.styles), true);
this.el.addEventListener('mousedown', () => this.applyStyles(this.activeStyles));
this.el.addEventListener('mouseup', () => this.applyStyles(this.styles));
}
applyStyles(stateStyles) {
if (!this.visible) return
Object.entries(stateStyles).forEach(([style, value]) => {
this.el.style[style] = value;
});
}
}
-693
View File
@@ -1,693 +0,0 @@
LiteGraph.use_uuids = true
LiteGraph.uuidv4 = function() { return Date.now() }
LGraph.prototype.getGroupByUUID = function (uuid) {
let groups = this._groups
let group = null
for (let index = 0; index < groups.length; index++) {
let tgroup = groups[index];
if (tgroup.uuid && tgroup.uuid == uuid) {
group = tgroup
break
}
}
return group
}
LGraph.prototype.getGroupIndexByUUID = function (uuid) {
let groups = this._groups
let at = -1
for (let index = 0; index < groups.length; index++) {
let tgroup = groups[index];
if (tgroup.uuid && tgroup.uuid == uuid) {
at = index
break
}
}
return at
}
LGraph.prototype.add = function (node, skip_compute_order) {
if (!node) {
return;
}
//groups
if (node.constructor === LGraphGroup) {
this._groups.push(node);
this.setDirtyCanvas(true);
this.change();
if(!node.uuid) {
node.uuid = LiteGraph.uuidv4()
}
node.graph = this;
this._version++;
if (this.onGroupAdded)
this.onGroupAdded(node)
return;
}
//nodes
if (node.id != -1 && this._nodes_by_id[node.id] != null) {
console.warn(
"Nexus LiteGraph: there is already a node with this ID, will reuse it"
);
if (LiteGraph.use_uuids) {
node.id = LiteGraph.uuidv4()
}
else {
node.id = ++this.last_node_id;
}
}
if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) {
throw "LiteGraph: max number of nodes in a graph reached";
}
//give him an id
if (LiteGraph.use_uuids) {
if (node.id == null || node.id == -1)
node.id = LiteGraph.uuidv4();
let uuid = localStorage.getItem('nexus-socket-uuid');
if (uuid) {
node.properties.owner = uuid
}
this.last_node_id = node.id;
}
else {
if (node.id == null || node.id == -1) {
node.id = ++this.last_node_id;
} else if (this.last_node_id < node.id) {
this.last_node_id = node.id;
}
}
node.graph = this;
this._version++;
this._nodes.push(node);
this._nodes_by_id[node.id] = node;
if (node.onAdded) {
node.onAdded(this);
}
if (this.config.align_to_grid) {
node.alignToGrid();
}
if (!skip_compute_order) {
this.updateExecutionOrder();
}
if (this.onNodeAdded) {
this.onNodeAdded(node);
}
this.setDirtyCanvas(true);
this.change();
return node; //to chain actions
};
LGraph.prototype.addNexus = function (node, skip_compute_order) {
if (!node) {
return;
}
//groups
if (node.constructor === LGraphGroup) {
this._groups.push(node);
this.setDirtyCanvas(true);
this.change();
// node.id = LiteGraph.uuidv4();
node.graph = this;
this._version++;
return;
}
// //nodes
// if (node.id != -1 && this._nodes_by_id[node.id] != null) {
// console.warn(
// "Nexus LiteGraph: there is already a node with this ID, will reuse it"
// );
// if (LiteGraph.use_uuids) {
// node.id = LiteGraph.uuidv4();
// }
// else {
// node.id = ++this.last_node_id;
// }
// }
if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) {
throw "LiteGraph: max number of nodes in a graph reached";
}
//give him an id
// if (LiteGraph.use_uuids) {
// if (node.id == null || node.id == -1)
// let uuid = localStorage.getItem('nexus-socket-uuid');
// if (uuid) {
// node.properties.owner = uuid
// }
// node.id = LiteGraph.uuidv4();
// }
// else {
// if (node.id == null || node.id == -1) {
// node.id = ++this.last_node_id;
// } else if (this.last_node_id < node.id) {
this.last_node_id = node.id;
// }
// }
node.graph = this;
this._version++;
this._nodes.push(node);
this._nodes_by_id[node.id] = node;
if (this.config.align_to_grid) {
node.alignToGrid();
}
if (!skip_compute_order) {
this.updateExecutionOrder();
}
if (this.onNodeAddedNexus) {
this.onNodeAddedNexus(node);
}
this.setDirtyCanvas(true);
this.change();
return node; //to chain actions
};
LGraph.prototype.remove = function (node) {
if (node.constructor === LiteGraph.LGraphGroup) {
var index = this._groups.indexOf(node);
if (index != -1) {
this._groups.splice(index, 1);
}
node.graph = null;
this._version++;
this.setDirtyCanvas(true, true);
this.change();
if (this.onGroupRemoved)
this.onGroupRemoved(node)
return;
}
if (this._nodes_by_id[node.id] == null) {
return;
} //not found
if (node.ignore_remove) {
return;
} //cannot be removed
this.beforeChange(); //sure? - almost sure is wrong
//disconnect inputs
if (node.inputs) {
for (var i = 0; i < node.inputs.length; i++) {
var slot = node.inputs[i];
if (slot.link != null) {
node.disconnectInput(i);
}
}
}
//disconnect outputs
if (node.outputs) {
for (var i = 0; i < node.outputs.length; i++) {
var slot = node.outputs[i];
if (slot.links != null && slot.links.length) {
node.disconnectOutput(i);
}
}
}
//node.id = -1; //why?
//callback
if (node.onRemoved) {
node.onRemoved();
}
node.graph = null;
this._version++;
//remove from canvas render
if (this.list_of_graphcanvas) {
for (var i = 0; i < this.list_of_graphcanvas.length; ++i) {
var canvas = this.list_of_graphcanvas[i];
if (canvas.selected_nodes[node.id]) {
delete canvas.selected_nodes[node.id];
}
if (canvas.node_dragged == node) {
canvas.node_dragged = null;
}
}
}
//remove from containers
var pos = this._nodes.indexOf(node);
if (pos != -1) {
this._nodes.splice(pos, 1);
}
delete this._nodes_by_id[node.id];
if (this.onNodeRemoved) {
this.onNodeRemoved(node);
}
//close panels
this.sendActionToCanvas("checkPanels");
this.setDirtyCanvas(true, true);
this.afterChange(); //sure? - almost sure is wrong
this.change();
this.updateExecutionOrder();
};
LGraph.prototype.removeNexus = function (node) {
if (node.constructor === LiteGraph.LGraphGroup) {
var index = this._groups.indexOf(node);
if (index != -1) {
this._groups.splice(index, 1);
}
node.graph = null;
this._version++;
this.setDirtyCanvas(true, true);
this.change();
return;
}
if (this._nodes_by_id[node.id] == null) {
return;
} //not found
if (node.ignore_remove) {
return;
} //cannot be removed
this.beforeChange(); //sure? - almost sure is wrong
//disconnect inputs
if (node.inputs) {
for (var i = 0; i < node.inputs.length; i++) {
var slot = node.inputs[i];
if (slot.link != null) {
node.disconnectInput(i);
}
}
}
//disconnect outputs
if (node.outputs) {
for (var i = 0; i < node.outputs.length; i++) {
var slot = node.outputs[i];
if (slot.links != null && slot.links.length) {
node.disconnectOutput(i);
}
}
}
//node.id = -1; //why?
//callback
if (node.onRemoved) {
node.onRemoved();
}
node.graph = null;
this._version++;
//remove from canvas render
if (this.list_of_graphcanvas) {
for (var i = 0; i < this.list_of_graphcanvas.length; ++i) {
var canvas = this.list_of_graphcanvas[i];
if (canvas.selected_nodes[node.id]) {
delete canvas.selected_nodes[node.id];
}
if (canvas.node_dragged == node) {
canvas.node_dragged = null;
}
}
}
//remove from containers
var pos = this._nodes.indexOf(node);
if (pos != -1) {
this._nodes.splice(pos, 1);
}
delete this._nodes_by_id[node.id];
//close panels
this.sendActionToCanvas("checkPanels");
this.setDirtyCanvas(true, true);
this.afterChange(); //sure? - almost sure is wrong
this.change();
this.updateExecutionOrder();
};
LGraphNode.prototype.configureNexus = function (info) {
if (this.graph) {
this.graph._version++;
}
for (var j in info) {
if (j == "properties") {
//i don't want to clone properties, I want to reuse the old container
for (var k in info.properties) {
this.properties[k] = info.properties[k];
}
continue;
}
if (info[j] == null) {
continue;
} else if (typeof info[j] == "object") {
//object
if (this[j] && this[j].configure) {
this[j].configure(info[j]);
} else {
this[j] = LiteGraph.cloneObject(info[j], this[j]);
}
} //value
else {
this[j] = info[j];
}
}
if (!info.title) {
this.title = this.constructor.title;
}
if (this.inputs) {
for (var i = 0; i < this.inputs.length; ++i) {
var input = this.inputs[i];
if (this.onInputAdded)
this.onInputAdded(input);
}
}
if (this.outputs) {
for (var i = 0; i < this.outputs.length; ++i) {
var output = this.outputs[i];
if (!output.links) {
continue;
}
if (this.onOutputAdded)
this.onOutputAdded(output);
}
}
if (this.widgets) {
for (var i = 0; i < this.widgets.length; ++i) {
var w = this.widgets[i];
if (!w)
continue;
if (w.options && w.options.property && (this.properties[w.options.property] != undefined))
w.value = JSON.parse(JSON.stringify(this.properties[w.options.property]));
}
if (info.widgets_values) {
for (var i = 0; i < info.widgets_values.length; ++i) {
if (this.widgets[i]) {
this.widgets[i].value = info.widgets_values[i];
}
}
}
}
if (this.onConfigure) {
this.onConfigure(info);
}
};
LGraphCanvas.prototype.selectNodesNexus = function (nodes, add_to_current_selection) {
if (!add_to_current_selection) {
this.deselectAllNodes();
}
nodes = nodes || this.graph._nodes;
if (typeof nodes == "string") nodes = [nodes];
for (var i in nodes) {
var node = nodes[i];
if (node.is_selected) {
this.deselectNode(node);
continue;
}
if (!node.is_selected && node.onSelected) {
node.onSelected();
}
node.is_selected = true;
this.selected_nodes[node.id] = node;
if (node.inputs) {
for (var j = 0; j < node.inputs.length; ++j) {
this.highlighted_links[node.inputs[j].link] = true;
}
}
if (node.outputs) {
for (var j = 0; j < node.outputs.length; ++j) {
var out = node.outputs[j];
if (out.links) {
for (var k = 0; k < out.links.length; ++k) {
this.highlighted_links[out.links[k]] = true;
}
}
}
}
}
this.setDirty(true);
};
LGraphCanvas.prototype.deselectNodeNexus = function(node) {
if (!node.is_selected) {
return;
}
if (node.onDeselected) {
node.onDeselected();
}
node.is_selected = false;
//remove highlighted
if (node.inputs) {
for (var i = 0; i < node.inputs.length; ++i) {
delete this.highlighted_links[node.inputs[i].link];
}
}
if (node.outputs) {
for (var i = 0; i < node.outputs.length; ++i) {
var out = node.outputs[i];
if (out.links) {
for (var j = 0; j < out.links.length; ++j) {
delete this.highlighted_links[out.links[j]];
}
}
}
}
};
LGraphGroup.prototype.move = function (deltax, deltay, ignore_nodes) {
this._pos[0] += deltax;
this._pos[1] += deltay;
if (ignore_nodes) {
return;
}
for (var i = 0; i < this._nodes.length; ++i) {
var node = this._nodes[i];
node.pos[0] += deltax;
node.pos[1] += deltay;
if (this.onGroupNodeMoved)
this.onGroupNodeMoved(node)
}
if (this.onGroupMoved)
this.onGroupMoved(this)
};
LGraphGroup.prototype.configure = function (o) {
this.title = o.title;
this.owner = o.owner;
this.uuid = o.uuid;
this._bounding.set(o.bounding);
this.color = o.color;
if (o.font_size) {
this.font_size = o.font_size;
}
};
LGraphGroup.prototype.serialize = function () {
var b = this._bounding;
return {
title: this.title,
bounding: [
Math.round(b[0]),
Math.round(b[1]),
Math.round(b[2]),
Math.round(b[3])
],
uuid: this.uuid,
color: this.color,
font_size: this.font_size
};
};
LGraph.prototype.addNode = function (nodeData) {
let node = LiteGraph.createNode(nodeData.type, nodeData.title);
if (!node) {
console.warn("Node type not found: " + nodeData.type);
return false;
}
node.id = nodeData.id;
node.configureNexus(nodeData);
this.addNexus(node);
return true;
};
LGraph.prototype.removeNode = function (data) {
let node = this.getNodeById(data.id);
if (!node) {
console.warn("Node ID not found: " + data);
return false;
}
this.removeNexus(node);
return true;
};
LGraph.prototype.addLink = function (linkData) {
let link = new LiteGraph.LLink();
link.configure(linkData);
this.links[link.id] = link;
return;
};
LGraph.prototype.addGroup = function (data) {
var group = new LiteGraph.LGraphGroup();
group.configure(data);
this._groups.push(group);
this.setDirtyCanvas(true);
this.change();
group.graph = this;
this._version++;
if (this.onGroupAdded)
this.onGroupAdded(group)
return;
};
LGraph.prototype.removeGroup = function (group) {
let existingGroupIndex = this.getGroupIndexByUUID(group.uuid);
if (existingGroupIndex != -1) {
this._groups.splice(existingGroupIndex, 1);
}
group.graph = null;
this._version++;
this.setDirtyCanvas(true, true);
this.change();
return;
};
LGraph.prototype.updateNode = function (nodeData, type = "all") {
let existingNode = this.getNodeById(nodeData.id);
if (existingNode) {
if (type == "all") {
existingNode.configureNexus(nodeData);
} else if (type == "widgets") {
if (existingNode.widgets) {
for (var i = 0; i < existingNode.widgets.length; ++i) {
var w = existingNode.widgets[i];
if (!w)
continue;
if (w.options && w.options.property && (existingNode.properties[w.options.property] != undefined))
w.value = JSON.parse(JSON.stringify(existingNode.properties[w.options.property]));
}
if (nodeData.widgets_values) {
for (var i = 0; i < nodeData.widgets_values.length; ++i) {
if (existingNode.widgets[i]) {
existingNode.widgets[i].value = nodeData.widgets_values[i];
}
}
}
}
}
} else {
this.addNode(nodeData);
}
};
LGraph.prototype.updateLink = function (linkData) {
if (linkData) {
let id
if (linkData instanceof Object) {
id = linkData.id
} else {
id = linkData[0]
}
let existingLink = this.links[id];
if (existingLink) {
existingLink.configure(linkData);
} else {
this.addLink(linkData);
}
}
};
LGraph.prototype.updateLinkManual = function (change, linkData, node, graph) {
if (change == "add") {
this.updateLink(linkData)
} else if (change == "remove") {
let existingLink = this.links[linkData[0]];
if (existingLink) {
this.removeLink(linkData[0])
delete this.links[linkData[0]]
}
}
if (node) {
this.updateNode(node)
}
graph.updateExecutionOrder();
graph._version++;
graph.setDirtyCanvas(true, true);
};
LGraph.prototype.updateGroup = function (groupData) {
let existingGroup = this.getGroupByUUID(groupData.uuid);
if (existingGroup) {
existingGroup.configure(groupData);
} else {
this.addGroup(groupData);
}
};
LGraph.prototype.updateNodes = function (newNodes) {
newNodes.forEach(nodeData => this.updateNode(nodeData));
};
LGraph.prototype.updateLinks = function (newLinks) {
newLinks.forEach(linkData => this.updateLink(linkData));
};
LGraph.prototype.updateGroups = function (newGroups) {
newGroups.forEach(groupData => this.updateGroup(groupData));
};
-190
View File
@@ -1,190 +0,0 @@
export class NexusMouseManager {
hexColors = ["#1abc9c","#2ecc71","#3498db","#9b59b6","#f1c40f","#e67e22","#e74c3c","#ecf0f1"]
createMouseSvg(color) {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 96 96" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"
width="96" height="96">
<path d="M22.311034,9.174631v81.070373L48,71.061684l35.530843-8.173761L22.311034,9.174631Z"
transform="translate(0-1.709817)" stroke="${color}" fill="${color}" stroke-width="7" stroke-linecap="round"
stroke-linejoin="round" />
</svg>`
}
svgStringToImage(svgString) {
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const image = new Image();
image.src = url;
return image;
}
hex2rgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
getRandomHexColor() {
return this.hexColors[Math.floor(Math.random() * this.hexColors.length)];
}
constructor(nexusSocket, app, fps) {
this.color = this.getRandomHexColor()
this.app = app
this.fps = fps
this.canSendMouse = false
this.nexusSocket = nexusSocket
this.nexusSocket.onConnected.push(this.handleConnect.bind(this))
this.nexusSocket.onMessageReceive.push(this.handleMessageReceive.bind(this))
document.addEventListener("visibilitychange", this.handleVisiblityChange.bind(this))
document.addEventListener('mousemove', this.handleSendMouseControl.bind(this));
this.app.graph.list_of_graphcanvas[0].onDrawForeground = this.handlePointerDraw.bind(this)
setInterval(this.handleSendMouse.bind(this), 1000 / this.fps)
}
throttle(func, limit) {
let lastFunc;
let lastRan;
return function (...args) {
const context = this;
if (!lastRan) {
func.apply(context, args);
lastRan = Date.now();
} else {
clearTimeout(lastFunc);
lastFunc = setTimeout(function () {
if ((Date.now() - lastRan) >= limit) {
func.apply(context, args);
lastRan = Date.now();
}
}, limit - (Date.now() - lastRan));
}
};
}
handleCanvasDraw() {
if (this.app.graph && this.app.graph.list_of_graphcanvas && this.app.graph.list_of_graphcanvas.length > 0) {
this.app.graph.list_of_graphcanvas[0].draw(true, true);
}
}
handleConnect() {
this.drawTimer = setInterval(this.handleCanvasDraw.bind(this), 1000 / this.fps)
}
handleMessageReceive(message) {
let name = message.name;
let from = message.from;
let data = message.data
if (name == "mouse") {
let mouse = this.nexusSocket.userManager.get(from, "mouse");
let pointer = this.nexusSocket.userManager.get(from, "pointer");
if (!pointer) {
this.nexusSocket.userManager.set(from, "pointer", this.svgStringToImage(this.createMouseSvg(data.mouse[3])));
} else if (pointer && mouse && mouse[3] != data.mouse[3]) {
this.nexusSocket.userManager.set(from, "pointer", this.svgStringToImage(this.createMouseSvg(data.mouse[3])));
}
this.nexusSocket.userManager.set(from, "mouse", data.mouse)
} else if (name == "hide_mouse") {
this.nexusSocket.userManager.set(from, "mouse", null)
} else if (name== "mouse_view_toggle") {
this.nexusSocket.userManager.set(from, "mouse_view", data.on)
if(data.on == 0){
this.nexusSocket.sendMessage("hide_mouse", { receiver: from }, false)
}
}
}
handleVisiblityChange() {
if (document.visibilityState === "hidden") {
clearInterval(this.drawTimer)
this.nexusSocket.sendMessage("hide_mouse", {}, false)
this.nexusSocket.sendMessage("afk", {}, false)
} else {
this.handleConnect()
this.nexusSocket.sendMessage("online", {}, false)
}
}
handleSendMouse = function () {
if (this.app.graph && this.app.graph.list_of_graphcanvas && this.app.graph.list_of_graphcanvas.length > 0) {
this.mouse = this.app.graph.list_of_graphcanvas[0].canvas_mouse
this.scale = this.app.graph.list_of_graphcanvas[0].ds.scale
}
this.mouse[3] = this.color
this.mouse[4] = this.scale / 10
if (this.canSendMouse) {
let users = Object.keys(this.nexusSocket.userManager.users)
for (let index = 0; index < users.length; index++) {
let user_id = users[index]
let view = this.nexusSocket.userManager.get(user_id, "mouse_view");
if(view != null) {
if(view == 1) {
this.nexusSocket.sendMessage('mouse', { mouse: this.mouse, receiver: user_id }, false)
}
}
else {
this.nexusSocket.sendMessage('mouse', { mouse: this.mouse, receiver: user_id }, false)
}
}
this.canSendMouse = false
}
}
handleSendMouseControl() {
this.canSendMouse = true
}
handlePointerDraw = function (ctx) {
Object.keys(this.nexusSocket.userManager.users).forEach(id => {
if (id != this.nexusSocket.uuid) {
let mouse = this.nexusSocket.userManager.get(id, "mouse");
let name = this.nexusSocket.userManager.get(id, "name") || "User";
let pointerImage = this.nexusSocket.userManager.get(id, "pointer");
let hidden = this.nexusSocket.userManager.get(id, "mouse_hidden");
if(hidden == 0) {
mouse = null
}
if (mouse && mouse.length > 0) {
let mouseX = mouse[0] - 4;
let mouseY = mouse[1] - 4;
let scale = 1 - mouse[4];
ctx.drawImage(pointerImage, mouseX, mouseY, 18, 18);
ctx.font = '14px Arial';
ctx.fillStyle = mouse[3];
ctx.fillText(name, mouseX + 22, mouseY + 16);
let color = this.hex2rgb(mouse[3])
ctx.beginPath();
ctx.arc(mouseX, mouseY, scale * 600, 0, 2 * Math.PI, false);
ctx.strokeStyle = `rgba(${color.r},${color.g},${color.b},0.1)`;
ctx.lineWidth = 2;
ctx.stroke();
}
}
});
}
}
-258
View File
@@ -1,258 +0,0 @@
import { DynamicElement } from "./nexus.elements.js"
import { StyleInjector } from "./nexus.styles.js";
export class UserPanel {
constructor() {
this.active = 0
this.panel = new DynamicElement({
id: 'nexus-player-panel',
tag: 'div',
visible: false,
styles: {
"width": "100%",
"height": "100vh",
"display": "flex",
"align-items": "center",
"justify-content": "center",
"box-sizing": "border-box",
"position": "absolute",
"left": "0",
"top": "0",
"z-index": "10000"
},
childrens: [
new DynamicElement({
id: 'nexus-player-panel-table',
tag: 'div',
visible: true,
styles: {
"width": "832px",
"display": "flex",
"flex-direction": "column",
"border-radius": "6px",
"box-sizing": "border-box"
},
childrens: [
new DynamicElement({
id: 'nexus-player-panel-table-body',
tag: 'div',
visible: true,
styles: {
"width": "100%",
"display": "flex",
"flex-direction": "column",
"align-items": "center",
"justify-content": "flex-start",
"color": "#a3a3a3",
"font-family": "Calibri",
"font-weight": "600",
"gap": "12px",
"padding-bottom": "12px",
"box-sizing": "border-box",
"background": "rgba(54,54,54,0.8)",
"border-radius": "6px",
"box-shadow": "4px 8px 8px rgba(0,0,0,0.5)",
"max-height": "540px",
"overflowY": "auto"
}
}),
new StyleInjector({
styles: {
"#nexus-player-panel .table-row": {
"width": "100%",
"display": "grid",
"align-items": "center",
"grid-template-columns": "20% 60% 20%",
"justify-content": "space-between",
},
"#nexus-player-panel .table-row:first-child": {
"width": "100%",
"color": "#a3a3a3",
"padding": "16px 0",
"background": "rgb(0,0,0,0.6)",
"border-top-left-radius": "6px",
"border-top-right-radius": "6px",
"position": "sticky",
"top": "0",
},
"#nexus-player-panel .table-row .table-row-child": {
"display": "flex",
"justify-content": "flex-start",
"gap": "12px",
"padding-left": "12px",
"width": "100%"
},
"#nexus-player-panel .table-row-child button": {
"border": "none",
"padding": "6px 12px",
"border-radius": "3px",
"cursor": "pointer",
"background": "rgba(94,94,94)",
"color": "#a1a1a1",
},
"#nexus-player-panel .table-row-child button:active": {
"opacity": "0.8"
},
"#nexus-player-panel .table-row-child button[active='1'][label='spectate']": {
"background": "#218c74",
"color": "#fafafa"
},
"#nexus-player-panel .table-row-child button[active='1'][label='editor']": {
"background": "#474787",
"color": "#fafafa"
},
"#nexus-player-panel .table-row-child button[active='1'][label='mouse']": {
"background": "#cd6133",
"color": "#fafafa"
},
"#nexus-player-panel .table-row-child button[active='1'][label='queue']": {
"background": "#6F1E51",
"color": "#fafafa"
}
}
})
]
})
],
})
this.onCommandReceived = null
this.buttons = []
this.columns = []
}
addColumnsToPanel(columns) {
this.columns = columns
}
addRowsToPanel(rows, callbacks, values, data) {
let tbody = this.panel.childrens[0].childrens[0];
tbody.removeChildrens();
this.buttons = []
const tr = document.createElement('div');
tr.className = "table-row";
this.columns.forEach(heading => {
const th = document.createElement('span');
th.className = "table-row-child"
th.textContent = heading;
tr.appendChild(th)
});
tbody.addChild(tr)
rows.forEach((row, i) => {
const tr = document.createElement('div');
tr.className = "table-row";
row.forEach((cell, j) => {
let td = document.createElement('span');
td.className = "table-row-child"
let hascomma = cell.indexOf(",") > -1
let hascolon = cell.indexOf(":") > -1
let content = []
if (hascomma) {
content = cell.split(',')
content.forEach((elm, k) => {
let elmcontent = elm.split(':')
this.addChildToParent(elmcontent, td, callbacks, values, data, i, j, k)
})
}
else if (hascolon) {
content = cell.split(':')
this.addChildToParent(content, td, callbacks, values, data, i, j, -1)
} else {
td.textContent = cell
}
tr.appendChild(td);
});
tbody.addChild(tr)
});
}
addChildToParent(content, td, callbacks, values, data, i, j, k) {
//console.log(content, td, callbacks, values, data, i, j, k)
switch (content[0]) {
case 'button':
let btn = document.createElement('button');
if (content[1] == 'none') {
btn.className = "none"
}
if (!this.buttons.includes(btn)) {
this.buttons.push(btn)
}
btn.textContent = content[1]
if (callbacks[i] && callbacks[i][j] && values[i][j] && data[i][j]) {
let inner_values = values[i][j].split(',')
let inner_data = data[i][j].split(',')
if (k != -1) {
btn.setAttribute('data', inner_data[k]);
btn.setAttribute('active', inner_values[k]);
} else {
btn.setAttribute('data', data[i][j]);
btn.setAttribute('active', values[i][j]);
}
btn.setAttribute('kind', content[0]);
btn.setAttribute('label', content[1]);
if (content[1] != 'none') {
btn.onclick = callbacks[i][j].bind(this);
}
}
td.appendChild(btn)
break;
}
}
disableButtons(label) {
this.buttons.forEach(btn => {
let btn_kind = btn.getAttribute('kind')
let btn_data = btn.getAttribute('data')
let btn_active = parseInt(btn.getAttribute('active'))
let btn_label = btn.getAttribute('label')
if (btn_label == label) {
if (btn_active == 1) {
btn.setAttribute('active', 0)
//console.log(btn_kind, btn_label, btn_data, 0)
if (this.onCommandReceived) {
this.onCommandReceived(btn_kind, btn_label, btn_data, 0)
}
}
}
})
}
onclick(e) {
let kind = e.target.getAttribute('kind')
let data = e.target.getAttribute('data')
let label = e.target.getAttribute('label')
let active = parseInt(e.target.getAttribute('active'))
if (kind == "button") {
if (label == "spectate") {
this.disableButtons(label)
}
if (active == 0) {
e.target.setAttribute('active', 1)
} else if (active == 1) {
e.target.setAttribute('active', 0)
}
}
active = parseInt(e.target.getAttribute('active'))
//console.log(kind, label, data, active)
if (this.onCommandReceived) {
this.onCommandReceived(kind, label, data, active)
}
}
}
-135
View File
@@ -1,135 +0,0 @@
import { UserManager } from "./nexus.user.manager.js";
export class NexusSocket extends EventTarget {
getName() {
let name = localStorage.getItem('nexus-socket-name');
if (!name) {
localStorage.setItem('nexus-socket-name', "User");
name = "User"
}
this.userManager.set(this.uuid, "name", name)
return name
}
getUUID() {
let uuid = localStorage.getItem('nexus-socket-uuid');
if (!uuid) {
uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c =>
(Math.random() * 16 | 0).toString(16)
);
localStorage.setItem('nexus-socket-uuid', uuid);
}
return uuid;
}
constructor() {
super();
this.ws = null;
this.userManager = new UserManager();
this.uuid = this.getUUID();
this.admin = false
this.wsUrl = `ws${window.location.protocol === "https:" ? "s" : ""}://${location.host}/nexus?id=${this.uuid}`;
this.onConnected = [this.onConnectedHandler.bind(this)];
this.onDisconnected = [];
this.onMessageReceive = [this.onMessageReceiveHandler.bind(this)];
this.onMessageSend = [];
this.onError = [];
this.onClose = [];
this.getName()
}
onConnectedHandler() {
this.userManager.create(this.uuid);
this.sendMessage('join', { name: this.getName() }, false);
this.sendMessage("online", {}, false)
}
onMessageReceiveHandler(message) {
let name = message.name;
let from = message.from;
switch (name) {
case "join":
this.userManager.create(from);
this.sendMessage('hi', {}, false);
break;
case "hi":
this.userManager.create(from);
break;
}
}
connect() {
try {
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = () => {
if (this.onConnected) {
this.onConnected.forEach(f => { f(); });
}
};
this.ws.onmessage = (msg) => {
try {
const message = JSON.parse(msg.data);
if (this.onMessageReceive) {
this.onMessageReceive.forEach(f => { f(message); });
}
} catch (e) {
console.error('Error handling message:', e);
}
};
this.ws.onerror = (error) => {
if (this.onError) {
this.onError.forEach(f => { f(error); });
}
console.error('WebSocket error:', error);
};
this.ws.onclose = () => {
if (this.onDisconnected) {
this.onDisconnected.forEach(f => { f(); });
}
setTimeout(() => {
this.connect();
}, 5000);
};
} catch (e) {
console.error('Error connecting to WebSocket:', e);
}
}
closeWebSocket() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.close();
} else {
console.log('WebSocket is not open.');
}
}
sendMessage(event_name, event_data, all = true) {
try {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
let data = {}
if(event_data.receiver) {
data.receiver = event_data.receiver
delete event_data.receiver
} else if (event_data.exclude) {
data.exclude = event_data.exclude
delete event_data.exclude
}
data.from = this.uuid
data.name = event_name
data.data = event_data
data.all = all
this.ws.send(JSON.stringify(data));
if (this.onMessageSend) {
this.onMessageSend.forEach(f => { f(message); });
}
}
} catch (e) {
console.error(`Error sending ${event_name} message:`, e);
}
}
}
-37
View File
@@ -1,37 +0,0 @@
export class StyleInjector {
constructor({ styles }) {
this.el = null
this.styles = styles;
this.create()
}
create() {
this.el = document.createElement('style');
let cssString = "";
for (const selector in this.styles) {
cssString += `${selector} { `;
for (const property in this.styles[selector]) {
cssString += `${property}: ${this.styles[selector][property]}; `;
}
cssString += `} `;
}
this.el.textContent = cssString;
}
softUpdate(selector, property, value) {
let cssString = "";
for (const key in this.styles) {
cssString += `${key} { `;
for (const property in this.styles[key]) {
cssString += `${property}: ${this.styles[key][property]}; `;
}
if(key == selector) {
cssString += `${property}: ${value}; `;
}
cssString += `} `;
}
this.el.textContent = cssString;
}
}
-27
View File
@@ -1,27 +0,0 @@
export class UserManager {
constructor() {
this.users = {}
}
create(id) {
if (!this.users[id]) {
this.users[id] = {}
}
}
set(id, key, data) {
this.create(id)
this.users[id][key] = data
}
get(id, key) {
this.create(id)
return this.users[id][key]
}
remove(id) {
if(this.users[id])
delete this.users[id];
return true
}
}
-306
View File
@@ -1,306 +0,0 @@
import "./nexus.litegraph.js"
import { postWorkflow, postBackupWorkflow, getLatestWorkflow } from "./nexus.api.js";
export class NexusWorkflowManager {
constructor(app, api, nexusSocket) {
this.app = app;
this.api = api;
this.nexusSocket = nexusSocket;
this.nexusSocket.onMessageReceive.push(this.handleMessageReceive.bind(this))
this.init()
this.spectators = []
this.saveTime = 0
setInterval(this.handleSpectators.bind(this), 1000 / 24) // 24 fps
}
async init() {
let workflow = (await this.app.graphToPrompt()).workflow;
await postBackupWorkflow(this.nexusSocket.uuid, workflow)
this.app.graph.clear()
}
removeSaveManager() {
if (this.saveTimer) {
clearInterval(this.saveTimer)
}
// document.removeEventListener("visibilitychange", this.handleSaveWorkflowManager.bind(this))
}
async saveManager(time) {
if (this.nexusSocket.admin) {
this.nexusSocket.sendMessage('workflow:timer', { time: time - 2 }, false)
}
this.removeSaveManager()
this.saveTime = time
this.saveTimer = setInterval(this.sendWorkFlow.bind(this), time * 1000)
console.log("Auto Backup Timer Setup:",time)
// document.addEventListener("visibilitychange", this.handleSaveWorkflowManager.bind(this))
}
async sendWorkFlow() {
let workflow = (await this.app.graphToPrompt()).workflow;
await postWorkflow(this.nexusSocket.uuid, workflow)
}
async loadLatestWorkflow() {
let workflow = await getLatestWorkflow()
let links = workflow.links || [];
let nodes = workflow.nodes || [];
let groups = workflow.groups || [];
this.app.graph.updateNodes(nodes);
this.app.graph.updateLinks(links);
this.app.graph.updateGroups(groups);
this.app.graph.updateExecutionOrder();
this.app.graph.setDirtyCanvas(true, true);
}
async handleSaveWorkflowManager() {
this.sendWorkFlow()
// if (document.visibilityState === "hidden") {
// if (this.saveTimer) {
// clearInterval(this.saveTimer)
// }
// }
// else {
// this.saveTimer = setInterval(this.sendWorkFlow.bind(this), this.saveTime * 1000)
// }
}
async handleSpectators() {
if (this.spectators.length > 0) {
let workflow = (await this.app.graphToPrompt()).workflow;
let wfe = workflow.extra
for (let index = 0; index < this.spectators.length; index++) {
const id = this.spectators[index];
this.nexusSocket.sendMessage('workflow:spectate', { workflow: wfe, receiver: id }, false)
}
}
}
async handleMessageReceive(message) {
let from = message.from;
let data = message.data
let name = message.name;
switch (name) {
case "spectate":
if (data.on == 0) {
if (this.spectators.includes(from)) {
this.spectators.splice(this.spectators.indexOf(from), 1)
}
} else if (data.on == 1) {
if (!this.spectators.includes(from)) {
this.spectators.push(from)
}
}
case "join":
// this.sendWorkFlow()
this.saveManager(this.saveTime || 60)
break;
case "workflow:spectate":
let wfe = data.workflow
if (wfe?.ds) {
this.app.canvas.ds.offset = wfe.ds.offset;
this.app.canvas.ds.scale = wfe.ds.scale;
}
break;
case "workflow:timer":
let time = data.time
this.saveManager(time)
break;
case "workflow:update":
this.handleWorkflowUpdate(from, data)
break;
case "workflow":
let workflow = data.workflow
if (workflow.clear) {
await this.app.graph.clear()
}
let links = workflow.links || [];
let nodes = workflow.nodes || [];
let groups = workflow.groups || [];
this.app.graph.updateNodes(nodes);
this.app.graph.updateLinks(links);
this.app.graph.updateGroups(groups);
this.app.graph.updateExecutionOrder();
this.app.graph.setDirtyCanvas(true, true);
}
}
handleWorkflowEvents(oN) {
if (oN) {
this.app.graph.onNodeAdded = this.onNodeAdded.bind(this);
this.app.graph.onNodeAddedNexus = this.onNodeAddedNexus.bind(this);
this.app.graph.onNodeRemoved = this.onNodeRemoved.bind(this);
this.app.canvas.onSelectionChange = this.onSelectionChange.bind(this);
this.app.canvas.onNodeDeselected = this.onNodeDeselected.bind(this);
this.app.canvas.onNodeMoved = this.onNodeMoved.bind(this);
this.app.graph.onGroupAdded = this.onGroupAdded.bind(this);
this.app.graph.onGroupRemoved = this.onGroupRemoved.bind(this);
this.app.graph._groups.forEach(group => {
group.onGroupMoved = this.onGroupMoved.bind(this, group);
group.onGroupNodeMoved = this.onNodeMoved.bind(this);
});
this.app.graph._nodes.forEach(node => {
node.onWidgetChanged = this.onWidgetChanged.bind(this, node);
node.onConnectionsChange = this.onNodeConnectionChange.bind(this, node);
});
}
else {
this.app.graph.onNodeAddedNexus = null;
this.app.graph.onNodeAdded = null;
this.app.graph.onNodeRemoved = null;
this.app.canvas.onSelectionChange = null;
this.app.canvas.onNodeDeselected = null;
this.app.canvas.onNodeMoved = null;
this.app.graph.onGroupAdded = null;
this.app.graph.onGroupRemoved = null;
this.app.graph._groups.forEach(group => {
group.onGroupMoved = null;
group.onGroupNodeMoved = null;
});
this.app.graph._nodes.forEach(node => {
node.onWidgetChanged = null;
node.onConnectionsChange = null;
});
}
}
handleWorkflowUpdate(from, detail) {
let graph = this.app.graph;
let canvas = this.app.canvas;
let update = detail.update;
let data = detail.data;
let existingNode;
switch (update) {
case "node-added":
graph.updateNode(data);
break;
case "node-removed":
graph.removeNode(data);
break;
case "node-moved":
graph.updateNode(data);
break;
case "node-widget-changed":
graph.updateNode(data);
break;
case "node-selection":
let nodes = []
data.forEach(node => {
existingNode = graph.getNodeById(node.id);
if (existingNode) {
nodes.push(existingNode)
}
})
canvas.selectNodesNexus(nodes, true)
break;
case "node-deselection":
existingNode = graph.getNodeById(data.id);
if (existingNode)
canvas.deselectNodeNexus(existingNode)
break;
case "group-added":
graph.updateGroup(data);
break;
case "group-moved":
graph.updateGroup(data);
break;
case "group-removed":
graph.removeGroup(data);
break;
case "node-connection":
graph.updateLinkManual(detail.change, detail.link, detail.node, graph);
break;
default:
//console.log(update);
break;
}
}
onNodeAddedNexus(node) {
node.onWidgetChanged = this.onWidgetChanged.bind(this, node);
node.onConnectionsChange = this.onNodeConnectionChange.bind(this, node);
}
onNodeAdded(node) {
let data = node.serialize();
node.onWidgetChanged = this.onWidgetChanged.bind(this, node);
node.onConnectionsChange = this.onNodeConnectionChange.bind(this, node);
this.nexusSocket.sendMessage('workflow:update', { update: "node-added", data }, false);
}
onWidgetChanged(node) {
let data = node.serialize();
this.nexusSocket.sendMessage('workflow:update', { update: "node-widget-changed", data }, false);
}
onNodeRemoved(node) {
let data = node.serialize();
this.nexusSocket.sendMessage('workflow:update', { update: "node-removed", data }, false);
}
async onNodeConnectionChange(node, a, b, c, d, e) {
let change = c ? "add" : "remove";
let link = d;
this.graphData = (await this.app.graphToPrompt()).workflow;
let data = this.graphData.links;
//console.log(data)
node = this.app.graph.getNodeById(node.id);
node = node ? node.serialize() : null;
this.nexusSocket.sendMessage('workflow:update', { node, update: "node-connection", link, change, data }, false);
}
onSelectionChange(nodes) {
let data = []
Object.keys(nodes).forEach(node_id => {
data.push(nodes[node_id].serialize())
})
this.nexusSocket.sendMessage('workflow:update', { update: "node-selection", data }, false);
}
onNodeDeselected(node) {
let data = node.serialize()
this.nexusSocket.sendMessage('workflow:update', { update: "node-deselection", data }, false);
}
onNodeMoved(node) {
let data = node.serialize();
this.nexusSocket.sendMessage('workflow:update', { update: "node-moved", data }, false);
}
onGroupAdded(group) {
let data = group.serialize();
let uuid = localStorage.getItem('nexus-socket-uuid');
if (uuid) {
data.owner = uuid
group.configure(data);
}
group.onGroupMoved = this.onGroupMoved.bind(this, group);
group.onGroupNodeMoved = this.onNodeMoved.bind(this);
this.nexusSocket.sendMessage('workflow:update', { update: "group-added", data }, false);
}
onGroupRemoved(group) {
let data = group.serialize();
this.nexusSocket.sendMessage('workflow:update', { update: "group-removed", data }, false);
}
onGroupMoved(group) {
let data = group.serialize();
this.nexusSocket.sendMessage('workflow:update', { update: "group-moved", data }, false);
}
}
-236
View File
@@ -1,236 +0,0 @@
import { DynamicElement } from "./nexus.elements.js"
import { StyleInjector } from "./nexus.styles.js";
export class WorkflowPanel {
constructor() {
this.active = 0
this.panel = new DynamicElement({
id: 'nexus-workflow-panel',
tag: 'div',
visible: false,
styles: {
"width": "100%",
"height": "100vh",
"display": "flex",
"align-items": "center",
"justify-content": "center",
"box-sizing": "border-box",
"position": "absolute",
"left": "0",
"top": "0",
"z-index": "10000"
},
childrens: [
new DynamicElement({
id: 'nexus-workflow-panel-table',
tag: 'div',
visible: true,
styles: {
"width": "512px",
"display": "flex",
"flex-direction": "column",
"border-radius": "6px",
"box-sizing": "border-box"
},
childrens: [
new DynamicElement({
id: 'nexus-workflow-panel-table-body',
tag: 'div',
visible: true,
styles: {
"width": "100%",
"display": "flex",
"flex-direction": "column",
"align-items": "center",
"justify-content": "flex-start",
"color": "#a3a3a3",
"font-family": "Calibri",
"font-weight": "600",
"gap": "12px",
"padding-bottom": "12px",
"box-sizing": "border-box",
"background": "rgba(54,54,54,0.8)",
"border-radius": "6px",
"box-shadow": "4px 8px 8px rgba(0,0,0,0.5)",
"max-height": "540px",
"overflowY": "auto"
}
}),
new StyleInjector({
styles: {
"#nexus-workflow-panel .table-row": {
"width": "100%",
"display": "flex",
"align-items": "center",
"justify-content": "center",
},
"#nexus-workflow-panel .table-row:first-child": {
"width": "100%",
"color": "#a3a3a3",
"padding": "16px 0",
"background": "rgb(0,0,0,0.6)",
"border-top-left-radius": "6px",
"border-top-right-radius": "6px",
"position": "sticky",
"top": "0",
},
"#nexus-workflow-panel .table-row .table-row-child": {
"display": "flex",
"justify-content": "flex-start",
"gap": "12px",
"padding-left": "12px",
"width": "100%"
},
"#nexus-workflow-panel .table-row-child button": {
"border": "none",
"padding": "6px 12px",
"border-radius": "3px",
"cursor": "pointer",
"background": "rgba(94,94,94)",
"color": "#a1a1a1",
"width": "100%"
},
"#nexus-workflow-panel .table-row-child:first-child": {
"width":"50%"
},
"#nexus-workflow-panel .table-row-child:last-child": {
"padding-right":"12px"
},
"#nexus-workflow-panel .table-row-child button:active": {
"opacity": "0.8"
},
"#nexus-workflow-panel .table-row-child button[label='load']:active": {
"background": "#218c74",
"color": "#fafafa"
},
"#nexus-workflow-panel .table-row-child button[label='loadforall']:active": {
"background": "#474787",
"color": "#fafafa"
}
}
})
]
})
],
})
this.onCommandReceived = null
this.buttons = []
this.columns = []
}
addColumnsToPanel(columns) {
this.columns = columns
}
addRowsToPanel(rows, callbacks, values, data) {
let tbody = this.panel.childrens[0].childrens[0];
tbody.removeChildrens();
this.buttons = []
const tr = document.createElement('div');
tr.className = "table-row";
this.columns.forEach(heading => {
const th = document.createElement('span');
th.className = "table-row-child"
th.textContent = heading;
tr.appendChild(th)
});
tbody.addChild(tr)
rows.forEach((row, i) => {
const tr = document.createElement('div');
tr.className = "table-row";
row.forEach((cell, j) => {
let td = document.createElement('span');
td.className = "table-row-child"
let hascomma = cell.indexOf(",") > -1
let hascolon = cell.indexOf(":") > -1
let content = []
if (hascomma) {
content = cell.split(',')
content.forEach((elm, k) => {
let elmcontent = elm.split(':')
this.addChildToParent(elmcontent, td, callbacks, values, data, i, j, k)
})
}
else if (hascolon) {
content = cell.split(':')
this.addChildToParent(content, td, callbacks, values, data, i, j, -1)
} else {
cell = cell.split("|")
cell = cell.join(":")
td.textContent = cell
}
tr.appendChild(td);
});
tbody.addChild(tr)
});
}
addChildToParent(content, td, callbacks, values, data, i, j, k) {
switch (content[0]) {
case 'button':
let btn = document.createElement('button');
if (content[1] == 'none') {
btn.className = "none"
}
if (!this.buttons.includes(btn)) {
this.buttons.push(btn)
}
btn.textContent = content[1]
if (callbacks[i] && callbacks[i][j] && values[i][j] && data[i][j]) {
let inner_values = values[i][j].split(',')
let inner_data = data[i][j].split(',')
if (k != -1) {
btn.setAttribute('data', inner_data[k]);
btn.setAttribute('active', inner_values[k]);
} else {
btn.setAttribute('data', data[i][j]);
btn.setAttribute('active', values[i][j]);
}
btn.setAttribute('kind', content[0]);
btn.setAttribute('label', content[1]);
if (content[1] != 'none') {
btn.onclick = callbacks[i][j].bind(this);
}
}
td.appendChild(btn)
break;
}
}
onclick(e) {
let kind = e.target.getAttribute('kind')
let data = e.target.getAttribute('data')
let label = e.target.getAttribute('label')
let active = parseInt(e.target.getAttribute('active'))
if (kind == "button") {
if (active == 0) {
e.target.setAttribute('active', 1)
} else if (active == 1) {
e.target.setAttribute('active', 0)
}
}
active = parseInt(e.target.getAttribute('active'))
//console.log(kind, label, data, active)
if (this.onCommandReceived) {
this.onCommandReceived(kind, label, data, active)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- "users" glyph from PrimeIcons (MIT, PrimeTek) on the RigShare gradient -->
<defs>
<linearGradient id="rs-g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b82f6"/>
<stop offset="1" stop-color="#a855f7"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#rs-g)"/>
<g fill="#fff" stroke="#fff" stroke-width=".35" stroke-linejoin="round" transform="translate(7.6 6.8) scale(2.1)">
<path d="M14,12.25A3.75,3.75,0,1,1,17.75,8.5,3.75,3.75,0,0,1,14,12.25Zm0-6A2.25,2.25,0,1,0,16.25,8.5,2.25,2.25,0,0,0,14,6.25Z"/>
<path d="M21,19.25a.76.76,0,0,1-.75-.75c0-1.950-1.06-3.25-6.25-3.25s-6.25,1.3-6.25,3.25a.75.75,0,0,1-1.5,0c0-4.75,5.43-4.75,7.750-4.75s7.75,0,7.75,4.75A.76.76,0,0,1,21,19.25Z"/>
<path d="M8.32,13.06H8a3,3,0,1,1,.58-6,.75.75,0,1,1-.15,1.49,1.46,1.46,0,0,0-1.09.34,1.47,1.47,0,0,0-.54,1,1.49,1.49,0,0,0,1.350,1.64,1.53,1.53,0,0,0,.93-.22.75.75,0,0,1,.79,1.28A3,3,0,0,1,8.32,13.06Z"/>
<path d="M3,18.5a.76.76,0,0,1-.75-.75c0-2.7.72-4.5,4.25-4.5a.75.75,0,0,1,0,1.5c-2.35,0-2.75.75-2.75,3A.76.76,0,0,1,3,18.5Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+215
View File
@@ -0,0 +1,215 @@
// Connection, identity and HTTP helpers shared by every RigShare module.
const LS = {
token: "rigshare.token",
guestId: "rigshare.guestId",
guestName: "rigshare.guestName",
};
function storageGet(key) {
try { return localStorage.getItem(key); } catch { return null; }
}
function storageSet(key, value) {
try {
if (value == null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch { /* private mode */ }
}
function randomId() {
return crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export class RigClient extends EventTarget {
constructor() {
super();
this.ws = null;
this.status = "offline"; // offline | connecting | online | denied
this.self = null;
this.users = [];
this.chat = [];
this.rooms = [];
this.stats = null;
this.server = {};
this.retry = 0;
this.token = storageGet(LS.token);
this.guestId = storageGet(LS.guestId) || randomId();
storageSet(LS.guestId, this.guestId);
this.guestName = storageGet(LS.guestName) || "";
}
get perms() {
return this.self?.perms ?? { edit: false, queue: false, admin: false };
}
get online() {
return this.status === "online";
}
emit(type, detail) {
this.dispatchEvent(new CustomEvent(type, { detail }));
}
on(type, fn) {
this.addEventListener(type, (e) => fn(e.detail));
}
// ----- websocket ------------------------------------------------------
connect() {
clearTimeout(this.reconnectTimer);
const base = location.pathname.replace(/[^/]*$/, "");
const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${base}rigshare/ws`;
this.setStatus("connecting");
const ws = new WebSocket(url);
this.ws = ws;
ws.onopen = () => {
this.retry = 0;
this.hello();
};
ws.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
this.handle(msg);
};
ws.onclose = () => {
if (this.ws !== ws) return;
if (this.status !== "denied") this.setStatus("offline");
const delay = Math.min(15000, 1000 * 2 ** this.retry++);
this.reconnectTimer = setTimeout(() => this.connect(), delay);
};
}
hello() {
this.send({ type: "hello", token: this.token, guest_id: this.guestId, name: this.guestName });
}
send(msg) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
return true;
}
return false;
}
setStatus(status) {
if (this.status === status) return;
this.status = status;
this.emit("status", status);
}
handle(msg) {
switch (msg.type) {
case "welcome":
this.self = msg.self;
this.users = msg.users;
this.chat = msg.chat || [];
this.rooms = msg.rooms || [];
this.stats = msg.stats;
this.server = msg.server || {};
this.setStatus("online");
this.emit("self", this.self);
this.emit("presence", this.users);
this.emit("chat-history", this.chat);
this.emit("welcome", msg);
this.emit("server", this.server);
break;
case "denied":
this.self = null;
this.denyReason = msg.reason;
this.setStatus("denied");
this.emit("self", null);
if (msg.login) this.gotoLogin();
break;
case "self":
this.self = msg.self;
this.emit("self", this.self);
break;
case "logged_out":
this.setToken(null);
this.gotoLogin();
this.emit("toast", { severity: "warn", summary: "RigShare", detail: "Your session ended. You are now a guest." });
break;
case "presence":
this.users = msg.users;
this.rooms = msg.rooms || this.rooms;
this.emit("presence", this.users);
break;
case "server":
this.server = msg.server || {};
this.emit("server", this.server);
break;
case "stats":
this.stats = msg.stats;
this.emit("stats", msg.stats);
break;
case "chat":
this.chat.push(msg.message);
if (this.chat.length > 500) this.chat.shift();
this.emit("chat", msg.message);
break;
default:
this.emit(msg.type, msg);
}
}
// ----- identity -------------------------------------------------------
setToken(token) {
this.token = token;
storageSet(LS.token, token);
}
async login(username, password) {
const res = await this.request("POST", "/rigshare/api/login", { username, password });
this.setToken(res.token);
this.reconnect();
return res.user;
}
async logout() {
try { await this.request("POST", "/rigshare/api/logout"); } catch { /* ignore */ }
this.setToken(null);
this.gotoLogin();
}
gotoLogin() {
const base = location.pathname.replace(/[^/]*$/, "");
const next = encodeURIComponent(location.pathname + location.search + location.hash);
location.replace(`${base}rigshare/login?next=${next}`);
}
rename(name) {
this.guestName = name;
storageSet(LS.guestName, name);
this.send({ type: "rename", name });
}
reconnect() {
const ws = this.ws;
this.ws = null;
this.status = "offline";
ws?.close();
this.connect();
}
// Headers that identify this browser to the ComfyUI HTTP API.
authHeaders() {
if (this.token) return { "X-RigShare-Token": this.token };
return { "X-RigShare-Guest": this.guestId };
}
async request(method, path, body) {
const base = location.pathname.replace(/[^/]*$/, "");
const res = await fetch(base + path.replace(/^\//, ""), {
method,
headers: { "Content-Type": "application/json", ...this.authHeaders() },
body: body === undefined ? undefined : JSON.stringify(body),
});
let data = null;
try { data = await res.json(); } catch { /* empty */ }
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
}
+106
View File
@@ -0,0 +1,106 @@
// Workflow document patches. Mirrors apply_patch()/reconcile() in
// rigshare_core/graphdoc.py exactly: clients and server must reach the same
// document from the same patches. See that file for the patch format.
const KEYED = ["links", "groups"];
const MAX_KEYS = ["last_node_id", "last_link_id"];
export function linkId(link) {
return Array.isArray(link) ? link[0] : link?.id;
}
export function linkEnds(link) {
return Array.isArray(link)
? [link[1], link[2], link[3], link[4]]
: [link.origin_id, link.origin_slot, link.target_id, link.target_slot];
}
export function itemId(key, item) {
return key === "links" ? linkId(item) : item?.id;
}
function num(value) {
const n = Number(value);
return value === null || value === undefined || Number.isNaN(n) ? -Infinity : n;
}
function mergeKeyed(key, current, change) {
const items = [...(current || [])];
const removed = new Set(change.removed || []);
const index = new Map(items.map((it, i) => [itemId(key, it), i]));
for (const it of change.upsert || []) {
const i = index.get(itemId(key, it));
if (i === undefined) {
index.set(itemId(key, it), items.length);
items.push(it);
} else {
items[i] = it;
}
}
return items.filter((it) => !removed.has(itemId(key, it)));
}
const slotKey = (id, slot) => JSON.stringify([id, slot]);
export function reconcile(doc) {
const nodes = doc.nodes || [];
const nodeIds = new Set(nodes.map((n) => n.id));
const links = doc.links;
if (!Array.isArray(links)) return doc;
const byTarget = new Map();
// Stable sort by id: the newest link on an input slot wins.
const sorted = links.map((l, i) => [l, i]).sort((a, b) => (num(linkId(a[0])) - num(linkId(b[0]))) || (a[1] - b[1]));
for (const [link] of sorted) {
const [origin, , target, targetSlot] = linkEnds(link);
if (nodeIds.has(origin) && nodeIds.has(target)) byTarget.set(slotKey(target, targetSlot), link);
}
const keep = new Set([...byTarget.values()].map(linkId));
doc.links = links.filter((l) => keep.has(linkId(l)));
for (const node of nodes) {
(node.inputs || []).forEach((slot, i) => {
if (slot && typeof slot === "object") {
const link = byTarget.get(slotKey(node.id, i));
slot.link = link ? linkId(link) : null;
}
});
(node.outputs || []).forEach((slot, j) => {
if (slot && typeof slot === "object") {
const ids = doc.links.filter((l) => { const e = linkEnds(l); return e[0] === node.id && e[1] === j; }).map(linkId);
slot.links = ids.length || (slot.links !== null && slot.links !== undefined) ? ids : null;
}
});
}
return doc;
}
export function applyPatch(doc, patch) {
if ("full" in patch) return structuredClone(patch.full);
doc = structuredClone(doc || {});
const nodes = [...(doc.nodes || [])];
const removed = new Set(patch.removed || []);
const index = new Map(nodes.map((n, i) => [n.id, i]));
for (let node of patch.nodes || []) {
node = structuredClone(node);
const i = index.get(node.id);
if (i === undefined) {
index.set(node.id, nodes.length);
nodes.push(node);
} else {
nodes[i] = node;
}
}
doc.nodes = nodes.filter((n) => !removed.has(n.id));
for (const key of KEYED) {
const change = patch[key];
if (change && typeof change === "object" && !Array.isArray(change)) doc[key] = mergeKeyed(key, doc[key], structuredClone(change));
}
for (const [key, value] of Object.entries(patch.set || {})) {
if (key === "nodes") continue;
if (MAX_KEYS.includes(key) && typeof value === "number" && typeof doc[key] === "number") doc[key] = Math.max(doc[key], value);
else doc[key] = structuredClone(value);
}
for (const key of patch.unset || []) delete doc[key];
const hasLinks = !!(patch.links?.upsert?.length || patch.links?.removed?.length);
if (hasLinks || patch.nodes?.length || patch.removed?.length) reconcile(doc);
return doc;
}
+273
View File
@@ -0,0 +1,273 @@
// RigShare — multi-user collaboration for the modern ComfyUI frontend.
// Continued from ComfyUI-Nexus by daxcay (MIT).
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { RigClient } from "./client.js";
import { RoomSync } from "./sync.js";
import { Presence } from "./presence.js";
import { RigSharePanel } from "./panel.js";
import { STYLES } from "./styles.js";
const TAB_ID = "rigshare";
const ICON_CLASS = "rigshare-tab-icon";
function setting(id, fallback) {
try {
const v = app.extensionManager?.setting?.get(id);
return v === undefined ? fallback : v;
} catch {
return fallback;
}
}
function installAuthHeaders(client) {
const original = api.fetchApi.bind(api);
api.fetchApi = (route, options = {}) => {
let headers = options.headers || {};
if (headers instanceof Headers) headers = Object.fromEntries(headers.entries());
return original(route, { ...options, headers: { ...headers, ...client.authHeaders() } });
};
}
// ComfyUI Manager: hide its button and refuse its commands without the
// "manager" permission (the server blocks its API too).
const MANAGER_COMMANDS = ["Comfy.Manager.Menu.ToggleVisibility", "Comfy.Manager.CustomNodesManager.ToggleVisibility"];
function installManagerGuard(client) {
const apply = () => {
const allowed = !client.online || !!client.perms.manager;
document.body.classList.toggle("rs-no-manager", !allowed);
for (const cmd of app.extensionManager?.command?.commands ?? []) {
if (!MANAGER_COMMANDS.includes(cmd.id) || cmd.__rigshare) continue;
const original = cmd.function;
cmd.function = (...args) => {
if (client.online && !client.perms.manager) {
client.emit("toast", { severity: "warn", summary: "RigShare", detail: "You need the Manager permission to open ComfyUI Manager. Ask an admin." });
return;
}
return original(...args);
};
cmd.__rigshare = true;
}
};
client.on("self", apply);
client.on("status", apply);
setTimeout(apply, 2000); // Manager registers its commands during setup
}
// Comfy.org account: hide the top-bar sign-in button and refuse the sign-in
// dialogs when the admin turned it off.
const SIGNIN_DIALOGS = ["global-signin", "api-nodes-signin"];
function installComfyAccountGuard(client) {
const hidden = () => !!client.server?.hide_comfy_account;
const patchStores = () => {
const cmd = (app.extensionManager?.command?.commands ?? []).find((c) => c.id === "Comfy.User.OpenSignInDialog");
if (cmd && !cmd.__rigshare) {
const original = cmd.function;
cmd.function = (...args) => (hidden() ? undefined : original(...args));
cmd.__rigshare = true;
}
try {
const vueApp = document.querySelector("[data-v-app]")?.__vue_app__;
const dialogs = vueApp?.config?.globalProperties?.$pinia?._s?.get("dialog");
if (dialogs && !dialogs.__rigshare) {
const show = dialogs.showDialog;
dialogs.showDialog = (options, ...rest) => {
if (hidden() && SIGNIN_DIALOGS.includes(options?.key)) return undefined;
return show.call(dialogs, options, ...rest);
};
dialogs.__rigshare = true;
}
} catch (e) {
console.warn("[RigShare] could not hook dialogs", e);
}
};
const apply = () => {
document.body.classList.toggle("rs-hide-comfy-account", hidden());
patchStores();
};
client.on("server", apply);
setTimeout(apply, 2000);
}
function installQueueGuard(client) {
const original = api.queuePrompt.bind(api);
api.queuePrompt = async (...args) => {
if (client.online && !client.perms.queue) {
throw new Error("RigShare: you do not have permission to queue prompts. Ask an admin, or log in with an account that can queue.");
}
return original(...args);
};
}
// Short two-tone "ping" synthesized with WebAudio (no asset to ship).
let audio = null;
function ping() {
try {
audio ??= new AudioContext();
if (audio.state === "suspended") audio.resume();
const now = audio.currentTime;
for (const [i, freq] of [880, 1320].entries()) {
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = "sine";
osc.frequency.value = freq;
const t = now + i * 0.11;
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(0.18, t + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
osc.connect(gain).connect(audio.destination);
osc.start(t);
osc.stop(t + 0.25);
}
} catch { /* audio unavailable */ }
}
function tabButton() {
return document.querySelector(`.${ICON_CLASS}`)?.closest("button");
}
function setBlink(on) {
tabButton()?.classList.toggle("rigshare-blink", on);
}
// Remember the active palette so the login page (served before ComfyUI
// loads) can use the same colors.
const THEME_VARS = ["--bg-color", "--comfy-menu-bg", "--comfy-input-bg", "--fg-color", "--descrip-text",
"--border-color", "--p-primary-color", "--p-primary-contrast-color"];
let lastTheme = "";
function saveTheme() {
const css = getComputedStyle(document.documentElement);
const theme = Object.fromEntries(THEME_VARS.map((v) => [v, css.getPropertyValue(v).trim()]).filter(([, v]) => v));
const json = JSON.stringify(theme);
if (json === lastTheme) return;
lastTheme = json;
try { localStorage.setItem("rigshare.theme", json); } catch { /* ignore */ }
}
let rig = null;
app.registerExtension({
name: "Comfy.RigShare",
settings: [
{
id: "RigShare.ShareSaved",
category: ["RigShare", "Sharing", "ShareSaved"],
name: "Share saved workflows automatically (everyone opening the same file edits it together)",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) rig.sync.autoShareFiles = v; },
},
{
id: "RigShare.ShareUnsaved",
category: ["RigShare", "Sharing", "ShareUnsaved"],
name: "Share unsaved tabs automatically once they contain nodes",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) rig.sync.autoShareUnsaved = v; },
},
{
id: "RigShare.ShowCursors",
category: ["RigShare", "Presence", "ShowCursors"],
name: "Show other people's cursors and selections",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) { rig.presence.showCursors = v; rig.presence.redraw(); } },
},
{
id: "RigShare.ShowCursorNames",
category: ["RigShare", "Presence", "ShowCursorNames"],
name: "Show names next to cursors",
type: "boolean",
defaultValue: true,
onChange: (v) => { if (rig) { rig.presence.showNames = v; rig.presence.redraw(); } },
},
{
id: "RigShare.ChatSound",
category: ["RigShare", "Chat", "ChatSound"],
name: "Play a sound for new chat messages when the panel is closed",
type: "boolean",
defaultValue: true,
},
{
id: "RigShare.ChatToasts",
category: ["RigShare", "Chat", "ChatToasts"],
name: "Show a notification for new chat messages when the panel is closed",
type: "boolean",
defaultValue: false,
onChange: (v) => { if (rig) rig.panel.chatToasts = v; },
},
],
commands: [
{
id: "RigShare.OpenChat",
label: "RigShare: Open chat",
icon: "pi pi-comments",
function: async () => {
if (!rig?.panel.visible) await app.extensionManager.command.execute(`Workspace.ToggleSidebarTab.${TAB_ID}`);
rig?.panel.focusChat();
},
},
{
id: "RigShare.ShareTab",
label: "RigShare: Share this tab",
icon: "pi pi-share-alt",
function: () => rig?.sync.shareCurrent(),
},
],
async setup() {
const style = document.createElement("style");
style.textContent = STYLES;
document.head.append(style);
const client = new RigClient();
const sync = new RoomSync(app, api, client);
const presence = new Presence(app, client, sync);
const panel = new RigSharePanel(app, client, sync, presence);
rig = { client, sync, presence, panel };
window.rigshare = rig; // handy for debugging from the console
sync.autoShareFiles = setting("RigShare.ShareSaved", true);
sync.autoShareUnsaved = setting("RigShare.ShareUnsaved", true);
presence.showCursors = setting("RigShare.ShowCursors", true);
presence.showNames = setting("RigShare.ShowCursorNames", true);
panel.chatToasts = setting("RigShare.ChatToasts", false);
client.on("toast", (t) => app.extensionManager?.toast?.add({ life: 4000, ...t }));
installAuthHeaders(client);
installQueueGuard(client);
installManagerGuard(client);
installComfyAccountGuard(client);
presence.install();
app.extensionManager.registerSidebarTab({
id: TAB_ID,
icon: `pi pi-users ${ICON_CLASS}`,
title: "RigShare",
tooltip: "RigShare — collaboration",
label: "RigShare",
type: "custom",
iconBadge: null,
render: (el) => panel.mount(el),
destroy: () => panel.unmount(),
});
// The registered tab object is wrapped by Vue; mutating it through the
// store keeps the unread badge reactive.
panel.onBadge = (badge) => {
const tab = app.extensionManager.getSidebarTabs?.().find((t) => t.id === TAB_ID);
if (tab) tab.iconBadge = badge;
setBlink(!!badge);
};
panel.onNotify = () => {
if (setting("RigShare.ChatSound", true)) ping();
};
client.connect();
saveTheme();
setInterval(saveTheme, 5000);
},
});
+722
View File
@@ -0,0 +1,722 @@
// Sidebar panel: tabs for workflows, people, chat, server and admin, plus an
// account view (profile, password, API keys) behind the avatar button.
import { h, initials, timeAgo, clock, roleChips, meter, storage } from "./ui.js";
import { ServerWidget } from "./server-widget.js";
const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
const VIEWS = [
{ id: "workflows", icon: "pi-share-alt", label: "Workflows" },
{ id: "people", icon: "pi-users", label: "People" },
{ id: "chat", icon: "pi-comments", label: "Chat" },
{ id: "server", icon: "pi-server", label: "Server" },
{ id: "admin", icon: "pi-shield", label: "Admin", admin: true },
];
export class RigSharePanel {
constructor(app, client, sync, presence) {
this.app = app;
this.client = client;
this.sync = sync;
this.presence = presence;
this.visible = false;
this.unread = 0;
this.chatToasts = false;
this.view = storage.get("rigshare.view") || "workflows";
this.historyOpen = false;
this.newKey = null;
this.widget = new ServerWidget(client, () => this.render());
this.header = h("div", { class: "rs-header" });
this.nav = h("div", { class: "rs-nav", role: "tablist" });
this.body = h("div", { class: "rs-body" });
this.root = h("div", { class: "rs-panel" }, this.header, this.nav, this.body);
this.chatLog = h("div", { class: "rs-chat-log" });
this.chatInput = h("textarea", {
class: "rs-input rs-chat-input", placeholder: "Message… (Enter to send)", maxLength: 1000, rows: 1,
onkeydown: (e) => {
e.stopPropagation();
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.sendChat(); }
},
});
this.chatView = h("div", { class: "rs-chat" }, this.chatLog,
h("div", { class: "rs-chat-compose" }, this.chatInput,
h("button", { class: "rs-btn rs-btn-primary rs-btn-icon", title: "Send", onclick: () => this.sendChat() }, h("i", { class: "pi pi-send" }))));
client.on("status", () => this.render());
client.on("self", () => this.render());
client.on("presence", () => this.renderSoft());
client.on("stats", () => { if (this.view === "server") this.renderBody(); });
client.on("chat-history", () => this.renderChatLog());
client.on("chat", (m) => this.onChat(m));
sync.addEventListener("state", () => { if (this.view === "workflows") this.renderBody(); });
presence.addEventListener("follow", () => { if (this.view === "people") this.renderBody(); });
this.render();
}
// ----- sidebar lifecycle ---------------------------------------------
mount(container) {
container.classList.add("rs-host");
container.append(this.root);
this.visible = true;
if (this.view === "chat") this.clearUnread();
this.render();
}
unmount() {
this.visible = false;
}
focusChat() {
this.setView("chat");
setTimeout(() => this.chatInput.focus(), 50);
}
setView(view) {
this.view = view;
storage.set("rigshare.view", view);
if (view === "chat") this.clearUnread();
this.render();
}
get badge() {
return this.unread ? String(this.unread > 99 ? "99+" : this.unread) : null;
}
clearUnread() {
if (!this.unread) return;
this.unread = 0;
this.onBadge?.(null);
}
toast(severity, detail, summary = "RigShare") {
this.client.emit("toast", { severity, summary, detail });
}
async confirm(title, message) {
const dialog = this.app.extensionManager?.dialog;
if (dialog?.confirm) return !!(await dialog.confirm({ title, message }));
return window.confirm(`${title}\n\n${message}`);
}
async prompt(title, message, defaultValue = "") {
const dialog = this.app.extensionManager?.dialog;
if (dialog?.prompt) return await dialog.prompt({ title, message, defaultValue });
return window.prompt(`${title}\n\n${message}`, defaultValue);
}
async run(fn, okMessage) {
try {
const result = await fn();
if (okMessage) this.toast("success", okMessage);
return result;
} catch (e) {
this.toast("error", e.message || String(e));
return undefined;
}
}
// ----- frame ----------------------------------------------------------
render() {
this.renderHeader();
this.renderNav();
this.renderBody();
}
/** Presence changes: refresh counts and the current view if it shows people. */
renderSoft() {
this.renderNav();
if (["workflows", "people"].includes(this.view)) this.renderBody();
}
renderHeader() {
const c = this.client;
const s = c.status;
const label = { online: "Connected", connecting: "Connecting…", offline: "Offline", denied: "Signed out" }[s];
const me = c.self;
this.header.replaceChildren(...[
h("div", { class: "rs-title" }, h("img", { class: "rs-logo", src: ICON_URL, alt: "" }),
h("div", { class: "rs-title-text" },
h("div", { class: "rs-title-name" }, c.server?.name || "RigShare"),
h("div", { class: `rs-status rs-status-${s}` }, h("span", { class: "rs-dot" }), label))),
me ? h("button", {
class: `rs-me-btn ${this.view === "account" ? "active" : ""}`, title: `${me.name} — account & API keys`,
onclick: () => this.setView(this.view === "account" ? "workflows" : "account"),
}, h("span", { class: "rs-avatar rs-avatar-sm", style: `background:${me.color}` }, initials(me.name))) : null,
].filter(Boolean));
}
renderNav() {
const c = this.client;
const counts = {
people: c.users.length || null,
chat: this.unread || null,
workflows: c.rooms?.length || null,
};
this.nav.replaceChildren(...VIEWS.filter((v) => !v.admin || c.perms.admin).map((v) =>
h("button", {
class: `rs-tab ${this.view === v.id ? "active" : ""}`, role: "tab", title: v.label,
onclick: () => this.setView(v.id),
}, h("i", { class: `pi ${v.icon}` }), h("span", { class: "rs-tab-label" }, v.label),
counts[v.id] ? h("span", { class: `rs-count ${v.id === "chat" ? "hot" : ""}` }, counts[v.id]) : null)));
this.nav.style.display = c.self ? "" : "none";
}
renderBody() {
const c = this.client;
this.body.classList.toggle("rs-body-chat", this.view === "chat" && !!c.self);
if (!c.self) {
const failing = c.retry >= 2 && c.status !== "denied";
this.body.replaceChildren(h("div", { class: "rs-empty" },
h("i", { class: "pi pi-spin pi-spinner" }),
c.status === "denied" ? "Signed out — redirecting to login…" : c.status === "connecting" ? "Connecting…" : "Server unreachable, retrying…",
failing ? h("div", { class: "rs-small rs-hint" },
"Can't open the live connection. If ComfyUI is behind a reverse proxy, enable WebSocket support for it (Nginx Proxy Manager: ",
h("b", {}, "Websockets Support"), ").") : null));
return;
}
const views = {
workflows: () => this.workflowsView(),
people: () => this.peopleView(),
chat: () => [this.chatView],
server: () => this.serverView(),
admin: () => this.adminView(),
account: () => this.accountView(),
};
if (this.view === "admin" && !c.perms.admin) this.view = "workflows";
const scroll = this.body.scrollTop;
this.body.replaceChildren(...(views[this.view] ?? views.workflows)().filter(Boolean));
this.body.scrollTop = scroll;
if (this.view === "chat") this.scrollChat();
}
section(icon, title, ...children) {
return h("section", { class: "rs-section" },
h("div", { class: "rs-section-title" }, h("i", { class: `pi ${icon}` }), title), ...children);
}
// ----- workflows ------------------------------------------------------
workflowsView() {
return [this.currentTabSection(), this.roomsSection()];
}
currentTabSection() {
const c = this.client;
const s = this.sync;
const wf = s.store?.activeWorkflow;
if (!wf) return this.section("pi-file", "This tab", h("p", { class: "rs-muted" }, "No tab open."));
const room = s.target;
const head = h("div", { class: "rs-tab-head" },
h("div", { class: "rs-name" }, s.tabName(wf)),
wf.isTemporary ? h("span", { class: "rs-chip" }, "unsaved") : null);
const row = h("div", { class: "rs-actions" });
const body = [head];
if (!room) {
body.push(h("p", { class: "rs-muted" }, wf.isTemporary
? "Private until you edit it or share it."
: "Private — not synced with others."));
row.append(h("button", { class: "rs-btn rs-btn-primary", onclick: () => s.shareCurrent() }, h("i", { class: "pi pi-share-alt" }), "Share this tab"));
} else {
const here = c.users.filter((u) => u.room === room.key);
const last = s.lastEditor && Date.now() - s.lastEditor.at < 60000 ? ` · ${s.lastEditor.name} editing` : "";
body.push(h("div", { class: "rs-live-row" },
s.live
? h("span", { class: "rs-live" }, h("span", { class: "rs-live-dot" }), `Live · rev ${s.version}${last}`)
: h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"),
h("span", { class: "rs-avatars" }, ...here.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))));
body.push(this.accessLine());
if (this.accessOpen && s.room?.manage) body.push(this.accessEditor());
if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access."));
if (s.canEdit) {
row.append(h("button", {
class: "rs-btn", title: "Save a named snapshot", onclick: async () => {
const label = await this.prompt("Snapshot", "Name this snapshot", `Snapshot ${new Date().toLocaleString()}`);
if (label === null || label === undefined) return;
await this.run(() => c.request("POST", "/rigshare/api/snapshots", { room: room.key, label: label || "Manual" }), "Snapshot saved");
if (this.historyOpen) this.loadHistory();
},
}, h("i", { class: "pi pi-camera" }), "Snapshot"));
}
row.append(
h("button", { class: `rs-btn ${this.historyOpen ? "active" : ""}`, onclick: () => { this.historyOpen = !this.historyOpen; this.renderBody(); if (this.historyOpen) this.loadHistory(); } },
h("i", { class: "pi pi-history" }), "History"),
h("button", { class: "rs-btn rs-btn-ghost", title: c.perms.admin ? "Stop sharing this tab (removed from the list if nobody else is in it)" : "Leave: stop syncing this tab and keep a private copy", onclick: () => s.unshareCurrent() },
h("i", { class: "pi pi-eye-slash" }), "Stop sharing"));
}
this.historyEl ??= h("div", { class: "rs-history" });
return this.section("pi-file", "This tab", ...body, row, room && this.historyOpen ? this.historyEl : null);
}
accessLine() {
const s = this.sync;
const info = s.room;
if (!info) return null;
const owner = info.owner ? this.client.users.find((u) => u.key === info.owner)?.name ?? `@${info.owner}` : null;
return h("div", { class: "rs-access" },
h("i", { class: `pi ${info.restricted ? "pi-lock" : "pi-globe"}` }),
h("span", { class: "rs-grow" }, info.restricted ? "Only people with access" : "Everyone can open it",
owner ? h("span", { class: "rs-muted" }, ` · owner ${owner}`) : null),
info.manage ? h("button", {
class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`,
onclick: () => { this.accessOpen = !this.accessOpen; this.accessDraft = null; this.renderBody(); },
}, "Access") : null);
}
accessEditor() {
const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…"));
const key = this.sync.targetKey;
Promise.all([
this.run(() => this.client.request("GET", `/rigshare/api/room/acl?key=${encodeURIComponent(key)}`)),
this.run(() => this.client.request("GET", "/rigshare/api/people")),
]).then(([acl, people]) => {
if (!acl || !people) return;
const draft = this.accessDraft ??= { restricted: acl.restricted, members: { ...acl.members } };
const render = () => {
const fixed = people.filter((p) => p.username === acl.owner || p.admin);
const choosable = people.filter((p) => p.username !== acl.owner && !p.admin);
const fixedRows = fixed.map((p) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`)),
h("span", { class: "rs-chip" }, p.username === acl.owner ? "Owner · always" : "Admin · always")));
const rows = choosable
.map((p) => {
const select = h("select", {
class: "rs-input rs-select", disabled: !draft.restricted,
onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; },
}, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) =>
h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label)));
return h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`),
p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")),
select);
});
const radio = (value, label, hint) => h("label", { class: "rs-check rs-block" },
h("input", { type: "radio", name: "rs-access", checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }),
h("span", {}, label, h("div", { class: "rs-muted rs-small" }, hint)));
box.replaceChildren(...[
radio(false, "Everyone", "Anyone with an account can open it, with their usual permissions"),
radio(true, "Only people I choose", "The owner and admins always have access"),
draft.restricted ? h("div", { class: "rs-access-list" }, ...fixedRows, ...rows,
rows.length ? null : h("p", { class: "rs-muted rs-small" },
"Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open this workflow.")) : null,
h("div", { class: "rs-actions" },
h("button", {
class: "rs-btn rs-btn-primary", onclick: async () => {
const ok = await this.run(() => this.sync.setAccess(draft.restricted, draft.restricted ? draft.members : {}), "Access updated");
if (ok !== undefined) { this.accessOpen = false; this.accessDraft = null; this.renderBody(); }
},
}, "Save"),
h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); } }, "Cancel")),
].filter(Boolean));
};
render();
});
return box;
}
async loadHistory() {
const room = this.sync.target;
if (!room) return;
this.historyEl.replaceChildren(h("p", { class: "rs-muted" }, "Loading…"));
const snaps = await this.run(() => this.client.request("GET", `/rigshare/api/snapshots?room=${encodeURIComponent(room.key)}`));
if (!snaps) return;
if (!snaps.length) {
this.historyEl.replaceChildren(h("p", { class: "rs-muted" }, "No snapshots yet. They are taken automatically every few minutes while people edit."));
return;
}
this.historyEl.replaceChildren(...snaps.map((snap) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-ellipsis" }, snap.label || "Auto", h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
h("div", { class: "rs-muted rs-small" }, `${timeAgo(snap.time * 1000)} · ${snap.author}`)),
h("button", { class: "rs-btn rs-btn-icon", title: "Open a private copy", onclick: () => this.openSnapshot(snap) }, h("i", { class: "pi pi-external-link" })),
this.sync.canEdit ? h("button", {
class: "rs-btn rs-btn-icon", title: "Restore for everyone", onclick: async () => {
if (!(await this.confirm("Restore snapshot", `Restore "${snap.label || "Auto"}" for everyone in this workflow?`))) return;
await this.run(() => this.client.request("POST", `/rigshare/api/snapshots/${snap.id}/restore`), "Snapshot restored");
this.loadHistory();
},
}, h("i", { class: "pi pi-replay" })) : null)));
}
async openSnapshot(snap) {
const data = await this.run(() => this.client.request("GET", `/rigshare/api/snapshots/${snap.id}`));
if (!data) return;
const doc = structuredClone(data.doc);
if (doc.extra) delete doc.extra.rigshare; // a private copy, not the live room
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snap.label || snap.id}).json`);
}
roomsSection() {
const c = this.client;
const rooms = c.rooms || [];
const byId = new Map(c.users.map((u) => [u.id, u]));
const rows = rooms.map((r) => {
const here = r.key === this.sync.targetKey;
const members = r.members.map((id) => byId.get(id)).filter(Boolean);
return h("div", { class: `rs-row-item rs-room ${here ? "here" : ""}`, title: r.key.replace(/^file:workflows\//, "") },
h("i", { class: `pi ${r.kind === "file" ? "pi-file" : "pi-file-edit"} rs-row-icon` }),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, r.name, r.restricted ? h("i", { class: "pi pi-lock rs-inline-icon rs-muted", title: "Restricted" }) : null),
members.length
? h("div", { class: "rs-avatars" }, ...members.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))
: h("div", { class: "rs-muted rs-small" }, `${timeAgo(r.updated * 1000)} · ${r.nodes} nodes`)),
!here && !members.length && c.perms.admin ? h("button", {
class: "rs-btn rs-btn-icon rs-danger", title: "Stop sharing (remove from this list)",
onclick: async () => {
if (!(await this.confirm("Stop sharing", `Remove "${r.name}" from the shared workflows? A snapshot is kept in its history.`))) return;
await this.run(() => c.request("DELETE", `/rigshare/api/room?key=${encodeURIComponent(r.key)}`), `"${r.name}" is no longer shared`);
},
}, h("i", { class: "pi pi-trash" })) : null,
here ? h("span", { class: "rs-chip rs-chip-edit" }, "open") : h("button", {
class: "rs-btn rs-btn-icon", title: "Open",
onclick: () => this.run(() => this.sync.openRoom(r)),
}, h("i", { class: "pi pi-arrow-right" })));
});
return this.section("pi-share-alt", "Shared workflows",
...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nothing shared yet. Open a saved workflow or edit a tab.")]));
}
// ----- people ---------------------------------------------------------
peopleView() {
const c = this.client;
const isAdmin = c.perms.admin;
const users = [...c.users].sort((a, b) => (a.id === c.self.id ? -1 : b.id === c.self.id ? 1 : a.joined - b.joined));
const rows = users.map((u) => {
const self = u.id === c.self.id;
const following = this.presence.following === u.id;
const toggles = [];
if (isAdmin && !self && !u.perms.admin) {
for (const perm of ["edit", "queue", "manager"]) {
toggles.push(h("button", {
class: `rs-toggle ${u.perms[perm] ? "on" : ""}`,
title: `${u.perms[perm] ? "Revoke" : "Grant"} ${perm} permission${u.kind === "guest" ? "" : ` for @${u.key}`}`,
onclick: () => this.setPerm(u, perm, !u.perms[perm]),
}, { edit: "Edit", queue: "Queue", manager: "Manager" }[perm]));
}
}
const where = c.rooms.find((r) => r.key === u.room)?.name;
return h("div", { class: "rs-row-item rs-person" },
h("span", { class: "rs-avatar", style: `background:${u.color}` }, initials(u.name)),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, u.name, self ? h("span", { class: "rs-muted" }, " (you)") : null,
u.idle ? h("i", { class: "pi pi-moon rs-muted rs-inline-icon", title: "Away" }) : null),
h("div", { class: "rs-chips" }, ...(toggles.length ? toggles : roleChips(u.perms))),
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"))),
self ? null : h("button", {
class: `rs-btn rs-btn-icon ${following ? "rs-btn-primary" : ""}`,
title: following ? "Stop following" : `Follow ${u.name} (jump to their tab and view)`,
onclick: () => this.presence.follow(following ? null : u.id),
}, h("i", { class: following ? "pi pi-eye-slash" : "pi pi-eye" })));
});
return [this.section("pi-users", `Online (${users.length})`, ...rows)];
}
async setPerm(user, perm, value) {
const perms = { [perm]: value };
if (user.kind === "user") await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(user.key)}`, { perms }));
else await this.run(() => this.client.request("POST", `/rigshare/api/guests/${encodeURIComponent(user.guest_id)}`, perms));
}
// ----- chat -----------------------------------------------------------
sendChat() {
const text = this.chatInput.value.trim();
if (!text) return;
if (this.client.send({ type: "chat", text })) this.chatInput.value = "";
}
chatLine(m) {
if (m.system) return h("div", { class: "rs-msg rs-msg-system" }, h("span", { class: "rs-time" }, clock(m.ts)), m.text);
const mine = m.from === this.client.self?.id;
return h("div", { class: `rs-msg ${mine ? "mine" : ""}` },
h("div", { class: "rs-msg-head" },
h("span", { class: "rs-msg-name", style: `color:${m.color}` }, m.name),
h("span", { class: "rs-time" }, clock(m.ts))),
h("div", { class: "rs-msg-text" }, m.text));
}
renderChatLog() {
this.chatLog.replaceChildren(...this.client.chat.slice(-200).map((m) => this.chatLine(m)));
this.scrollChat();
}
scrollChat() {
requestAnimationFrame(() => { this.chatLog.scrollTop = this.chatLog.scrollHeight; });
}
onChat(m) {
const atBottom = this.chatLog.scrollHeight - this.chatLog.scrollTop - this.chatLog.clientHeight < 40;
this.chatLog.append(this.chatLine(m));
while (this.chatLog.childElementCount > 300) this.chatLog.firstChild.remove();
if (atBottom || m.from === this.client.self?.id) this.scrollChat();
const seen = this.visible && this.view === "chat" && !document.hidden;
if (!seen && !m.system && m.from !== this.client.self?.id) {
this.unread++;
this.onBadge?.(this.badge);
this.onNotify?.(m);
if (this.chatToasts && !this.visible) this.toast("info", m.text, m.name);
this.renderNav();
}
}
// ----- server ---------------------------------------------------------
serverView() {
const st = this.client.stats;
const floating = this.widget.enabled;
const pop = h("button", {
class: `rs-btn ${floating ? "active" : ""}`, title: "Show a small floating monitor over the canvas",
onclick: () => this.widget.toggle(),
}, h("i", { class: "pi pi-window-maximize" }), floating ? "Floating: on" : "Pop out");
if (!st) return [this.section("pi-server", "Server", h("p", { class: "rs-muted" }, "Waiting for data…"), pop)];
const items = [];
if (st.cpu != null) items.push(meter("CPU", st.cpu, `${Math.round(st.cpu)}%`));
if (st.ram_total) items.push(meter("RAM", (st.ram_used / st.ram_total) * 100, `${st.ram_used} / ${st.ram_total} GB`));
const gpus = (st.gpus || []).map((g) => {
const extra = [g.temp != null ? `${g.temp}°C` : null, g.power != null ? `${g.power} / ${g.power_limit} W` : null].filter(Boolean).join(" · ");
return h("div", { class: "rs-gpu" },
h("div", { class: "rs-gpu-name" }, h("span", {}, `GPU ${g.index} · ${g.name}`), h("span", { class: "rs-muted" }, extra)),
meter("Load", g.util, `${g.util}%`),
meter("VRAM", (g.vram_used / g.vram_total) * 100, `${g.vram_used.toFixed(1)} / ${g.vram_total.toFixed(0)} GB`));
});
const q = st.queue;
const queue = q ? h("div", { class: "rs-queue-line" }, h("i", { class: "pi pi-list" }),
q.running || q.pending ? `${q.running} running · ${q.pending} queued` : "Queue idle") : null;
return [this.section("pi-server", "Server", queue, ...items, h("div", { class: "rs-actions" }, pop)),
gpus.length ? this.section("pi-bolt", "GPUs", ...gpus) : null].filter(Boolean);
}
// ----- account --------------------------------------------------------
accountView() {
const me = this.client.self;
const profile = this.section("pi-user", "Profile",
h("div", { class: "rs-me" },
h("span", { class: "rs-avatar rs-avatar-lg", style: `background:${me.color}` }, initials(me.name)),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name" }, me.name),
h("div", { class: "rs-muted rs-small" }, me.key ? `@${me.key}` : "guest"),
h("div", { class: "rs-chips" }, ...roleChips(me.perms)))),
me.kind === "user" ? this.profileForm(me) : null,
h("div", { class: "rs-actions" },
h("button", { class: "rs-btn", onclick: () => this.client.logout() }, h("i", { class: "pi pi-sign-out" }), "Log out")));
const out = [profile];
if (me.kind === "user") {
out.push(this.passwordSection());
this.keysEl ??= h("div", {});
out.push(this.section("pi-key", "API keys",
h("p", { class: "rs-muted rs-small" }, "Use a key to call the ComfyUI API from scripts or other apps with your permissions: ",
h("code", {}, "Authorization: Bearer <key>")),
this.newKey ? h("div", { class: "rs-newkey" },
h("div", { class: "rs-small" }, "Copy it now — it will not be shown again."),
h("div", { class: "rs-row" }, h("code", { class: "rs-grow rs-keytext" }, this.newKey),
h("button", { class: "rs-btn rs-btn-icon", title: "Copy", onclick: async () => { await navigator.clipboard?.writeText(this.newKey); this.toast("success", "Copied"); } }, h("i", { class: "pi pi-copy" })))) : null,
this.keysEl, this.keyForm()));
this.loadKeys();
}
return out;
}
profileForm(me) {
const display = h("input", { class: "rs-input", value: me.name, maxLength: 32 });
return h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
await this.run(() => this.client.request("POST", "/rigshare/api/me", { display_name: display.value.trim() }), "Display name saved");
},
}, h("label", { class: "rs-label" }, "Display name"),
h("div", { class: "rs-row" }, display, h("button", { class: "rs-btn", type: "submit" }, "Save")));
}
passwordSection() {
const cur = h("input", { class: "rs-input", type: "password", placeholder: "Current password", autocomplete: "current-password" });
const next = h("input", { class: "rs-input", type: "password", placeholder: "New password (min 8)", autocomplete: "new-password" });
return this.section("pi-lock", "Password", h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
const res = await this.run(() => this.client.request("POST", "/rigshare/api/me",
{ current_password: cur.value, new_password: next.value }), "Password changed; other sessions were signed out");
if (res?.token) this.client.setToken(res.token);
if (res) { cur.value = ""; next.value = ""; }
},
}, cur, next, h("button", { class: "rs-btn", type: "submit" }, "Change password")));
}
async loadKeys() {
const keys = await this.run(() => this.client.request("GET", "/rigshare/api/me/keys"));
if (!keys) return;
this.keysEl.replaceChildren(...keys.map((k) => h("div", { class: "rs-row-item" },
h("i", { class: "pi pi-key rs-row-icon" }),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, k.name),
h("div", { class: "rs-muted rs-small" }, `${k.hint} · ${k.last_used ? `used ${timeAgo(k.last_used * 1000)}` : "never used"}`)),
h("button", {
class: "rs-btn rs-btn-icon rs-danger", title: "Revoke", onclick: async () => {
if (!(await this.confirm("Revoke API key", `Revoke "${k.name}"? Apps using it stop working.`))) return;
await this.run(() => this.client.request("DELETE", `/rigshare/api/me/keys/${k.id}`), "Key revoked");
this.loadKeys();
},
}, h("i", { class: "pi pi-trash" })))));
}
keyForm() {
const name = h("input", { class: "rs-input", placeholder: "Key name, e.g. Open WebUI", maxLength: 40 });
return h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
const res = await this.run(() => this.client.request("POST", "/rigshare/api/me/keys", { name: name.value.trim() || "API key" }));
if (res) { this.newKey = res.key; this.renderBody(); }
},
}, h("div", { class: "rs-row" }, name, h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, h("i", { class: "pi pi-plus" }), "Create")));
}
// ----- admin ----------------------------------------------------------
adminView() {
this.adminAccounts ??= h("div", {});
this.adminSettings ??= h("div", {});
this.loadAdmin();
return [
this.section("pi-users", "Accounts", this.adminAccounts, this.createAccountForm()),
this.section("pi-cog", "Server settings", this.adminSettings),
];
}
async loadAdmin() {
const [users, config] = await Promise.all([
this.run(() => this.client.request("GET", "/rigshare/api/users")),
this.run(() => this.client.request("GET", "/rigshare/api/config")),
]);
if (users) this.adminAccounts.replaceChildren(...users.map((u) => this.accountRow(u)));
if (config) this.adminSettings.replaceChildren(this.configForm(config));
}
accountRow(u) {
const me = u.username === this.client.self?.key;
const toggle = (perm, label) => u.perms.admin && perm !== "admin" ? h("button", {
class: "rs-toggle on locked", disabled: true, title: `Admins always have ${label}. Remove Admin to control it.`,
}, label) : h("button", {
class: `rs-toggle ${u.perms[perm] ? "on" : ""}`, title: `Toggle ${label}`,
onclick: async () => {
await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { perms: { [perm]: !u.perms[perm] } }));
this.loadAdmin();
},
}, label);
return h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, u.display_name, h("span", { class: "rs-muted" }, ` @${u.username}${me ? " (you)" : ""}`)),
h("div", { class: "rs-chips" }, toggle("edit", "Edit"), toggle("queue", "Queue"), toggle("manager", "Manager"), toggle("admin", "Admin"))),
h("button", {
class: "rs-btn rs-btn-icon", title: "Change display name", onclick: async () => {
const name = await this.prompt("Display name", `Name shown to others for @${u.username}${me ? " (you)" : ""}`, u.display_name);
if (name == null || name.trim() === u.display_name) return;
const ok = await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { display_name: name.trim() }), "Display name saved");
if (ok) this.loadAdmin();
},
}, h("i", { class: "pi pi-id-card" })),
h("button", {
class: "rs-btn rs-btn-icon", title: "Change login username", onclick: async () => {
const name = await this.prompt("Change username", `New login username for @${u.username}${me ? " (you)" : ""}. Letters, digits, _ . -`, u.username);
if (!name || name.trim() === u.username) return;
const ok = await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { username: name.trim() }), `Renamed to @${name.trim()}`);
if (ok) this.loadAdmin();
},
}, h("i", { class: "pi pi-pencil" })),
h("button", {
class: "rs-btn rs-btn-icon", title: "Set password", onclick: async () => {
const pw = await this.prompt("Set password", `New password for @${u.username} (min 8 characters)`);
if (!pw) return;
await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { password: pw }), "Password changed; their sessions were signed out");
},
}, h("i", { class: "pi pi-key" })),
me ? null : h("button", {
class: "rs-btn rs-btn-icon rs-danger", title: "Delete account", onclick: async () => {
if (!(await this.confirm("Delete account", `Delete @${u.username}? This cannot be undone.`))) return;
await this.run(() => this.client.request("DELETE", `/rigshare/api/users/${encodeURIComponent(u.username)}`), "Account deleted");
this.loadAdmin();
},
}, h("i", { class: "pi pi-trash" })));
}
createAccountForm() {
const username = h("input", { class: "rs-input", placeholder: "Username", autocomplete: "off" });
const display = h("input", { class: "rs-input", placeholder: "Display name (optional)", autocomplete: "off" });
const password = h("input", { class: "rs-input", placeholder: "Password (min 8)", type: "password", autocomplete: "new-password" });
const check = (label, checked) => { const box = h("input", { type: "checkbox", checked }); return [box, h("label", { class: "rs-check" }, box, label)]; };
const [edit, editL] = check("Edit", true);
const [queue, queueL] = check("Queue", true);
const [manager, managerL] = check("Manager", false);
const [admin, adminL] = check("Admin", false);
return h("details", { class: "rs-details" }, h("summary", {}, h("i", { class: "pi pi-user-plus" }), "New account"),
h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
const ok = await this.run(() => this.client.request("POST", "/rigshare/api/users", {
username: username.value.trim(), password: password.value, display_name: display.value.trim() || null,
perms: { edit: edit.checked, queue: queue.checked, manager: manager.checked, admin: admin.checked },
}), "Account created");
if (ok) { username.value = display.value = password.value = ""; this.loadAdmin(); }
},
}, username, display, password,
h("div", { class: "rs-row rs-wrap" }, editL, queueL, managerL, adminL),
h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, "Create account")));
}
configForm(cfg) {
const text = (value, placeholder) => h("input", { class: "rs-input", value: value ?? "", placeholder });
const box = (checked) => h("input", { type: "checkbox", checked: !!checked });
const f = {
name: text(cfg.server_name, "Server name"),
login: box(cfg.require_login),
guests: box(cfg.allow_guests),
broadcast: box(cfg.broadcast_execution),
protect: box(cfg.api_protection?.enabled),
hideAccount: box(cfg.hide_comfy_account),
hideApiTemplates: box(cfg.hide_api_templates),
trusted: text((cfg.api_protection?.trusted_ips || []).join(", "), "127.0.0.1, 172.17.0.0/16"),
interval: h("input", { class: "rs-input rs-num", type: "number", min: 0, value: Math.round((cfg.snapshot_interval_sec ?? 300) / 60) }),
keep: h("input", { class: "rs-input rs-num", type: "number", min: 1, value: cfg.snapshot_keep ?? 30 }),
};
const line = (input, label, hint) => h("label", { class: "rs-check rs-block" }, input,
h("span", {}, label, hint ? h("div", { class: "rs-muted rs-small" }, hint) : null));
return h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
const ok = await this.run(() => this.client.request("PATCH", "/rigshare/api/config", {
server_name: f.name.value.trim() || "RigShare",
require_login: f.login.checked,
allow_guests: f.guests.checked,
broadcast_execution: f.broadcast.checked,
hide_comfy_account: f.hideAccount.checked,
hide_api_templates: f.hideApiTemplates.checked,
api_protection: { enabled: f.protect.checked, trusted_ips: f.trusted.value.split(/[\s,]+/).filter(Boolean) },
snapshot_interval_sec: Math.max(0, +f.interval.value || 0) * 60,
snapshot_keep: Math.max(1, +f.keep.value || 30),
}), "Settings saved");
if (ok) { this.client.server.name = ok.server_name; this.renderHeader(); }
},
},
h("label", { class: "rs-label" }, "Server name"), f.name,
line(f.login, "Require login", "Everyone signs in before ComfyUI loads. API clients use API keys."),
line(f.guests, "Allow guests (only without required login)", "Visitors can watch and chat without an account"),
line(f.broadcast, "Share execution progress", "Everyone sees progress and previews, not only whoever queued"),
line(f.protect, "Enforce permissions on the ComfyUI API", "Blocks queueing, interrupting and uploads for users without permission"),
line(f.hideAccount, "Hide the Comfy.org account login", "Removes ComfyUI's own sign-in button and dialog (used for paid API nodes)"),
line(f.hideApiTemplates, "Hide API templates", "Removes templates that need paid API nodes from the template browser"),
h("label", { class: "rs-label" }, "Trusted IPs / networks (skip login and checks)"), f.trusted,
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Auto snapshot every (min, 0 = off)"), f.interval),
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Automatic snapshots kept"), f.keep),
h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, h("i", { class: "pi pi-save" }), "Save settings"));
}
}
+202
View File
@@ -0,0 +1,202 @@
// Remote cursors, remote selections and "follow" (spectate) mode.
const CURSOR_TTL = 20000;
export class Presence extends EventTarget {
constructor(app, client, sync) {
super();
this.app = app;
this.client = client;
this.sync = sync;
this.cursors = new Map(); // client id -> {x, y, graph, sel, vp, at}
this.following = null;
this.showCursors = true;
this.showNames = true;
this.lastSent = "";
this.pending = false;
client.on("cursor", (m) => this.onCursor(m));
let lastRoom = null;
sync.addEventListener("state", () => {
if (sync.targetKey !== lastRoom) {
lastRoom = sync.targetKey;
this.cursors.clear();
this.redraw();
}
});
client.on("leave", (m) => { this.cursors.delete(m.id); this.redraw(); });
client.on("presence", (users) => {
const ids = new Set(users.map((u) => u.id));
for (const id of this.cursors.keys()) if (!ids.has(id)) this.cursors.delete(id);
if (this.following && !ids.has(this.following)) this.follow(null);
});
window.addEventListener("pointermove", () => { this.pending = true; }, { passive: true });
// User takes the wheel: stop following.
window.addEventListener("pointerdown", (e) => {
if (this.following && e.target?.tagName === "CANVAS") this.follow(null);
}, true);
window.addEventListener("wheel", (e) => {
if (this.following && e.target?.tagName === "CANVAS") this.follow(null);
}, { capture: true, passive: true });
setInterval(() => this.sendState(), 70);
document.addEventListener("visibilitychange", () => {
client.send({ type: "idle", idle: document.hidden });
});
}
install() {
const canvas = this.app.canvas;
const previous = canvas.onDrawForeground;
canvas.onDrawForeground = (ctx, area) => {
previous?.call(canvas, ctx, area);
try { this.draw(ctx); } catch (e) { console.error("[RigShare] draw", e); }
};
}
currentGraphId() {
const g = this.app.canvas?.graph;
const root = this.app.rootGraph ?? this.app.graph;
return !g || g === root ? null : g.id ?? null;
}
viewport() {
const canvas = this.app.canvas;
const rect = canvas.canvas.getBoundingClientRect();
const center = canvas.ds.convertCanvasToOffset([rect.width / 2, rect.height / 2]);
return { cx: Math.round(center[0]), cy: Math.round(center[1]), scale: +canvas.ds.scale.toFixed(3) };
}
sendState() {
if (!this.client.online || !this.app.canvas) return;
let msg;
if (!this.sync.live || document.hidden) {
msg = { type: "cursor", x: null, y: null };
} else {
const [x, y] = this.app.canvas.graph_mouse ?? [0, 0];
const sel = Object.keys(this.app.canvas.selected_nodes ?? {});
msg = { type: "cursor", x: Math.round(x), y: Math.round(y), graph: this.currentGraphId(), sel, vp: this.viewport() };
}
const key = JSON.stringify(msg);
if (key === this.lastSent) return;
this.lastSent = key;
this.client.send(msg);
}
onCursor(m) {
if (m.room !== this.sync.targetKey) return;
this.cursors.set(m.id, { ...m, at: Date.now() });
if (m.id === this.following && m.vp && this.sync.live) this.applyViewport(m.vp, m.graph);
this.redraw();
}
async follow(id) {
this.following = id || null;
const user = id && this.userFor(id);
const room = user?.room && this.client.rooms.find((r) => r.key === user.room);
if (room && room.key !== this.sync.targetKey) {
try { await this.sync.openRoom(room); } catch (e) { console.error("[RigShare] follow", e); }
}
const c = id && this.cursors.get(id);
if (c?.vp) this.applyViewport(c.vp, c.graph);
this.dispatchEvent(new CustomEvent("follow", { detail: this.following }));
}
applyViewport(vp, graphId) {
if (graphId !== this.currentGraphId()) return;
const canvas = this.app.canvas;
const rect = canvas.canvas.getBoundingClientRect();
canvas.ds.scale = vp.scale;
canvas.ds.offset[0] = rect.width / 2 / vp.scale - vp.cx;
canvas.ds.offset[1] = rect.height / 2 / vp.scale - vp.cy;
canvas.setDirty(true, true);
}
redraw() {
this.app.canvas?.setDirty(true, false);
}
userFor(id) {
return this.client.users.find((u) => u.id === id);
}
draw(ctx) {
const visible = new Set();
if (!this.sync.live || !this.showCursors) return this.pruneCursorEls(visible);
const canvas = this.app.canvas;
const graph = canvas.graph;
const scale = canvas.ds.scale;
const here = this.currentGraphId();
const now = Date.now();
for (const [id, c] of this.cursors) {
const user = this.userFor(id);
if (!user || (c.graph ?? null) !== here) continue;
const color = user.color;
// Selection outlines.
for (const nodeId of c.sel || []) {
const node = graph.getNodeById(isNaN(+nodeId) ? nodeId : +nodeId);
if (!node) continue;
const [x, y, w, h] = node.boundingRect ?? [node.pos[0], node.pos[1] - 30, node.size[0], node.size[1] + 30];
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2 / scale;
ctx.setLineDash([6 / scale, 4 / scale]);
ctx.strokeRect(x - 4 / scale, y - 4 / scale, w + 8 / scale, h + 8 / scale);
ctx.restore();
}
if (c.x == null || now - c.at > CURSOR_TTL) continue;
visible.add(id);
this.placeCursor(id, user, c);
}
this.pruneCursorEls(visible);
}
// Cursors live in a DOM layer above the canvas so they stay on top of
// DOM-rendered content (image previews, text widgets, Vue nodes).
overlay() {
if (!this.layer) {
this.layer = document.createElement("div");
this.layer.className = "rs-cursor-layer";
document.body.append(this.layer);
this.els = new Map();
}
return this.layer;
}
placeCursor(id, user, c) {
const layer = this.overlay();
let el = this.els.get(id);
if (!el) {
el = document.createElement("div");
el.className = "rs-cursor";
el.innerHTML = '<svg width="16" height="22" viewBox="0 0 16 22"><path d="M1 1v17l4.5-4 3.5 7.5 3-1.5-3.5-7.2H14z" stroke="rgba(0,0,0,.6)" stroke-width="1.5" stroke-linejoin="round"/></svg><span class="rs-cursor-name"></span>';
layer.append(el);
this.els.set(id, el);
}
const canvas = this.app.canvas;
const rect = canvas.canvas.getBoundingClientRect();
const [x, y] = canvas.ds.convertOffsetToCanvas([c.x, c.y]);
const inside = x >= 0 && y >= 0 && x <= rect.width && y <= rect.height;
el.style.display = inside ? "" : "none";
el.style.transform = `translate(${rect.left + x}px, ${rect.top + y}px)`;
el.querySelector("path").setAttribute("fill", user.color);
const name = el.querySelector(".rs-cursor-name");
name.textContent = user.name;
name.style.background = user.color;
name.style.display = this.showNames ? "" : "none";
}
pruneCursorEls(visible) {
if (!this.els) return;
for (const [id, el] of this.els) {
if (!visible.has(id)) {
el.remove();
this.els.delete(id);
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
// Optional floating server monitor: a small draggable card over the canvas.
import { h, storage } from "./ui.js";
const KEY_ON = "rigshare.serverFloat";
const KEY_POS = "rigshare.serverFloatPos";
function mini(label, pct, text) {
const p = Math.max(0, Math.min(100, pct || 0));
const level = p > 90 ? "hot" : p > 70 ? "warm" : "";
return h("div", { class: "rs-mini", title: `${label}: ${text}` },
h("span", { class: "rs-mini-label" }, label),
h("span", { class: "rs-mini-track" }, h("span", { class: `rs-meter-fill ${level}`, style: `width:${p}%` })),
h("span", { class: "rs-mini-text" }, text));
}
export class ServerWidget {
constructor(client, onChange) {
this.client = client;
this.onChange = onChange;
this.enabled = storage.get(KEY_ON) === "1";
this.el = null;
client.on("stats", () => this.render());
if (this.enabled) this.show();
}
toggle() {
this.enabled = !this.enabled;
storage.set(KEY_ON, this.enabled ? "1" : null);
this.enabled ? this.show() : this.hide();
this.onChange?.();
}
show() {
if (this.el) return;
this.body = h("div", { class: "rs-float-body" });
const head = h("div", { class: "rs-float-head" },
h("i", { class: "pi pi-server" }), h("span", { class: "rs-grow" }, "Server"),
h("button", { class: "rs-float-close", title: "Close", onclick: () => this.toggle() }, h("i", { class: "pi pi-times" })));
this.el = h("div", { class: "rs-float rs-panel-vars" }, head, this.body);
let pos = null;
try { pos = JSON.parse(storage.get(KEY_POS)); } catch { /* default */ }
this.place(pos?.x ?? window.innerWidth - 300, pos?.y ?? 70);
document.body.append(this.el);
this.drag(head);
this.render();
}
hide() {
this.el?.remove();
this.el = null;
}
place(x, y) {
const w = this.el.offsetWidth || 260;
const hgt = this.el.offsetHeight || 120;
x = Math.max(4, Math.min(window.innerWidth - w - 4, x));
y = Math.max(4, Math.min(window.innerHeight - hgt - 4, y));
this.el.style.left = `${x}px`;
this.el.style.top = `${y}px`;
return { x, y };
}
drag(handle) {
handle.addEventListener("pointerdown", (e) => {
if (e.target.closest("button")) return;
e.preventDefault();
const startX = e.clientX - this.el.offsetLeft;
const startY = e.clientY - this.el.offsetTop;
const move = (ev) => this.place(ev.clientX - startX, ev.clientY - startY);
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
storage.set(KEY_POS, JSON.stringify({ x: this.el.offsetLeft, y: this.el.offsetTop }));
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
}
render() {
if (!this.el) return;
const st = this.client.stats;
if (!st) {
this.body.replaceChildren(h("div", { class: "rs-muted rs-small" }, "Waiting for data…"));
return;
}
const rows = [];
if (st.cpu != null) rows.push(mini("CPU", st.cpu, `${Math.round(st.cpu)}%`));
if (st.ram_total) rows.push(mini("RAM", (st.ram_used / st.ram_total) * 100, `${Math.round(st.ram_used)}G`));
for (const g of st.gpus || []) {
rows.push(mini(`GPU${g.index}`, g.util, `${g.util}%${g.temp != null ? ` ${g.temp}°` : ""}`));
rows.push(mini(`VRAM${g.index}`, (g.vram_used / g.vram_total) * 100, `${g.vram_used.toFixed(1)}G`));
}
const q = st.queue;
if (q) rows.push(h("div", { class: "rs-mini-queue" }, q.running || q.pending ? `${q.running} running · ${q.pending} queued` : "queue idle"));
this.body.replaceChildren(...rows);
}
}
+199
View File
@@ -0,0 +1,199 @@
// Panel styles. Colors come from ComfyUI's palette variables so everything
// follows the active theme.
export const STYLES = `
.rs-host { height: 100%; overflow: hidden; }
.rs-panel, .rs-panel-vars {
--rs-bg: var(--comfy-menu-bg, #202020);
--rs-bg2: var(--comfy-input-bg, #2a2a2a);
--rs-fg: var(--fg-color, #ddd);
--rs-muted: var(--descrip-text, #999);
--rs-border: var(--border-color, #3a3a3a);
--rs-accent: var(--p-primary-color, #3b82f6);
--rs-accent-fg: var(--p-primary-contrast-color, #fff);
--rs-soft: color-mix(in srgb, var(--rs-border) 55%, transparent);
color: var(--rs-fg); font-size: 13px; line-height: 1.4;
}
.rs-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; }
/* header */
.rs-header { display: flex; align-items: center; gap: 8px; padding: 10px 12px 8px; }
.rs-title { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.rs-logo { width: 28px; height: 28px; border-radius: 7px; flex: none; display: block;
background: linear-gradient(135deg, #3b82f6, #a855f7); color: #fff; font-weight: 800; font-size: 14px; }
.rs-title-text { min-width: 0; }
.rs-title-name { font-weight: 650; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rs-status { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--rs-muted); }
.rs-dot { width: 7px; height: 7px; border-radius: 50%; background: #888; }
.rs-status-online .rs-dot { background: #22c55e; box-shadow: 0 0 0 3px rgb(34 197 94 / .18); }
.rs-status-connecting .rs-dot { background: #eab308; }
.rs-status-offline .rs-dot, .rs-status-denied .rs-dot { background: #ef4444; }
.rs-me-btn { border: 2px solid transparent; background: none; padding: 1px; border-radius: 50%; cursor: pointer; }
.rs-me-btn:hover, .rs-me-btn.active { border-color: var(--rs-accent); }
/* tabs */
.rs-nav { display: flex; gap: 2px; padding: 0 8px; border-bottom: 1px solid var(--rs-border); }
.rs-tab { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 2px; position: relative;
padding: 6px 2px 7px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--rs-muted);
font: inherit; font-size: 10.5px; cursor: pointer; }
.rs-tab .pi { font-size: 15px; }
.rs-tab:hover { color: var(--rs-fg); }
.rs-tab.active { color: var(--rs-fg); border-bottom-color: var(--rs-accent); }
.rs-tab-label { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rs-count { position: absolute; top: 2px; left: calc(50% + 6px); min-width: 15px; height: 15px; padding: 0 4px; border-radius: 8px;
background: var(--rs-bg2); color: var(--rs-muted); font-size: 9.5px; line-height: 15px; font-weight: 600; }
.rs-count.hot { background: #ef4444; color: #fff; }
/* body + sections */
.rs-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 12px 14px; }
.rs-body-chat { display: flex; flex-direction: column; overflow: hidden; padding-bottom: 10px; }
.rs-section { margin-bottom: 14px; }
.rs-section-title { display: flex; align-items: center; gap: 7px; margin-bottom: 6px; color: var(--rs-muted);
font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: .05em; }
.rs-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 40px 10px; color: var(--rs-muted); text-align: center; }
.rs-muted { color: var(--rs-muted); }
.rs-hint { max-width: 260px; line-height: 1.5; }
.rs-access { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 12px; }
.rs-access .pi { color: var(--rs-muted); }
.rs-btn-sm { padding: 3px 9px; font-size: 11px; }
.rs-access-editor { margin-top: 8px; padding: 8px 10px; border: 1px solid var(--rs-border); border-radius: 8px; background: color-mix(in srgb, var(--rs-bg2) 50%, transparent); }
.rs-access-list { margin-top: 8px; max-height: 240px; overflow-y: auto; }
.rs-input.rs-select { width: auto; flex: none; padding: 4px 6px; font-size: 12px; }
.rs-small { font-size: 11px; }
.rs-section p { margin: 4px 0 8px; }
.rs-note { display: flex; gap: 6px; align-items: center; color: var(--rs-muted); font-size: 12px; }
.rs-grow { flex: 1; }
.rs-min0 { min-width: 0; }
.rs-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rs-row { display: flex; gap: 6px; align-items: center; }
.rs-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
code { font-family: ui-monospace, monospace; font-size: 11px; background: var(--rs-bg2); padding: 1px 4px; border-radius: 4px; }
/* list rows */
.rs-row-item { display: flex; align-items: center; gap: 10px; padding: 7px 6px; border-radius: 6px; }
.rs-row-item + .rs-row-item { border-top: 1px solid var(--rs-soft); }
.rs-row-icon { color: var(--rs-muted); flex: none; width: 16px; text-align: center; }
.rs-room { cursor: default; }
.rs-room.here { background: color-mix(in srgb, var(--rs-accent) 12%, transparent); }
.rs-where { display: flex; align-items: center; gap: 5px; margin-top: 3px; color: var(--rs-muted); font-size: 11px; min-width: 0; }
.rs-where .pi { font-size: 10px; flex: none; }
.rs-inline-icon { margin-left: 6px; font-size: 11px; }
/* this tab */
.rs-tab-head { display: flex; align-items: center; gap: 8px; }
.rs-tab-head .rs-name { font-size: 14px; }
.rs-live-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
.rs-live { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; }
.rs-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: rs-pulse 1.6s infinite; flex: none; }
@keyframes rs-pulse { 50% { opacity: .35; } }
.rs-history { margin-top: 8px; max-height: 260px; overflow-y: auto; border: 1px solid var(--rs-soft); border-radius: 8px; padding: 2px 4px; }
/* avatars, chips */
.rs-avatar { width: 30px; height: 30px; border-radius: 50%; flex: none; display: inline-grid; place-items: center;
font-weight: 700; color: #111; font-size: 11px; }
.rs-avatar-sm { width: 28px; height: 28px; font-size: 10.5px; }
.rs-avatar-lg { width: 44px; height: 44px; font-size: 15px; }
.rs-avatar-xs { width: 20px; height: 20px; font-size: 8.5px; }
.rs-avatars { display: inline-flex; gap: 3px; flex-wrap: wrap; margin-top: 3px; }
.rs-name { font-weight: 600; }
.rs-me { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.rs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
.rs-chip { font-size: 10px; line-height: 16px; padding: 0 7px; border-radius: 999px; border: 1px solid var(--rs-border); color: var(--rs-muted); white-space: nowrap; }
.rs-chip-admin { border-color: #f59e0b; color: #f59e0b; }
.rs-chip-edit { border-color: #22c55e; color: #22c55e; }
.rs-chip-queue { border-color: #38bdf8; color: #38bdf8; }
.rs-chip-manager { border-color: #c084fc; color: #c084fc; }
.rs-wrap { flex-wrap: wrap; }
/* Comfy.org account sign-in hidden by the admin */
body.rs-hide-comfy-account [data-testid="login-button"],
body.rs-hide-comfy-account [data-testid="login-button-popover"] { display: none !important; }
/* ComfyUI Manager hidden for accounts without the "manager" permission */
body.rs-no-manager .comfyui-button-group:has(> [title="ComfyUI Manager"]),
body.rs-no-manager [title="ComfyUI Manager"],
body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
.rs-toggle { font-size: 10px; line-height: 16px; padding: 0 8px; border-radius: 999px; border: 1px dashed var(--rs-border); background: none; color: var(--rs-muted); cursor: pointer; }
.rs-toggle.on { border-style: solid; border-color: #22c55e; color: #22c55e; }
.rs-toggle:hover { border-color: var(--rs-accent); }
.rs-toggle.locked { opacity: .55; cursor: default; }
.rs-toggle.locked:hover { border-color: #22c55e; }
/* controls */
.rs-form { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.rs-label { font-size: 11px; color: var(--rs-muted); margin-top: 4px; }
.rs-input { width: 100%; box-sizing: border-box; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border);
background: var(--rs-bg2); color: var(--rs-fg); font: inherit; }
.rs-input:focus { outline: none; border-color: var(--rs-accent); }
.rs-num { width: 70px; flex: none; }
.rs-btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 6px; border: 1px solid var(--rs-border);
background: var(--rs-bg2); color: var(--rs-fg); font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; }
.rs-btn:hover:not(:disabled), .rs-btn.active { border-color: var(--rs-accent); }
.rs-btn.active { color: var(--rs-accent); }
.rs-btn:disabled { opacity: .5; cursor: default; }
.rs-btn-primary, .rs-btn-primary.active { background: var(--rs-accent); border-color: var(--rs-accent); color: var(--rs-accent-fg); }
.rs-btn-ghost { background: none; }
.rs-btn-icon { padding: 5px 7px; flex: none; }
.rs-danger:hover { border-color: #ef4444 !important; color: #ef4444; }
.rs-check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; }
.rs-block { display: flex; align-items: flex-start; margin-top: 4px; }
.rs-block input { margin-top: 3px; }
.rs-details { margin-top: 8px; border: 1px dashed var(--rs-border); border-radius: 8px; padding: 6px 8px; }
.rs-details summary { cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 12px; list-style: none; }
.rs-details summary::-webkit-details-marker { display: none; }
.rs-newkey { border: 1px solid #22c55e; border-radius: 8px; padding: 8px; margin: 6px 0; background: rgb(34 197 94 / .08); }
.rs-keytext { word-break: break-all; padding: 4px 6px; }
/* chat */
.rs-chat { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.rs-chat-log { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 2px 2px 8px; }
.rs-msg { max-width: 92%; }
.rs-msg-head { display: flex; align-items: baseline; gap: 6px; }
.rs-msg-name { font-weight: 650; font-size: 12px; }
.rs-msg-text { margin-top: 2px; padding: 6px 9px; border-radius: 4px 10px 10px 10px; background: var(--rs-bg2);
font-size: 12.5px; white-space: pre-wrap; overflow-wrap: anywhere; width: fit-content; max-width: 100%; }
.rs-msg.mine { align-self: flex-end; }
.rs-msg.mine .rs-msg-head { justify-content: flex-end; }
.rs-msg.mine .rs-msg-text { border-radius: 10px 4px 10px 10px; background: color-mix(in srgb, var(--rs-accent) 22%, var(--rs-bg2)); margin-left: auto; }
.rs-msg-system { align-self: center; color: var(--rs-muted); font-size: 11px; font-style: italic; }
.rs-time { color: var(--rs-muted); font-size: 10px; font-variant-numeric: tabular-nums; margin-right: 4px; }
.rs-chat-compose { display: flex; gap: 6px; align-items: flex-end; border-top: 1px solid var(--rs-border); padding-top: 8px; }
.rs-chat-input { resize: none; max-height: 120px; min-height: 32px; }
/* server */
.rs-meter { margin-top: 7px; }
.rs-meter-top { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 3px; }
.rs-meter-track { height: 6px; border-radius: 3px; background: var(--rs-bg2); overflow: hidden; }
.rs-meter-fill { display: block; height: 100%; border-radius: 3px; background: #22c55e; transition: width .6s ease; }
.rs-meter-fill.warm { background: #eab308; }
.rs-meter-fill.hot { background: #ef4444; }
.rs-gpu + .rs-gpu { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--rs-soft); }
.rs-gpu-name { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; font-weight: 600; flex-wrap: wrap; }
.rs-gpu-name .rs-muted { font-weight: 400; font-size: 11px; }
.rs-queue-line { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 6px 8px; border-radius: 6px; background: var(--rs-bg2); }
/* floating server monitor */
.rs-float { position: fixed; z-index: 1000; width: 240px; background: var(--rs-bg); border: 1px solid var(--rs-border);
border-radius: 10px; box-shadow: 0 10px 30px rgb(0 0 0 / .35); font-size: 11px; user-select: none; }
.rs-float-head { display: flex; align-items: center; gap: 6px; padding: 6px 8px; cursor: grab; border-bottom: 1px solid var(--rs-border);
font-weight: 600; font-size: 11px; }
.rs-float-head:active { cursor: grabbing; }
.rs-float-close { border: 0; background: none; color: var(--rs-muted); cursor: pointer; padding: 2px; }
.rs-float-close:hover { color: var(--rs-fg); }
.rs-float-body { padding: 6px 8px 8px; display: flex; flex-direction: column; gap: 4px; }
.rs-mini { display: grid; grid-template-columns: 46px 1fr 62px; align-items: center; gap: 6px; }
.rs-mini-label { color: var(--rs-muted); }
.rs-mini-track { height: 5px; border-radius: 3px; background: var(--rs-bg2); overflow: hidden; }
.rs-mini-text { text-align: right; font-variant-numeric: tabular-nums; }
.rs-mini-queue { color: var(--rs-muted); text-align: center; margin-top: 2px; }
/* remote cursors (DOM layer above canvas content) */
.rs-cursor-layer { position: fixed; inset: 0; pointer-events: none; z-index: 900; overflow: hidden; }
.rs-cursor { position: absolute; left: 0; top: 0; will-change: transform; transition: transform 70ms linear; }
.rs-cursor svg { display: block; filter: drop-shadow(0 1px 1px rgb(0 0 0 / .4)); }
.rs-cursor-name { position: absolute; left: 13px; top: 17px; padding: 1px 6px; border-radius: 4px; color: #111;
font: 600 11px/16px system-ui, sans-serif; white-space: nowrap; }
/* sidebar icon blink for unread chat */
@keyframes rigshare-blink { 0%, 100% { box-shadow: none; } 50% { box-shadow: inset 0 0 0 2px var(--p-primary-color, #3b82f6); background: color-mix(in srgb, var(--p-primary-color, #3b82f6) 25%, transparent); } }
.rigshare-blink { animation: rigshare-blink 1.1s ease-in-out infinite; border-radius: 8px; }
.rigshare-blink .rigshare-tab-icon { color: var(--p-primary-color, #3b82f6); }
`;
+538
View File
@@ -0,0 +1,538 @@
// Live sync of workflow tabs between browsers.
//
// Every shared tab maps to a room on the server: a saved workflow is the room
// "file:<path>" (everyone opening the same file lands in the same room), an
// unsaved tab joins a room only after "Share this tab" ("tmp:<id>", stored in
// the workflow's extra so it survives reloads and travels with the doc).
//
// Only the tab on screen is live. Switching tabs leaves the old room and joins
// the new one; the server copy is authoritative when a room already exists.
//
// Local edits are detected through the frontend change tracker (`graphChanged`)
// plus a light poll while dragging, diffed per node against the last synced
// state and sent as small patches. Remote patches that only touch existing
// nodes (move, resize, widget values, title, mode…) are applied in place;
// anything structural (added/removed nodes, links, groups, subgraphs) reloads
// the tab from the room document, keeping the local viewport.
const LS_OVERRIDES = "rigshare.shareOverrides";
import { applyPatch, itemId } from "./graphdoc.js";
export { applyPatch };
// Split a workflow into keyed parts so edits diff per node / link / group.
export function snapshotOf(wf) {
const nodes = new Map();
for (const n of wf?.nodes || []) nodes.set(n.id, JSON.stringify(n));
const keyed = {};
const groupsKeyed = Array.isArray(wf?.groups) && wf.groups.every((g) => g && g.id !== undefined && g.id !== null);
for (const key of ["links", "groups"]) {
if (key === "groups" && !groupsKeyed) continue;
if (!Array.isArray(wf?.[key])) continue;
keyed[key] = new Map(wf[key].map((it) => [itemId(key, it), JSON.stringify(it)]));
}
const rest = new Map();
for (const [k, v] of Object.entries(wf || {})) if (k !== "nodes" && !keyed[k]) rest.set(k, JSON.stringify(v));
return { nodes, keyed, rest };
}
export function diffSnapshots(base, snap) {
const patch = {};
const nodes = [];
const added = [];
const removed = [];
for (const [id, json] of snap.nodes) {
const old = base.nodes.get(id);
if (old !== json) nodes.push(JSON.parse(json));
if (old === undefined) added.push(id);
}
for (const id of base.nodes.keys()) if (!snap.nodes.has(id)) removed.push(id);
if (nodes.length) patch.nodes = nodes;
if (added.length) patch.added = added;
if (removed.length) patch.removed = removed;
const set = {};
const unset = [];
for (const key of ["links", "groups"]) {
const now = snap.keyed[key];
const was = base.keyed[key];
if (!now || !was) {
// Not keyed on one side (unusual shape): fall back to a plain set.
if (now) set[key] = [...now.values()].map((j) => JSON.parse(j));
continue;
}
const upsert = [];
const newIds = [];
const gone = [];
for (const [id, json] of now) {
if (was.get(id) !== json) upsert.push(JSON.parse(json));
if (!was.has(id)) newIds.push(id);
}
for (const id of was.keys()) if (!now.has(id)) gone.push(id);
if (upsert.length || gone.length) {
patch[key] = { upsert, removed: gone };
if (key === "links" && newIds.length) patch[key].added = newIds;
}
}
for (const [k, json] of snap.rest) if (base.rest.get(k) !== json) set[k] = JSON.parse(json);
for (const k of base.rest.keys()) if (!snap.rest.has(k) && !snap.keyed[k]) unset.push(k);
if (Object.keys(set).length) patch.set = set;
if (unset.length) patch.unset = unset;
return Object.keys(patch).length ? patch : null;
}
function stripView(wf) {
if (wf?.extra) {
delete wf.extra.ds;
}
return wf;
}
export class RoomSync extends EventTarget {
constructor(app, api, client) {
super();
this.app = app;
this.api = api;
this.client = client;
this.autoShareFiles = true;
this.autoShareUnsaved = true;
this.optedOut = new WeakSet(); // unsaved tabs the user chose to keep private
this.seen = new WeakMap(); // unsaved tab -> nodes when first seen
this.overrides = this.loadOverrides();
this.target = null; // room info of the tab on screen, or null
this.targetWf = null; // that tab's workflow object
this.targetKey = undefined;
this.ready = false; // server confirmed the join
this.doc = null;
this.version = 0;
this.baseline = null;
this.busy = Promise.resolve();
this.applying = 0;
this.settleUntil = 0;
this.pointerDown = false;
this.lastEditor = null;
this.readOnlyForced = false;
this.room = null; // {role, manage, restricted, owner} of the joined room
this.denied = new Set(); // rooms we were refused; not retried until access changes
this.inFlight = 0; // our patches not yet acknowledged
this.crossed = false; // a remote patch arrived while ours were in flight
client.on("welcome", () => { this.targetKey = undefined; this.tick(); });
client.on("status", (s) => { if (s !== "online") { this.ready = false; this.emitState(); } });
client.on("room", (msg) => this.onRoom(msg));
client.on("room_info", (msg) => {
if (msg.room !== this.targetKey) return;
this.room = { role: msg.role, manage: msg.manage, restricted: msg.restricted, owner: msg.owner };
this.updateReadOnly();
this.emitState();
});
client.on("room_denied", (msg) => {
this.denied.add(msg.room);
if (msg.room === this.targetKey) {
this.ready = false;
this.room = null;
}
client.emit("toast", { severity: "warn", summary: "RigShare", detail: `You don't have access to "${msg.name}". Your tab stays as a private copy.` });
this.updateReadOnly();
this.emitState();
});
client.on("presence", () => {
// Access granted again: the room shows up in our list.
for (const key of this.denied) if (client.rooms.some((r) => r.key === key)) this.denied.delete(key);
});
client.on("graph", (msg) => this.onRemotePatch(msg));
client.on("doc", (msg) => this.onRemoteDoc(msg));
client.on("ack", (msg) => {
if (msg.room !== this.targetKey) return;
this.version = msg.version;
this.inFlight = Math.max(0, this.inFlight - 1);
if (!this.inFlight && this.crossed) {
// Our edits and someone else's crossed on the way: fetch the
// server's merged result so every copy converges.
this.crossed = false;
this.client.send({ type: "request_doc", room: this.targetKey });
}
this.emitState();
});
client.on("self", () => this.updateReadOnly());
client.on("unshared", (msg) => {
const detail = msg.removed
? `"${msg.name}" is no longer shared.`
: msg.others.length
? `You left "${msg.name}". Still shared with ${msg.others.join(", ")}.`
: `You left "${msg.name}".`;
client.emit("toast", { severity: "info", summary: "RigShare", detail });
});
api.addEventListener("graphChanged", () => this.checkLocal());
window.addEventListener("pointerdown", (e) => {
if (e.target?.tagName === "CANVAS") this.pointerDown = true;
}, true);
window.addEventListener("pointerup", () => {
if (this.pointerDown) {
this.pointerDown = false;
setTimeout(() => this.checkLocal(), 30);
}
}, true);
// Drag updates stream at ~8 Hz; the tick notices tab switches and
// catches edits that bypass the change tracker.
setInterval(() => { if (this.pointerDown) this.checkLocal(); }, 120);
setInterval(() => this.tick(), 400);
}
emitState() {
this.dispatchEvent(new CustomEvent("state"));
}
get store() {
return this.app.extensionManager?.workflow;
}
/** The tab on screen is shared and in sync with its room. */
get live() {
return this.ready && !!this.targetKey;
}
/** May edit the tab on screen: account permission, narrowed by the room's access list. */
get canEdit() {
if (!this.client.perms.edit) return false;
return !this.targetKey || !this.room || this.room.role === "edit";
}
graph() {
return this.app.rootGraph ?? this.app.graph;
}
serialize() {
return stripView(JSON.parse(JSON.stringify(this.graph().serialize())));
}
// ----- which room does a tab belong to --------------------------------
loadOverrides() {
try { return JSON.parse(localStorage.getItem(LS_OVERRIDES)) || {}; } catch { return {}; }
}
saveOverrides() {
try { localStorage.setItem(LS_OVERRIDES, JSON.stringify(this.overrides)); } catch { /* ignore */ }
}
tabName(wf) {
return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/\.json$/, "");
}
roomFor(wf, extra) {
if (!wf) return null;
if (!wf.isTemporary) {
const shared = this.overrides[wf.path] ?? this.autoShareFiles;
return shared ? { key: `file:${wf.path}`, name: this.tabName(wf), kind: "file" } : null;
}
extra ??= (wf.changeTracker?.activeState ?? wf.activeState)?.extra;
const id = extra?.rigshare?.room;
return id ? { key: id, name: this.tabName(wf), kind: "tmp" } : null;
}
activeRoom() {
const wf = this.store?.activeWorkflow;
// For the tab on screen, read the live graph (share flag may be fresh).
const room = this.roomFor(wf, this.graph()?.extra);
return { wf, room: room && this.denied.has(room.key) ? null : room };
}
/** Change who can open the room on screen (owner or admin). */
async setAccess(restricted, members) {
if (!this.targetKey) return;
return await this.client.request("PUT", "/rigshare/api/room/acl", { key: this.targetKey, restricted, members });
}
// ----- joining --------------------------------------------------------
tick() {
if (!this.client.online || !this.store) return;
this.maybeAutoShare();
const { wf, room } = this.activeRoom();
const key = room?.key ?? null;
if (key !== this.targetKey || (key && wf !== this.targetWf)) {
this.switchTo(room, wf);
} else if (room && this.target && room.name !== this.target.name) {
this.target = room; // tab renamed
this.emitState();
} else if (this.live && this.canEdit) {
this.checkLocal();
}
this.updateReadOnly();
}
switchTo(room, wf) {
this.target = room;
this.targetWf = wf;
this.targetKey = room?.key ?? null;
this.ready = false;
this.baseline = null;
this.doc = null;
this.lastEditor = null;
this.inFlight = 0;
this.crossed = false;
this.room = null;
this.client.send(room
? { type: "join", room: room.key, name: room.name, doc: this.serialize() }
: { type: "join", room: null });
this.updateReadOnly();
this.emitState();
}
onRoom(msg) {
if (msg.room !== this.targetKey) return;
this.version = msg.version;
this.room = { role: msg.role, manage: msg.manage, restricted: msg.restricted, owner: msg.owner };
if (msg.created || !msg.doc) {
this.doc = this.serialize();
this.ready = true;
this.settle();
this.emitState();
return;
}
this.doc = msg.doc;
this.ready = true;
const differs = diffSnapshots(snapshotOf(this.serialize()), snapshotOf(this.doc));
if (differs) this.enqueue(() => this.loadDoc(true));
else this.settle();
this.updateReadOnly();
this.emitState();
}
enqueue(fn) {
this.busy = this.busy.then(fn).catch((e) => console.error("[RigShare] sync error", e));
return this.busy;
}
/** Reload the tab on screen from the room document. */
async loadDoc(keepView) {
const wf = this.store?.activeWorkflow;
if (!this.live || wf !== this.targetWf) return;
const doc = structuredClone(this.doc);
const ds = this.app.canvas?.ds;
if (keepView && ds) doc.extra = { ...(doc.extra || {}), ds: { scale: ds.scale, offset: [...ds.offset] } };
this.applying++;
try {
await this.app.loadGraphData(doc, true, keepView, wf, { skipAssetScans: true, silentAssetErrors: true });
} finally {
this.applying--;
}
this.settle();
this.updateReadOnly();
}
settle(ms = 250) {
this.baseline = snapshotOf(this.serialize());
this.settleUntil = performance.now() + ms;
clearTimeout(this.settleTimer);
this.settleTimer = setTimeout(() => {
if (this.live && !this.applying) this.baseline = snapshotOf(this.serialize());
}, ms + 20);
}
// ----- local -> remote ------------------------------------------------
checkLocal() {
if (!this.live || this.applying || !this.baseline || !this.client.online) return;
if (this.store?.activeWorkflow !== this.targetWf) return;
if (performance.now() < this.settleUntil) return;
const snap = snapshotOf(this.serialize());
const patch = diffSnapshots(this.baseline, snap);
if (!patch) return;
if (!this.canEdit) {
// Viewers cannot change shared tabs: put it back.
clearTimeout(this.revertTimer);
this.revertTimer = setTimeout(() => this.enqueue(() => this.loadDoc(true)), 300);
return;
}
this.baseline = snap;
this.doc = applyPatch(this.doc, patch);
this.inFlight++;
this.client.send({ type: "graph", room: this.targetKey, patch });
}
// ----- remote -> local ------------------------------------------------
onRemoteDoc(msg) {
if (msg.room !== this.targetKey) return;
this.doc = msg.doc;
this.version = msg.version;
if (msg.reason) this.client.emit("toast", { severity: "info", summary: "RigShare", detail: msg.reason });
this.enqueue(() => this.loadDoc(true));
this.emitState();
}
onRemotePatch(msg) {
if (msg.room !== this.targetKey || !this.ready) return;
if (this.inFlight) this.crossed = true;
const patch = msg.patch;
this.doc = applyPatch(this.doc, patch);
this.version = msg.version;
this.lastEditor = { id: msg.from, name: msg.name, at: Date.now() };
this.emitState();
this.enqueue(async () => {
if (this.isStructural(patch)) {
// Send our own pending edits first so they are not lost.
this.checkLocal();
await this.loadDoc(true);
} else {
this.applyInPlace(patch.nodes || []);
}
});
}
isStructural(patch) {
if ("full" in patch || patch.removed?.length || patch.unset?.length || patch.added?.length) return true;
if (patch.links || patch.groups) return true;
if (Object.keys(patch.set || {}).some((k) => !["last_node_id", "last_link_id"].includes(k))) return true;
const graph = this.graph();
for (const data of patch.nodes || []) {
const node = graph.getNodeById(data.id);
if (!node) return true;
const links = (arr, key) => JSON.stringify((arr || []).map((s) => s?.[key] ?? null));
if (links(data.inputs, "link") !== links(node.inputs, "link")) return true;
if (links(data.outputs, "links") !== links(node.outputs, "links")) return true;
if (data.type !== node.type) return true;
}
return false;
}
applyInPlace(nodes) {
if (this.store?.activeWorkflow !== this.targetWf) return;
const graph = this.graph();
const focused = document.activeElement;
// Keep unsent local edits: diff before touching anything.
this.checkLocal();
this.applying++;
try {
for (const data of nodes) {
const node = graph.getNodeById(data.id);
if (!node) continue;
// Don't yank a text box out from under someone typing in it.
const typing = focused && node.widgets?.some((w) => w.element?.contains?.(focused));
if (typing) continue;
node.configure(structuredClone(data));
}
} finally {
this.applying--;
}
this.app.canvas?.setDirty(true, true);
this.settle();
}
// ----- permissions ----------------------------------------------------
updateReadOnly() {
const canvas = this.app.canvas;
if (!canvas) return;
const lock = this.live && !this.canEdit;
if (lock && !canvas.read_only) {
canvas.read_only = true;
this.readOnlyForced = true;
} else if (!lock && this.readOnlyForced) {
canvas.read_only = false;
this.readOnlyForced = false;
this.room = null; // {role, manage, restricted, owner} of the joined room
this.denied = new Set(); // rooms we were refused; not retried until access changes
this.inFlight = 0; // our patches not yet acknowledged
this.crossed = false; // a remote patch arrived while ours were in flight
}
}
// ----- actions --------------------------------------------------------
/** Start sharing the tab on screen. */
shareCurrent() {
const wf = this.store?.activeWorkflow;
if (!wf) return;
if (wf.isTemporary) {
const graph = this.graph();
graph.extra ??= {};
graph.extra.rigshare = { room: `tmp:${crypto.randomUUID?.() ?? Date.now().toString(36)}` };
wf.changeTracker?.checkState();
} else {
this.overrides[wf.path] = true;
this.saveOverrides();
}
this.tick();
}
/**
* Unsaved tabs join a room on their own once someone edits them. Tabs that
* are merely open (the default workflow, restored tabs) stay private so the
* shared list is not flooded with untouched copies.
*/
maybeAutoShare() {
const wf = this.store.activeWorkflow;
if (!this.autoShareUnsaved || !wf?.isTemporary || this.optedOut.has(wf)) return;
const graph = this.graph();
if (!graph || graph.extra?.rigshare?.room) return;
const sig = JSON.stringify(graph.serialize().nodes);
const seen = this.seen.get(wf);
const now = performance.now();
// Keep re-baselining while the tab is still loading/settling.
if (!seen || now - seen.at < 3000) {
this.seen.set(wf, { sig, at: seen?.at ?? now });
if (seen) seen.sig = sig;
return;
}
if (sig !== seen.sig && graph.nodes?.length) this.shareCurrent();
}
/** Stop sharing the tab on screen (keeps a private copy). */
unshareCurrent() {
const wf = this.store?.activeWorkflow;
if (!wf) return;
if (this.targetKey) this.client.send({ type: "unshare", room: this.targetKey });
if (wf.isTemporary) {
this.optedOut.add(wf);
delete this.graph().extra?.rigshare;
wf.changeTracker?.checkState();
} else {
this.overrides[wf.path] = false;
this.saveOverrides();
}
this.tick();
}
/** Find an already-open tab for a room. */
findTab(room) {
for (const wf of this.store?.openWorkflows || []) {
const r = this.roomFor(wf);
if (r?.key === room.key) return wf;
}
return null;
}
/** Open a room from the list (switching to its tab if already open). */
async openRoom(room) {
const store = this.store;
let wf = this.findTab(room);
if (wf && wf === store.activeWorkflow) return;
const data = await this.client.request("GET", `/rigshare/api/room?key=${encodeURIComponent(room.key)}`);
if (!wf && room.kind === "file") {
const path = room.key.slice(5);
if (this.overrides[path] === false) {
delete this.overrides[path];
this.saveOverrides();
}
await store.syncWorkflows?.();
wf = store.getWorkflowByPath?.(path) ?? null;
if (wf && !wf.isLoaded) await wf.load();
}
if (wf) {
await this.app.loadGraphData(data.doc ?? wf.activeState, true, true, wf);
} else {
// Unsaved shared tab (or a file that no longer exists): open a
// temporary tab carrying the room id.
const doc = structuredClone(data.doc || {});
doc.extra = { ...(doc.extra || {}), rigshare: { room: room.key } };
await this.app.loadGraphData(doc, true, true, `${room.name}.json`);
}
this.tick();
}
}
+64
View File
@@ -0,0 +1,64 @@
// Small DOM helpers shared by the panel and widgets.
export function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === "class") el.className = v;
else if (k === "style") el.style.cssText = v;
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
else if (k in el && typeof v !== "string") el[k] = v;
else el.setAttribute(k, v === true ? "" : v);
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
return el;
}
export function initials(name) {
return (name || "?").split(/[\s_.-]+/).filter(Boolean).slice(0, 2).map((s) => s[0].toUpperCase()).join("") || "?";
}
export function timeAgo(ts) {
const s = Math.max(0, (Date.now() - ts) / 1000);
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
return new Date(ts).toLocaleDateString();
}
export function clock(ts) {
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function roleChips(perms) {
if (perms.admin) return [h("span", { class: "rs-chip rs-chip-admin" }, "Admin")];
const chips = [];
if (perms.edit) chips.push(h("span", { class: "rs-chip rs-chip-edit" }, "Editor"));
if (perms.queue) chips.push(h("span", { class: "rs-chip rs-chip-queue" }, "Queue"));
if (perms.manager) chips.push(h("span", { class: "rs-chip rs-chip-manager" }, "Manager"));
if (!chips.length) chips.push(h("span", { class: "rs-chip" }, "Viewer"));
return chips;
}
export function meter(label, pct, text) {
const p = Math.max(0, Math.min(100, pct || 0));
const level = p > 90 ? "hot" : p > 70 ? "warm" : "";
return h("div", { class: "rs-meter" },
h("div", { class: "rs-meter-top" }, h("span", {}, label), h("span", { class: "rs-muted" }, text)),
h("div", { class: "rs-meter-track" }, h("div", { class: `rs-meter-fill ${level}`, style: `width:${p}%` })));
}
export const storage = {
get(key) {
try { return localStorage.getItem(key); } catch { return null; }
},
set(key, value) {
try {
if (value == null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch { /* private mode */ }
},
};
-25
View File
@@ -1,25 +0,0 @@
import { NexusSocket } from "./lib/nexus.sockets.js";
import { NexusMouseManager } from "./lib/nexus.mouse.js";
import { NexusWorkflowManager } from "./lib/nexus.workflow.js";
import { NexusPromptControl } from "./lib/nexus.comfy.js";
import { NexusCommands } from "./lib/nexus.cmd.js";
let app = window.comfyAPI.app.app;
let api = window.comfyAPI.api.api;
let nexusSocket = new NexusSocket()
let nexus = {
name: "ComfyUI-Nexus",
async setup(app) {
app = app
let mouseManager = new NexusMouseManager(nexusSocket, app, 10)
let workflowManager = new NexusWorkflowManager(app, api, nexusSocket)
let nexusPromptControl = new NexusPromptControl(api, app, nexusSocket)
new NexusCommands(app, api, nexusSocket, mouseManager.color, mouseManager, workflowManager, nexusPromptControl)
nexusSocket.connect()
},
async beforeConfigureGraph() {
}
}
app.registerExtension(nexus);