Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e123dc9279 | ||
|
|
a75443d8bb | ||
|
|
6e2324a7c3 | ||
|
|
137051a135 | ||
|
|
78415ebf25 | ||
|
|
c94b90133a | ||
|
|
5373c85b86 | ||
|
|
1f38a22535 | ||
|
|
531fec2ec9 | ||
|
|
91f3383e86 | ||
|
|
2b1b3b11ce | ||
|
|
7e10db82df | ||
|
|
5e943f3e60 | ||
|
|
f4131ac6f5 | ||
|
|
d3cef4a6ca |
@@ -0,0 +1,74 @@
|
||||
"""Print a version's CHANGELOG.md section as plain text for the Comfy Registry.
|
||||
|
||||
The registry shows a version's changelog as one paragraph: line breaks and
|
||||
Markdown are not rendered. So headings become "Added:", "Fixed:"..., list
|
||||
items are joined with "; ", and Markdown markup is dropped.
|
||||
|
||||
python .github/scripts/registry_changelog.py 1.2.2
|
||||
python .github/scripts/registry_changelog.py --all # JSON {version: text}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def sections(path="CHANGELOG.md"):
|
||||
out, current = {}, None
|
||||
for line in open(path, encoding="utf-8").read().splitlines():
|
||||
m = re.match(r"^## (\d+\.\d+\.\d+)\b", line)
|
||||
if m:
|
||||
current = m.group(1)
|
||||
out[current] = []
|
||||
elif line.startswith("## "):
|
||||
current = None
|
||||
elif current:
|
||||
out[current].append(line)
|
||||
return out
|
||||
|
||||
|
||||
def plain(text):
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # links: keep the text
|
||||
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
||||
text = re.sub(r"(?<!\w)\*([^*]+)\*(?!\w)", r"\1", text)
|
||||
return text.replace("`", "").strip()
|
||||
|
||||
|
||||
def registry_text(lines):
|
||||
parts, heading, items, intro = [], None, [], []
|
||||
|
||||
def flush():
|
||||
if items:
|
||||
body = "; ".join(i.rstrip(".") for i in items) + "."
|
||||
parts.append(f"{heading}: {body}" if heading else body)
|
||||
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("### "):
|
||||
flush()
|
||||
heading, items = plain(line[4:]), []
|
||||
elif line.startswith("- "):
|
||||
items.append(plain(line[2:]))
|
||||
elif items:
|
||||
items[-1] += " " + plain(line) # a wrapped list item
|
||||
else:
|
||||
intro.append(plain(line))
|
||||
flush()
|
||||
return " ".join(intro + parts).strip()
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
all_sections = sections()
|
||||
if sys.argv[1:] == ["--all"]:
|
||||
print(json.dumps({v: registry_text(lines) for v, lines in all_sections.items()}, ensure_ascii=False))
|
||||
elif len(sys.argv) == 2:
|
||||
print(registry_text(all_sections.get(sys.argv[1], [])))
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,7 +14,25 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v7
|
||||
- name: Changelog for this version
|
||||
# comfy-cli sends COMFY_NODE_CHANGELOG as the version's changelog (the
|
||||
# registry's Updates section), as plain text: the registry shows one paragraph.
|
||||
run: |
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1)
|
||||
notes=$(python3 .github/scripts/registry_changelog.py "$version")
|
||||
if [ -z "$notes" ]; then
|
||||
echo "::warning::No CHANGELOG.md section for $version"
|
||||
exit 0
|
||||
fi
|
||||
delimiter="EOF_$(date +%s%N)"
|
||||
{
|
||||
echo "COMFY_NODE_CHANGELOG<<$delimiter"
|
||||
echo "$notes"
|
||||
echo "$delimiter"
|
||||
} >> "$GITHUB_ENV"
|
||||
echo "Changelog for $version: $notes"
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
skip_checkout: "true"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Update registry changelogs
|
||||
# Fills in the Comfy Registry changelog of versions already published, from the
|
||||
# matching CHANGELOG.md sections. Run it by hand (Actions -> Run workflow).
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
versions:
|
||||
description: "Versions to update, space separated (empty: every version in CHANGELOG.md)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Update changelogs
|
||||
env:
|
||||
# The registry's edit endpoint takes a registry account key; the publishing
|
||||
# token is tried when no REGISTRY_UPDATE_TOKEN secret is set.
|
||||
TOKEN: ${{ secrets.REGISTRY_UPDATE_TOKEN || secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
VERSIONS: ${{ inputs.versions }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, re, sys, urllib.error, urllib.request
|
||||
|
||||
API = "https://api.comfy.org"
|
||||
publisher = re.search(r'^PublisherId\s*=\s*"([^"]+)"', open("pyproject.toml").read(), re.M).group(1)
|
||||
node = re.search(r'^name\s*=\s*"([^"]+)"', open("pyproject.toml").read(), re.M).group(1)
|
||||
|
||||
import subprocess
|
||||
# Plain text: the registry shows the changelog as one paragraph.
|
||||
notes = json.loads(subprocess.check_output(
|
||||
[sys.executable, ".github/scripts/registry_changelog.py", "--all"], text=True, encoding="utf-8"))
|
||||
|
||||
with urllib.request.urlopen(f"{API}/nodes/{node}/versions") as r:
|
||||
published = json.load(r)
|
||||
ids = {v["version"]: v["id"] for v in published}
|
||||
wanted = os.environ.get("VERSIONS", "").split() or sorted(notes)
|
||||
|
||||
failed = False
|
||||
for version in wanted:
|
||||
if version not in ids or not notes.get(version):
|
||||
print(f"skip {version}: {'not published' if version not in ids else 'no CHANGELOG section'}")
|
||||
continue
|
||||
req = urllib.request.Request(
|
||||
f"{API}/publishers/{publisher}/nodes/{node}/versions/{ids[version]}",
|
||||
data=json.dumps({"changelog": notes[version]}).encode(), method="PUT",
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {os.environ['TOKEN']}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
print(f"updated {version}: HTTP {r.status}")
|
||||
except urllib.error.HTTPError as e:
|
||||
failed = True
|
||||
print(f"::error::{version}: HTTP {e.code} {e.read()[:300]!r}")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## 1.2.3 (2026-09-27)
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Files tab didn't show a workflow you had just saved into the folder on screen until you left the folder and came back. It now refreshes whenever workflows are saved, moved, renamed or deleted, or folders change, including by other people (each person is only told about folders they can open)
|
||||
- When the session had ended (logged out elsewhere, expired, cookie cleared), a ComfyUI page that was already open showed "API key missing" errors instead of going back to the login screen. It now returns to the login screen as soon as a request says login is required, even if ComfyUI had not finished loading
|
||||
|
||||
## 1.2.2 (2026-09-26)
|
||||
|
||||
A Queue tab showing who launched what, and chat clean-up for admins.
|
||||
|
||||
### Added
|
||||
|
||||
- **Queue** tab: the run in progress and the ones waiting, each with the workflow's name, who queued it and when. Stop or remove your own runs from there; admins can do it for anyone's. The server enforces it too: stopping or removing someone else's prompt, or clearing the whole queue, needs *Admin*
|
||||
- Admins can clear the whole chat history (*Admin → Chat*, or `DELETE /rigshare/api/chat`). Everyone's chat empties at once, followed by a notice saying who cleared it
|
||||
|
||||
### Changed
|
||||
|
||||
- Panel tabs in a new order: Live, Chat, People, Queue, Server, Admin
|
||||
- A fuller description on the Comfy Registry
|
||||
|
||||
### Fixed
|
||||
|
||||
- Entering the App view closed an open RigShare or Files panel (going back to the graph kept it open). It now stays open both ways
|
||||
- The Comfy Registry showed no changelog: the publish workflow now sends the matching `CHANGELOG.md` section
|
||||
- The registry logo was a clipped render with only one rounded corner
|
||||
|
||||
## 1.2.1 (2026-09-26)
|
||||
|
||||
### Changed
|
||||
|
||||
- App mode: the **RigShare** and **Files** buttons open their tabs in ComfyUI's docked side panel, like Assets and Apps, instead of a floating drawer
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching between the App view and the graph with the RigShare or Files panel open left the panel blank
|
||||
|
||||
## 1.2.0 (2026-09-26)
|
||||
|
||||
ComfyUI's App mode works with RigShare, and every save keeps a snapshot.
|
||||
|
||||
@@ -25,13 +25,14 @@ A custom node that turns a single ComfyUI server into a shared workspace. Everyo
|
||||
- lets you switch tabs freely, because only the tab on screen syncs, and keeps open tabs pointed at a workflow when someone renames or moves it
|
||||
- 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 the whole chat history, loading older messages as you scroll up, with an unread badge, a blinking icon and a sound
|
||||
- keeps the whole chat history, loading older messages as you scroll up, with an unread badge, a blinking icon and a sound; admins can clear it
|
||||
- shows the queue: what is running and waiting, and who launched each run, with a button to stop or remove your own
|
||||
- keeps per-workflow history: a snapshot on every save and every few minutes, named snapshots, one-click restore
|
||||
- works in ComfyUI's App mode: everyone picks their own graph or App view, App inputs sync live, and you can see who is in which view
|
||||
- gives each account permissions to edit, queue, use ComfyUI Manager or administer, enforced by the server and not just hidden in the UI
|
||||
- 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
|
||||
- shows CPU, RAM, and for each GPU the load, VRAM, temperature and power, in a panel tab or a floating widget
|
||||
- sends execution progress and previews to everyone, not only whoever queued
|
||||
|
||||
What this fork adds over Nexus:
|
||||
@@ -55,9 +56,14 @@ What this fork adds over Nexus:
|
||||
<td align="center"><img src="docs/images/access.png" width="260" alt="Access list of a shared folder"><br><sub>Who can open a folder</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="docs/images/queue.png" width="260" alt="Queue tab"><br><sub>Queue: who launched what</sub></td>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="docs/images/admin.png" width="260" alt="Admin tab"><br><sub>Accounts and server settings</sub></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|---|---|
|
||||
| *(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 |
|
||||
| **Queue** | Queue prompts, stop or remove your own runs, free memory. Stopping or removing someone else's, or clearing the queue, needs *Admin* |
|
||||
| **Manager** | Open ComfyUI Manager: install, update or remove custom nodes and models, snapshots, restart |
|
||||
| **Admin** | Everything, plus deleting workflow files, accounts and server settings |
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ The panel uses these; they take the same session cookie or API key.
|
||||
| `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/chat?before=<seq>&limit=50` | Older chat messages, oldest first, and whether more exist |
|
||||
| `DELETE /rigshare/api/chat` | Clear the whole chat history (admin) |
|
||||
| `GET /rigshare/api/rooms` | Live workflows |
|
||||
| `GET /rigshare/api/room?key=…` | One room, with its workflow JSON |
|
||||
| `GET /rigshare/api/snapshots?room=…` | History of a room |
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
@@ -34,7 +34,7 @@ Admins change these under **Admin → Server settings**. They are stored in `rig
|
||||
| `room_expiry_days` | `14` | Forget the live state of 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, and as many on-save ones; named ones are always kept |
|
||||
| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup |
|
||||
| `chat_keep` | `0` | Chat messages kept: `0` keeps the whole history, a number trims it to the latest ones at startup. Admins can also clear it under *Admin → Chat* |
|
||||
|
||||
## Environment variables
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw
|
||||
|
||||
**Live** shows the tab on screen: whether it is live, who else is in it, and buttons for **Snapshot** and **History**. For a private or unsaved tab it says why it is not live. Below that is the list of **live workflows**: those someone has open right now, with their folder and the people in each one. Open any of them with the arrow. The Files tab marks live files with a green dot.
|
||||
|
||||
**Queue** shows the run in progress and the ones waiting, each with the workflow's name, who queued it and when. The button on a row stops or removes it: your own runs, or anyone's for an admin.
|
||||
|
||||
**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
|
||||
@@ -29,7 +31,7 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw
|
||||
- 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.
|
||||
|
||||
**App mode.** Each person chooses for themselves whether a workflow shows as the node graph or as an App. Switching doesn't change anyone else's view, and live edits keep it. The Live tab and People show which view each person is in, using ComfyUI's Graph and App icons. Inputs changed in the App view sync like any other widget value. The app's layout, meaning its chosen inputs and outputs, belongs to the workflow and syncs. Cursors aren't shown in the App view. ComfyUI's App view shows only its own sidebar tabs, so RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open the panels in a drawer beside it.
|
||||
**App mode.** Each person chooses for themselves whether a workflow shows as the node graph or as an App. Switching doesn't change anyone else's view, and live edits keep it. The Live tab and People show which view each person is in, using ComfyUI's Graph and App icons. Inputs changed in the App view sync like any other widget value. The app's layout, meaning its chosen inputs and outputs, belongs to the workflow and syncs. Cursors aren't shown in the App view. ComfyUI's App view shows only its own sidebar tabs, so RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open in the same docked side panel as ComfyUI's own tabs.
|
||||
|
||||
Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-rigshare"
|
||||
description = "Multi-user collaboration for ComfyUI: accounts and login, live-shared workflows, cursors, chat, snapshots and a server monitor."
|
||||
version = "1.2.0"
|
||||
description = "Turn one ComfyUI server into a shared team workspace. Sign-in with accounts, permissions (edit, queue, Manager, admin) and personal API keys, enforced by the server. A private folder for each person plus shared folders with access lists, with Save asking where. Live co-editing of every shared workflow, with cursors, follow mode and conflict-free merges, in the graph or App view. Per-workflow history with a snapshot on every save, team chat with its whole history, and a CPU, RAM, GPU and queue monitor."
|
||||
version = "1.2.3"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ class ChatLog:
|
||||
os.replace(tmp, self.path)
|
||||
self._index()
|
||||
|
||||
def clear(self):
|
||||
"""Delete the whole history; numbering starts again at 0."""
|
||||
with self._lock:
|
||||
with open(self.path, "w", encoding="utf-8"):
|
||||
pass
|
||||
self.offsets = []
|
||||
|
||||
def __len__(self):
|
||||
return len(self.offsets)
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
import secrets
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from aiohttp import web, WSMsgType
|
||||
|
||||
@@ -106,6 +108,7 @@ class Hub:
|
||||
self.stats = None
|
||||
self._task = None
|
||||
self.workspace = None # set by __init__ (folders, private spaces)
|
||||
self.queue_owners = OrderedDict() # prompt_id -> {user, name, workflow, at}
|
||||
|
||||
# ----- lifecycle ----------------------------------------------------
|
||||
|
||||
@@ -142,10 +145,39 @@ class Hub:
|
||||
continue
|
||||
try:
|
||||
self.stats = await loop.run_in_executor(None, collect_stats, self.server)
|
||||
self.stats["queue_items"] = self.with_owners(self.stats.get("queue_items"))
|
||||
await self.broadcast({"type": "stats", "stats": self.stats})
|
||||
except Exception as e:
|
||||
log.debug(f"[RigShare] stats error: {e}")
|
||||
|
||||
# ----- queue ----------------------------------------------------------
|
||||
|
||||
def record_prompt(self, prompt_id, user, workflow):
|
||||
"""Remember who queued a prompt (ComfyUI itself doesn't)."""
|
||||
if not prompt_id:
|
||||
return
|
||||
self.queue_owners[prompt_id] = {
|
||||
"user": user["username"] if user else None,
|
||||
"name": (user.get("display_name") or user["username"]) if user else None,
|
||||
"workflow": clean_text(workflow, 120) or None, "at": int(time.time())}
|
||||
while len(self.queue_owners) > 2000:
|
||||
self.queue_owners.popitem(last=False)
|
||||
|
||||
def owner_of(self, prompt_id):
|
||||
return (self.queue_owners.get(prompt_id) or {}).get("user")
|
||||
|
||||
def with_owners(self, items):
|
||||
if items is None:
|
||||
return None
|
||||
return [{**it, **(self.queue_owners.get(it["id"]) or {})} for it in items]
|
||||
|
||||
def running_prompt_ids(self):
|
||||
try:
|
||||
running, _ = self.server.prompt_queue.get_current_queue_volatile()
|
||||
return [entry[1] for entry in running]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def snapshot(self, room, author, label=None):
|
||||
if room.doc is None:
|
||||
return None
|
||||
@@ -334,6 +366,14 @@ class Hub:
|
||||
return True
|
||||
return self.workspace.role(rel, client.kind, key, client.perms) is not None
|
||||
|
||||
async def files_changed(self, *paths):
|
||||
"""Workflow files changed in these folders (userdata paths): tell whoever can
|
||||
open one of them, so their Files tab reloads."""
|
||||
folders = {posixpath.dirname(p) if p.endswith(".json") else p for p in paths if p}
|
||||
for client in list(self.clients.values()):
|
||||
if client.kind and any(self.path_access(f, client) for f in folders):
|
||||
await self.send(client, {"type": "files_changed"})
|
||||
|
||||
def who_sees(self, path):
|
||||
"""Connected clients that may open ``path``; call before moving it."""
|
||||
return [c for c in self.clients.values() if c.kind and self.path_access(path, c)]
|
||||
@@ -351,6 +391,7 @@ class Hub:
|
||||
key = (renamed or {}).get(client.key, client.key) if client.kind == "user" else client.key
|
||||
notes.append((client, self.path_access(new, client, key=key)))
|
||||
self.rekey_rooms("file:" + old, "file:" + new)
|
||||
await self.files_changed(posixpath.dirname(old), posixpath.dirname(new), new)
|
||||
for client, follows in notes:
|
||||
await self.send(client, {"type": "path_moved", "from": old, "to": new} if follows
|
||||
else {"type": "path_closed", "from": old})
|
||||
@@ -381,6 +422,12 @@ class Hub:
|
||||
async def broadcast_server(self):
|
||||
await self.broadcast({"type": "server", "server": self.server_info()})
|
||||
|
||||
async def clear_chat(self, by):
|
||||
"""An admin wiped the chat: everyone's history empties, then a notice says who."""
|
||||
self.chat.clear()
|
||||
await self.broadcast({"type": "chat_cleared"})
|
||||
await self.system_chat(f"{by} cleared the chat history")
|
||||
|
||||
async def system_chat(self, text):
|
||||
msg = {"id": secrets.token_hex(6), "system": True, "text": text, "ts": int(time.time() * 1000)}
|
||||
msg = self.chat.append(msg)
|
||||
|
||||
@@ -7,7 +7,9 @@ import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
from collections import defaultdict
|
||||
|
||||
from aiohttp import web
|
||||
@@ -16,6 +18,21 @@ from .workspace import Workspace, norm
|
||||
|
||||
log = logging.getLogger("ComfyUI-RigShare")
|
||||
|
||||
# Runs in the ComfyUI page before anything else: any request answered "login
|
||||
# required" (session ended, cookie cleared) sends the browser to the login page.
|
||||
LOGIN_WATCH = """<script id="rigshare-login-watch">(() => {
|
||||
const fetch0 = window.fetch;
|
||||
let leaving = false;
|
||||
window.fetch = async (...args) => {
|
||||
const res = await fetch0(...args);
|
||||
if (res.status === 401 && !leaving && res.headers.get("X-RigShare-Login") === "required") {
|
||||
leaving = true;
|
||||
location.href = "/rigshare/login?next=" + encodeURIComponent(location.pathname + location.search);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
})();</script>"""
|
||||
|
||||
# (method, path) prefixes that need a permission when api_protection is on.
|
||||
PROTECTED = [
|
||||
("POST", "/prompt", "queue"),
|
||||
@@ -356,6 +373,13 @@ def setup(server, store, hub):
|
||||
messages, more = hub.chat.page(before, limit)
|
||||
return web.json_response({"messages": messages, "more": more})
|
||||
|
||||
@routes.delete("/rigshare/api/chat")
|
||||
async def clear_chat(request):
|
||||
admin = require_admin(request)
|
||||
await hub.clear_chat(admin.get("display_name") or admin["username"])
|
||||
log.info(f"[RigShare] {admin['username']} cleared the chat history")
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
@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"]])])
|
||||
@@ -394,7 +418,10 @@ def setup(server, store, hub):
|
||||
async def ws_mkdir(request):
|
||||
body = await request.json()
|
||||
err, path = ws_call(lambda: ws().make_dir(body.get("parent", ""), body.get("name"), *who(request)))
|
||||
return err or web.json_response({"path": path})
|
||||
if err:
|
||||
return err
|
||||
await hub.files_changed("workflows/" + posixpath.dirname(path))
|
||||
return web.json_response({"path": path})
|
||||
|
||||
@routes.post("/rigshare/api/workspace/rename-folder")
|
||||
async def ws_rename_dir(request):
|
||||
@@ -426,6 +453,7 @@ def setup(server, store, hub):
|
||||
if err:
|
||||
return err
|
||||
await hub.drop_rooms("file:workflows/" + path, display(request))
|
||||
await hub.files_changed("workflows/" + posixpath.dirname(path))
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
@routes.get("/rigshare/api/workspace/folder")
|
||||
@@ -450,6 +478,7 @@ def setup(server, store, hub):
|
||||
except ValueError as e:
|
||||
return error(str(e))
|
||||
log.info(f"[RigShare] {key} created folder {folder}")
|
||||
await hub.files_changed("workflows/shared")
|
||||
return web.json_response({"path": folder})
|
||||
|
||||
@routes.patch("/rigshare/api/workspace/folders")
|
||||
@@ -478,6 +507,7 @@ def setup(server, store, hub):
|
||||
except ValueError as e:
|
||||
return error(str(e))
|
||||
await hub.refresh_rooms_access()
|
||||
await hub.files_changed("workflows/shared") # the folder's access changed who sees it
|
||||
return web.json_response({"ok": True, "path": folder})
|
||||
|
||||
@routes.delete("/rigshare/api/workspace/folders")
|
||||
@@ -609,6 +639,53 @@ def setup(server, store, hub):
|
||||
action = {"GET": "get", "HEAD": "get", "POST": "save", "DELETE": "delete"}.get(request.method)
|
||||
return (action, src, None) if action else None
|
||||
|
||||
def watch_login(response):
|
||||
try:
|
||||
file_path = getattr(response, "_path", None)
|
||||
if file_path:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
page = f.read()
|
||||
elif isinstance(response, web.Response) and response.body and "html" in (response.content_type or ""):
|
||||
page = response.body.decode("utf-8")
|
||||
else:
|
||||
return response
|
||||
if "<head>" not in page or "rigshare-login-watch" in page:
|
||||
return response
|
||||
page = page.replace("<head>", "<head>" + LOGIN_WATCH, 1)
|
||||
return web.Response(text=page, content_type="text/html",
|
||||
headers={"Cache-Control": "no-store, must-revalidate"})
|
||||
except Exception as e:
|
||||
log.debug(f"[RigShare] could not add the login watch: {e}")
|
||||
return response
|
||||
|
||||
def api_path(request):
|
||||
return request.path[4:] if request.path.startswith("/api/") else request.path
|
||||
|
||||
async def check_queue_ownership(request):
|
||||
"""Cancelling a queued prompt or stopping the running one: your own, unless admin."""
|
||||
if request.method != "POST" or api_path(request) not in ("/queue", "/interrupt"):
|
||||
return None
|
||||
if trusted(request.remote or "") or perms_for(request).get("admin"):
|
||||
return None
|
||||
user = current_user(request)
|
||||
me = user["username"] if user else None
|
||||
try:
|
||||
body = await request.json() if request.body_exists else {}
|
||||
except ValueError:
|
||||
body = {}
|
||||
body = body if isinstance(body, dict) else {}
|
||||
if api_path(request) == "/queue":
|
||||
if body.get("clear"):
|
||||
return "only admins can clear the whole queue"
|
||||
ids = body.get("delete") or []
|
||||
else:
|
||||
ids = [body["prompt_id"]] if body.get("prompt_id") else hub.running_prompt_ids()
|
||||
for prompt_id in ids:
|
||||
owner = hub.owner_of(prompt_id)
|
||||
if owner and owner != me:
|
||||
return "you can only cancel your own prompts"
|
||||
return None
|
||||
|
||||
def snapshot_saved_file(request, path, body):
|
||||
if not path.endswith(".json") or Workspace.workflows_rel(path) is None:
|
||||
return
|
||||
@@ -693,8 +770,10 @@ def setup(server, store, hub):
|
||||
path in ("/", "/index.html") or "text/html" in request.headers.get("Accept", ""))
|
||||
if wants_page:
|
||||
raise web.HTTPFound(f"/rigshare/login?next={path}")
|
||||
# The header lets the ComfyUI page (see LOGIN_WATCH) go back to the login screen.
|
||||
return web.json_response({"error": "RigShare: login required. API clients: send "
|
||||
"'Authorization: Bearer <api key>'."}, status=401)
|
||||
"'Authorization: Bearer <api key>'."}, status=401,
|
||||
headers={"X-RigShare-Login": "required"})
|
||||
userdata = parse_userdata(request)
|
||||
if userdata and not trusted(request.remote or ""):
|
||||
refusal = check_userdata(request, *userdata)
|
||||
@@ -719,12 +798,23 @@ def setup(server, store, hub):
|
||||
"details": "", "extra_info": {}}, "node_errors": {}},
|
||||
status=403)
|
||||
break
|
||||
refusal = await check_queue_ownership(request)
|
||||
if refusal:
|
||||
return web.json_response({"error": f"RigShare: {refusal}"}, status=403)
|
||||
if userdata and userdata[0] == "save":
|
||||
await request.read() # keep the body for the snapshot below (aiohttp caches it)
|
||||
response = await handler(request)
|
||||
# Remember who queued each prompt, for the Queue tab.
|
||||
if request.method == "POST" and api_path(request) == "/prompt" and getattr(response, "status", 0) == 200:
|
||||
try:
|
||||
prompt_id = json.loads(response.body).get("prompt_id")
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
prompt_id = None
|
||||
hub.record_prompt(prompt_id, current_user(request), unquote(request.headers.get("X-RigShare-Workflow", "")))
|
||||
# Every save of a workflow keeps a snapshot, named after when it was saved.
|
||||
if userdata and userdata[0] == "save" and getattr(response, "status", 0) == 200:
|
||||
snapshot_saved_file(request, userdata[1], await request.read())
|
||||
await hub.files_changed(userdata[1])
|
||||
# Workflow listings only show what this person may open.
|
||||
if userdata and userdata[0] == "list" and not trusted(request.remote or "") and hub.workspace:
|
||||
response = filter_listing(request, response, userdata[1])
|
||||
@@ -733,6 +823,7 @@ def setup(server, store, hub):
|
||||
await hub.path_moved(userdata[1], userdata[2])
|
||||
if userdata and userdata[0] == "delete" and getattr(response, "status", 0) in (200, 204):
|
||||
await hub.drop_rooms("file:" + userdata[1], display(request))
|
||||
await hub.files_changed(userdata[1])
|
||||
# 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")):
|
||||
@@ -747,6 +838,11 @@ def setup(server, store, hub):
|
||||
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}")
|
||||
# The ComfyUI page: never cached (an ended session must reach the redirect
|
||||
# above), and watched, so a session that ends while it is open goes back
|
||||
# to the login screen instead of showing API errors.
|
||||
if request.method == "GET" and request.path in ("/", "/index.html") and store.config.get("require_login"):
|
||||
response = watch_login(response)
|
||||
# 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:
|
||||
|
||||
@@ -51,6 +51,23 @@ def _gpus():
|
||||
return out
|
||||
|
||||
|
||||
def queue_items(server):
|
||||
"""Running and pending prompts: [{id, number, status, nodes}], running first."""
|
||||
try:
|
||||
running, pending = server.prompt_queue.get_current_queue_volatile()
|
||||
except Exception:
|
||||
return None
|
||||
items = []
|
||||
for status, entries in (("running", running), ("pending", sorted(pending, key=lambda e: e[0]))):
|
||||
for entry in entries:
|
||||
try:
|
||||
prompt = entry[2] if isinstance(entry[2], dict) else {}
|
||||
items.append({"id": entry[1], "number": entry[0], "status": status, "nodes": len(prompt)})
|
||||
except (IndexError, TypeError):
|
||||
continue
|
||||
return items
|
||||
|
||||
|
||||
def _queue(server):
|
||||
try:
|
||||
running, pending = server.prompt_queue.get_current_queue_volatile()
|
||||
@@ -76,4 +93,5 @@ def collect_stats(server=None):
|
||||
stats["gpus"] = []
|
||||
if server is not None:
|
||||
stats["queue"] = _queue(server)
|
||||
stats["queue_items"] = queue_items(server)
|
||||
return stats
|
||||
|
||||
@@ -33,17 +33,28 @@ def enc(path):
|
||||
return quote(path, safe="")
|
||||
|
||||
|
||||
class FakeQueue:
|
||||
"""ComfyUI's PromptQueue, as far as RigShare reads it."""
|
||||
|
||||
def __init__(self):
|
||||
self.running, self.pending, self.number = [], [], 0
|
||||
|
||||
def get_current_queue_volatile(self):
|
||||
return list(self.running), list(self.pending)
|
||||
|
||||
|
||||
class FakePromptServer:
|
||||
def __init__(self):
|
||||
self.app = web.Application()
|
||||
self.sent = []
|
||||
self.prompt_queue = FakeQueue()
|
||||
|
||||
def send_sync(self, event, data, sid=None):
|
||||
self.sent.append((event, sid))
|
||||
|
||||
|
||||
def comfy_routes(user_root):
|
||||
"""Minimal ComfyUI /userdata routes for the single default user."""
|
||||
def comfy_routes(user_root, queue=None):
|
||||
"""Minimal ComfyUI /userdata routes for the single default user, plus the queue."""
|
||||
|
||||
def resolve(name):
|
||||
if "%" in name:
|
||||
@@ -83,6 +94,24 @@ def comfy_routes(user_root):
|
||||
async def ok(request):
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
async def prompt(request):
|
||||
if request.method != "POST" or queue is None:
|
||||
return web.json_response({"ok": True})
|
||||
body = await request.json() if request.body_exists else {}
|
||||
queue.number += 1
|
||||
prompt_id = f"p{queue.number}"
|
||||
queue.pending.append((queue.number, prompt_id, (body or {}).get("prompt") or {"1": {}}, {}, []))
|
||||
return web.json_response({"prompt_id": prompt_id, "number": queue.number, "node_errors": {}})
|
||||
|
||||
async def queue_route(request):
|
||||
body = await request.json() if request.body_exists else {}
|
||||
if queue is not None:
|
||||
gone = set((body or {}).get("delete") or [])
|
||||
queue.pending = [e for e in queue.pending if e[1] not in gone]
|
||||
if (body or {}).get("clear"):
|
||||
queue.pending = []
|
||||
return web.Response(status=200)
|
||||
|
||||
table = web.RouteTableDef()
|
||||
for prefix in ("", "/api"):
|
||||
table.get(prefix + "/userdata")(listing)
|
||||
@@ -90,7 +119,9 @@ def comfy_routes(user_root):
|
||||
table.post(prefix + "/userdata/{file}")(post)
|
||||
table.delete(prefix + "/userdata/{file}")(delete)
|
||||
table.post(prefix + "/userdata/{file}/move/{dest}")(move)
|
||||
table.route("*", prefix + "/prompt")(ok)
|
||||
table.route("*", prefix + "/prompt")(prompt)
|
||||
table.post(prefix + "/queue")(queue_route)
|
||||
table.post(prefix + "/interrupt")(ok)
|
||||
table.route("*", prefix + "/manager/{tail:.*}")(ok)
|
||||
table.route("*", prefix + "/customnode/{tail:.*}")(ok)
|
||||
return table
|
||||
@@ -184,7 +215,7 @@ async def rig_server(config=None, workspace=True, setup=None):
|
||||
if workspace:
|
||||
hub.workspace = Workspace(store, os.path.join(user_root, "workflows"))
|
||||
routes.setup(ps, store, hub)
|
||||
ps.app.add_routes(comfy_routes(user_root))
|
||||
ps.app.add_routes(comfy_routes(user_root, ps.prompt_queue))
|
||||
if setup:
|
||||
setup(ps.app)
|
||||
server = TestServer(ps.app, host="127.0.0.1")
|
||||
|
||||
@@ -440,3 +440,109 @@ def test_every_save_keeps_a_snapshot(run):
|
||||
await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=b'{"nodes": []}', headers=viewer)
|
||||
assert len(rig.store.list_snapshots("file:" + path)) == 2
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_admin_clears_chat_history(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
sock = await rig.ws("alice")
|
||||
for i in range(5):
|
||||
await sock.send({"type": "chat", "text": f"hi {i}"})
|
||||
await asyncio.sleep(0.2)
|
||||
await sock.drain()
|
||||
assert (await rig.http.delete(rig.url("/rigshare/api/chat"), headers=alice)).status == 403, "admins only"
|
||||
assert (await rig.http.delete(rig.url("/rigshare/api/chat"), headers=admin)).status == 200
|
||||
msgs = await sock.drain()
|
||||
kinds = [m["type"] for m in msgs]
|
||||
assert "chat_cleared" in kinds and kinds.index("chat_cleared") < kinds.index("chat")
|
||||
notice = next(m for m in msgs if m["type"] == "chat")["message"]
|
||||
assert notice["system"] and "cleared the chat history" in notice["text"] and notice["seq"] == 0
|
||||
history = await (await rig.http.get(rig.url("/rigshare/api/chat"), headers=alice)).json()
|
||||
assert [m["text"] for m in history["messages"]] == [notice["text"]] and not history["more"]
|
||||
await sock.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_queue_shows_who_launched_and_guards_cancelling(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", queue=True)
|
||||
bob = await rig.add_user("bob", queue=True)
|
||||
h = rig.http
|
||||
|
||||
async def queue(user, name):
|
||||
r = await h.post(rig.url("/api/prompt"), json={"prompt": {"1": {}, "2": {}}},
|
||||
headers={**user, "X-RigShare-Workflow": quote(name)})
|
||||
return (await r.json())["prompt_id"]
|
||||
a = await queue(alice, "Face swap é")
|
||||
b = await queue(bob, "Upscale")
|
||||
rig.ps.prompt_queue.running.append(rig.ps.prompt_queue.pending.pop(0)) # alice's starts
|
||||
|
||||
from rigshare_core.stats import queue_items
|
||||
items = rig.hub.with_owners(queue_items(rig.ps))
|
||||
assert [(i["status"], i["user"], i["workflow"], i["nodes"]) for i in items] == [
|
||||
("running", "alice", "Face swap é", 2), ("pending", "bob", "Upscale", 2)]
|
||||
|
||||
# Your own runs only, unless admin; clearing everyone's is admin-only.
|
||||
assert (await h.post(rig.url("/api/queue"), json={"delete": [b]}, headers=alice)).status == 403
|
||||
assert (await h.post(rig.url("/api/interrupt"), json={}, headers=bob)).status == 403, "alice's is running"
|
||||
assert (await h.post(rig.url("/api/interrupt"), json={"prompt_id": a}, headers=alice)).status == 200
|
||||
assert (await h.post(rig.url("/api/queue"), json={"clear": True}, headers=bob)).status == 403
|
||||
assert (await h.post(rig.url("/api/queue"), json={"delete": [b]}, headers=bob)).status == 200
|
||||
assert not rig.ps.prompt_queue.pending
|
||||
assert (await h.post(rig.url("/api/queue"), json={"clear": True}, headers=admin)).status == 200
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_ended_session_goes_back_to_login(run, tmp_path):
|
||||
index = tmp_path / "index.html"
|
||||
index.write_text("<!doctype html><html><head><title>ComfyUI</title></head><body></body></html>")
|
||||
|
||||
async def scenario():
|
||||
async def page(request):
|
||||
from aiohttp import web
|
||||
return web.FileResponse(str(index))
|
||||
|
||||
async with rig_server(setup=lambda app: app.router.add_get("/", page)) as rig:
|
||||
admin = await rig.setup_admin()
|
||||
h = rig.http
|
||||
r = await h.get(rig.url("/"), headers=admin)
|
||||
text = await r.text()
|
||||
assert r.status == 200 and "rigshare-login-watch" in text and text.index("rigshare-login-watch") < text.index("<title>")
|
||||
assert "no-store" in r.headers.get("Cache-Control", ""), "never served from the browser cache"
|
||||
|
||||
# Session gone: API calls say so in a header the page watches for...
|
||||
await h.post(rig.url("/rigshare/api/logout"), headers=admin)
|
||||
r = await h.get(rig.url("/api/prompt"), headers={"Authorization": "Bearer nope"})
|
||||
assert r.status == 401 and r.headers.get("X-RigShare-Login") == "required"
|
||||
# ...and the page itself redirects to the login screen.
|
||||
r = await h.get(rig.url("/"), headers={"Accept": "text/html"}, allow_redirects=False)
|
||||
assert r.status == 302 and r.headers["Location"].startswith("/rigshare/login")
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_file_changes_reach_who_can_see_them(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
await rig.add_user("bob", edit=True)
|
||||
a, b = await rig.ws("alice"), await rig.ws("bob")
|
||||
h = rig.http
|
||||
|
||||
async def changed(sock):
|
||||
return any(m["type"] == "files_changed" for m in await sock.drain())
|
||||
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/mine.json')}"), data=b'{"nodes": []}', headers=alice)
|
||||
assert await changed(a) and not await changed(b), "a private save only tells its owner"
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/team.json')}"), data=b'{"nodes": []}', headers=alice)
|
||||
assert await changed(a) and await changed(b)
|
||||
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Crew"}, headers=alice)
|
||||
assert await changed(b), "new shared folder"
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/team.json')}/move/{enc('workflows/shared/Crew/team.json')}"), headers=rig.headers["boss"])
|
||||
assert await changed(b), "moves"
|
||||
await a.close(); await b.close()
|
||||
run(scenario())
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// RigShare in ComfyUI's App mode.
|
||||
//
|
||||
// The App view's sidebar shows a fixed set of tabs (assets and apps), so the
|
||||
// RigShare and Files tabs disappear there. While the tab on screen is in the
|
||||
// App view, RigShare adds its own buttons to that sidebar, copied from
|
||||
// ComfyUI's so they look the same, and opens the panels in a drawer next to it.
|
||||
// If the sidebar can't be found, a small floating dock offers them instead.
|
||||
// The App view's sidebar only draws buttons for a fixed set of tabs (assets
|
||||
// and apps), but its docked side panel shows whichever sidebar tab is active.
|
||||
// So RigShare adds its own buttons to that sidebar, copied from ComfyUI's so
|
||||
// they look the same, and they open the regular RigShare and Files tabs in the
|
||||
// docked panel, exactly like ComfyUI's own tabs. If the sidebar can't be
|
||||
// found, a small floating dock offers the same buttons.
|
||||
|
||||
import { h } from "./ui.js";
|
||||
|
||||
@@ -12,19 +13,15 @@ const RAIL = 'nav[data-testid="side-toolbar"]';
|
||||
|
||||
export class AppDock {
|
||||
/**
|
||||
* views: { id: { icon, title, mount(el), unmount() } }
|
||||
* views: { id: { icon, title, tabId } }, tabId being a registered sidebar tab.
|
||||
*/
|
||||
constructor({ sync, views }) {
|
||||
constructor({ app, sync, views }) {
|
||||
this.app = app;
|
||||
this.sync = sync;
|
||||
this.views = views;
|
||||
this.open = null;
|
||||
this.badges = {};
|
||||
this.railButtons = {};
|
||||
this.dockButtons = {};
|
||||
this.body = h("div", { class: "rs-host rs-dock-drawer-body" });
|
||||
this.drawer = h("div", { class: "rs-dock-drawer rs-panel-vars", role: "dialog" },
|
||||
h("button", { class: "rs-dock-close", title: "Close", onclick: () => this.show(null) }, h("i", { class: "pi pi-times" })),
|
||||
this.body);
|
||||
this.dock = h("div", { class: "rs-dock rs-panel-vars" }, ...Object.entries(views).map(([id, v]) => {
|
||||
const badge = h("span", { class: "rs-dock-badge" });
|
||||
const btn = h("button", {
|
||||
@@ -35,24 +32,53 @@ export class AppDock {
|
||||
return btn;
|
||||
}));
|
||||
this.dock.style.display = "none";
|
||||
this.drawer.style.display = "none";
|
||||
document.body.append(this.dock, this.drawer);
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.open) this.show(null); });
|
||||
setInterval(() => this.update(), 400);
|
||||
document.body.append(this.dock);
|
||||
this.wasApp = false;
|
||||
this.graphTab = null; // our tab open in the graph view, if any
|
||||
this.restore = null; // { tabId, until }: reopen it after entering the App view
|
||||
setInterval(() => this.update(), 300);
|
||||
}
|
||||
|
||||
get activeTabId() {
|
||||
return this.app.extensionManager?.sidebarTab?.activeSidebarTabId ?? null;
|
||||
}
|
||||
|
||||
/** Open (or close) the tab in ComfyUI's docked side panel, like its own buttons do. */
|
||||
toggle(id) {
|
||||
this.show(this.open === id ? null : id);
|
||||
this.app.extensionManager?.command?.execute(`Workspace.ToggleSidebarTab.${this.views[id].tabId}`);
|
||||
setTimeout(() => this.refreshButtons(), 30);
|
||||
}
|
||||
|
||||
update() {
|
||||
const active = this.sync.inAppMode;
|
||||
this.keepOpenIntoApp(active);
|
||||
const rail = active ? document.querySelector(RAIL) : null;
|
||||
if (rail) this.addToRail(rail);
|
||||
else this.removeFromRail();
|
||||
this.dock.style.display = active && !rail ? "" : "none";
|
||||
if (!active && this.open) this.show(null);
|
||||
if (this.open) this.place(rail);
|
||||
this.refreshButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* ComfyUI closes the side panel when a tab enters the App view (not when it
|
||||
* goes back to the graph). If a RigShare tab was open, open it again.
|
||||
*/
|
||||
keepOpenIntoApp(inApp) {
|
||||
const open = this.activeTabId;
|
||||
const ours = Object.values(this.views).map((v) => v.tabId);
|
||||
if (!inApp) {
|
||||
this.graphTab = ours.includes(open) ? open : null;
|
||||
this.restore = null;
|
||||
} else if (!this.wasApp && this.graphTab) {
|
||||
this.restore = { tabId: this.graphTab, until: Date.now() + 1500 };
|
||||
}
|
||||
this.wasApp = inApp;
|
||||
if (!this.restore) return;
|
||||
if (open === this.restore.tabId || Date.now() > this.restore.until) {
|
||||
this.restore = null;
|
||||
} else if (open === null) {
|
||||
this.app.extensionManager?.command?.execute(`Workspace.ToggleSidebarTab.${this.restore.tabId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy one of ComfyUI's sidebar buttons for each view, placed after its own tabs. */
|
||||
@@ -99,41 +125,14 @@ export class AppDock {
|
||||
this.railButtons = {};
|
||||
}
|
||||
|
||||
/** Beside the sidebar when there is one, above the floating dock otherwise. */
|
||||
place(rail) {
|
||||
const d = this.drawer.style;
|
||||
if (rail) {
|
||||
const r = rail.getBoundingClientRect();
|
||||
d.left = `${Math.round(r.right + 8)}px`;
|
||||
d.top = `${Math.round(r.top + 8)}px`;
|
||||
d.bottom = "8px";
|
||||
} else {
|
||||
d.left = "64px";
|
||||
d.top = "8px";
|
||||
d.bottom = "64px";
|
||||
}
|
||||
}
|
||||
|
||||
show(id) {
|
||||
if (this.open) this.views[this.open].unmount?.();
|
||||
this.open = id;
|
||||
this.refreshButtons();
|
||||
this.drawer.style.display = id ? "" : "none";
|
||||
if (id) {
|
||||
this.place(document.querySelector(RAIL));
|
||||
this.views[id].mount(this.body);
|
||||
} else {
|
||||
this.body.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
refreshButtons() {
|
||||
const open = this.activeTabId;
|
||||
for (const [id, b] of Object.entries(this.railButtons)) {
|
||||
b.btn.classList.toggle("side-bar-button-selected", id === this.open);
|
||||
b.btn.classList.toggle("side-bar-button-selected", this.views[id].tabId === open);
|
||||
this.paintBadge(b.badge, this.badges[id]);
|
||||
}
|
||||
for (const [id, b] of Object.entries(this.dockButtons)) {
|
||||
b.btn.classList.toggle("active", id === this.open);
|
||||
b.btn.classList.toggle("active", this.views[id].tabId === open);
|
||||
this.paintBadge(b.badge, this.badges[id]);
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 118 KiB |
@@ -146,6 +146,11 @@ export class RigClient extends EventTarget {
|
||||
this.stats = msg.stats;
|
||||
this.emit("stats", msg.stats);
|
||||
break;
|
||||
case "chat_cleared":
|
||||
this.chat = [];
|
||||
this.chatMore = false;
|
||||
this.emit("chat-history", this.chat);
|
||||
break;
|
||||
case "chat":
|
||||
this.chat.push(msg.message);
|
||||
this.emit("chat", msg.message);
|
||||
|
||||
@@ -35,7 +35,14 @@ export class FilesBrowser {
|
||||
h("div", { class: "rs-fb-tools" }, this.search),
|
||||
this.list);
|
||||
// The sidebar browser lives as long as the page: keep its live dots current.
|
||||
if (mode === "browse") client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
|
||||
if (mode === "browse") {
|
||||
client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
|
||||
// Someone (maybe you, from the Save dialog) saved, moved or deleted a workflow: reload.
|
||||
client.on("files_changed", () => {
|
||||
clearTimeout(this.changedTimer);
|
||||
this.changedTimer = setTimeout(() => { if (this.el.isConnected && !this.accessFor) this.reload(); }, 250);
|
||||
});
|
||||
}
|
||||
this.setStart(start);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,32 @@ import { h } from "./ui.js";
|
||||
import { AppDock } from "./app-dock.js";
|
||||
|
||||
const TAB_ID = "rigshare";
|
||||
const FILES_TAB_ID = "rigshare-files";
|
||||
|
||||
/** Remembers the side panel elements each custom tab was rendered into and keeps
|
||||
* the tab's content in whichever of them is on screen. */
|
||||
class SlotTracker {
|
||||
constructor(app, tabs) {
|
||||
this.app = app;
|
||||
this.tabs = tabs;
|
||||
this.slots = Object.fromEntries(Object.keys(tabs).map((id) => [id, new Set()]));
|
||||
setInterval(() => this.check(), 300);
|
||||
}
|
||||
|
||||
add(id, el) {
|
||||
this.slots[id]?.add(el);
|
||||
}
|
||||
|
||||
check() {
|
||||
const active = this.app.extensionManager?.sidebarTab?.activeSidebarTabId;
|
||||
for (const [id, set] of Object.entries(this.slots)) {
|
||||
for (const el of set) if (!el.isConnected) set.delete(el);
|
||||
if (id !== active) continue;
|
||||
const visible = [...set].find((el) => el.getClientRects().length > 0);
|
||||
if (visible && !this.tabs[id].has(visible)) this.tabs[id].fill(visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
const ICON_CLASS = "rigshare-tab-icon";
|
||||
|
||||
function setting(id, fallback) {
|
||||
@@ -25,12 +51,18 @@ function setting(id, fallback) {
|
||||
}
|
||||
}
|
||||
|
||||
function installAuthHeaders(client) {
|
||||
function installAuthHeaders(client, sync) {
|
||||
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() } });
|
||||
headers = { ...headers, ...client.authHeaders() };
|
||||
// Queueing: name the workflow, so the Queue tab can show what each run is.
|
||||
if (route === "/prompt" && (options.method || "GET").toUpperCase() === "POST") {
|
||||
const wf = sync.store?.activeWorkflow;
|
||||
if (wf) headers["X-RigShare-Workflow"] = encodeURIComponent(sync.tabName(wf));
|
||||
}
|
||||
return original(route, { ...options, headers });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -271,7 +303,7 @@ app.registerExtension({
|
||||
panel.chatToasts = setting("RigShare.ChatToasts", false);
|
||||
|
||||
client.on("toast", (t) => app.extensionManager?.toast?.add({ life: 4000, ...t }));
|
||||
installAuthHeaders(client);
|
||||
installAuthHeaders(client, sync);
|
||||
installQueueGuard(client);
|
||||
installManagerGuard(client);
|
||||
installComfyAccountGuard(client);
|
||||
@@ -279,6 +311,18 @@ app.registerExtension({
|
||||
installSaveDialog(app, api, client, sync);
|
||||
presence.install();
|
||||
|
||||
// ComfyUI keeps a separate side panel for the graph and the App view and
|
||||
// shows the old one again without asking the tab to render: follow the
|
||||
// visible one, or the panel comes back blank after switching views.
|
||||
let filesPanelRef = () => null; // set once the Files panel is built below
|
||||
const slots = new SlotTracker(app, {
|
||||
[TAB_ID]: { has: (el) => el.contains(panel.root), fill: (el) => panel.mount(el) },
|
||||
[FILES_TAB_ID]: {
|
||||
has: (el) => !filesPanelRef() || el.contains(filesPanelRef()),
|
||||
fill: (el) => { if (el.contains(panel.root)) panel.unmount(); el.replaceChildren(filesPanelRef()); },
|
||||
},
|
||||
});
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: TAB_ID,
|
||||
icon: `pi pi-users ${ICON_CLASS}`,
|
||||
@@ -287,7 +331,7 @@ app.registerExtension({
|
||||
label: "RigShare",
|
||||
type: "custom",
|
||||
iconBadge: null,
|
||||
render: (el) => panel.mount(el),
|
||||
render: (el) => { slots.add(TAB_ID, el); panel.mount(el); },
|
||||
destroy: () => panel.unmount(),
|
||||
});
|
||||
|
||||
@@ -312,15 +356,17 @@ app.registerExtension({
|
||||
onclick: () => app.extensionManager.command.execute("Comfy.OpenWorkflow"),
|
||||
}, h("i", { class: "pi pi-upload" }), "From computer")),
|
||||
h("div", { class: "rs-body" }, filesBrowser.el));
|
||||
filesPanelRef = () => filesPanel;
|
||||
let filesLoaded = false;
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "rigshare-files",
|
||||
id: FILES_TAB_ID,
|
||||
icon: "pi pi-folder-open",
|
||||
title: "Files",
|
||||
tooltip: "RigShare files: My files and Shared",
|
||||
label: "Files",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
slots.add(FILES_TAB_ID, el);
|
||||
el.classList.add("rs-host");
|
||||
// Same slot element as the RigShare tab when switching between them
|
||||
// (ComfyUI only calls destroy on unmount): take it over cleanly.
|
||||
@@ -331,15 +377,12 @@ app.registerExtension({
|
||||
});
|
||||
client.on("self", () => { if (filesPanel.isConnected) filesBrowser.reload(); });
|
||||
|
||||
// ComfyUI's App view hides extension sidebar tabs: offer them in a dock there.
|
||||
// ComfyUI's App view draws buttons only for its own tabs: add ours to its sidebar.
|
||||
const dock = new AppDock({
|
||||
sync,
|
||||
app, sync,
|
||||
views: {
|
||||
[TAB_ID]: { icon: "pi-users", title: "RigShare", mount: (el) => panel.mount(el), unmount: () => panel.unmount() },
|
||||
files: {
|
||||
icon: "pi-folder-open", title: "Files",
|
||||
mount: (el) => { el.replaceChildren(filesPanel); filesBrowser.reload(); },
|
||||
},
|
||||
[TAB_ID]: { icon: "pi-users", title: "RigShare", tabId: TAB_ID },
|
||||
files: { icon: "pi-folder-open", title: "Files", tabId: FILES_TAB_ID },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
|
||||
|
||||
const VIEWS = [
|
||||
{ id: "workflows", icon: "pi-share-alt", label: "Live" },
|
||||
{ id: "people", icon: "pi-users", label: "People" },
|
||||
{ id: "chat", icon: "pi-comments", label: "Chat" },
|
||||
{ id: "people", icon: "pi-users", label: "People" },
|
||||
{ id: "queue", icon: "pi-list", label: "Queue" },
|
||||
{ id: "server", icon: "pi-server", label: "Server" },
|
||||
{ id: "admin", icon: "pi-shield", label: "Admin", admin: true },
|
||||
];
|
||||
@@ -60,7 +61,10 @@ export class RigSharePanel {
|
||||
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("stats", () => {
|
||||
if (this.view === "server" || this.view === "queue") this.renderBody();
|
||||
this.renderNav(); // queue count
|
||||
});
|
||||
client.on("chat-history", () => this.renderChatLog());
|
||||
client.on("chat", (m) => this.onChat(m));
|
||||
sync.addEventListener("state", () => { if (this.view === "workflows") this.renderBody(); });
|
||||
@@ -171,6 +175,7 @@ export class RigSharePanel {
|
||||
people: c.users.length || null,
|
||||
chat: this.unread || null,
|
||||
workflows: c.rooms?.length || null,
|
||||
queue: c.stats?.queue_items?.length || null,
|
||||
};
|
||||
this.nav.replaceChildren(...VIEWS.filter((v) => !v.admin || c.perms.admin).map((v) =>
|
||||
h("button", {
|
||||
@@ -199,6 +204,7 @@ export class RigSharePanel {
|
||||
people: () => this.peopleView(),
|
||||
chat: () => [this.chatView],
|
||||
server: () => this.serverView(),
|
||||
queue: () => this.queueView(),
|
||||
admin: () => this.adminView(),
|
||||
account: () => this.accountView(),
|
||||
};
|
||||
@@ -566,6 +572,44 @@ export class RigSharePanel {
|
||||
|
||||
// ----- server ---------------------------------------------------------
|
||||
|
||||
// ----- queue ----------------------------------------------------------
|
||||
|
||||
queueView() {
|
||||
const c = this.client;
|
||||
const items = c.stats?.queue_items;
|
||||
if (items == null) return [this.section("pi-list", "Queue", h("p", { class: "rs-muted" }, "Waiting for the server…"))];
|
||||
const running = items.filter((it) => it.status === "running");
|
||||
const pending = items.filter((it) => it.status === "pending");
|
||||
return [
|
||||
this.section("pi-play", "Running", ...(running.length ? running.map((it) => this.queueRow(it)) : [h("p", { class: "rs-muted" }, "Nothing running.")])),
|
||||
this.section("pi-list", `Pending${pending.length ? ` (${pending.length})` : ""}`,
|
||||
...(pending.length ? pending.map((it, i) => this.queueRow(it, i + 1)) : [h("p", { class: "rs-muted" }, "Nothing waiting.")])),
|
||||
];
|
||||
}
|
||||
|
||||
queueRow(it, position) {
|
||||
const c = this.client;
|
||||
const online = it.user ? c.users.find((u) => u.key === it.user) : null;
|
||||
const who = it.name || (it.user ? `@${it.user}` : "Unknown");
|
||||
const mine = it.user && it.user === c.self?.key;
|
||||
const canCancel = c.perms.queue && (mine || c.perms.admin);
|
||||
const running = it.status === "running";
|
||||
const meta = [position ? `#${position}` : null, `${it.nodes} nodes`, it.at ? `queued ${timeAgo(it.at * 1000)}` : null].filter(Boolean).join(" · ");
|
||||
return h("div", { class: `rs-row-item rs-queue-row ${running ? "running" : ""}` },
|
||||
h("span", { class: "rs-avatar", style: `background:${online?.color ?? "var(--rs-border)"}`, title: who }, initials(who)),
|
||||
h("div", { class: "rs-grow rs-min0" },
|
||||
h("div", { class: "rs-name rs-ellipsis" }, it.workflow || "Workflow",
|
||||
running ? h("i", { class: "pi pi-spin pi-spinner rs-inline-icon rs-muted", title: "Running" }) : null),
|
||||
h("div", { class: "rs-muted rs-small rs-ellipsis" }, `${who}${mine ? " (you)" : ""}`),
|
||||
h("div", { class: "rs-muted rs-small" }, meta)),
|
||||
canCancel ? h("button", {
|
||||
class: "rs-btn rs-btn-icon rs-danger", title: running ? "Stop this run" : "Remove from the queue",
|
||||
onclick: () => this.run(() => running
|
||||
? c.request("POST", "/api/interrupt", { prompt_id: it.id })
|
||||
: c.request("POST", "/api/queue", { delete: [it.id] }), running ? "Stopping…" : "Removed from the queue"),
|
||||
}, running ? h("span", { class: "rs-stop-icon" }) : h("i", { class: "pi pi-times" })) : null);
|
||||
}
|
||||
|
||||
serverView() {
|
||||
const st = this.client.stats;
|
||||
const floating = this.widget.enabled;
|
||||
@@ -687,6 +731,14 @@ export class RigSharePanel {
|
||||
return [
|
||||
this.section("pi-users", "Accounts", this.adminAccounts, this.createAccountForm()),
|
||||
this.section("pi-cog", "Server settings", this.adminSettings),
|
||||
this.section("pi-comments", "Chat",
|
||||
h("p", { class: "rs-muted rs-small" }, "The whole chat history is kept. Clearing it deletes every message for everyone; it cannot be undone."),
|
||||
h("div", { class: "rs-actions" }, h("button", {
|
||||
class: "rs-btn rs-danger", onclick: async () => {
|
||||
if (!(await this.confirm("Clear chat history", "Delete every chat message for everyone? This cannot be undone."))) return;
|
||||
await this.run(() => this.client.request("DELETE", "/rigshare/api/chat"), "Chat history cleared");
|
||||
},
|
||||
}, h("i", { class: "pi pi-trash" }), "Clear chat history"))),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -253,14 +253,8 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-dock-btn.active { color: var(--rs-accent-fg); background: var(--rs-accent); }
|
||||
.rs-dock-badge { display: none; position: absolute; top: 1px; right: 1px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
|
||||
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; }
|
||||
.rs-dock-drawer { position: fixed; top: 8px; bottom: 64px; left: 64px; width: min(380px, calc(100vw - 80px)); z-index: 1100;
|
||||
background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 12px; box-shadow: 0 12px 40px rgb(0 0 0 / .45); overflow: hidden; }
|
||||
.rs-dock-drawer-body { height: 100%; }
|
||||
.rs-dock-drawer .rs-header { padding-right: 40px; }
|
||||
.rs-rail-badge { position: absolute; top: -4px; right: -6px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px;
|
||||
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; text-align: center; }
|
||||
.rs-dock-close { position: absolute; top: 8px; right: 8px; z-index: 2; border: 0; background: none; color: var(--rs-muted); cursor: pointer; padding: 4px; }
|
||||
.rs-dock-close:hover { color: var(--rs-fg); }
|
||||
.rs-view-avatar { position: relative; overflow: visible; }
|
||||
.rs-comfy-icon { display: inline-block; width: 1em; height: 1em; flex: none; }
|
||||
.rs-view-mark { position: absolute; right: -6px; bottom: -5px; font-size: 8px; width: 13px; height: 13px; display: grid; place-items: center;
|
||||
@@ -269,6 +263,7 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-view-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 6px; font-size: 10.5px; padding: 0 6px; }
|
||||
.rs-view-chip .rs-comfy-icon { font-size: 11px; }
|
||||
.rs-view-chip.rs-view-app { color: var(--rs-accent); border-color: var(--rs-accent); }
|
||||
.rs-stop-icon { display: block; width: 10px; height: 10px; border-radius: 2px; background: currentColor; }
|
||||
.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)); }
|
||||
|
||||