Files
ComfyUI-OpenClaw/web/openclaw_ui.js
T

652 lines
22 KiB
JavaScript

/**
* F7: Moltbot UI Shell
* Manages the main sidebar layout: Header, Tabs, Content.
*/
import { tabManager } from "./openclaw_tabs.js";
import { ErrorBoundary } from "./ErrorBoundary.js";
import { moltbotApi } from "./openclaw_api.js";
export class MoltbotUI {
constructor() {
this.container = null;
this.boundary = new ErrorBoundary("MoltbotUI");
this.floating = {
panel: null,
content: null,
};
}
/**
* Mount the UI into a provided container (sidebar render target).
*/
mount(container) {
this.container = container;
// CRITICAL: Must run before render to prevent first-paint clipping in Splitter sidebar host.
this._enforceSidebarMinWidth(container);
this.boundary.run(container, () => this._render(container));
}
_enforceSidebarMinWidth(container) {
// IMPORTANT: Keep this value aligned with CSS .moltbot-sidebar-container/.side-bar-panel min-width.
const minWidthPx = 560;
const applyMinWidth = () => {
// CRITICAL: Sidebar width is controlled by ComfyUI SplitterPanel (.side-bar-panel),
// not by our inner root container. If we only set inner min-width, content gets clipped.
const sidePanel = container.closest(".side-bar-panel");
const splitterPanel = sidePanel || container.closest(".p-splitterpanel");
if (splitterPanel) {
splitterPanel.style.minWidth = `${minWidthPx}px`;
}
const sidebarContent = container.closest(".sidebar-content-container");
if (sidebarContent) {
sidebarContent.style.minWidth = `${minWidthPx}px`;
}
container.style.minWidth = `${minWidthPx}px`;
};
// Run once now, then again after mount/paint to handle late-attached sidebar wrappers.
applyMinWidth();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(applyMinWidth);
} else {
setTimeout(applyMinWidth, 0);
}
}
/**
* Legacy fallback: toggle a floating panel (must not touch document.body directly).
*/
toggleFloatingPanel() {
if (!this.floating.panel) {
const panel = document.createElement("div");
panel.className = "moltbot-floating-panel";
const close = document.createElement("button");
close.className = "moltbot-floating-close";
close.textContent = "\u00D7";
close.title = "Close";
close.addEventListener("click", () => {
panel.classList.remove("visible");
});
const content = document.createElement("div");
content.className = "moltbot-floating-content";
panel.appendChild(close);
panel.appendChild(content);
document.body.appendChild(panel);
this.floating.panel = panel;
this.floating.content = content;
}
const panel = this.floating.panel;
const content = this.floating.content;
const isVisible = panel.classList.contains("visible");
if (isVisible) {
panel.classList.remove("visible");
return;
}
panel.classList.add("visible");
this.mount(content);
}
_render(container) {
container.innerHTML = "";
container.className = "moltbot-sidebar-container";
// 1. Header
const header = document.createElement("div");
header.className = "moltbot-header";
const statusDot = document.createElement("div");
statusDot.className = "moltbot-status-dot ok";
statusDot.title = "System Status";
this.statusDot = statusDot;
const title = document.createElement("div");
title.className = "moltbot-title";
title.textContent = "OpenClaw";
// F9: About badges (version fetched from /openclaw/health; legacy /moltbot/health)
const badges = document.createElement("div");
badges.className = "moltbot-badges";
const versionSpan = document.createElement("span");
versionSpan.className = "moltbot-version";
versionSpan.textContent = "v...";
const repoLink = document.createElement("a");
repoLink.href = "https://github.com/rookiestar28/ComfyUI-OpenClaw";
repoLink.target = "_blank";
repoLink.className = "moltbot-repo-link";
repoLink.title = "View on GitHub";
repoLink.textContent = "View on GitHub";
badges.appendChild(versionSpan);
badges.appendChild(repoLink);
// Fetch version from health endpoint
moltbotApi.getHealth().then(res => {
if (res.ok && res.data) {
const data = res.data;
if (data.pack) {
versionSpan.textContent = `v${data.pack.version}`;
}
// S15: Check exposure
this.checkExposure(data?.access_policy);
// R87: Check Backpressure
const obs = data.stats?.observability;
if (obs && obs.total_dropped > 0) {
const dropCount = obs.total_dropped;
// Only warn if significant relative to capacity or distinct threshold
this.showBanner("warning", `\u26A0\uFE0F High load: ${dropCount} observability events dropped (Queue full). logs/traces might be incomplete.`);
}
} else {
versionSpan.textContent = "v?.?.?";
}
}).catch(() => {
versionSpan.textContent = "v?.?.?";
});
header.appendChild(statusDot);
header.appendChild(title);
header.appendChild(badges);
container.appendChild(header);
// 2. Tab Bar
const tabBar = document.createElement("div");
tabBar.className = "moltbot-tabs";
this.tabBar = tabBar;
container.appendChild(tabBar);
// 3. Content Area
const contentArea = document.createElement("div");
contentArea.className = "moltbot-content";
this.contentArea = contentArea;
container.appendChild(contentArea);
// Initialize Tabs
tabManager.init(tabBar, contentArea);
}
/**
* S15: Check if OpenClaw is exposed remotely and warn user.
*/
checkExposure(policy) {
if (!policy) return;
const isLocal = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
// Warn if not local
if (!isLocal) {
const isProtected = policy.observability === "token" && policy.token_configured;
if (!isProtected) {
// High risk: Remote + No Token
this.showBanner("warning", "\u26A0\uFE0F Remote access detected; logs/config are protected unless you explicitly enable token-based access.");
} else {
// Medium risk: Remote + Token (Just info)
// Optionally show nothing, or a small "Remote Access Secured" badge
// console.log("OpenClaw remote access secured by token.");
}
}
}
/**
* F49: Display a banner with id, severity, ttl, and optional action.
* @param {Object} banner - { id, severity, message, source, ttl_ms, dismissible, action }
*/
showBanner(banner) {
// If passed raw args (legacy compatibility)
if (arguments.length > 1 && typeof arguments[0] === "string") {
banner = {
severity: arguments[0],
message: arguments[1],
id: "legacy_" + Date.now(),
ttl_ms: 5000
};
}
const { id, severity, message, ttl_ms, action, dismissible = true } = banner;
// 1. Priority Check
// If an error is currently shown, don't replace with info/warning unless it's a new error
const currentBanner = this.container.querySelector(".moltbot-banner");
if (currentBanner) {
const currentSeverity = currentBanner.dataset.severity;
const isCurrentError = currentSeverity === "error";
const isNewError = severity === "error";
// If current is error and new is not, ignore new (unless current is stale? handled by TTL)
// Exception: update content if same ID
const sameId = currentBanner.dataset.id === id;
if (isCurrentError && !isNewError && !sameId) {
return; // Suppress lower priority
}
}
// 2. Clear existing timer
if (this._bannerTimer) {
clearTimeout(this._bannerTimer);
this._bannerTimer = null;
}
// 3. Render
let bannerEl = currentBanner; // Reuse or create
if (!bannerEl) {
bannerEl = document.createElement("div");
// Insert after header
const header = this.container.querySelector(".moltbot-header");
header.after(bannerEl);
}
bannerEl.className = `moltbot-banner moltbot-banner-${severity}`;
bannerEl.dataset.id = id;
bannerEl.dataset.severity = severity;
bannerEl.innerHTML = ""; // Clear content
// Message
const msgSpan = document.createElement("span");
msgSpan.textContent = message;
bannerEl.appendChild(msgSpan);
// Action Button
if (action) {
const btn = document.createElement("button");
btn.className = "moltbot-banner-action";
btn.textContent = action.label;
btn.addEventListener("click", () => this.handleAction(action));
bannerEl.appendChild(btn);
}
// Dismiss Button
if (dismissible) {
const close = document.createElement("button");
close.className = "moltbot-banner-close";
close.textContent = "\u00D7";
close.addEventListener("click", () => {
bannerEl.remove();
if (this._bannerTimer) clearTimeout(this._bannerTimer);
});
bannerEl.appendChild(close);
}
// 4. TTL / Auto-dismiss
if (ttl_ms > 0) {
this._bannerTimer = setTimeout(() => {
if (bannerEl.isConnected) bannerEl.remove();
}, ttl_ms);
}
}
handleAction(action) {
if (!action) return;
const run = () => {
switch (action.type) {
case "url":
window.open(action.payload, "_blank");
break;
case "tab":
tabManager.activateTab(action.payload);
break;
case "action":
// F51: Route through MoltbotActions
if (moltbotActions && moltbotActions.dispatch) {
moltbotActions.dispatch(action.payload);
} else {
console.log("Action triggered:", action.payload);
}
break;
}
};
// F51: Check if action requires confirmation (heuristic or explicit)
// For now, only explicit 'confirm' property in action banner handles this,
// OR if the action type itself implies mutation.
// But Banner actions are usually just navigation.
// Use showConfirm if the banner action metadata says so?
// Let's assume standard banner actions are safe unless specified.
run();
}
/**
* F51: Glassmorphism Confirmation Modal.
* @param {Object} options - { title, message, fatal, onConfirm }
*/
showConfirm({ title, message, fatal = false, onConfirm }) {
// Create modal overlay
const overlay = document.createElement("div");
overlay.className = "moltbot-modal-overlay";
const modal = document.createElement("div");
modal.className = `moltbot-modal ${fatal ? "fatal" : ""}`;
const h3 = document.createElement("h3");
h3.textContent = title || "Confirm Action";
const p = document.createElement("p");
p.textContent = message || "Are you sure?";
const buttons = document.createElement("div");
buttons.className = "moltbot-modal-buttons";
const cancelBtn = document.createElement("button");
cancelBtn.className = "moltbot-btn secondary";
cancelBtn.textContent = "Cancel";
cancelBtn.onclick = () => overlay.remove();
const confirmBtn = document.createElement("button");
confirmBtn.className = `moltbot-btn ${fatal ? "danger" : "primary"}`;
confirmBtn.textContent = "Confirm";
confirmBtn.onclick = () => {
overlay.remove();
if (onConfirm) onConfirm();
};
buttons.appendChild(cancelBtn);
buttons.appendChild(confirmBtn);
modal.appendChild(h3);
modal.appendChild(p);
modal.appendChild(buttons);
overlay.appendChild(modal);
this.container.appendChild(overlay);
}
}
/**
* F48/F49: Queue Lifecycle Monitor.
* Consumes R71 events (SSE) with polling fallback to show deduplicated status banners.
* Handles disconnected state and recovery based on B-Strict/B-Loose contracts.
*/
class QueueMonitor {
constructor(ui) {
this.ui = ui;
this.lastBannerTime = 0;
this.lastStatusId = null;
this.bannerTTL = 5000; // 5s debounce for transient statuses
this.es = null;
this.isConnected = true; // Assume connected initially
}
start() {
// 1. Start SSE subscription
this.connectSSE();
// 2. Poll health periodically (backup & backpressure check)
setInterval(() => this.checkHealth(), 10000);
}
connectSSE() {
if (this.es) {
this.es.close();
}
this.es = moltbotApi.subscribeEvents(
(data) => this.handleEvent(data),
(err) => this.handleConnectionError(err)
);
}
handleEvent(data) {
// Recovered connection if we get an event
if (!this.isConnected) {
this.isConnected = true;
this.showBanner("success", "\u2705 OpenClaw Backend Connected", "connection_restored", 3000);
}
const type = data.event_type;
const pid = data.prompt_id ? data.prompt_id.slice(0, 8) : "???";
switch (type) {
case "queued":
this.showBanner("info", `\u23F3 Job ${pid} queued`, `job_${type}`, 2000);
break;
case "running":
this.showBanner("info", `\u25B6 Job ${pid} running...`, `job_${type}`, 5000);
break;
case "failed":
this.showBanner("error", `\u274C Job ${pid} failed`, `job_${type}`, 10000);
break;
case "completed":
// Optional: distinct success banner or silent
// this.showBanner("success", `\u2705 Job ${pid} completed`, `job_${type}`, 3000);
break;
}
}
handleConnectionError(err) {
// EventSource will retry automatically, but we flag UI state
// Only show disconnected if it persists (debounce?)
// For now, strict feedback:
if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Backend Disconnected. Retrying...", "connection_lost");
}
}
async checkHealth() {
try {
const res = await moltbotApi.getHealth();
if (res.ok && res.data) {
if (!this.isConnected) {
this.isConnected = true;
this.showBanner("success", "\u2705 Connection Restored", "connection_restored", 3000);
// Reconnect SSE if it was closed or dead
if (!this.es || this.es.readyState === 2) {
this.connectSSE();
}
}
const stats = res.data.stats || {};
const obs = stats.observability || {};
// R87: Backpressure
if (obs.total_dropped > 0) {
this.showBanner(
"warning",
`\u26A0\uFE0F High load: ${obs.total_dropped} events dropped.`,
"backpressure"
);
}
} else {
if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Backend Unreachable", "health_check_failed");
}
}
} catch (e) {
if (this.isConnected) {
this.isConnected = false;
this.showBanner("error", "\u26A0\uFE0F Connection Error", "health_check_exception");
}
}
}
showBanner(type, message, statusId, ttl = this.bannerTTL) {
const now = Date.now();
// Dedupe at Monitor level (still useful to avoid spamming UI)
if (this.lastStatusId === statusId && (now - this.lastBannerTime < ttl)) {
return;
}
// Update state
this.lastStatusId = statusId;
this.lastBannerTime = now;
// F49: Delegate to UI with full schema
this.ui.showBanner({
id: statusId || "monitor_" + now,
severity: type,
message: message,
source: "QueueMonitor",
ttl_ms: ttl,
dismissible: true
});
}
}
/**
* F51: Unified Action Router.
* Centralizes navigation and command logic for key operator tasks.
*/
export class MoltbotActions {
constructor(ui) {
this.ui = ui;
this.capabilities = null;
this._initPromise = this._fetchCapabilities();
}
async _fetchCapabilities() {
try {
const res = await moltbotApi.getCapabilities();
if (res.ok) {
this.capabilities = res.data;
}
} catch (e) {
console.warn("MoltbotActions: Failed to fetch capabilities", e);
}
}
/**
* F51: Universal dispatcher for string-based action IDs.
*/
dispatch(actionId, context = null) {
switch (actionId) {
case "doctor": this.openDoctor(); break;
case "queue": this.openQueue(); break;
case "settings": this.openSettings(); break;
case "inspect": this.openExplorer(); break;
default: console.warn("Unknown action:", actionId);
}
}
/**
* Check if an action is allowed/mutating.
*/
_checkAction(actionName) {
if (!this.capabilities || !this.capabilities.actions) return { enabled: true, mutating: false }; // fallback safe
return this.capabilities.actions[actionName] || { enabled: false, mutating: false };
}
async _runGuarded(actionName, fn) {
await this._initPromise;
const cap = this._checkAction(actionName);
if (!cap.enabled) {
this.ui.showBanner("warning", `Action '${actionName}' is disabled by policy.`);
return;
}
if (cap.mutating) {
this.ui.showConfirm({
title: "Confirm Action",
message: `This action (${actionName}) will modify system state. Proceed?`,
onConfirm: fn
});
} else {
fn();
}
}
/**
* Open Settings tab, optionally scrolling to a specific section.
*/
openSettings(section = "general") {
tabManager.activateTab("settings");
// Future: signal settings tab to scroll to section
}
/**
* Open Queue/Jobs view.
* Currently mapped to "Queue" or "Jobs" tab if it exists, or just sidebar.
* For MVP, we don't have a dedicated Jobs tab yet (it's part of Explorer or separate).
* We'll map to Explorer for now as it has "Jobs" sub-view concept in plan.
*/
openQueue(filter = "all") {
if (tabManager.tabs["job-monitor"]) {
tabManager.activateTab("job-monitor");
return;
}
tabManager.activateTab("explorer");
}
/**
* Run Doctor diagnostics.
* Opens Doctor view (in Explorer or Settings).
*/
async openDoctor() {
this._runGuarded("doctor", async () => {
await this._openDoctorImpl();
});
}
async _openDoctorImpl() {
tabManager.activateTab("settings");
try {
const res = await moltbotApi.fetch(moltbotApi._path("/security/doctor"));
if (res.ok && res.data) {
const issueCount = Array.isArray(res.data.issues)
? res.data.issues.length
: 0;
this.ui.showBanner(
issueCount > 0 ? "warning" : "success",
issueCount > 0
? `Doctor found ${issueCount} issues. See Settings for details.`
: "Doctor check passed."
);
return;
}
} catch (_err) {
// Capability fallback below.
}
this.ui.showBanner(
"info",
"Doctor diagnostics endpoint unavailable. Open Settings for manual checks."
);
}
/**
* Open Explorer, optionally filtering by node type.
*/
openExplorer(nodeType = null) {
tabManager.activateTab("explorer");
}
/**
* Open Parameter Lab for comparison.
* Sets the lab to Compare mode for the given node.
*/
openCompare(node = null) {
tabManager.activateTab("parameter-lab");
// F50: Signal Lab to init comparison for this node
// We'll rely on global accessible tab instance or event bus
// For now, let's assume tabManager can give us the instance if we need to call methods directly
// or we just open the tab and let the user set it up (MVP)
if (node) {
console.log("OpenClaw: Compare requested for", node.title || node.type);
// Dispatch after tab activation tick so listeners are ready.
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("moltbot:lab:compare", { detail: { node } })
);
}, 0);
}
}
}
export const moltbotUI = new MoltbotUI();
export const moltbotActions = new MoltbotActions(moltbotUI);
const monitor = new QueueMonitor(moltbotUI);
monitor.start();