F63/R138/R140: add frontend quality baseline and coverage

This commit is contained in:
rookiestar28
2026-03-05 02:12:09 +08:00
parent 807a78e76f
commit 450e978a9c
19 changed files with 4834 additions and 2267 deletions
+11
View File
@@ -91,6 +91,17 @@ Deployment profiles and hardening checklists:
<details>
<summary><strong>Frontend quality baseline for Library and Approvals surfaces</strong></summary>
- Canonicalized active frontend styling ownership around `openclaw-*`, including shell/tab-manager cleanup and a deterministic split of `web/openclaw.css` into core and legacy-alias modules.
- Added a frontend unit-test lane with Vitest + jsdom plus baseline coverage for shared UI helpers and extracted Library tab state logic.
- Expanded Playwright coverage for `Library` and `Approvals`, including success/degraded paths and approvals parity between the sidebar and the Remote Admin Console.
- Completed full verification gate pass (detect-secrets, pre-commit, backend unit suites, adversarial/retry/real-backend lanes, and frontend Playwright E2E).
</details>
<details>
<summary><strong>Audit event clarity and connector ingress fail-closed hardening</strong></summary>
- Normalized audit helper behavior so config/secret/LLM-test convenience wrappers now emit one canonical audit event per action, reducing duplicate noise while preserving legacy compatibility paths.
+2078 -1
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -5,10 +5,14 @@
"node": ">=18.0.0"
},
"devDependencies": {
"@playwright/test": "^1.50.0"
"@playwright/test": "^1.50.0",
"jsdom": "^26.0.0",
"vitest": "^3.2.4"
},
"scripts": {
"test": "node scripts/run-playwright.mjs",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:ui": "npx playwright test --ui",
"test:headed": "npx playwright test --headed",
"test:debug": "npx playwright test --debug",
+124
View File
@@ -0,0 +1,124 @@
import { expect, test } from '@playwright/test';
import { clickTab, mockComfyUiCore, waitForOpenClawReady } from '../utils/helpers.js';
const pendingApproval = {
approval_id: 'apr-001',
template_id: 'render_portrait',
status: 'pending',
requested_at: '2026-03-05T10:00:00Z',
source: 'telegram',
inputs: { prompt: 'portrait', style: 'studio' },
};
async function mockApprovalApis(page, { listStatus = 200, listData = [pendingApproval], approveStatus = 200 } = {}) {
let approvals = [...listData];
await page.route('**/openclaw/approvals**', async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === 'GET' && /\/approvals\/[^/]+$/.test(url.pathname)) {
const id = decodeURIComponent(url.pathname.split('/').pop());
const match = approvals.find((item) => item.approval_id === id);
await route.fulfill({
status: match ? 200 : 404,
contentType: 'application/json',
body: JSON.stringify(match ? { approval: match } : { error: 'not_found' }),
});
return;
}
if (request.method() === 'GET') {
await route.fulfill({
status: listStatus,
contentType: 'application/json',
body: JSON.stringify(
listStatus === 200 ? { approvals } : { error: 'approval_list_failed' }
),
});
return;
}
if (request.method() === 'POST' && url.pathname.endsWith('/approve')) {
if (approveStatus === 200) {
approvals = approvals.map((item) =>
item.approval_id === pendingApproval.approval_id
? { ...item, status: 'approved' }
: item
);
}
await route.fulfill({
status: approveStatus,
contentType: 'application/json',
body: JSON.stringify(
approveStatus === 200
? { executed: true, prompt_id: 'prompt-42' }
: { error: 'approve_failed' }
),
});
return;
}
if (request.method() === 'POST' && url.pathname.endsWith('/reject')) {
approvals = approvals.map((item) =>
item.approval_id === pendingApproval.approval_id
? { ...item, status: 'rejected' }
: item
);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
return;
}
await route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not_found' }) });
});
}
test.describe('Approvals surfaces', () => {
test.beforeEach(async ({ page }) => {
await mockComfyUiCore(page);
await page.addInitScript(() => {
window.confirm = () => true;
window.alert = () => {};
});
});
test('loads pending approvals and allows approve action', async ({ page }) => {
await mockApprovalApis(page);
await page.goto('test-harness.html');
await waitForOpenClawReady(page);
await clickTab(page, 'Approvals');
await expect(page.locator('#apr-list .openclaw-list-item')).toHaveCount(1);
await expect(page.locator('#apr-list')).toContainText('render_portrait');
await page.locator('#apr-list button[data-action="approve"]').click();
await expect(page.locator('#apr-list')).toContainText('APPROVED');
});
test('shows approval list fetch failures inside the sidebar', async ({ page }) => {
await mockApprovalApis(page, { listStatus: 500, listData: [] });
await page.goto('test-harness.html');
await waitForOpenClawReady(page);
await clickTab(page, 'Approvals');
await expect(page.locator('.openclaw-error-box')).toContainText('approval_list_failed');
});
test('keeps admin console pending approvals aligned with sidebar data', async ({ page }) => {
await mockApprovalApis(page);
await page.goto('test-harness.html');
await waitForOpenClawReady(page);
await clickTab(page, 'Approvals');
await expect(page.locator('#apr-list')).toContainText('apr-001');
await page.goto('http://127.0.0.1:3000/web/admin_console.html');
await page.locator('#refreshApprovals').click();
await expect(page.locator('#approvalsList')).toContainText('apr-001');
await expect(page.locator('#approvalsList')).toContainText('render_portrait');
});
});
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from '@playwright/test';
import { clickTab, mockComfyUiCore, waitForOpenClawReady } from '../utils/helpers.js';
const presets = [
{
id: 'prompt-1',
name: 'Portrait Prompt',
category: 'prompt',
content: { positive: 'portrait lighting', negative: 'blurry' },
},
{
id: 'params-1',
name: 'Landscape Params',
category: 'params',
content: { params: { width: 1280, height: 720, seed: 99 } },
},
];
test.describe('Library Tab', () => {
test.beforeEach(async ({ page }) => {
await mockComfyUiCore(page);
await page.addInitScript(() => {
window.confirm = () => true;
window.alert = () => {};
});
await page.route('**/openclaw/presets**', async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === 'GET' && /\/presets\/[^/]+$/.test(url.pathname)) {
const id = decodeURIComponent(url.pathname.split('/').pop());
const preset = presets.find((item) => item.id === id);
await route.fulfill({
status: preset ? 200 : 404,
contentType: 'application/json',
body: JSON.stringify(preset || { error: 'not_found' }),
});
return;
}
if (request.method() === 'GET') {
const category = url.searchParams.get('category');
const items = category ? presets.filter((item) => item.category === category) : presets;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(items),
});
return;
}
if (request.method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, id: 'new-preset' }),
});
return;
}
await route.fulfill({ status: 204, body: '' });
});
await page.route('**/openclaw/packs**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ packs: [] }),
});
});
await page.goto('test-harness.html');
await waitForOpenClawReady(page);
});
test('loads presets and applies prompt presets into Planner', async ({ page }) => {
await clickTab(page, 'Library');
await expect(page.locator('#lib-list .openclaw-list-item')).toHaveCount(2);
await expect(page.locator('#lib-list')).toContainText('Portrait Prompt');
await page.locator('#lib-list button[data-action="apply"]').first().click();
await expect(page.locator('#openclaw-tab-planner')).toHaveClass(/active/);
await expect(page.locator('#planner-out-pos')).toHaveValue('portrait lighting');
await expect(page.locator('#planner-out-neg')).toHaveValue('blurry');
});
test('filters presets and routes params presets into Variants', async ({ page }) => {
await clickTab(page, 'Library');
await page.locator('#lib-search').fill('landscape');
await expect(page.locator('#lib-list .openclaw-list-item')).toHaveCount(1);
await expect(page.locator('#lib-list')).toContainText('Landscape Params');
await page.locator('#lib-list button[data-action="apply"]').click();
await expect(page.locator('#openclaw-tab-variants')).toHaveClass(/active/);
await expect(page.locator('#var-base-params')).toHaveValue(/1280/);
await expect(page.locator('#var-base-params')).toHaveValue(/99/);
});
test('shows a deterministic error state when presets fail to load', async ({ page }) => {
await page.unroute('**/openclaw/presets**');
await page.route('**/openclaw/presets**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'preset_list_failed' }),
});
});
await page.goto('test-harness.html');
await waitForOpenClawReady(page);
await clickTab(page, 'Library');
await expect(page.locator('.openclaw-error-box')).toContainText('preset_list_failed');
});
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./web/tests/unit/setup.js"],
include: ["web/tests/unit/**/*.test.js"],
restoreMocks: true,
clearMocks: true,
},
});
+3 -1854
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -3,6 +3,7 @@
* Handles tab creation, switching, and lazy rendering.
*/
import { ErrorBoundary } from "./ErrorBoundary.js";
import { normalizeLegacyClassNames } from "./openclaw_utils.js";
export class TabManager {
constructor() {
@@ -49,12 +50,12 @@ export class TabManager {
this.tabs.forEach(tab => {
const btn = document.createElement("div");
btn.className = "openclaw-tab moltbot-tab";
btn.className = "openclaw-tab";
if (tab.icon) {
const icon = document.createElement("i");
icon.className = `openclaw-tab-icon moltbot-tab-icon ${tab.icon}`;
icon.className = `openclaw-tab-icon ${tab.icon}`;
const label = document.createElement("span");
label.className = "openclaw-tab-label moltbot-tab-label";
label.className = "openclaw-tab-label";
label.textContent = tab.title;
btn.appendChild(icon);
btn.appendChild(label);
@@ -70,10 +71,13 @@ export class TabManager {
if (!this.contentEl.querySelector(`#openclaw-tab-${tab.id}`)) {
const pane = document.createElement("div");
pane.id = `openclaw-tab-${tab.id}`;
pane.className = "openclaw-tab-pane moltbot-tab-pane";
pane.className = "openclaw-tab-pane";
this.contentEl.appendChild(pane);
}
});
normalizeLegacyClassNames(this.tabsEl);
normalizeLegacyClassNames(this.contentEl);
}
activateTab(id) {
@@ -111,6 +115,10 @@ export class TabManager {
});
tab.loaded = true;
}
if (pane) {
normalizeLegacyClassNames(pane);
}
}
_restoreActiveTab() {
+26 -23
View File
@@ -5,6 +5,7 @@
import { tabManager } from "./openclaw_tabs.js";
import { ErrorBoundary } from "./ErrorBoundary.js";
import { openclawApi } from "./openclaw_api.js";
import { normalizeLegacyClassNames } from "./openclaw_utils.js";
export class OpenClawUI {
constructor() {
@@ -27,7 +28,7 @@ export class OpenClawUI {
}
_enforceSidebarMinWidth(container) {
// IMPORTANT: Keep this value aligned with CSS .openclaw-sidebar-container/.moltbot-sidebar-container min-width.
// IMPORTANT: Keep this value aligned with CSS .openclaw-sidebar-container min-width.
const minWidthPx = 560;
const applyMinWidth = () => {
@@ -62,10 +63,10 @@ export class OpenClawUI {
toggleFloatingPanel() {
if (!this.floating.panel) {
const panel = document.createElement("div");
panel.className = "openclaw-floating-panel moltbot-floating-panel";
panel.className = "openclaw-floating-panel";
const close = document.createElement("button");
close.className = "openclaw-floating-close moltbot-floating-close";
close.className = "openclaw-floating-close";
close.textContent = "\u00D7";
close.title = "Close";
close.addEventListener("click", () => {
@@ -73,7 +74,7 @@ export class OpenClawUI {
});
const content = document.createElement("div");
content.className = "openclaw-floating-content moltbot-floating-content";
content.className = "openclaw-floating-content";
panel.appendChild(close);
panel.appendChild(content);
@@ -98,31 +99,31 @@ export class OpenClawUI {
_render(container) {
container.innerHTML = "";
container.className = "openclaw-sidebar-container moltbot-sidebar-container";
container.className = "openclaw-sidebar-container";
// 1. Header
const header = document.createElement("div");
header.className = "openclaw-header moltbot-header";
header.className = "openclaw-header";
const statusDot = document.createElement("div");
statusDot.className = "openclaw-status-dot moltbot-status-dot ok";
statusDot.className = "openclaw-status-dot ok";
statusDot.title = "System Status";
this.statusDot = statusDot;
const title = document.createElement("div");
title.className = "openclaw-title moltbot-title";
title.className = "openclaw-title";
title.textContent = "OpenClaw";
// F9: About badges (version fetched from /openclaw/health; legacy /moltbot/health)
const badges = document.createElement("div");
badges.className = "openclaw-badges moltbot-badges";
badges.className = "openclaw-badges";
const versionSpan = document.createElement("span");
versionSpan.className = "openclaw-version moltbot-version";
versionSpan.className = "openclaw-version";
versionSpan.textContent = "v...";
const repoLink = document.createElement("a");
repoLink.href = "https://github.com/rookiestar28/ComfyUI-OpenClaw";
repoLink.target = "_blank";
repoLink.className = "openclaw-repo-link moltbot-repo-link";
repoLink.className = "openclaw-repo-link";
repoLink.title = "View on GitHub";
repoLink.textContent = "View on GitHub";
badges.appendChild(versionSpan);
@@ -139,7 +140,7 @@ export class OpenClawUI {
// F55: Control plane mode indicator badge
const cpMode = data?.control_plane?.mode || data?.deployment_profile || "local";
const modeBadge = document.createElement("span");
modeBadge.className = `openclaw-mode-badge moltbot-mode-badge openclaw-mode-${cpMode} moltbot-mode-${cpMode}`;
modeBadge.className = `openclaw-mode-badge openclaw-mode-${cpMode}`;
modeBadge.textContent = cpMode.toUpperCase();
modeBadge.title = `Control plane: ${cpMode}`;
// Style inline for immediate visibility
@@ -183,18 +184,19 @@ export class OpenClawUI {
// 2. Tab Bar
const tabBar = document.createElement("div");
tabBar.className = "openclaw-tabs moltbot-tabs";
tabBar.className = "openclaw-tabs";
this.tabBar = tabBar;
container.appendChild(tabBar);
// 3. Content Area
const contentArea = document.createElement("div");
contentArea.className = "openclaw-content moltbot-content";
contentArea.className = "openclaw-content";
this.contentArea = contentArea;
container.appendChild(contentArea);
// Initialize Tabs
tabManager.init(tabBar, contentArea);
normalizeLegacyClassNames(container);
}
/**
@@ -268,7 +270,7 @@ export class OpenClawUI {
header.after(bannerEl);
}
bannerEl.className = `openclaw-banner moltbot-banner openclaw-banner-${severity} moltbot-banner-${severity}`;
bannerEl.className = `openclaw-banner openclaw-banner-${severity}`;
bannerEl.dataset.id = id;
bannerEl.dataset.severity = severity;
bannerEl.innerHTML = ""; // Clear content
@@ -281,7 +283,7 @@ export class OpenClawUI {
// Action Button
if (action) {
const btn = document.createElement("button");
btn.className = "openclaw-banner-action moltbot-banner-action";
btn.className = "openclaw-banner-action";
btn.textContent = action.label;
btn.addEventListener("click", () => this.handleAction(action));
bannerEl.appendChild(btn);
@@ -290,7 +292,7 @@ export class OpenClawUI {
// Dismiss Button
if (dismissible) {
const close = document.createElement("button");
close.className = "openclaw-banner-close moltbot-banner-close";
close.className = "openclaw-banner-close";
close.textContent = "\u00D7";
close.addEventListener("click", () => {
bannerEl.remove();
@@ -345,10 +347,10 @@ export class OpenClawUI {
showConfirm({ title, message, fatal = false, onConfirm }) {
// Create modal overlay
const overlay = document.createElement("div");
overlay.className = "openclaw-modal-overlay moltbot-modal-overlay";
overlay.className = "openclaw-modal-overlay";
const modal = document.createElement("div");
modal.className = `openclaw-modal moltbot-modal ${fatal ? "fatal" : ""}`;
modal.className = `openclaw-modal ${fatal ? "fatal" : ""}`;
const h3 = document.createElement("h3");
h3.textContent = title || "Confirm Action";
@@ -357,15 +359,15 @@ export class OpenClawUI {
p.textContent = message || "Are you sure?";
const buttons = document.createElement("div");
buttons.className = "openclaw-modal-buttons moltbot-modal-buttons";
buttons.className = "openclaw-modal-buttons";
const cancelBtn = document.createElement("button");
cancelBtn.className = "openclaw-btn moltbot-btn secondary";
cancelBtn.className = "openclaw-btn secondary";
cancelBtn.textContent = "Cancel";
cancelBtn.onclick = () => overlay.remove();
const confirmBtn = document.createElement("button");
confirmBtn.className = `openclaw-btn moltbot-btn ${fatal ? "danger" : "primary"}`;
confirmBtn.className = `openclaw-btn ${fatal ? "danger" : "primary"}`;
confirmBtn.textContent = "Confirm";
confirmBtn.onclick = () => {
overlay.remove();
@@ -381,6 +383,7 @@ export class OpenClawUI {
overlay.appendChild(modal);
this.container.appendChild(overlay);
normalizeLegacyClassNames(overlay);
}
}
@@ -574,7 +577,7 @@ export class OpenClawActions {
*/
_showBlockedToast(actionName, reason) {
const toast = document.createElement("div");
toast.className = "openclaw-blocked-toast moltbot-blocked-toast";
toast.className = "openclaw-blocked-toast";
toast.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 99999;
background: #1e1e2e; border: 1px solid #f59e0b;
+53
View File
@@ -18,6 +18,59 @@ export function makeEl(tag, className = "", text = "") {
return el;
}
/**
* F63: prefer canonical `openclaw-*` classes when both legacy and canonical
* variants are present on the same node.
*/
export function normalizeLegacyClassTokens(className = "") {
const tokens = String(className)
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
const canonical = new Set(
tokens.filter((token) => token.startsWith("openclaw-"))
);
const seen = new Set();
const normalized = [];
tokens.forEach((token) => {
if (token.startsWith("moltbot-")) {
const suffix = token.slice("moltbot-".length);
if (canonical.has(`openclaw-${suffix}`)) {
return;
}
}
if (seen.has(token)) {
return;
}
seen.add(token);
normalized.push(token);
});
return normalized.join(" ");
}
export function normalizeLegacyClassNames(root) {
if (!root) return root;
const nodes = [];
if (typeof root.className === "string") {
nodes.push(root);
}
if (typeof root.querySelectorAll === "function") {
nodes.push(...root.querySelectorAll("[class]"));
}
nodes.forEach((node) => {
if (typeof node.className !== "string") return;
const normalized = normalizeLegacyClassTokens(node.className);
if (normalized !== node.className) {
node.className = normalized;
}
});
return root;
}
/**
* Lightweight toast helper for UI feedback.
* @param {string} message
+926
View File
@@ -0,0 +1,926 @@
/* Moltbot Design System */
:root {
/* Spacing Scale */
--openclaw-space-xs: 4px;
--openclaw-space-sm: 8px;
--openclaw-space-md: 12px;
--openclaw-space-lg: 16px;
--openclaw-space-xl: 24px;
/* Typography */
--openclaw-font-xs: 11px;
--openclaw-font-sm: 12px;
--openclaw-font-md: 13px;
--openclaw-font-lg: 14px;
--openclaw-font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
/* Colors (inheriting from ComfyUI variable names where possible, fallbacks provided) */
--openclaw-color-bg: var(--comfy-menu-bg, #222);
--openclaw-color-bg-light: var(--comfy-input-bg, #333);
--openclaw-color-fg: var(--fg-color, #eee);
--openclaw-color-fg-muted: #aaa;
--openclaw-color-border: var(--border-color, #444);
--openclaw-color-primary: var(--primary-color, #2a2);
--openclaw-color-danger: #d44;
--openclaw-color-warning: #ea0;
--openclaw-color-success: #2a2;
}
/* Layout Primitives */
.openclaw-sidebar-container {
display: flex;
flex-direction: column;
height: 100%;
min-width: 560px; /* Keep sidebar readable: header tabs + Parameter Lab controls */
min-height: 0; /* allow children to shrink for overflow handling */
background: var(--openclaw-color-bg);
color: var(--openclaw-color-fg);
font-family: Arial, sans-serif;
font-size: var(--openclaw-font-md);
}
/* CRITICAL: Keep this rule + !important; SplitterPanel inline sizing can override inner container min-width. */
.side-bar-panel:has(.openclaw-sidebar-container) {
min-width: 560px !important;
}
.openclaw-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--openclaw-space-md);
padding: var(--openclaw-space-md) var(--openclaw-space-lg);
border-bottom: 1px solid var(--openclaw-color-border);
background: rgba(0,0,0,0.15);
}
.openclaw-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #666;
flex: 0 0 auto;
}
.openclaw-status-dot.ok { background: #2a2; }
.openclaw-status-dot.warn { background: #ea0; }
.openclaw-status-dot.err { background: #c44; }
.openclaw-title {
font-size: var(--openclaw-font-lg);
font-weight: 700;
flex: 1 1 auto;
min-width: 0;
}
.openclaw-badges {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--openclaw-space-sm);
flex: 0 0 auto;
}
.openclaw-version {
font-size: var(--openclaw-font-sm);
color: var(--openclaw-color-fg-muted);
}
.openclaw-repo-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--openclaw-color-border);
border-radius: 999px;
background: rgba(255,255,255,0.04);
color: var(--openclaw-color-fg);
text-decoration: none;
font-size: var(--openclaw-font-sm);
white-space: nowrap;
}
.openclaw-repo-link:hover { background: rgba(255,255,255,0.08); }
.openclaw-banner {
padding: var(--openclaw-space-sm) var(--openclaw-space-lg);
border-bottom: 1px solid var(--openclaw-color-border);
font-size: var(--openclaw-font-sm);
display: flex;
align-items: center;
gap: var(--openclaw-space-md);
justify-content: space-between;
}
.openclaw-banner > span { flex: 1; }
.openclaw-banner-warning { background: rgba(255, 170, 0, 0.10); color: #ffd27a; }
.openclaw-banner-info { background: rgba(0, 120, 255, 0.10); color: #a8d1ff; }
.openclaw-banner-success { background: rgba(42, 170, 42, 0.15); color: #bfffc0; }
.openclaw-banner-error { background: rgba(200, 50, 50, 0.15); color: #ffb4b4; }
.openclaw-banner-action {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: inherit;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
font-size: var(--openclaw-font-xs);
text-transform: uppercase;
font-weight: bold;
}
.openclaw-banner-action:hover { background: rgba(255,255,255,0.2); }
.openclaw-banner-close {
background: none;
border: none;
color: inherit;
opacity: 0.7;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.openclaw-banner-close:hover { opacity: 1; }
.openclaw-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); /* 4 tabs per row */
grid-auto-flow: row; /* left-to-right, then top-to-bottom */
gap: 6px;
padding: 8px var(--openclaw-space-lg);
border-bottom: 1px solid var(--openclaw-color-border);
background: rgba(0,0,0,0.10);
}
.openclaw-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
user-select: none;
color: var(--openclaw-color-fg-muted);
background: rgba(255,255,255,0.02);
font-size: var(--openclaw-font-sm);
line-height: 1;
width: 100%;
min-width: 0;
justify-content: center;
}
.openclaw-tab:hover {
color: var(--openclaw-color-fg);
border-color: rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04);
}
.openclaw-tab.active {
color: var(--openclaw-color-fg);
border-color: rgba(42, 170, 42, 0.5);
background: rgba(42, 170, 42, 0.15);
}
.openclaw-tab-icon {
font-size: 13px;
opacity: 0.9;
}
.openclaw-tab-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.openclaw-content {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.openclaw-tab-pane {
display: none;
height: 100%;
min-height: 0;
}
.openclaw-tab-pane.active {
display: block;
}
.openclaw-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
color: var(--openclaw-color-fg);
font-size: var(--openclaw-font-md);
background: var(--openclaw-color-bg);
}
.openclaw-scroll-area {
flex: 1;
overflow-y: auto;
padding: var(--openclaw-space-lg); /* More breathing room */
display: flex;
flex-direction: column;
gap: var(--openclaw-space-lg);
}
.openclaw-card {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--openclaw-color-border);
border-radius: 4px;
padding: var(--openclaw-space-md);
display: flex;
flex-direction: column;
gap: var(--openclaw-space-sm);
}
.openclaw-section-header {
font-size: var(--openclaw-font-sm);
text-transform: uppercase;
color: var(--openclaw-color-fg-muted);
letter-spacing: 0.05em;
margin-bottom: var(--openclaw-space-xs);
font-weight: bold;
border-bottom: 1px solid var(--openclaw-color-border);
padding-bottom: var(--openclaw-space-xs);
}
/* Grids & Splits */
.openclaw-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--openclaw-space-md);
}
.openclaw-split-v {
display: flex;
flex-direction: column;
gap: var(--openclaw-space-md);
}
.openclaw-split-h {
display: flex;
flex-direction: row;
gap: var(--openclaw-space-md);
align-items: center;
}
/* Form Elements */
.openclaw-input-group {
display: flex;
flex-direction: column;
gap: var(--openclaw-space-xs);
}
.openclaw-section {
background: rgba(0,0,0,0.18);
border: 1px solid var(--openclaw-color-border);
border-radius: 6px;
padding: var(--openclaw-space-lg);
display: flex;
flex-direction: column;
gap: var(--openclaw-space-md);
}
.openclaw-section > h4 {
margin: 0;
font-size: var(--openclaw-font-lg);
}
.openclaw-form-row {
display: flex;
flex-direction: column;
gap: var(--openclaw-space-xs);
}
.openclaw-btn-row {
display: flex;
flex-direction: row;
gap: var(--openclaw-space-sm);
flex-wrap: wrap;
}
.openclaw-note {
padding: var(--openclaw-space-sm) var(--openclaw-space-md);
border: 1px solid var(--openclaw-color-border);
border-radius: 4px;
background: rgba(255,255,255,0.03);
color: var(--openclaw-color-fg-muted);
font-size: var(--openclaw-font-sm);
line-height: 1.35;
}
.openclaw-status {
font-size: var(--openclaw-font-sm);
color: var(--openclaw-color-fg-muted);
min-height: 16px;
}
.openclaw-status.ok { color: #bfffc0; }
.openclaw-status.error { color: #ffb4b4; }
.openclaw-kv-row {
display: flex;
justify-content: space-between;
gap: var(--openclaw-space-md);
}
.openclaw-kv-key { color: var(--openclaw-color-fg-muted); }
.openclaw-kv-val { color: var(--openclaw-color-fg); }
.openclaw-kv-val.ok { color: #bfffc0; }
.openclaw-kv-val.error { color: #ffb4b4; }
.openclaw-log-viewer {
white-space: pre-wrap;
font-family: var(--openclaw-font-mono);
font-size: var(--openclaw-font-sm);
background: rgba(0,0,0,0.25);
border: 1px solid var(--openclaw-color-border);
border-radius: 4px;
padding: var(--openclaw-space-md);
max-height: 260px;
overflow: auto;
}
.openclaw-checkbox {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.openclaw-help-btn {
width: 22px;
height: 22px;
border-radius: 999px;
border: 1px solid var(--openclaw-color-border);
background: rgba(255,255,255,0.04);
color: var(--openclaw-color-fg);
cursor: pointer;
font-size: var(--openclaw-font-sm);
line-height: 1;
flex: 0 0 auto;
}
.openclaw-help-btn:hover {
background: rgba(255,255,255,0.08);
}
.openclaw-label {
font-size: var(--openclaw-font-sm);
color: var(--openclaw-color-fg-muted);
}
.openclaw-input, .openclaw-select, .openclaw-textarea {
background: var(--openclaw-color-bg-light);
border: 1px solid var(--openclaw-color-border);
color: var(--openclaw-color-fg);
padding: 6px 8px;
border-radius: 2px;
font-size: var(--openclaw-font-md);
width: 100%;
box-sizing: border-box;
}
.openclaw-input:focus, .openclaw-select:focus, .openclaw-textarea:focus {
border-color: var(--openclaw-color-primary);
outline: none;
}
.openclaw-textarea {
font-family: var(--openclaw-font-mono);
resize: vertical;
min-height: 60px;
}
.openclaw-textarea-sm { min-height: 80px; }
.openclaw-textarea-md { min-height: 150px; }
.openclaw-textarea-lg { min-height: 250px; }
.openclaw-textarea-xl { min-height: 400px; }
/* Buttons */
.openclaw-btn {
background: var(--openclaw-color-bg-light);
border: 1px solid var(--openclaw-color-border);
color: var(--openclaw-color-fg);
padding: 6px 12px;
cursor: pointer;
font-size: var(--openclaw-font-md);
border-radius: 2px;
text-align: center;
transition: background 0.1s;
}
.openclaw-btn:hover:not(:disabled) {
background: #444;
}
.openclaw-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.openclaw-btn-primary {
background: #2a2; /* Green-ish */
color: #fff;
border-color: #181;
}
.openclaw-btn-primary:hover:not(:disabled) { background: #3b3; }
.openclaw-btn-danger {
background: #a33;
color: #fff;
border-color: #822;
}
.openclaw-btn-danger:hover:not(:disabled) { background: #c44; }
.openclaw-btn-sm {
padding: 2px 6px;
font-size: var(--openclaw-font-sm);
}
.openclaw-btn-success {
background: rgba(42, 170, 42, 0.2);
border-color: rgba(42, 170, 42, 0.6);
color: #dfffe0;
}
/* Compatibility aliases used by existing tabs */
.openclaw-btn.primary {
background: rgba(42, 170, 42, 0.2);
border-color: rgba(42, 170, 42, 0.6);
color: #dfffe0;
}
.openclaw-btn.primary:hover:not(:disabled) {
background: rgba(42, 170, 42, 0.3);
}
.openclaw-btn.secondary {
background: rgba(255, 255, 255, 0.04);
border-color: var(--openclaw-color-border);
color: var(--openclaw-color-fg);
}
.openclaw-btn.has-icon {
display: inline-flex;
align-items: center;
gap: 6px;
}
.openclaw-btn-icon {
width: 26px;
height: 26px;
border: 1px solid var(--openclaw-color-border);
background: rgba(255, 255, 255, 0.04);
color: var(--openclaw-color-fg);
border-radius: 4px;
cursor: pointer;
font-size: var(--openclaw-font-sm);
line-height: 1;
}
.openclaw-btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
}
.openclaw-separator {
width: 1px;
height: 24px;
background: var(--openclaw-color-border);
opacity: 0.7;
}
.openclaw-form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.openclaw-form-group > label {
font-size: var(--openclaw-font-sm);
color: var(--openclaw-color-fg-muted);
}
.openclaw-form-group > input,
.openclaw-form-group > select,
.openclaw-form-group > textarea {
background: var(--openclaw-color-bg-light);
border: 1px solid var(--openclaw-color-border);
color: var(--openclaw-color-fg);
padding: 6px 8px;
border-radius: 4px;
font-size: var(--openclaw-font-md);
}
.openclaw-form-group > input:focus,
.openclaw-form-group > select:focus,
.openclaw-form-group > textarea:focus {
outline: none;
border-color: rgba(42, 170, 42, 0.7);
}
.openclaw-hint,
.openclaw-loading,
.openclaw-error {
padding: 10px 12px;
border-radius: 6px;
font-size: var(--openclaw-font-sm);
border: 1px solid var(--openclaw-color-border);
background: rgba(255, 255, 255, 0.03);
color: var(--openclaw-color-fg-muted);
}
.openclaw-loading {
color: #d2e4ff;
border-color: rgba(90, 150, 255, 0.45);
background: rgba(90, 150, 255, 0.12);
}
.openclaw-error {
color: #ffb4b4;
border-color: rgba(200, 60, 60, 0.6);
background: rgba(200, 60, 60, 0.18);
}
/* Status Indicators */
.openclaw-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: var(--openclaw-font-xs);
font-weight: bold;
text-transform: uppercase;
}
.openclaw-badge.pending { background: #aa0; color: #000; }
.openclaw-badge.approved { background: #2a2; color: #fff; }
.openclaw-badge.rejected { background: #c00; color: #fff; }
.openclaw-badge.expired { background: #666; color: #ccc; }
/* Error / Empty States */
.openclaw-empty-state {
padding: var(--openclaw-space-xl);
text-align: center;
color: var(--openclaw-color-fg-muted);
border: 1px dashed var(--openclaw-color-border);
border-radius: 4px;
background: rgba(255, 255, 255, 0.02);
}
.openclaw-error-box {
padding: var(--openclaw-space-md);
background: rgba(200, 50, 50, 0.1);
border: 1px solid #a33;
border-radius: 4px;
color: #eaa;
display: flex;
flex-direction: column;
gap: var(--openclaw-space-sm);
}
.openclaw-error-title {
font-weight: bold;
display: flex;
align-items: center;
gap: var(--openclaw-space-sm);
}
.openclaw-error-hint {
font-size: var(--openclaw-font-sm);
color: #ccc;
background: rgba(0,0,0,0.3);
padding: 4px;
border-radius: 2px;
}
/* Loading Spinner (Simple) */
.openclaw-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Modals */
.openclaw-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.openclaw-modal {
background: var(--openclaw-color-bg);
border: 1px solid var(--openclaw-color-border);
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
border-radius: 6px;
min-width: 400px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.openclaw-modal-header {
padding: var(--openclaw-space-md);
border-bottom: 1px solid var(--openclaw-color-border);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
font-size: var(--openclaw-font-lg);
}
.openclaw-modal-body {
padding: var(--openclaw-space-lg);
overflow-y: auto;
flex: 1;
}
.openclaw-modal-footer {
padding: var(--openclaw-space-md);
border-top: 1px solid var(--openclaw-color-border);
display: flex;
justify-content: flex-end;
gap: var(--openclaw-space-md);
background: rgba(0,0,0,0.1);
}
/* Parameter Lab */
.openclaw-lab-container {
display: flex;
flex-direction: column;
gap: var(--openclaw-space-md);
height: 100%;
min-height: 0;
padding: var(--openclaw-space-md);
box-sizing: border-box;
overflow: hidden;
}
.openclaw-lab-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--openclaw-space-md);
border: 1px solid var(--openclaw-color-border);
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: var(--openclaw-space-md);
}
.openclaw-lab-title-wrap {
flex: 1 1 100%;
min-width: 0;
}
.openclaw-lab-title-wrap > h3 {
margin: 0;
font-size: 20px;
line-height: 1.1;
word-break: normal;
overflow-wrap: normal;
}
.openclaw-lab-title-wrap > p {
margin: 4px 0 0;
color: var(--openclaw-color-fg-muted);
font-size: var(--openclaw-font-sm);
line-height: 1.35;
max-width: 60ch;
word-break: normal;
overflow-wrap: normal;
}
.openclaw-lab-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--openclaw-space-sm);
width: 100%;
margin: 0 auto;
align-items: stretch;
}
.openclaw-lab-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
width: 100%;
text-align: center;
overflow: hidden;
}
.openclaw-lab-actions .openclaw-btn.has-icon.openclaw-lab-action-btn {
justify-content: center;
}
.openclaw-lab-action-icon {
flex: 0 0 auto;
line-height: 1;
}
.openclaw-lab-action-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.openclaw-lab-action-btn.active {
border-color: rgba(42, 170, 42, 0.65);
background: rgba(42, 170, 42, 0.2);
color: #dfffe0;
}
.openclaw-lab-action-btn:focus {
outline: none;
}
.openclaw-lab-action-btn:focus-visible {
border-color: rgba(42, 170, 42, 0.75);
box-shadow: 0 0 0 1px rgba(42, 170, 42, 0.35) inset;
}
.openclaw-lab-actions .openclaw-separator {
display: none;
}
.openclaw-lab-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--openclaw-space-md);
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.openclaw-lab-card {
border: 1px solid var(--openclaw-color-border);
background: rgba(0, 0, 0, 0.16);
border-radius: 8px;
padding: var(--openclaw-space-md);
display: flex;
flex-direction: column;
gap: var(--openclaw-space-md);
}
.openclaw-lab-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--openclaw-space-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: var(--openclaw-space-sm);
}
.openclaw-lab-card-head > h4 {
margin: 0;
font-size: 15px;
}
.openclaw-lab-meta {
color: var(--openclaw-color-fg-muted);
font-size: var(--openclaw-font-sm);
}
.openclaw-lab-config,
.openclaw-lab-results {
display: flex;
flex-direction: column;
gap: var(--openclaw-space-sm);
}
.openclaw-lab-dim-row {
display: grid;
grid-template-columns: 84px 110px 1fr 32px;
gap: var(--openclaw-space-sm);
align-items: end;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
padding: var(--openclaw-space-sm);
}
.openclaw-lab-dim-row .openclaw-form-group.wide {
min-width: 0;
}
.openclaw-lab-plan-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--openclaw-space-sm);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
padding: var(--openclaw-space-sm);
}
.openclaw-lab-plan-header > h4 {
margin: 0;
font-size: var(--openclaw-font-md);
}
.openclaw-lab-run-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.openclaw-lab-run-item {
display: grid;
grid-template-columns: 72px 1fr auto 32px;
align-items: center;
gap: var(--openclaw-space-sm);
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
}
.run-idx {
font-family: var(--openclaw-font-mono);
color: var(--openclaw-color-fg-muted);
font-size: var(--openclaw-font-sm);
}
.run-params {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--openclaw-font-mono);
font-size: var(--openclaw-font-sm);
}
.run-status {
font-size: var(--openclaw-font-sm);
border-radius: 999px;
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--openclaw-color-fg-muted);
background: rgba(255, 255, 255, 0.05);
white-space: nowrap;
}
.run-status.pending,
.run-status.queued {
color: #ffe38f;
border-color: rgba(230, 180, 50, 0.55);
background: rgba(230, 180, 50, 0.14);
}
.run-status.running {
color: #a8d1ff;
border-color: rgba(90, 150, 255, 0.55);
background: rgba(90, 150, 255, 0.16);
}
.run-status.success,
.run-status.completed {
color: #bfffc0;
border-color: rgba(42, 170, 42, 0.55);
background: rgba(42, 170, 42, 0.16);
}
.run-status.error,
.run-status.failed {
color: #ffb4b4;
border-color: rgba(200, 60, 60, 0.6);
background: rgba(200, 60, 60, 0.16);
}
@media (max-width: 820px) {
.openclaw-lab-header {
flex-direction: column;
align-items: stretch;
}
.openclaw-lab-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.openclaw-lab-dim-row {
grid-template-columns: 1fr;
}
.openclaw-lab-run-item {
grid-template-columns: 1fr;
align-items: start;
}
}
@media (max-width: 720px) {
.openclaw-lab-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+928
View File
@@ -0,0 +1,928 @@
/*====================================
* LEGACY COMPATIBILITY
*====================================*/
/* Moltbot Design System */
:root {
/* Spacing Scale */
--moltbot-space-xs: 4px;
--moltbot-space-sm: 8px;
--moltbot-space-md: 12px;
--moltbot-space-lg: 16px;
--moltbot-space-xl: 24px;
/* Typography */
--moltbot-font-xs: 11px;
--moltbot-font-sm: 12px;
--moltbot-font-md: 13px;
--moltbot-font-lg: 14px;
--moltbot-font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
/* Colors (inheriting from ComfyUI variable names where possible, fallbacks provided) */
--moltbot-color-bg: var(--comfy-menu-bg, #222);
--moltbot-color-bg-light: var(--comfy-input-bg, #333);
--moltbot-color-fg: var(--fg-color, #eee);
--moltbot-color-fg-muted: #aaa;
--moltbot-color-border: var(--border-color, #444);
--moltbot-color-primary: var(--primary-color, #2a2);
--moltbot-color-danger: #d44;
--moltbot-color-warning: #ea0;
--moltbot-color-success: #2a2;
}
/* Layout Primitives */
.moltbot-sidebar-container {
display: flex;
flex-direction: column;
height: 100%;
min-width: 560px; /* Keep sidebar readable: header tabs + Parameter Lab controls */
min-height: 0; /* allow children to shrink for overflow handling */
background: var(--moltbot-color-bg);
color: var(--moltbot-color-fg);
font-family: Arial, sans-serif;
font-size: var(--moltbot-font-md);
}
/* CRITICAL: Keep this rule + !important; SplitterPanel inline sizing can override inner container min-width. */
.side-bar-panel:has(.moltbot-sidebar-container) {
min-width: 560px !important;
}
.moltbot-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--moltbot-space-md);
padding: var(--moltbot-space-md) var(--moltbot-space-lg);
border-bottom: 1px solid var(--moltbot-color-border);
background: rgba(0,0,0,0.15);
}
.moltbot-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #666;
flex: 0 0 auto;
}
.moltbot-status-dot.ok { background: #2a2; }
.moltbot-status-dot.warn { background: #ea0; }
.moltbot-status-dot.err { background: #c44; }
.moltbot-title {
font-size: var(--moltbot-font-lg);
font-weight: 700;
flex: 1 1 auto;
min-width: 0;
}
.moltbot-badges {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--moltbot-space-sm);
flex: 0 0 auto;
}
.moltbot-version {
font-size: var(--moltbot-font-sm);
color: var(--moltbot-color-fg-muted);
}
.moltbot-repo-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--moltbot-color-border);
border-radius: 999px;
background: rgba(255,255,255,0.04);
color: var(--moltbot-color-fg);
text-decoration: none;
font-size: var(--moltbot-font-sm);
white-space: nowrap;
}
.moltbot-repo-link:hover { background: rgba(255,255,255,0.08); }
.moltbot-banner {
padding: var(--moltbot-space-sm) var(--moltbot-space-lg);
border-bottom: 1px solid var(--moltbot-color-border);
font-size: var(--moltbot-font-sm);
display: flex;
align-items: center;
gap: var(--moltbot-space-md);
justify-content: space-between;
}
.moltbot-banner > span { flex: 1; }
.moltbot-banner-warning { background: rgba(255, 170, 0, 0.10); color: #ffd27a; }
.moltbot-banner-info { background: rgba(0, 120, 255, 0.10); color: #a8d1ff; }
.moltbot-banner-success { background: rgba(42, 170, 42, 0.15); color: #bfffc0; }
.moltbot-banner-error { background: rgba(200, 50, 50, 0.15); color: #ffb4b4; }
.moltbot-banner-action {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: inherit;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
font-size: var(--moltbot-font-xs);
text-transform: uppercase;
font-weight: bold;
}
.moltbot-banner-action:hover { background: rgba(255,255,255,0.2); }
.moltbot-banner-close {
background: none;
border: none;
color: inherit;
opacity: 0.7;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.moltbot-banner-close:hover { opacity: 1; }
.moltbot-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); /* 4 tabs per row */
grid-auto-flow: row; /* left-to-right, then top-to-bottom */
gap: 6px;
padding: 8px var(--moltbot-space-lg);
border-bottom: 1px solid var(--moltbot-color-border);
background: rgba(0,0,0,0.10);
}
.moltbot-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
user-select: none;
color: var(--moltbot-color-fg-muted);
background: rgba(255,255,255,0.02);
font-size: var(--moltbot-font-sm);
line-height: 1;
width: 100%;
min-width: 0;
justify-content: center;
}
.moltbot-tab:hover {
color: var(--moltbot-color-fg);
border-color: rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04);
}
.moltbot-tab.active {
color: var(--moltbot-color-fg);
border-color: rgba(42, 170, 42, 0.5);
background: rgba(42, 170, 42, 0.15);
}
.moltbot-tab-icon {
font-size: 13px;
opacity: 0.9;
}
.moltbot-tab-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.moltbot-content {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.moltbot-tab-pane {
display: none;
height: 100%;
min-height: 0;
}
.moltbot-tab-pane.active {
display: block;
}
.moltbot-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
color: var(--moltbot-color-fg);
font-size: var(--moltbot-font-md);
background: var(--moltbot-color-bg);
}
.moltbot-scroll-area {
flex: 1;
overflow-y: auto;
padding: var(--moltbot-space-lg); /* More breathing room */
display: flex;
flex-direction: column;
gap: var(--moltbot-space-lg);
}
.moltbot-card {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--moltbot-color-border);
border-radius: 4px;
padding: var(--moltbot-space-md);
display: flex;
flex-direction: column;
gap: var(--moltbot-space-sm);
}
.moltbot-section-header {
font-size: var(--moltbot-font-sm);
text-transform: uppercase;
color: var(--moltbot-color-fg-muted);
letter-spacing: 0.05em;
margin-bottom: var(--moltbot-space-xs);
font-weight: bold;
border-bottom: 1px solid var(--moltbot-color-border);
padding-bottom: var(--moltbot-space-xs);
}
/* Grids & Splits */
.moltbot-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--moltbot-space-md);
}
.moltbot-split-v {
display: flex;
flex-direction: column;
gap: var(--moltbot-space-md);
}
.moltbot-split-h {
display: flex;
flex-direction: row;
gap: var(--moltbot-space-md);
align-items: center;
}
/* Form Elements */
.moltbot-input-group {
display: flex;
flex-direction: column;
gap: var(--moltbot-space-xs);
}
.moltbot-section {
background: rgba(0,0,0,0.18);
border: 1px solid var(--moltbot-color-border);
border-radius: 6px;
padding: var(--moltbot-space-lg);
display: flex;
flex-direction: column;
gap: var(--moltbot-space-md);
}
.moltbot-section > h4 {
margin: 0;
font-size: var(--moltbot-font-lg);
}
.moltbot-form-row {
display: flex;
flex-direction: column;
gap: var(--moltbot-space-xs);
}
.moltbot-btn-row {
display: flex;
flex-direction: row;
gap: var(--moltbot-space-sm);
flex-wrap: wrap;
}
.moltbot-note {
padding: var(--moltbot-space-sm) var(--moltbot-space-md);
border: 1px solid var(--moltbot-color-border);
border-radius: 4px;
background: rgba(255,255,255,0.03);
color: var(--moltbot-color-fg-muted);
font-size: var(--moltbot-font-sm);
line-height: 1.35;
}
.moltbot-status {
font-size: var(--moltbot-font-sm);
color: var(--moltbot-color-fg-muted);
min-height: 16px;
}
.moltbot-status.ok { color: #bfffc0; }
.moltbot-status.error { color: #ffb4b4; }
.moltbot-kv-row {
display: flex;
justify-content: space-between;
gap: var(--moltbot-space-md);
}
.moltbot-kv-key { color: var(--moltbot-color-fg-muted); }
.moltbot-kv-val { color: var(--moltbot-color-fg); }
.moltbot-kv-val.ok { color: #bfffc0; }
.moltbot-kv-val.error { color: #ffb4b4; }
.moltbot-log-viewer {
white-space: pre-wrap;
font-family: var(--moltbot-font-mono);
font-size: var(--moltbot-font-sm);
background: rgba(0,0,0,0.25);
border: 1px solid var(--moltbot-color-border);
border-radius: 4px;
padding: var(--moltbot-space-md);
max-height: 260px;
overflow: auto;
}
.moltbot-checkbox {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.moltbot-help-btn {
width: 22px;
height: 22px;
border-radius: 999px;
border: 1px solid var(--moltbot-color-border);
background: rgba(255,255,255,0.04);
color: var(--moltbot-color-fg);
cursor: pointer;
font-size: var(--moltbot-font-sm);
line-height: 1;
flex: 0 0 auto;
}
.moltbot-help-btn:hover {
background: rgba(255,255,255,0.08);
}
.moltbot-label {
font-size: var(--moltbot-font-sm);
color: var(--moltbot-color-fg-muted);
}
.moltbot-input, .moltbot-select, .moltbot-textarea {
background: var(--moltbot-color-bg-light);
border: 1px solid var(--moltbot-color-border);
color: var(--moltbot-color-fg);
padding: 6px 8px;
border-radius: 2px;
font-size: var(--moltbot-font-md);
width: 100%;
box-sizing: border-box;
}
.moltbot-input:focus, .moltbot-select:focus, .moltbot-textarea:focus {
border-color: var(--moltbot-color-primary);
outline: none;
}
.moltbot-textarea {
font-family: var(--moltbot-font-mono);
resize: vertical;
min-height: 60px;
}
.moltbot-textarea-sm { min-height: 80px; }
.moltbot-textarea-md { min-height: 150px; }
.moltbot-textarea-lg { min-height: 250px; }
.moltbot-textarea-xl { min-height: 400px; }
/* Buttons */
.moltbot-btn {
background: var(--moltbot-color-bg-light);
border: 1px solid var(--moltbot-color-border);
color: var(--moltbot-color-fg);
padding: 6px 12px;
cursor: pointer;
font-size: var(--moltbot-font-md);
border-radius: 2px;
text-align: center;
transition: background 0.1s;
}
.moltbot-btn:hover:not(:disabled) {
background: #444;
}
.moltbot-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.moltbot-btn-primary {
background: #2a2; /* Green-ish */
color: #fff;
border-color: #181;
}
.moltbot-btn-primary:hover:not(:disabled) { background: #3b3; }
.moltbot-btn-danger {
background: #a33;
color: #fff;
border-color: #822;
}
.moltbot-btn-danger:hover:not(:disabled) { background: #c44; }
.moltbot-btn-sm {
padding: 2px 6px;
font-size: var(--moltbot-font-sm);
}
.moltbot-btn-success {
background: rgba(42, 170, 42, 0.2);
border-color: rgba(42, 170, 42, 0.6);
color: #dfffe0;
}
/* Compatibility aliases used by existing tabs */
.moltbot-btn.primary {
background: rgba(42, 170, 42, 0.2);
border-color: rgba(42, 170, 42, 0.6);
color: #dfffe0;
}
.moltbot-btn.primary:hover:not(:disabled) {
background: rgba(42, 170, 42, 0.3);
}
.moltbot-btn.secondary {
background: rgba(255, 255, 255, 0.04);
border-color: var(--moltbot-color-border);
color: var(--moltbot-color-fg);
}
.moltbot-btn.has-icon {
display: inline-flex;
align-items: center;
gap: 6px;
}
.moltbot-btn-icon {
width: 26px;
height: 26px;
border: 1px solid var(--moltbot-color-border);
background: rgba(255, 255, 255, 0.04);
color: var(--moltbot-color-fg);
border-radius: 4px;
cursor: pointer;
font-size: var(--moltbot-font-sm);
line-height: 1;
}
.moltbot-btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
}
.moltbot-separator {
width: 1px;
height: 24px;
background: var(--moltbot-color-border);
opacity: 0.7;
}
.moltbot-form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.moltbot-form-group > label {
font-size: var(--moltbot-font-sm);
color: var(--moltbot-color-fg-muted);
}
.moltbot-form-group > input,
.moltbot-form-group > select,
.moltbot-form-group > textarea {
background: var(--moltbot-color-bg-light);
border: 1px solid var(--moltbot-color-border);
color: var(--moltbot-color-fg);
padding: 6px 8px;
border-radius: 4px;
font-size: var(--moltbot-font-md);
}
.moltbot-form-group > input:focus,
.moltbot-form-group > select:focus,
.moltbot-form-group > textarea:focus {
outline: none;
border-color: rgba(42, 170, 42, 0.7);
}
.moltbot-hint,
.moltbot-loading,
.moltbot-error {
padding: 10px 12px;
border-radius: 6px;
font-size: var(--moltbot-font-sm);
border: 1px solid var(--moltbot-color-border);
background: rgba(255, 255, 255, 0.03);
color: var(--moltbot-color-fg-muted);
}
.moltbot-loading {
color: #d2e4ff;
border-color: rgba(90, 150, 255, 0.45);
background: rgba(90, 150, 255, 0.12);
}
.moltbot-error {
color: #ffb4b4;
border-color: rgba(200, 60, 60, 0.6);
background: rgba(200, 60, 60, 0.18);
}
/* Status Indicators */
.moltbot-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: var(--moltbot-font-xs);
font-weight: bold;
text-transform: uppercase;
}
.moltbot-badge.pending { background: #aa0; color: #000; }
.moltbot-badge.approved { background: #2a2; color: #fff; }
.moltbot-badge.rejected { background: #c00; color: #fff; }
.moltbot-badge.expired { background: #666; color: #ccc; }
/* Error / Empty States */
.moltbot-empty-state {
padding: var(--moltbot-space-xl);
text-align: center;
color: var(--moltbot-color-fg-muted);
border: 1px dashed var(--moltbot-color-border);
border-radius: 4px;
background: rgba(255, 255, 255, 0.02);
}
.moltbot-error-box {
padding: var(--moltbot-space-md);
background: rgba(200, 50, 50, 0.1);
border: 1px solid #a33;
border-radius: 4px;
color: #eaa;
display: flex;
flex-direction: column;
gap: var(--moltbot-space-sm);
}
.moltbot-error-title {
font-weight: bold;
display: flex;
align-items: center;
gap: var(--moltbot-space-sm);
}
.moltbot-error-hint {
font-size: var(--moltbot-font-sm);
color: #ccc;
background: rgba(0,0,0,0.3);
padding: 4px;
border-radius: 2px;
}
/* Loading Spinner (Simple) */
.moltbot-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Modals */
.moltbot-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.moltbot-modal {
background: var(--moltbot-color-bg);
border: 1px solid var(--moltbot-color-border);
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
border-radius: 6px;
min-width: 400px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.moltbot-modal-header {
padding: var(--moltbot-space-md);
border-bottom: 1px solid var(--moltbot-color-border);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
font-size: var(--moltbot-font-lg);
}
.moltbot-modal-body {
padding: var(--moltbot-space-lg);
overflow-y: auto;
flex: 1;
}
.moltbot-modal-footer {
padding: var(--moltbot-space-md);
border-top: 1px solid var(--moltbot-color-border);
display: flex;
justify-content: flex-end;
gap: var(--moltbot-space-md);
background: rgba(0,0,0,0.1);
}
/* Parameter Lab */
.moltbot-lab-container {
display: flex;
flex-direction: column;
gap: var(--moltbot-space-md);
height: 100%;
min-height: 0;
padding: var(--moltbot-space-md);
box-sizing: border-box;
overflow: hidden;
}
.moltbot-lab-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--moltbot-space-md);
border: 1px solid var(--moltbot-color-border);
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: var(--moltbot-space-md);
}
.moltbot-lab-title-wrap {
flex: 1 1 100%;
min-width: 0;
}
.moltbot-lab-title-wrap > h3 {
margin: 0;
font-size: 20px;
line-height: 1.1;
word-break: normal;
overflow-wrap: normal;
}
.moltbot-lab-title-wrap > p {
margin: 4px 0 0;
color: var(--moltbot-color-fg-muted);
font-size: var(--moltbot-font-sm);
line-height: 1.35;
max-width: 60ch;
word-break: normal;
overflow-wrap: normal;
}
.moltbot-lab-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--moltbot-space-sm);
width: 100%;
margin: 0 auto;
align-items: stretch;
}
.moltbot-lab-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
width: 100%;
text-align: center;
overflow: hidden;
}
.moltbot-lab-actions .moltbot-btn.has-icon.moltbot-lab-action-btn {
justify-content: center;
}
.moltbot-lab-action-icon {
flex: 0 0 auto;
line-height: 1;
}
.moltbot-lab-action-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.moltbot-lab-action-btn.active {
border-color: rgba(42, 170, 42, 0.65);
background: rgba(42, 170, 42, 0.2);
color: #dfffe0;
}
.moltbot-lab-action-btn:focus {
outline: none;
}
.moltbot-lab-action-btn:focus-visible {
border-color: rgba(42, 170, 42, 0.75);
box-shadow: 0 0 0 1px rgba(42, 170, 42, 0.35) inset;
}
.moltbot-lab-actions .moltbot-separator {
display: none;
}
.moltbot-lab-main {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--moltbot-space-md);
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.moltbot-lab-card {
border: 1px solid var(--moltbot-color-border);
background: rgba(0, 0, 0, 0.16);
border-radius: 8px;
padding: var(--moltbot-space-md);
display: flex;
flex-direction: column;
gap: var(--moltbot-space-md);
}
.moltbot-lab-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--moltbot-space-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: var(--moltbot-space-sm);
}
.moltbot-lab-card-head > h4 {
margin: 0;
font-size: 15px;
}
.moltbot-lab-meta {
color: var(--moltbot-color-fg-muted);
font-size: var(--moltbot-font-sm);
}
.moltbot-lab-config,
.moltbot-lab-results {
display: flex;
flex-direction: column;
gap: var(--moltbot-space-sm);
}
.moltbot-lab-dim-row {
display: grid;
grid-template-columns: 84px 110px 1fr 32px;
gap: var(--moltbot-space-sm);
align-items: end;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
padding: var(--moltbot-space-sm);
}
.moltbot-lab-dim-row .moltbot-form-group.wide {
min-width: 0;
}
.moltbot-lab-plan-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--moltbot-space-sm);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
padding: var(--moltbot-space-sm);
}
.moltbot-lab-plan-header > h4 {
margin: 0;
font-size: var(--moltbot-font-md);
}
.moltbot-lab-run-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.moltbot-lab-run-item {
display: grid;
grid-template-columns: 72px 1fr auto 32px;
align-items: center;
gap: var(--moltbot-space-sm);
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
}
.run-idx {
font-family: var(--moltbot-font-mono);
color: var(--moltbot-color-fg-muted);
font-size: var(--moltbot-font-sm);
}
.run-params {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--moltbot-font-mono);
font-size: var(--moltbot-font-sm);
}
.run-status {
font-size: var(--moltbot-font-sm);
border-radius: 999px;
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--moltbot-color-fg-muted);
background: rgba(255, 255, 255, 0.05);
white-space: nowrap;
}
.run-status.pending,
.run-status.queued {
color: #ffe38f;
border-color: rgba(230, 180, 50, 0.55);
background: rgba(230, 180, 50, 0.14);
}
.run-status.running {
color: #a8d1ff;
border-color: rgba(90, 150, 255, 0.55);
background: rgba(90, 150, 255, 0.16);
}
.run-status.success,
.run-status.completed {
color: #bfffc0;
border-color: rgba(42, 170, 42, 0.55);
background: rgba(42, 170, 42, 0.16);
}
.run-status.error,
.run-status.failed {
color: #ffb4b4;
border-color: rgba(200, 60, 60, 0.6);
background: rgba(200, 60, 60, 0.16);
}
@media (max-width: 820px) {
.moltbot-lab-header {
flex-direction: column;
align-items: stretch;
}
.moltbot-lab-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.moltbot-lab-dim-row {
grid-template-columns: 1fr;
}
.moltbot-lab-run-item {
grid-template-columns: 1fr;
align-items: start;
}
}
@media (max-width: 720px) {
.moltbot-lab-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+126 -152
View File
@@ -1,152 +1,139 @@
import { openclawApi } from "../openclaw_api.js";
import { showError, clearError } from "../openclaw_utils.js";
import {
showError,
clearError,
normalizeLegacyClassNames,
} from "../openclaw_utils.js";
// Helper for safe HTML escaping
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function stringifyVal(value) {
if (typeof value === "object" && value !== null) return "{...}";
return String(value);
}
function renderInputsSummary(inputs) {
if (!inputs) return "";
const keys = Object.keys(inputs);
if (keys.length === 0) return "No inputs";
const preview = keys.slice(0, 2).map((key) => `${key}: ${stringifyVal(inputs[key])}`);
if (keys.length > 2) preview.push(`+${keys.length - 2} more`);
return escapeHtml(preview.join(", "));
}
function renderApprovalItem(request) {
const statusClass = request.status;
const isPending = request.status === "pending";
return `
<div class="openclaw-list-item" style="padding: 10px; border-bottom: 1px solid var(--openclaw-color-border); margin-bottom: 4px; border-left: 3px solid var(--openclaw-color-border);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;">
<div>
<div style="font-weight: bold; font-size: var(--openclaw-font-md); color: var(--openclaw-color-fg);">
${escapeHtml(request.template_id)}
<span style="font-size: var(--openclaw-font-xs); font-weight: normal; color: var(--openclaw-color-fg-muted); margin-left:8px;">${escapeHtml(request.approval_id)}</span>
</div>
<div style="font-size: var(--openclaw-font-sm); color: var(--openclaw-color-fg-muted); margin-top: 4px;">
Inputs: <span style="color: #aaa;">${renderInputsSummary(request.inputs)}</span>
</div>
<div style="font-size: var(--openclaw-font-xs); color: #666; margin-top: 4px;">
Requested: ${new Date(request.requested_at).toLocaleString()}
${request.source ? `via ${escapeHtml(request.source)}` : ""}
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 5px;">
<span class="openclaw-badge ${statusClass}">${request.status.toUpperCase()}</span>
<div style="display: flex; gap: 5px; margin-top: 5px; flex-wrap: wrap; justify-content: flex-end;">
<button class="openclaw-btn openclaw-btn-sm" data-action="details" data-id="${request.approval_id}">Details</button>
${isPending ? `
<button class="openclaw-btn openclaw-btn-sm openclaw-btn-primary" data-action="approve" data-id="${request.approval_id}">Approve</button>
<button class="openclaw-btn openclaw-btn-sm openclaw-btn-danger" data-action="reject" data-id="${request.approval_id}">Reject</button>
` : ""}
</div>
</div>
</div>
</div>
`;
}
export const ApprovalsTab = {
id: "approvals",
title: "Approvals",
icon: "pi pi-check-circle",
render(container) {
// --- 1. Static Layout ---
container.innerHTML = `
<div class="openclaw-panel openclaw-panel moltbot-panel">
<div class="openclaw-card openclaw-card moltbot-card" style="border-radius:0; border:none; border-bottom:1px solid var(--moltbot-color-border);">
<div class="openclaw-section-header openclaw-section-header moltbot-section-header">Approval Requests</div>
<div class="openclaw-error-box openclaw-error-box moltbot-error-box" style="display:none"></div>
<div class="openclaw-toolbar openclaw-toolbar moltbot-toolbar" style="margin-top:5px; display:flex; gap:5px; align-items:center;" id="apr-toolbar">
<div id="apr-filter-btns" style="display: flex; gap: 5px;">
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-primary openclaw-btn-primary moltbot-btn-primary" data-status="pending">Pending</button>
<button class="openclaw-btn openclaw-btn moltbot-btn" data-status="approved">Approved</button>
<button class="openclaw-btn openclaw-btn moltbot-btn" data-status="rejected">Rejected</button>
<button class="openclaw-btn openclaw-btn moltbot-btn" data-status="">All</button>
<div class="openclaw-panel">
<div class="openclaw-card" style="border-radius:0; border:none; border-bottom:1px solid var(--openclaw-color-border);">
<div class="openclaw-section-header">Approval Requests</div>
<div class="openclaw-error-box" style="display:none"></div>
<div class="openclaw-toolbar" style="margin-top:5px; display:flex; gap:5px; align-items:center;" id="apr-toolbar">
<div id="apr-filter-btns" style="display: flex; gap: 5px; flex-wrap: wrap;">
<button class="openclaw-btn openclaw-btn-primary" data-status="pending">Pending</button>
<button class="openclaw-btn" data-status="approved">Approved</button>
<button class="openclaw-btn" data-status="rejected">Rejected</button>
<button class="openclaw-btn" data-status="">All</button>
</div>
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm" id="apr-refresh-btn" style="margin-left: auto;">
Refresh
</button>
<button class="openclaw-btn openclaw-btn-sm" id="apr-refresh-btn" style="margin-left: auto;">Refresh</button>
</div>
</div>
<div id="apr-list" class="openclaw-scroll-area openclaw-scroll-area moltbot-scroll-area" style="padding:0;">
<div class="openclaw-empty-state openclaw-empty-state moltbot-empty-state">Loading...</div>
<div id="apr-list" class="openclaw-scroll-area" style="padding:0;">
<div class="openclaw-empty-state">Loading...</div>
</div>
</div>
<!-- Details Modal -->
<div id="apr-editor-overlay" class="openclaw-modal-overlay openclaw-modal-overlay moltbot-modal-overlay" style="display:none;">
<div id="apr-details-modal" class="openclaw-modal openclaw-modal moltbot-modal" style="width: 600px;">
<div class="openclaw-modal-header openclaw-modal-header moltbot-modal-header">
<div id="apr-editor-overlay" class="openclaw-modal-overlay" style="display:none;">
<div id="apr-details-modal" class="openclaw-modal" style="width: 600px;">
<div class="openclaw-modal-header">
<span id="apr-modal-title">Request Details</span>
</div>
<div class="openclaw-modal-body openclaw-modal-body moltbot-modal-body">
<div style="font-family: var(--moltbot-font-mono); font-size: var(--moltbot-font-xs); background: #111; padding: 10px; border: 1px solid #333; height: 300px; overflow: auto; white-space: pre-wrap;" id="apr-modal-content"></div>
<div class="openclaw-modal-body">
<div style="font-family: var(--openclaw-font-mono); font-size: var(--openclaw-font-xs); background: #111; padding: 10px; border: 1px solid #333; height: 300px; overflow: auto; white-space: pre-wrap;" id="apr-modal-content"></div>
</div>
<div class="openclaw-modal-footer openclaw-modal-footer moltbot-modal-footer">
<button class="openclaw-btn openclaw-btn moltbot-btn" id="apr-modal-close">Close</button>
<div class="openclaw-modal-footer">
<button class="openclaw-btn" id="apr-modal-close">Close</button>
<div id="apr-modal-actions" style="display:flex; gap:10px;"></div>
</div>
</div>
</div>
`;
normalizeLegacyClassNames(container);
// --- 2. State & References ---
const ui = {
list: container.querySelector("#apr-list"),
filters: container.querySelector("#apr-filter-btns"),
refreshBtn: container.querySelector("#apr-refresh-btn"),
modal: {
overlay: container.querySelector("#apr-editor-overlay"),
el: container.querySelector("#apr-details-modal"),
title: container.querySelector("#apr-modal-title"),
content: container.querySelector("#apr-modal-content"),
close: container.querySelector("#apr-modal-close"),
actions: container.querySelector("#apr-modal-actions"),
}
},
};
let currentState = {
const currentState = {
status: "pending",
approvals: []
};
// --- 3. View Logic ---
const renderInputsSummary = (inputs) => {
if (!inputs) return "";
const keys = Object.keys(inputs);
if (keys.length === 0) return "No inputs";
// Show first 2 inputs
const firstTwo = keys.slice(0, 2).map(k => `${k}: ${stringifyVal(inputs[k])}`);
if (keys.length > 2) firstTwo.push(`+${keys.length - 2} more`);
return escapeHtml(firstTwo.join(", "));
};
const stringifyVal = (v) => {
if (typeof v === "object") return "{...}";
return String(v);
}
const renderListItem = (req) => {
// Mapping status to CSS classes is cleaner than inline colors
const statusClass = req.status; // pending, approved, rejected
const isPending = req.status === "pending";
return `
<div class="openclaw-list-item openclaw-list-item moltbot-list-item" style="padding: 10px; border-bottom: 1px solid var(--moltbot-color-border); margin-bottom: 4px; border-left: 3px solid var(--moltbot-color-border);">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<div style="font-weight: bold; font-size: var(--moltbot-font-md); color: var(--moltbot-color-fg);">
${escapeHtml(req.template_id)}
<span style="font-size: var(--moltbot-font-xs); font-weight: normal; color: var(--moltbot-color-fg-muted); margin-left:8px;">${escapeHtml(req.approval_id)}</span>
</div>
<div style="font-size: var(--moltbot-font-sm); color: var(--moltbot-color-fg-muted); margin-top: 4px;">
Inputs: <span style="color: #aaa;">${renderInputsSummary(req.inputs)}</span>
</div>
<div style="font-size: var(--moltbot-font-xs); color: #666; margin-top: 4px;">
Requested: ${new Date(req.requested_at).toLocaleString()}
${req.source ? `via ${escapeHtml(req.source)}` : ''}
</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 5px;">
<span class="openclaw-badge openclaw-badge moltbot-badge ${statusClass}">
${req.status.toUpperCase()}
</span>
<div style="display: flex; gap: 5px; margin-top: 5px;">
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm" data-action="details" data-id="${req.approval_id}">Details</button>
${isPending ? `
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm openclaw-btn-primary openclaw-btn-primary moltbot-btn-primary" data-action="approve" data-id="${req.approval_id}">Approve</button>
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm openclaw-btn-danger openclaw-btn-danger moltbot-btn-danger" data-action="reject" data-id="${req.approval_id}">Reject</button>
` : ''}
</div>
</div>
</div>
</div>
`;
approvals: [],
};
const renderList = () => {
if (currentState.approvals.length === 0) {
ui.list.innerHTML = '<div class="openclaw-empty-state openclaw-empty-state moltbot-empty-state">No requests found.</div>';
ui.list.innerHTML = '<div class="openclaw-empty-state">No requests found.</div>';
return;
}
ui.list.innerHTML = currentState.approvals.map(renderListItem).join("");
ui.list.innerHTML = currentState.approvals.map(renderApprovalItem).join("");
normalizeLegacyClassNames(ui.list);
};
// --- 4. Logic ---
const loadApprovals = async () => {
clearError(container);
ui.list.innerHTML = '<div style="padding: 10px; text-align: center;">Loading...</div>';
@@ -159,7 +146,7 @@ export const ApprovalsTab = {
currentState.approvals = res.data.approvals || [];
renderList();
} else {
ui.list.innerHTML = ''; // Clear loading
ui.list.innerHTML = "";
showError(container, res.error);
}
};
@@ -169,7 +156,7 @@ export const ApprovalsTab = {
const res = await openclawApi.approveRequest(id, { autoExecute: true });
if (res.ok) {
alert(`Approved! ` + (res.data.executed ? `Executed as prompt ${res.data.prompt_id}` : `Marked approved.`));
alert(`Approved! ${res.data.executed ? `Executed as prompt ${res.data.prompt_id}` : "Marked approved."}`);
loadApprovals();
} else {
showError(container, `Approval failed: ${res.error}`);
@@ -187,69 +174,58 @@ export const ApprovalsTab = {
}
};
const showDetails = async (id) => {
// Find in local state first, or fetch
let req = currentState.approvals.find(a => a.approval_id === id);
if (!req) {
const res = await openclawApi.getApproval(id);
if (res.ok) req = res.data.approval;
}
if (!req) return;
ui.modal.content.textContent = JSON.stringify(req, null, 2);
// Render actions
if (req.status === "pending") {
ui.modal.actions.innerHTML = `
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-primary openclaw-btn-primary moltbot-btn-primary" id="apr-modal-approve">Approve</button>
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-danger openclaw-btn-danger moltbot-btn-danger" id="apr-modal-reject">Reject</button>
`;
// Bind dynamic buttons
container.querySelector("#apr-modal-approve").onclick = () => { handleApprove(id); closeModal(); };
container.querySelector("#apr-modal-reject").onclick = () => { handleReject(id); closeModal(); };
} else {
ui.modal.actions.innerHTML = "";
}
openModal();
const closeModal = () => {
ui.modal.overlay.style.display = "none";
};
const openModal = () => {
ui.modal.overlay.style.display = "flex";
};
const closeModal = () => {
ui.modal.overlay.style.display = "none";
const showDetails = async (id) => {
let request = currentState.approvals.find((approval) => approval.approval_id === id);
if (!request) {
const res = await openclawApi.getApproval(id);
if (res.ok) request = res.data.approval;
}
if (!request) return;
ui.modal.content.textContent = JSON.stringify(request, null, 2);
if (request.status === "pending") {
ui.modal.actions.innerHTML = `
<button class="openclaw-btn openclaw-btn-primary" id="apr-modal-approve">Approve</button>
<button class="openclaw-btn openclaw-btn-danger" id="apr-modal-reject">Reject</button>
`;
container.querySelector("#apr-modal-approve").onclick = () => {
handleApprove(id);
closeModal();
};
container.querySelector("#apr-modal-reject").onclick = () => {
handleReject(id);
closeModal();
};
} else {
ui.modal.actions.innerHTML = "";
}
openModal();
};
// --- 5. Event Binding ---
ui.filters.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-status]");
if (!btn || btn.parentElement !== ui.filters) return;
// Filters
ui.filters.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-status]");
if (btn && btn.parentElement === ui.filters) { // Ensure strict match
// Update active state
ui.filters.querySelectorAll("button").forEach(b => b.classList.remove("openclaw-btn-primary", "openclaw-btn-primary", "moltbot-btn-primary"));
btn.classList.add("openclaw-btn-primary", "openclaw-btn-primary", "moltbot-btn-primary");
// Update state
currentState.status = btn.dataset.status; // "" for all
loadApprovals();
}
ui.filters
.querySelectorAll("button[data-status]")
.forEach((button) => button.classList.remove("openclaw-btn-primary"));
btn.classList.add("openclaw-btn-primary");
currentState.status = btn.dataset.status;
loadApprovals();
});
// Refresh
ui.refreshBtn.addEventListener("click", loadApprovals);
// List Actions
ui.list.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-action]");
ui.list.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-action]");
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
@@ -258,13 +234,11 @@ export const ApprovalsTab = {
else if (action === "details") showDetails(id);
});
// Modal
ui.modal.close.addEventListener("click", closeModal);
ui.modal.overlay.addEventListener("click", (e) => {
if (e.target === ui.modal.overlay) closeModal();
ui.modal.overlay.addEventListener("click", (event) => {
if (event.target === ui.modal.overlay) closeModal();
});
// Initial Load
loadApprovals();
}
},
};
+219 -232
View File
@@ -1,85 +1,140 @@
import { openclawApi } from "../openclaw_api.js";
import { tabManager } from "../openclaw_tabs.js";
import { showError, clearError, parseJsonOrThrow } from "../openclaw_utils.js";
import {
showError,
clearError,
parseJsonOrThrow,
normalizeLegacyClassNames,
} from "../openclaw_utils.js";
import {
normalizeLibraryCategory,
filterLibraryItems,
getLibraryApplyTarget,
} from "./library_tab_state.js";
// Helper for safe HTML escaping
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderApplyButtons(preset) {
if (preset.category === "prompt") {
return `
<button class="openclaw-btn openclaw-btn-sm openclaw-btn-primary" data-action="apply" data-id="${preset.id}">Plan</button>
<button class="openclaw-btn openclaw-btn-sm openclaw-btn-primary" data-action="apply-refiner" data-id="${preset.id}">Refine</button>
`;
}
if (preset.category === "params") {
return `<button class="openclaw-btn openclaw-btn-sm openclaw-btn-primary" data-action="apply" data-id="${preset.id}">Use</button>`;
}
return "";
}
function renderPresetItem(preset) {
return `
<div class="openclaw-list-item" style="padding: 10px; border-bottom: 1px solid var(--openclaw-color-border); display: flex; justify-content: space-between; align-items: center; gap: 10px;">
<div>
<div style="font-weight: bold;">${escapeHtml(preset.name)}</div>
<div style="font-size: var(--openclaw-font-sm); color: var(--openclaw-color-fg-muted); margin-top:4px;">
<span class="openclaw-badge" style="background:#555; color:#eee;">${escapeHtml(preset.category)}</span>
</div>
</div>
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end;">
${renderApplyButtons(preset)}
<button class="openclaw-btn openclaw-btn-sm" data-action="edit" data-id="${preset.id}">Edit</button>
<button class="openclaw-btn openclaw-btn-sm openclaw-btn-danger" data-action="delete" data-id="${preset.id}">Del</button>
</div>
</div>
`;
}
function renderPackItem(pack) {
return `
<div class="openclaw-list-item" style="padding: 10px; border-bottom: 1px solid var(--openclaw-color-border); display: flex; justify-content: space-between; align-items: center; gap: 10px;">
<div>
<div style="font-weight: bold;">${escapeHtml(pack.name)} <span style="font-weight:normal; opacity:0.7">v${escapeHtml(pack.version)}</span></div>
<div style="font-size: var(--openclaw-font-sm); color: var(--openclaw-color-fg-muted); margin-top:4px;">
<span class="openclaw-badge" style="background:#2c4f7c; color:#eee;">${escapeHtml(pack.type)}</span>
<span style="margin-left:6px;">by ${escapeHtml(pack.author)}</span>
</div>
</div>
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end;">
<button class="openclaw-btn openclaw-btn-sm" data-action="export-pack" data-name="${pack.name}" data-ver="${pack.version}">Export</button>
<button class="openclaw-btn openclaw-btn-sm openclaw-btn-danger" data-action="delete-pack" data-name="${pack.name}" data-ver="${pack.version}">Uninst</button>
</div>
</div>
`;
}
export const LibraryTab = {
id: "library",
title: "Library",
icon: "pi pi-book",
render(container) {
// --- 1. Static Layout ---
container.innerHTML = `
<div class="openclaw-panel openclaw-panel moltbot-panel">
<div class="openclaw-card openclaw-card moltbot-card" style="border-radius:0; border:none; border-bottom:1px solid var(--moltbot-color-border);">
<div class="openclaw-section-header openclaw-section-header moltbot-section-header">Asset Library</div>
<div class="openclaw-error-box openclaw-error-box moltbot-error-box" style="display:none"></div>
<div class="openclaw-input-group openclaw-input-group moltbot-input-group">
<input type="text" id="lib-search" class="openclaw-input openclaw-input moltbot-input" placeholder="Search...">
<div class="openclaw-panel">
<div class="openclaw-card" style="border-radius:0; border:none; border-bottom:1px solid var(--openclaw-color-border);">
<div class="openclaw-section-header">Asset Library</div>
<div class="openclaw-error-box" style="display:none"></div>
<div class="openclaw-input-group">
<input type="text" id="lib-search" class="openclaw-input" placeholder="Search...">
</div>
<div class="openclaw-toolbar openclaw-toolbar moltbot-toolbar" style="margin-top:8px; display:flex; gap:5px;" id="lib-filter-btns">
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-primary openclaw-btn-primary moltbot-btn-primary" data-cat="all">All</button>
<button class="openclaw-btn openclaw-btn moltbot-btn" data-cat="prompt">Prompts</button>
<button class="openclaw-btn openclaw-btn moltbot-btn" data-cat="params">Params</button>
<button class="openclaw-btn openclaw-btn moltbot-btn" data-cat="packs">Packs</button>
<button class="openclaw-btn openclaw-btn moltbot-btn" id="lib-new-btn" style="margin-left: auto;">+ New</button>
<div class="openclaw-toolbar" style="margin-top:8px; display:flex; gap:5px;" id="lib-filter-btns">
<button class="openclaw-btn openclaw-btn-primary" data-cat="all">All</button>
<button class="openclaw-btn" data-cat="prompt">Prompts</button>
<button class="openclaw-btn" data-cat="params">Params</button>
<button class="openclaw-btn" data-cat="packs">Packs</button>
<button class="openclaw-btn" id="lib-new-btn" style="margin-left: auto;">+ New</button>
</div>
<input type="file" id="lib-pack-upload" accept=".zip" style="display:none">
</div>
<div id="lib-list" class="openclaw-scroll-area openclaw-scroll-area moltbot-scroll-area" style="padding:0;">
<!-- Items -->
<div class="openclaw-empty-state openclaw-empty-state moltbot-empty-state">Loading...</div>
<div id="lib-list" class="openclaw-scroll-area" style="padding:0;">
<div class="openclaw-empty-state">Loading...</div>
</div>
</div>
<!-- Editor Modal (Presets) -->
<div id="lib-editor-overlay" class="openclaw-modal-overlay openclaw-modal-overlay moltbot-modal-overlay" style="display:none;">
<div id="lib-editor" class="openclaw-modal openclaw-modal moltbot-modal">
<div class="openclaw-modal-header openclaw-modal-header moltbot-modal-header">
<div id="lib-editor-overlay" class="openclaw-modal-overlay" style="display:none;">
<div id="lib-editor" class="openclaw-modal">
<div class="openclaw-modal-header">
<span id="lib-editor-title">Edit Preset</span>
<input type="hidden" id="lib-edit-id">
</div>
<div class="openclaw-modal-body openclaw-modal-body moltbot-modal-body">
<div class="openclaw-input-group openclaw-input-group moltbot-input-group">
<label class="openclaw-label openclaw-label moltbot-label">Name</label>
<input type="text" id="lib-edit-name" class="openclaw-input openclaw-input moltbot-input">
<div class="openclaw-modal-body">
<div class="openclaw-input-group">
<label class="openclaw-label">Name</label>
<input type="text" id="lib-edit-name" class="openclaw-input">
</div>
<br>
<div class="openclaw-input-group openclaw-input-group moltbot-input-group">
<label class="openclaw-label openclaw-label moltbot-label">Category</label>
<select id="lib-edit-cat" class="openclaw-select openclaw-select moltbot-select">
<div class="openclaw-input-group">
<label class="openclaw-label">Category</label>
<select id="lib-edit-cat" class="openclaw-select">
<option value="general">General</option>
<option value="prompt">Prompt</option>
<option value="params">Params</option>
</select>
</div>
<br>
<div class="openclaw-input-group openclaw-input-group moltbot-input-group">
<label class="openclaw-label openclaw-label moltbot-label">Content (JSON)</label>
<textarea id="lib-edit-params-json" class="openclaw-textarea openclaw-textarea moltbot-textarea openclaw-textarea-md openclaw-textarea-md moltbot-textarea-md"></textarea>
<div class="openclaw-input-group">
<label class="openclaw-label">Content (JSON)</label>
<textarea id="lib-edit-params-json" class="openclaw-textarea openclaw-textarea-md"></textarea>
</div>
</div>
<div class="openclaw-modal-footer openclaw-modal-footer moltbot-modal-footer">
<button class="openclaw-btn openclaw-btn moltbot-btn" id="lib-editor-cancel">Cancel</button>
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-primary openclaw-btn-primary moltbot-btn-primary" id="lib-editor-save">Save</button>
<div class="openclaw-modal-footer">
<button class="openclaw-btn" id="lib-editor-cancel">Cancel</button>
<button class="openclaw-btn openclaw-btn-primary" id="lib-editor-save">Save</button>
</div>
</div>
</div>
`;
normalizeLegacyClassNames(container);
// --- 2. State & References ---
const ui = {
list: container.querySelector("#lib-list"),
search: container.querySelector("#lib-search"),
@@ -88,7 +143,6 @@ export const LibraryTab = {
packUpload: container.querySelector("#lib-pack-upload"),
modal: {
overlay: container.querySelector("#lib-editor-overlay"),
el: container.querySelector("#lib-editor"),
title: container.querySelector("#lib-editor-title"),
id: container.querySelector("#lib-edit-id"),
name: container.querySelector("#lib-edit-name"),
@@ -96,98 +150,41 @@ export const LibraryTab = {
content: container.querySelector("#lib-edit-params-json"),
save: container.querySelector("#lib-editor-save"),
cancel: container.querySelector("#lib-editor-cancel"),
}
},
};
let currentState = {
category: null, // 'packs' is a special category here
items: []
const currentState = {
category: null,
items: [],
};
// --- 3. View Logic (Renderers) ---
const renderPresetItem = (p) => `
<div class="openclaw-list-item openclaw-list-item moltbot-list-item" style="padding: 10px; border-bottom: 1px solid var(--moltbot-color-border); display: flex; justify-content: space-between; align-items: center;">
<div>
<div style="font-weight: bold;">${escapeHtml(p.name)}</div>
<div style="font-size: var(--moltbot-font-sm); color: var(--moltbot-color-fg-muted); margin-top:4px;">
<span class="openclaw-badge openclaw-badge moltbot-badge" style="background:#555; color:#eee;">${escapeHtml(p.category)}</span>
</div>
</div>
<div style="display: flex; gap: 5px;">
${getApplyButton(p)}
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm" data-action="edit" data-id="${p.id}">Edit</button>
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm openclaw-btn-danger openclaw-btn-danger moltbot-btn-danger" data-action="delete" data-id="${p.id}">Del</button>
</div>
</div>
`;
const renderPackItem = (p) => `
<div class="openclaw-list-item openclaw-list-item moltbot-list-item" style="padding: 10px; border-bottom: 1px solid var(--moltbot-color-border); display: flex; justify-content: space-between; align-items: center;">
<div>
<div style="font-weight: bold;">${escapeHtml(p.name)} <span style="font-weight:normal; opacity:0.7">v${escapeHtml(p.version)}</span></div>
<div style="font-size: var(--moltbot-font-sm); color: var(--moltbot-color-fg-muted); margin-top:4px;">
<span class="openclaw-badge openclaw-badge moltbot-badge" style="background:#2c4f7c; color:#eee;">${escapeHtml(p.type)}</span>
<span style="margin-left:6px;">by ${escapeHtml(p.author)}</span>
</div>
</div>
<div style="display: flex; gap: 5px;">
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm" data-action="export-pack" data-name="${p.name}" data-ver="${p.version}">Export</button>
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm openclaw-btn-danger openclaw-btn-danger moltbot-btn-danger" data-action="delete-pack" data-name="${p.name}" data-ver="${p.version}">Uninst</button>
</div>
</div>
`;
function getApplyButton(p) {
if (p.category === "prompt") {
return `
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm openclaw-btn-primary openclaw-btn-primary moltbot-btn-primary" data-action="apply" data-id="${p.id}">Plan</button>
<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm openclaw-btn-primary openclaw-btn-primary moltbot-btn-primary" data-action="apply-refiner" data-id="${p.id}">Refine</button>
`;
} else if (p.category === "params") {
return `<button class="openclaw-btn openclaw-btn moltbot-btn openclaw-btn-sm openclaw-btn-sm moltbot-btn-sm openclaw-btn-primary openclaw-btn-primary moltbot-btn-primary" data-action="apply" data-id="${p.id}">Use</button>`;
}
return "";
}
const renderList = () => {
const term = ui.search.value.toLowerCase();
const filtered = currentState.items.filter(item =>
item.name.toLowerCase().includes(term)
);
const filtered = filterLibraryItems(currentState.items, ui.search.value);
if (filtered.length === 0) {
ui.list.innerHTML = '<div class="openclaw-empty-state openclaw-empty-state moltbot-empty-state">No items found.</div>';
ui.list.innerHTML = '<div class="openclaw-empty-state">No items found.</div>';
return;
}
if (currentState.category === "packs") {
ui.list.innerHTML = filtered.map(renderPackItem).join("");
} else {
ui.list.innerHTML = filtered.map(renderPresetItem).join("");
}
ui.list.innerHTML = currentState.category === "packs"
? filtered.map(renderPackItem).join("")
: filtered.map(renderPresetItem).join("");
normalizeLegacyClassNames(ui.list);
};
// --- 4. Logic/Controllers ---
const loadContent = async () => {
clearError(container);
ui.list.innerHTML = '<div style="padding: 20px; text-align: center;">Loading...</div>';
let res;
if (currentState.category === "packs") {
res = await openclawApi.getPacks();
} else {
// If cat is 'all', allow backend/logic to handle null
const cat = currentState.category === "all" ? null : currentState.category;
res = await openclawApi.listPresets(cat);
}
const category = normalizeLibraryCategory(currentState.category);
const res = category === "packs"
? await openclawApi.getPacks()
: await openclawApi.listPresets(category);
if (res.ok) {
currentState.items = res.data || (res.packs ? res.packs : []);
renderList();
} else {
ui.list.innerHTML = '';
ui.list.innerHTML = "";
showError(container, res.error);
}
};
@@ -200,39 +197,44 @@ export const LibraryTab = {
ui.modal.name.value = preset.name;
ui.modal.cat.value = preset.category;
ui.modal.content.value = JSON.stringify(preset.content, null, 2);
} else {
ui.modal.title.textContent = "New Preset";
ui.modal.id.value = "";
ui.modal.name.value = "New Preset";
ui.modal.cat.value = "general";
ui.modal.content.value = "{}";
return;
}
ui.modal.title.textContent = "New Preset";
ui.modal.id.value = "";
ui.modal.name.value = "New Preset";
ui.modal.cat.value = "general";
ui.modal.content.value = "{}";
};
const closeModal = () => { ui.modal.overlay.style.display = "none"; };
const closeModal = () => {
ui.modal.overlay.style.display = "none";
};
const savePreset = async () => {
const id = ui.modal.id.value;
const name = ui.modal.name.value.trim();
const cat = ui.modal.cat.value;
const category = ui.modal.cat.value;
let content;
try {
content = parseJsonOrThrow(
ui.modal.content.value,
"Content must be valid JSON"
);
content = parseJsonOrThrow(ui.modal.content.value, "Content must be valid JSON");
} catch (error) {
alert(error.message);
return;
}
catch (e) { alert(e.message); return; }
if (!name) { alert("Name required"); return; }
if (!name) {
alert("Name required");
return;
}
ui.modal.save.textContent = "Saving...";
ui.modal.save.disabled = true;
let res;
if (id) res = await openclawApi.updatePreset(id, { name, category: cat, content });
else res = await openclawApi.createPreset({ name, category: cat, content });
const res = id
? await openclawApi.updatePreset(id, { name, category, content })
: await openclawApi.createPreset({ name, category, content });
ui.modal.save.textContent = "Save";
ui.modal.save.disabled = false;
@@ -248,113 +250,19 @@ export const LibraryTab = {
const deleteItem = async (idOrName, version = null) => {
if (!confirm("Are you sure you want to delete this item?")) return;
let res;
if (currentState.category === "packs") {
res = await openclawApi.deletePack(idOrName, version);
} else {
res = await openclawApi.deletePreset(idOrName);
}
const res = currentState.category === "packs"
? await openclawApi.deletePack(idOrName, version)
: await openclawApi.deletePreset(idOrName);
if (res.ok) loadContent();
else alert(`Delete failed: ${res.error}`);
};
// Pack Import
ui.packUpload.onchange = async () => {
const file = ui.packUpload.files[0];
if (!file) return;
ui.list.innerHTML = '<div style="padding: 20px; text-align: center;">Importing Pack...</div>';
const res = await openclawApi.importPack(file, false); // No overwrite by default for now
if (res.ok) {
alert(`Imported ${res.data.pack.name} v${res.data.pack.version}`);
loadContent();
} else {
showError(container, res.error);
// Reload to restore list
setTimeout(loadContent, 2000);
}
ui.packUpload.value = ""; // Reset
};
const triggerNew = () => {
if (currentState.category === "packs") {
ui.packUpload.click();
} else {
openModal();
alert(`Delete failed: ${res.error}`);
}
};
// --- 5. Event Binding ---
ui.search.addEventListener("input", renderList);
ui.filters.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-cat]");
if (!btn) return;
ui.filters.querySelectorAll("button").forEach(b => b.classList.remove("openclaw-btn-primary", "openclaw-btn-primary", "moltbot-btn-primary"));
btn.classList.add("openclaw-btn-primary", "openclaw-btn-primary", "moltbot-btn-primary");
const cat = btn.dataset.cat;
currentState.category = cat === "all" ? null : cat;
// Update New Button Text
ui.newBtn.textContent = currentState.category === "packs" ? "Import" : "+ New";
loadContent();
});
ui.newBtn.addEventListener("click", triggerNew);
// List Delegation
ui.list.addEventListener("click", async (e) => {
const btn = e.target.closest("button[data-action]");
if (!btn) return;
const action = btn.dataset.action;
if (action === "edit") {
const res = await openclawApi.getPreset(btn.dataset.id);
if (res.ok) openModal(res.data);
else showError(container, "Failed to load preset details");
} else if (action === "delete") {
await deleteItem(btn.dataset.id);
} else if (action === "delete-pack") {
await deleteItem(btn.dataset.name, btn.dataset.ver);
} else if (action === "export-pack") {
const res = await openclawApi.exportPack(btn.dataset.name, btn.dataset.ver);
if (res.ok) {
// Create blob link and click it
const url = window.URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = `${btn.dataset.name}-${btn.dataset.ver}.zip`; // Or preserve filename from header if possible
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => window.URL.revokeObjectURL(url), 1000);
} else {
showError(container, res.error);
}
} else if (action === "apply") {
const res = await openclawApi.getPreset(btn.dataset.id);
if (res.ok) applyPreset(res.data, "planner");
else showError(container, "Failed to load preset for apply");
} else if (action === "apply-refiner") {
const res = await openclawApi.getPreset(btn.dataset.id);
if (res.ok) applyPreset(res.data, "refiner");
else showError(container, "Failed to load preset for apply");
}
});
// Apply Logic reused...
function applyPreset(preset, explicitTarget = null) {
let targetTabId = explicitTarget;
if (!targetTabId) {
if (preset.category === "prompt") targetTabId = "planner";
else if (preset.category === "params") targetTabId = "variants";
}
const targetTabId = getLibraryApplyTarget(preset, explicitTarget);
if (!targetTabId) return;
tabManager.activateTab(targetTabId);
@@ -370,26 +278,105 @@ export const LibraryTab = {
if (neg && content.negative) neg.value = content.negative;
} else if (targetTabId === "variants") {
const baseParams = document.getElementById("var-base-params");
if (baseParams && content.params) baseParams.value = JSON.stringify(content.params, null, 2);
if (baseParams && content.params) {
baseParams.value = JSON.stringify(content.params, null, 2);
}
} else if (targetTabId === "refiner") {
const pos = document.getElementById("refiner-orig-pos");
const neg = document.getElementById("refiner-orig-neg");
if (pos && content.positive) pos.value = content.positive;
if (neg && content.negative) neg.value = content.negative;
}
} catch (e) {
console.error("Failed to apply preset:", e);
} catch (error) {
console.error("Failed to apply preset:", error);
alert("Error applying preset to target tab.");
}
}, 100);
}
ui.search.addEventListener("input", renderList);
ui.filters.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-cat]");
if (!btn) return;
ui.filters
.querySelectorAll("button[data-cat]")
.forEach((button) => button.classList.remove("openclaw-btn-primary"));
btn.classList.add("openclaw-btn-primary");
currentState.category = btn.dataset.cat || null;
ui.newBtn.textContent = currentState.category === "packs" ? "Import" : "+ New";
loadContent();
});
ui.newBtn.addEventListener("click", () => {
if (currentState.category === "packs") {
ui.packUpload.click();
return;
}
openModal();
});
ui.packUpload.addEventListener("change", async () => {
const file = ui.packUpload.files[0];
if (!file) return;
ui.list.innerHTML = '<div style="padding: 20px; text-align: center;">Importing Pack...</div>';
const res = await openclawApi.importPack(file, false);
if (res.ok) {
alert(`Imported ${res.data.pack.name} v${res.data.pack.version}`);
loadContent();
} else {
showError(container, res.error);
setTimeout(loadContent, 2000);
}
ui.packUpload.value = "";
});
ui.list.addEventListener("click", async (event) => {
const btn = event.target.closest("button[data-action]");
if (!btn) return;
const action = btn.dataset.action;
if (action === "edit") {
const res = await openclawApi.getPreset(btn.dataset.id);
if (res.ok) openModal(res.data);
else showError(container, "Failed to load preset details");
} else if (action === "delete") {
await deleteItem(btn.dataset.id);
} else if (action === "delete-pack") {
await deleteItem(btn.dataset.name, btn.dataset.ver);
} else if (action === "export-pack") {
const res = await openclawApi.exportPack(btn.dataset.name, btn.dataset.ver);
if (res.ok) {
const url = window.URL.createObjectURL(res.data);
const link = document.createElement("a");
link.href = url;
link.download = `${btn.dataset.name}-${btn.dataset.ver}.zip`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => window.URL.revokeObjectURL(url), 1000);
} else {
showError(container, res.error);
}
} else if (action === "apply" || action === "apply-refiner") {
const res = await openclawApi.getPreset(btn.dataset.id);
if (res.ok) {
applyPreset(res.data, action === "apply-refiner" ? "refiner" : null);
} else {
showError(container, "Failed to load preset for apply");
}
}
});
ui.modal.cancel.addEventListener("click", closeModal);
ui.modal.save.addEventListener("click", savePreset);
ui.modal.overlay.addEventListener("click", (e) => {
if (e.target === ui.modal.overlay) closeModal();
ui.modal.overlay.addEventListener("click", (event) => {
if (event.target === ui.modal.overlay) closeModal();
});
loadContent();
}
},
};
+21
View File
@@ -0,0 +1,21 @@
export function normalizeLibraryCategory(category) {
if (!category || category === "all") return null;
return category;
}
export function filterLibraryItems(items = [], term = "") {
const normalizedTerm = String(term || "").trim().toLowerCase();
if (!normalizedTerm) {
return Array.isArray(items) ? [...items] : [];
}
return (Array.isArray(items) ? items : []).filter((item) =>
String(item?.name || "").toLowerCase().includes(normalizedTerm)
);
}
export function getLibraryApplyTarget(preset = {}, explicitTarget = null) {
if (explicitTarget) return explicitTarget;
if (preset?.category === "prompt") return "planner";
if (preset?.category === "params") return "variants";
return null;
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
normalizeLibraryCategory,
filterLibraryItems,
getLibraryApplyTarget,
} from "../../tabs/library_tab_state.js";
describe("library_tab_state", () => {
it("normalizes all-like categories to null", () => {
expect(normalizeLibraryCategory(null)).toBeNull();
expect(normalizeLibraryCategory("all")).toBeNull();
expect(normalizeLibraryCategory("packs")).toBe("packs");
});
it("filters items by case-insensitive name matches", () => {
const items = [
{ name: "Portrait Prompt" },
{ name: "Landscape Params" },
{ name: "Workflow Pack" },
];
expect(filterLibraryItems(items, "prompt")).toEqual([{ name: "Portrait Prompt" }]);
expect(filterLibraryItems(items, "PaR")).toEqual([{ name: "Landscape Params" }]);
expect(filterLibraryItems(items, "")).toEqual(items);
});
it("resolves apply targets from explicit target or category", () => {
expect(getLibraryApplyTarget({ category: "prompt" })).toBe("planner");
expect(getLibraryApplyTarget({ category: "params" })).toBe("variants");
expect(getLibraryApplyTarget({ category: "general" }, "refiner")).toBe("refiner");
expect(getLibraryApplyTarget({ category: "general" })).toBeNull();
});
});
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
composeFetchWrappersOnce,
getFetchWrapperMeta,
withAbortPassthrough,
withGetRetry,
withPreconnectHint,
} from "../../openclaw_fetch_wrappers.js";
describe("openclaw_fetch_wrappers", () => {
beforeEach(() => {
document.head.innerHTML = "";
});
it("does not stack the same decorator chain twice", async () => {
const fetchFn = vi.fn(async () => ({ ok: true }));
const decorators = [withAbortPassthrough(), withGetRetry({ retries: 1 })];
const wrapped = composeFetchWrappersOnce(fetchFn, decorators);
const wrappedAgain = composeFetchWrappersOnce(wrapped, decorators);
expect(wrappedAgain).toBe(wrapped);
expect(getFetchWrapperMeta(wrappedAgain)).toMatchObject({
baseFetch: fetchFn,
appliedCount: 1,
chainIds: ["abort_passthrough", "retry_get_1"],
});
await wrappedAgain("/health");
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("adds one preconnect link per origin", async () => {
const fetchFn = vi.fn(async () => ({ ok: true }));
const wrapped = composeFetchWrappersOnce(fetchFn, [withPreconnectHint()]);
await wrapped("https://example.com/api/one");
await wrapped("https://example.com/api/two");
const links = [...document.head.querySelectorAll('link[rel="preconnect"]')];
expect(links).toHaveLength(1);
expect(links[0].href).toContain("https://example.com");
});
it("retries GET once on network failure", async () => {
const fetchFn = vi
.fn()
.mockRejectedValueOnce(new Error("temporary"))
.mockResolvedValueOnce({ ok: true, status: 200 });
const wrapped = composeFetchWrappersOnce(fetchFn, [withGetRetry({ retries: 1 })]);
const result = await wrapped("/health", { method: "GET" });
expect(result).toMatchObject({ ok: true, status: 200 });
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it("does not retry non-GET requests", async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error("no retry"));
const wrapped = composeFetchWrappersOnce(fetchFn, [withGetRetry({ retries: 2 })]);
await expect(wrapped("/config", { method: "POST" })).rejects.toThrow("no retry");
expect(fetchFn).toHaveBeenCalledTimes(1);
});
it("does not retry abort errors", async () => {
const fetchFn = vi.fn().mockRejectedValue(new DOMException("Cancelled", "AbortError"));
const wrapped = composeFetchWrappersOnce(fetchFn, [withGetRetry({ retries: 2 })]);
await expect(wrapped("/health")).rejects.toThrow(/Cancelled/);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
makeEl,
normalizeLegacyClassTokens,
normalizeLegacyClassNames,
parseJsonSafe,
parseJsonOrThrow,
isAbortError,
} from "../../openclaw_utils.js";
describe("openclaw_utils", () => {
it("creates elements with class and text", () => {
const el = makeEl("div", "openclaw-card", "Hello");
expect(el.tagName).toBe("DIV");
expect(el.className).toBe("openclaw-card");
expect(el.textContent).toBe("Hello");
});
it("normalizes duplicate legacy class tokens", () => {
expect(
normalizeLegacyClassTokens("openclaw-btn moltbot-btn openclaw-btn-primary moltbot-btn-primary openclaw-btn")
).toBe("openclaw-btn openclaw-btn-primary");
});
it("normalizes class names in a subtree", () => {
document.body.innerHTML = `
<section class="openclaw-panel moltbot-panel">
<button class="openclaw-btn moltbot-btn openclaw-btn-primary moltbot-btn-primary">Run</button>
</section>
`;
const root = document.body.firstElementChild;
normalizeLegacyClassNames(root);
expect(root.className).toBe("openclaw-panel");
expect(root.querySelector("button").className).toBe("openclaw-btn openclaw-btn-primary");
});
it("returns fallback data for invalid JSON", () => {
const parsed = parseJsonSafe("{bad", { safe: true });
expect(parsed.ok).toBe(false);
expect(parsed.value).toEqual({ safe: true });
expect(parsed.error).toBeInstanceOf(Error);
});
it("throws with the provided parse message", () => {
expect(() => parseJsonOrThrow("{bad", "Broken payload")).toThrow(/Broken payload/);
});
it("detects abort errors by name", () => {
expect(isAbortError(new DOMException("Cancelled", "AbortError"))).toBe(true);
expect(isAbortError(new Error("boom"))).toBe(false);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { vi } from "vitest";
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn();
}
if (!globalThis.alert) {
globalThis.alert = vi.fn();
}
if (!globalThis.confirm) {
globalThis.confirm = vi.fn(() => true);
}