Files

902 lines
52 KiB
JavaScript

// 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, viewIcon, APP_ICON } from "./ui.js";
import { ServerWidget, PerfWidget } from "./server-widget.js";
import { perfView, openHistory } from "./perf.js";
import { pickFolder } from "./files.js";
import { isPrivatePath } from "./sync.js";
const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href;
const VIEWS = [
{ id: "workflows", icon: "pi-share-alt", label: "Live" },
{ 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;
this.client = client;
this.sync = sync;
this.presence = presence;
this.visible = false;
this.unread = 0;
this.chatToasts = false;
this.view = storage.get("rigshare.view") || "workflows";
this.historyOpen = false;
this.newKey = null;
this.widget = new ServerWidget(client, () => this.render(), { inAppMode: () => sync.inAppMode });
this.perfWidget = new PerfWidget(client, () => this.render(), {
inAppMode: () => sync.inAppMode,
workflow: () => { const wf = sync.store?.activeWorkflow; return wf ? sync.tabName(wf) : null; },
});
this.statsAt = Date.now();
client.on("stats", () => { this.statsAt = Date.now(); });
// The Performance clock ticks between server updates (only that section redraws).
setInterval(() => {
if (this.view === "server" && this.visible && this.perfBody?.isConnected && this.client.stats?.performance?.running?.length) {
this.perfBody.replaceChildren(...perfView(this.client.stats.performance, this.statsAt, this.perfWidget.workflow()));
}
}, 250);
this.header = h("div", { class: "rs-header" });
this.nav = h("div", { class: "rs-nav", role: "tablist" });
this.body = h("div", { class: "rs-body" });
this.root = h("div", { class: "rs-panel" }, this.header, this.nav, this.body);
this.chatTop = h("div", { class: "rs-chat-top" });
this.chatLog = h("div", { class: "rs-chat-log", onscroll: () => this.maybeLoadOlder() });
this.chatInput = h("textarea", {
class: "rs-input rs-chat-input", placeholder: "Message… (Enter to send)", maxLength: 1000, rows: 1,
onkeydown: (e) => {
e.stopPropagation();
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.sendChat(); }
},
});
this.chatView = h("div", { class: "rs-chat" }, this.chatLog,
h("div", { class: "rs-chat-compose" }, this.chatInput,
h("button", { class: "rs-btn rs-btn-primary rs-btn-icon", title: "Send", onclick: () => this.sendChat() }, h("i", { class: "pi pi-send" }))));
client.on("status", () => this.render());
client.on("self", () => this.render());
client.on("presence", () => this.renderSoft());
client.on("stats", () => {
if (this.view === "server" || this.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(); });
presence.addEventListener("follow", () => { if (this.view === "people") this.renderBody(); });
this.render();
}
// ----- sidebar lifecycle ---------------------------------------------
mount(container) {
container.classList.add("rs-host");
// ComfyUI reuses the same slot element when switching straight between
// two custom sidebar tabs: replace whatever the other tab left there.
container.replaceChildren(this.root);
this.visible = true;
this.renderedView = null; // reopened: the chat starts at the newest message
if (this.view === "chat") this.clearUnread();
this.render();
}
unmount() {
this.visible = false;
}
focusChat() {
this.setView("chat");
setTimeout(() => this.chatInput.focus(), 50);
}
setView(view) {
this.view = view;
storage.set("rigshare.view", view);
if (view === "chat") this.clearUnread();
this.render();
}
get badge() {
return this.unread ? String(this.unread > 99 ? "99+" : this.unread) : null;
}
clearUnread() {
if (!this.unread) return;
this.unread = 0;
this.onBadge?.(null);
}
toast(severity, detail, summary = "RigShare") {
this.client.emit("toast", { severity, summary, detail });
}
async confirm(title, message) {
const dialog = this.app.extensionManager?.dialog;
if (dialog?.confirm) return !!(await dialog.confirm({ title, message }));
return window.confirm(`${title}\n\n${message}`);
}
async prompt(title, message, defaultValue = "") {
const dialog = this.app.extensionManager?.dialog;
if (dialog?.prompt) return await dialog.prompt({ title, message, defaultValue });
return window.prompt(`${title}\n\n${message}`, defaultValue);
}
async run(fn, okMessage) {
try {
const result = await fn();
if (okMessage) this.toast("success", okMessage);
return result;
} catch (e) {
this.toast("error", e.message || String(e));
return undefined;
}
}
// ----- frame ----------------------------------------------------------
render() {
this.renderHeader();
this.renderNav();
this.renderBody();
}
/** Presence changes: refresh counts and the current view if it shows people. */
renderSoft() {
this.renderNav();
if (["workflows", "people"].includes(this.view)) this.renderBody();
}
renderHeader() {
const c = this.client;
const s = c.status;
const label = { online: "Connected", connecting: "Connecting…", offline: "Offline", denied: "Signed out" }[s];
const me = c.self;
this.header.replaceChildren(...[
h("div", { class: "rs-title" }, h("img", { class: "rs-logo", src: ICON_URL, alt: "" }),
h("div", { class: "rs-title-text" },
h("div", { class: "rs-title-name" }, c.server?.name || "RigShare"),
h("div", { class: `rs-status rs-status-${s}` }, h("span", { class: "rs-dot" }), label))),
me ? h("button", {
class: `rs-me-btn ${this.view === "account" ? "active" : ""}`, title: `${me.name} — account & API keys`,
onclick: () => this.setView(this.view === "account" ? "workflows" : "account"),
}, h("span", { class: "rs-avatar rs-avatar-sm", style: `background:${me.color}` }, initials(me.name))) : null,
].filter(Boolean));
}
renderNav() {
const c = this.client;
const counts = {
people: c.users.length || null,
chat: this.unread || null,
workflows: c.rooms?.length || null,
queue: c.stats?.queue_items?.length || null,
};
this.nav.replaceChildren(...VIEWS.filter((v) => !v.admin || c.perms.admin).map((v) =>
h("button", {
class: `rs-tab ${this.view === v.id ? "active" : ""}`, role: "tab", title: v.label,
onclick: () => this.setView(v.id),
}, h("i", { class: `pi ${v.icon}` }), h("span", { class: "rs-tab-label" }, v.label),
counts[v.id] ? h("span", { class: `rs-count ${v.id === "chat" ? "hot" : ""}` }, counts[v.id]) : null)));
this.nav.style.display = c.self ? "" : "none";
}
renderBody() {
const c = this.client;
this.body.classList.toggle("rs-body-chat", this.view === "chat" && !!c.self);
if (!c.self) {
const failing = c.retry >= 2 && c.status !== "denied";
this.body.replaceChildren(h("div", { class: "rs-empty" },
h("i", { class: "pi pi-spin pi-spinner" }),
c.status === "denied" ? "Signed out — redirecting to login…" : c.status === "connecting" ? "Connecting…" : "Server unreachable, retrying…",
failing ? h("div", { class: "rs-small rs-hint" },
"Can't open the live connection. If ComfyUI is behind a reverse proxy, enable WebSocket support for it (Nginx Proxy Manager: ",
h("b", {}, "Websockets Support"), ").") : null));
return;
}
const views = {
workflows: () => this.workflowsView(),
people: () => this.peopleView(),
chat: () => [this.chatView],
server: () => this.serverView(),
queue: () => this.queueView(),
admin: () => this.adminView(),
account: () => this.accountView(),
};
if ((this.view === "admin" && !c.perms.admin) || !VIEWS.some((v) => v.id === this.view) && this.view !== "account") this.view = "workflows";
const scroll = this.body.scrollTop;
// Re-attaching the chat resets its scroll: keep the reader's place in the history.
const stay = this.view === "chat" && this.renderedView === "chat";
const chatScroll = this.chatLog.scrollTop;
this.body.replaceChildren(...(views[this.view] ?? views.workflows)().filter(Boolean));
this.body.scrollTop = scroll;
if (stay) this.chatLog.scrollTop = chatScroll;
else if (this.view === "chat") this.scrollChat();
this.renderedView = this.view;
}
section(icon, title, ...children) {
return h("section", { class: "rs-section" },
h("div", { class: "rs-section-title" }, h("i", { class: `pi ${icon}` }), title), ...children);
}
// ----- workflows ------------------------------------------------------
workflowsView() {
return [this.currentTabSection(), this.roomsSection()];
}
currentTabSection() {
const c = this.client;
const s = this.sync;
const wf = s.store?.activeWorkflow;
if (!wf) return this.section("pi-file", "This tab", h("p", { class: "rs-muted" }, "No tab open."));
const room = s.target;
const privacy = s.privacyOf(wf);
const head = h("div", { class: "rs-tab-head" },
h("div", { class: "rs-name" }, s.tabName(wf)),
privacy ? h("span", { class: "rs-chip" }, privacy) : null);
const row = h("div", { class: "rs-actions" });
const body = [head];
if (!room) {
body.push(h("p", { class: "rs-muted" }, privacy === "unsaved"
? "Not live. Save it outside your private folder to work on it with others."
: privacy === "private"
? "In your private folder: only you see it. Move it out to work on it with others."
: "Not live: this tab is a private copy (no access, or the file was deleted)."));
if (privacy === "private" && c.perms.edit) {
row.append(h("button", {
class: "rs-btn rs-btn-primary", onclick: () => this.moveOutOfPrivate(wf),
}, h("i", { class: "pi pi-share-alt" }), "Move to Shared…"));
}
} else {
const here = c.users.filter((u) => u.room === room.key);
const last = s.lastEditor && Date.now() - s.lastEditor.at < 60000 ? ` · ${s.lastEditor.name} editing` : "";
body.push(h("div", { class: "rs-live-row" },
s.live
? h("span", { class: "rs-live" }, h("span", { class: "rs-live-dot" }), `Live · rev ${s.version}${last}`)
: h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"),
h("span", { class: "rs-avatars" }, ...here.map((u) => this.viewAvatar(u)))));
body.push(this.accessLine());
if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access."));
if (s.canEdit) {
row.append(h("button", {
class: "rs-btn", title: "Save a named snapshot", onclick: async () => {
const label = await this.prompt("Snapshot", "Name this snapshot", `Snapshot ${new Date().toLocaleString()}`);
if (label === null || label === undefined) return;
await this.run(() => c.request("POST", "/rigshare/api/snapshots", { room: room.key, label: label || "Manual" }), "Snapshot saved");
if (this.historyOpen) this.loadHistory();
},
}, h("i", { class: "pi pi-camera" }), "Snapshot"));
}
row.append(
h("button", { class: `rs-btn ${this.historyOpen ? "active" : ""}`, onclick: () => { this.historyOpen = !this.historyOpen; this.renderBody(); if (this.historyOpen) this.loadHistory(); } },
h("i", { class: "pi pi-history" }), "History"));
}
this.historyEl ??= h("div", { class: "rs-history" });
return this.section("pi-file", "This tab", ...body, row, room && this.historyOpen ? this.historyEl : null);
}
/** Move a private workflow into a shared location, where it goes live. */
async moveOutOfPrivate(wf) {
const dest = await pickFolder({ app: this.app, client: this.client, sync: this.sync, title: `Move "${this.sync.tabName(wf)}" to…`, start: "@shared" });
if (dest === null || dest === undefined) return;
if (isPrivatePath(`workflows/${dest}/`)) return this.client.emit("toast", { severity: "warn", summary: "RigShare", detail: "Pick a folder outside a private folder." });
const base = wf.path.split("/").pop();
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: decided by its folder, managed in the Files tab. */
accessLine() {
const info = this.sync.room;
if (!info) return null;
const folder = info.folder?.replace(/^shared\//, "");
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));
}
/** 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]) => {
if (!acl || !people) return;
const draft = this[draftKey] ??= { restricted: acl.restricted, members: { ...acl.members } };
const render = () => {
const fixed = people.filter((p) => p.username === acl.owner || p.admin);
const choosable = people.filter((p) => p.username !== acl.owner && !p.admin);
const fixedRows = fixed.map((p) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`)),
h("span", { class: "rs-chip" }, p.username === acl.owner ? "Owner · always" : "Admin · always")));
const rows = choosable.map((p) => {
const select = h("select", {
class: "rs-input rs-select", disabled: !draft.restricted,
onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; },
}, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) =>
h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label)));
return h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`),
p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")),
select);
});
const name = `rs-access-${draftKey}`;
const radio = (value, label, hint) => h("label", { class: "rs-check rs-block" },
h("input", { type: "radio", name, checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }),
h("span", {}, label, h("div", { class: "rs-muted rs-small" }, hint)));
box.replaceChildren(...[
radio(false, "Everyone", everyoneHint),
radio(true, "Only people I choose", "The owner and admins always have access"),
draft.restricted ? h("div", { class: "rs-access-list" }, ...fixedRows, ...rows,
rows.length ? null : h("p", { class: "rs-muted rs-small" },
"Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open it.")) : null,
h("div", { class: "rs-actions" },
h("button", {
class: "rs-btn rs-btn-primary", onclick: async () => {
const ok = await this.run(() => save(draft.restricted, draft.restricted ? draft.members : {}), "Access updated");
if (ok !== undefined) { this[draftKey] = null; close(); }
},
}, "Save"),
h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this[draftKey] = null; close(); } }, "Cancel")),
].filter(Boolean));
};
render();
});
return box;
}
async loadHistory() {
const room = this.sync.target;
if (!room) return;
this.historyEl.replaceChildren(h("p", { class: "rs-muted" }, "Loading…"));
const snaps = await this.run(() => this.client.request("GET", `/rigshare/api/snapshots?room=${encodeURIComponent(room.key)}`));
if (!snaps) return;
if (!snaps.length) {
this.historyEl.replaceChildren(h("p", { class: "rs-muted" }, "No snapshots yet. They are taken automatically every few minutes while people edit."));
return;
}
this.historyEl.replaceChildren(...snaps.map((snap) => h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-ellipsis" }, 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 "${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();
},
}, h("i", { class: "pi pi-replay" })) : null)));
}
async openSnapshot(snap) {
const data = await this.run(() => this.client.request("GET", `/rigshare/api/snapshots/${snap.id}`));
if (!data) return;
const doc = structuredClone(data.doc);
if (doc.extra) delete doc.extra.rigshare; // a private copy, not the live room
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${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() {
const c = this.client;
const rooms = c.rooms || [];
const byId = new Map(c.users.map((u) => [u.id, u]));
const rows = rooms.map((r) => {
const here = r.key === this.sync.targetKey;
const members = r.members.map((id) => byId.get(id)).filter(Boolean);
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: `${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) => 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)),
}, h("i", { class: "pi pi-arrow-right" })));
});
return this.section("pi-share-alt", "Live workflows",
...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nobody has a shared workflow open. Any workflow outside a private folder is live while it is open.")]));
}
// ----- people ---------------------------------------------------------
peopleView() {
const c = this.client;
const isAdmin = c.perms.admin;
const users = [...c.users].sort((a, b) => (a.id === c.self.id ? -1 : b.id === c.self.id ? 1 : a.joined - b.joined));
const rows = users.map((u) => {
const self = u.id === c.self.id;
const following = this.presence.following === u.id;
const toggles = [];
if (isAdmin && !self && !u.perms.admin) {
for (const perm of ["edit", "queue", "manager"]) {
toggles.push(h("button", {
class: `rs-toggle ${u.perms[perm] ? "on" : ""}`,
title: `${u.perms[perm] ? "Revoke" : "Grant"} ${perm} permission${u.kind === "guest" ? "" : ` for @${u.key}`}`,
onclick: () => this.setPerm(u, perm, !u.perms[perm]),
}, { edit: "Edit", queue: "Queue", manager: "Manager" }[perm]));
}
}
const where = c.rooms.find((r) => r.key === u.room)?.name;
return h("div", { class: "rs-row-item rs-person" },
h("span", { class: "rs-avatar", style: `background:${u.color}` }, initials(u.name)),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, u.name, self ? h("span", { class: "rs-muted" }, " (you)") : null,
u.idle ? h("i", { class: "pi pi-moon rs-muted rs-inline-icon", title: "Away" }) : null),
h("div", { class: "rs-chips" }, ...(toggles.length ? toggles : roleChips(u.perms))),
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"),
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)`,
onclick: () => this.presence.follow(following ? null : u.id),
}, h("i", { class: following ? "pi pi-eye-slash" : "pi pi-eye" })));
});
return [this.section("pi-users", `Online (${users.length})`, ...rows)];
}
async setPerm(user, perm, value) {
const perms = { [perm]: value };
if (user.kind === "user") await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(user.key)}`, { perms }));
else await this.run(() => this.client.request("POST", `/rigshare/api/guests/${encodeURIComponent(user.guest_id)}`, perms));
}
// ----- chat -----------------------------------------------------------
sendChat() {
const text = this.chatInput.value.trim();
if (!text) return;
if (this.client.send({ type: "chat", text })) this.chatInput.value = "";
}
dayLabel(ts) {
const d = new Date(ts);
const today = new Date();
const yesterday = new Date(today.getTime() - 86400000);
if (d.toDateString() === today.toDateString()) return "Today";
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
return d.toLocaleDateString([], { weekday: "short", year: "numeric", month: "short", day: "numeric" });
}
/** Chat lines for ``messages``, with a date divider wherever the day changes after ``prev``. */
chatLines(messages, prev) {
const out = [];
for (const m of messages) {
if (!prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString()) {
out.push(h("div", { class: "rs-chat-day" }, h("span", {}, this.dayLabel(m.ts))));
}
out.push(this.chatLine(m));
prev = m;
}
return out;
}
renderChatTop() {
const c = this.client;
this.chatTop.replaceChildren(this.loadingOlder
? h("span", { class: "rs-muted rs-small" }, h("i", { class: "pi pi-spin pi-spinner" }), " Loading older messages…")
: c.chatMore
? h("button", { class: "rs-btn rs-btn-sm rs-btn-ghost", onclick: () => this.loadOlder() }, "Load older messages")
: c.chat.length ? h("span", { class: "rs-muted rs-small" }, "Beginning of the chat") : null);
}
maybeLoadOlder() {
if (this.chatLog.scrollTop < 120 && this.client.chatMore && !this.loadingOlder) this.loadOlder();
}
/** Infinite scroll back: prepend the previous page and keep the view where it was. */
async loadOlder() {
if (this.loadingOlder || !this.client.chatMore) return;
this.loadingOlder = true;
this.renderChatTop();
try {
const next = this.client.chat.find((m) => m.seq !== undefined);
const older = await this.client.loadOlderChat();
if (older.length) {
const log = this.chatLog;
const before = log.scrollHeight - log.scrollTop;
// The first message's date divider is redrawn with the older page.
const firstDay = this.chatTop.nextSibling;
if (firstDay?.classList?.contains("rs-chat-day") && next
&& new Date(older.at(-1).ts).toDateString() === new Date(next.ts).toDateString()) firstDay.remove();
this.chatTop.after(...this.chatLines(older));
log.scrollTop = log.scrollHeight - before;
}
} catch (e) {
this.toast("error", e.message || String(e));
} finally {
this.loadingOlder = false;
this.renderChatTop();
}
// Still not enough to scroll: keep going.
requestAnimationFrame(() => {
if (this.chatLog.scrollHeight <= this.chatLog.clientHeight + 120) this.maybeLoadOlder();
});
}
chatLine(m) {
if (m.system) return h("div", { class: "rs-msg rs-msg-system" }, h("span", { class: "rs-time" }, clock(m.ts)), m.text);
const mine = m.from === this.client.self?.id;
return h("div", { class: `rs-msg ${mine ? "mine" : ""}` },
h("div", { class: "rs-msg-head" },
h("span", { class: "rs-msg-name", style: `color:${m.color}` }, m.name),
h("span", { class: "rs-time" }, clock(m.ts))),
h("div", { class: "rs-msg-text" }, m.text));
}
renderChatLog() {
this.chatLog.replaceChildren(this.chatTop, ...this.chatLines(this.client.chat));
this.renderChatTop();
this.scrollChat();
requestAnimationFrame(() => {
if (this.chatLog.isConnected && this.chatLog.scrollHeight <= this.chatLog.clientHeight + 120) this.maybeLoadOlder();
});
}
scrollChat() {
requestAnimationFrame(() => { this.chatLog.scrollTop = this.chatLog.scrollHeight; });
}
onChat(m) {
const atBottom = this.chatLog.scrollHeight - this.chatLog.scrollTop - this.chatLog.clientHeight < 40;
const chat = this.client.chat;
this.chatLog.append(...this.chatLines([m], chat[chat.length - 2]));
if (chat.length === 1) this.renderChatTop();
if (atBottom || m.from === this.client.self?.id) this.scrollChat();
const seen = this.visible && this.view === "chat" && !document.hidden;
if (!seen && !m.system && m.from !== this.client.self?.id) {
this.unread++;
this.onBadge?.(this.badge);
this.onNotify?.(m);
if (this.chatToasts && !this.visible) this.toast("info", m.text, m.name);
this.renderNav();
}
}
// ----- server ---------------------------------------------------------
// ----- 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;
const pop = h("button", {
class: `rs-btn ${floating ? "active" : ""}`, title: "Show a small floating monitor over the canvas",
onclick: () => this.widget.toggle(),
}, h("i", { class: "pi pi-window-maximize" }), floating ? "Floating: on" : "Pop out");
if (!st) return [this.section("pi-server", "Server", h("p", { class: "rs-muted" }, "Waiting for data…"), pop)];
const items = [];
if (st.cpu != null) items.push(meter("CPU", st.cpu, `${Math.round(st.cpu)}%`));
if (st.ram_total) items.push(meter("RAM", (st.ram_used / st.ram_total) * 100, `${st.ram_used} / ${st.ram_total} GB`));
const gpus = (st.gpus || []).map((g) => {
const extra = [g.temp != null ? `${g.temp}°C` : null, g.power != null ? `${g.power} / ${g.power_limit} W` : null].filter(Boolean).join(" · ");
return h("div", { class: "rs-gpu" },
h("div", { class: "rs-gpu-name" }, h("span", {}, `GPU ${g.index} · ${g.name}`), h("span", { class: "rs-muted" }, extra)),
meter("Load", g.util, `${g.util}%`),
meter("VRAM", (g.vram_used / g.vram_total) * 100, `${g.vram_used.toFixed(1)} / ${g.vram_total.toFixed(0)} GB`));
});
const q = st.queue;
const queue = q ? h("div", { class: "rs-queue-line" }, h("i", { class: "pi pi-list" }),
q.running || q.pending ? `${q.running} running · ${q.pending} queued` : "Queue idle") : null;
const perfOn = this.perfWidget.enabled;
const perfPop = h("button", {
class: `rs-btn ${perfOn ? "active" : ""}`, title: "Show the performance card over the canvas",
onclick: () => this.perfWidget.toggle(),
}, h("i", { class: "pi pi-window-maximize" }), perfOn ? "Floating: on" : "Pop out");
this.perfBody = h("div", { class: "rs-perf" }, ...perfView(st.performance, this.statsAt, this.perfWidget.workflow()));
const history = h("button", { class: "rs-btn", title: "Every recorded run", onclick: () => openHistory(this.client) },
h("i", { class: "pi pi-history" }), "History");
return [this.section("pi-stopwatch", "Performance", this.perfBody, h("div", { class: "rs-actions" }, history, perfPop)),
this.section("pi-server", "Server", queue, ...items, h("div", { class: "rs-actions" }, pop)),
gpus.length ? this.section("pi-bolt", "GPUs", ...gpus) : null].filter(Boolean);
}
// ----- account --------------------------------------------------------
accountView() {
const me = this.client.self;
const profile = this.section("pi-user", "Profile",
h("div", { class: "rs-me" },
h("span", { class: "rs-avatar rs-avatar-lg", style: `background:${me.color}` }, initials(me.name)),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name" }, me.name),
h("div", { class: "rs-muted rs-small" }, me.key ? `@${me.key}` : "guest"),
h("div", { class: "rs-chips" }, ...roleChips(me.perms)))),
me.kind === "user" ? this.profileForm(me) : null,
h("div", { class: "rs-actions" },
h("button", { class: "rs-btn", onclick: () => this.client.logout() }, h("i", { class: "pi pi-sign-out" }), "Log out")));
const out = [profile];
if (me.kind === "user") {
out.push(this.passwordSection());
this.keysEl ??= h("div", {});
out.push(this.section("pi-key", "API keys",
h("p", { class: "rs-muted rs-small" }, "Use a key to call the ComfyUI API from scripts or other apps with your permissions: ",
h("code", {}, "Authorization: Bearer <key>")),
this.newKey ? h("div", { class: "rs-newkey" },
h("div", { class: "rs-small" }, "Copy it now — it will not be shown again."),
h("div", { class: "rs-row" }, h("code", { class: "rs-grow rs-keytext" }, this.newKey),
h("button", { class: "rs-btn rs-btn-icon", title: "Copy", onclick: async () => { await navigator.clipboard?.writeText(this.newKey); this.toast("success", "Copied"); } }, h("i", { class: "pi pi-copy" })))) : null,
this.keysEl, this.keyForm()));
this.loadKeys();
}
return out;
}
profileForm(me) {
const display = h("input", { class: "rs-input", value: me.name, maxLength: 32 });
return h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
await this.run(() => this.client.request("POST", "/rigshare/api/me", { display_name: display.value.trim() }), "Display name saved");
},
}, h("label", { class: "rs-label" }, "Display name"),
h("div", { class: "rs-row" }, display, h("button", { class: "rs-btn", type: "submit" }, "Save")));
}
passwordSection() {
const cur = h("input", { class: "rs-input", type: "password", placeholder: "Current password", autocomplete: "current-password" });
const next = h("input", { class: "rs-input", type: "password", placeholder: "New password (min 8)", autocomplete: "new-password" });
return this.section("pi-lock", "Password", h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
const res = await this.run(() => this.client.request("POST", "/rigshare/api/me",
{ current_password: cur.value, new_password: next.value }), "Password changed; other sessions were signed out");
if (res?.token) this.client.setToken(res.token);
if (res) { cur.value = ""; next.value = ""; }
},
}, cur, next, h("button", { class: "rs-btn", type: "submit" }, "Change password")));
}
async loadKeys() {
const keys = await this.run(() => this.client.request("GET", "/rigshare/api/me/keys"));
if (!keys) return;
this.keysEl.replaceChildren(...keys.map((k) => h("div", { class: "rs-row-item" },
h("i", { class: "pi pi-key rs-row-icon" }),
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, k.name),
h("div", { class: "rs-muted rs-small" }, `${k.hint} · ${k.last_used ? `used ${timeAgo(k.last_used * 1000)}` : "never used"}`)),
h("button", {
class: "rs-btn rs-btn-icon rs-danger", title: "Revoke", onclick: async () => {
if (!(await this.confirm("Revoke API key", `Revoke "${k.name}"? Apps using it stop working.`))) return;
await this.run(() => this.client.request("DELETE", `/rigshare/api/me/keys/${k.id}`), "Key revoked");
this.loadKeys();
},
}, h("i", { class: "pi pi-trash" })))));
}
keyForm() {
const name = h("input", { class: "rs-input", placeholder: "Key name, e.g. Open WebUI", maxLength: 40 });
return h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
const res = await this.run(() => this.client.request("POST", "/rigshare/api/me/keys", { name: name.value.trim() || "API key" }));
if (res) { this.newKey = res.key; this.renderBody(); }
},
}, h("div", { class: "rs-row" }, name, h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, h("i", { class: "pi pi-plus" }), "Create")));
}
// ----- admin ----------------------------------------------------------
adminView() {
this.adminAccounts ??= h("div", {});
this.adminSettings ??= h("div", {});
this.loadAdmin();
return [
this.section("pi-users", "Accounts", this.adminAccounts, this.createAccountForm()),
this.section("pi-cog", "Server settings", this.adminSettings),
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"))),
];
}
async loadAdmin() {
const [users, config] = await Promise.all([
this.run(() => this.client.request("GET", "/rigshare/api/users")),
this.run(() => this.client.request("GET", "/rigshare/api/config")),
]);
if (users) this.adminAccounts.replaceChildren(...users.map((u) => this.accountRow(u)));
if (config) this.adminSettings.replaceChildren(this.configForm(config));
}
accountRow(u) {
const me = u.username === this.client.self?.key;
const toggle = (perm, label) => u.perms.admin && perm !== "admin" ? h("button", {
class: "rs-toggle on locked", disabled: true, title: `Admins always have ${label}. Remove Admin to control it.`,
}, label) : h("button", {
class: `rs-toggle ${u.perms[perm] ? "on" : ""}`, title: `Toggle ${label}`,
onclick: async () => {
await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { perms: { [perm]: !u.perms[perm] } }));
this.loadAdmin();
},
}, label);
return h("div", { class: "rs-row-item" },
h("div", { class: "rs-grow rs-min0" },
h("div", { class: "rs-name rs-ellipsis" }, u.display_name, h("span", { class: "rs-muted" }, ` @${u.username}${me ? " (you)" : ""}`)),
h("div", { class: "rs-chips" }, toggle("edit", "Edit"), toggle("queue", "Queue"), toggle("manager", "Manager"), toggle("admin", "Admin"))),
h("button", {
class: "rs-btn rs-btn-icon", title: "Change display name", onclick: async () => {
const name = await this.prompt("Display name", `Name shown to others for @${u.username}${me ? " (you)" : ""}`, u.display_name);
if (name == null || name.trim() === u.display_name) return;
const ok = await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { display_name: name.trim() }), "Display name saved");
if (ok) this.loadAdmin();
},
}, h("i", { class: "pi pi-id-card" })),
h("button", {
class: "rs-btn rs-btn-icon", title: "Change login username", onclick: async () => {
const name = await this.prompt("Change username", `New login username for @${u.username}${me ? " (you)" : ""}. Letters, digits, _ . -`, u.username);
if (!name || name.trim() === u.username) return;
const ok = await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { username: name.trim() }), `Renamed to @${name.trim()}`);
if (ok) this.loadAdmin();
},
}, h("i", { class: "pi pi-pencil" })),
h("button", {
class: "rs-btn rs-btn-icon", title: "Set password", onclick: async () => {
const pw = await this.prompt("Set password", `New password for @${u.username} (min 8 characters)`);
if (!pw) return;
await this.run(() => this.client.request("PATCH", `/rigshare/api/users/${encodeURIComponent(u.username)}`, { password: pw }), "Password changed; their sessions were signed out");
},
}, h("i", { class: "pi pi-key" })),
me ? null : h("button", {
class: "rs-btn rs-btn-icon rs-danger", title: "Delete account", onclick: async () => {
if (!(await this.confirm("Delete account", `Delete @${u.username}? This cannot be undone.`))) return;
await this.run(() => this.client.request("DELETE", `/rigshare/api/users/${encodeURIComponent(u.username)}`), "Account deleted");
this.loadAdmin();
},
}, h("i", { class: "pi pi-trash" })));
}
createAccountForm() {
const username = h("input", { class: "rs-input", placeholder: "Username", autocomplete: "off" });
const display = h("input", { class: "rs-input", placeholder: "Display name (optional)", autocomplete: "off" });
const password = h("input", { class: "rs-input", placeholder: "Password (min 8)", type: "password", autocomplete: "new-password" });
const check = (label, checked) => { const box = h("input", { type: "checkbox", checked }); return [box, h("label", { class: "rs-check" }, box, label)]; };
const [edit, editL] = check("Edit", true);
const [queue, queueL] = check("Queue", true);
const [manager, managerL] = check("Manager", false);
const [admin, adminL] = check("Admin", false);
return h("details", { class: "rs-details" }, h("summary", {}, h("i", { class: "pi pi-user-plus" }), "New account"),
h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
const ok = await this.run(() => this.client.request("POST", "/rigshare/api/users", {
username: username.value.trim(), password: password.value, display_name: display.value.trim() || null,
perms: { edit: edit.checked, queue: queue.checked, manager: manager.checked, admin: admin.checked },
}), "Account created");
if (ok) { username.value = display.value = password.value = ""; this.loadAdmin(); }
},
}, username, display, password,
h("div", { class: "rs-row rs-wrap" }, editL, queueL, managerL, adminL),
h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, "Create account")));
}
configForm(cfg) {
const text = (value, placeholder) => h("input", { class: "rs-input", value: value ?? "", placeholder });
const box = (checked) => h("input", { type: "checkbox", checked: !!checked });
const f = {
name: text(cfg.server_name, "Server name"),
login: box(cfg.require_login),
guests: box(cfg.allow_guests),
broadcast: box(cfg.broadcast_execution),
protect: box(cfg.api_protection?.enabled),
hideAccount: box(cfg.hide_comfy_account),
hideFileTabs: box(cfg.hide_comfy_file_tabs ?? true),
managerTab: box(cfg.manager_tab),
hideApiTemplates: box(cfg.hide_api_templates),
trusted: text((cfg.api_protection?.trusted_ips || []).join(", "), "127.0.0.1, 172.17.0.0/16"),
interval: h("input", { class: "rs-input rs-num", type: "number", min: 0, value: Math.round((cfg.snapshot_interval_sec ?? 300) / 60) }),
keep: h("input", { class: "rs-input rs-num", type: "number", min: 1, value: cfg.snapshot_keep ?? 30 }),
};
const line = (input, label, hint) => h("label", { class: "rs-check rs-block" }, input,
h("span", {}, label, hint ? h("div", { class: "rs-muted rs-small" }, hint) : null));
return h("form", {
class: "rs-form", onkeydown: (e) => e.stopPropagation(),
onsubmit: async (e) => {
e.preventDefault();
const ok = await this.run(() => this.client.request("PATCH", "/rigshare/api/config", {
server_name: f.name.value.trim() || "RigShare",
require_login: f.login.checked,
allow_guests: f.guests.checked,
broadcast_execution: f.broadcast.checked,
hide_comfy_account: f.hideAccount.checked,
hide_comfy_file_tabs: f.hideFileTabs.checked,
manager_tab: f.managerTab.checked,
hide_api_templates: f.hideApiTemplates.checked,
api_protection: { enabled: f.protect.checked, trusted_ips: f.trusted.value.split(/[\s,]+/).filter(Boolean) },
snapshot_interval_sec: Math.max(0, +f.interval.value || 0) * 60,
snapshot_keep: Math.max(1, +f.keep.value || 30),
}), "Settings saved");
if (ok) { this.client.server.name = ok.server_name; this.renderHeader(); }
},
},
h("label", { class: "rs-label" }, "Server name"), f.name,
line(f.login, "Require login", "Everyone signs in before ComfyUI loads. API clients use API keys."),
line(f.guests, "Allow guests (only without required login)", "Visitors can watch and chat without an account"),
line(f.broadcast, "Share execution progress", "Everyone sees progress and previews, not only whoever queued"),
line(f.protect, "Enforce permissions on the ComfyUI API", "Blocks queueing, interrupting and uploads for users without permission"),
line(f.hideAccount, "Hide the Comfy.org account login", "Removes ComfyUI's own sign-in button and dialog (used for paid API nodes)"),
line(f.managerTab, "ComfyUI Manager in its own sidebar tab", "A Manager tab in the left bar replaces the top-bar button, for accounts with the Manager permission"),
line(f.hideFileTabs, "Hide other workflow browsers", "ComfyUI's Workflows and Apps tabs, and Pixaroma's Workflows panel: RigShare's Files tab replaces them and their shortcuts open Files"),
line(f.hideApiTemplates, "Hide API templates", "Removes templates that need paid API nodes from the template browser"),
h("label", { class: "rs-label" }, "Trusted IPs / networks (skip login and checks)"), f.trusted,
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Auto snapshot every (min, 0 = off)"), f.interval),
h("div", { class: "rs-row" }, h("label", { class: "rs-label rs-grow" }, "Automatic snapshots kept"), f.keep),
h("button", { class: "rs-btn rs-btn-primary", type: "submit" }, h("i", { class: "pi pi-save" }), "Save settings"));
}
}