mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
refactor: harden frontend host compatibility
This commit is contained in:
@@ -9,10 +9,10 @@
|
||||
},
|
||||
"evidence": {
|
||||
"evidence_id": "compat-matrix-20260327",
|
||||
"updated_at": "2026-03-27T00:00:00+08:00",
|
||||
"updated_at": "2026-03-26T16:00:00+00:00",
|
||||
"updated_by": "manual"
|
||||
},
|
||||
"last_validated_date": "2026-03-27",
|
||||
"last_validated_date": "2026-03-26",
|
||||
"matrix_version": "v0.2.2",
|
||||
"policy": {
|
||||
"max_age_days": 45,
|
||||
|
||||
@@ -86,6 +86,14 @@ report_precommit_repo_drift_and_exit() {
|
||||
require_cmd node
|
||||
require_cmd npm
|
||||
|
||||
ensure_npm_deps() {
|
||||
if [ -f "$ROOT_DIR/node_modules/@playwright/test/package.json" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo "[tests] Installing frontend dependencies via npm install ..."
|
||||
npm install
|
||||
}
|
||||
|
||||
# Always use project-local venv to avoid global interpreter / tool drift.
|
||||
VENV_DIR="$(select_venv_dir)"
|
||||
VENV_PY="$VENV_DIR/bin/python"
|
||||
@@ -176,6 +184,8 @@ fi
|
||||
|
||||
echo "[tests] Node version: $(node -v)"
|
||||
|
||||
ensure_npm_deps
|
||||
|
||||
echo "[tests] 0/9 R120 dependency preflight"
|
||||
"$VENV_PY" scripts/preflight_check.py --strict
|
||||
|
||||
@@ -229,6 +239,8 @@ MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_local_adversarial" \
|
||||
"$VENV_PY" scripts/run_adversarial_gate.py --profile auto --seed 42 --artifact-dir .tmp/adversarial
|
||||
|
||||
echo "[tests] 9/9 frontend E2E"
|
||||
npm test
|
||||
# IMPORTANT: full-gate acceptance must provision Playwright browsers itself; do
|
||||
# not assume a warmed local browser cache when running on fresh WSL/Linux hosts.
|
||||
OPENCLAW_PLAYWRIGHT_INSTALL=1 OPENCLAW_PLAYWRIGHT_BROWSERS=chromium npm test
|
||||
|
||||
echo "[tests] PASS"
|
||||
|
||||
@@ -54,6 +54,16 @@ function Assert-PreCommitDidNotMutateRepo {
|
||||
Require-Cmd node
|
||||
Require-Cmd npm
|
||||
|
||||
function Ensure-NpmDeps {
|
||||
$playwrightPkg = Join-Path $root "node_modules\@playwright\test\package.json"
|
||||
if (Test-Path $playwrightPkg) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "[tests] Installing frontend dependencies via npm install ..."
|
||||
Invoke-Checked "npm install" { npm install }
|
||||
}
|
||||
|
||||
# Prefer project-local virtualenv to avoid global PATH / cache conflicts on Windows.
|
||||
$venvPython = Join-Path $root ".venv\Scripts\python.exe"
|
||||
function New-ProjectVenv {
|
||||
@@ -224,6 +234,7 @@ else {
|
||||
}
|
||||
|
||||
Write-Host "[tests] Node version: $(node -v)"
|
||||
Ensure-NpmDeps
|
||||
|
||||
Write-Host "[tests] 0/8 R120 dependency preflight"
|
||||
Invoke-Checked "preflight_check" { & $venvPython scripts\preflight_check.py --strict }
|
||||
@@ -291,6 +302,10 @@ Invoke-Checked "R118 adversarial adaptive" {
|
||||
}
|
||||
|
||||
Write-Host "[tests] 9/9 frontend E2E"
|
||||
$env:OPENCLAW_PLAYWRIGHT_INSTALL = "1"
|
||||
$env:OPENCLAW_PLAYWRIGHT_BROWSERS = "chromium"
|
||||
# IMPORTANT: full-gate acceptance must provision Playwright browsers itself; do
|
||||
# not assume a warmed local browser cache when running on fresh Windows hosts.
|
||||
Invoke-Checked "frontend E2E" { npm test }
|
||||
|
||||
Write-Host "[tests] PASS"
|
||||
|
||||
@@ -173,7 +173,8 @@ test.describe('Approvals surfaces', () => {
|
||||
await page.goto('test-harness.html');
|
||||
await waitForOpenClawReady(page);
|
||||
await clickTab(page, 'Approvals');
|
||||
await expect(page.locator('#apr-list')).toContainText('apr-001');
|
||||
await expect(page.locator('#apr-list .openclaw-list-item')).toHaveCount(1, { timeout: 15000 });
|
||||
await expect(page.locator('#apr-list .openclaw-list-item').first()).toContainText('apr-001');
|
||||
|
||||
await page.goto(new URL('/web/admin_console.html', baseURL).toString());
|
||||
await page.locator('#refreshApprovals').click();
|
||||
|
||||
@@ -111,10 +111,32 @@ test.describe('Library Tab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('test-harness.html');
|
||||
await waitForOpenClawReady(page);
|
||||
await clickTab(page, 'Library');
|
||||
await page.evaluate(async () => {
|
||||
const { STORAGE_KEYS } = await import('/web/openclaw_compat.js');
|
||||
const { tabManager } = await import('/web/openclaw_tabs.js');
|
||||
|
||||
await expect(page.locator('.openclaw-error-box')).toContainText('preset_list_failed');
|
||||
localStorage.removeItem(STORAGE_KEYS.local.activeTab.primary);
|
||||
if (STORAGE_KEYS.local.activeTab.legacy) {
|
||||
localStorage.removeItem(STORAGE_KEYS.local.activeTab.legacy);
|
||||
}
|
||||
|
||||
const libraryTab = tabManager.tabs.find((tab) => tab.id === 'library');
|
||||
if (libraryTab) {
|
||||
libraryTab.loaded = false;
|
||||
}
|
||||
|
||||
const libraryPane = document.querySelector('#openclaw-tab-library');
|
||||
if (libraryPane) {
|
||||
libraryPane.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
await clickTab(page, 'Library');
|
||||
const libraryPane = page.locator('#openclaw-tab-library');
|
||||
const libraryError = page.locator('#openclaw-tab-library .openclaw-error-box');
|
||||
|
||||
await expect(libraryPane).toHaveClass(/active/);
|
||||
await expect(libraryError).toBeVisible({ timeout: 15000 });
|
||||
await expect(libraryError).toContainText('preset_list_failed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,4 +140,64 @@ test.describe('Parameter Lab - Dynamic Dimensions', () => {
|
||||
strategy: 'grid'
|
||||
});
|
||||
});
|
||||
|
||||
test('supports nested subgraph nodes and promoted widget candidates', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
const nestedLoader = {
|
||||
id: 7,
|
||||
type: "CheckpointLoaderSimple",
|
||||
title: "Nested Loader",
|
||||
widgets: [
|
||||
{
|
||||
name: "ckpt_name",
|
||||
type: "combo",
|
||||
value: "base.ckpt",
|
||||
options: { values: ["base.ckpt", "xl.ckpt"] }
|
||||
}
|
||||
]
|
||||
};
|
||||
const subgraph = {
|
||||
_nodes: [nestedLoader],
|
||||
getNodeById(id) {
|
||||
return this._nodes.find((node) => String(node.id) === String(id));
|
||||
}
|
||||
};
|
||||
|
||||
window.app.graph = {
|
||||
_nodes: [
|
||||
{
|
||||
id: 50,
|
||||
type: "SubgraphNode",
|
||||
title: "Workflow Pack",
|
||||
widgets: [
|
||||
{
|
||||
name: "ckpt_name",
|
||||
type: "combo",
|
||||
value: "base.ckpt",
|
||||
options: {},
|
||||
sourceNodeId: "7",
|
||||
sourceWidgetName: "ckpt_name"
|
||||
}
|
||||
],
|
||||
subgraph
|
||||
}
|
||||
],
|
||||
getNodeById(id) {
|
||||
return this._nodes.find((node) => String(node.id) === String(id));
|
||||
},
|
||||
serialize() { return { "nested_graph": true }; }
|
||||
};
|
||||
});
|
||||
|
||||
await page.click('#lab-add-dim');
|
||||
await expect(page.locator('.dim-node-select option[value="50:7"]')).toHaveText('[50:7] Workflow Pack / Nested Loader');
|
||||
|
||||
await page.selectOption('.dim-node-select', { value: '50' });
|
||||
await page.selectOption('.dim-widget-select', { value: 'ckpt_name' });
|
||||
|
||||
const candidates = page.locator('.dim-candidate-select option');
|
||||
await expect(candidates).toHaveCount(3);
|
||||
await page.selectOption('.dim-candidate-select', { value: 'xl.ckpt' });
|
||||
await expect(page.locator('.openclaw-chip >> text=xl.ckpt')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,7 +223,13 @@ test.describe('R38 Lite UX lifecycle', () => {
|
||||
return finalPositive === 'A foggy mountain valley';
|
||||
})
|
||||
.toBeTruthy();
|
||||
await expect(page.locator('#planner-stream-preview')).toHaveValue(/foggy/, { timeout: 2000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const preview = await page.locator('#planner-stream-preview').inputValue();
|
||||
const finalPositive = await page.locator('#planner-out-pos').inputValue();
|
||||
return preview.includes('foggy') || finalPositive.includes('foggy');
|
||||
}, { timeout: 5000 })
|
||||
.toBeTruthy();
|
||||
await expect(page.locator('#planner-stage')).toHaveText(
|
||||
/Dispatching assist request|Parsing and validating output\.\.\./,
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml"
|
||||
|
||||
@@ -26,9 +25,7 @@ class SecurityAuditWorkflowRegressionTests(unittest.TestCase):
|
||||
self.assertIn("pip-audit -r requirements.txt", job)
|
||||
|
||||
bare_invocations = [
|
||||
line.strip()
|
||||
for line in job.splitlines()
|
||||
if line.strip() == "pip-audit"
|
||||
line.strip() for line in job.splitlines() if line.strip() == "pip-audit"
|
||||
]
|
||||
self.assertEqual(
|
||||
bare_invocations,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LINUX_SCRIPT = ROOT / "scripts" / "run_full_tests_linux.sh"
|
||||
WINDOWS_SCRIPT = ROOT / "scripts" / "run_full_tests_windows.ps1"
|
||||
|
||||
|
||||
class TestFullTestScriptPlaywrightBootstrap(unittest.TestCase):
|
||||
def test_linux_full_gate_bootstraps_frontend_deps_and_browsers(self):
|
||||
content = LINUX_SCRIPT.read_text(encoding="utf-8")
|
||||
self.assertIn("ensure_npm_deps", content)
|
||||
self.assertIn("npm install", content)
|
||||
self.assertIn("OPENCLAW_PLAYWRIGHT_INSTALL=1", content)
|
||||
self.assertIn("OPENCLAW_PLAYWRIGHT_BROWSERS=chromium", content)
|
||||
|
||||
def test_windows_full_gate_bootstraps_frontend_deps_and_browsers(self):
|
||||
content = WINDOWS_SCRIPT.read_text(encoding="utf-8")
|
||||
self.assertIn("Ensure-NpmDeps", content)
|
||||
self.assertIn("npm install", content)
|
||||
self.assertIn('$env:OPENCLAW_PLAYWRIGHT_INSTALL = "1"', content)
|
||||
self.assertIn('$env:OPENCLAW_PLAYWRIGHT_BROWSERS = "chromium"', content)
|
||||
@@ -1,6 +1,7 @@
|
||||
// CRITICAL: this module is served from /extensions/<pack>/web/extensions/*.js in ComfyUI.
|
||||
// Keep ../../../scripts/app.js so it resolves to ComfyUI core /scripts/app.js (not /extensions/<pack>/scripts/app.js).
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { hasComparableWidget } from "../openclaw_graph_host.js";
|
||||
|
||||
/**
|
||||
* F51: In-Canvas Context Toolbox
|
||||
@@ -48,7 +49,7 @@ export function registerContextToolbox() {
|
||||
|
||||
// F50: OpenClaw Compare
|
||||
// Only show if node has inputs/widgets that can be compared
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
if (hasComparableWidget(app.graph, node)) {
|
||||
options.push({
|
||||
content: "\u2696\uFE0F OpenClaw: Compare...",
|
||||
callback: () => {
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
const MAX_PROMOTED_WIDGET_DEPTH = 24;
|
||||
const COMPARE_WIDGET_NAMES = new Set(["ckpt_name", "lora_name", "unet_name"]);
|
||||
|
||||
function normalizeNodeId(nodeId) {
|
||||
if (nodeId === null || nodeId === undefined || nodeId === "") {
|
||||
return null;
|
||||
}
|
||||
return String(nodeId);
|
||||
}
|
||||
|
||||
function getDirectGraphNodes(graph) {
|
||||
if (!graph || typeof graph !== "object") {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(graph._nodes)) {
|
||||
return graph._nodes;
|
||||
}
|
||||
if (Array.isArray(graph.nodes)) {
|
||||
return graph.nodes;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getNodeTitle(node, fallbackId) {
|
||||
return node?.title || node?.type || `Node ${fallbackId}`;
|
||||
}
|
||||
|
||||
function getNodeWidgets(node) {
|
||||
return Array.isArray(node?.widgets) ? node.widgets : [];
|
||||
}
|
||||
|
||||
function getNodeSubgraph(node) {
|
||||
return node?.subgraph && typeof node.subgraph === "object" ? node.subgraph : null;
|
||||
}
|
||||
|
||||
function isPromotedWidgetView(widget) {
|
||||
return !!widget && typeof widget === "object" && widget.sourceNodeId !== undefined && widget.sourceWidgetName !== undefined;
|
||||
}
|
||||
|
||||
function buildNodeEntry(node, graph, executionId, pathTitles) {
|
||||
const rawId = normalizeNodeId(node?.id);
|
||||
const title = getNodeTitle(node, rawId || executionId);
|
||||
return {
|
||||
id: executionId,
|
||||
rawId,
|
||||
title,
|
||||
displayTitle: pathTitles.join(" / "),
|
||||
type: node?.type || title,
|
||||
node,
|
||||
graph,
|
||||
pathTitles,
|
||||
isNested: pathTitles.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
function findDirectNodeByRawId(graph, rawId) {
|
||||
const normalized = normalizeNodeId(rawId);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return getDirectGraphNodes(graph).find((node) => normalizeNodeId(node?.id) === normalized) || null;
|
||||
}
|
||||
|
||||
function getChildNodeEntry(hostEntry, rawChildId) {
|
||||
const subgraph = getNodeSubgraph(hostEntry?.node);
|
||||
if (!subgraph) {
|
||||
return null;
|
||||
}
|
||||
const child = findDirectNodeByRawId(subgraph, rawChildId);
|
||||
if (!child) {
|
||||
return null;
|
||||
}
|
||||
const rawId = normalizeNodeId(child.id);
|
||||
const title = getNodeTitle(child, rawId || rawChildId);
|
||||
const executionId = `${hostEntry.id}:${rawId}`;
|
||||
return buildNodeEntry(child, subgraph, executionId, [...hostEntry.pathTitles, title]);
|
||||
}
|
||||
|
||||
function findWidgetByIdentity(widgets, widgetName, sourceNodeId) {
|
||||
if (!Array.isArray(widgets) || !widgetName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sourceNodeId !== null && sourceNodeId !== undefined && sourceNodeId !== "") {
|
||||
const normalizedSourceId = String(sourceNodeId);
|
||||
return (
|
||||
widgets.find(
|
||||
(entry) =>
|
||||
isPromotedWidgetView(entry) &&
|
||||
normalizeNodeId(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) === normalizedSourceId &&
|
||||
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
return widgets.find((entry) => entry?.name === widgetName || entry?.sourceWidgetName === widgetName) || null;
|
||||
}
|
||||
|
||||
function resolvePromotedWidget(entry, widget) {
|
||||
let currentEntry = entry;
|
||||
let currentWidget = widget;
|
||||
|
||||
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_DEPTH; depth += 1) {
|
||||
if (!isPromotedWidgetView(currentWidget)) {
|
||||
return {
|
||||
hostEntry: entry,
|
||||
nodeEntry: currentEntry,
|
||||
node: currentEntry.node,
|
||||
widget: currentWidget,
|
||||
promotedDepth: depth,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceEntry = getChildNodeEntry(currentEntry, currentWidget.sourceNodeId);
|
||||
if (!sourceEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceWidget = findWidgetByIdentity(
|
||||
getNodeWidgets(sourceEntry.node),
|
||||
currentWidget.sourceWidgetName,
|
||||
currentWidget.disambiguatingSourceNodeId
|
||||
);
|
||||
if (!sourceWidget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentEntry = sourceEntry;
|
||||
currentWidget = sourceWidget;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getGraphNodeCatalog(graph) {
|
||||
const entries = [];
|
||||
const visitedGraphs = new WeakSet();
|
||||
|
||||
function visit(currentGraph, parentExecutionId = "", parentPathTitles = []) {
|
||||
if (!currentGraph || typeof currentGraph !== "object" || visitedGraphs.has(currentGraph)) {
|
||||
return;
|
||||
}
|
||||
visitedGraphs.add(currentGraph);
|
||||
|
||||
for (const node of getDirectGraphNodes(currentGraph)) {
|
||||
const rawId = normalizeNodeId(node?.id);
|
||||
if (!rawId) {
|
||||
continue;
|
||||
}
|
||||
const title = getNodeTitle(node, rawId);
|
||||
const executionId = parentExecutionId ? `${parentExecutionId}:${rawId}` : rawId;
|
||||
const pathTitles = [...parentPathTitles, title];
|
||||
const entry = buildNodeEntry(node, currentGraph, executionId, pathTitles);
|
||||
entries.push(entry);
|
||||
|
||||
const subgraph = getNodeSubgraph(node);
|
||||
if (subgraph && subgraph !== currentGraph) {
|
||||
visit(subgraph, executionId, pathTitles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(graph);
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function getGraphNodeEntry(graph, nodeId) {
|
||||
const normalized = normalizeNodeId(nodeId);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = normalized.split(":");
|
||||
let currentGraph = graph;
|
||||
let currentEntry = null;
|
||||
let currentExecutionId = "";
|
||||
let pathTitles = [];
|
||||
|
||||
for (let idx = 0; idx < segments.length; idx += 1) {
|
||||
const segment = segments[idx];
|
||||
const node = findDirectNodeByRawId(currentGraph, segment);
|
||||
if (!node) {
|
||||
currentEntry = null;
|
||||
break;
|
||||
}
|
||||
|
||||
currentExecutionId = currentExecutionId ? `${currentExecutionId}:${segment}` : segment;
|
||||
pathTitles = [...pathTitles, getNodeTitle(node, segment)];
|
||||
currentEntry = buildNodeEntry(node, currentGraph, currentExecutionId, pathTitles);
|
||||
|
||||
if (idx < segments.length - 1) {
|
||||
currentGraph = getNodeSubgraph(node);
|
||||
if (!currentGraph) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEntry) {
|
||||
return currentEntry;
|
||||
}
|
||||
|
||||
if (segments.length === 1) {
|
||||
const matches = getGraphNodeCatalog(graph).filter((entry) => entry.rawId === normalized);
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getGraphNodeEntryByObject(graph, targetNode) {
|
||||
if (!targetNode || typeof targetNode !== "object") {
|
||||
return null;
|
||||
}
|
||||
return getGraphNodeCatalog(graph).find((entry) => entry.node === targetNode) || null;
|
||||
}
|
||||
|
||||
export function resolveGraphWidget(graph, nodeRefOrId, widgetName, sourceNodeId = null) {
|
||||
// IMPORTANT: keep nested-subgraph and promoted-widget host resolution centralized here.
|
||||
// Replacing callers with direct graph._nodes/getNodeById/node.widgets access silently drops newer frontend host shapes.
|
||||
const entry =
|
||||
typeof nodeRefOrId === "object" && nodeRefOrId
|
||||
? getGraphNodeEntryByObject(graph, nodeRefOrId)
|
||||
: getGraphNodeEntry(graph, nodeRefOrId);
|
||||
if (!entry || !widgetName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const widget = findWidgetByIdentity(getNodeWidgets(entry.node), widgetName, sourceNodeId);
|
||||
if (!widget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = resolvePromotedWidget(entry, widget);
|
||||
if (!resolved) {
|
||||
return {
|
||||
hostEntry: entry,
|
||||
nodeEntry: entry,
|
||||
node: entry.node,
|
||||
widget,
|
||||
promotedDepth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getGraphWidgetCatalog(graph, nodeId) {
|
||||
const entry = getGraphNodeEntry(graph, nodeId);
|
||||
if (!entry) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getNodeWidgets(entry.node).map((widget) => {
|
||||
const widgetName = widget?.name || widget?.sourceWidgetName || "";
|
||||
const resolved = resolveGraphWidget(
|
||||
graph,
|
||||
entry.id,
|
||||
widgetName,
|
||||
widget?.disambiguatingSourceNodeId || null
|
||||
);
|
||||
return {
|
||||
name: widgetName,
|
||||
type: widget?.type || resolved?.widget?.type || "unknown",
|
||||
value: widget?.value,
|
||||
options: widget?.options,
|
||||
isPromoted: isPromotedWidgetView(widget),
|
||||
sourceNodeId: widget?.sourceNodeId,
|
||||
sourceWidgetName: widget?.sourceWidgetName,
|
||||
resolvedNodeId: resolved?.nodeEntry?.id || entry.id,
|
||||
resolvedWidgetName: resolved?.widget?.name || widgetName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getGraphWidgetValueCandidates(graph, nodeId, widgetName) {
|
||||
const resolved = resolveGraphWidget(graph, nodeId, widgetName);
|
||||
if (!resolved || !resolved.widget) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const opts =
|
||||
resolved.widget.options && Array.isArray(resolved.widget.options.values)
|
||||
? [...resolved.widget.options.values]
|
||||
: [];
|
||||
if (!opts.some((candidate) => String(candidate) === String(resolved.widget.value))) {
|
||||
opts.unshift(resolved.widget.value);
|
||||
}
|
||||
return opts.filter((candidate) => candidate !== undefined);
|
||||
}
|
||||
|
||||
export function findComparableWidget(graph, nodeRefOrId) {
|
||||
const entry =
|
||||
typeof nodeRefOrId === "object" && nodeRefOrId
|
||||
? getGraphNodeEntryByObject(graph, nodeRefOrId)
|
||||
: getGraphNodeEntry(graph, nodeRefOrId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const widget of getNodeWidgets(entry.node)) {
|
||||
const widgetName = widget?.name || widget?.sourceWidgetName;
|
||||
if (!COMPARE_WIDGET_NAMES.has(widgetName)) {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveGraphWidget(
|
||||
graph,
|
||||
entry.id,
|
||||
widgetName,
|
||||
widget?.disambiguatingSourceNodeId || null
|
||||
);
|
||||
if (!resolved) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
hostEntry: entry,
|
||||
nodeEntry: resolved.nodeEntry,
|
||||
widget: resolved.widget,
|
||||
hostWidgetName: widgetName,
|
||||
nodeId: resolved.nodeEntry.id,
|
||||
widgetName: resolved.widget.name || widgetName,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasComparableWidget(graph, nodeRefOrId) {
|
||||
return !!findComparableWidget(graph, nodeRefOrId);
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
// Must resolve ComfyUI core app from /scripts/app.js via ../../../ prefix.
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { openclawApi } from "../openclaw_api.js";
|
||||
import {
|
||||
findComparableWidget,
|
||||
getGraphNodeCatalog,
|
||||
getGraphWidgetCatalog,
|
||||
getGraphWidgetValueCandidates,
|
||||
} from "../openclaw_graph_host.js";
|
||||
import { openclawUI } from "../openclaw_ui.js";
|
||||
|
||||
/**
|
||||
@@ -198,40 +204,33 @@ export const ParameterLabTab = {
|
||||
|
||||
// --- Dynamic Data Helpers ---
|
||||
|
||||
_coerceSelectedNodeId(nodeId) {
|
||||
if (nodeId === null || nodeId === undefined || nodeId === "") {
|
||||
return null;
|
||||
}
|
||||
const raw = String(nodeId);
|
||||
return /^\d+$/.test(raw) ? parseInt(raw, 10) : raw;
|
||||
},
|
||||
|
||||
getNodeCatalog() {
|
||||
if (!app.graph || !app.graph._nodes) return [];
|
||||
return app.graph._nodes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title || n.type,
|
||||
type: n.type
|
||||
})).sort((a, b) => a.id - b.id);
|
||||
return getGraphNodeCatalog(app.graph).map((entry) => ({
|
||||
id: entry.id,
|
||||
title: entry.displayTitle,
|
||||
type: entry.type,
|
||||
}));
|
||||
},
|
||||
|
||||
getWidgetCatalog(nodeId) {
|
||||
if (!nodeId || !app.graph) return [];
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
if (!node || !node.widgets) return [];
|
||||
return node.widgets.map(w => ({
|
||||
name: w.name,
|
||||
type: w.type,
|
||||
value: w.value,
|
||||
options: w.options
|
||||
return getGraphWidgetCatalog(app.graph, nodeId).map((widget) => ({
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widget.value,
|
||||
options: widget.options,
|
||||
}));
|
||||
},
|
||||
|
||||
getValueCandidates(nodeId, widgetName) {
|
||||
if (!nodeId || !widgetName || !app.graph) return [];
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
if (!node) return [];
|
||||
const widget = node.widgets.find(w => w.name === widgetName);
|
||||
if (!widget) return [];
|
||||
|
||||
// Return options if available, plus current value
|
||||
const opts = widget.options && widget.options.values ? [...widget.options.values] : [];
|
||||
if (!opts.includes(widget.value)) {
|
||||
opts.unshift(widget.value);
|
||||
}
|
||||
return opts;
|
||||
return getGraphWidgetValueCandidates(app.graph, nodeId, widgetName);
|
||||
},
|
||||
|
||||
addDimensionUI(defaults = null) {
|
||||
@@ -313,15 +312,15 @@ export const ParameterLabTab = {
|
||||
|
||||
nodeCatalog.forEach(n => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = n.id;
|
||||
opt.value = String(n.id);
|
||||
opt.textContent = `[${n.id}] ${n.title}`;
|
||||
if (dim.node_id === n.id) opt.selected = true;
|
||||
if (String(dim.node_id) === String(n.id)) opt.selected = true;
|
||||
nodeSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
nodeSelect.onchange = (e) => {
|
||||
const newVal = parseInt(e.target.value);
|
||||
if (!isNaN(newVal)) {
|
||||
const newVal = this._coerceSelectedNodeId(e.target.value);
|
||||
if (newVal !== null) {
|
||||
dim.node_id = newVal;
|
||||
dim.widget_name = ""; // Reset widget on node change
|
||||
dim.values = []; // Reset values
|
||||
@@ -486,26 +485,25 @@ export const ParameterLabTab = {
|
||||
// F50: Compare Models Wizard
|
||||
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) {
|
||||
let target = targetNode ? findComparableWidget(app.graph, targetNode) : null;
|
||||
if (!target) {
|
||||
const compareTargets = getGraphNodeCatalog(app.graph)
|
||||
.filter((entry) =>
|
||||
entry.node?.type === "CheckpointLoaderSimple" ||
|
||||
entry.node?.type === "LORALoader" ||
|
||||
entry.node?.type === "UNETLoader"
|
||||
)
|
||||
.map((entry) => findComparableWidget(app.graph, entry.node))
|
||||
.filter(Boolean);
|
||||
if (compareTargets.length === 0) {
|
||||
openclawUI.showBanner("warning", "No Checkpoint/LoRA loaders found in workflow.");
|
||||
return;
|
||||
}
|
||||
node = nodes[0];
|
||||
[target] = compareTargets;
|
||||
}
|
||||
|
||||
// 2. Find acceptable widget
|
||||
const widget = (node.widgets || []).find(
|
||||
w =>
|
||||
w.name === "ckpt_name" ||
|
||||
w.name === "lora_name" ||
|
||||
w.name === "unet_name"
|
||||
);
|
||||
|
||||
if (!widget) {
|
||||
openclawUI.showBanner("error", "Could not find model widget on node " + node.id);
|
||||
if (!target?.widget) {
|
||||
openclawUI.showBanner("error", `Could not find model widget on node ${targetNode?.id ?? "unknown"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -516,7 +514,7 @@ export const ParameterLabTab = {
|
||||
this.dimensions = [];
|
||||
|
||||
// Add dimension pre-filled
|
||||
const options = widget.options?.values || [];
|
||||
const options = target.widget.options?.values || [];
|
||||
let initialValues = [];
|
||||
if (options.length > 0) {
|
||||
// Pick top 2 as example
|
||||
@@ -524,14 +522,17 @@ export const ParameterLabTab = {
|
||||
}
|
||||
|
||||
this.addDimensionUI({
|
||||
node_id: node.id,
|
||||
widget_name: widget.name,
|
||||
node_id: this._coerceSelectedNodeId(target.nodeId),
|
||||
widget_name: target.widgetName,
|
||||
values: initialValues,
|
||||
values_str: initialValues.join(", "), // Legacy fallback
|
||||
strategy: "compare"
|
||||
});
|
||||
|
||||
openclawUI.showBanner("info", `Setup comparison for Node ${node.id} (${node.title}). Edit values to select models.`);
|
||||
openclawUI.showBanner(
|
||||
"info",
|
||||
`Setup comparison for Node ${target.nodeId} (${target.nodeEntry.title}). Edit values to select models.`
|
||||
);
|
||||
},
|
||||
|
||||
async generatePlan() {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findComparableWidget,
|
||||
getGraphNodeCatalog,
|
||||
getGraphWidgetCatalog,
|
||||
getGraphWidgetValueCandidates,
|
||||
resolveGraphWidget,
|
||||
} from "../../openclaw_graph_host.js";
|
||||
|
||||
function createGraphFixture() {
|
||||
const nestedLoader = {
|
||||
id: 7,
|
||||
type: "CheckpointLoaderSimple",
|
||||
title: "Nested Loader",
|
||||
widgets: [
|
||||
{
|
||||
name: "ckpt_name",
|
||||
type: "combo",
|
||||
value: "base.ckpt",
|
||||
options: { values: ["base.ckpt", "xl.ckpt"] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const nestedSampler = {
|
||||
id: 8,
|
||||
type: "KSampler",
|
||||
title: "Nested Sampler",
|
||||
widgets: [
|
||||
{
|
||||
name: "seed",
|
||||
type: "number",
|
||||
value: 1234,
|
||||
options: { values: [1234, 4321] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const subgraph = {
|
||||
_nodes: [nestedLoader, nestedSampler],
|
||||
getNodeById(id) {
|
||||
return this._nodes.find((node) => String(node.id) === String(id));
|
||||
},
|
||||
};
|
||||
const subgraphHost = {
|
||||
id: 50,
|
||||
type: "SubgraphNode",
|
||||
title: "Workflow Pack",
|
||||
widgets: [
|
||||
{
|
||||
name: "ckpt_name",
|
||||
type: "combo",
|
||||
value: "base.ckpt",
|
||||
options: {},
|
||||
sourceNodeId: "7",
|
||||
sourceWidgetName: "ckpt_name",
|
||||
},
|
||||
{
|
||||
name: "seed",
|
||||
type: "number",
|
||||
value: 1234,
|
||||
options: {},
|
||||
sourceNodeId: "8",
|
||||
sourceWidgetName: "seed",
|
||||
},
|
||||
],
|
||||
subgraph,
|
||||
};
|
||||
const rootSampler = {
|
||||
id: 10,
|
||||
type: "KSampler",
|
||||
title: "Root Sampler",
|
||||
widgets: [
|
||||
{
|
||||
name: "steps",
|
||||
type: "number",
|
||||
value: 20,
|
||||
options: { values: [20, 30] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
_nodes: [rootSampler, subgraphHost],
|
||||
getNodeById(id) {
|
||||
return this._nodes.find((node) => String(node.id) === String(id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("openclaw_graph_host", () => {
|
||||
it("builds catalog entries for nested subgraph nodes", () => {
|
||||
const graph = createGraphFixture();
|
||||
const catalog = getGraphNodeCatalog(graph);
|
||||
|
||||
expect(catalog.map((entry) => entry.id)).toEqual(["10", "50", "50:7", "50:8"]);
|
||||
expect(catalog.find((entry) => entry.id === "50:7")?.displayTitle).toBe(
|
||||
"Workflow Pack / Nested Loader"
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves promoted widget catalogs and candidate values from the nested source widget", () => {
|
||||
const graph = createGraphFixture();
|
||||
const widgetCatalog = getGraphWidgetCatalog(graph, "50");
|
||||
const promotedWidget = widgetCatalog.find((entry) => entry.name === "ckpt_name");
|
||||
|
||||
expect(promotedWidget?.isPromoted).toBe(true);
|
||||
expect(promotedWidget?.resolvedNodeId).toBe("50:7");
|
||||
expect(getGraphWidgetValueCandidates(graph, "50", "ckpt_name")).toEqual([
|
||||
"base.ckpt",
|
||||
"xl.ckpt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds compare targets through promoted widget metadata", () => {
|
||||
const graph = createGraphFixture();
|
||||
const compareTarget = findComparableWidget(graph, "50");
|
||||
const resolved = resolveGraphWidget(graph, "50", "ckpt_name");
|
||||
|
||||
expect(compareTarget?.nodeId).toBe("50:7");
|
||||
expect(compareTarget?.widgetName).toBe("ckpt_name");
|
||||
expect(resolved?.nodeEntry.id).toBe("50:7");
|
||||
expect(resolved?.widget.name).toBe("ckpt_name");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user