Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5373c85b86 | ||
|
|
1f38a22535 | ||
|
|
531fec2ec9 | ||
|
|
91f3383e86 | ||
|
|
2b1b3b11ce | ||
|
|
7e10db82df |
@@ -14,7 +14,30 @@ 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): the matching CHANGELOG.md section.
|
||||
run: |
|
||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1)
|
||||
notes=$(awk -v v="$version" '
|
||||
$0 ~ "^## " v "( |$)" { on = 1; next }
|
||||
on && /^## / { exit }
|
||||
on { print }
|
||||
' CHANGELOG.md | sed -e '/./,$!d')
|
||||
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"
|
||||
printf '%s\n' "$notes"
|
||||
echo "$delimiter"
|
||||
} >> "$GITHUB_ENV"
|
||||
echo "Changelog for $version:"
|
||||
printf '%s\n' "$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"
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.1"
|
||||
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.2"
|
||||
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)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from aiohttp import web, WSMsgType
|
||||
|
||||
@@ -106,6 +107,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 +144,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
|
||||
@@ -381,6 +412,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)
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
from collections import defaultdict
|
||||
|
||||
from aiohttp import web
|
||||
@@ -356,6 +357,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"]])])
|
||||
@@ -609,6 +617,34 @@ 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 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
|
||||
@@ -719,9 +755,19 @@ 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())
|
||||
|
||||
@@ -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,58 @@ 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())
|
||||
|
||||
@@ -33,6 +33,9 @@ export class AppDock {
|
||||
}));
|
||||
this.dock.style.display = "none";
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -48,6 +51,7 @@ export class AppDock {
|
||||
|
||||
update() {
|
||||
const active = this.sync.inAppMode;
|
||||
this.keepOpenIntoApp(active);
|
||||
const rail = active ? document.querySelector(RAIL) : null;
|
||||
if (rail) this.addToRail(rail);
|
||||
else this.removeFromRail();
|
||||
@@ -55,6 +59,28 @@ export class AppDock {
|
||||
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. */
|
||||
addToRail(rail) {
|
||||
const ours = Object.values(this.railButtons);
|
||||
|
||||
|
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);
|
||||
|
||||
@@ -51,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 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -297,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);
|
||||
|
||||
@@ -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"))),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -263,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)); }
|
||||
|
||||