Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e123dc9279 | ||
|
|
a75443d8bb | ||
|
|
6e2324a7c3 | ||
|
|
137051a135 | ||
|
|
78415ebf25 | ||
|
|
c94b90133a | ||
|
|
5373c85b86 | ||
|
|
1f38a22535 | ||
|
|
531fec2ec9 | ||
|
|
91f3383e86 | ||
|
|
2b1b3b11ce | ||
|
|
7e10db82df | ||
|
|
5e943f3e60 | ||
|
|
f4131ac6f5 | ||
|
|
d3cef4a6ca | ||
|
|
9f70cf9205 | ||
|
|
e533e64970 | ||
|
|
d6ef325ee1 | ||
|
|
c1b2bf0d5e | ||
|
|
aadedc9f03 | ||
|
|
45aebb0776 | ||
|
|
435acb0914 | ||
|
|
51c16fa93e | ||
|
|
6833141e58 |
@@ -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,66 @@
|
||||
# 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.
|
||||
|
||||
### Added
|
||||
|
||||
- **App mode**: live workflows work in ComfyUI's App view. Each person keeps their own graph or App view through live edits, App inputs sync, and the app's layout (chosen inputs and outputs) syncs with the workflow. Cursors are off in the App view
|
||||
- In the App view, where ComfyUI shows only its own sidebar tabs, **RigShare** and **Files** buttons join that sidebar, with the unread chat badge, and open the panels in a drawer beside it
|
||||
- The Live tab and People show whether each person is in the node graph or the App view, with ComfyUI's own Graph and App icons
|
||||
- Workflows with an App set up, and workflows saved as Apps (`.app.json`), show ComfyUI's App icon in Files and the Live list. Save keeps an App an App
|
||||
- **A snapshot on every save**, named *Saved at* and the time (in each viewer's time zone). They rotate like automatic snapshots (`snapshot_keep`)
|
||||
- Inside a shared folder you own, its 🔒 access button sits next to the new-folder button
|
||||
|
||||
### Changed
|
||||
|
||||
- ComfyUI's own Workflows and Apps sidebar tabs are hidden, since Files replaces them, and their shortcuts open Files. Admin setting `hide_comfy_file_tabs`, on by default. The server enforces file permissions either way
|
||||
- Folder access is managed only in the Files tab. *This tab* in the Live tab still says whether the folder is restricted
|
||||
- Smaller icons in folder lists
|
||||
|
||||
### Fixed
|
||||
|
||||
- In the Save and Move dialogs, **New folder** asked for the name in a prompt hidden behind the dialog
|
||||
- Snapshots taken in the same second could be rotated out of order
|
||||
|
||||
## 1.1.0 (2026-09-26)
|
||||
|
||||
Workflows now live in folders, like a shared drive, and sharing follows them.
|
||||
|
||||
@@ -25,12 +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 per-workflow history: snapshots every few minutes, named snapshots, one-click restore
|
||||
- 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:
|
||||
@@ -54,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>
|
||||
|
||||
@@ -115,16 +122,3 @@ I run one ComfyUI box for several people. [ComfyUI-Nexus](https://github.com/dax
|
||||
- it hid menus with CSS and monkeypatched litegraph internals that no longer exist
|
||||
- anyone could become admin with a shared password typed into chat
|
||||
- everybody worked on one canvas, and opening ComfyUI wiped your own workflow
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] Login page, JWT sessions, first-run admin setup
|
||||
- [x] Per-user API keys
|
||||
- [x] Live co-editing of every shared workflow, per-workflow snapshots
|
||||
- [x] Cursors, selections, follow mode
|
||||
- [x] Chat with badge, blink and sound, whole history with scroll back
|
||||
- [x] Server monitor with floating widget
|
||||
- [x] Conflict-free node ids when two people add nodes at the same instant
|
||||
- [x] Private folder per account, shared folders, Save asks where
|
||||
- [x] Folder access lists (share with specific people)
|
||||
- [x] Publish to the Comfy registry
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -29,15 +29,19 @@ RigShare organises ComfyUI's workflows folder like a shared drive. Where a workf
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
ComfyUI's own **Workflows** and **Apps** tabs are hidden, since Files replaces them, and their keyboard shortcuts open Files. Admins can bring them back under *Admin → Server settings*. Hiding them isn't what protects the files, though: the server applies the same rules whichever browser is used. If you show ComfyUI's open workflow tabs in the sidebar (*Comfy.Workflow.WorkflowTabsPosition*), the Workflows tab stays, because it lists them.
|
||||
|
||||
Workflows with an App set up (ComfyUI's App mode), or saved as Apps (`.app.json`), show ComfyUI's App icon. Opening an App opens it in the App view.
|
||||
|
||||
Click a workflow to open it. The Files tab marks live workflows with a green dot. **From computer** opens a workflow file from your machine, like ComfyUI's own *Open*.
|
||||
|
||||
Hover a row for its actions: **Rename**, **Move to…**, **Delete**, and on a shared folder you own, 🔒 **Who can open it**. The folder button creates a folder. In the Shared tab it creates a shared folder that you own.
|
||||
Hover a row for its actions: **Rename**, **Move to…**, **Delete**, and on a shared folder you own, 🔒 **Who can open it**. Inside a shared folder you own, the same 🔒 sits next to the new-folder button. The folder button creates a folder. In the Shared tab it creates a shared folder that you own.
|
||||
|
||||
## Saving
|
||||
|
||||
<p align="center"><img src="images/save.png" width="720" alt="Save dialog"></p>
|
||||
|
||||
**Save** (Ctrl+S) on a new workflow, and **Save As**, open a dialog with a name and the same three tabs. It starts in My files, or in the folder you last saved to. Pick any folder you can edit and click **Save here**. Clicking an existing workflow reuses its name, and ComfyUI asks before overwriting.
|
||||
**Save** (Ctrl+S) on a new workflow, and **Save As**, open a dialog with a name and the same three tabs. It starts in My files, or in the folder you last saved to. Pick any folder you can edit and click **Save here**. Clicking an existing workflow reuses its name, and ComfyUI asks before overwriting. Every save also keeps a snapshot in the workflow's history.
|
||||
|
||||
## Opening
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 122 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: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 73 KiB |
@@ -27,13 +27,14 @@ Admins change these under **Admin → Server settings**. They are stored in `rig
|
||||
| `guest_permissions` | `{"edit": false, "queue": false}` | What guests may do |
|
||||
| `broadcast_execution` | `true` | Send progress and previews to everyone, not only whoever queued |
|
||||
| `hide_comfy_account` | `false` | Hide ComfyUI's Comfy.org sign-in button and dialog |
|
||||
| `hide_comfy_file_tabs` | `true` | Hide ComfyUI's own Workflows and Apps sidebar tabs; RigShare's Files tab replaces them and their shortcuts open it |
|
||||
| `hide_api_templates` | `false` | Remove templates that need paid API nodes from the template browser |
|
||||
| `api_protection.enabled` | `true` | Server-side checks on queue, interrupt, free and upload calls |
|
||||
| `api_protection.trusted_ips` | `["127.0.0.1", "::1"]` | Addresses or networks that skip login and checks |
|
||||
| `room_expiry_days` | `14` | Forget 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; 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 |
|
||||
| `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. 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,6 +31,8 @@ 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 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.
|
||||
|
||||
## When a workflow stops being live
|
||||
@@ -40,7 +44,7 @@ Viewers (no *Edit* permission) see a read-only canvas. Anything they change loca
|
||||
|
||||
## Who can open it
|
||||
|
||||
Access follows the folder, not the workflow. To keep a workflow to some people, put it in a shared folder and restrict the folder: in the Files tab (🔒 on the folder), or with **Folder access** under *This tab* when the workflow on screen is in a folder you own. Choose *Only people I choose* and give each person **Can view** or **Can edit**.
|
||||
Access follows the folder, not the workflow. To keep a workflow to some people, put it in a shared folder and restrict the folder in the Files tab (🔒 on the folder). *This tab* shows whether the workflow on screen is in a restricted folder. Choose *Only people I choose* and give each person **Can view** or **Can edit**.
|
||||
|
||||
- Workflows at the top level of Shared (not in a folder) can be opened by everyone with an account, with their usual permissions.
|
||||
- The folder's owner (whoever created it) and admins always have access.
|
||||
@@ -54,6 +58,7 @@ Saved workflow files still show up by name in ComfyUI's workflow browser; openin
|
||||
## Snapshots
|
||||
|
||||
- Automatic every 5 minutes while a workflow is being edited (`snapshot_interval_sec`). The last 30 are kept (`snapshot_keep`).
|
||||
- On every save, by anyone, of any workflow: named *Saved at* and the time, shown in your own time zone. The last 30 are kept too.
|
||||
- **Snapshot** saves a named one, which is never rotated out.
|
||||
- **History** lists them. ↗ opens a private copy; ⟲ restores it for everyone, after first saving the current state as a snapshot.
|
||||
|
||||
|
||||
@@ -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.1.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,15 +13,17 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
import secrets
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from aiohttp import web, WSMsgType
|
||||
|
||||
from .chatlog import ChatLog
|
||||
from .graphdoc import apply_patch, remap_conflicts
|
||||
from .stats import collect_stats
|
||||
from .workspace import Workspace
|
||||
from .workspace import Workspace, has_app
|
||||
|
||||
log = logging.getLogger("ComfyUI-RigShare")
|
||||
|
||||
@@ -76,12 +78,13 @@ class Client:
|
||||
self.joined = time.time()
|
||||
self.idle = False
|
||||
self.room = None
|
||||
self.view = "graph" # "graph" (node editor) or "app" (ComfyUI's App view)
|
||||
|
||||
def public(self):
|
||||
return {"id": self.id, "kind": self.kind, "key": self.key if self.kind == "user" else None,
|
||||
"guest_id": self.key if self.kind == "guest" else None,
|
||||
"name": self.name, "color": self.color, "perms": self.perms,
|
||||
"joined": int(self.joined), "idle": self.idle, "room": self.room}
|
||||
"joined": int(self.joined), "idle": self.idle, "room": self.room, "view": self.view}
|
||||
|
||||
|
||||
class Hub:
|
||||
@@ -105,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 ----------------------------------------------------
|
||||
|
||||
@@ -141,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
|
||||
@@ -283,7 +316,8 @@ class Hub:
|
||||
out.append({"key": room.key, "name": room.name, "kind": room.kind, "version": room.version,
|
||||
"updated": int(room.updated), "members": members,
|
||||
"nodes": len((room.doc or {}).get("nodes") or []),
|
||||
"restricted": self.folder_restricted(self.folder_of(room))})
|
||||
"restricted": self.folder_restricted(self.folder_of(room)),
|
||||
"app": room.key.endswith(".app.json") or has_app(room.doc)})
|
||||
out.sort(key=lambda r: (-len(r["members"]), -r["updated"]))
|
||||
return out
|
||||
|
||||
@@ -318,7 +352,7 @@ class Hub:
|
||||
room.key = new_prefix + key[len(old_prefix):]
|
||||
if not is_live_key(room.key):
|
||||
continue # moved into a private folder: no longer live
|
||||
room.name = room.key.rsplit("/", 1)[-1].removesuffix(".json")
|
||||
room.name = room.key.rsplit("/", 1)[-1].removesuffix(".json").removesuffix(".app")
|
||||
room.dirty = True
|
||||
self.rooms[room.key] = room
|
||||
moved.append(room.key)
|
||||
@@ -332,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)]
|
||||
@@ -349,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})
|
||||
@@ -373,11 +416,18 @@ class Hub:
|
||||
cfg = self.store.config
|
||||
return {"name": cfg.get("server_name"), "allow_guests": cfg.get("allow_guests"),
|
||||
"hide_comfy_account": bool(cfg.get("hide_comfy_account")),
|
||||
"hide_comfy_file_tabs": bool(cfg.get("hide_comfy_file_tabs", True)),
|
||||
"hide_api_templates": bool(cfg.get("hide_api_templates"))}
|
||||
|
||||
async def broadcast_server(self):
|
||||
await self.broadcast({"type": "server", "server": self.server_info()})
|
||||
|
||||
async def 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)
|
||||
@@ -503,6 +553,12 @@ class Hub:
|
||||
await self.broadcast_presence()
|
||||
await self.system_chat(f"{old} is now known as {name}")
|
||||
|
||||
elif kind == "view":
|
||||
view = data.get("view")
|
||||
if view in ("graph", "app") and view != client.view:
|
||||
client.view = view
|
||||
await self.broadcast_presence()
|
||||
|
||||
elif kind == "idle":
|
||||
client.idle = bool(data.get("idle"))
|
||||
await self.broadcast_presence()
|
||||
|
||||
@@ -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,67 @@ 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
|
||||
try:
|
||||
doc = json.loads(body)
|
||||
except ValueError:
|
||||
return
|
||||
if not isinstance(doc, dict) or "nodes" not in doc:
|
||||
return
|
||||
name = path.rsplit("/", 1)[-1].removesuffix(".json").removesuffix(".app")
|
||||
# The panel shows the time in each viewer's own time zone; the label is for API clients.
|
||||
label = "Saved at " + time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime())
|
||||
store.add_snapshot(doc, display(request), "file:" + path, name, label, kind="save")
|
||||
|
||||
def check_userdata(request, action, src, dest):
|
||||
"""Folder rules (private spaces, restricted shared folders)."""
|
||||
kind, key, perms = who(request)
|
||||
@@ -679,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)
|
||||
@@ -705,7 +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])
|
||||
@@ -714,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")):
|
||||
@@ -728,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
|
||||
|
||||
@@ -47,6 +47,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
# Hide ComfyUI's own Comfy.org account sign-in (top-bar button and dialog).
|
||||
"hide_comfy_account": False,
|
||||
# ComfyUI's own Workflows and Apps sidebar tabs duplicate RigShare's Files tab.
|
||||
"hide_comfy_file_tabs": True,
|
||||
# Remove templates that need paid API nodes from the template browser.
|
||||
"hide_api_templates": False,
|
||||
# Rooms nobody opened for this many days are forgotten.
|
||||
@@ -442,8 +444,11 @@ class Store:
|
||||
items.append({"id": name[:-5], **meta})
|
||||
return items
|
||||
|
||||
def add_snapshot(self, doc, author, room, room_name, label=None):
|
||||
snap_id = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
|
||||
def add_snapshot(self, doc, author, room, room_name, label=None, kind=None):
|
||||
"""``kind`` "save": taken when the file was saved; those rotate like automatic ones."""
|
||||
# Sorted by name, newest first: include microseconds so saves in the same second keep their order.
|
||||
now = time.time()
|
||||
snap_id = time.strftime("%Y%m%d-%H%M%S", time.localtime(now)) + f"-{int(now % 1 * 1e6):06d}-" + secrets.token_hex(2)
|
||||
meta = {
|
||||
"time": int(time.time()),
|
||||
"author": author,
|
||||
@@ -452,6 +457,8 @@ class Store:
|
||||
"room_name": room_name,
|
||||
"nodes": len(doc.get("nodes", [])) if isinstance(doc, dict) else 0,
|
||||
}
|
||||
if kind:
|
||||
meta["kind"] = kind
|
||||
with open(os.path.join(self.snapshot_dir, snap_id + ".json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"meta": meta, "doc": doc}, f)
|
||||
self._prune_snapshots(room)
|
||||
@@ -468,9 +475,11 @@ class Store:
|
||||
|
||||
def _prune_snapshots(self, room):
|
||||
keep = max(1, int(self.config.get("snapshot_keep", 30)))
|
||||
# Labelled (manual) snapshots are kept; only automatic ones rotate.
|
||||
auto = [s for s in self.list_snapshots(room) if not s.get("label")]
|
||||
for snap in auto[keep:]:
|
||||
# Named (manual) snapshots are kept; automatic and on-save ones rotate, each on its own.
|
||||
snaps = self.list_snapshots(room)
|
||||
auto = [s for s in snaps if not s.get("label") and not s.get("kind")]
|
||||
saves = [s for s in snaps if s.get("kind") == "save"]
|
||||
for snap in auto[keep:] + saves[keep:]:
|
||||
try:
|
||||
os.remove(os.path.join(self.snapshot_dir, snap["id"] + ".json"))
|
||||
except OSError:
|
||||
|
||||
@@ -11,6 +11,7 @@ Paths handled here are relative to the *userdata* root, e.g.
|
||||
every path is fully URL-decoded and normalised before it is checked.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
@@ -40,6 +41,13 @@ def norm(path):
|
||||
return posixpath.normpath("/" + path).lstrip("/") if path else ""
|
||||
|
||||
|
||||
def has_app(doc):
|
||||
"""A workflow with an App set up (ComfyUI App mode: chosen inputs or outputs)."""
|
||||
data = (doc or {}).get("extra") if isinstance(doc, dict) else None
|
||||
linear = (data or {}).get("linearData") if isinstance(data, dict) else None
|
||||
return isinstance(linear, dict) and bool(linear.get("inputs") or linear.get("outputs"))
|
||||
|
||||
|
||||
def min_role(a, b):
|
||||
return a if ROLE_RANK[a] <= ROLE_RANK[b] else b
|
||||
|
||||
@@ -51,6 +59,7 @@ class Workspace:
|
||||
self.folders = store._read("folders.json", {}) # "shared/<name>" -> {owner, acl, created}
|
||||
os.makedirs(os.path.join(root, "users"), exist_ok=True)
|
||||
os.makedirs(os.path.join(root, "shared"), exist_ok=True)
|
||||
self._app_cache = {} # absolute path -> (mtime, size, has an App)
|
||||
|
||||
def save(self):
|
||||
self.store._write("folders.json", self.folders)
|
||||
@@ -191,10 +200,29 @@ class Workspace:
|
||||
def _abs(self, rel):
|
||||
return os.path.join(self.root, *[p for p in rel.split("/") if p])
|
||||
|
||||
def _file_has_app(self, full):
|
||||
"""Read a workflow file only when it changed since the last check."""
|
||||
try:
|
||||
st = os.stat(full)
|
||||
except OSError:
|
||||
return False
|
||||
cached = self._app_cache.get(full)
|
||||
if cached and cached[0] == st.st_mtime and cached[1] == st.st_size:
|
||||
return cached[2]
|
||||
try:
|
||||
with open(full, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
found = '"linearData"' in text and has_app(json.loads(text))
|
||||
except (OSError, ValueError):
|
||||
found = False
|
||||
self._app_cache[full] = (st.st_mtime, st.st_size, found)
|
||||
return found
|
||||
|
||||
def _entry(self, rel, name, is_dir, kind, key, perms):
|
||||
full = self._abs(rel)
|
||||
is_app = not is_dir and name.endswith(".app.json") # saved as an App (ComfyUI App mode)
|
||||
entry = {"type": "folder" if is_dir else "file", "path": rel,
|
||||
"name": name if is_dir else name[:-5],
|
||||
"name": name if is_dir else name[:-9] if is_app else name[:-5],
|
||||
"modified": int(os.path.getmtime(full)) if os.path.exists(full) else 0,
|
||||
"role": self.role(rel, kind, key, perms)}
|
||||
area, folder = self.area(rel)
|
||||
@@ -202,6 +230,8 @@ class Workspace:
|
||||
meta = self.folders.get(folder) or {}
|
||||
entry.update(owner=meta.get("owner"), restricted=bool(meta.get("acl")),
|
||||
manage=self.can_manage(folder, kind, key, perms))
|
||||
if is_app or (not is_dir and self._file_has_app(full)):
|
||||
entry["app"] = True
|
||||
entry["deletable"] = self.can_delete(rel, kind, key, perms)
|
||||
return entry
|
||||
|
||||
@@ -249,7 +279,15 @@ class Workspace:
|
||||
entries.append(self._entry(child, name, os.path.isdir(full), kind, key, perms))
|
||||
can_mkdir = role == "edit" and rel not in ("", "users", "shared")
|
||||
entries.sort(key=lambda e: (e["type"] != "folder", e["name"].lower()))
|
||||
return {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir}
|
||||
out = {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir}
|
||||
# Inside a shared folder: which one, and whether this person manages its access.
|
||||
area, folder = self.area(rel) if rel != "@shared" else (None, None)
|
||||
if area == "shared":
|
||||
meta = self.folders.get(folder) or {}
|
||||
out["shared_folder"] = {"path": folder, "name": folder.split("/", 1)[1], "owner": meta.get("owner"),
|
||||
"restricted": bool(meta.get("acl")),
|
||||
"manage": self.can_manage(folder, kind, key, perms)}
|
||||
return out
|
||||
|
||||
def make_dir(self, parent, name, kind, key, perms):
|
||||
"""New folder. In the Shared root it becomes a shared folder you own."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -324,3 +324,225 @@ def test_chat_history_pages(run):
|
||||
assert "hello 0" in texts and seqs == list(range(len(seqs))), "no gaps, no repeats"
|
||||
assert (await session.get(rig.url("/rigshare/api/chat?before=x"))).status in (400, 401)
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_apps_and_comfy_file_tabs(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
h = rig.http
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/demo.app.json')}"), data=b"{}", headers=alice)
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/plain.json')}"), data=b"{}", headers=alice)
|
||||
listing = await (await h.get(rig.url("/rigshare/api/workspace/list?path=users/alice"), headers=alice)).json()
|
||||
entries = {e["name"]: e for e in listing["entries"]}
|
||||
assert entries["demo"].get("app") is True and "app" not in entries["plain"], "apps named without .app"
|
||||
|
||||
sock = await rig.ws("alice")
|
||||
key = "file:workflows/shared/demo.app.json"
|
||||
await sock.send({"type": "join", "room": key, "name": "demo", "doc": BASE_DOC})
|
||||
await sock.until("room")
|
||||
presence = [m for m in await sock.drain() if m["type"] == "presence"][-1]
|
||||
assert presence["rooms"][0]["app"] is True
|
||||
|
||||
# ComfyUI's own Workflows/Apps tabs are hidden unless an admin turns that off.
|
||||
assert rig.hub.server_info()["hide_comfy_file_tabs"] is True
|
||||
await h.patch(rig.url("/rigshare/api/config"), json={"hide_comfy_file_tabs": False}, headers=admin)
|
||||
assert (await sock.until("server"))["server"]["hide_comfy_file_tabs"] is False
|
||||
await sock.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_view_mode_in_presence(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
await rig.add_user("alice", edit=True)
|
||||
alice, boss = await rig.ws("alice"), await rig.ws("boss")
|
||||
await boss.drain()
|
||||
await alice.send({"type": "view", "view": "app"})
|
||||
presence = await boss.until("presence")
|
||||
assert {u["key"]: u["view"] for u in presence["users"]} == {"alice": "app", "boss": "graph"}
|
||||
await alice.send({"type": "view", "view": "nonsense"})
|
||||
await asyncio.sleep(0.2)
|
||||
assert next(c for c in rig.hub.clients.values() if c.key == "alice").view == "app", "unknown views are ignored"
|
||||
await alice.close(); await boss.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_listing_inside_a_shared_folder(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
bob = await rig.add_user("bob", edit=True)
|
||||
h = rig.http
|
||||
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "FaceSwap"}, headers=alice)
|
||||
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "shared/FaceSwap", "name": "old"}, headers=alice)
|
||||
|
||||
async def here(user, path):
|
||||
r = await h.get(rig.url(f"/rigshare/api/workspace/list?path={path}"), headers=user)
|
||||
return (await r.json()).get("shared_folder")
|
||||
|
||||
# The folder you are in, even deeper down, and whether you manage its access.
|
||||
assert await here(alice, "shared/FaceSwap/old") == {"path": "shared/FaceSwap", "name": "FaceSwap", "owner": "alice",
|
||||
"restricted": False, "manage": True}
|
||||
assert (await here(bob, "shared/FaceSwap"))["manage"] is False
|
||||
assert await here(alice, "users/alice") is None
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_workflows_with_an_app_set_up(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
h = rig.http
|
||||
with_app = json.dumps({"nodes": [], "extra": {"linearData": {"inputs": [[3, "seed"]], "outputs": [9]}}}).encode()
|
||||
empty_app = json.dumps({"nodes": [], "extra": {"linearData": {"inputs": [], "outputs": []}}}).encode()
|
||||
for name, body in (("faceswap.json", with_app), ("empty.json", empty_app), ("plain.json", b"{}")):
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/' + name)}"), data=body, headers=alice)
|
||||
|
||||
async def apps():
|
||||
listing = await (await h.get(rig.url("/rigshare/api/workspace/list?path=users/alice"), headers=alice)).json()
|
||||
return {e["name"] for e in listing["entries"] if e.get("app")}
|
||||
assert await apps() == {"faceswap"}, "only a workflow whose App has inputs or outputs"
|
||||
# Edited on disk: the cached answer follows the file.
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/faceswap.json')}"), data=b'{"nodes": []}', headers=alice)
|
||||
assert await apps() == set()
|
||||
|
||||
sock = await rig.ws("alice")
|
||||
await sock.send({"type": "join", "room": "file:workflows/shared/f.json", "name": "f", "doc": json.loads(with_app)})
|
||||
await sock.until("room")
|
||||
presence = [m for m in await sock.drain() if m["type"] == "presence"][-1]
|
||||
assert presence["rooms"][0]["app"] is True
|
||||
await sock.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_every_save_keeps_a_snapshot(run):
|
||||
async def scenario():
|
||||
async with rig_server({"snapshot_keep": 2}) as rig:
|
||||
await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
h = rig.http
|
||||
path = "workflows/shared/flow.json"
|
||||
for n in range(3):
|
||||
doc = json.dumps({"nodes": [{"id": i} for i in range(n + 1)], "links": []}).encode()
|
||||
assert (await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=doc, headers=alice)).status == 200
|
||||
await h.post(rig.url(f"/api/userdata/{enc('workflows/notes.json')}"), data=b'{"not": "a workflow"}', headers=alice)
|
||||
snaps = await (await h.get(rig.url(f"/rigshare/api/snapshots?room={enc('file:' + path)}"), headers=alice)).json()
|
||||
assert [s["nodes"] for s in snaps] == [3, 2], "newest first, rotated to snapshot_keep"
|
||||
assert all(s["kind"] == "save" and s["label"].startswith("Saved at ") and s["author"] == "alice" for s in snaps)
|
||||
assert not rig.store.list_snapshots("file:workflows/notes.json"), "only workflows"
|
||||
# A save that is refused keeps nothing.
|
||||
viewer = await rig.add_user("viewer")
|
||||
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())
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// RigShare in ComfyUI's App mode.
|
||||
//
|
||||
// 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";
|
||||
|
||||
const RAIL = 'nav[data-testid="side-toolbar"]';
|
||||
|
||||
export class AppDock {
|
||||
/**
|
||||
* views: { id: { icon, title, tabId } }, tabId being a registered sidebar tab.
|
||||
*/
|
||||
constructor({ app, sync, views }) {
|
||||
this.app = app;
|
||||
this.sync = sync;
|
||||
this.views = views;
|
||||
this.badges = {};
|
||||
this.railButtons = {};
|
||||
this.dockButtons = {};
|
||||
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", {
|
||||
class: "rs-dock-btn", title: v.title, "aria-label": v.title,
|
||||
onclick: () => this.toggle(id),
|
||||
}, h("i", { class: `pi ${v.icon}` }), badge);
|
||||
this.dockButtons[id] = { btn, badge };
|
||||
return btn;
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
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.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";
|
||||
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);
|
||||
if (ours.length && ours.every((b) => rail.contains(b.btn))) return;
|
||||
const tabs = [...rail.querySelectorAll('[data-testid$="-tab-button"]')].filter((b) => !b.dataset.rigshare);
|
||||
const template = tabs.at(-1);
|
||||
if (!template) return;
|
||||
this.removeFromRail();
|
||||
let after = template;
|
||||
for (const [id, view] of Object.entries(this.views)) {
|
||||
const btn = template.cloneNode(true);
|
||||
btn.removeAttribute("data-testid");
|
||||
btn.dataset.rigshare = id;
|
||||
btn.classList.remove("side-bar-button-selected");
|
||||
btn.setAttribute("aria-label", view.title);
|
||||
btn.title = view.title;
|
||||
const icon = btn.querySelector(".side-bar-button-icon");
|
||||
if (icon) {
|
||||
const mine = document.createElement("i");
|
||||
mine.className = `pi ${view.icon} side-bar-button-icon`;
|
||||
mine.style.fontSize = "18px";
|
||||
icon.replaceWith(mine);
|
||||
}
|
||||
btn.querySelector(".sidebar-icon-badge")?.remove();
|
||||
const label = [...btn.querySelectorAll("span")].find((s) => s.textContent.trim());
|
||||
if (label) label.textContent = view.title;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "sidebar-icon-badge rs-rail-badge";
|
||||
const holder = btn.querySelector(".side-bar-button-icon")?.parentElement ?? btn;
|
||||
holder.style.position = "relative"; // the badge sits on the icon's corner
|
||||
holder.append(badge);
|
||||
btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.toggle(id); });
|
||||
after.after(btn);
|
||||
after = btn;
|
||||
this.railButtons[id] = { btn, badge };
|
||||
}
|
||||
this.refreshButtons();
|
||||
}
|
||||
|
||||
removeFromRail() {
|
||||
for (const b of Object.values(this.railButtons)) b.btn.remove();
|
||||
this.railButtons = {};
|
||||
}
|
||||
|
||||
refreshButtons() {
|
||||
const open = this.activeTabId;
|
||||
for (const [id, b] of Object.entries(this.railButtons)) {
|
||||
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", this.views[id].tabId === open);
|
||||
this.paintBadge(b.badge, this.badges[id]);
|
||||
}
|
||||
}
|
||||
|
||||
paintBadge(el, text) {
|
||||
el.textContent = text || "";
|
||||
el.style.display = text ? "block" : "none";
|
||||
}
|
||||
|
||||
setBadge(id, text) {
|
||||
this.badges[id] = text || null;
|
||||
this.refreshButtons();
|
||||
}
|
||||
}
|
||||
|
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);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// One component serves the Files tab ("browse"), the "Open from RigShare"
|
||||
// dialog ("open"), the Save dialog ("save") and the "Move to…" picker ("pick").
|
||||
|
||||
import { h, timeAgo, storage } from "./ui.js";
|
||||
import { h, timeAgo, storage, APP_ICON } from "./ui.js";
|
||||
|
||||
const SHARED = "@shared";
|
||||
const ALL = "";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -161,7 +168,14 @@ export class FilesBrowser {
|
||||
h("button", { class: `rs-fb-crumb ${i === trail.length - 1 ? "current" : ""}`, onclick: () => this.go(c.path) },
|
||||
c.icon ? h("i", { class: `pi ${c.icon}` }) : null, i === 0 && c.icon ? null : c.label),
|
||||
]).filter(Boolean));
|
||||
const shared = this.listing?.shared_folder;
|
||||
this.actions.replaceChildren(...[
|
||||
// Inside a shared folder: its access list, like the 🔒 on its row one level up.
|
||||
shared?.manage && this.mode === "browse" && this.accessEditor ? h("button", {
|
||||
class: `rs-btn rs-btn-icon ${this.accessFor === shared.path ? "active" : ""}`,
|
||||
title: `Who can open "${shared.name}"${shared.restricted ? " (restricted)" : ""}`,
|
||||
onclick: () => { this.accessFor = this.accessFor === shared.path ? null : shared.path; this.render(); },
|
||||
}, h("i", { class: `pi ${shared.restricted ? "pi-lock" : "pi-lock-open"}` })) : null,
|
||||
this.listing?.can_mkdir && this.mode !== "open" ? h("button", {
|
||||
class: "rs-btn rs-btn-icon", title: this.path === SHARED ? "New shared folder" : "New folder",
|
||||
onclick: () => this.newFolder(),
|
||||
@@ -173,6 +187,14 @@ export class FilesBrowser {
|
||||
|
||||
renderList() {
|
||||
const entries = (this.listing?.entries || []).filter((e) => !this.filter || e.name.toLowerCase().includes(this.filter));
|
||||
// The access editor of the shared folder we are in shows above its content.
|
||||
const here = this.listing?.shared_folder;
|
||||
const top = here && this.accessFor === here.path && this.accessEditor
|
||||
? this.accessEditor(here.path, () => { this.accessFor = null; this.reload(); }) : null;
|
||||
if (top) {
|
||||
this.list.replaceChildren(top, ...entries.map((e) => this.row(e)));
|
||||
return;
|
||||
}
|
||||
if (!entries.length) {
|
||||
this.list.replaceChildren(h("div", { class: "rs-fb-empty" },
|
||||
this.filter ? "Nothing matches." : this.path === `users/${this.me}` ? "Nothing here yet. Save a workflow and choose My files." : "This folder is empty."));
|
||||
@@ -191,8 +213,8 @@ export class FilesBrowser {
|
||||
const owner = e.owner ? this.client.users.find((u) => u.key === e.owner)?.name ?? `@${e.owner}` : null;
|
||||
const meta = isFolder
|
||||
? [e.owner !== undefined && owner ? `owner ${owner}` : null, e.role === "view" ? "view only" : null].filter(Boolean).join(" · ")
|
||||
: [timeAgo(e.modified * 1000), e.role === "view" ? "view only" : null].filter(Boolean).join(" · ");
|
||||
const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : "pi-file";
|
||||
: [e.app ? "App" : null, timeAgo(e.modified * 1000), e.role === "view" ? "view only" : null].filter(Boolean).join(" · ");
|
||||
const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : e.app ? "app" : "pi-file";
|
||||
// Files outside private folders are live while open: show who is in them.
|
||||
const live = isFolder ? null : this.client.rooms?.find((r) => r.key === `file:workflows/${e.path}`);
|
||||
const disabled = this.mode === "pick" && !isFolder;
|
||||
@@ -207,7 +229,7 @@ export class FilesBrowser {
|
||||
if (this.mode !== "pick") this.onOpenFile?.(`workflows/${e.path}`);
|
||||
},
|
||||
},
|
||||
h("i", { class: `pi ${icon} rs-fb-icon ${isFolder ? "folder" : ""}` }),
|
||||
h("i", { class: `${icon === "app" ? `${APP_ICON} rs-comfy-icon` : `pi ${icon}`} rs-fb-icon ${isFolder ? "folder" : ""}` }),
|
||||
h("span", { class: "rs-grow rs-min0" },
|
||||
h("div", { class: "rs-name rs-ellipsis", title: e.name }, e.name,
|
||||
live ? h("span", { class: "rs-live rs-inline-icon", title: `Live · ${live.members.length} open` }, h("span", { class: "rs-live-dot" })) : null),
|
||||
@@ -234,6 +256,8 @@ export class FilesBrowser {
|
||||
// ----- actions ----------------------------------------------------------
|
||||
|
||||
async ask(title, message, value = "") {
|
||||
// Inside the Save / Move / Open dialogs ComfyUI's own prompt would open underneath.
|
||||
if (this.mode !== "browse") return await promptModal(title, message, value);
|
||||
const dialog = this.app.extensionManager?.dialog;
|
||||
if (dialog?.prompt) return await dialog.prompt({ title, message, defaultValue: value });
|
||||
return window.prompt(message, value);
|
||||
@@ -316,7 +340,13 @@ export function modal(title, icon, body, footer) {
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
resolveClose(value);
|
||||
};
|
||||
const onKey = (e) => { if (e.key === "Escape" && backdrop.isConnected) { e.stopPropagation(); close(null); } };
|
||||
// Stacked modals (a prompt over the Save dialog): Escape closes only the top one.
|
||||
const onKey = (e) => {
|
||||
if (e.key !== "Escape" || !backdrop.isConnected) return;
|
||||
if ([...document.querySelectorAll(".rs-modal-backdrop")].at(-1) !== backdrop) return;
|
||||
e.stopPropagation();
|
||||
close(null);
|
||||
};
|
||||
const backdrop = h("div", { class: "rs-modal-backdrop rs-panel-vars", onmousedown: (e) => { if (e.target === backdrop) close(null); } },
|
||||
h("div", { class: "rs-modal rs-modal-wide", role: "dialog", "aria-label": title },
|
||||
h("div", { class: "rs-modal-title" }, h("i", { class: `pi ${icon}` }), h("span", { class: "rs-grow" }, title),
|
||||
@@ -329,6 +359,19 @@ export function modal(title, icon, body, footer) {
|
||||
return { close, closed };
|
||||
}
|
||||
|
||||
/** A text prompt in RigShare's own modal, so it stacks above another RigShare modal. */
|
||||
export async function promptModal(title, message, value = "") {
|
||||
const input = h("input", { class: "rs-input", value, maxLength: 120 });
|
||||
const ok = h("button", { class: "rs-btn rs-btn-primary" }, "OK");
|
||||
const m = modal(title, "pi-pencil", h("div", { class: "rs-save-body" }, h("label", { class: "rs-label" }, message), input),
|
||||
[h("span", { class: "rs-grow" }), h("button", { class: "rs-btn rs-btn-ghost", onclick: () => m.close(null) }, "Cancel"), ok]);
|
||||
const submit = () => m.close(input.value);
|
||||
ok.onclick = submit;
|
||||
input.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); submit(); } });
|
||||
setTimeout(() => { input.focus(); input.select(); }, 30);
|
||||
return await m.closed;
|
||||
}
|
||||
|
||||
/** Folder picker; resolves to a folder path ("" = common top level) or null. */
|
||||
export async function pickFolder({ app, client, sync, title, start }) {
|
||||
const browser = new FilesBrowser({ app, client, sync, mode: "pick", start });
|
||||
|
||||
@@ -11,8 +11,35 @@ import { STYLES } from "./styles.js";
|
||||
import { installSaveDialog } from "./save-dialog.js";
|
||||
import { FilesBrowser, openFromWorkspace } from "./files.js";
|
||||
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) {
|
||||
@@ -24,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 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +126,45 @@ function installComfyAccountGuard(client) {
|
||||
setTimeout(apply, 2000);
|
||||
}
|
||||
|
||||
// ComfyUI's Workflows and Apps sidebar tabs browse the same files as RigShare's
|
||||
// Files tab, without its folders. The server enforces permissions either way;
|
||||
// hiding them avoids two file browsers and buttons the server would refuse.
|
||||
const COMFY_FILE_TABS = ["workflows", "apps"];
|
||||
function installFileTabsGuard(client) {
|
||||
const removed = new Map();
|
||||
const manager = () => app.extensionManager;
|
||||
const tabs = () => manager()?.getSidebarTabs?.() ?? manager()?.sidebarTab?.sidebarTabs ?? [];
|
||||
const hidden = () => client.server?.hide_comfy_file_tabs !== false;
|
||||
const openFiles = () => manager()?.command?.execute("Workspace.ToggleSidebarTab.rigshare-files");
|
||||
const apply = () => {
|
||||
if (!client.server) return; // wait for the server's settings
|
||||
const hide = hidden();
|
||||
for (const id of COMFY_FILE_TABS) {
|
||||
const tab = tabs().find((t) => t.id === id);
|
||||
// Open workflow tabs can be shown in the Workflows sidebar tab: keep it then.
|
||||
const needed = id === "workflows" && setting("Comfy.Workflow.WorkflowTabsPosition", "Topbar") === "Sidebar";
|
||||
if (hide && tab && !needed) {
|
||||
removed.set(id, tab);
|
||||
(manager().unregisterSidebarTab ?? manager().sidebarTab.unregisterSidebarTab)(id);
|
||||
} else if ((!hide || needed) && !tab && removed.has(id)) {
|
||||
manager().registerSidebarTab(removed.get(id));
|
||||
removed.delete(id);
|
||||
}
|
||||
// Their keyboard shortcuts open Files instead.
|
||||
const cmd = (manager()?.command?.commands ?? []).find((c) => c.id === `Workspace.ToggleSidebarTab.${id}`);
|
||||
if (cmd && !cmd.__rigshare) {
|
||||
const original = cmd.function;
|
||||
cmd.function = (...args) => (removed.has(id) ? openFiles() : original(...args));
|
||||
cmd.__rigshare = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
client.on("server", apply);
|
||||
// ComfyUI registers its own tabs around the time extensions load.
|
||||
let tries = 0;
|
||||
const timer = setInterval(() => { apply(); if (++tries > 15) clearInterval(timer); }, 1000);
|
||||
}
|
||||
|
||||
function installQueueGuard(client) {
|
||||
const original = api.queuePrompt.bind(api);
|
||||
api.queuePrompt = async (...args) => {
|
||||
@@ -231,13 +303,26 @@ 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);
|
||||
installFileTabsGuard(client);
|
||||
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}`,
|
||||
@@ -246,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(),
|
||||
});
|
||||
|
||||
@@ -271,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.
|
||||
@@ -290,11 +377,21 @@ app.registerExtension({
|
||||
});
|
||||
client.on("self", () => { if (filesPanel.isConnected) filesBrowser.reload(); });
|
||||
|
||||
// ComfyUI's App view draws buttons only for its own tabs: add ours to its sidebar.
|
||||
const dock = new AppDock({
|
||||
app, sync,
|
||||
views: {
|
||||
[TAB_ID]: { icon: "pi-users", title: "RigShare", tabId: TAB_ID },
|
||||
files: { icon: "pi-folder-open", title: "Files", tabId: FILES_TAB_ID },
|
||||
},
|
||||
});
|
||||
|
||||
// The registered tab object is wrapped by Vue; mutating it through the
|
||||
// store keeps the unread badge reactive.
|
||||
panel.onBadge = (badge) => {
|
||||
const tab = app.extensionManager.getSidebarTabs?.().find((t) => t.id === TAB_ID);
|
||||
if (tab) tab.iconBadge = badge;
|
||||
dock.setBadge(TAB_ID, badge);
|
||||
setBlink(!!badge);
|
||||
};
|
||||
panel.onNotify = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Sidebar panel: tabs for workflows, people, chat, server and admin, plus an
|
||||
// account view (profile, password, API keys) behind the avatar button.
|
||||
|
||||
import { h, initials, timeAgo, clock, roleChips, meter, storage } from "./ui.js";
|
||||
import { h, initials, timeAgo, clock, roleChips, meter, storage, viewIcon, APP_ICON } from "./ui.js";
|
||||
import { ServerWidget } from "./server-widget.js";
|
||||
import { pickFolder } from "./files.js";
|
||||
import { isPrivatePath } from "./sync.js";
|
||||
@@ -10,12 +10,21 @@ 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 },
|
||||
];
|
||||
|
||||
/** A snapshot's name; on-save ones show the save time in the viewer's own time zone. */
|
||||
function snapLabel(snap) {
|
||||
if (snap.kind === "save") {
|
||||
return `Saved at ${new Date(snap.time * 1000).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}`;
|
||||
}
|
||||
return snap.label || "Auto";
|
||||
}
|
||||
|
||||
export class RigSharePanel {
|
||||
constructor(app, client, sync, presence) {
|
||||
this.app = app;
|
||||
@@ -52,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(); });
|
||||
@@ -163,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", {
|
||||
@@ -191,6 +204,7 @@ export class RigSharePanel {
|
||||
people: () => this.peopleView(),
|
||||
chat: () => [this.chatView],
|
||||
server: () => this.serverView(),
|
||||
queue: () => this.queueView(),
|
||||
admin: () => this.adminView(),
|
||||
account: () => this.accountView(),
|
||||
};
|
||||
@@ -247,9 +261,8 @@ export class RigSharePanel {
|
||||
s.live
|
||||
? h("span", { class: "rs-live" }, h("span", { class: "rs-live-dot" }), `Live · rev ${s.version}${last}`)
|
||||
: h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"),
|
||||
h("span", { class: "rs-avatars" }, ...here.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))));
|
||||
h("span", { class: "rs-avatars" }, ...here.map((u) => this.viewAvatar(u)))));
|
||||
body.push(this.accessLine());
|
||||
if (this.accessOpen && s.room?.manage && s.room.folder) body.push(this.accessEditor(s.room.folder));
|
||||
if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access."));
|
||||
if (s.canEdit) {
|
||||
row.append(h("button", {
|
||||
@@ -278,7 +291,7 @@ export class RigSharePanel {
|
||||
await this.run(() => this.sync.movePath(wf.path, dest ? `workflows/${dest}/${base}` : `workflows/${base}`), "Moved: it is live now");
|
||||
}
|
||||
|
||||
/** Who can open the workflow on screen: that is decided by its folder. */
|
||||
/** Who can open the workflow on screen: decided by its folder, managed in the Files tab. */
|
||||
accessLine() {
|
||||
const info = this.sync.room;
|
||||
if (!info) return null;
|
||||
@@ -286,24 +299,10 @@ export class RigSharePanel {
|
||||
return h("div", { class: "rs-access" },
|
||||
h("i", { class: `pi ${info.restricted ? "pi-lock" : "pi-globe"}` }),
|
||||
h("span", { class: "rs-grow" }, info.restricted ? `Only people with access to "${folder}"` : "Everyone can open it",
|
||||
folder && !info.restricted ? h("span", { class: "rs-muted" }, ` · in "${folder}"`) : null),
|
||||
info.manage ? h("button", {
|
||||
class: `rs-btn rs-btn-sm ${this.accessOpen ? "active" : ""}`, title: `Who can open the folder "${folder}"`,
|
||||
onclick: () => { this.accessOpen = !this.accessOpen; this.accessDraft = null; this.renderBody(); },
|
||||
}, "Folder access") : null);
|
||||
folder && !info.restricted ? h("span", { class: "rs-muted" }, ` · in "${folder}"`) : null));
|
||||
}
|
||||
|
||||
accessEditor(folder) {
|
||||
return this.accessEditorFor({
|
||||
load: () => this.client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(folder)}`),
|
||||
save: (restricted, members) => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: folder, restricted, members }),
|
||||
everyoneHint: "Anyone with an account can open its workflows, with their usual permissions",
|
||||
close: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); },
|
||||
draftKey: "accessDraft",
|
||||
});
|
||||
}
|
||||
|
||||
/** Access editor for a shared folder (Files tab and the live workflow's folder). */
|
||||
/** Access editor for a shared folder (used by the Files tab). */
|
||||
accessEditorFor({ load, save, everyoneHint, close, draftKey }) {
|
||||
const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…"));
|
||||
Promise.all([this.run(load), this.run(() => this.client.request("GET", "/rigshare/api/people"))]).then(([acl, people]) => {
|
||||
@@ -363,12 +362,12 @@ export class RigSharePanel {
|
||||
}
|
||||
this.historyEl.replaceChildren(...snaps.map((snap) => h("div", { class: "rs-row-item" },
|
||||
h("div", { class: "rs-grow rs-min0" },
|
||||
h("div", { class: "rs-ellipsis" }, snap.label || "Auto", h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
|
||||
h("div", { class: "rs-ellipsis" }, snapLabel(snap), h("span", { class: "rs-muted" }, ` · ${snap.nodes} nodes`)),
|
||||
h("div", { class: "rs-muted rs-small" }, `${timeAgo(snap.time * 1000)} · ${snap.author}`)),
|
||||
h("button", { class: "rs-btn rs-btn-icon", title: "Open a private copy", onclick: () => this.openSnapshot(snap) }, h("i", { class: "pi pi-external-link" })),
|
||||
this.sync.canEdit ? h("button", {
|
||||
class: "rs-btn rs-btn-icon", title: "Restore for everyone", onclick: async () => {
|
||||
if (!(await this.confirm("Restore snapshot", `Restore "${snap.label || "Auto"}" for everyone in this workflow?`))) return;
|
||||
if (!(await this.confirm("Restore snapshot", `Restore "${snapLabel(snap)}" for everyone in this workflow?`))) return;
|
||||
await this.run(() => this.client.request("POST", `/rigshare/api/snapshots/${snap.id}/restore`), "Snapshot restored");
|
||||
this.loadHistory();
|
||||
},
|
||||
@@ -380,7 +379,16 @@ export class RigSharePanel {
|
||||
if (!data) return;
|
||||
const doc = structuredClone(data.doc);
|
||||
if (doc.extra) delete doc.extra.rigshare; // a private copy, not the live room
|
||||
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snap.label || snap.id}).json`);
|
||||
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snapLabel(snap).replace(/[\/:]/g, "-")}).json`);
|
||||
}
|
||||
|
||||
/** Small avatar marked with how that person views the workflow: node graph or App. */
|
||||
viewAvatar(u) {
|
||||
const app = u.view === "app";
|
||||
return h("span", {
|
||||
class: `rs-avatar rs-avatar-xs rs-view-avatar ${app ? "rs-view-app" : ""}`, style: `background:${u.color}`,
|
||||
title: `${u.name} · ${app ? "App view" : "editing the graph"}`,
|
||||
}, initials(u.name), h("span", { class: "rs-view-mark" }, viewIcon(u.view)));
|
||||
}
|
||||
|
||||
roomsSection() {
|
||||
@@ -392,11 +400,11 @@ export class RigSharePanel {
|
||||
const members = r.members.map((id) => byId.get(id)).filter(Boolean);
|
||||
const folder = r.key.replace(/^file:workflows\//, "").split("/").slice(0, -1).join("/");
|
||||
return h("div", { class: `rs-row-item rs-room ${here ? "here" : ""}`, title: r.key.replace(/^file:workflows\//, "") },
|
||||
h("i", { class: "pi pi-file rs-row-icon" }),
|
||||
h("i", { class: `${r.app ? `${APP_ICON} rs-comfy-icon` : "pi pi-file"} rs-row-icon`, title: r.app ? "App" : null }),
|
||||
h("div", { class: "rs-grow rs-min0" },
|
||||
h("div", { class: "rs-name rs-ellipsis" }, r.name, r.restricted ? h("i", { class: "pi pi-lock rs-inline-icon rs-muted", title: "Restricted" }) : null),
|
||||
h("div", { class: "rs-muted rs-small rs-ellipsis" }, folder ? folder.replace(/^shared(\/|$)/, "Shared$1") : "Workflows", ` · ${r.nodes} nodes`),
|
||||
h("div", { class: "rs-avatars" }, ...members.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))),
|
||||
h("div", { class: "rs-avatars" }, ...members.map((u) => this.viewAvatar(u)))),
|
||||
here ? h("span", { class: "rs-chip rs-chip-edit" }, "open") : h("button", {
|
||||
class: "rs-btn rs-btn-icon", title: "Open",
|
||||
onclick: () => this.run(() => this.sync.openRoom(r)),
|
||||
@@ -432,7 +440,9 @@ export class RigSharePanel {
|
||||
h("div", { class: "rs-name rs-ellipsis" }, u.name, self ? h("span", { class: "rs-muted" }, " (you)") : null,
|
||||
u.idle ? h("i", { class: "pi pi-moon rs-muted rs-inline-icon", title: "Away" }) : null),
|
||||
h("div", { class: "rs-chips" }, ...(toggles.length ? toggles : roleChips(u.perms))),
|
||||
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"))),
|
||||
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"),
|
||||
where ? h("span", { class: `rs-chip rs-view-chip ${u.view === "app" ? "rs-view-app" : ""}` },
|
||||
viewIcon(u.view), u.view === "app" ? "App" : "Graph") : null)),
|
||||
self ? null : h("button", {
|
||||
class: `rs-btn rs-btn-icon ${following ? "rs-btn-primary" : ""}`,
|
||||
title: following ? "Stop following" : `Follow ${u.name} (jump to their tab and view)`,
|
||||
@@ -562,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;
|
||||
@@ -683,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"))),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -777,6 +833,7 @@ export class RigSharePanel {
|
||||
broadcast: box(cfg.broadcast_execution),
|
||||
protect: box(cfg.api_protection?.enabled),
|
||||
hideAccount: box(cfg.hide_comfy_account),
|
||||
hideFileTabs: box(cfg.hide_comfy_file_tabs ?? true),
|
||||
hideApiTemplates: box(cfg.hide_api_templates),
|
||||
trusted: text((cfg.api_protection?.trusted_ips || []).join(", "), "127.0.0.1, 172.17.0.0/16"),
|
||||
interval: h("input", { class: "rs-input rs-num", type: "number", min: 0, value: Math.round((cfg.snapshot_interval_sec ?? 300) / 60) }),
|
||||
@@ -794,6 +851,7 @@ export class RigSharePanel {
|
||||
allow_guests: f.guests.checked,
|
||||
broadcast_execution: f.broadcast.checked,
|
||||
hide_comfy_account: f.hideAccount.checked,
|
||||
hide_comfy_file_tabs: f.hideFileTabs.checked,
|
||||
hide_api_templates: f.hideApiTemplates.checked,
|
||||
api_protection: { enabled: f.protect.checked, trusted_ips: f.trusted.value.split(/[\s,]+/).filter(Boolean) },
|
||||
snapshot_interval_sec: Math.max(0, +f.interval.value || 0) * 60,
|
||||
@@ -808,6 +866,7 @@ export class RigSharePanel {
|
||||
line(f.broadcast, "Share execution progress", "Everyone sees progress and previews, not only whoever queued"),
|
||||
line(f.protect, "Enforce permissions on the ComfyUI API", "Blocks queueing, interrupting and uploads for users without permission"),
|
||||
line(f.hideAccount, "Hide the Comfy.org account login", "Removes ComfyUI's own sign-in button and dialog (used for paid API nodes)"),
|
||||
line(f.hideFileTabs, "Hide ComfyUI's Workflows and Apps tabs", "RigShare's Files tab replaces them. Their shortcuts open Files instead"),
|
||||
line(f.hideApiTemplates, "Hide API templates", "Removes templates that need paid API nodes from the template browser"),
|
||||
h("label", { class: "rs-label" }, "Trusted IPs / networks (skip login and checks)"), f.trusted,
|
||||
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Auto snapshot every (min, 0 = off)"), f.interval),
|
||||
|
||||
@@ -71,7 +71,7 @@ export class Presence extends EventTarget {
|
||||
sendState() {
|
||||
if (!this.client.online || !this.app.canvas) return;
|
||||
let msg;
|
||||
if (!this.sync.live || document.hidden) {
|
||||
if (!this.sync.live || document.hidden || this.sync.inAppMode) {
|
||||
msg = { type: "cursor", x: null, y: null };
|
||||
} else {
|
||||
const [x, y] = this.app.canvas.graph_mouse ?? [0, 0];
|
||||
@@ -104,7 +104,7 @@ export class Presence extends EventTarget {
|
||||
}
|
||||
|
||||
applyViewport(vp, graphId) {
|
||||
if (graphId !== this.currentGraphId()) return;
|
||||
if (graphId !== this.currentGraphId() || this.sync.inAppMode) return;
|
||||
const canvas = this.app.canvas;
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
canvas.ds.scale = vp.scale;
|
||||
@@ -123,7 +123,7 @@ export class Presence extends EventTarget {
|
||||
|
||||
draw(ctx) {
|
||||
const visible = new Set();
|
||||
if (!this.sync.live || !this.showCursors) return this.pruneCursorEls(visible);
|
||||
if (!this.sync.live || !this.showCursors || this.sync.inAppMode) return this.pruneCursorEls(visible);
|
||||
const canvas = this.app.canvas;
|
||||
const graph = canvas.graph;
|
||||
const scale = canvas.ds.scale;
|
||||
|
||||
@@ -9,8 +9,9 @@ import { FilesBrowser, modal } from "./files.js";
|
||||
|
||||
const LAST_FOLDER = "rigshare.lastSaveFolder";
|
||||
|
||||
// ComfyUI adds ".json", or ".app.json" for a workflow saved as an App: names go back without either.
|
||||
function cleanName(name) {
|
||||
return (name || "").trim().replace(/\.json$/i, "").replace(/[\\/]+/g, "-").slice(0, 120);
|
||||
return (name || "").trim().replace(/(\.app)?\.json$/i, "").replace(/\.app$/i, "").replace(/[\\/]+/g, "-").slice(0, 120);
|
||||
}
|
||||
|
||||
export function installSaveDialog(app, api, client, sync) {
|
||||
@@ -75,7 +76,8 @@ export async function chooseLocation(app, api, client, sync, wf) {
|
||||
if (directory === "workflows") return `${result.folder}/${result.name}`;
|
||||
// "Save As" from a file that lives in another folder: write the copy
|
||||
// ourselves, open it, and tell ComfyUI there is nothing left to do.
|
||||
return await saveCopyElsewhere(app, api, client, wf, `${targetDir}/${result.name}.json`);
|
||||
const isApp = wf.initialMode === "app" || /\.app(\.json)?$/i.test(wf.filename || "");
|
||||
return await saveCopyElsewhere(app, api, client, wf, `${targetDir}/${result.name}${isApp ? ".app" : ""}.json`);
|
||||
}
|
||||
|
||||
async function saveCopyElsewhere(app, api, client, wf, path) {
|
||||
|
||||
@@ -94,7 +94,7 @@ code { font-family: ui-monospace, monospace; font-size: 11px; background: var(--
|
||||
.rs-avatar-sm { width: 28px; height: 28px; font-size: 10.5px; }
|
||||
.rs-avatar-lg { width: 44px; height: 44px; font-size: 15px; }
|
||||
.rs-avatar-xs { width: 20px; height: 20px; font-size: 8.5px; }
|
||||
.rs-avatars { display: inline-flex; gap: 3px; flex-wrap: wrap; margin-top: 3px; }
|
||||
.rs-avatars { display: inline-flex; gap: 7px; flex-wrap: wrap; margin-top: 3px; }
|
||||
.rs-name { font-weight: 600; }
|
||||
.rs-me { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.rs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
||||
@@ -204,7 +204,7 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-fb-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 7px 8px; border: 0; background: none;
|
||||
color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||
.rs-fb-main:disabled { cursor: default; }
|
||||
.rs-fb-icon { font-size: 16px; color: var(--rs-muted); width: 18px; text-align: center; flex: none; }
|
||||
.rs-fb-icon { font-size: 13px; color: var(--rs-muted); width: 15px; text-align: center; flex: none; }
|
||||
.rs-fb-icon.folder { color: #f5b942; }
|
||||
.rs-fb-tools-row { display: none; gap: 3px; padding-right: 6px; }
|
||||
.rs-fb-row:hover .rs-fb-tools-row, .rs-fb-row:focus-within .rs-fb-tools-row { display: flex; }
|
||||
@@ -244,6 +244,26 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
.rs-mini-queue { color: var(--rs-muted); text-align: center; margin-top: 2px; }
|
||||
|
||||
/* remote cursors (DOM layer above canvas content) */
|
||||
.rs-dock { position: fixed; left: 64px; bottom: 16px; z-index: 1100; display: flex; gap: 4px; padding: 4px;
|
||||
background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 12px; box-shadow: 0 6px 24px rgb(0 0 0 / .35); }
|
||||
.rs-dock-btn { position: relative; width: 36px; height: 36px; display: grid; place-items: center; border: 0; border-radius: 8px;
|
||||
background: none; color: var(--rs-muted); cursor: pointer; }
|
||||
.rs-dock-btn .pi { font-size: 16px; }
|
||||
.rs-dock-btn:hover { color: var(--rs-fg); background: var(--rs-bg2); }
|
||||
.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-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-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;
|
||||
border-radius: 50%; background: #71717a; color: #fff; border: 1.5px solid var(--rs-bg); }
|
||||
.rs-view-app .rs-view-mark { color: var(--rs-accent-fg); background: var(--rs-accent); }
|
||||
.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)); }
|
||||
|
||||
@@ -120,7 +120,7 @@ export class RoomSync extends EventTarget {
|
||||
this.inFlight = 0; // our patches not yet acknowledged
|
||||
this.crossed = false; // a remote patch arrived while ours were in flight
|
||||
|
||||
client.on("welcome", () => { this.targetKey = undefined; this.tick(); });
|
||||
client.on("welcome", () => { this.targetKey = undefined; this.sentView = undefined; this.tick(); });
|
||||
client.on("status", (s) => { if (s !== "online") { this.ready = false; this.emitState(); } });
|
||||
client.on("room", (msg) => this.onRoom(msg));
|
||||
client.on("room_info", (msg) => {
|
||||
@@ -222,6 +222,13 @@ export class RoomSync extends EventTarget {
|
||||
return this.app.rootGraph ?? this.app.graph;
|
||||
}
|
||||
|
||||
/** The tab on screen shows ComfyUI's App view (no canvas), not the node graph. */
|
||||
get inAppMode() {
|
||||
const wf = this.store?.activeWorkflow;
|
||||
const mode = wf?.activeMode ?? wf?.initialMode ?? "graph";
|
||||
return mode === "app" || mode === "builder:arrange";
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return stripView(JSON.parse(JSON.stringify(this.graph().serialize())));
|
||||
}
|
||||
@@ -229,7 +236,7 @@ export class RoomSync extends EventTarget {
|
||||
// ----- which room does a tab belong to --------------------------------
|
||||
|
||||
tabName(wf) {
|
||||
return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/\.json$/, "");
|
||||
return (wf.filename ?? wf.path?.split("/").pop() ?? "Workflow").replace(/(\.app)?\.json$/, "");
|
||||
}
|
||||
|
||||
/** Why a tab is not live: "unsaved", "private", or null when it is shared. */
|
||||
@@ -254,6 +261,9 @@ export class RoomSync extends EventTarget {
|
||||
|
||||
tick() {
|
||||
if (!this.client.online || !this.store) return;
|
||||
// Tell the others whether this tab shows the node graph or the App view.
|
||||
const view = this.inAppMode ? "app" : "graph";
|
||||
if (view !== this.sentView && this.client.send({ type: "view", view })) this.sentView = view;
|
||||
const { wf, room } = this.activeRoom();
|
||||
const key = room?.key ?? null;
|
||||
if (key !== this.targetKey || (key && wf !== this.targetWf)) {
|
||||
@@ -317,6 +327,8 @@ export class RoomSync extends EventTarget {
|
||||
const doc = structuredClone(this.doc);
|
||||
const ds = this.app.canvas?.ds;
|
||||
if (keepView && ds) doc.extra = { ...(doc.extra || {}), ds: { scale: ds.scale, offset: [...ds.offset] } };
|
||||
// Graph or App view is each person's own choice: a reload must not switch it.
|
||||
const mode = wf.activeMode;
|
||||
this.applying++;
|
||||
this.ownLoad++;
|
||||
try {
|
||||
@@ -324,6 +336,7 @@ export class RoomSync extends EventTarget {
|
||||
} finally {
|
||||
this.ownLoad--;
|
||||
this.applying--;
|
||||
if (mode && wf.activeMode !== mode) wf.activeMode = mode;
|
||||
}
|
||||
this.settle();
|
||||
this.updateReadOnly();
|
||||
|
||||
@@ -62,3 +62,11 @@ export const storage = {
|
||||
} catch { /* private mode */ }
|
||||
},
|
||||
};
|
||||
|
||||
// ComfyUI's own icons for its Graph / App switch, so RigShare matches what people see there.
|
||||
export const GRAPH_ICON = "icon-[comfy--workflow]";
|
||||
export const APP_ICON = "icon-[lucide--panels-top-left]";
|
||||
|
||||
export function viewIcon(view, cls = "") {
|
||||
return h("i", { class: `${view === "app" ? APP_ICON : GRAPH_ICON} rs-comfy-icon ${cls}` });
|
||||
}
|
||||
|
||||