diff --git a/.gitignore b/.gitignore index e99eebf..b55b7c8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ coverage.xml REFERENCE/ AGENT_CONTEXT.md AGENTS.md +AGENT.md ROADMAP.md tests_output.txt node_modules/ diff --git a/README.md b/README.md index 40b9dfe..bc4b681 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ This project is intentionally **not** a general-purpose assistant platform with ## Latest Updates - Click to expand +
+Operator UX improvements: context toolbox, parameter lab history/replay, and compare workflow baseline + +- 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. + +
+
Pack security hardening: path traversal defense and strict API validation @@ -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 diff --git a/api/routes.py b/api/routes.py index 005df38..c12c64e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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), ( diff --git a/docs/release/operator_ux_contracts.md b/docs/release/operator_ux_contracts.md index 53da6ce..810b852 100644 --- a/docs/release/operator_ux_contracts.md +++ b/docs/release/operator_ux_contracts.md @@ -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; } diff --git a/services/parameter_lab.py b/services/parameter_lab.py index 006993d..3549010 100644 --- a/services/parameter_lab.py +++ b/services/parameter_lab.py @@ -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") diff --git a/tests/test_parameter_lab.py b/tests/test_parameter_lab.py index 8c0f912..60bf918 100644 --- a/tests/test_parameter_lab.py +++ b/tests/test_parameter_lab.py @@ -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") diff --git a/tests/test_s33r76r78_auth_hardening.py b/tests/test_s33r76r78_auth_hardening.py index 0883bf7..ba5d785 100644 --- a/tests/test_s33r76r78_auth_hardening.py +++ b/tests/test_s33r76r78_auth_hardening.py @@ -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 = ( diff --git a/web/extensions/context_toolbox.js b/web/extensions/context_toolbox.js index c820db4..60b36ea 100644 --- a/web/extensions/context_toolbox.js +++ b/web/extensions/context_toolbox.js @@ -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; }; } diff --git a/web/openclaw_ui.js b/web/openclaw_ui.js index f8903b1..1a829a2 100644 --- a/web/openclaw_ui.js +++ b/web/openclaw_ui.js @@ -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(); diff --git a/web/tabs/parameter_lab_tab.js b/web/tabs/parameter_lab_tab.js index ce5349a..60652dc 100644 --- a/web/tabs/parameter_lab_tab.js +++ b/web/tabs/parameter_lab_tab.js @@ -30,6 +30,10 @@ export const ParameterLabTab = { header.innerHTML = `

Parameter Lab

+ +
@@ -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 = "
Loading history...
"; + try { + const res = await moltbotApi.fetch(moltbotApi._path("/lab/experiments")); + if (res.ok && res.data) { + this.renderHistoryList(res.data.experiments); + } else { + this.resultsContainer.innerHTML = "
Failed to load history.
"; + } + } catch (e) { + this.resultsContainer.innerHTML = "
Error: " + e.message + "
"; + } + }, + + renderHistoryList(experiments) { + this.resultsContainer.innerHTML = ""; + const header = document.createElement("div"); + header.className = "moltbot-lab-plan-header"; + header.innerHTML = `

Experiment History

${experiments.length} Records`; + this.resultsContainer.appendChild(header); + + const list = document.createElement("div"); + list.className = "moltbot-lab-run-list"; + + if (experiments.length === 0) { + list.innerHTML = "
No history found. Run a sweep or compare to see results here.
"; + } + + 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 = ` + ${exp.id.slice(0, 8)} + ${dateStr} + ${exp.completed_count}/${exp.run_count} runs + + `; + item.querySelector(".load-exp").onclick = () => this.loadExperiment(exp.id); + list.appendChild(item); + }); + this.resultsContainer.appendChild(list); + }, + + async loadExperiment(expId) { + this.resultsContainer.innerHTML = "
Loading details...
"; + 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 = "
Failed to load experiment.
"; + } }, 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 = ` #${idx + 1} ${JSON.stringify(run).slice(0, 50)}... - Pending + ${run.status || 'Pending'} + `; 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;