Refine Control Center and legacy debug UX

This commit is contained in:
BlackVortexAI
2026-08-25 07:56:32 +02:00
parent a40ee07380
commit 2a70ec5030
15 changed files with 155 additions and 314 deletions
-5
View File
@@ -11,11 +11,6 @@ try:
except Exception as error:
print(f"BV Node Pack: completion API unavailable: {error}")
try:
from .py.util.debug_bridge import routes as _debug_bridge_routes # noqa: F401
except Exception as error:
print(f"BV Node Pack: debug bridge API unavailable: {error}")
try:
from .py.util import remote_llm_routes as _remote_llm_routes # noqa: F401
except Exception as error:
+27 -27
View File
File diff suppressed because one or more lines are too long
-3
View File
@@ -1,3 +0,0 @@
from .service import DebugBridge, DebugBridgeError, debug_bridge
__all__ = ["DebugBridge", "DebugBridgeError", "debug_bridge"]
-65
View File
@@ -1,65 +0,0 @@
from __future__ import annotations
import json
from aiohttp import web
from server import PromptServer
from .service import DebugBridgeError, debug_bridge
routes = PromptServer.instance.routes
_LOOPBACK_ADDRESSES = frozenset({"127.0.0.1", "::1", "localhost"})
def _loopback_only(request: web.Request) -> None:
if request.remote not in _LOOPBACK_ADDRESSES:
raise web.HTTPForbidden(text="BV Debug Bridge accepts loopback requests only")
async def _json_body(request: web.Request) -> dict:
try:
body = await request.json()
except (json.JSONDecodeError, ValueError) as error:
raise web.HTTPBadRequest(text="Request body must be valid JSON") from error
if not isinstance(body, dict):
raise web.HTTPBadRequest(text="Request body must be a JSON object")
return body
@routes.get("/bv_nodepack/debug/status")
async def debug_status(request):
_loopback_only(request)
return web.json_response(debug_bridge.status())
@routes.post("/bv_nodepack/debug/session")
async def debug_session(request):
_loopback_only(request)
body = await _json_body(request)
if set(body) != {"enabled"} or not isinstance(body["enabled"], bool):
raise web.HTTPBadRequest(text="Body must contain exactly one boolean enabled field")
return web.json_response(debug_bridge.set_enabled(body["enabled"]))
@routes.put("/bv_nodepack/debug/snapshot")
async def debug_snapshot_put(request):
_loopback_only(request)
body = await _json_body(request)
if not set(body).issubset({"prompt", "workflow_name"}) or "prompt" not in body:
raise web.HTTPBadRequest(text="Body must contain prompt and may contain workflow_name")
try:
result = debug_bridge.publish(body["prompt"], body.get("workflow_name", ""))
except DebugBridgeError as error:
raise web.HTTPConflict(text=str(error)) from error
return web.json_response(result)
@routes.get("/bv_nodepack/debug/snapshot")
async def debug_snapshot_get(request):
_loopback_only(request)
try:
result = debug_bridge.snapshot()
except DebugBridgeError as error:
raise web.HTTPConflict(text=str(error)) from error
return web.json_response(result)
-83
View File
@@ -1,83 +0,0 @@
from __future__ import annotations
import copy
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
MAX_SNAPSHOT_BYTES = 33_554_432
class DebugBridgeError(ValueError):
pass
@dataclass(frozen=True)
class DebugSnapshot:
prompt: dict[str, Any]
revision: str
updated_at: str
workflow_name: str
class DebugBridge:
def __init__(self):
self._enabled = False
self._snapshot: DebugSnapshot | None = None
def set_enabled(self, enabled: bool) -> dict[str, Any]:
self._enabled = bool(enabled)
if not self._enabled:
self._snapshot = None
return self.status()
def publish(self, prompt: Any, workflow_name: Any = "") -> dict[str, Any]:
if not self._enabled:
raise DebugBridgeError("BV Debug Bridge is disabled")
if not isinstance(prompt, dict) or not prompt:
raise DebugBridgeError("prompt must be a non-empty object")
if any(not isinstance(node_id, str) or not isinstance(node, dict) for node_id, node in prompt.items()):
raise DebugBridgeError("prompt must map string node IDs to objects")
encoded = json.dumps(prompt, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_SNAPSHOT_BYTES:
raise DebugBridgeError(f"prompt exceeds {MAX_SNAPSHOT_BYTES} bytes")
clean_prompt = json.loads(encoded.decode("utf-8"))
revision = hashlib.sha256(encoded).hexdigest()
name = str(workflow_name).strip()[:256]
self._snapshot = DebugSnapshot(
prompt=clean_prompt,
revision=revision,
updated_at=datetime.now(timezone.utc).isoformat(),
workflow_name=name,
)
return self.status()
def status(self) -> dict[str, Any]:
snapshot = self._snapshot
return {
"schema": "bv.debug_bridge.status",
"version": 1,
"enabled": self._enabled,
"snapshot_available": snapshot is not None,
"revision": snapshot.revision if snapshot else None,
"updated_at": snapshot.updated_at if snapshot else None,
"workflow_name": snapshot.workflow_name if snapshot else "",
"node_count": len(snapshot.prompt) if snapshot else 0,
}
def snapshot(self) -> dict[str, Any]:
if not self._enabled:
raise DebugBridgeError("BV Debug Bridge is disabled")
if self._snapshot is None:
raise DebugBridgeError("No BV Debug Bridge snapshot is available")
return {
**self.status(),
"schema": "bv.debug_bridge.snapshot",
"prompt": copy.deepcopy(self._snapshot.prompt),
}
debug_bridge = DebugBridge()
+48
View File
@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import {readFileSync} from "node:fs";
const portal=readFileSync(new URL("../ui/src/components/BVPortal.tsx",import.meta.url),"utf8");
const editor=readFileSync(new URL("../ui/src/components/control/BVControlComponent.tsx",import.meta.url),"utf8");
const row=readFileSync(new URL("../ui/src/components/control/BVControlRowComponent.tsx",import.meta.url),"utf8");
const styles=readFileSync(new URL("../ui/src/index.css",import.meta.url),"utf8");
const nodeIntegration=readFileSync(new URL("../ui/src/components/control/bv_control_center.ts",import.meta.url),"utf8");
const entrypoint=readFileSync(new URL("../ui/src/index.tsx",import.meta.url),"utf8");
test("Control Center live controls use switch rows instead of bare checkboxes",()=>{
assert.match(portal,/className="bv-control-live-row"/);
assert.match(portal,/<ToggleField className="bv-control-live-toggle-field"/);
assert.doesNotMatch(portal,/config\.controls\.map\(control=><CheckboxField/);
});
test("Control Center keeps FlexLayout with silent autosave and a plain reset action",()=>{
assert.match(portal,/<BvDockLayout storageId="control-center"/);
assert.match(portal,/<ResetLayoutButton onClick=/);
assert.doesNotMatch(portal,/<ResetLayoutButton[^>]+storageId=/);
assert.match(portal,/selected:hasControls\?0:1/);
});
test("Control Center configuration owns a responsive editor layout",()=>{
assert.match(editor,/className="bv-control-editor"/);
assert.match(editor,/<ToggleField[^>]+label="Force active after releasing restrictions"/);
assert.match(row,/className="bv-control-card-header"/);
assert.match(row,/bv-control-assignment-row/);
assert.match(styles,/\.bv-control-editor\s*\{[^}]*grid-template-rows:auto minmax\(0,1fr\) auto/);
assert.match(styles,/@container bv-control-editor \(max-width:560px\)/);
});
test("Control Center node keeps conflict status after dynamic controls",()=>{
assert.match(nodeIntegration,/function moveWidgetToEnd/);
assert.match(nodeIntegration,/moveWidgetToEnd\(node, CONTROL_STATUS_WIDGET\);/);
});
test("Regional legacy debug uses one setting and the native Ctrl Alt B command binding",()=>{
assert.match(entrypoint,/name: "Enable BV Regional Legacy Debug Mode"/);
assert.match(entrypoint,/keybindings: \[\{commandId:LEGACY_DEBUG_COMMAND_ID,combo:\{key:"b",ctrl:true,alt:true\}\}\]/);
assert.match(entrypoint,/Default shortcut · change it in ComfyUI Settings → Shortcuts/);
assert.doesNotMatch(entrypoint,/name: "Show BV Regional Legacy Ports"/);
});
test("retired HTTP debug bridge is absent from the frontend",()=>{
assert.doesNotMatch(entrypoint,/Debug Bridge|debug\/snapshot|debug\/session|publishDebugSnapshot/);
});
-38
View File
@@ -1,38 +0,0 @@
import unittest
from py.util.debug_bridge.service import DebugBridge, DebugBridgeError
class DebugBridgeTests(unittest.TestCase):
def test_disabled_bridge_rejects_and_clears_snapshot(self):
bridge = DebugBridge()
with self.assertRaisesRegex(DebugBridgeError, "disabled"):
bridge.publish({"1": {"class_type": "Test"}})
bridge.set_enabled(True)
bridge.publish({"1": {"class_type": "Test"}}, "Workflow")
self.assertTrue(bridge.status()["snapshot_available"])
bridge.set_enabled(False)
self.assertFalse(bridge.status()["snapshot_available"])
def test_snapshot_is_validated_copied_and_fingerprinted(self):
bridge = DebugBridge()
bridge.set_enabled(True)
prompt = {"1": {"class_type": "Test", "inputs": {"text": "hello"}}}
status = bridge.publish(prompt, " Debug ")
prompt["1"]["inputs"]["text"] = "mutated"
snapshot = bridge.snapshot()
self.assertEqual(snapshot["prompt"]["1"]["inputs"]["text"], "hello")
self.assertEqual(status["workflow_name"], "Debug")
self.assertEqual(len(status["revision"]), 64)
def test_snapshot_requires_string_node_ids_and_object_nodes(self):
bridge = DebugBridge()
bridge.set_enabled(True)
for invalid in ({}, {1: {}}, {"1": "node"}):
with self.subTest(invalid=invalid):
with self.assertRaises(DebugBridgeError):
bridge.publish(invalid)
if __name__ == "__main__":
unittest.main()
+6 -3
View File
@@ -1,6 +1,6 @@
import { FC, useEffect, useMemo, useState } from "react";
import BVControl from "./control/BVControlComponent";
import { BvDockLayout, BvManagedWindow, Callout, CheckboxField, ResetLayoutButton } from "../ui";
import { BvDockLayout, BvManagedWindow, Callout, ResetLayoutButton, ToggleField } from "../ui";
import { BVControlConfig, CONFIG_CHANGED_EVENT, readConfig, writeConfig } from "../util/control/configHandler";
interface IBVPortalProps { open:boolean; onClose:()=>void; hasControlNodes?:boolean }
@@ -10,13 +10,16 @@ function ControlCenterLiveView(){
useEffect(()=>{const update=(event:Event)=>setConfig((event as CustomEvent<BVControlConfig>).detail);window.addEventListener(CONFIG_CHANGED_EVENT,update);return()=>window.removeEventListener(CONFIG_CHANGED_EVENT,update)},[]);
const toggle=(id:string,enabled:boolean)=>writeConfig({...config,controls:config.controls.map(control=>control.id===id?{...control,enabled}:control)});
if(!config.controls.length)return <Callout tone="info" title="No controls configured">Create a control in Configuration before using live workflow states.</Callout>;
return <div className="bv-control-live-list">{config.controls.map(control=><CheckboxField key={control.id} label={control.name} checked={control.enabled} onValue={value=>toggle(control.id,value)} />)}</div>;
return <div className="bv-control-live-list">{config.controls.map(control=><div className="bv-control-live-row" key={control.id}>
<div className="bv-control-live-copy"><strong>{control.name}</strong><span>{control.assignments.length} assignment{control.assignments.length===1?"":"s"}</span></div>
<ToggleField className="bv-control-live-toggle-field" label={`${control.name} state`} value={control.enabled} trueLabel="Active" falseLabel="Inactive" onValue={value=>toggle(control.id,value)}/>
</div>)}</div>;
}
const BvPortal: FC<IBVPortalProps> = ({open,onClose,hasControlNodes=true}) => {
const [layoutReset,setLayoutReset]=useState(0);
const hasControls=readConfig().controls.length>0,defaultModel=useMemo(()=>({global:{tabEnableClose:false,tabEnableDrag:false,tabEnablePopout:false,tabEnablePopoutIcon:false,tabEnableFloat:false},borders:[],layout:{type:"row" as const,children:[{type:"tabset" as const,selected:hasControls?0:1,children:[{type:"tab" as const,id:"control-center-controls",name:"Controls",component:"controls",enableClose:false,enableDrag:false},{type:"tab" as const,id:"control-center-configuration",name:"Configuration",component:"configuration",enableClose:false,enableDrag:false}]}]}}),[hasControls]);
return <BvManagedWindow open={open} title="BV Control Center" shortTitle="Control Center" initialGeometry={{width:860,height:640}} minSize={{width:420,height:360}} onClose={onClose} status="Workflow-wide controls are saved automatically." actions={<ResetLayoutButton storageId="control-center" editorType="control-center" onClick={()=>setLayoutReset(value=>value+1)}/>}>
return <BvManagedWindow open={open} title="BV Control Center" shortTitle="Control Center" initialGeometry={{width:860,height:640}} minSize={{width:420,height:360}} onClose={onClose} status="Workflow-wide controls and layout are saved automatically." actions={<ResetLayoutButton onClick={()=>setLayoutReset(value=>value+1)}/>}>
{!hasControlNodes&&<Callout tone="warning" title="No Control Center nodes in workflow">The workflow configuration is still available. Closing this window makes it unavailable until a Control Center node is added.</Callout>}
<BvDockLayout storageId="control-center" resetSignal={layoutReset+(hasControls?0:1)} defaultModel={defaultModel as any} panels={[{id:"controls",title:"Controls",weight:1,content:<ControlCenterLiveView/>},{id:"configuration",title:"Configuration",weight:1,content:<BVControl/>}]}/>
</BvManagedWindow>;
@@ -5,7 +5,7 @@ import { collectAllGroups } from "../../util/control/collector";
import { BVControl, BVControlConfig, CONFIG_CHANGED_EVENT, readConfig, writeConfig } from "../../util/control/configHandler";
import { findActiveControlConflicts } from "../../util/control/controlCenterModel.js";
import BVControlRowComponent from "./BVControlRowComponent";
import { Badge, Button, CheckboxField, SortableList, type SortableItem } from "../../ui/components";
import { Badge, Button, SortableList, ToggleField, type SortableItem } from "../../ui/components";
import { useBvHistory, useBvHistoryShortcuts } from "../../ui/history";
function id() {
@@ -41,10 +41,13 @@ const BVControlComponent: FC = () => {
setConfig({ ...config, controls: [...config.controls, { id: id(), name, enabled: true, assignments: [] }] });
};
const items:SortableItem[]=config.controls.map((control,index)=>({id:control.id,title:control.name||"Unnamed control",description:`${control.assignments.length} assignment${control.assignments.length===1?"":"s"}`,content:<BVControlRowComponent control={control} groups={groups} conflicts={conflicts} onChange={(value)=>updateControl(index,value)}/>}));
return <div className="bv-ui-stack">
<CheckboxField label="Force active after releasing restrictions" checked={config.forceActive} onValue={forceActive=>setConfig({...config,forceActive})}/>
return <div className="bv-control-editor">
<section className="bv-control-editor-settings">
<div><strong>Release behavior</strong><span>Choose whether controls become active again when restrictions are released.</span></div>
<ToggleField label="Force active after releasing restrictions" value={config.forceActive} trueLabel="Force active" falseLabel="Keep current state" onValue={forceActive=>setConfig({...config,forceActive})}/>
</section>
<div className="bv-controls"><SortableList items={items} onReorder={ordered=>setConfig({...config,controls:ordered.map(item=>config.controls.find(control=>control.id===item.id)!).filter(Boolean)})} onRemove={controlId=>setConfig({...config,controls:config.controls.filter(control=>control.id!==controlId)})}/></div>
<footer className="bv-ui-inline-footer"><Badge tone="success" dot>Autosaved</Badge><Button intent="ghost" disabled={!history.canUndo} onClick={history.undo}>Undo</Button><Button intent="ghost" disabled={!history.canRedo} onClick={history.redo}>Redo</Button><Button onClick={addControl}>Add Control</Button></footer>
<footer className="bv-control-editor-footer bv-ui-inline-footer"><Badge tone="success" dot>Autosaved</Badge><Button intent="ghost" disabled={!history.canUndo} onClick={history.undo}>Undo</Button><Button intent="ghost" disabled={!history.canRedo} onClick={history.redo}>Redo</Button><Button onClick={addControl}>Add Control</Button></footer>
</div>;
};
@@ -13,7 +13,7 @@ const BVControlRowComponent: FC<{ control: BVControl; groups: CollectedGroup[];
onChange({ ...control, assignments: [...control.assignments, { groupId, groupPath: group.pathLabel, groupTitle: String(group.group.title || ""), action: "bypass" }] });
};
return <section className="bv-control-card">
<div className="bv-control-title"><TextField label="Control name" value={control.name} onValue={name=>onChange({...control,name})}/><span>{control.assignments.length} groups</span><Button intent="danger" onClick={() => onChange(null)}>Delete</Button></div>
<div className="bv-control-card-header"><TextField label="Control name" value={control.name} onValue={name=>onChange({...control,name})}/><span>{control.assignments.length} group{control.assignments.length===1?"":"s"}</span></div>
<div className="bv-assignments">{control.assignments.map((assignment, index) => {
const conflict = conflicts.find((item) => item.groupId === assignment.groupId);
const winnerNames = conflict?.entries.filter((entry) => entry.action === conflict.winnerAction).map((entry) => entry.controlName).join(", ");
@@ -22,13 +22,13 @@ const BVControlRowComponent: FC<{ control: BVControl; groups: CollectedGroup[];
? `Overrides ${[...new Set(conflict.entries.filter((entry) => entry.action !== conflict.winnerAction).map((entry) => actionLabel(entry.action)))].join(", ")}`
: `Overridden by ${actionLabel(conflict.winnerAction)} from ${winnerNames}`
: "";
return <div className={assignment.unresolved ? "unresolved" : conflict ? "conflict" : ""} key={assignment.groupId}>
<span>{assignment.unresolved ? "⚠ " : ""}{assignment.groupPath}{conflictText && <small>⚠ {conflictText}</small>}</span>
return <div className={`bv-control-assignment-row ${assignment.unresolved ? "unresolved" : conflict ? "conflict" : ""}`} key={assignment.groupId}>
<div className="bv-control-assignment-target"><strong>{assignment.unresolved ? "⚠ " : ""}{assignment.groupPath}</strong>{conflictText && <small>⚠ {conflictText}</small>}</div>
<SelectField label="Action" value={assignment.action} options={[{value:"activate",label:"Activate"},{value:"mute",label:"Mute"},{value:"bypass",label:"Bypass"}]} onValue={action=>onChange({...control,assignments:control.assignments.map((item,itemIndex)=>itemIndex===index?{...item,action:action as BVControlAction}:item)})}/>
<Button intent="ghost" iconOnly aria-label="Remove assignment" onClick={() => onChange({ ...control, assignments: control.assignments.filter((_, itemIndex) => itemIndex !== index) })}>×</Button>
</div>;
})}</div>
<SelectField label="Add group" value="" onValue={addAssignment} options={[{value:"",label:"Choose group…"},...groups.map(group=>({value:group.id,label:group.pathLabel}))]}/>
<div className="bv-control-add-group"><SelectField label="Add group" value="" onValue={addAssignment} options={[{value:"",label:"Choose group…"},...groups.map(group=>({value:group.id,label:group.pathLabel}))]}/></div>
</section>;
};
@@ -108,6 +108,15 @@ function syncConflictStatus(node: any, config: BVControlConfig) {
widget.tooltip = conflicts.map((conflict: any) => `${conflict.groupPath}: ${conflict.winnerAction} wins`).join("\n");
}
function moveWidgetToEnd(node: any, name: string) {
const widgets = node.widgets;
if (!Array.isArray(widgets)) return;
const index = widgets.findIndex((widget: any) => widget.name === name);
if (index < 0 || index === widgets.length - 1) return;
const [widget] = widgets.splice(index, 1);
widgets.push(widget);
}
function syncNode(node: any, config: BVControlConfig) {
node.mode = NORMAL;
if (node.properties) delete node.properties[BASE_MODE];
@@ -161,6 +170,9 @@ function syncNode(node: any, config: BVControlConfig) {
configWidget.computeSize = () => [0, -4];
configWidget.serializeValue = () => JSON.stringify(configForValidation(readConfig()));
}
// Dynamic control widgets are appended by ComfyUI. Reassert the status row
// after every reconciliation so it remains the final row on the node.
moveWidgetToEnd(node, CONTROL_STATUS_WIDGET);
node.serialize_widgets = true;
const currentSize = node.size ?? [0, 0];
const computedSize = node.computeSize?.() ?? currentSize;
+21 -10
View File
@@ -160,20 +160,31 @@
.bv-dialog { width: min(900px, 92vw); max-height: 84vh; }
.bv-rack { width:100%; max-height:84vh; overflow:auto; padding:calc(18px * var(--bv-ui-scale)); border:1px solid var(--bv-ui-border); border-radius:var(--bv-ui-radius-lg); background:var(--bv-ui-surface); box-shadow:0 18px 60px #0009; }
.bv-rack header, .bv-rack footer, .bv-control-title, .bv-assignments > div { display: flex; align-items: center; gap: 10px; }
.bv-rack header, .bv-rack footer { display: flex; align-items: center; gap: 10px; }
.bv-rack header { justify-content: space-between; border-bottom: 1px solid #ffffff20; margin-bottom: 16px; }
.bv-rack h2 { margin: 0; }
.bv-rack p { margin-top: 4px; color: var(--bv-muted-text-color); }
.bv-force { display: flex; gap: 8px; padding: 10px 12px; background: #ffffff08; border-radius: var(--bv-ui-radius); }
.bv-controls { display: grid; gap: 12px; margin: 16px 0; }
.bv-control-card { border:1px solid var(--bv-ui-border); border-radius:var(--bv-ui-radius-lg); padding:calc(12px * var(--bv-ui-scale)); background:rgba(255,255,255,.035); }
.bv-control-title input { flex: 1; font-weight: 650; }
.bv-control-title span { color: var(--bv-muted-text-color); }
.bv-assignments { display: grid; gap: 6px; margin: 10px 0; }
.bv-assignments > div span { flex: 1; }
.bv-assignments > div span small { display: block; margin-top: 3px; color: #ffbd66; }
.bv-assignments .conflict { border-left: 2px solid #ffbd66; padding-left: 8px; }
.bv-assignments .unresolved { color: #ff9f7a; }
.bv-control-editor { container:bv-control-editor / inline-size; display:grid; grid-template-rows:auto minmax(0,1fr) auto; gap:calc(12px * var(--bv-ui-scale)); width:100%; height:100%; min-width:0; min-height:0; padding:calc(12px * var(--bv-ui-scale)); overflow:hidden; }
.bv-control-editor-settings { display:grid; grid-template-columns:minmax(0,1fr) minmax(260px,auto); align-items:center; gap:calc(16px * var(--bv-ui-scale)); padding:calc(12px * var(--bv-ui-scale)); background:rgba(255,255,255,.03); border:1px solid var(--bv-ui-border); border-radius:var(--bv-ui-radius-lg); }
.bv-control-editor-settings>div { display:grid; gap:3px; }.bv-control-editor-settings>div>span { color:var(--bv-ui-muted); font-size:calc(10px * var(--bv-ui-scale)); }
.bv-controls { min-height:0; overflow:auto; padding-right:4px; }
.bv-control-card { display:grid; gap:calc(12px * var(--bv-ui-scale)); border:1px solid var(--bv-ui-border); border-radius:var(--bv-ui-radius-lg); padding:calc(12px * var(--bv-ui-scale)); background:rgba(255,255,255,.035); }
.bv-control-card-header { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:calc(12px * var(--bv-ui-scale)); }
.bv-control-card-header>span { align-self:center; color:var(--bv-ui-muted); white-space:nowrap; }
.bv-assignments { display:grid; gap:calc(8px * var(--bv-ui-scale)); }
.bv-control-assignment-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(150px,220px) auto; align-items:end; gap:calc(10px * var(--bv-ui-scale)); padding:calc(9px * var(--bv-ui-scale)); background:rgba(0,0,0,.12); border:1px solid transparent; border-radius:var(--bv-ui-radius); }
.bv-control-assignment-target { display:grid; align-self:center; gap:3px; min-width:0; }.bv-control-assignment-target strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.bv-control-assignment-target small { color:#ffbd66; }
.bv-control-assignment-row.conflict { border-left-color:#ffbd66; }.bv-control-assignment-row.unresolved { color:#ff9f7a; border-left-color:#ff9f7a; }
.bv-control-add-group { padding-top:calc(10px * var(--bv-ui-scale)); border-top:1px solid var(--bv-ui-border); }
.bv-control-editor-footer { padding-top:calc(10px * var(--bv-ui-scale)); }
.bv-control-live-list { container:bv-control-live-list / inline-size; display:grid; align-content:start; gap:calc(9px * var(--bv-ui-scale)); width:100%; height:100%; min-width:0; min-height:0; padding:calc(12px * var(--bv-ui-scale)); overflow:auto; }
.bv-control-live-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:calc(16px * var(--bv-ui-scale)); padding:calc(12px * var(--bv-ui-scale)); background:rgba(255,255,255,.035); border:1px solid var(--bv-ui-border); border-radius:var(--bv-ui-radius-lg); }
.bv-control-live-copy { display:grid; gap:3px; min-width:0; }.bv-control-live-copy strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.bv-control-live-copy span { color:var(--bv-ui-muted); font-size:calc(10px * var(--bv-ui-scale)); }
.bv-control-live-toggle-field { width:max-content; max-width:100%; justify-self:end; }.bv-control-live-toggle-field>.bv-control-label { display:none; }.bv-control-live-toggle-field>.bv-toggle { max-width:100%; white-space:nowrap; }
.bv-legacy-shortcut-info { display:flex; align-items:center; justify-content:flex-end; gap:6px; min-width:0; color:var(--bv-ui-muted); }.bv-legacy-shortcut-info kbd { padding:3px 7px; color:var(--bv-ui-text); background:var(--bv-ui-input); border:1px solid var(--bv-ui-border); border-radius:var(--bv-ui-radius-sm); box-shadow:inset 0 -1px rgba(255,255,255,.05); }.bv-legacy-shortcut-info small { margin-left:6px; }
@container bv-control-editor (max-width:560px) { .bv-control-editor-settings,.bv-control-card-header { grid-template-columns:1fr; align-items:stretch; }.bv-control-assignment-row { grid-template-columns:minmax(0,1fr) auto; }.bv-control-assignment-target { grid-column:1/-1; }.bv-control-editor-footer { justify-content:flex-start; } }
@container bv-control-live-list (max-width:420px) { .bv-control-live-row { grid-template-columns:1fr; }.bv-control-live-toggle-field { justify-self:start; } }
.bv-rack input, .bv-rack select, .bv-rack button { min-height:var(--bv-ui-control-height); color:inherit; background:var(--bv-ui-input); border:1px solid var(--bv-ui-border); border-radius:var(--bv-ui-radius); padding:7px 9px; }
.bv-rack button { cursor: pointer; }
.bv-rack button.primary { background:var(--bv-ui-accent); border-color:var(--bv-ui-accent-hover); }
+27 -71
View File
@@ -33,7 +33,7 @@ import { compactLoraConsumerNode, hideLoraV3Widget, installLoraV3ConsumerSlot, i
import LoraV3EditorWindow from "./regional/LoraV3EditorWindow";
import { installM0CanvasVisibility, requestM0DebugAnimation } from "./regional/m0VisualProjection";
import { migrateRegionalNode, migrationReportMessage, queueRegionalMigrationReport, regionalEditorDraft, REGIONAL_MIGRATION_EVENT, REGIONAL_VALIDATION_EVENT } from "./regional/milestoneE";
import { clearLegacyPortSticky, installLegacyPorts, legacyPortDescriptors, legacyUsage, refreshLegacyPorts, setLegacyPortsVisible, SHOW_LEGACY_PORTS_SETTING_ID, toggleLegacyDebugVisible } from "./regional/legacyPorts";
import { clearLegacyPortSticky, installLegacyPorts, legacyDebugVisible, LEGACY_DEBUG_COMMAND_ID, LEGACY_DEBUG_SETTING_ID, legacyPortDescriptors, legacyUsage, refreshLegacyPorts, setLegacyDebugVisible } from "./regional/legacyPorts";
import { applyReducedEffects, applyUiPreferences, applyUiSize, bindWindowSwitchModePersistence, getWindowSwitchMode, setWindowSwitchMode, UI_REDUCED_EFFECTS_SETTING_ID, UI_SIZE_SETTING_ID, UI_WINDOW_SWITCH_MODE_SETTING_ID } from "./ui/preferences";
import { BvGlobalToastStack, collectScopedNodes, dismissBvToast, lastBvFullWindowType, lastBvWindowInstance, rememberBvWindowInstance, setWindowMenuVisible, showBvToast, switchBvView, toggleToolbarWindowLauncher, ToolbarWindowLauncher, ToolbarLauncherColumn, windowMenuVisible } from "./ui";
const comfyApp = getApp();
@@ -49,46 +49,6 @@ const OPEN_REGIONAL_EDITOR_EVENT = "bv-open-regional-editor";
const OPEN_REGIONAL_QUICK_EDIT_EVENT = "bv-open-regional-quick-edit";
const REGIONAL_DOCUMENT_CHANGED_EVENT = "bv-regional-document-changed";
const STYLE_ID = "bv-nodepack-styles";
const DEBUG_BRIDGE_SETTING_ID = "BV.DebugBridge.Enabled";
let debugBridgeEnabled = false;
let debugBridgePublishTimer: number | null = null;
const debugWorkflowName = () => {
const workflow = (comfyApp as any).extensionManager?.workflow?.activeWorkflow
?? (comfyApp as any).workflowManager?.activeWorkflow;
return String(workflow?.filename ?? workflow?.path ?? "Current workflow");
};
const setDebugBridgeSession = async (enabled: boolean) => {
debugBridgeEnabled = Boolean(enabled);
const response = await fetch(comfyApi.apiURL("/bv_nodepack/debug/session"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: debugBridgeEnabled }),
});
if (!response.ok) throw new Error(`BV Debug Bridge session failed: ${response.status} ${await response.text()}`);
};
const publishDebugSnapshot = async () => {
if (!debugBridgeEnabled) return false;
const prompt = await (comfyApp as any).graphToPrompt();
const response = await fetch(comfyApi.apiURL("/bv_nodepack/debug/snapshot"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: prompt.output, workflow_name: debugWorkflowName() }),
});
if (!response.ok) throw new Error(`BV Debug Bridge snapshot failed: ${response.status} ${await response.text()}`);
return true;
};
const scheduleDebugSnapshot = () => {
if (!debugBridgeEnabled) return;
if (debugBridgePublishTimer != null) window.clearTimeout(debugBridgePublishTimer);
debugBridgePublishTimer = window.setTimeout(() => {
debugBridgePublishTimer = null;
publishDebugSnapshot().catch(error => console.error(error));
}, 600);
};
const hideRegionalWidget = (widget: any) => {
widget.type = "converted-widget";
@@ -602,13 +562,8 @@ comfyApp.registerExtension({
ensureMountedOnce();
installM0CanvasVisibility((comfyApp as any).canvas);
applyUiPreferences((comfyApp as any).ui?.settings);
setLegacyPortsVisible(Boolean((comfyApp as any).ui?.settings?.getSettingValue?.(SHOW_LEGACY_PORTS_SETTING_ID, false)),(comfyApp as any).graph);
setLegacyDebugVisible(Boolean((comfyApp as any).ui?.settings?.getSettingValue?.(LEGACY_DEBUG_SETTING_ID, false)),(comfyApp as any).graph);
installGlobalTextareaCompletion();
debugBridgeEnabled = Boolean((comfyApp as any).ui?.settings?.getSettingValue?.(DEBUG_BRIDGE_SETTING_ID, false));
setDebugBridgeSession(debugBridgeEnabled)
.then(() => { if (debugBridgeEnabled) scheduleDebugSnapshot(); })
.catch(error => console.error(error));
comfyApi.addEventListener("graphChanged", scheduleDebugSnapshot);
comfyApi.addEventListener("graphChanged", refreshBvToolbarCapabilities);
},
});
@@ -649,25 +604,29 @@ comfyApp.registerExtension({
tooltip: "Choose whether changing the node in a BV editor minimizes or closes the current window. Hold Shift to invert the mode once.",
onChange: (value: string) => setWindowSwitchMode(value, false),
}, {
id: SHOW_LEGACY_PORTS_SETTING_ID as any,
name: "Show BV Regional Legacy Ports",
id: LEGACY_DEBUG_SETTING_ID as any,
name: "Enable BV Regional Legacy Debug Mode",
type: "boolean",
defaultValue: false,
category: ["BV Node Pack", "Regional", "Show Legacy ports"],
tooltip: "Temporarily reveal every deprecated BV Regional port in the active workflow.",
onChange: (value: boolean) => setLegacyPortsVisible(Boolean(value),(comfyApp as any).graph),
}, {
id: DEBUG_BRIDGE_SETTING_ID as any,
name: "Enable BV Debug Bridge",
type: "boolean",
defaultValue: false,
category: ["BV Node Pack", "Developer", "Enable Debug Bridge"],
tooltip: "Expose the current API-format workflow as an in-memory snapshot to loopback clients only.",
category: ["BV Node Pack", "Regional", "Legacy debug mode"],
tooltip: "Reveal deprecated Regional ports, legacy nodes and hidden provider wiring for debugging.",
onChange: (value: boolean) => {
setDebugBridgeSession(Boolean(value))
.then(() => { if (value) scheduleDebugSnapshot(); })
.catch(error => console.error(error));
const canvas=(comfyApp as any).canvas;
setLegacyDebugVisible(Boolean(value),canvas?.graph??(comfyApp as any).graph);
if(value)requestM0DebugAnimation(canvas);
},
}, {
id: "BV.Regional.LegacyDebugShortcutInfo" as any,
name: "Legacy Debug Shortcut",
type: (_name:string) => {
const host=document.createElement("div");
host.className="bv-legacy-shortcut-info";
host.innerHTML="<kbd>Ctrl</kbd><span>+</span><kbd>Alt</kbd><span>+</span><kbd>B</kbd><small>Default shortcut · change it in ComfyUI Settings → Shortcuts</small>";
return host;
},
defaultValue: "Ctrl+Alt+B",
category: ["BV Node Pack", "Regional", "Legacy debug shortcut"],
tooltip: "The effective binding is managed by ComfyUI's native Shortcuts menu.",
}, {
id: COMPLETION_SETTING_ID as any,
name: "Enable BV Prompt Autocomplete",
@@ -694,25 +653,23 @@ comfyApp.registerExtension({
tooltip: "Choose one or more local CSV/TSV datasets for BV Prompt Autocomplete.",
onChange: (value: string) => applyCompletionDatasetSetting(value),
}],
keybindings: [{commandId:LEGACY_DEBUG_COMMAND_ID,combo:{key:"b",ctrl:true,alt:true}}],
commands: [{
id: "bv.regional.openEditor",
label: "Open BV Regional Editor",
icon: "icon-[lucide--layers]",
function: () => { window.dispatchEvent(new CustomEvent(OPEN_REGIONAL_EDITOR_EVENT)); },
}, {
id: "bv.regional.toggleDebugWiring",
id: LEGACY_DEBUG_COMMAND_ID,
label: "Toggle BV Regional Legacy wiring debug",
icon: "icon-[lucide--workflow]",
function: () => {
const canvas=(comfyApp as any).canvas,active=toggleLegacyDebugVisible(canvas?.graph??(comfyApp as any).graph);
if(active)requestM0DebugAnimation(canvas);
const canvas=(comfyApp as any).canvas,active=!legacyDebugVisible();
const settings=(comfyApp as any).ui?.settings;
settings?.setSettingValue?.(LEGACY_DEBUG_SETTING_ID,active);
if(!settings?.setSettingValue)setLegacyDebugVisible(active,canvas?.graph??(comfyApp as any).graph);
showBvToast({id:"bv-regional-debug-wiring",title:`Regional wiring debug ${active?"enabled":"disabled"}`,message:active?"Legacy ports and hidden V3 provider links are visible for this UI session.":"Legacy ports and V3 provider links returned to their normal presentation.",tone:"info",duration:3500});
},
}, {
id: "bv.debugBridge.publishSnapshot",
label: "Refresh BV Debug Bridge Snapshot",
icon: "icon-[lucide--radio-tower]",
function: () => publishDebugSnapshot().catch(error => console.error(error)),
}],
actionBarButtons: [{
icon: "icon-[lucide--sliders-horizontal]",
@@ -978,7 +935,6 @@ comfyApp.registerExtension({
};
},
afterConfigureGraph() {
scheduleDebugSnapshot();
queueMicrotask(refreshBvToolbarCapabilities);
},
});
+2
View File
@@ -1,4 +1,6 @@
export const SHOW_LEGACY_PORTS_SETTING_ID="BV.Regional.ShowLegacyPorts";
export const LEGACY_DEBUG_SETTING_ID="BV.Regional.LegacyDebugMode";
export const LEGACY_DEBUG_COMMAND_ID="bv.regional.toggleDebugWiring";
export const LEGACY_USAGE_EVENT="bv-regional-legacy-usage";
// BV-LEGACY(marked=2026-08-25, remove-after=2026-10-25): Entire Regional sidecar-port UX.
+1 -1
View File
@@ -59,7 +59,7 @@ export function RichSelect({label,value,options,onValue,help}:{label:string;valu
return <FieldFrame label={label} help={help}><div ref={anchor} className="bv-rich-select"><button type="button" className="bv-rich-select-trigger" aria-haspopup="listbox" aria-expanded={open} onClick={()=>setOpen(!open)}>{selected?.icon}<span><strong>{selected?.label??"Select…"}</strong>{selected?.description&&<small>{selected.description}</small>}</span><span className="bv-chevron"/></button><AnchoredPopover open={open} anchor={anchor} onClose={()=>setOpen(false)} className="bv-rich-select-menu" matchWidth><div role="listbox">{options.map(option=><button type="button" role="option" aria-selected={option.value===value} disabled={option.disabled} key={option.value} onClick={()=>{onValue(option.value);setOpen(false);}}>{option.icon}<span><strong>{option.label}</strong>{option.description&&<small>{option.description}</small>}</span>{option.meta}</button>)}</div></AnchoredPopover></div></FieldFrame>;
}
export function CheckboxField({label,checked,onValue,help,disabled}:{label:string;checked:boolean;onValue:(value:boolean)=>void;help?:ReactNode;disabled?:boolean}) { return <label className="bv-checkbox-field"><input type="checkbox" checked={checked} disabled={disabled} onChange={e=>onValue(e.target.checked)}/><span className="bv-checkbox-mark" aria-hidden="true"/><span><strong>{label}</strong>{help&&<small>{help}</small>}</span></label>; }
export function ToggleField({label,value,onValue,trueLabel="True",falseLabel="False",help}:{label:string;value:boolean;onValue:(value:boolean)=>void;trueLabel?:string;falseLabel?:string;help?:ReactNode}) { return <FieldFrame label={label} help={help}><button type="button" role="switch" aria-checked={value} className="bv-toggle" onClick={()=>onValue(!value)}><span className="bv-toggle-track"><span/></span><strong>{value?trueLabel:falseLabel}</strong></button></FieldFrame>; }
export function ToggleField({label,value,onValue,trueLabel="True",falseLabel="False",help,className=""}:{label:string;value:boolean;onValue:(value:boolean)=>void;trueLabel?:string;falseLabel?:string;help?:ReactNode;className?:string}) { return <FieldFrame label={label} help={help} className={className}><button type="button" role="switch" aria-checked={value} className="bv-toggle" onClick={()=>onValue(!value)}><span className="bv-toggle-track"><span/></span><strong>{value?trueLabel:falseLabel}</strong></button></FieldFrame>; }
export function BooleanField({variant="toggle",...props}:{variant?:"toggle"|"checkbox"|"select";label:string;value:boolean;onValue:(value:boolean)=>void;trueLabel?:string;falseLabel?:string;help?:ReactNode}) { if(variant==="checkbox")return <CheckboxField label={props.label} checked={props.value} onValue={props.onValue} help={props.help}/>;if(variant==="select")return <SelectField label={props.label} value={String(props.value)} onValue={v=>props.onValue(v==="true")} help={props.help} options={[{value:"true",label:props.trueLabel??"True"},{value:"false",label:props.falseLabel??"False"}]}/>;return <ToggleField {...props}/>; }
export function OptionSet({label,value,options,onValue,variant="radio"}:{label:string;value:string;options:SelectOption[];onValue:(value:string)=>void;variant?:"radio"|"segmented"|"select"}) { if(variant==="select")return <SelectField label={label} value={value} options={options} onValue={onValue}/>;return <FieldFrame label={label}><div className={`bv-option-set ${variant}`} role="radiogroup">{options.map(option=><button type="button" role="radio" aria-checked={option.value===value} key={option.value} onClick={()=>onValue(option.value)}>{option.icon}{option.label}</button>)}</div></FieldFrame>; }