mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
feat: add persistent operator notification center
This commit is contained in:
@@ -52,6 +52,40 @@ interface BannerStatus {
|
||||
- **Display**: Simple DOM injection of `.moltbot-banner`.
|
||||
- **Limitations**: No connectivity state handling, no 'info'/'success' states, simplistic dedupe.
|
||||
|
||||
## 1.1 Notification Center (F66)
|
||||
|
||||
Persistent operator notifications are the durable counterpart to transient banners and toasts.
|
||||
|
||||
### Schema (TypeScript)
|
||||
|
||||
```typescript
|
||||
interface NotificationEntry {
|
||||
id: string;
|
||||
severity: BannerSeverity;
|
||||
message: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
count: number;
|
||||
acknowledged_at?: string | null;
|
||||
dismissed_at?: string | null;
|
||||
action?: {
|
||||
label: string;
|
||||
type: 'url' | 'tab' | 'action';
|
||||
payload: string;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### F66 Baseline
|
||||
|
||||
- Warning/error banners and selected operator toasts are mirrored into the in-app notification center.
|
||||
- Entries are deduplicated by source-specific keys and persisted in local storage across reloads.
|
||||
- `Dismiss` hides an entry from the active list without deleting the historical record from storage.
|
||||
- `Acknowledge` clears unread state while keeping the entry visible.
|
||||
- Sources with jump targets should attach a tab/action deep link so operators can navigate directly to the affected surface.
|
||||
|
||||
## 2. Context Actions (F51)
|
||||
|
||||
Defines quick actions available in the node context menu (via ComfyUI extension hooks).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { clickTab, mockComfyUiCore, waitForOpenClawReady } from "../utils/helpers.js";
|
||||
|
||||
function normalizeApiPath(pathname) {
|
||||
const stripped = pathname.startsWith("/api/") ? pathname.slice(4) : pathname;
|
||||
return stripped.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isPath(pathname, suffix) {
|
||||
const normalizedSuffix = String(suffix || "").replace(/\/+$/, "");
|
||||
const path = normalizeApiPath(pathname);
|
||||
return path === `/openclaw${normalizedSuffix}` || path === `/moltbot${normalizedSuffix}`;
|
||||
}
|
||||
|
||||
test.describe("Notification Center", () => {
|
||||
test("persists model-manager failures across reload until dismissed", async ({ page }) => {
|
||||
await mockComfyUiCore(page);
|
||||
|
||||
await page.route("**/models/search**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (!isPath(url.pathname, "/models/search")) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: false, error: "search_failed" }),
|
||||
});
|
||||
});
|
||||
|
||||
const okCollection = JSON.stringify({
|
||||
ok: true,
|
||||
tasks: [],
|
||||
installations: [],
|
||||
pagination: { limit: 100, offset: 0, total: 0 },
|
||||
filters: {},
|
||||
});
|
||||
|
||||
await page.route("**/models/downloads**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (!isPath(url.pathname, "/models/downloads")) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: okCollection,
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/models/installations**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (!isPath(url.pathname, "/models/installations")) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: okCollection,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("test-harness.html");
|
||||
await waitForOpenClawReady(page);
|
||||
await clickTab(page, "Model Manager");
|
||||
|
||||
const toggle = page.locator("#openclaw-notification-toggle");
|
||||
await expect(toggle.locator(".openclaw-notification-badge")).toHaveText("1");
|
||||
|
||||
await toggle.click();
|
||||
await expect(page.locator("#openclaw-notification-panel")).toContainText("search: search_failed");
|
||||
await expect(page.locator("#openclaw-notification-panel")).toContainText("Open Model Manager");
|
||||
|
||||
await page.reload();
|
||||
await waitForOpenClawReady(page);
|
||||
await page.locator("#openclaw-notification-toggle").click();
|
||||
await expect(page.locator("#openclaw-notification-panel")).toContainText("search: search_failed");
|
||||
|
||||
await page.getByRole("button", { name: "Dismiss" }).first().click();
|
||||
await expect(page.locator("#openclaw-notification-panel")).not.toContainText("search: search_failed");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ export const STORAGE_KEYS = Object.freeze({
|
||||
primary: "openclaw-active-tab",
|
||||
legacy: "moltbot-active-tab",
|
||||
}),
|
||||
notifications: Object.freeze({
|
||||
primary: "openclaw_notifications",
|
||||
legacy: null,
|
||||
}),
|
||||
remoteAdminToken: Object.freeze({
|
||||
primary: "openclaw_remote_admin_token",
|
||||
legacy: null,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { STORAGE_KEYS, getMirroredStorageValue, setMirroredStorageValue } from "./openclaw_compat.js";
|
||||
|
||||
const DEFAULT_LIMIT = 60;
|
||||
|
||||
function toIsoString(nowValue) {
|
||||
if (typeof nowValue === "number") {
|
||||
return new Date(nowValue).toISOString();
|
||||
}
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function safeLocalStorage() {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneAction(action) {
|
||||
if (!action || typeof action !== "object") return null;
|
||||
return {
|
||||
label: String(action.label || "").trim(),
|
||||
type: String(action.type || "").trim(),
|
||||
payload: String(action.payload || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntry(raw) {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const id = String(raw.id || "").trim();
|
||||
const message = String(raw.message || "").trim();
|
||||
if (!id || !message) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
source: String(raw.source || "system").trim() || "system",
|
||||
severity: String(raw.severity || "info").trim() || "info",
|
||||
message,
|
||||
dedupe_key: String(raw.dedupe_key || "").trim() || `${raw.source || "system"}:${message}`,
|
||||
created_at: String(raw.created_at || raw.updated_at || new Date().toISOString()),
|
||||
updated_at: String(raw.updated_at || raw.created_at || new Date().toISOString()),
|
||||
count: Math.max(1, Number.parseInt(raw.count, 10) || 1),
|
||||
acknowledged_at: raw.acknowledged_at ? String(raw.acknowledged_at) : null,
|
||||
dismissed_at: raw.dismissed_at ? String(raw.dismissed_at) : null,
|
||||
action: cloneAction(raw.action),
|
||||
metadata: raw.metadata && typeof raw.metadata === "object" ? { ...raw.metadata } : {},
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenClawNotifications {
|
||||
constructor(deps = {}) {
|
||||
this.storage = deps.storage || safeLocalStorage();
|
||||
this.storageKey = deps.storageKey || STORAGE_KEYS.local.notifications;
|
||||
this.now = deps.now || (() => Date.now());
|
||||
this.limit = deps.limit || DEFAULT_LIMIT;
|
||||
this.listeners = new Set();
|
||||
this.entries = this._load();
|
||||
}
|
||||
|
||||
_storageValue() {
|
||||
if (!this.storage || !this.storageKey) return null;
|
||||
return getMirroredStorageValue(this.storage, this.storageKey);
|
||||
}
|
||||
|
||||
_load() {
|
||||
const raw = this._storageValue();
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data
|
||||
.map((entry) => normalizeEntry(entry))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => String(right.updated_at).localeCompare(String(left.updated_at)))
|
||||
.slice(0, this.limit);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
_save() {
|
||||
if (!this.storage || !this.storageKey) return;
|
||||
setMirroredStorageValue(this.storage, this.storageKey, JSON.stringify(this.entries.slice(0, this.limit)));
|
||||
}
|
||||
|
||||
_emit() {
|
||||
const snapshot = this.getEntries({ includeDismissed: true });
|
||||
this.listeners.forEach((listener) => listener(snapshot));
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== "function") {
|
||||
return () => { };
|
||||
}
|
||||
this.listeners.add(listener);
|
||||
listener(this.getEntries({ includeDismissed: true }));
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getEntries(options = {}) {
|
||||
const includeDismissed = Boolean(options.includeDismissed);
|
||||
const limit = Math.max(1, Number.parseInt(options.limit, 10) || this.limit);
|
||||
return this.entries
|
||||
.filter((entry) => includeDismissed || !entry.dismissed_at)
|
||||
.slice(0, limit)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
action: cloneAction(entry.action),
|
||||
metadata: { ...entry.metadata },
|
||||
}));
|
||||
}
|
||||
|
||||
getUnreadCount() {
|
||||
return this.entries.filter((entry) => !entry.dismissed_at && !entry.acknowledged_at).length;
|
||||
}
|
||||
|
||||
notify(payload = {}) {
|
||||
const message = String(payload.message || "").trim();
|
||||
if (!message) return null;
|
||||
|
||||
const nowIso = toIsoString(this.now());
|
||||
const source = String(payload.source || "system").trim() || "system";
|
||||
const severity = String(payload.severity || payload.variant || "info").trim() || "info";
|
||||
const action = cloneAction(payload.action);
|
||||
const dedupeKey = String(payload.dedupeKey || payload.dedupe_key || `${source}:${severity}:${message}`).trim();
|
||||
const metadata = payload.metadata && typeof payload.metadata === "object" ? { ...payload.metadata } : {};
|
||||
|
||||
const existing = this.entries.find((entry) => entry.dedupe_key === dedupeKey && !entry.dismissed_at);
|
||||
if (existing) {
|
||||
existing.message = message;
|
||||
existing.source = source;
|
||||
existing.severity = severity;
|
||||
existing.updated_at = nowIso;
|
||||
existing.count = Math.max(1, Number(existing.count || 1) + 1);
|
||||
existing.acknowledged_at = null;
|
||||
existing.action = action;
|
||||
existing.metadata = metadata;
|
||||
} else {
|
||||
this.entries.unshift({
|
||||
id: String(payload.id || `ntf_${Math.random().toString(36).slice(2, 10)}`),
|
||||
source,
|
||||
severity,
|
||||
message,
|
||||
dedupe_key: dedupeKey,
|
||||
created_at: nowIso,
|
||||
updated_at: nowIso,
|
||||
count: 1,
|
||||
acknowledged_at: null,
|
||||
dismissed_at: null,
|
||||
action,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
this.entries = this.entries
|
||||
.sort((left, right) => String(right.updated_at).localeCompare(String(left.updated_at)))
|
||||
.slice(0, this.limit);
|
||||
this._save();
|
||||
this._emit();
|
||||
return this.entries[0];
|
||||
}
|
||||
|
||||
acknowledge(id) {
|
||||
const target = this.entries.find((entry) => entry.id === id && !entry.dismissed_at);
|
||||
if (!target || target.acknowledged_at) return null;
|
||||
target.acknowledged_at = toIsoString(this.now());
|
||||
target.updated_at = target.updated_at || target.acknowledged_at;
|
||||
this._save();
|
||||
this._emit();
|
||||
return { ...target };
|
||||
}
|
||||
|
||||
dismiss(id) {
|
||||
const target = this.entries.find((entry) => entry.id === id && !entry.dismissed_at);
|
||||
if (!target) return null;
|
||||
target.dismissed_at = toIsoString(this.now());
|
||||
if (!target.acknowledged_at) {
|
||||
target.acknowledged_at = target.dismissed_at;
|
||||
}
|
||||
this._save();
|
||||
this._emit();
|
||||
return { ...target };
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.entries = [];
|
||||
this._save();
|
||||
this._emit();
|
||||
}
|
||||
}
|
||||
|
||||
export const openclawNotifications = new OpenClawNotifications();
|
||||
@@ -37,7 +37,13 @@ export class QueueMonitor {
|
||||
handleEvent(data) {
|
||||
if (!this.isConnected) {
|
||||
this.isConnected = true;
|
||||
this.showBanner("success", "\u2705 OpenClaw Backend Connected", "connection_restored", 3000);
|
||||
this.showBanner({
|
||||
severity: "success",
|
||||
message: "\u2705 OpenClaw Backend Connected",
|
||||
id: "connection_restored",
|
||||
ttl_ms: 3000,
|
||||
source: "queue-monitor",
|
||||
});
|
||||
}
|
||||
|
||||
const type = data.event_type;
|
||||
@@ -45,13 +51,37 @@ export class QueueMonitor {
|
||||
|
||||
switch (type) {
|
||||
case "queued":
|
||||
this.showBanner("info", `\u23F3 Job ${pid} queued`, `job_${type}`, 2000);
|
||||
this.showBanner({
|
||||
severity: "info",
|
||||
message: `\u23F3 Job ${pid} queued`,
|
||||
id: `job_${type}`,
|
||||
ttl_ms: 2000,
|
||||
source: "queue-monitor",
|
||||
});
|
||||
break;
|
||||
case "running":
|
||||
this.showBanner("info", `\u25B6 Job ${pid} running...`, `job_${type}`, 5000);
|
||||
this.showBanner({
|
||||
severity: "info",
|
||||
message: `\u25B6 Job ${pid} running...`,
|
||||
id: `job_${type}`,
|
||||
ttl_ms: 5000,
|
||||
source: "queue-monitor",
|
||||
});
|
||||
break;
|
||||
case "failed":
|
||||
this.showBanner("error", `\u274C Job ${pid} failed`, `job_${type}`, 10000);
|
||||
this.showBanner({
|
||||
severity: "error",
|
||||
message: `\u274C Job ${pid} failed`,
|
||||
id: `job_${type}`,
|
||||
ttl_ms: 10000,
|
||||
source: "queue-monitor",
|
||||
persist: true,
|
||||
action: {
|
||||
label: "Open Jobs",
|
||||
type: "tab",
|
||||
payload: "job-monitor",
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "completed":
|
||||
break;
|
||||
@@ -61,7 +91,13 @@ export class QueueMonitor {
|
||||
handleConnectionError(err) {
|
||||
if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Backend Disconnected. Retrying...", "connection_lost");
|
||||
this.showBanner({
|
||||
severity: "error",
|
||||
message: "\u26A0\uFE0F Backend Disconnected. Retrying...",
|
||||
id: "connection_lost",
|
||||
source: "queue-monitor",
|
||||
persist: true,
|
||||
});
|
||||
}
|
||||
return err;
|
||||
}
|
||||
@@ -72,7 +108,13 @@ export class QueueMonitor {
|
||||
if (res.ok && res.data) {
|
||||
if (!this.isConnected) {
|
||||
this.isConnected = true;
|
||||
this.showBanner("success", "\u2705 Connection Restored", "connection_restored", 3000);
|
||||
this.showBanner({
|
||||
severity: "success",
|
||||
message: "\u2705 Connection Restored",
|
||||
id: "connection_restored",
|
||||
ttl_ms: 3000,
|
||||
source: "queue-monitor",
|
||||
});
|
||||
if (!this.es || this.es.readyState === 2) {
|
||||
this.connectSSE();
|
||||
}
|
||||
@@ -81,40 +123,70 @@ export class QueueMonitor {
|
||||
const stats = res.data.stats || {};
|
||||
const obs = stats.observability || {};
|
||||
if (obs.total_dropped > 0) {
|
||||
this.showBanner(
|
||||
"warning",
|
||||
`\u26A0\uFE0F High load: ${obs.total_dropped} events dropped.`,
|
||||
"backpressure"
|
||||
);
|
||||
this.showBanner({
|
||||
severity: "warning",
|
||||
message: `\u26A0\uFE0F High load: ${obs.total_dropped} events dropped.`,
|
||||
id: "backpressure",
|
||||
source: "queue-monitor",
|
||||
persist: true,
|
||||
action: {
|
||||
label: "Open Explorer",
|
||||
type: "tab",
|
||||
payload: "explorer",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Backend Unreachable", "health_check_failed");
|
||||
this.showBanner({
|
||||
severity: "error",
|
||||
message: "\u26A0\uFE0F Backend Unreachable",
|
||||
id: "health_check_failed",
|
||||
source: "queue-monitor",
|
||||
persist: true,
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
if (this.isConnected) {
|
||||
this.isConnected = false;
|
||||
this.showBanner("error", "\u26A0\uFE0F Connection Error", "health_check_exception");
|
||||
this.showBanner({
|
||||
severity: "error",
|
||||
message: "\u26A0\uFE0F Connection Error",
|
||||
id: "health_check_exception",
|
||||
source: "queue-monitor",
|
||||
persist: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showBanner(type, message, statusId, ttl = this.bannerTTL) {
|
||||
const payload = typeof type === "object"
|
||||
? {
|
||||
id: type.id || `monitor_${this.now()}`,
|
||||
severity: type.severity || "info",
|
||||
message: type.message || "",
|
||||
source: type.source || "QueueMonitor",
|
||||
ttl_ms: type.ttl_ms != null ? type.ttl_ms : this.bannerTTL,
|
||||
dismissible: type.dismissible !== false,
|
||||
action: type.action,
|
||||
persist: type.persist,
|
||||
}
|
||||
: {
|
||||
id: statusId || `monitor_${this.now()}`,
|
||||
severity: type,
|
||||
message,
|
||||
source: "QueueMonitor",
|
||||
ttl_ms: ttl,
|
||||
dismissible: true,
|
||||
};
|
||||
const now = this.now();
|
||||
if (this.lastStatusId === statusId && (now - this.lastBannerTime < ttl)) {
|
||||
if (this.lastStatusId === payload.id && (now - this.lastBannerTime < payload.ttl_ms)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastStatusId = statusId;
|
||||
this.lastStatusId = payload.id;
|
||||
this.lastBannerTime = now;
|
||||
|
||||
this.ui.showBanner({
|
||||
id: statusId || "monitor_" + now,
|
||||
severity: type,
|
||||
message,
|
||||
source: "QueueMonitor",
|
||||
ttl_ms: ttl,
|
||||
dismissible: true,
|
||||
});
|
||||
this.ui.showBanner(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { openclawApi } from "./openclaw_api.js";
|
||||
import { normalizeLegacyClassNames } from "./openclaw_utils.js";
|
||||
import { OpenClawActions } from "./openclaw_actions.js";
|
||||
import { QueueMonitor } from "./openclaw_queue_monitor.js";
|
||||
import { openclawNotifications } from "./openclaw_notifications.js";
|
||||
|
||||
export class OpenClawUI {
|
||||
constructor() {
|
||||
@@ -17,6 +18,13 @@ export class OpenClawUI {
|
||||
panel: null,
|
||||
content: null,
|
||||
};
|
||||
this.notificationsOpen = false;
|
||||
this.notificationNodes = null;
|
||||
this._notificationSnapshot = [];
|
||||
this._unsubscribeNotifications = openclawNotifications.subscribe((entries) => {
|
||||
this._notificationSnapshot = entries;
|
||||
this._renderNotificationSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +138,7 @@ export class OpenClawUI {
|
||||
repoLink.textContent = "View on GitHub";
|
||||
badges.appendChild(versionSpan);
|
||||
badges.appendChild(repoLink);
|
||||
badges.appendChild(this._buildNotificationToggle());
|
||||
|
||||
// Fetch version from health endpoint
|
||||
openclawApi.getHealth().then(res => {
|
||||
@@ -183,6 +192,7 @@ export class OpenClawUI {
|
||||
header.appendChild(title);
|
||||
header.appendChild(badges);
|
||||
container.appendChild(header);
|
||||
container.appendChild(this._buildNotificationPanel());
|
||||
|
||||
// 2. Tab Bar
|
||||
const tabBar = document.createElement("div");
|
||||
@@ -199,6 +209,140 @@ export class OpenClawUI {
|
||||
// Initialize Tabs
|
||||
tabManager.init(tabBar, contentArea);
|
||||
normalizeLegacyClassNames(container);
|
||||
this._renderNotificationSnapshot();
|
||||
}
|
||||
|
||||
_buildNotificationToggle() {
|
||||
const button = document.createElement("button");
|
||||
button.className = "openclaw-notification-toggle";
|
||||
button.id = "openclaw-notification-toggle";
|
||||
button.type = "button";
|
||||
button.innerHTML = `
|
||||
<span class="openclaw-notification-toggle-label">Alerts</span>
|
||||
<span class="openclaw-notification-badge" hidden>0</span>
|
||||
`;
|
||||
button.addEventListener("click", () => this.toggleNotifications());
|
||||
this.notificationNodes = this.notificationNodes || {};
|
||||
this.notificationNodes.toggle = button;
|
||||
this.notificationNodes.badge = button.querySelector(".openclaw-notification-badge");
|
||||
return button;
|
||||
}
|
||||
|
||||
_buildNotificationPanel() {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "openclaw-notification-panel";
|
||||
panel.id = "openclaw-notification-panel";
|
||||
panel.hidden = true;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="openclaw-notification-panel-header">
|
||||
<div>
|
||||
<div class="openclaw-notification-panel-title">Notification Center</div>
|
||||
<div class="openclaw-notification-panel-subtitle">Persistent operator alerts and actions</div>
|
||||
</div>
|
||||
<button type="button" class="openclaw-notification-close" aria-label="Close notification center">x</button>
|
||||
</div>
|
||||
<div class="openclaw-notification-list"></div>
|
||||
`;
|
||||
|
||||
panel.querySelector(".openclaw-notification-close").addEventListener("click", () => {
|
||||
this.notificationsOpen = false;
|
||||
this._renderNotificationSnapshot();
|
||||
});
|
||||
|
||||
panel.querySelector(".openclaw-notification-list").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-notification-action]");
|
||||
if (!button) return;
|
||||
const action = button.getAttribute("data-notification-action");
|
||||
const id = button.getAttribute("data-notification-id");
|
||||
if (!id) return;
|
||||
|
||||
if (action === "ack") {
|
||||
openclawNotifications.acknowledge(id);
|
||||
return;
|
||||
}
|
||||
if (action === "dismiss") {
|
||||
openclawNotifications.dismiss(id);
|
||||
return;
|
||||
}
|
||||
if (action === "open") {
|
||||
const entry = this._notificationSnapshot.find((item) => item.id === id);
|
||||
if (!entry?.action) return;
|
||||
openclawNotifications.acknowledge(id);
|
||||
this.handleAction(entry.action);
|
||||
}
|
||||
});
|
||||
|
||||
this.notificationNodes = this.notificationNodes || {};
|
||||
this.notificationNodes.panel = panel;
|
||||
this.notificationNodes.list = panel.querySelector(".openclaw-notification-list");
|
||||
return panel;
|
||||
}
|
||||
|
||||
toggleNotifications() {
|
||||
this.notificationsOpen = !this.notificationsOpen;
|
||||
this._renderNotificationSnapshot();
|
||||
}
|
||||
|
||||
_formatNotificationTime(value) {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
_renderNotificationSnapshot() {
|
||||
const badge = this.notificationNodes?.badge;
|
||||
const panel = this.notificationNodes?.panel;
|
||||
const list = this.notificationNodes?.list;
|
||||
if (!badge || !panel || !list) return;
|
||||
|
||||
const activeEntries = this._notificationSnapshot.filter((entry) => !entry.dismissed_at);
|
||||
const unreadCount = activeEntries.filter((entry) => !entry.acknowledged_at).length;
|
||||
badge.textContent = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
badge.hidden = unreadCount <= 0;
|
||||
panel.hidden = !this.notificationsOpen;
|
||||
|
||||
if (activeEntries.length === 0) {
|
||||
list.innerHTML = '<div class="openclaw-notification-empty">No active operator notifications.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = activeEntries.map((entry) => {
|
||||
const actionHtml = entry.action?.type && entry.action?.payload
|
||||
? `<button type="button" class="openclaw-btn openclaw-btn-sm" data-notification-action="open" data-notification-id="${entry.id}">${entry.action.label || "Open"}</button>`
|
||||
: "";
|
||||
const countHtml = entry.count > 1
|
||||
? `<span class="openclaw-notification-count">x${entry.count}</span>`
|
||||
: "";
|
||||
const ackLabel = entry.acknowledged_at ? "Acknowledged" : "Acknowledge";
|
||||
const ackDisabled = entry.acknowledged_at ? "disabled" : "";
|
||||
return `
|
||||
<div class="openclaw-notification-item openclaw-notification-${entry.severity}">
|
||||
<div class="openclaw-notification-meta">
|
||||
<span class="openclaw-notification-source">${entry.source}</span>
|
||||
<span class="openclaw-notification-time">${this._formatNotificationTime(entry.updated_at)}</span>
|
||||
</div>
|
||||
<div class="openclaw-notification-message">${entry.message}</div>
|
||||
<div class="openclaw-notification-footer">
|
||||
<div class="openclaw-notification-state">
|
||||
<span class="openclaw-notification-severity">${entry.severity}</span>
|
||||
${countHtml}
|
||||
</div>
|
||||
<div class="openclaw-notification-actions">
|
||||
${actionHtml}
|
||||
<button type="button" class="openclaw-btn openclaw-btn-sm" data-notification-action="ack" data-notification-id="${entry.id}" ${ackDisabled}>${ackLabel}</button>
|
||||
<button type="button" class="openclaw-btn openclaw-btn-sm openclaw-btn-danger" data-notification-action="dismiss" data-notification-id="${entry.id}">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,6 +453,23 @@ export class OpenClawUI {
|
||||
if (bannerEl.isConnected) bannerEl.remove();
|
||||
}, ttl_ms);
|
||||
}
|
||||
|
||||
const shouldPersist = banner.persist != null
|
||||
? Boolean(banner.persist)
|
||||
: severity === "warning" || severity === "error";
|
||||
if (shouldPersist) {
|
||||
openclawNotifications.notify({
|
||||
id: `banner_${id || severity}`,
|
||||
severity,
|
||||
message,
|
||||
source: banner.source || "banner",
|
||||
dedupeKey: `banner:${id || `${severity}:${message}`}`,
|
||||
action,
|
||||
metadata: {
|
||||
ttl_ms: ttl_ms || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleAction(action) {
|
||||
|
||||
+16
-3
@@ -2,6 +2,7 @@
|
||||
/**
|
||||
* Shared Utilities for Moltbot UI
|
||||
*/
|
||||
import { openclawNotifications } from "./openclaw_notifications.js";
|
||||
|
||||
/**
|
||||
* Simple DOM factory helper.
|
||||
@@ -74,9 +75,9 @@ export function normalizeLegacyClassNames(root) {
|
||||
/**
|
||||
* Lightweight toast helper for UI feedback.
|
||||
* @param {string} message
|
||||
* @param {"info"|"error"|"success"} variant
|
||||
* @param {"info"|"error"|"success"|"warning"} variant
|
||||
*/
|
||||
export function showToast(message, variant = "info") {
|
||||
export function showToast(message, variant = "info", options = {}) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `openclaw-toast moltbot-toast openclaw-toast-${variant} moltbot-toast-${variant}`;
|
||||
toast.textContent = message;
|
||||
@@ -91,9 +92,21 @@ export function showToast(message, variant = "info") {
|
||||
toast.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
|
||||
document.body.appendChild(toast);
|
||||
|
||||
const shouldPersist = options.persist != null ? Boolean(options.persist) : variant === "error";
|
||||
if (shouldPersist) {
|
||||
openclawNotifications.notify({
|
||||
severity: variant,
|
||||
message,
|
||||
source: options.source || "toast",
|
||||
dedupeKey: options.dedupeKey,
|
||||
action: options.action,
|
||||
metadata: options.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 2500);
|
||||
}, Number.isFinite(options.durationMs) ? options.durationMs : 2500);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,6 +82,129 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.openclaw-notification-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--openclaw-color-border);
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--openclaw-color-fg);
|
||||
cursor: pointer;
|
||||
font-size: var(--openclaw-font-sm);
|
||||
}
|
||||
|
||||
.openclaw-notification-toggle:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.openclaw-notification-badge {
|
||||
min-width: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(212, 68, 68, 0.95);
|
||||
color: #fff;
|
||||
font-size: var(--openclaw-font-xs);
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.openclaw-notification-panel {
|
||||
border-bottom: 1px solid var(--openclaw-color-border);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: var(--openclaw-space-md) var(--openclaw-space-lg);
|
||||
}
|
||||
|
||||
.openclaw-notification-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--openclaw-space-md);
|
||||
margin-bottom: var(--openclaw-space-md);
|
||||
}
|
||||
|
||||
.openclaw-notification-panel-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.openclaw-notification-panel-subtitle {
|
||||
font-size: var(--openclaw-font-sm);
|
||||
color: var(--openclaw-color-fg-muted);
|
||||
}
|
||||
|
||||
.openclaw-notification-close {
|
||||
border: 1px solid var(--openclaw-color-border);
|
||||
background: transparent;
|
||||
color: var(--openclaw-color-fg);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.openclaw-notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--openclaw-space-sm);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.openclaw-notification-item {
|
||||
border: 1px solid var(--openclaw-color-border);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
padding: var(--openclaw-space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.openclaw-notification-error { border-left-color: #d44; }
|
||||
.openclaw-notification-warning { border-left-color: #ea0; }
|
||||
.openclaw-notification-success { border-left-color: #2a2; }
|
||||
.openclaw-notification-info { border-left-color: #4da3ff; }
|
||||
|
||||
.openclaw-notification-meta,
|
||||
.openclaw-notification-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--openclaw-space-sm);
|
||||
}
|
||||
|
||||
.openclaw-notification-source,
|
||||
.openclaw-notification-time,
|
||||
.openclaw-notification-severity,
|
||||
.openclaw-notification-count {
|
||||
font-size: var(--openclaw-font-xs);
|
||||
color: var(--openclaw-color-fg-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.openclaw-notification-message {
|
||||
font-size: var(--openclaw-font-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.openclaw-notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.openclaw-notification-empty {
|
||||
border: 1px dashed var(--openclaw-color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--openclaw-color-fg-muted);
|
||||
font-size: var(--openclaw-font-sm);
|
||||
padding: var(--openclaw-space-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.openclaw-version {
|
||||
font-size: var(--openclaw-font-sm);
|
||||
color: var(--openclaw-color-fg-muted);
|
||||
|
||||
@@ -212,6 +212,22 @@ export const ModelManagerTab = {
|
||||
installations: [],
|
||||
pollingTimer: null,
|
||||
};
|
||||
const modelManagerAction = {
|
||||
label: "Open Model Manager",
|
||||
type: "tab",
|
||||
payload: "model-manager",
|
||||
};
|
||||
|
||||
const reportIssue = (message, dedupeKey) => {
|
||||
const text = String(message || "request_failed");
|
||||
showError(container, text);
|
||||
showToast(text, "error", {
|
||||
persist: true,
|
||||
source: "model-manager",
|
||||
dedupeKey,
|
||||
action: modelManagerAction,
|
||||
});
|
||||
};
|
||||
|
||||
const renderResults = () => {
|
||||
if (!Array.isArray(state.items) || state.items.length === 0) {
|
||||
@@ -312,7 +328,7 @@ export const ModelManagerTab = {
|
||||
await runOne(loadTasks, "tasks");
|
||||
await runOne(loadInstallations, "installations");
|
||||
if (failures.length) {
|
||||
showError(container, failures.join(" | "));
|
||||
reportIssue(failures.join(" | "), "model-manager:refresh");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -333,10 +349,15 @@ export const ModelManagerTab = {
|
||||
clearError(container);
|
||||
const res = await openclawApi.createModelDownloadTask(payload);
|
||||
if (!res.ok) {
|
||||
showError(container, `queue failed: ${res.error || "request_failed"}`);
|
||||
reportIssue(`queue failed: ${res.error || "request_failed"}`, "model-manager:queue");
|
||||
return;
|
||||
}
|
||||
showToast("Model download queued", "success");
|
||||
showToast("Model download queued", "success", {
|
||||
persist: true,
|
||||
source: "model-manager",
|
||||
dedupeKey: "model-manager:queue-success",
|
||||
action: modelManagerAction,
|
||||
});
|
||||
await loadTasks();
|
||||
};
|
||||
|
||||
@@ -344,7 +365,7 @@ export const ModelManagerTab = {
|
||||
clearError(container);
|
||||
const res = await openclawApi.cancelModelDownloadTask(taskId);
|
||||
if (!res.ok) {
|
||||
showError(container, `cancel failed: ${res.error || "request_failed"}`);
|
||||
reportIssue(`cancel failed: ${res.error || "request_failed"}`, "model-manager:cancel");
|
||||
return;
|
||||
}
|
||||
await loadTasks();
|
||||
@@ -358,27 +379,32 @@ export const ModelManagerTab = {
|
||||
};
|
||||
const res = await openclawApi.importDownloadedModel(payload);
|
||||
if (!res.ok) {
|
||||
showError(container, `import failed: ${res.error || "request_failed"}`);
|
||||
reportIssue(`import failed: ${res.error || "request_failed"}`, "model-manager:import");
|
||||
return;
|
||||
}
|
||||
showToast("Model imported", "success");
|
||||
showToast("Model imported", "success", {
|
||||
persist: true,
|
||||
source: "model-manager",
|
||||
dedupeKey: "model-manager:import-success",
|
||||
action: modelManagerAction,
|
||||
});
|
||||
await loadTasks();
|
||||
await loadInstallations();
|
||||
await loadSearch();
|
||||
};
|
||||
|
||||
ui.searchBtn.onclick = () => {
|
||||
loadSearch().catch((error) => showError(container, `search failed: ${error?.message || String(error)}`));
|
||||
loadSearch().catch((error) => reportIssue(`search failed: ${error?.message || String(error)}`, "model-manager:search"));
|
||||
};
|
||||
ui.refreshBtn.onclick = () => {
|
||||
refreshAll().catch((error) => showError(container, `refresh failed: ${error?.message || String(error)}`));
|
||||
refreshAll().catch((error) => reportIssue(`refresh failed: ${error?.message || String(error)}`, "model-manager:refresh"));
|
||||
};
|
||||
ui.results.onclick = (event) => {
|
||||
const btn = event.target.closest("button[data-action='queue']");
|
||||
if (!btn) return;
|
||||
const index = Number(btn.getAttribute("data-index"));
|
||||
if (!Number.isFinite(index)) return;
|
||||
queueModelFromIndex(index).catch((error) => showError(container, `queue failed: ${error?.message || String(error)}`));
|
||||
queueModelFromIndex(index).catch((error) => reportIssue(`queue failed: ${error?.message || String(error)}`, "model-manager:queue"));
|
||||
};
|
||||
ui.tasks.onclick = (event) => {
|
||||
const btn = event.target.closest("button[data-action]");
|
||||
@@ -387,9 +413,9 @@ export const ModelManagerTab = {
|
||||
const taskId = normalizeString(btn.getAttribute("data-task-id"));
|
||||
if (!taskId) return;
|
||||
if (action === "cancel-task") {
|
||||
cancelTask(taskId).catch((error) => showError(container, `cancel failed: ${error?.message || String(error)}`));
|
||||
cancelTask(taskId).catch((error) => reportIssue(`cancel failed: ${error?.message || String(error)}`, "model-manager:cancel"));
|
||||
} else if (action === "import-task") {
|
||||
importTask(taskId).catch((error) => showError(container, `import failed: ${error?.message || String(error)}`));
|
||||
importTask(taskId).catch((error) => reportIssue(`import failed: ${error?.message || String(error)}`, "model-manager:import"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -408,6 +434,6 @@ export const ModelManagerTab = {
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
refreshAll().catch((error) => showError(container, `initial load failed: ${error?.message || String(error)}`));
|
||||
refreshAll().catch((error) => reportIssue(`initial load failed: ${error?.message || String(error)}`, "model-manager:initial-load"));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { apiMock, utilsMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
searchModels: vi.fn(),
|
||||
listModelDownloadTasks: vi.fn(),
|
||||
listModelInstallations: vi.fn(),
|
||||
createModelDownloadTask: vi.fn(),
|
||||
cancelModelDownloadTask: vi.fn(),
|
||||
importDownloadedModel: vi.fn(),
|
||||
},
|
||||
utilsMock: {
|
||||
clearError: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../openclaw_api.js", () => ({
|
||||
openclawApi: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../openclaw_utils.js", () => utilsMock);
|
||||
|
||||
import { ModelManagerTab } from "../../tabs/model_manager_tab.js";
|
||||
|
||||
describe("model_manager_tab", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
Object.values(apiMock).forEach((fn) => fn.mockReset());
|
||||
Object.values(utilsMock).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
it("records persistent operator notifications when the initial search load fails", async () => {
|
||||
apiMock.searchModels.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "search_failed",
|
||||
});
|
||||
apiMock.listModelDownloadTasks.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { tasks: [] },
|
||||
});
|
||||
apiMock.listModelInstallations.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { installations: [] },
|
||||
});
|
||||
|
||||
const container = document.createElement("div");
|
||||
ModelManagerTab.render(container);
|
||||
await vi.waitFor(() => {
|
||||
expect(utilsMock.showError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(utilsMock.showError).toHaveBeenCalledWith(
|
||||
container,
|
||||
"search: search_failed"
|
||||
);
|
||||
expect(utilsMock.showToast).toHaveBeenCalledWith(
|
||||
"search: search_failed",
|
||||
"error",
|
||||
expect.objectContaining({
|
||||
persist: true,
|
||||
source: "model-manager",
|
||||
dedupeKey: "model-manager:refresh",
|
||||
action: expect.objectContaining({
|
||||
payload: "model-manager",
|
||||
type: "tab",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { OpenClawNotifications } from "../../openclaw_notifications.js";
|
||||
|
||||
describe("OpenClawNotifications", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("deduplicates active entries by dedupe key and increments the count", () => {
|
||||
let nowValue = Date.parse("2026-03-19T00:00:00Z");
|
||||
const store = new OpenClawNotifications({
|
||||
storage: localStorage,
|
||||
now: () => nowValue,
|
||||
});
|
||||
|
||||
store.notify({
|
||||
severity: "error",
|
||||
source: "model-manager",
|
||||
message: "search failed: search_failed",
|
||||
dedupeKey: "model-manager:search",
|
||||
});
|
||||
|
||||
nowValue += 1_000;
|
||||
store.notify({
|
||||
severity: "error",
|
||||
source: "model-manager",
|
||||
message: "search failed: search_failed",
|
||||
dedupeKey: "model-manager:search",
|
||||
});
|
||||
|
||||
const entries = store.getEntries();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].count).toBe(2);
|
||||
expect(entries[0].acknowledged_at).toBeNull();
|
||||
});
|
||||
|
||||
it("persists acknowledgement and dismissal state in local storage", () => {
|
||||
const store = new OpenClawNotifications({
|
||||
storage: localStorage,
|
||||
now: () => Date.parse("2026-03-19T00:00:00Z"),
|
||||
});
|
||||
|
||||
const entry = store.notify({
|
||||
severity: "warning",
|
||||
source: "queue-monitor",
|
||||
message: "High load: dropped events",
|
||||
dedupeKey: "queue-monitor:backpressure",
|
||||
});
|
||||
|
||||
store.acknowledge(entry.id);
|
||||
store.dismiss(entry.id);
|
||||
|
||||
const reloaded = new OpenClawNotifications({
|
||||
storage: localStorage,
|
||||
now: () => Date.parse("2026-03-19T00:00:01Z"),
|
||||
});
|
||||
|
||||
expect(reloaded.getEntries()).toHaveLength(0);
|
||||
expect(reloaded.getEntries({ includeDismissed: true })[0].dismissed_at).not.toBeNull();
|
||||
expect(reloaded.getEntries({ includeDismissed: true })[0].acknowledged_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -57,4 +57,29 @@ describe("QueueMonitor", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("emits persistent failed-job notifications with a job-monitor jump action", () => {
|
||||
const ui = { showBanner: vi.fn() };
|
||||
const monitor = new QueueMonitor(ui, {
|
||||
api: {},
|
||||
now: () => 1000,
|
||||
setIntervalRef: vi.fn(),
|
||||
});
|
||||
|
||||
monitor.handleEvent({
|
||||
event_type: "failed",
|
||||
prompt_id: "prompt-12345678",
|
||||
});
|
||||
|
||||
expect(ui.showBanner).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: "error",
|
||||
persist: true,
|
||||
action: expect.objectContaining({
|
||||
type: "tab",
|
||||
payload: "job-monitor",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user