feat(operator-ux): close F49/F51/F52/F50 remainder gaps and sync compare/auth/history contracts

This commit is contained in:
rookiestar28
2026-02-17 00:55:33 +08:00
parent df91b68e3a
commit 1769546648
10 changed files with 564 additions and 75 deletions
+1
View File
@@ -19,6 +19,7 @@ coverage.xml
REFERENCE/
AGENT_CONTEXT.md
AGENTS.md
AGENT.md
ROADMAP.md
tests_output.txt
node_modules/
+55
View File
@@ -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
+3
View File
@@ -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),
(
+11 -11
View File
@@ -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
View File
@@ -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")
+96
View File
@@ -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")
+1
View File
@@ -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 = (
+34 -34
View File
@@ -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;
};
}
+92
View File
@@ -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
View File
@@ -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;