Updated Design of Borealis Agent Node

This commit is contained in:
2025-10-15 08:41:27 -06:00
parent d33a9b0541
commit 23f2ed43dd
2 changed files with 314 additions and 148 deletions

View File

@@ -40,23 +40,55 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti
const config = nodeData?.config || []; const config = nodeData?.config || [];
const nodeId = nodeData?.nodeId; const nodeId = nodeData?.nodeId;
const normalizeOptions = (opts = []) =>
opts.map((opt) => {
if (typeof opt === "string") {
return { value: opt, label: opt, disabled: false };
}
if (opt && typeof opt === "object") {
const val =
opt.value ??
opt.id ??
opt.handle ??
(typeof opt.label === "string" ? opt.label : "");
const label =
opt.label ??
opt.name ??
opt.title ??
(typeof val !== "undefined" ? String(val) : "");
return {
value: typeof val === "undefined" ? "" : String(val),
label: typeof label === "undefined" ? "" : String(label),
disabled: Boolean(opt.disabled)
};
}
return { value: String(opt ?? ""), label: String(opt ?? ""), disabled: false };
});
return config.map((field, index) => { return config.map((field, index) => {
const value = nodeData?.[field.key] || ""; const value = nodeData?.[field.key] ?? "";
const isReadOnly = Boolean(field.readOnly);
// ---- DYNAMIC DROPDOWN SUPPORT ---- // ---- DYNAMIC DROPDOWN SUPPORT ----
if (field.type === "select") { if (field.type === "select") {
let options = field.options || []; let options = field.options || [];
// Handle dynamic options for things like Target Window if (field.optionsKey && Array.isArray(nodeData?.[field.optionsKey])) {
if (field.dynamicOptions && nodeData?.windowList && Array.isArray(nodeData.windowList)) { options = nodeData[field.optionsKey];
} else if (field.dynamicOptions && nodeData?.windowList && Array.isArray(nodeData.windowList)) {
options = nodeData.windowList options = nodeData.windowList
.map(win => ({ .map((win) => ({
value: String(win.handle), value: String(win.handle),
label: `${win.title} (${win.handle})` label: `${win.title} (${win.handle})`
})) }))
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })); .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" }));
} else { }
options = options.map(opt => ({ value: opt, label: opt }));
options = normalizeOptions(options);
// Handle dynamic options for things like Target Window
if (field.dynamicOptions && (!nodeData?.windowList || !Array.isArray(nodeData.windowList))) {
options = [];
} }
return ( return (
@@ -70,6 +102,7 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti
size="small" size="small"
value={value} value={value}
onChange={(e) => { onChange={(e) => {
if (isReadOnly) return;
const newValue = e.target.value; const newValue = e.target.value;
if (!nodeId) return; if (!nodeId) return;
effectiveSetNodes((nds) => effectiveSetNodes((nds) =>
@@ -134,7 +167,7 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti
</MenuItem> </MenuItem>
) : ( ) : (
options.map((opt, idx) => ( options.map((opt, idx) => (
<MenuItem key={idx} value={opt.value}> <MenuItem key={idx} value={opt.value} disabled={opt.disabled}>
{opt.label} {opt.label}
</MenuItem> </MenuItem>
)) ))
@@ -155,7 +188,13 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti
size="small" size="small"
fullWidth fullWidth
value={value} value={value}
disabled={isReadOnly}
InputProps={{
readOnly: isReadOnly,
sx: { color: "#ccc" }
}}
onChange={(e) => { onChange={(e) => {
if (isReadOnly) return;
const newValue = e.target.value; const newValue = e.target.value;
if (!nodeId) return; if (!nodeId) return;
effectiveSetNodes((nds) => effectiveSetNodes((nds) =>

View File

@@ -8,18 +8,18 @@ const BorealisAgentNode = ({ id, data }) => {
const edges = useStore((state) => state.edges); const edges = useStore((state) => state.edges);
const [agents, setAgents] = useState({}); const [agents, setAgents] = useState({});
const [sites, setSites] = useState([]); const [sites, setSites] = useState([]);
const [selectedAgent, setSelectedAgent] = useState(data.agent_id || "");
const [selectedHost, setSelectedHost] = useState(data.agent_host || "");
const [selectedSiteId, setSelectedSiteId] = useState(
data.agent_site_id ? String(data.agent_site_id) : ""
);
const initialMode = (data.agent_mode || "currentuser").toLowerCase();
const [selectedMode, setSelectedMode] = useState(
initialMode === "system" ? "system" : "currentuser"
);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [siteMapping, setSiteMapping] = useState({}); const [siteMapping, setSiteMapping] = useState({});
const prevRolesRef = useRef([]); const prevRolesRef = useRef([]);
const selectionRef = useRef({ host: "", mode: "", agentId: "", siteId: "" });
const selectedSiteId = data?.agent_site_id ? String(data.agent_site_id) : "";
const selectedHost = data?.agent_host || "";
const selectedMode =
(data?.agent_mode || "currentuser").toString().toLowerCase() === "system"
? "system"
: "currentuser";
const selectedAgent = data?.agent_id || "";
// Group agents by hostname and execution context // Group agents by hostname and execution context
const agentsByHostname = useMemo(() => { const agentsByHostname = useMemo(() => {
@@ -127,18 +127,31 @@ const hostOptions = useMemo(() => {
}); });
}, [hostOptions, selectedSiteId, siteMapping]); }, [hostOptions, selectedSiteId, siteMapping]);
const hasSiteSelection = Boolean(selectedSiteId);
// Align selected site with known host mapping when available // Align selected site with known host mapping when available
useEffect(() => { useEffect(() => {
if (selectedSiteId || !selectedHost) return; if (selectedSiteId || !selectedHost) return;
const mapping = siteMapping[selectedHost]; const mapping = siteMapping[selectedHost];
if (!mapping || typeof mapping.site_id === "undefined" || mapping.site_id === null) return; if (!mapping || typeof mapping.site_id === "undefined" || mapping.site_id === null) return;
setSelectedSiteId(String(mapping.site_id)); const mappedId = String(mapping.site_id);
}, [selectedHost, selectedSiteId, siteMapping]); setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_site_id: mappedId,
},
}
: n
)
);
}, [selectedHost, selectedSiteId, siteMapping, id, setNodes]);
// Ensure host selection stays aligned with available agents // Ensure host selection stays aligned with available agents
useEffect(() => { useEffect(() => {
if (!selectedHost) return;
const hostExists = filteredHostOptions.some((opt) => opt.host === selectedHost); const hostExists = filteredHostOptions.some((opt) => opt.host === selectedHost);
if (hostExists) return; if (hostExists) return;
@@ -147,52 +160,78 @@ const hostOptions = useMemo(() => {
const inferredHost = (info?.hostname || info?.agent_hostname || "").trim() || "unknown"; const inferredHost = (info?.hostname || info?.agent_hostname || "").trim() || "unknown";
const allowed = filteredHostOptions.some((opt) => opt.host === inferredHost); const allowed = filteredHostOptions.some((opt) => opt.host === inferredHost);
if (allowed && inferredHost && inferredHost !== selectedHost) { if (allowed && inferredHost && inferredHost !== selectedHost) {
setSelectedHost(inferredHost); setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_host: inferredHost,
},
}
: n
)
);
return; return;
} }
} }
const fallbackHost = filteredHostOptions[0]?.host || ""; setNodes((nds) =>
if (fallbackHost !== selectedHost) { nds.map((n) =>
setSelectedHost(fallbackHost); n.id === id
} ? {
if (!fallbackHost && selectedAgent) { ...n,
setSelectedAgent(""); data: {
} ...n.data,
}, [filteredHostOptions, selectedHost, selectedAgent, agents]); agent_host: "",
agent_id: "",
agent_mode: "currentuser",
},
}
: n
)
);
}, [filteredHostOptions, selectedHost, selectedAgent, agents, id, setNodes]);
// Align agent selection with host/mode choice const siteSelectOptions = useMemo(() => {
useEffect(() => { const entries = Array.isArray(sites) ? [...sites] : [];
if (!selectedHost) { entries.sort((a, b) =>
if (selectedMode !== "currentuser") setSelectedMode("currentuser"); (a?.name || "").localeCompare(b?.name || "", undefined, { sensitivity: "base" })
if (selectedAgent) setSelectedAgent(""); );
return; const mapped = entries.map((site) => ({
} value: String(site.id),
const contexts = agentsByHostname[selectedHost]; label: site.name || `Site ${site.id}`,
if (!contexts) { }));
if (selectedMode !== "currentuser") setSelectedMode("currentuser"); return [{ value: "", label: "All Sites" }, ...mapped];
if (selectedAgent) setSelectedAgent(""); }, [sites]);
return;
} const hostSelectOptions = useMemo(() => {
if (!contexts[selectedMode]) { const mapped = filteredHostOptions.map(({ host, label }) => ({
const fallbackMode = contexts.currentuser value: host,
? "currentuser" label,
: contexts.system }));
? "system" return [{ value: "", label: "-- Select --" }, ...mapped];
: selectedMode; }, [filteredHostOptions]);
if (fallbackMode !== selectedMode) {
setSelectedMode(fallbackMode); const activeHostContexts = selectedHost ? agentsByHostname[selectedHost] : null;
return;
} const modeSelectOptions = useMemo(
} () => [
const activeContext = contexts[selectedMode]; {
const targetAgentId = activeContext?.agent_id || ""; value: "currentuser",
if (targetAgentId !== selectedAgent) { label: "CURRENTUSER (Screen Capture / Macros)",
setSelectedAgent(targetAgentId); disabled: !activeHostContexts?.currentuser,
} },
}, [selectedHost, selectedMode, agentsByHostname, selectedAgent]); {
value: "system",
label: "SYSTEM (Scripts)",
disabled: !activeHostContexts?.system,
},
],
[activeHostContexts]
);
// Sync node data with sidebar changes
useEffect(() => { useEffect(() => {
setNodes((nds) => setNodes((nds) =>
nds.map((n) => nds.map((n) =>
@@ -201,17 +240,126 @@ const hostOptions = useMemo(() => {
...n, ...n,
data: { data: {
...n.data, ...n.data,
agent_id: selectedAgent, siteOptions: siteSelectOptions,
agent_host: selectedHost, hostOptions: hostSelectOptions,
agent_mode: selectedMode, modeOptions: modeSelectOptions,
agent_site_id: selectedSiteId || "",
}, },
} }
: n : n
) )
); );
setIsConnected(false); }, [id, setNodes, siteSelectOptions, hostSelectOptions, modeSelectOptions]);
}, [selectedAgent, selectedHost, selectedMode, selectedSiteId, setNodes, id]);
useEffect(() => {
if (!selectedHost) {
if (selectedAgent || selectedMode !== "currentuser") {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_id: "",
agent_mode: "currentuser",
},
}
: n
)
);
}
return;
}
const contexts = agentsByHostname[selectedHost];
if (!contexts) {
if (selectedAgent || selectedMode !== "currentuser") {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_id: "",
agent_mode: "currentuser",
},
}
: n
)
);
}
return;
}
if (!contexts[selectedMode]) {
const fallbackMode = contexts.currentuser
? "currentuser"
: contexts.system
? "system"
: "currentuser";
const fallbackAgentId = contexts[fallbackMode]?.agent_id || "";
if (fallbackMode !== selectedMode || fallbackAgentId !== selectedAgent) {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_mode: fallbackMode,
agent_id: fallbackAgentId,
},
}
: n
)
);
}
return;
}
const targetAgentId = contexts[selectedMode]?.agent_id || "";
if (targetAgentId !== selectedAgent) {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
agent_id: targetAgentId,
},
}
: n
)
);
}
}, [selectedHost, selectedMode, agentsByHostname, selectedAgent, id, setNodes]);
useEffect(() => {
const prev = selectionRef.current;
const changed =
prev.host !== selectedHost ||
prev.mode !== selectedMode ||
prev.agentId !== selectedAgent ||
prev.siteId !== selectedSiteId;
if (!changed) return;
const selectionChangedAgent =
prev.agentId &&
(prev.agentId !== selectedAgent || prev.host !== selectedHost || prev.mode !== selectedMode);
if (selectionChangedAgent) {
setIsConnected(false);
prevRolesRef.current = [];
}
selectionRef.current = {
host: selectedHost,
mode: selectedMode,
agentId: selectedAgent,
siteId: selectedSiteId,
};
}, [selectedHost, selectedMode, selectedAgent, selectedSiteId]);
// Attached Roles logic // Attached Roles logic
const attachedRoleIds = useMemo( const attachedRoleIds = useMemo(
@@ -287,8 +435,6 @@ const hostOptions = useMemo(() => {
return status.charAt(0).toUpperCase() + status.slice(1); return status.charAt(0).toUpperCase() + status.slice(1);
}, [agentsByHostname, selectedHost, selectedMode, selectedAgent]); }, [agentsByHostname, selectedHost, selectedMode, selectedAgent]);
const activeHostContexts = selectedHost ? agentsByHostname[selectedHost] : null;
// Render (Sidebar handles config) // Render (Sidebar handles config)
return ( return (
<div className="borealis-node"> <div className="borealis-node">
@@ -301,76 +447,40 @@ const hostOptions = useMemo(() => {
/> />
<div className="borealis-node-header">Device Agent</div> <div className="borealis-node-header">Device Agent</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}> <div
<label>Site:</label> className="borealis-node-content"
<select style={{
value={selectedSiteId} fontSize: "9px",
onChange={(e) => setSelectedSiteId(e.target.value)} display: "flex",
style={{ width: "100%", marginBottom: "6px", fontSize: "9px" }} flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
minHeight: "80px",
gap: "8px",
}}
>
<div style={{ fontSize: "8px", color: "#666" }}>Right-Click to Configure Agent</div>
<button
onClick={isConnected ? handleDisconnect : handleConnect}
style={{
padding: "6px 14px",
fontSize: "10px",
background: isConnected ? "#3a3a3a" : "#0475c2",
color: "#fff",
border: "1px solid #0475c2",
borderRadius: "4px",
cursor: selectedAgent ? "pointer" : "not-allowed",
opacity: selectedAgent ? 1 : 0.5,
minWidth: "150px",
}}
disabled={!selectedAgent}
> >
<option value="">All Sites</option> {isConnected ? "Disconnect" : "Connect to Device"}
{sites.map((site) => ( </button>
<option key={site.id} value={String(site.id)}> <div style={{ fontSize: "8px", color: "#777" }}>
{site.name} {selectedHost ? `${selectedHost} · ${selectedMode.toUpperCase()}` : "No device selected"}
</option>
))}
</select>
<label>Device:</label>
<select
value={selectedHost}
onChange={(e) => setSelectedHost(e.target.value)}
style={{ width: "100%", marginBottom: "6px", fontSize: "9px" }}
disabled={hasSiteSelection && !filteredHostOptions.length}
>
<option value="">-- Select --</option>
{filteredHostOptions.map(({ host, label }) => (
<option key={host} value={host}>
{label}
</option>
))}
</select>
<label>Available Agent Context(s):</label>
<select
value={selectedMode}
onChange={(e) => setSelectedMode(e.target.value)}
style={{ width: "100%", marginBottom: "2px", fontSize: "9px" }}
disabled={!selectedHost}
>
<option value="currentuser" disabled={!activeHostContexts?.currentuser}>
CURRENTUSER (Screen Capture / Macros)
</option>
<option value="system" disabled={!activeHostContexts?.system}>
SYSTEM (Scripts)
</option>
</select>
<div style={{ fontSize: "6px", color: "#aaa", marginBottom: "6px" }}>
Agent ID:{" "}
{selectedAgent ? (
<span style={{ color: "#666" }}>{selectedAgent}</span>
) : (
<span style={{ color: "#666" }}>No Agent Selected</span>
)}
</div> </div>
{isConnected ? (
<button
onClick={handleDisconnect}
style={{ width: "100%", fontSize: "9px", padding: "4px", marginTop: "4px" }}
>
Disconnect from Agent
</button>
) : (
<button
onClick={handleConnect}
style={{ width: "100%", fontSize: "9px", padding: "4px", marginTop: "4px" }}
disabled={!selectedAgent}
>
Connect to Device
</button>
)}
</div> </div>
</div> </div>
); );
@@ -390,37 +500,54 @@ Select and connect to a remote Borealis Agent.
content: "Select and manage an Agent with dynamic roles", content: "Select and manage an Agent with dynamic roles",
component: BorealisAgentNode, component: BorealisAgentNode,
config: [ config: [
{
key: "agent_site_id",
label: "Site",
type: "select",
optionsKey: "siteOptions",
defaultValue: ""
},
{
key: "agent_host",
label: "Device",
type: "select",
optionsKey: "hostOptions",
defaultValue: ""
},
{
key: "agent_mode",
label: "Agent Context",
type: "select",
optionsKey: "modeOptions",
defaultValue: "currentuser"
},
{ {
key: "agent_id", key: "agent_id",
label: "Agent", label: "Agent ID",
type: "text", // NOTE: UI populates via agent fetch, but config drives default for sidebar. type: "text",
readOnly: true,
defaultValue: "" defaultValue: ""
} }
], ],
usage_documentation: ` usage_documentation: `
### Borealis Agent Node ### Borealis Agent Node
This node represents an available Borealis Agent (Python client) you can control from your workflow. This node allows you to establish a connection with a device running a Borealis "Agent", so you can instruct the agent to do things from your workflow.
#### Features #### Features
- **Select** a device and agent context (CURRENTUSER vs SYSTEM). - **Select** a site, then a device, then finally an agent context (CURRENTUSER vs SYSTEM).
- **Connect/Disconnect** from the agent at any time. - **Connect/Disconnect** from the agent at any time.
- **Attach roles** (by connecting "Agent Role" nodes to this node's output handle) to assign behaviors dynamically. - **Attach roles** (by connecting "Agent Role" nodes to this node's output handle) to assign behaviors dynamically.
- **Live status** shows if the agent is available, connected, or offline.
#### How to Use #### How to Use
1. **Drag in a Borealis Agent node.** 1. **Drag and drop in a Borealis Agent node.**
2. **Pick an agent** from the dropdown list (auto-populates from backend). 2. **Pick an agent** from the dropdown list (auto-populates from API backend).
3. **Click "Connect to Agent"** to provision it for the workflow. 3. **Click "Connect to Agent"**.
4. **Attach Agent Role Nodes** (e.g., Screenshot, Macro Keypress) to the "provisioner" output handle to define what the agent should do. 4. **Attach Agent Role Nodes** (e.g., Screenshot, Macro Keypress) to the "provisioner" output handle to define what the agent should do.
5. Agent will automatically update its roles as you change connected Role Nodes. 5. Agent will automatically update its roles as you change connected Role Nodes.
#### Output Handle
- "provisioner" (bottom): Connect Agent Role nodes here.
#### Good to Know #### Good to Know
- If an agent disconnects or goes offline, its status will show "Reconnecting..." until it returns. - If an agent disconnects or goes offline, its status will show "Reconnecting..." until it returns.
- Node config can be edited in the right sidebar.
- **Roles update LIVE**: Any time you change attached roles, the agent gets updated instantly. - **Roles update LIVE**: Any time you change attached roles, the agent gets updated instantly.
`.trim() `.trim()