feat: add persistent operator notification center

This commit is contained in:
rookiestar28
2026-03-19 23:19:41 +08:00
parent 1b104b170b
commit 0d7020211c
12 changed files with 912 additions and 39 deletions
+34
View File
@@ -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).
+85
View File
@@ -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");
});
});
+4
View File
@@ -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,
+195
View File
@@ -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();
+96 -24
View File
@@ -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);
}
}
+161
View File
@@ -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
View File
@@ -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);
}
/**
+123
View File
@@ -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);
+38 -12
View File
@@ -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"));
},
};
+72
View File
@@ -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",
}),
})
);
});
});