mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
feat(operator-ux): close F49/F51/F52/F50 remainder gaps and sync compare/auth/history contracts
This commit is contained in:
@@ -19,6 +19,7 @@ coverage.xml
|
||||
REFERENCE/
|
||||
AGENT_CONTEXT.md
|
||||
AGENTS.md
|
||||
AGENT.md
|
||||
ROADMAP.md
|
||||
tests_output.txt
|
||||
node_modules/
|
||||
|
||||
@@ -31,6 +31,17 @@ This project is intentionally **not** a general-purpose assistant platform with
|
||||
|
||||
## Latest Updates - Click to expand
|
||||
|
||||
<details>
|
||||
<summary><strong>Operator UX improvements: context toolbox, parameter lab history/replay, and compare workflow baseline</strong></summary>
|
||||
|
||||
- Added in-canvas OpenClaw quick actions on node context menus: Inspect, Doctor, Queue Status, Compare, and Settings.
|
||||
- Improved operator recovery flow by wiring quick actions to capability-aware targets with deterministic fallback guidance when optional endpoints are unavailable.
|
||||
- Added Parameter Lab history flow so operators can browse saved experiments, load details, and replay run parameters back into the current graph.
|
||||
- Added compare workflow baseline in Parameter Lab, including a dedicated compare endpoint with bounded fan-out and stricter payload validation.
|
||||
- Expanded auth and regression coverage so compare routes remain admin-protected and route-registration drift is caught earlier.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Pack security hardening: path traversal defense and strict API validation</strong></summary>
|
||||
|
||||
@@ -168,6 +179,7 @@ This project is intentionally **not** a general-purpose assistant platform with
|
||||
- [Set an Admin Token](#3-optional-recommended-set-an-admin-token)
|
||||
- [Nodes](#nodes)
|
||||
- [Extension UI](#extension-ui)
|
||||
- [Operator UX Features](#operator-ux-features)
|
||||
- [API Overview](#api-overview)
|
||||
- [Observability](#observability-read-only)
|
||||
- [LLM config](#llm-config-non-secret)
|
||||
@@ -273,6 +285,49 @@ See `web/docs/` for node usage notes.
|
||||
|
||||
The frontend lives in `web/` and is served by ComfyUI as an extension panel. It uses the backend routes below (preferring `/api/openclaw/*`).
|
||||
|
||||
## Operator UX Features
|
||||
|
||||
### In-canvas context toolbox
|
||||
|
||||
Right-click a node and open the `OpenClaw` menu to access:
|
||||
|
||||
- `Inspect`: jump to the Explorer troubleshooting path.
|
||||
- `Doctor`: run diagnostics and show readiness feedback.
|
||||
- `Queue Status`: jump directly to queue/job monitoring.
|
||||
- `Compare`: open Parameter Lab in compare setup mode for the selected node.
|
||||
- `Settings`: jump to OpenClaw settings.
|
||||
|
||||
These actions are capability-aware and degrade to safe guidance when optional backend capabilities are unavailable.
|
||||
|
||||
### Parameter Lab history and replay
|
||||
|
||||
Parameter Lab now supports experiment history and run replay:
|
||||
|
||||
- `History` lists saved experiments from local state.
|
||||
- `Load` opens stored experiment details and run statuses.
|
||||
- `Replay` applies a selected run's parameter values back into the active workflow graph.
|
||||
|
||||
This makes iterative tuning and backtracking faster without manually retyping prior parameter sets.
|
||||
|
||||
### Compare workflow baseline
|
||||
|
||||
Parameter Lab includes a baseline compare flow for model/widget A/B style checks:
|
||||
|
||||
- Use `Compare` from the node context toolbox, or `Compare Models` inside Parameter Lab.
|
||||
- The compare planner generates bounded runs from one selected comparison dimension.
|
||||
- Backend compare submission is validated and admin-protected.
|
||||
- Compare experiments are persisted and visible in history alongside sweep experiments.
|
||||
|
||||
Current scope is focused on bounded compare orchestration and replay-ready records; richer side-by-side evaluation and winner handoff are still being expanded.
|
||||
|
||||
### Operator guidance and quick recovery
|
||||
|
||||
Operator actions are wired for faster recovery loops:
|
||||
|
||||
- queue/status routing prefers the dedicated monitor view when available
|
||||
- doctor checks surface immediate readiness feedback
|
||||
- compare and history flows are connected so experiments can be reviewed and replayed quickly
|
||||
|
||||
## API Overview
|
||||
|
||||
### Base paths
|
||||
|
||||
@@ -75,6 +75,7 @@ if web is not None:
|
||||
from ..services.log_tail import tail_log
|
||||
from ..services.metrics import metrics
|
||||
from ..services.parameter_lab import ( # F52
|
||||
create_compare_handler,
|
||||
create_sweep_handler,
|
||||
get_experiment_handler,
|
||||
list_experiments_handler,
|
||||
@@ -125,6 +126,7 @@ if web is not None:
|
||||
from services.log_tail import tail_log # type: ignore
|
||||
from services.metrics import metrics # type: ignore
|
||||
from services.parameter_lab import ( # F52
|
||||
create_compare_handler,
|
||||
create_sweep_handler,
|
||||
get_experiment_handler,
|
||||
list_experiments_handler,
|
||||
@@ -575,6 +577,7 @@ def register_routes(server) -> None:
|
||||
), # S12: Execute tool (admin only)
|
||||
# F52: Parameter Lab
|
||||
("POST", f"{prefix}/lab/sweep", create_sweep_handler),
|
||||
("POST", f"{prefix}/lab/compare", create_compare_handler),
|
||||
("GET", f"{prefix}/lab/experiments", list_experiments_handler),
|
||||
("GET", f"{prefix}/lab/experiments/{{exp_id}}", get_experiment_handler),
|
||||
(
|
||||
|
||||
@@ -18,22 +18,22 @@ type BannerSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||
interface BannerStatus {
|
||||
/** Unique identifier for deduplication (e.g., 'backpressure_123') */
|
||||
id: string;
|
||||
|
||||
|
||||
/** Visual severity level */
|
||||
severity: BannerSeverity;
|
||||
|
||||
|
||||
/** Display message */
|
||||
message: string;
|
||||
|
||||
|
||||
/** Source of the banner (e.g., 'system', 'queue', 'connectivity') */
|
||||
source: string;
|
||||
|
||||
|
||||
/** Time-to-live in milliseconds. If missing, persists until dismissed or replaced. */
|
||||
ttl_ms?: number;
|
||||
|
||||
|
||||
/** Whether the user can manually dismiss the banner */
|
||||
dismissible?: boolean;
|
||||
|
||||
|
||||
/** Optional clickable action */
|
||||
action?: {
|
||||
label: string;
|
||||
@@ -62,16 +62,16 @@ Defines quick actions available in the node context menu (via ComfyUI extension
|
||||
interface ContextAction {
|
||||
/** Unique action ID */
|
||||
id: string;
|
||||
|
||||
|
||||
/** Display label */
|
||||
label: string;
|
||||
|
||||
|
||||
/** Optional icon class or emoji */
|
||||
icon?: string;
|
||||
|
||||
|
||||
/** Primary target category */
|
||||
target: 'explorer' | 'jobs' | 'settings' | 'doctor' | 'url';
|
||||
|
||||
|
||||
/** Context data required for the action */
|
||||
payload?: {
|
||||
node_type?: string;
|
||||
@@ -79,7 +79,7 @@ interface ContextAction {
|
||||
widget_name?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
|
||||
/** Filter function to determine availability (frontend-side) */
|
||||
condition?: (node: any) => boolean;
|
||||
}
|
||||
|
||||
+126
-2
@@ -30,6 +30,7 @@ logger = logging.getLogger("ComfyUI-OpenClaw.services.parameter_lab")
|
||||
|
||||
# Configuration
|
||||
MAX_SWEEP_COMBINATIONS = 50 # Hard cap to prevent queue flooding
|
||||
MAX_COMPARE_ITEMS = 8 # F50: Hard cap for side-by-side comparison
|
||||
EXPERIMENT_RETENTION_COUNT = 20
|
||||
|
||||
|
||||
@@ -122,6 +123,64 @@ class SweepPlanner:
|
||||
return runs
|
||||
|
||||
|
||||
class ComparePlanner:
|
||||
"""
|
||||
F50: Generates bounded multi-model comparison plans.
|
||||
Enforces stricter fan-out and timeout policies than generic sweeps.
|
||||
"""
|
||||
|
||||
def generate(
|
||||
self, workflow: str, items: List[Any], node_id: Any, widget_name: str
|
||||
) -> SweepPlan:
|
||||
if not isinstance(workflow, str) or not workflow.strip():
|
||||
raise ValueError("workflow_json is required")
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError("items must be a non-empty list")
|
||||
if node_id is None:
|
||||
raise ValueError("node_id is required")
|
||||
if not isinstance(widget_name, str) or not widget_name.strip():
|
||||
raise ValueError("widget_name is required")
|
||||
if len(items) > MAX_COMPARE_ITEMS:
|
||||
raise ValueError(f"Too many items for comparison (max {MAX_COMPARE_ITEMS})")
|
||||
|
||||
normalized_items: List[Any] = []
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
if not item.strip():
|
||||
raise ValueError("items must not contain empty strings")
|
||||
normalized_items.append(item)
|
||||
continue
|
||||
if isinstance(item, (int, float, bool)):
|
||||
normalized_items.append(item)
|
||||
continue
|
||||
raise ValueError("items must contain only scalar values")
|
||||
|
||||
exp_id = f"cmp_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create a single dimension for the model/item
|
||||
dim = SweepDimension(
|
||||
node_id=str(node_id),
|
||||
widget_name=widget_name,
|
||||
values=normalized_items,
|
||||
strategy="compare",
|
||||
)
|
||||
|
||||
# Generate runs (1 per item)
|
||||
runs = []
|
||||
for val in normalized_items:
|
||||
runs.append({f"{node_id}.{widget_name}": val})
|
||||
|
||||
return SweepPlan(
|
||||
experiment_id=exp_id,
|
||||
workflow_json=workflow,
|
||||
dimensions=[dim],
|
||||
runs=runs,
|
||||
)
|
||||
|
||||
|
||||
_compare_planner = ComparePlanner()
|
||||
|
||||
|
||||
class ExperimentStore:
|
||||
"""Persists experiment metadata."""
|
||||
|
||||
@@ -129,10 +188,19 @@ class ExperimentStore:
|
||||
self.store_dir = state_dir / "experiments"
|
||||
self.store_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def _is_experiment_file(path: Path) -> bool:
|
||||
return path.name.startswith("exp_") or path.name.startswith("cmp_")
|
||||
|
||||
def _enforce_retention(self) -> None:
|
||||
"""Delete oldest experiments if count exceeds limit."""
|
||||
try:
|
||||
files = [(f, f.stat().st_mtime) for f in self.store_dir.glob("exp_*.json")]
|
||||
# R78/F50: Include both exp_* (sweeps) and cmp_* (compares).
|
||||
files = [
|
||||
(file_path, file_path.stat().st_mtime)
|
||||
for file_path in self.store_dir.glob("*.json")
|
||||
if self._is_experiment_file(file_path)
|
||||
]
|
||||
files.sort(key=lambda item: item[1], reverse=True)
|
||||
for file_path, _ in files[EXPERIMENT_RETENTION_COUNT:]:
|
||||
try:
|
||||
@@ -161,8 +229,13 @@ class ExperimentStore:
|
||||
|
||||
def list_experiments(self) -> List[Dict[str, Any]]:
|
||||
results: List[Dict[str, Any]] = []
|
||||
# R78/F50: Include both exp_* and cmp_*.
|
||||
files = sorted(
|
||||
self.store_dir.glob("exp_*.json"),
|
||||
[
|
||||
file_path
|
||||
for file_path in self.store_dir.glob("*.json")
|
||||
if self._is_experiment_file(file_path)
|
||||
],
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
@@ -175,6 +248,13 @@ class ExperimentStore:
|
||||
"id": data["experiment_id"],
|
||||
"created_at": data.get("created_at"),
|
||||
"run_count": len(data.get("runs", [])),
|
||||
"completed_count": len(
|
||||
[
|
||||
r
|
||||
for r in data.get("results", {}).values()
|
||||
if r.get("status") == "completed"
|
||||
]
|
||||
),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
@@ -246,6 +326,50 @@ def _require_admin(request: web.Request) -> Optional[web.Response]:
|
||||
return None
|
||||
|
||||
|
||||
async def create_compare_handler(request: web.Request) -> web.Response:
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
deny = _require_admin(request)
|
||||
if deny:
|
||||
return deny
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)
|
||||
|
||||
# Input validation.
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"ok": False, "error": "invalid_payload"}, status=400)
|
||||
|
||||
workflow = data.get("workflow_json")
|
||||
items = data.get("items", []) # List of comparison values.
|
||||
node_id = data.get("node_id")
|
||||
widget_name = data.get("widget_name")
|
||||
|
||||
if not isinstance(items, list):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "items_must_be_list"}, status=400
|
||||
)
|
||||
if node_id is None:
|
||||
return web.json_response({"ok": False, "error": "node_id_required"}, status=400)
|
||||
if not isinstance(widget_name, str) or not widget_name.strip():
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "widget_name_required"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
plan = _compare_planner.generate(workflow, items, node_id, widget_name)
|
||||
get_store().save_plan(plan)
|
||||
return web.json_response({"ok": True, "plan": asdict(plan)})
|
||||
except ValueError as exc:
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
logger.error("Compare creation failed: %s", exc)
|
||||
return web.json_response({"ok": False, "error": "internal_error"}, status=500)
|
||||
|
||||
|
||||
async def create_sweep_handler(request: web.Request) -> web.Response:
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
@@ -15,9 +15,12 @@ except Exception: # pragma: no cover
|
||||
|
||||
|
||||
from services.parameter_lab import ( # noqa: E402
|
||||
MAX_COMPARE_ITEMS,
|
||||
MAX_SWEEP_COMBINATIONS,
|
||||
ComparePlanner,
|
||||
ExperimentStore,
|
||||
SweepPlanner,
|
||||
create_compare_handler,
|
||||
create_sweep_handler,
|
||||
get_experiment_handler,
|
||||
list_experiments_handler,
|
||||
@@ -54,11 +57,64 @@ class TestSweepPlanner(unittest.TestCase):
|
||||
self.assertIn("exceeds limit", str(ctx.exception))
|
||||
|
||||
|
||||
class TestComparePlanner(unittest.TestCase):
|
||||
def test_generate_compare_plan(self):
|
||||
planner = ComparePlanner()
|
||||
items = ["checkpoint1.ckpt", "checkpoint2.ckpt"]
|
||||
plan = planner.generate(
|
||||
workflow='{"nodes":[]}', items=items, node_id="10", widget_name="ckpt_name"
|
||||
)
|
||||
self.assertEqual(len(plan.runs), 2)
|
||||
self.assertEqual(plan.dimensions[0].strategy, "compare")
|
||||
self.assertIn({"10.ckpt_name": "checkpoint1.ckpt"}, plan.runs)
|
||||
|
||||
def test_generate_rejects_oversized_compare(self):
|
||||
planner = ComparePlanner()
|
||||
items = [f"model_{i}" for i in range(MAX_COMPARE_ITEMS + 1)]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
planner.generate(
|
||||
workflow='{"nodes":[]}',
|
||||
items=items,
|
||||
node_id="10",
|
||||
widget_name="ckpt_name",
|
||||
)
|
||||
self.assertIn(f"max {MAX_COMPARE_ITEMS}", str(ctx.exception))
|
||||
|
||||
def test_generate_rejects_non_scalar_items(self):
|
||||
planner = ComparePlanner()
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
planner.generate(
|
||||
workflow='{"nodes":[]}',
|
||||
items=[{"bad": "item"}],
|
||||
node_id="10",
|
||||
widget_name="ckpt_name",
|
||||
)
|
||||
self.assertIn("scalar values", str(ctx.exception))
|
||||
|
||||
|
||||
class TestExperimentStore(unittest.TestCase):
|
||||
def test_list_experiments_includes_compare_plans(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
store = ExperimentStore(Path(tmp_dir))
|
||||
compare = ComparePlanner().generate(
|
||||
workflow='{"nodes":[]}',
|
||||
items=["m1", "m2"],
|
||||
node_id="10",
|
||||
widget_name="ckpt_name",
|
||||
)
|
||||
store.save_plan(compare)
|
||||
|
||||
experiments = store.list_experiments()
|
||||
self.assertEqual(1, len(experiments))
|
||||
self.assertTrue(experiments[0]["id"].startswith("cmp_"))
|
||||
|
||||
|
||||
@unittest.skipIf(web is None, "aiohttp not installed")
|
||||
class TestParameterLabHandlers(AioHTTPTestCase):
|
||||
async def get_application(self):
|
||||
app = web.Application()
|
||||
app.router.add_post("/openclaw/lab/sweep", create_sweep_handler)
|
||||
app.router.add_post("/openclaw/lab/compare", create_compare_handler)
|
||||
app.router.add_get("/openclaw/lab/experiments", list_experiments_handler)
|
||||
app.router.add_get("/openclaw/lab/experiments/{exp_id}", get_experiment_handler)
|
||||
app.router.add_post(
|
||||
@@ -116,6 +172,46 @@ class TestParameterLabHandlers(AioHTTPTestCase):
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertEqual(len(data["plan"]["runs"]), 2)
|
||||
|
||||
@patch("services.parameter_lab.check_rate_limit", return_value=True)
|
||||
@patch("services.parameter_lab.require_admin_token", return_value=(True, None))
|
||||
@patch("services.parameter_lab.get_store")
|
||||
@unittest_run_loop
|
||||
async def test_create_compare_success(
|
||||
self, mock_get_store, _mock_admin, _mock_rate_limit
|
||||
):
|
||||
mock_get_store.return_value = self._store
|
||||
payload = {
|
||||
"workflow_json": '{"nodes":[]}',
|
||||
"items": ["model_A", "model_B"],
|
||||
"node_id": "10",
|
||||
"widget_name": "ckpt",
|
||||
}
|
||||
resp = await self.client.post("/openclaw/lab/compare", json=payload)
|
||||
self.assertEqual(resp.status, 200)
|
||||
data = await resp.json()
|
||||
self.assertTrue(data["ok"])
|
||||
self.assertEqual(len(data["plan"]["runs"]), 2)
|
||||
|
||||
@patch("services.parameter_lab.check_rate_limit", return_value=True)
|
||||
@patch("services.parameter_lab.require_admin_token", return_value=(True, None))
|
||||
@patch("services.parameter_lab.get_store")
|
||||
@unittest_run_loop
|
||||
async def test_create_compare_rejects_non_list_items(
|
||||
self, mock_get_store, _mock_admin, _mock_rate_limit
|
||||
):
|
||||
mock_get_store.return_value = self._store
|
||||
payload = {
|
||||
"workflow_json": '{"nodes":[]}',
|
||||
"items": "model_A",
|
||||
"node_id": "10",
|
||||
"widget_name": "ckpt",
|
||||
}
|
||||
resp = await self.client.post("/openclaw/lab/compare", json=payload)
|
||||
self.assertEqual(resp.status, 400)
|
||||
data = await resp.json()
|
||||
self.assertFalse(data["ok"])
|
||||
self.assertEqual(data["error"], "items_must_be_list")
|
||||
|
||||
@patch("services.parameter_lab.check_rate_limit", return_value=True)
|
||||
@patch("services.parameter_lab.require_admin_token", return_value=(True, None))
|
||||
@patch("services.parameter_lab.get_store")
|
||||
|
||||
@@ -63,6 +63,7 @@ AUTH_CLASS_BY_ROUTE = {
|
||||
("GET", "/lab/experiments"): "admin",
|
||||
("GET", "/lab/experiments/{exp_id}"): "admin",
|
||||
("POST", "/lab/experiments/{exp_id}/runs/{run_id}"): "admin",
|
||||
("POST", "/lab/compare"): "admin",
|
||||
}
|
||||
|
||||
OPTIONAL_SUFFIX_PREFIXES = (
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { tabManager } from "../openclaw_tabs.js";
|
||||
import { moltbotUI } from "../openclaw_ui.js";
|
||||
import { moltbotApi } from "../openclaw_api.js";
|
||||
|
||||
/**
|
||||
* F51: In-Canvas Context Toolbox
|
||||
@@ -11,6 +8,10 @@ export function registerContextToolbox() {
|
||||
app.registerExtension({
|
||||
name: "OpenClaw.ContextToolbox",
|
||||
async setup() {
|
||||
// Wait for MoltbotActions to be available (defer slightly if needed, or import directly)
|
||||
// Since we import moltbotActions, it should be ready.
|
||||
const { moltbotActions } = await import("../openclaw_ui.js");
|
||||
|
||||
const originalGetNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions;
|
||||
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
@@ -22,51 +23,50 @@ export function registerContextToolbox() {
|
||||
|
||||
// 1. Inspect in Explorer
|
||||
options.push({
|
||||
content: "\uD83D\uDD0D OpenClaw: Inspect Node", // Magnifying glass
|
||||
content: "\uD83D\uDD0D OpenClaw: Inspect",
|
||||
callback: () => {
|
||||
// Switch to Explorer tab
|
||||
const explorerTab = tabManager.tabs["explorer"];
|
||||
if (explorerTab) {
|
||||
tabManager.activateTab("explorer");
|
||||
// If Explorer has a filter/search API, use it
|
||||
if (explorerTab.instance && typeof explorerTab.instance.search === "function") {
|
||||
explorerTab.instance.search(node.type);
|
||||
} else {
|
||||
// Fallback: try to set input value if exposed
|
||||
const input = document.querySelector(".moltbot-explorer-search");
|
||||
if (input) {
|
||||
input.value = node.type;
|
||||
input.dispatchEvent(new Event("input"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
moltbotUI.showBanner("warning", "Explorer tab not available.");
|
||||
}
|
||||
moltbotActions.openExplorer(node.type);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. View Stats (Placeholder for now, maybe deep link to metrics)
|
||||
// options.push({ content: "OpenClaw: View Stats", ... });
|
||||
|
||||
// 3. Jump to Settings (if node has settings, generic for now)
|
||||
// 2. Doctor / Stats
|
||||
options.push({
|
||||
content: "\u2699\uFE0F OpenClaw: Settings", // Gear
|
||||
content: "\uD83D\uDC89 OpenClaw: Doctor",
|
||||
callback: () => {
|
||||
tabManager.activateTab("settings");
|
||||
moltbotActions.openDoctor();
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Missing Node Guidance (if node is red/missing)
|
||||
if (node.type === "undefined" || node.type === undefined || node.has_errors) {
|
||||
// 3. Queue / Status
|
||||
options.push({
|
||||
content: "\u23F3 OpenClaw: Queue Status",
|
||||
callback: () => {
|
||||
moltbotActions.openQueue("all");
|
||||
}
|
||||
});
|
||||
|
||||
// F50: OpenClaw Compare
|
||||
// Only show if node has inputs/widgets that can be compared
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
options.push({
|
||||
content: "\uD83E\uDE79 OpenClaw: Find Replacements", // Bandage
|
||||
callback: async () => {
|
||||
// Deep link to packs/manager or show replacements
|
||||
moltbotUI.showBanner("info", "Searching for replacements... (Simulated)");
|
||||
content: "\u2696\uFE0F OpenClaw: Compare...",
|
||||
callback: () => {
|
||||
moltbotActions.openCompare(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Settings
|
||||
options.push({
|
||||
content: "\u2699\uFE0F OpenClaw: Settings",
|
||||
callback: () => {
|
||||
moltbotActions.openSettings();
|
||||
}
|
||||
});
|
||||
|
||||
// 5. History (if applicable)
|
||||
// options.push({ ... });
|
||||
|
||||
return options;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,6 +308,98 @@ class QueueMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* F51: Unified Action Router.
|
||||
* Centralizes navigation and command logic for key operator tasks.
|
||||
*/
|
||||
export class MoltbotActions {
|
||||
constructor(ui) {
|
||||
this.ui = ui;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Settings tab, optionally scrolling to a specific section.
|
||||
*/
|
||||
openSettings(section = "general") {
|
||||
tabManager.activateTab("settings");
|
||||
// Future: signal settings tab to scroll to section
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Queue/Jobs view.
|
||||
* Currently mapped to "Queue" or "Jobs" tab if it exists, or just sidebar.
|
||||
* For MVP, we don't have a dedicated Jobs tab yet (it's part of Explorer or separate).
|
||||
* We'll map to Explorer for now as it has "Jobs" sub-view concept in plan.
|
||||
*/
|
||||
openQueue(filter = "all") {
|
||||
if (tabManager.tabs["job-monitor"]) {
|
||||
tabManager.activateTab("job-monitor");
|
||||
return;
|
||||
}
|
||||
tabManager.activateTab("explorer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Doctor diagnostics.
|
||||
* Opens Doctor view (in Explorer or Settings).
|
||||
*/
|
||||
async openDoctor() {
|
||||
tabManager.activateTab("settings");
|
||||
try {
|
||||
const res = await moltbotApi.fetch(moltbotApi._path("/security/doctor"));
|
||||
if (res.ok && res.data) {
|
||||
const issueCount = Array.isArray(res.data.issues)
|
||||
? res.data.issues.length
|
||||
: 0;
|
||||
this.ui.showBanner(
|
||||
issueCount > 0 ? "warning" : "success",
|
||||
issueCount > 0
|
||||
? `Doctor found ${issueCount} issues. See Settings for details.`
|
||||
: "Doctor check passed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Capability fallback below.
|
||||
}
|
||||
this.ui.showBanner(
|
||||
"info",
|
||||
"Doctor diagnostics endpoint unavailable. Open Settings for manual checks."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Explorer, optionally filtering by node type.
|
||||
*/
|
||||
openExplorer(nodeType = null) {
|
||||
tabManager.activateTab("explorer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open Parameter Lab for comparison.
|
||||
* Sets the lab to Compare mode for the given node.
|
||||
*/
|
||||
openCompare(node = null) {
|
||||
tabManager.activateTab("parameter-lab");
|
||||
// F50: Signal Lab to init comparison for this node
|
||||
// We'll rely on global accessible tab instance or event bus
|
||||
// For now, let's assume tabManager can give us the instance if we need to call methods directly
|
||||
// or we just open the tab and let the user set it up (MVP)
|
||||
if (node) {
|
||||
console.log("OpenClaw: Compare requested for", node.title || node.type);
|
||||
// Dispatch after tab activation tick so listeners are ready.
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("moltbot:lab:compare", { detail: { node } })
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const moltbotUI = new MoltbotUI();
|
||||
export const moltbotActions = new MoltbotActions(moltbotUI);
|
||||
|
||||
const monitor = new QueueMonitor(moltbotUI);
|
||||
monitor.start();
|
||||
|
||||
+145
-28
@@ -30,6 +30,10 @@ export const ParameterLabTab = {
|
||||
header.innerHTML = `
|
||||
<h3>Parameter Lab</h3>
|
||||
<div class="moltbot-lab-actions">
|
||||
<button id="lab-history" class="moltbot-btn has-icon" title="View History">
|
||||
\uD83D\uDCDC History
|
||||
</button>
|
||||
<div class="moltbot-separator"></div>
|
||||
<button id="lab-compare-models" class="moltbot-btn has-icon" title="Wizard: Compare Models">
|
||||
\u2696\uFE0F Compare Models
|
||||
</button>
|
||||
@@ -56,9 +60,79 @@ export const ParameterLabTab = {
|
||||
container.querySelector("#lab-add-dim").onclick = () => this.addDimensionUI();
|
||||
container.querySelector("#lab-generate").onclick = () => this.generatePlan();
|
||||
container.querySelector("#lab-compare-models").onclick = () => this.showCompareWizard();
|
||||
container.querySelector("#lab-history").onclick = () => this.showHistory();
|
||||
|
||||
// Initial Render
|
||||
this.renderDimensions();
|
||||
|
||||
// F50: Listen for Compare Request (once)
|
||||
if (!this._listeningForCompare) {
|
||||
window.addEventListener("moltbot:lab:compare", (e) => {
|
||||
const node = e.detail.node;
|
||||
if (node) {
|
||||
this.showCompareWizard(node);
|
||||
}
|
||||
});
|
||||
this._listeningForCompare = true;
|
||||
}
|
||||
},
|
||||
|
||||
async showHistory() {
|
||||
this.resultsContainer.innerHTML = "<div class='moltbot-loading'>Loading history...</div>";
|
||||
try {
|
||||
const res = await moltbotApi.fetch(moltbotApi._path("/lab/experiments"));
|
||||
if (res.ok && res.data) {
|
||||
this.renderHistoryList(res.data.experiments);
|
||||
} else {
|
||||
this.resultsContainer.innerHTML = "<div class='moltbot-error'>Failed to load history.</div>";
|
||||
}
|
||||
} catch (e) {
|
||||
this.resultsContainer.innerHTML = "<div class='moltbot-error'>Error: " + e.message + "</div>";
|
||||
}
|
||||
},
|
||||
|
||||
renderHistoryList(experiments) {
|
||||
this.resultsContainer.innerHTML = "";
|
||||
const header = document.createElement("div");
|
||||
header.className = "moltbot-lab-plan-header";
|
||||
header.innerHTML = `<h4>Experiment History</h4><span>${experiments.length} Records</span>`;
|
||||
this.resultsContainer.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "moltbot-lab-run-list";
|
||||
|
||||
if (experiments.length === 0) {
|
||||
list.innerHTML = "<div class='moltbot-hint'>No history found. Run a sweep or compare to see results here.</div>";
|
||||
}
|
||||
|
||||
experiments.forEach(exp => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "moltbot-lab-run-item";
|
||||
const dateStr = new Date(exp.created_at * 1000).toLocaleString();
|
||||
item.innerHTML = `
|
||||
<span class="run-idx">${exp.id.slice(0, 8)}</span>
|
||||
<span class="run-params">${dateStr}</span>
|
||||
<span class="run-status">${exp.completed_count}/${exp.run_count} runs</span>
|
||||
<button class="moltbot-btn-icon load-exp" title="Load Details">\u2192</button>
|
||||
`;
|
||||
item.querySelector(".load-exp").onclick = () => this.loadExperiment(exp.id);
|
||||
list.appendChild(item);
|
||||
});
|
||||
this.resultsContainer.appendChild(list);
|
||||
},
|
||||
|
||||
async loadExperiment(expId) {
|
||||
this.resultsContainer.innerHTML = "<div class='moltbot-loading'>Loading details...</div>";
|
||||
try {
|
||||
const res = await moltbotApi.fetch(moltbotApi._path(`/lab/experiments/${expId}`));
|
||||
if (res.ok && res.data) {
|
||||
this.plan = res.data.experiment;
|
||||
this.experimentId = this.plan.experiment_id;
|
||||
this.renderPlan();
|
||||
}
|
||||
} catch (e) {
|
||||
this.resultsContainer.innerHTML = "<div class='moltbot-error'>Failed to load experiment.</div>";
|
||||
}
|
||||
},
|
||||
|
||||
addDimensionUI(defaults = null) {
|
||||
@@ -114,20 +188,25 @@ export const ParameterLabTab = {
|
||||
},
|
||||
|
||||
// F50: Compare Models Wizard
|
||||
showCompareWizard() {
|
||||
// 1. Scan for loader nodes
|
||||
const nodes = app.graph._nodes.filter(n => n.type === "CheckpointLoaderSimple" || n.type === "LORALoader" || n.type === "UNETLoader");
|
||||
if (nodes.length === 0) {
|
||||
moltbotUI.showBanner("warning", "No Checkpoint/LoRA loaders found in workflow.");
|
||||
return;
|
||||
showCompareWizard(targetNode = null) {
|
||||
// 1. Scan for loader nodes if no target provided
|
||||
let node = targetNode;
|
||||
if (!node) {
|
||||
const nodes = app.graph._nodes.filter(n => n.type === "CheckpointLoaderSimple" || n.type === "LORALoader" || n.type === "UNETLoader");
|
||||
if (nodes.length === 0) {
|
||||
moltbotUI.showBanner("warning", "No Checkpoint/LoRA loaders found in workflow.");
|
||||
return;
|
||||
}
|
||||
node = nodes[0];
|
||||
}
|
||||
|
||||
// Simple prompt (In a real UI, use a modal. Here uses window.prompt/confirm for MVP or reuse config)
|
||||
// Let's autopick the first one and show a prompt for models?
|
||||
// Better: Clear dimensions and set up the first found loader.
|
||||
|
||||
const node = nodes[0];
|
||||
const widget = node.widgets.find(w => w.name === "ckpt_name" || w.name === "lora_name" || w.name === "unet_name");
|
||||
// 2. Find acceptable widget
|
||||
const widget = (node.widgets || []).find(
|
||||
w =>
|
||||
w.name === "ckpt_name" ||
|
||||
w.name === "lora_name" ||
|
||||
w.name === "unet_name"
|
||||
);
|
||||
|
||||
if (!widget) {
|
||||
moltbotUI.showBanner("error", "Could not find model widget on node " + node.id);
|
||||
@@ -141,10 +220,6 @@ export const ParameterLabTab = {
|
||||
this.dimensions = [];
|
||||
|
||||
// Add dimension pre-filled
|
||||
// We can't easily show a checkbox modal in 3 lines of code without a proper dialog system.
|
||||
// So we'll just add the dimension and let the user type/paste the model names,
|
||||
// OR we can try to get the options.
|
||||
|
||||
const options = widget.options?.values || [];
|
||||
let defaultValues = "";
|
||||
if (options.length > 0) {
|
||||
@@ -156,7 +231,7 @@ export const ParameterLabTab = {
|
||||
node_id: node.id,
|
||||
widget_name: widget.name,
|
||||
values_str: defaultValues,
|
||||
strategy: "grid"
|
||||
strategy: "compare"
|
||||
});
|
||||
|
||||
moltbotUI.showBanner("info", `Setup comparison for Node ${node.id} (${node.title}). Edit values to select models.`);
|
||||
@@ -181,7 +256,7 @@ export const ParameterLabTab = {
|
||||
if (v === "false") return false;
|
||||
// Check if it looks like a number
|
||||
const n = parseFloat(v);
|
||||
// If it parses as a number but was meant as a string (e.g. "1.5" model name),
|
||||
// If it parses as a number but was meant as a string (e.g. "1.5" model name),
|
||||
// we might have issues. But usually models have extensions.
|
||||
// If it contains non-numeric chars, it's a string.
|
||||
if (!isNaN(n) && isFinite(n) && !v.match(/[a-zA-Z]/)) return n;
|
||||
@@ -192,24 +267,47 @@ export const ParameterLabTab = {
|
||||
node_id: d.node_id,
|
||||
widget_name: d.widget_name,
|
||||
values: values,
|
||||
strategy: "grid"
|
||||
strategy: d.strategy || "grid"
|
||||
};
|
||||
});
|
||||
|
||||
moltbotUI.showBanner("info", "Generating sweep plan...");
|
||||
const hasCompare = params.some(p => p.strategy === "compare");
|
||||
if (hasCompare && params.length !== 1) {
|
||||
moltbotUI.showBanner(
|
||||
"error",
|
||||
"Compare mode supports exactly one comparison dimension."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Serialize current workflow
|
||||
// Use app.graph.serialize() to get state
|
||||
const graphJson = JSON.stringify(app.graph.serialize());
|
||||
|
||||
const res = await moltbotApi.fetch(moltbotApi._path("/lab/sweep"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
workflow_json: graphJson,
|
||||
params: params
|
||||
})
|
||||
});
|
||||
let res;
|
||||
if (hasCompare) {
|
||||
const compare = params[0];
|
||||
moltbotUI.showBanner("info", "Generating compare plan...");
|
||||
res = await moltbotApi.fetch(moltbotApi._path("/lab/compare"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
workflow_json: graphJson,
|
||||
items: compare.values,
|
||||
node_id: compare.node_id,
|
||||
widget_name: compare.widget_name
|
||||
})
|
||||
});
|
||||
} else {
|
||||
moltbotUI.showBanner("info", "Generating sweep plan...");
|
||||
res = await moltbotApi.fetch(moltbotApi._path("/lab/sweep"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
workflow_json: graphJson,
|
||||
params: params
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (res.ok && res.data) {
|
||||
this.plan = res.data.plan;
|
||||
@@ -246,14 +344,26 @@ export const ParameterLabTab = {
|
||||
item.innerHTML = `
|
||||
<span class="run-idx">#${idx + 1}</span>
|
||||
<span class="run-params">${JSON.stringify(run).slice(0, 50)}...</span>
|
||||
<span class="run-status pending">Pending</span>
|
||||
<span class="run-status ${run.status || 'pending'}">${run.status || 'Pending'}</span>
|
||||
<button class="moltbot-btn-icon replay-run" title="Replay (Apply Values)">\u21A9\uFE0F</button>
|
||||
`;
|
||||
item.dataset.idx = idx;
|
||||
item.querySelector(".replay-run").onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.replayRun(run);
|
||||
};
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
this.resultsContainer.appendChild(list);
|
||||
|
||||
// F50: Side-by-Side Comparison Layout
|
||||
if (this.plan.dimensions.some(d => d.strategy === "compare")) {
|
||||
this.resultsContainer.classList.add("moltbot-lab-compare-mode");
|
||||
} else {
|
||||
this.resultsContainer.classList.remove("moltbot-lab-compare-mode");
|
||||
}
|
||||
|
||||
this.resultsContainer.querySelector("#lab-run-all").onclick = () => this.runExperiment();
|
||||
},
|
||||
|
||||
@@ -343,6 +453,13 @@ export const ParameterLabTab = {
|
||||
}
|
||||
},
|
||||
|
||||
replayRun(run) {
|
||||
if (confirm("Apply these parameter values to the current workflow?")) {
|
||||
this.applyOverrides(run);
|
||||
moltbotUI.showBanner("success", "Values applied to nodes.");
|
||||
}
|
||||
},
|
||||
|
||||
applyOverrides(run) {
|
||||
Object.entries(run).forEach(([key, value]) => {
|
||||
if (key === "prompt_id" || key === "status") return;
|
||||
|
||||
Reference in New Issue
Block a user