Overhauled Deployment Structure

This commit is contained in:
2025-04-19 16:01:12 -06:00
parent 2dc79a03ad
commit 72f919b971
151 changed files with 13 additions and 9 deletions

View File

@ -0,0 +1,460 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/App.jsx
// Core React Imports
import React, {
useState,
useEffect,
useCallback,
useRef
} from "react";
// Material UI - Components
import {
AppBar,
Toolbar,
Typography,
Box,
Menu,
MenuItem,
Button,
CssBaseline,
ThemeProvider,
createTheme
} from "@mui/material";
// Material UI - Icons
import {
KeyboardArrowDown as KeyboardArrowDownIcon,
InfoOutlined as InfoOutlinedIcon,
MergeType as MergeTypeIcon,
People as PeopleIcon
} from "@mui/icons-material";
// React Flow
import { ReactFlowProvider } from "reactflow";
// Styles
import "reactflow/dist/style.css";
// Import Borealis Modules
import FlowTabs from "./Flow_Tabs";
import FlowEditor from "./Flow_Editor";
import NodeSidebar from "./Node_Sidebar";
import {
CloseAllDialog,
CreditsDialog,
RenameTabDialog,
TabContextMenu
} from "./Dialogs";
import StatusBar from "./Status_Bar";
// Websocket Functionality
import { io } from "socket.io-client";
if (!window.BorealisSocket) {
window.BorealisSocket = io(window.location.origin, {
transports: ["websocket"]
});
}
// Global Node Update Timer Variable
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 200;
}
// Dynamically load all node components
const nodeContext = require.context("./nodes", true, /\.jsx$/);
const nodeTypes = {};
const categorizedNodes = {};
nodeContext.keys().forEach((path) => {
const mod = nodeContext(path);
if (!mod.default) return;
const { type, label, component } = mod.default;
if (!type || !component) return;
const pathParts = path.replace("./", "").split("/");
if (pathParts.length < 2) return;
const category = pathParts[0];
if (!categorizedNodes[category]) {
categorizedNodes[category] = [];
}
categorizedNodes[category].push(mod.default);
nodeTypes[type] = component;
});
const darkTheme = createTheme({
palette: {
mode: "dark",
background: {
default: "#121212",
paper: "#1e1e1e"
},
text: {
primary: "#ffffff"
}
}
});
export default function App() {
const [tabs, setTabs] = useState([
{
id: "flow_1",
tab_name: "Flow 1",
nodes: [],
edges: []
}
]);
const [activeTabId, setActiveTabId] = useState("flow_1");
const [aboutAnchorEl, setAboutAnchorEl] = useState(null);
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameTabId, setRenameTabId] = useState(null);
const [renameValue, setRenameValue] = useState("");
const [tabMenuAnchor, setTabMenuAnchor] = useState(null);
const [tabMenuTabId, setTabMenuTabId] = useState(null);
const fileInputRef = useRef(null);
const handleSetNodes = useCallback(
(callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) => {
if (tab.id !== targetId) return tab;
const newNodes =
typeof callbackOrArray === "function"
? callbackOrArray(tab.nodes)
: callbackOrArray;
return { ...tab, nodes: newNodes };
})
);
},
[activeTabId]
);
const handleSetEdges = useCallback(
(callbackOrArray, tId) => {
const targetId = tId || activeTabId;
setTabs((old) =>
old.map((tab) => {
if (tab.id !== targetId) return tab;
const newEdges =
typeof callbackOrArray === "function"
? callbackOrArray(tab.edges)
: callbackOrArray;
return { ...tab, edges: newEdges };
})
);
},
[activeTabId]
);
const handleAboutMenuOpen = (event) => setAboutAnchorEl(event.currentTarget);
const handleAboutMenuClose = () => setAboutAnchorEl(null);
const openCreditsDialog = () => {
handleAboutMenuClose();
setCreditsDialogOpen(true);
};
const handleOpenCloseAllDialog = () => setConfirmCloseOpen(true);
const handleCloseDialog = () => setConfirmCloseOpen(false);
const handleConfirmCloseAll = () => {
setTabs([
{
id: "flow_1",
tab_name: "Flow 1",
nodes: [],
edges: []
}
]);
setActiveTabId("flow_1");
setConfirmCloseOpen(false);
};
const createNewTab = () => {
const nextIndex = tabs.length + 1;
const newId = "flow_" + nextIndex;
setTabs((old) => [
...old,
{
id: newId,
tab_name: "Flow " + nextIndex,
nodes: [],
edges: []
}
]);
setActiveTabId(newId);
};
const handleTabChange = (newActiveTabId) => {
setActiveTabId(newActiveTabId);
};
const handleTabRightClick = (evt, tabId) => {
evt.preventDefault();
setTabMenuAnchor({ x: evt.clientX, y: evt.clientY });
setTabMenuTabId(tabId);
};
const handleCloseTabMenu = () => {
setTabMenuAnchor(null);
setTabMenuTabId(null);
};
const handleRenameTab = () => {
setRenameDialogOpen(true);
setRenameTabId(tabMenuTabId);
const t = tabs.find((x) => x.id === tabMenuTabId);
setRenameValue(t ? t.tab_name : "");
handleCloseTabMenu();
};
const handleCloseTab = () => {
setTabs((old) => {
const idx = old.findIndex((t) => t.id === tabMenuTabId);
if (idx === -1) return old;
const newList = [...old];
newList.splice(idx, 1);
if (tabMenuTabId === activeTabId && newList.length > 0) {
setActiveTabId(newList[0].id);
} else if (newList.length === 0) {
newList.push({
id: "flow_1",
tab_name: "Flow 1",
nodes: [],
edges: []
});
setActiveTabId("flow_1");
}
return newList;
});
handleCloseTabMenu();
};
const handleRenameDialogSave = () => {
if (!renameTabId) {
setRenameDialogOpen(false);
return;
}
setTabs((old) =>
old.map((tab) =>
tab.id === renameTabId
? { ...tab, tab_name: renameValue }
: tab
)
);
setRenameDialogOpen(false);
};
const handleExportFlow = async () => {
const activeTab = tabs.find((x) => x.id === activeTabId);
if (!activeTab) return;
const data = JSON.stringify(
{
nodes: activeTab.nodes,
edges: activeTab.edges,
tab_name: activeTab.tab_name
},
null,
2
);
const blob = new Blob([data], { type: "application/json" });
const sanitizedTabName = activeTab.tab_name.replace(/\s+/g, "_").toLowerCase();
const suggestedFilename = sanitizedTabName + "_workflow.json";
if (window.showSaveFilePicker) {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: suggestedFilename,
types: [
{
description: "Workflow JSON File",
accept: { "application/json": [".json"] }
}
]
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
console.error("Save cancelled or failed:", err);
}
} else {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = suggestedFilename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
};
const handleImportFlow = async () => {
if (window.showOpenFilePicker) {
try {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: "Workflow JSON File",
accept: { "application/json": [".json"] }
}
]
});
const file = await fileHandle.getFile();
const text = await file.text();
const json = JSON.parse(text);
const newId = "flow_" + (tabs.length + 1);
setTabs((prev) => [
...prev,
{
id: newId,
tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1),
nodes: json.nodes || [],
edges: json.edges || []
}
]);
setActiveTabId(newId);
} catch (err) {
console.error("Import cancelled or failed:", err);
}
} else {
fileInputRef.current?.click();
}
};
const handleFileInputChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const json = JSON.parse(text);
const newId = "flow_" + (tabs.length + 1);
setTabs((prev) => [
...prev,
{
id: newId,
tab_name: json.tab_name || "Imported Flow " + (tabs.length + 1),
nodes: json.nodes || [],
edges: json.edges || []
}
]);
setActiveTabId(newId);
} catch (err) {
console.error("Failed to read file:", err);
}
};
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Box sx={{ width: "100vw", height: "100vh", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<AppBar position="static" sx={{ bgcolor: "#16191d" }}>
<Toolbar sx={{ minHeight: "36px" }}>
<Box component="img" src="/Borealis_Logo_Full.png" alt="Borealis Logo" sx={{ height: "52px", marginRight: "8px" }} />
<Typography variant="h6" sx={{ flexGrow: 1, fontSize: "1rem" }}></Typography>
<Button
color="inherit"
onClick={handleAboutMenuOpen}
endIcon={<KeyboardArrowDownIcon />}
startIcon={<InfoOutlinedIcon />}
sx={{ height: "36px" }}
>
About
</Button>
<Menu anchorEl={aboutAnchorEl} open={Boolean(aboutAnchorEl)} onClose={handleAboutMenuClose}>
<MenuItem onClick={() => { handleAboutMenuClose(); window.open("https://git.bunny-lab.io/Borealis", "_blank"); }}>
<MergeTypeIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Gitea Project
</MenuItem>
<MenuItem onClick={openCreditsDialog}>
<PeopleIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} /> Credits
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
<NodeSidebar
categorizedNodes={categorizedNodes}
handleExportFlow={handleExportFlow}
handleImportFlow={handleImportFlow}
handleOpenCloseAllDialog={handleOpenCloseAllDialog}
fileInputRef={fileInputRef}
onFileInputChange={handleFileInputChange}
/>
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
<FlowTabs
tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onAddTab={createNewTab}
onTabRightClick={handleTabRightClick}
/>
<Box sx={{ flexGrow: 1, position: "relative" }}>
{tabs.map((tab) => (
<Box
key={tab.id}
sx={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
display: tab.id === activeTabId ? "block" : "none"
}}
>
<ReactFlowProvider>
<FlowEditor
nodes={tab.nodes}
edges={tab.edges}
setNodes={(val) => handleSetNodes(val, tab.id)}
setEdges={(val) => handleSetEdges(val, tab.id)}
nodeTypes={nodeTypes}
categorizedNodes={categorizedNodes}
/>
</ReactFlowProvider>
</Box>
))}
</Box>
</Box>
</Box>
<StatusBar />
</Box>
<CloseAllDialog
open={confirmCloseOpen}
onClose={handleCloseDialog}
onConfirm={handleConfirmCloseAll}
/>
<CreditsDialog open={creditsDialogOpen} onClose={() => setCreditsDialogOpen(false)} />
<RenameTabDialog
open={renameDialogOpen}
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameDialogOpen(false)}
onSave={handleRenameDialogSave}
/>
<TabContextMenu
anchor={tabMenuAnchor}
onClose={handleCloseTabMenu}
onRename={handleRenameTab}
onCloseTab={handleCloseTab}
/>
</ThemeProvider>
);
}

View File

@ -0,0 +1,166 @@
/* ///////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Borealis.css
/* ======================================= */
/* FLOW EDITOR */
/* ======================================= */
/* FlowEditor background container */
.flow-editor-container {
position: relative;
width: 100vw;
height: 100vh;
}
/* Blue Gradient Overlay */
.flow-editor-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(9, 44, 68, 0.9) 0%,
rgba(30, 30, 30, 0) 45%,
rgba(30, 30, 30, 0) 75%,
rgba(9, 44, 68, 0.7) 100%
);
z-index: -1;
}
/* ======================================= */
/* NODE SIDEBAR */
/* ======================================= */
/* Emphasize Drag & Drop Node Functionality */
.sidebar-button:hover {
background-color: #2a2a2a !important;
box-shadow: 0 0 5px rgba(88, 166, 255, 0.3);
cursor: grab;
}
/* ======================================= */
/* NODES */
/* ======================================= */
/* Borealis Node Styling */
.borealis-node {
background: #2c2c2c;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #ccc;
font-size: 12px;
min-width: 160px;
max-width: 260px;
position: relative;
box-shadow: 0 0 5px rgba(88, 166, 255, 0.15),
0 0 10px rgba(88, 166, 255, 0.15);
transition: box-shadow 0.3s ease-in-out;
}
.borealis-node-header {
background: #232323;
padding: 6px 10px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
font-weight: bold;
color: #58a6ff;
font-size: 10px;
}
.borealis-node-content {
padding: 10px;
font-size: 9px;
}
.borealis-handle {
background: #58a6ff;
width: 10px;
height: 10px;
}
/* Global dark form inputs */
input,
select,
button {
background-color: #2a2a2a;
color: #ccc;
border: 1px solid #444;
font-size: 12px;
}
/* Label / Dark Text styling */
label {
color: #aaa;
font-size: 10px;
}
/* ======================================= */
/* FLOW TABS */
/* ======================================= */
/* Multi-Tab Bar Adjustments */
.MuiTabs-root {
min-height: 32px !important;
}
.MuiTab-root {
min-height: 32px !important;
padding: 6px 12px !important;
color: #58a6ff !important;
text-transform: none !important;
}
/* Highlight tab on hover if it's not active */
.MuiTab-root:hover:not(.Mui-selected) {
background-color: #2C2C2C !important;
}
/* We rely on the TabIndicatorProps to show the underline highlight for active tabs. */
/* ======================================= */
/* REACT-SIMPLE-KEYBOARD */
/* ======================================= */
/* keyboard-dark-theme.css */
/* react-simple-keyboard dark theming */
.hg-theme-dark {
background-color: #1e1e1e;
border: 1px solid #444;
}
.hg-button {
background: #2c2c2c;
color: #ccc;
border: 1px solid #444;
font-size: 11px;
}
.hg-button:hover {
background: #58a6ff;
color: #000;
border-color: #58a6ff;
}
.borealis-keyboard .hg-button.hg-standardBtn {
border-radius: 3px;
}
/* Force rows to appear horizontally */
.simple-keyboard-main .hg-row {
display: flex !important;
flex-flow: row wrap;
justify-content: center;
}
/* Slight spacing around each key (optional) */
.simple-keyboard-main .hg-row .hg-button {
margin: 3px !important;
}
/* Keep the entire keyboard from shrinking or going vertical */
.simple-keyboard-main {
display: inline-block !important;
width: auto !important;
max-width: 1000px; /* or whatever max width you like */
}

View File

@ -0,0 +1,105 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Dialogs.jsx
import React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
Menu,
MenuItem,
TextField
} from "@mui/material";
export function CloseAllDialog({ open, onClose, onConfirm }) {
return (
<Dialog open={open} onClose={onClose} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Close All Flow Tabs?</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>
This will remove all existing flow tabs and create a fresh tab named Flow 1.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onConfirm} sx={{ color: "#ff4f4f" }}>Close All</Button>
</DialogActions>
</Dialog>
);
}
export function CreditsDialog({ open, onClose }) {
return (
<Dialog open={open} onClose={onClose} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Borealis Workflow Automation Tool</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: "#ccc" }}>
Designed by Nicole Rappe @ Bunny Lab
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} sx={{ color: "#58a6ff" }}>Close</Button>
</DialogActions>
</Dialog>
);
}
export function RenameTabDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
<DialogTitle>Rename Tab</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Tab Name"
fullWidth
variant="outlined"
value={value}
onChange={(e) => onChange(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#2a2a2a",
color: "#ccc",
"& fieldset": {
borderColor: "#444"
},
"&:hover fieldset": {
borderColor: "#666"
}
},
label: { color: "#aaa" },
mt: 1
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
</DialogActions>
</Dialog>
);
}
export function TabContextMenu({ anchor, onClose, onRename, onCloseTab }) {
return (
<Menu
open={Boolean(anchor)}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchor ? { top: anchor.y, left: anchor.x } : undefined}
PaperProps={{
sx: {
bgcolor: "#1e1e1e",
color: "#fff",
fontSize: "13px"
}
}}
>
<MenuItem onClick={onRename}>Rename</MenuItem>
<MenuItem onClick={onCloseTab}>Close</MenuItem>
</Menu>
);
}

View File

@ -0,0 +1,213 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Editor.jsx
import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactFlow, {
Background,
addEdge,
applyNodeChanges,
applyEdgeChanges,
useReactFlow
} from "reactflow";
import { Menu, MenuItem } from "@mui/material";
import {
Polyline as PolylineIcon,
DeleteForever as DeleteForeverIcon
} from "@mui/icons-material";
import "reactflow/dist/style.css";
/**
* Single flow editor component.
*
* Props:
* - nodes
* - edges
* - setNodes
* - setEdges
* - nodeTypes
* - categorizedNodes (used to find node meta info on drop)
*/
export default function FlowEditor({
nodes,
edges,
setNodes,
setEdges,
nodeTypes,
categorizedNodes
}) {
const wrapperRef = useRef(null);
const { project } = useReactFlow();
const [contextMenu, setContextMenu] = useState(null);
const onDrop = useCallback(
(event) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow");
if (!type) return;
const bounds = wrapperRef.current.getBoundingClientRect();
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
const id = "node-" + Date.now();
// Find node definition in the categorizedNodes
const nodeMeta = Object.values(categorizedNodes)
.flat()
.find((n) => n.type === type);
const newNode = {
id: id,
type: type,
position: position,
data: {
label: nodeMeta?.label || type,
content: nodeMeta?.content
}
};
setNodes((nds) => [...nds, newNode]);
},
[project, setNodes, categorizedNodes]
);
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onConnect = useCallback(
(params) =>
setEdges((eds) =>
addEdge(
{
...params,
type: "smoothstep",
animated: true,
style: {
strokeDasharray: "6 3",
stroke: "#58a6ff"
}
},
eds
)
),
[setEdges]
);
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[setNodes]
);
const onEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[setEdges]
);
const handleRightClick = (e, node) => {
e.preventDefault();
setContextMenu({
mouseX: e.clientX + 2,
mouseY: e.clientY - 6,
nodeId: node.id
});
};
const handleDisconnect = () => {
if (contextMenu?.nodeId) {
setEdges((eds) =>
eds.filter(
(e) =>
e.source !== contextMenu.nodeId &&
e.target !== contextMenu.nodeId
)
);
}
setContextMenu(null);
};
const handleRemoveNode = () => {
if (contextMenu?.nodeId) {
setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
setEdges((eds) =>
eds.filter(
(e) =>
e.source !== contextMenu.nodeId &&
e.target !== contextMenu.nodeId
)
);
}
setContextMenu(null);
};
useEffect(() => {
const nodeCountEl = document.getElementById("nodeCount");
if (nodeCountEl) {
nodeCountEl.innerText = nodes.length;
}
}, [nodes]);
return (
<div className="flow-editor-container" ref={wrapperRef}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeContextMenu={handleRightClick}
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
edgeOptions={{
type: "smoothstep",
animated: true,
style: {
strokeDasharray: "6 3",
stroke: "#58a6ff"
}
}}
proOptions={{ hideAttribution: true }}
>
<Background
variant="lines"
gap={65}
size={1}
color="rgba(255, 255, 255, 0.2)"
/>
</ReactFlow>
{/* Right-click node menu */}
<Menu
open={Boolean(contextMenu)}
onClose={() => setContextMenu(null)}
anchorReference="anchorPosition"
anchorPosition={
contextMenu
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
PaperProps={{
sx: {
bgcolor: "#1e1e1e",
color: "#fff",
fontSize: "13px"
}
}}
>
<MenuItem onClick={handleDisconnect}>
<PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
Disconnect All Edges
</MenuItem>
<MenuItem onClick={handleRemoveNode}>
<DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
Remove Node
</MenuItem>
</Menu>
</div>
);
}

View File

@ -0,0 +1,98 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Tabs.jsx
import React from "react";
import { Box, Tabs, Tab } from "@mui/material";
import { Add as AddIcon } from "@mui/icons-material";
/**
* Renders the tab bar (including the "add tab" button).
*
* Props:
* - tabs (array of {id, tab_name, nodes, edges})
* - activeTabId (string)
* - onTabChange(newActiveTabId: string)
* - onAddTab()
* - onTabRightClick(evt: MouseEvent, tabId: string)
*/
export default function FlowTabs({
tabs,
activeTabId,
onTabChange,
onAddTab,
onTabRightClick
}) {
// Determine the currently active tab index
const activeIndex = (() => {
const idx = tabs.findIndex((t) => t.id === activeTabId);
return idx >= 0 ? idx : 0;
})();
// Handle tab clicks
const handleChange = (event, newValue) => {
if (newValue === "__addtab__") {
// The "plus" tab
onAddTab();
} else {
// normal tab index
const newTab = tabs[newValue];
if (newTab) {
onTabChange(newTab.id);
}
}
};
return (
<Box
sx={{
display: "flex",
alignItems: "center",
backgroundColor: "#232323",
borderBottom: "1px solid #333",
height: "36px"
}}
>
<Tabs
value={activeIndex}
onChange={handleChange}
variant="scrollable"
scrollButtons="auto"
textColor="inherit"
TabIndicatorProps={{
style: { backgroundColor: "#58a6ff" }
}}
sx={{
minHeight: "36px",
height: "36px",
flexGrow: 1
}}
>
{tabs.map((tab, index) => (
<Tab
key={tab.id}
label={tab.tab_name}
value={index}
onContextMenu={(evt) => onTabRightClick(evt, tab.id)}
sx={{
minHeight: "36px",
height: "36px",
textTransform: "none",
backgroundColor: tab.id === activeTabId ? "#2C2C2C" : "transparent",
color: "#58a6ff"
}}
/>
))}
{/* The "plus" tab has a special value */}
<Tab
icon={<AddIcon />}
value="__addtab__"
sx={{
minHeight: "36px",
height: "36px",
color: "#58a6ff",
textTransform: "none"
}}
/>
</Tabs>
</Box>
);
}

View File

@ -0,0 +1,191 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Node_Sidebar.jsx
import React, { useState } from "react";
import {
Accordion,
AccordionSummary,
AccordionDetails,
Button,
Tooltip,
Typography
} from "@mui/material";
import {
ExpandMore as ExpandMoreIcon,
Save as SaveIcon,
FileOpen as FileOpenIcon,
DeleteForever as DeleteForeverIcon,
DragIndicator as DragIndicatorIcon,
Polyline as PolylineIcon
} from "@mui/icons-material";
export default function NodeSidebar({
categorizedNodes,
handleExportFlow,
handleImportFlow,
handleOpenCloseAllDialog,
fileInputRef,
onFileInputChange
}) {
const [expandedCategory, setExpandedCategory] = useState(null);
const handleAccordionChange = (category) => (_, isExpanded) => {
setExpandedCategory(isExpanded ? category : null);
};
return (
<div
style={{
width: 300, //Width of the Node Sidebar
backgroundColor: "#121212",
borderRight: "1px solid #333",
overflowY: "auto"
}}
>
{/* Workflows Section */}
<Accordion
defaultExpanded
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
backgroundColor: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 }
}}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<b>Workflows</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
<Button fullWidth startIcon={<SaveIcon />} onClick={handleExportFlow} sx={buttonStyle}>
Export Current Flow
</Button>
<Button fullWidth startIcon={<FileOpenIcon />} onClick={handleImportFlow} sx={buttonStyle}>
Import Flow
</Button>
<Button fullWidth startIcon={<DeleteForeverIcon />} onClick={handleOpenCloseAllDialog} sx={buttonStyle}>
Close All Flows
</Button>
</AccordionDetails>
</Accordion>
{/* Nodes Section */}
<Accordion
defaultExpanded
square
disableGutters
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
backgroundColor: "#2c2c2c",
minHeight: "36px",
"& .MuiAccordionSummary-content": { margin: 0 }
}}
>
<Typography sx={{ fontSize: "0.9rem", color: "#0475c2" }}>
<b>Nodes</b>
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 0 }}>
{Object.entries(categorizedNodes).map(([category, items]) => (
<Accordion
key={category}
square
expanded={expandedCategory === category}
onChange={handleAccordionChange(category)}
disableGutters
sx={{
bgcolor: "#232323",
"&:before": { display: "none" },
margin: 0,
border: 0
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
bgcolor: "#1e1e1e",
px: 2,
minHeight: "32px",
"& .MuiAccordionSummary-content": { margin: 0 }
}}
>
<Typography sx={{ color: "#888", fontSize: "0.75rem" }}>
{category}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 1, py: 0 }}>
{items.map((nodeDef) => (
<Tooltip
key={`${category}-${nodeDef.type}`}
title={
<span style={{ whiteSpace: "pre-line", wordWrap: "break-word", maxWidth: 220 }}>
{nodeDef.description || "Drag & Drop into Editor"}
</span>
}
placement="right"
arrow
>
<Button
fullWidth
sx={nodeButtonStyle}
draggable
onDragStart={(event) => {
event.dataTransfer.setData("application/reactflow", nodeDef.type);
event.dataTransfer.effectAllowed = "move";
}}
startIcon={<DragIndicatorIcon sx={{ color: "#666", fontSize: 18 }} />}
>
<span style={{ flexGrow: 1, textAlign: "left" }}>{nodeDef.label}</span>
<PolylineIcon sx={{ color: "#58a6ff", fontSize: 18, ml: 1 }} />
</Button>
</Tooltip>
))}
</AccordionDetails>
</Accordion>
))}
</AccordionDetails>
</Accordion>
{/* Hidden file input fallback for older browsers */}
<input
type="file"
accept=".json,application/json"
style={{ display: "none" }}
ref={fileInputRef}
onChange={onFileInputChange}
/>
</div>
);
}
const buttonStyle = {
color: "#ccc",
backgroundColor: "#232323",
justifyContent: "flex-start",
pl: 2,
fontSize: "0.9rem",
textTransform: "none",
"&:hover": {
backgroundColor: "#2a2a2a"
}
};
const nodeButtonStyle = {
color: "#ccc",
backgroundColor: "#232323",
justifyContent: "space-between",
pl: 2,
pr: 1,
fontSize: "0.9rem",
textTransform: "none",
"&:hover": {
backgroundColor: "#2a2a2a"
}
};

View File

@ -0,0 +1,71 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Status_Bar.jsx
import React from "react";
import { Box, Button, Divider } from "@mui/material";
export default function StatusBar() {
const applyRate = () => {
const val = parseInt(
document.getElementById("updateRateInput")?.value
);
if (!isNaN(val) && val >= 50) {
window.BorealisUpdateRate = val;
console.log("Global update rate set to", val + "ms");
} else {
alert("Please enter a valid number (min 50).");
}
};
return (
<Box
component="footer"
sx={{
bgcolor: "#1e1e1e",
color: "white",
px: 2,
py: 1,
display: "flex",
alignItems: "center",
gap: 2
}}
>
<b>Nodes</b>: <span id="nodeCount">0</span>
<Divider
orientation="vertical"
flexItem
sx={{ borderColor: "#444" }}
/>
<b>Update Rate (ms):</b>
<input
id="updateRateInput"
type="number"
min="50"
step="50"
defaultValue={window.BorealisUpdateRate}
style={{
width: "80px",
background: "#121212",
color: "#fff",
border: "1px solid #444",
borderRadius: "3px",
padding: "3px",
fontSize: "0.8rem"
}}
/>
<Button
variant="outlined"
size="small"
onClick={applyRate}
sx={{
color: "#58a6ff",
borderColor: "#58a6ff",
fontSize: "0.75rem",
textTransform: "none",
px: 1.5
}}
>
Apply Rate
</Button>
</Box>
);
}

View File

@ -0,0 +1,18 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
// Global Styles
import './index.css';
import "normalize.css/normalize.css";
import './Borealis.css'; // Global Theming for All of Borealis
import App from './App.jsx';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,154 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent/Node_Agent.jsx
import React, { useEffect, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
const BorealisAgentNode = ({ id, data }) => {
const { getNodes, getEdges, setNodes } = useReactFlow();
const [agents, setAgents] = useState([]);
const [selectedAgent, setSelectedAgent] = useState(data.agent_id || "");
// -------------------------------
// Load agent list from backend
// -------------------------------
useEffect(() => {
fetch("/api/agents")
.then(res => res.json())
.then(setAgents);
const interval = setInterval(() => {
fetch("/api/agents")
.then(res => res.json())
.then(setAgents);
}, 5000);
return () => clearInterval(interval);
}, []);
// -------------------------------
// Helper: Get all provisioner role nodes connected to bottom port
// -------------------------------
const getAttachedProvisioners = () => {
const allNodes = getNodes();
const allEdges = getEdges();
const attached = [];
for (const edge of allEdges) {
if (edge.source === id && edge.sourceHandle === "provisioner") {
const roleNode = allNodes.find(n => n.id === edge.target);
if (roleNode && typeof window.__BorealisInstructionNodes?.[roleNode.id] === "function") {
attached.push(window.__BorealisInstructionNodes[roleNode.id]());
}
}
}
return attached;
};
// -------------------------------
// Provision Agent with all Roles
// -------------------------------
const handleProvision = () => {
if (!selectedAgent) return;
const provisionRoles = getAttachedProvisioners();
if (!provisionRoles.length) {
console.warn("No provisioner nodes connected to agent.");
return;
}
const configPayload = {
agent_id: selectedAgent,
roles: provisionRoles
};
fetch("/api/agent/provision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(configPayload)
})
.then(res => res.json())
.then(() => {
console.log(`[Provision] Agent ${selectedAgent} updated with ${provisionRoles.length} roles.`);
});
};
return (
<div className="borealis-node">
{/* This bottom port is used for bi-directional provisioning & feedback */}
<Handle
type="source"
position={Position.Bottom}
id="provisioner"
className="borealis-handle"
style={{ top: "100%", background: "#58a6ff" }}
/>
<div className="borealis-node-header">Borealis Agent</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<label>Agent:</label>
<select
value={selectedAgent}
onChange={(e) => {
const newId = e.target.value;
setSelectedAgent(newId);
setNodes(nds =>
nds.map(n =>
n.id === id
? { ...n, data: { ...n.data, agent_id: newId } }
: n
)
);
}}
style={{ width: "100%", marginBottom: "6px", fontSize: "9px" }}
>
<option value="">-- Select --</option>
{Object.entries(agents).map(([id, info]) => {
const label = info.status === "provisioned" ? "(Provisioned)" : "(Idle)";
return (
<option key={id} value={id}>
{id} {label}
</option>
);
})}
</select>
<button
onClick={handleProvision}
style={{ width: "100%", fontSize: "9px", padding: "4px", marginTop: "4px" }}
>
Provision Agent
</button>
<hr style={{ margin: "6px 0", borderColor: "#444" }} />
<div style={{ fontSize: "8px", color: "#aaa" }}>
Connect <strong>Instruction Nodes</strong> below to define roles.
Each instruction node will send back its results (like screenshots) and act as a separate data output.
</div>
<div style={{ fontSize: "8px", color: "#aaa", marginTop: "4px" }}>
<strong>Supported Roles:</strong>
<ul style={{ paddingLeft: "14px", marginTop: "2px", marginBottom: "0" }}>
<li><code>screenshot</code>: Capture a region with interval and overlay</li>
{/* Future roles will be listed here */}
</ul>
</div>
</div>
</div>
);
};
export default {
type: "Borealis_Agent",
label: "Borealis Agent",
description: `
Main Agent Node
- Selects an available agent
- Connect role nodes below to assign tasks to the agent
- Roles include screenshots, keyboard macros, etc.
`.trim(),
content: "Select and provision a Borealis Agent with task roles",
component: BorealisAgentNode
};

View File

@ -0,0 +1,186 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Agent/Node_Agent_Role_Screenshot.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
import ShareIcon from "@mui/icons-material/Share";
import IconButton from "@mui/material/IconButton";
if (!window.BorealisValueBus) {
window.BorealisValueBus = {};
}
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 100;
}
const ScreenshotInstructionNode = ({ id, data }) => {
const { setNodes, getNodes } = useReactFlow();
const edges = useStore(state => state.edges);
const [interval, setInterval] = useState(data?.interval || 1000);
const [region, setRegion] = useState({
x: data?.x ?? 250,
y: data?.y ?? 100,
w: data?.w ?? 300,
h: data?.h ?? 200,
});
const [visible, setVisible] = useState(data?.visible ?? true);
const [alias, setAlias] = useState(data?.alias || "");
const [imageBase64, setImageBase64] = useState("");
const base64Ref = useRef("");
const handleCopyLiveViewLink = () => {
const agentEdge = edges.find(e => e.target === id && e.sourceHandle === "provisioner");
if (!agentEdge) {
alert("No upstream agent connection found.");
return;
}
const agentNode = getNodes().find(n => n.id === agentEdge.source);
const selectedAgentId = agentNode?.data?.agent_id;
if (!selectedAgentId) {
alert("Upstream agent node does not have a selected agent.");
return;
}
const liveUrl = `${window.location.origin}/api/agent/${selectedAgentId}/node/${id}/screenshot/live`;
navigator.clipboard.writeText(liveUrl)
.then(() => console.log(`[Clipboard] Copied Live View URL: ${liveUrl}`))
.catch(err => console.error("Clipboard copy failed:", err));
};
useEffect(() => {
const intervalId = setInterval(() => {
const val = base64Ref.current;
console.log(`[Screenshot Node] setInterval update. Current base64 length: ${val?.length || 0}`);
if (!val) return;
window.BorealisValueBus[id] = val;
setNodes(nds =>
nds.map(n =>
n.id === id
? { ...n, data: { ...n.data, value: val } }
: n
)
);
}, window.BorealisUpdateRate || 100);
return () => clearInterval(intervalId);
}, [id, setNodes]);
useEffect(() => {
const socket = window.BorealisSocket || null;
if (!socket) {
console.warn("[Screenshot Node] BorealisSocket not available");
return;
}
console.log(`[Screenshot Node] Listening for agent_screenshot_task with node_id: ${id}`);
const handleScreenshot = (payload) => {
console.log("[Screenshot Node] Received payload:", payload);
if (payload?.node_id === id && payload?.image_base64) {
base64Ref.current = payload.image_base64;
setImageBase64(payload.image_base64);
window.BorealisValueBus[id] = payload.image_base64;
console.log(`[Screenshot Node] Updated base64Ref and ValueBus for ${id}, length: ${payload.image_base64.length}`);
} else {
console.log(`[Screenshot Node] Ignored payload for mismatched node_id (${payload?.node_id})`);
}
};
socket.on("agent_screenshot_task", handleScreenshot);
return () => socket.off("agent_screenshot_task", handleScreenshot);
}, [id]);
window.__BorealisInstructionNodes = window.__BorealisInstructionNodes || {};
window.__BorealisInstructionNodes[id] = () => ({
node_id: id,
role: "screenshot",
interval,
visible,
alias,
...region
});
return (
<div className="borealis-node" style={{ position: "relative" }}>
<Handle type="target" position={Position.Left} className="borealis-handle" />
<Handle type="source" position={Position.Right} className="borealis-handle" />
<div className="borealis-node-header">Agent Role: Screenshot</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<label>Update Interval (ms):</label>
<input
type="number"
min="100"
step="100"
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
style={{ width: "100%", marginBottom: "4px" }}
/>
<label>Region X / Y / W / H:</label>
<div style={{ display: "flex", gap: "4px", marginBottom: "4px" }}>
<input type="number" value={region.x} onChange={(e) => setRegion({ ...region, x: Number(e.target.value) })} style={{ width: "25%" }} />
<input type="number" value={region.y} onChange={(e) => setRegion({ ...region, y: Number(e.target.value) })} style={{ width: "25%" }} />
<input type="number" value={region.w} onChange={(e) => setRegion({ ...region, w: Number(e.target.value) })} style={{ width: "25%" }} />
<input type="number" value={region.h} onChange={(e) => setRegion({ ...region, h: Number(e.target.value) })} style={{ width: "25%" }} />
</div>
<div style={{ marginBottom: "4px" }}>
<label>
<input
type="checkbox"
checked={visible}
onChange={() => setVisible(!visible)}
style={{ marginRight: "4px" }}
/>
Show Overlay on Agent
</label>
</div>
<label>Overlay Label:</label>
<input
type="text"
value={alias}
onChange={(e) => setAlias(e.target.value)}
placeholder="Label (optional)"
style={{ width: "100%", fontSize: "9px", marginBottom: "6px" }}
/>
<div style={{ textAlign: "center", fontSize: "8px", color: "#aaa" }}>
{imageBase64
? `Last image: ${Math.round(imageBase64.length / 1024)} KB`
: "Awaiting Screenshot Data..."}
</div>
</div>
<div style={{ position: "absolute", top: 4, right: 4 }}>
<IconButton size="small" onClick={handleCopyLiveViewLink}>
<ShareIcon style={{ fontSize: 14 }} />
</IconButton>
</div>
</div>
);
};
export default {
type: "Agent_Role_Screenshot",
label: "Agent Role: Screenshot",
description: `
Agent Role Node: Screenshot Region
- Defines a single region capture role
- Allows custom update interval and overlay
- Emits captured base64 PNG data from agent
`.trim(),
content: "Capture screenshot region via agent",
component: ScreenshotInstructionNode
};

View File

@ -0,0 +1,325 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: Node_Alert_Sound.jsx
/**
* ==================================================
* Borealis - Alert Sound Node (with Base64 Restore)
* ==================================================
*
* COMPONENT ROLE:
* Plays a sound when input = "1". Provides a visual indicator:
* - Green dot: input is 0
* - Red dot: input is 1
*
* Modes:
* - "Once": Triggers once when going 0 -> 1
* - "Constant": Triggers repeatedly every X ms while input = 1
*
* Supports embedding base64 audio directly into the workflow.
*/
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const AlertSoundNode = ({ id, data }) => {
const edges = useStore(state => state.edges);
const { setNodes } = useReactFlow();
const [alertType, setAlertType] = useState(data?.alertType || "Once");
const [intervalMs, setIntervalMs] = useState(data?.interval || 1000);
const [prevInput, setPrevInput] = useState("0");
const [customAudioBase64, setCustomAudioBase64] = useState(data?.audio || null);
const [currentInput, setCurrentInput] = useState("0");
const audioRef = useRef(null);
const playSound = () => {
if (audioRef.current) {
console.log(`[Alert Node ${id}] Attempting to play sound`);
try {
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.load();
audioRef.current.play().then(() => {
console.log(`[Alert Node ${id}] Sound played successfully`);
}).catch((err) => {
console.warn(`[Alert Node ${id}] Audio play blocked or errored:`, err);
});
} catch (err) {
console.error(`[Alert Node ${id}] Failed to play sound:`, err);
}
} else {
console.warn(`[Alert Node ${id}] No audioRef loaded`);
}
};
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
console.log(`[Alert Node ${id}] File selected:`, file.name, file.type);
const supportedTypes = ["audio/wav", "audio/mp3", "audio/mpeg", "audio/ogg"];
if (!supportedTypes.includes(file.type)) {
console.warn(`[Alert Node ${id}] Unsupported audio type: ${file.type}`);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target.result;
const mimeType = file.type || "audio/mpeg";
const safeURL = base64.startsWith("data:")
? base64
: `data:${mimeType};base64,${base64}`;
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = "";
audioRef.current.load();
audioRef.current = null;
}
const newAudio = new Audio();
newAudio.src = safeURL;
let readyFired = false;
newAudio.addEventListener("canplaythrough", () => {
if (readyFired) return;
readyFired = true;
console.log(`[Alert Node ${id}] Audio is decodable and ready: ${file.name}`);
setCustomAudioBase64(safeURL);
audioRef.current = newAudio;
newAudio.load();
setNodes(nds =>
nds.map(n =>
n.id === id
? { ...n, data: { ...n.data, audio: safeURL } }
: n
)
);
});
setTimeout(() => {
if (!readyFired) {
console.warn(`[Alert Node ${id}] WARNING: Audio not marked ready in time. May fail silently.`);
}
}, 2000);
};
reader.onerror = (e) => {
console.error(`[Alert Node ${id}] File read error:`, e);
};
reader.readAsDataURL(file);
};
// Restore embedded audio from saved workflow
useEffect(() => {
if (customAudioBase64) {
console.log(`[Alert Node ${id}] Loading embedded audio from workflow`);
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = "";
audioRef.current.load();
audioRef.current = null;
}
const loadedAudio = new Audio(customAudioBase64);
loadedAudio.addEventListener("canplaythrough", () => {
console.log(`[Alert Node ${id}] Embedded audio ready`);
});
audioRef.current = loadedAudio;
loadedAudio.load();
} else {
console.log(`[Alert Node ${id}] No custom audio, using fallback silent wav`);
audioRef.current = new Audio("data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YRAAAAAA");
audioRef.current.load();
}
}, [customAudioBase64]);
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
let intervalId = null;
const runLogic = () => {
const inputEdge = edges.find(e => e.target === id);
const sourceId = inputEdge?.source || null;
const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0";
setCurrentInput(val);
if (alertType === "Once") {
if (val === "1" && prevInput !== "1") {
console.log(`[Alert Node ${id}] Triggered ONCE playback`);
playSound();
}
}
setPrevInput(val);
};
const start = () => {
if (alertType === "Constant") {
intervalId = setInterval(() => {
const inputEdge = edges.find(e => e.target === id);
const sourceId = inputEdge?.source || null;
const val = sourceId ? (window.BorealisValueBus[sourceId] || "0") : "0";
setCurrentInput(val);
if (String(val) === "1") {
console.log(`[Alert Node ${id}] Triggered CONSTANT playback`);
playSound();
}
}, intervalMs);
} else {
intervalId = setInterval(runLogic, currentRate);
}
};
start();
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate && alertType === "Once") {
currentRate = newRate;
clearInterval(intervalId);
start();
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [edges, alertType, intervalMs, prevInput]);
const indicatorColor = currentInput === "1" ? "#ff4444" : "#44ff44";
return (
<div className="borealis-node" style={{ position: "relative" }}>
<Handle type="target" position={Position.Left} className="borealis-handle" />
{/* Header with indicator dot */}
<div className="borealis-node-header" style={{ position: "relative" }}>
{data?.label || "Alert Sound"}
<div style={{
position: "absolute",
top: "12px", // Adjusted from 6px to 12px for better centering
right: "6px",
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: indicatorColor,
border: "1px solid #222"
}} />
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
Play a sound when input equals "1"
</div>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Alerting Type:
</label>
<select
value={alertType}
onChange={(e) => setAlertType(e.target.value)}
style={dropdownStyle}
>
<option value="Once">Once</option>
<option value="Constant">Constant</option>
</select>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Alert Interval (ms):
</label>
<input
type="number"
min="100"
step="100"
value={intervalMs}
onChange={(e) => setIntervalMs(parseInt(e.target.value))}
disabled={alertType === "Once"}
style={{
...inputStyle,
background: alertType === "Once" ? "#2a2a2a" : "#1e1e1e"
}}
/>
<label style={{ fontSize: "9px", display: "block", marginTop: "6px", marginBottom: "4px" }}>
Custom Sound:
</label>
<div style={{ display: "flex", gap: "4px" }}>
<input
type="file"
accept=".wav,.mp3,.mpeg,.ogg"
onChange={handleFileUpload}
style={{ ...inputStyle, marginBottom: 0, flex: 1 }}
/>
<button
style={{
fontSize: "9px",
padding: "4px 8px",
backgroundColor: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
cursor: "pointer"
}}
onClick={playSound}
title="Test playback"
>
Test
</button>
</div>
</div>
</div>
);
};
const dropdownStyle = {
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%",
marginBottom: "8px"
};
const inputStyle = {
fontSize: "9px",
padding: "4px",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%",
marginBottom: "8px"
};
export default {
type: "AlertSoundNode",
label: "Alert Sound",
description: `
Plays a sound alert when input = "1"
- "Once" = Only when 0 -> 1 transition
- "Constant" = Repeats every X ms while input stays 1
- Custom audio supported (MP3/WAV/OGG)
- Base64 audio embedded in workflow and restored
- Visual status indicator (green = 0, red = 1)
- Manual "Test" button for validation
`.trim(),
content: "Sound alert when input value = 1",
component: AlertSoundNode
};

View File

@ -0,0 +1,242 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: Node_OCR_Text_Extraction.jsx
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// Base64 comparison using hash (lightweight)
const getHashScore = (str = "") => {
let hash = 0;
for (let i = 0; i < str.length; i += 101) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return Math.abs(hash);
};
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const OCRNode = ({ id, data }) => {
const edges = useStore((state) => state.edges);
const { setNodes } = useReactFlow();
const [ocrOutput, setOcrOutput] = useState("");
const [engine, setEngine] = useState("None");
const [backend, setBackend] = useState("CPU");
const [dataType, setDataType] = useState("Mixed");
const [customRateEnabled, setCustomRateEnabled] = useState(true);
const [customRateMs, setCustomRateMs] = useState(1000);
const [changeThreshold, setChangeThreshold] = useState(0);
const valueRef = useRef("");
const lastUsed = useRef({ engine: "", backend: "", dataType: "" });
const lastProcessedAt = useRef(0);
const lastImageHash = useRef(0);
const sendToOCRAPI = async (base64) => {
const cleanBase64 = base64.replace(/^data:image\/[a-zA-Z]+;base64,/, "");
try {
const response = await fetch("/api/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: cleanBase64, engine, backend })
});
const result = await response.json();
return response.ok && Array.isArray(result.lines)
? result.lines
: [`[ERROR] ${result.error || "Invalid OCR response."}`];
} catch (err) {
return [`[ERROR] OCR API request failed: ${err.message}`];
}
};
const filterLines = (lines) => {
if (dataType === "Numerical") {
return lines.map(line => line.replace(/[^\d.%\s]/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean);
}
if (dataType === "String") {
return lines.map(line => line.replace(/[^a-zA-Z\s]/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean);
}
return lines;
};
useEffect(() => {
let intervalId = null;
let currentRate = window.BorealisUpdateRate || 100;
const runNodeLogic = async () => {
const inputEdge = edges.find((e) => e.target === id);
if (!inputEdge) {
window.BorealisValueBus[id] = [];
setOcrOutput("");
return;
}
const upstreamValue = window.BorealisValueBus[inputEdge.source] || "";
const now = Date.now();
const effectiveRate = customRateEnabled ? customRateMs : window.BorealisUpdateRate || 100;
const configChanged =
lastUsed.current.engine !== engine ||
lastUsed.current.backend !== backend ||
lastUsed.current.dataType !== dataType;
const upstreamHash = getHashScore(upstreamValue);
const hashDelta = Math.abs(upstreamHash - lastImageHash.current);
const hashThreshold = (changeThreshold / 100) * 1000000000;
const imageChanged = hashDelta > hashThreshold;
// Only reprocess if config changed, or image changed AND time passed
if (!configChanged && (!imageChanged || (now - lastProcessedAt.current < effectiveRate))) return;
lastUsed.current = { engine, backend, dataType };
lastProcessedAt.current = now;
lastImageHash.current = upstreamHash;
valueRef.current = upstreamValue;
const lines = await sendToOCRAPI(upstreamValue);
const filtered = filterLines(lines);
setOcrOutput(filtered.join("\n"));
window.BorealisValueBus[id] = filtered;
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate || 100;
if (newRate !== currentRate) {
clearInterval(intervalId);
intervalId = setInterval(runNodeLogic, newRate);
currentRate = newRate;
}
}, 300);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, engine, backend, dataType, customRateEnabled, customRateMs, changeThreshold, edges]);
return (
<div className="borealis-node" style={{ minWidth: "200px" }}>
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">OCR-Based Text Extraction</div>
<div className="borealis-node-content">
<div style={{ fontSize: "9px", marginBottom: "8px", color: "#ccc" }}>
Extract Multi-Line Text from Upstream Image Node
</div>
<label style={labelStyle}>OCR Engine:</label>
<select value={engine} onChange={(e) => setEngine(e.target.value)} style={dropdownStyle}>
<option value="None">None</option>
<option value="TesseractOCR">TesseractOCR</option>
<option value="EasyOCR">EasyOCR</option>
</select>
<label style={labelStyle}>Compute:</label>
<select value={backend} onChange={(e) => setBackend(e.target.value)} style={dropdownStyle} disabled={engine === "None"}>
<option value="CPU">CPU</option>
<option value="GPU">GPU</option>
</select>
<label style={labelStyle}>Data Type:</label>
<select value={dataType} onChange={(e) => setDataType(e.target.value)} style={dropdownStyle}>
<option value="Mixed">Mixed Data</option>
<option value="Numerical">Numerical Data</option>
<option value="String">String Data</option>
</select>
<label style={labelStyle}>Custom API Rate-Limit (ms):</label>
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
<input
type="checkbox"
checked={customRateEnabled}
onChange={(e) => setCustomRateEnabled(e.target.checked)}
style={{ marginRight: "8px" }}
/>
<input
type="number"
min="100"
step="100"
value={customRateMs}
onChange={(e) => setCustomRateMs(Number(e.target.value))}
disabled={!customRateEnabled}
style={numberInputStyle}
/>
</div>
<label style={labelStyle}>Change Detection Sensitivity Threshold:</label>
<input
type="number"
min="0"
max="100"
step="1"
value={changeThreshold}
onChange={(e) => setChangeThreshold(Number(e.target.value))}
style={numberInputStyle}
/>
<label style={labelStyle}>OCR Output:</label>
<textarea
readOnly
value={ocrOutput}
rows={6}
style={{
width: "100%",
fontSize: "9px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "4px",
resize: "vertical"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
const labelStyle = {
fontSize: "9px",
display: "block",
marginTop: "6px",
marginBottom: "2px"
};
const dropdownStyle = {
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px"
};
const numberInputStyle = {
width: "80px",
fontSize: "9px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "2px 4px",
marginBottom: "8px"
};
export default {
type: "OCR_Text_Extraction",
label: "OCR Text Extraction",
description: `
Extract text from upstream image using backend OCR engine via API.
Includes rate limiting and sensitivity detection for smart processing.`,
content: "Extract Multi-Line Text from Upstream Image Node",
component: OCRNode
};

View File

@ -0,0 +1,143 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: Node_Array_Index_Extractor.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const ArrayIndexExtractorNode = ({ id, data }) => {
const edges = useStore((state) => state.edges);
const { setNodes } = useReactFlow();
const [lineNumber, setLineNumber] = useState(data?.lineNumber || 1);
const [result, setResult] = useState("Line Does Not Exist");
const valueRef = useRef(result);
const handleLineNumberChange = (e) => {
const num = parseInt(e.target.value, 10);
const clamped = isNaN(num) ? 1 : Math.max(1, num);
setLineNumber(clamped);
setNodes((nds) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, lineNumber: clamped } } : n
)
);
};
useEffect(() => {
let intervalId = null;
let currentRate = window.BorealisUpdateRate;
const runNodeLogic = () => {
const inputEdge = edges.find((e) => e.target === id);
if (!inputEdge) {
valueRef.current = "Line Does Not Exist";
setResult("Line Does Not Exist");
window.BorealisValueBus[id] = "Line Does Not Exist";
return;
}
const upstreamValue = window.BorealisValueBus[inputEdge.source];
if (!Array.isArray(upstreamValue)) {
valueRef.current = "Line Does Not Exist";
setResult("Line Does Not Exist");
window.BorealisValueBus[id] = "Line Does Not Exist";
return;
}
const index = Math.max(0, lineNumber - 1); // Convert 1-based input to 0-based
const selected = upstreamValue[index] ?? "Line Does Not Exist";
if (selected !== valueRef.current) {
valueRef.current = selected;
setResult(selected);
window.BorealisValueBus[id] = selected;
}
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 300);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, lineNumber]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Array Index Extractor</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<div style={{ marginBottom: "6px", color: "#ccc" }}>
Output a Specific Array Index's Value
</div>
<label style={{ display: "block", marginBottom: "2px" }}>
Line Number (1 = First Line):
</label>
<input
type="number"
min="1"
step="1"
value={lineNumber}
onChange={handleLineNumberChange}
style={{
width: "100%",
fontSize: "9px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "3px",
marginBottom: "6px"
}}
/>
<label style={{ display: "block", marginBottom: "2px" }}>
Output:
</label>
<input
type="text"
value={result}
disabled
style={{
width: "100%",
fontSize: "9px",
background: "#2a2a2a",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
padding: "3px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "ArrayIndexExtractor",
label: "Array Index Extractor",
description: `
Outputs a specific line from an upstream array (e.g., OCR multi-line output).
- User specifies the line number (1-based index)
- Outputs the value from that line if it exists
- If the index is out of bounds, outputs "Line Does Not Exist"
`.trim(),
content: "Output a Specific Array Index's Value",
component: ArrayIndexExtractorNode
};

View File

@ -0,0 +1,192 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Data.jsx
/**
* ============================================
* Borealis - Standard Live Data Node Template
* ============================================
*
* COMPONENT ROLE:
* This component defines a "data conduit" node that can accept input,
* process/override it with local logic, and forward the output on a timed basis.
*
* It serves as the core behavior model for other nodes that rely on live propagation.
* Clone and extend this file to create nodes with specialized logic.
*
* CORE CONCEPTS:
* - Uses a centralized shared memory (window.BorealisValueBus) for value sharing
* - Synchronizes with upstream nodes based on ReactFlow edges
* - Emits to downstream nodes by updating its own BorealisValueBus[id] value
* - Controlled by a global polling timer (window.BorealisUpdateRate)
*
* LIFECYCLE SUMMARY:
* - onMount: initializes logic loop and sync monitor
* - onUpdate: watches edges and global rate, reconfigures as needed
* - onUnmount: cleans up all timers
*
* DATA FLOW OVERVIEW:
* - INPUT: if a left-edge (target) is connected, disables manual editing
* - OUTPUT: propagates renderValue to downstream nodes via right-edge (source)
*
* STRUCTURE:
* - Node UI includes:
* * Label (from data.label)
* * Body description (from data.content)
* * Input textbox (disabled if input is connected)
*
* HOW TO EXTEND:
* - For transformations, insert logic into runNodeLogic()
* - To validate or restrict input types, modify handleManualInput()
* - For side-effects or external API calls, add hooks inside runNodeLogic()
*/
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
// Global Shared Bus for Node Data Propagation
if (!window.BorealisValueBus) {
window.BorealisValueBus = {};
}
// Global Update Rate (ms) for All Data Nodes
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 100;
}
const DataNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore(state => state.edges);
const [renderValue, setRenderValue] = useState(data?.value || "");
const valueRef = useRef(renderValue);
// Manual input handler (disabled if connected to input)
const handleManualInput = (e) => {
const newValue = e.target.value;
// TODO: Add input validation/sanitization here if needed
valueRef.current = newValue;
setRenderValue(newValue);
window.BorealisValueBus[id] = newValue;
setNodes(nds =>
nds.map(n =>
n.id === id
? { ...n, data: { ...n.data, value: newValue } }
: n
)
);
};
useEffect(() => {
let currentRate = window.BorealisUpdateRate || 100;
let intervalId = null;
const runNodeLogic = () => {
const inputEdge = edges.find(e => e?.target === id);
const hasInput = Boolean(inputEdge);
if (hasInput && inputEdge.source) {
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
// TODO: Insert custom transform logic here (e.g., parseInt, apply formula)
if (upstreamValue !== valueRef.current) {
valueRef.current = upstreamValue;
setRenderValue(upstreamValue);
window.BorealisValueBus[id] = upstreamValue;
setNodes(nds =>
nds.map(n =>
n.id === id
? { ...n, data: { ...n.data, value: upstreamValue } }
: n
)
);
}
} else {
// OUTPUT BROADCAST: emits to downstream via shared memory
window.BorealisValueBus[id] = valueRef.current;
}
};
const startInterval = () => {
intervalId = setInterval(runNodeLogic, currentRate);
};
startInterval();
// Monitor for global update rate changes
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate || 100;
if (newRate !== currentRate) {
currentRate = newRate;
clearInterval(intervalId);
startInterval();
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, setNodes, edges]);
const inputEdge = edges.find(e => e?.target === id);
const hasInput = Boolean(inputEdge);
const upstreamId = inputEdge?.source || "";
const upstreamValue = window.BorealisValueBus[upstreamId] || "";
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">
{data?.label || "Data Node"}
</div>
<div className="borealis-node-content">
{/* Description visible in node body */}
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
{data?.content || "Foundational node for live value propagation."}
</div>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Value:
</label>
<input
type="text"
value={renderValue}
onChange={handleManualInput}
disabled={hasInput}
style={{
fontSize: "9px",
padding: "4px",
background: hasInput ? "#2a2a2a" : "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "DataNode", // REQUIRED: unique identifier for the node type
label: "String / Number",
description: `
Foundational Data Node
- Accepts input from another node
- If no input is connected, allows user-defined value
- Pushes value to downstream nodes every X ms
- Uses BorealisValueBus to communicate with other nodes
`.trim(),
content: "Store a String or Number",
component: DataNode
};

View File

@ -0,0 +1,175 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Logical_Operators.jsx
/**
* ==============================================
* Borealis - Comparison Node (Logic Evaluation)
* ==============================================
*
* COMPONENT ROLE:
* This node takes two input values and evaluates them using a selected comparison operator.
* It returns 1 (true) or 0 (false) depending on the result of the comparison.
*
* FEATURES:
* - Dropdown to select input type: "Number" or "String"
* - Dropdown to select comparison operator: ==, !=, >, <, >=, <=
* - Dynamically disables numeric-only operators for string inputs
* - Automatically resets operator to == when switching to String
* - Supports summing multiple inputs per side (A, B)
* - For "String" mode: concatenates inputs in connection order
* - Uses BorealisValueBus for input/output
* - Controlled by global update timer
*
* STRUCTURE:
* - Label and Description
* - Input A (top-left) and Input B (middle-left)
* - Output (right edge) result: 1 (true) or 0 (false)
* - Operator dropdown and Input Type dropdown
*/
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const ComparisonNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore(state => state.edges);
const [inputType, setInputType] = useState(data?.inputType || "Number");
const [operator, setOperator] = useState(data?.operator || "Equal (==)");
const [renderValue, setRenderValue] = useState("0");
const valueRef = useRef("0");
useEffect(() => {
if (inputType === "String" && !["Equal (==)", "Not Equal (!=)"].includes(operator)) {
setOperator("Equal (==)");
}
}, [inputType]);
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
let intervalId = null;
const runNodeLogic = () => {
const edgeInputsA = edges.filter(e => e?.target === id && e.targetHandle === "a");
const edgeInputsB = edges.filter(e => e?.target === id && e.targetHandle === "b");
const extractValues = (edgeList) => {
const values = edgeList.map(e => window.BorealisValueBus[e.source]).filter(v => v !== undefined);
if (inputType === "Number") {
return values.reduce((sum, v) => sum + (parseFloat(v) || 0), 0);
}
return values.join("");
};
const a = extractValues(edgeInputsA);
const b = extractValues(edgeInputsB);
const resultMap = {
"Equal (==)": a === b,
"Not Equal (!=)": a !== b,
"Greater Than (>)": a > b,
"Less Than (<)": a < b,
"Greater Than or Equal (>=)": a >= b,
"Less Than or Equal (<=)": a <= b
};
const result = resultMap[operator] ? "1" : "0";
valueRef.current = result;
setRenderValue(result);
window.BorealisValueBus[id] = result;
setNodes(nds =>
nds.map(n =>
n.id === id ? { ...n, data: { ...n.data, value: result, inputType, operator } } : n
)
);
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, inputType, operator, setNodes]);
return (
<div className="borealis-node">
<div style={{ position: "absolute", left: -16, top: 12, fontSize: "8px", color: "#ccc" }}>A</div>
<div style={{ position: "absolute", left: -16, top: 50, fontSize: "8px", color: "#ccc" }}>B</div>
<Handle type="target" position={Position.Left} id="a" style={{ top: 12 }} className="borealis-handle" />
<Handle type="target" position={Position.Left} id="b" style={{ top: 50 }} className="borealis-handle" />
<div className="borealis-node-header">
{data?.label || "Comparison Node"}
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "6px", fontSize: "9px", color: "#ccc" }}>
{data?.content || "Evaluates A vs B and outputs 1 (true) or 0 (false)."}
</div>
<label style={{ fontSize: "9px" }}>Input Type:</label>
<select value={inputType} onChange={(e) => setInputType(e.target.value)} style={dropdownStyle}>
<option value="Number">Number</option>
<option value="String">String</option>
</select>
<label style={{ fontSize: "9px", marginTop: "6px" }}>Operator:</label>
<select value={operator} onChange={(e) => setOperator(e.target.value)} style={dropdownStyle}>
<option>Equal (==)</option>
<option>Not Equal (!=)</option>
<option disabled={inputType === "String"}>Greater Than (&gt;)</option>
<option disabled={inputType === "String"}>Less Than (&lt;)</option>
<option disabled={inputType === "String"}>Greater Than or Equal (&gt;=)</option>
<option disabled={inputType === "String"}>Less Than or Equal (&lt;=)</option>
</select>
<div style={{ marginTop: "8px", fontSize: "9px" }}>Result: {renderValue}</div>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
const dropdownStyle = {
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
marginBottom: "4px"
};
export default {
type: "ComparisonNode",
label: "Logic Comparison",
description: `
Compare Two Inputs (A vs B)
- Uses configurable operator
- Supports numeric and string comparison
- Aggregates multiple inputs by summing (Number) or joining (String in connection order)
- Only == and != are valid for String mode
- Automatically resets operator when switching to String mode
- Outputs 1 (true) or 0 (false) into BorealisValueBus
- Live-updates based on global timer
`.trim(),
content: "Compare A and B using Logic",
component: ComparisonNode
};

View File

@ -0,0 +1,179 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/General Purpose/Node_Math_Operations.jsx
/**
* ============================================
* Borealis - Math Operation Node (Multi-Input A/B)
* ============================================
*
* COMPONENT ROLE:
* Performs live math operations on *two grouped input sets* (A and B).
*
* FUNCTIONALITY:
* - Inputs connected to Handle A are summed
* - Inputs connected to Handle B are summed
* - Math operation is applied as: A &lt;operator&gt; B
* - Result pushed via BorealisValueBus[id]
*
* SUPPORTED OPERATORS:
* - Add, Subtract, Multiply, Divide, Average
*/
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const MathNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const edges = useStore(state => state.edges);
const [operator, setOperator] = useState(data?.operator || "Add");
const [result, setResult] = useState("0");
const resultRef = useRef(0);
useEffect(() => {
let intervalId = null;
let currentRate = window.BorealisUpdateRate;
const runLogic = () => {
const inputsA = edges.filter(e => e.target === id && e.targetHandle === "a");
const inputsB = edges.filter(e => e.target === id && e.targetHandle === "b");
const sum = (list) =>
list.map(e => parseFloat(window.BorealisValueBus[e.source]) || 0).reduce((a, b) => a + b, 0);
const valA = sum(inputsA);
const valB = sum(inputsB);
let value = 0;
switch (operator) {
case "Add":
value = valA + valB;
break;
case "Subtract":
value = valA - valB;
break;
case "Multiply":
value = valA * valB;
break;
case "Divide":
value = valB !== 0 ? valA / valB : 0;
break;
case "Average":
const totalInputs = inputsA.length + inputsB.length;
const totalSum = valA + valB;
value = totalInputs > 0 ? totalSum / totalInputs : 0;
break;
}
resultRef.current = value;
setResult(value.toString());
window.BorealisValueBus[id] = value.toString();
setNodes(nds =>
nds.map(n =>
n.id === id ? { ...n, data: { ...n.data, operator, value: value.toString() } } : n
)
);
};
intervalId = setInterval(runLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runLogic, currentRate);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, operator, edges, setNodes]);
return (
<div className="borealis-node">
<div style={{ position: "absolute", left: -16, top: 12, fontSize: "8px", color: "#ccc" }}>A</div>
<div style={{ position: "absolute", left: -16, top: 50, fontSize: "8px", color: "#ccc" }}>B</div>
<Handle type="target" position={Position.Left} id="a" style={{ top: 12 }} className="borealis-handle" />
<Handle type="target" position={Position.Left} id="b" style={{ top: 50 }} className="borealis-handle" />
<div className="borealis-node-header">
{data?.label || "Math Operation"}
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
Aggregates A and B inputs then performs operation.
</div>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Operator:
</label>
<select
value={operator}
onChange={(e) => setOperator(e.target.value)}
style={dropdownStyle}
>
<option value="Add">Add</option>
<option value="Subtract">Subtract</option>
<option value="Multiply">Multiply</option>
<option value="Divide">Divide</option>
<option value="Average">Average</option>
</select>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Result:
</label>
<input
type="text"
value={result}
disabled
style={resultBoxStyle}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
const dropdownStyle = {
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%",
marginBottom: "8px"
};
const resultBoxStyle = {
fontSize: "9px",
padding: "4px",
background: "#2a2a2a",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%"
};
export default {
type: "MathNode",
label: "Math Operation",
description: `
Perform Math on Aggregated Inputs
- A and B groups are independently summed
- Performs: Add, Subtract, Multiply, Divide, or Average
- Result = A <op> B
- Emits result via BorealisValueBus every update tick
`.trim(),
content: "Perform Math Operations",
component: MathNode
};

View File

@ -0,0 +1,113 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Adjust_Contrast.jsx
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const ContrastNode = ({ id }) => {
const edges = useStore((state) => state.edges);
const [contrast, setContrast] = useState(100);
const valueRef = useRef("");
const [renderValue, setRenderValue] = useState("");
const applyContrast = (base64Data, contrastVal) => {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const factor = (259 * (contrastVal + 255)) / (255 * (259 - contrastVal));
for (let i = 0; i < data.length; i += 4) {
data[i] = factor * (data[i] - 128) + 128;
data[i + 1] = factor * (data[i + 1] - 128) + 128;
data[i + 2] = factor * (data[i + 2] - 128) + 128;
}
ctx.putImageData(imageData, 0, 0);
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
};
img.onerror = () => resolve(base64Data);
img.src = `data:image/png;base64,${base64Data}`;
});
};
useEffect(() => {
const inputEdge = edges.find(e => e.target === id);
if (!inputEdge?.source) return;
const input = window.BorealisValueBus[inputEdge.source] ?? "";
if (!input) return;
applyContrast(input, contrast).then((output) => {
setRenderValue(output);
window.BorealisValueBus[id] = output;
});
}, [contrast, edges, id]);
useEffect(() => {
let interval = null;
const tick = async () => {
const edge = edges.find(e => e.target === id);
const input = edge ? window.BorealisValueBus[edge.source] : "";
if (input && input !== valueRef.current) {
const result = await applyContrast(input, contrast);
valueRef.current = input;
setRenderValue(result);
window.BorealisValueBus[id] = result;
}
};
interval = setInterval(tick, window.BorealisUpdateRate);
return () => clearInterval(interval);
}, [id, contrast, edges]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Adjust Contrast</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<label>Contrast (1255):</label>
<input
type="number"
min="1"
max="255"
value={contrast}
onChange={(e) => setContrast(parseInt(e.target.value) || 100)}
style={{
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
marginTop: "4px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "ContrastNode",
label: "Adjust Contrast",
description: "Modify contrast of base64 image using a contrast multiplier.",
content: "Adjusts contrast of image using canvas pixel transform.",
component: ContrastNode
};

View File

@ -0,0 +1,195 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_BW_Threshold.jsx
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useStore } from "reactflow";
// Ensure BorealisValueBus exists
if (!window.BorealisValueBus) {
window.BorealisValueBus = {};
}
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 100;
}
const BWThresholdNode = ({ id, data }) => {
const edges = useStore((state) => state.edges);
// Attempt to parse threshold from data.value (if present),
// otherwise default to 128.
const initial = parseInt(data?.value, 10);
const [threshold, setThreshold] = useState(
isNaN(initial) ? 128 : initial
);
const [renderValue, setRenderValue] = useState("");
const valueRef = useRef("");
const lastUpstreamRef = useRef("");
// If the node is reimported and data.value changes externally,
// update the threshold accordingly.
useEffect(() => {
const newVal = parseInt(data?.value, 10);
if (!isNaN(newVal)) {
setThreshold(newVal);
}
}, [data?.value]);
const handleThresholdInput = (e) => {
let val = parseInt(e.target.value, 10);
if (isNaN(val)) {
val = 128;
}
val = Math.max(0, Math.min(255, val));
// Keep the Node's data.value updated
data.value = val;
setThreshold(val);
window.BorealisValueBus[id] = val;
};
const applyThreshold = async (base64Data, cutoff) => {
if (!base64Data || typeof base64Data !== "string") {
return "";
}
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const dataArr = imageData.data;
for (let i = 0; i < dataArr.length; i += 4) {
const avg = (dataArr[i] + dataArr[i + 1] + dataArr[i + 2]) / 3;
const color = avg < cutoff ? 0 : 255;
dataArr[i] = color;
dataArr[i + 1] = color;
dataArr[i + 2] = color;
}
ctx.putImageData(imageData, 0, 0);
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
};
img.onerror = () => resolve(base64Data);
img.src = "data:image/png;base64," + base64Data;
});
};
// Main polling logic
useEffect(() => {
let currentRate = window.BorealisUpdateRate;
let intervalId = null;
const runNodeLogic = async () => {
const inputEdge = edges.find(e => e.target === id);
if (inputEdge?.source) {
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
if (upstreamValue !== lastUpstreamRef.current) {
const transformed = await applyThreshold(upstreamValue, threshold);
lastUpstreamRef.current = upstreamValue;
valueRef.current = transformed;
setRenderValue(transformed);
window.BorealisValueBus[id] = transformed;
}
} else {
lastUpstreamRef.current = "";
valueRef.current = "";
setRenderValue("");
window.BorealisValueBus[id] = "";
}
};
intervalId = setInterval(runNodeLogic, currentRate);
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate;
if (newRate !== currentRate) {
clearInterval(intervalId);
currentRate = newRate;
intervalId = setInterval(runNodeLogic, currentRate);
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, edges, threshold]);
// Reapply when threshold changes (even if image didn't)
useEffect(() => {
const inputEdge = edges.find(e => e.target === id);
if (!inputEdge?.source) {
return;
}
const upstreamValue = window.BorealisValueBus[inputEdge.source] ?? "";
if (!upstreamValue) {
return;
}
applyThreshold(upstreamValue, threshold).then((result) => {
valueRef.current = result;
setRenderValue(result);
window.BorealisValueBus[id] = result;
});
}, [threshold, edges, id]);
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">BW Threshold</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<div style={{ marginBottom: "6px", color: "#ccc" }}>
Threshold Strength (0255):
</div>
<input
type="number"
min="0"
max="255"
value={threshold}
onChange={handleThresholdInput}
style={{
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
marginBottom: "6px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "BWThresholdNode",
label: "BW Threshold",
description: `
Black & White Threshold (Stateless)
- Converts a base64 image to black & white using a user-defined threshold value
- Reapplies threshold when the number changes, even if image stays the same
- Outputs a new base64 PNG with BW transformation
`.trim(),
content: "Applies black & white threshold to base64 image input.",
component: BWThresholdNode
};

View File

@ -0,0 +1,135 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Convert_to_Grayscale.jsx
import React, { useEffect, useState, useRef } from "react";
import { Handle, Position, useStore } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const GrayscaleNode = ({ id }) => {
const edges = useStore((state) => state.edges);
const [grayscaleLevel, setGrayscaleLevel] = useState(100); // percentage (0100)
const [renderValue, setRenderValue] = useState("");
const valueRef = useRef("");
const applyGrayscale = (base64Data, level) => {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const alpha = level / 100;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const avg = (r + g + b) / 3;
data[i] = r * (1 - alpha) + avg * alpha;
data[i + 1] = g * (1 - alpha) + avg * alpha;
data[i + 2] = b * (1 - alpha) + avg * alpha;
}
ctx.putImageData(imageData, 0, 0);
resolve(canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, ""));
};
img.onerror = () => resolve(base64Data);
img.src = `data:image/png;base64,${base64Data}`;
});
};
useEffect(() => {
const inputEdge = edges.find(e => e.target === id);
if (!inputEdge?.source) return;
const input = window.BorealisValueBus[inputEdge.source] ?? "";
if (!input) return;
applyGrayscale(input, grayscaleLevel).then((output) => {
valueRef.current = input;
setRenderValue(output);
window.BorealisValueBus[id] = output;
});
}, [grayscaleLevel, edges, id]);
useEffect(() => {
let interval = null;
const run = async () => {
const edge = edges.find(e => e.target === id);
const input = edge ? window.BorealisValueBus[edge.source] : "";
if (input && input !== valueRef.current) {
const result = await applyGrayscale(input, grayscaleLevel);
valueRef.current = input;
setRenderValue(result);
window.BorealisValueBus[id] = result;
}
};
interval = setInterval(run, window.BorealisUpdateRate);
return () => clearInterval(interval);
}, [id, edges, grayscaleLevel]);
const handleLevelChange = (e) => {
let val = parseInt(e.target.value, 10);
if (isNaN(val)) val = 100;
val = Math.min(100, Math.max(0, val));
setGrayscaleLevel(val);
};
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Convert to Grayscale</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
<label style={{ display: "block", marginBottom: "4px" }}>
Grayscale Intensity (0100):
</label>
<input
type="number"
min="0"
max="100"
step="1"
value={grayscaleLevel}
onChange={handleLevelChange}
style={{
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "GrayscaleNode",
label: "Convert to Grayscale",
description: `
Adjustable Grayscale Conversion
- Accepts base64 image input
- Applies grayscale effect using a % level
- 0% = no change, 100% = full grayscale
- Outputs result downstream as base64
`.trim(),
content: "Convert image to grayscale with adjustable intensity.",
component: GrayscaleNode
};

View File

@ -0,0 +1,90 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Export_Image.jsx
import React, { useEffect, useState } from "react";
import { Handle, Position, useReactFlow } from "reactflow";
const ExportImageNode = ({ id }) => {
const { getEdges } = useReactFlow();
const [imageData, setImageData] = useState("");
useEffect(() => {
const interval = setInterval(() => {
const edges = getEdges();
const inputEdge = edges.find(e => e.target === id);
if (inputEdge) {
const base64 = window.BorealisValueBus?.[inputEdge.source];
if (typeof base64 === "string") {
setImageData(base64);
}
}
}, 1000);
return () => clearInterval(interval);
}, [id, getEdges]);
const handleDownload = async () => {
const blob = await (async () => {
const res = await fetch(`data:image/png;base64,${imageData}`);
return await res.blob();
})();
if (window.showSaveFilePicker) {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: "image.png",
types: [{
description: "PNG Image",
accept: { "image/png": [".png"] }
}]
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (e) {
console.warn("Save cancelled:", e);
}
} else {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "image.png";
a.style.display = "none";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
};
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Export Image</div>
<div className="borealis-node-content" style={{ fontSize: "9px" }}>
Export upstream base64-encoded image data as a PNG on-disk.
<button
style={{
marginTop: "6px",
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px"
}}
onClick={handleDownload}
disabled={!imageData}
>
Download PNG
</button>
</div>
</div>
);
};
export default {
type: "ExportImageNode",
label: "Export Image",
description: "Lets the user download the base64 PNG image to disk.",
content: "Save base64 PNG to disk as a file.",
component: ExportImageNode
};

View File

@ -0,0 +1,132 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Image_Viewer.jsx
import React, { useEffect, useState } from "react";
import { Handle, Position, useReactFlow } from "reactflow";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
const ImageViewerNode = ({ id, data }) => {
const { getEdges } = useReactFlow();
const [imageBase64, setImageBase64] = useState("");
const [selectedType, setSelectedType] = useState("base64");
// Monitor upstream input and propagate to ValueBus
useEffect(() => {
const interval = setInterval(() => {
const edges = getEdges();
const inputEdge = edges.find(e => e.target === id);
if (inputEdge) {
const sourceId = inputEdge.source;
const valueBus = window.BorealisValueBus || {};
const value = valueBus[sourceId];
if (typeof value === "string") {
setImageBase64(value);
window.BorealisValueBus[id] = value;
}
} else {
setImageBase64("");
window.BorealisValueBus[id] = "";
}
}, window.BorealisUpdateRate || 100);
return () => clearInterval(interval);
}, [id, getEdges]);
const handleDownload = async () => {
if (!imageBase64) return;
const blob = await (await fetch(`data:image/png;base64,${imageBase64}`)).blob();
if (window.showSaveFilePicker) {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: "image.png",
types: [{
description: "PNG Image",
accept: { "image/png": [".png"] }
}]
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (e) {
console.warn("Save cancelled:", e);
}
} else {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "image.png";
a.style.display = "none";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(a.href);
document.body.removeChild(a);
}
};
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">Image Viewer</div>
<div className="borealis-node-content">
<label style={{ fontSize: "10px" }}>Data Type:</label>
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
style={{ width: "100%", fontSize: "9px", marginBottom: "6px" }}
>
<option value="base64">Base64 Encoded Image</option>
</select>
{imageBase64 ? (
<>
<img
src={`data:image/png;base64,${imageBase64}`}
alt="Live"
style={{
width: "100%",
border: "1px solid #333",
marginTop: "6px",
marginBottom: "6px"
}}
/>
<button
onClick={handleDownload}
style={{
width: "100%",
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px"
}}
>
Export to PNG
</button>
</>
) : (
<div style={{ fontSize: "9px", color: "#888", marginTop: "6px" }}>
Waiting for image...
</div>
)}
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "Image_Viewer",
label: "Image Viewer",
description: `
Displays base64 image and exports it
- Accepts upstream base64 image
- Shows preview
- Provides "Export to PNG" button
- Outputs the same base64 to downstream
`.trim(),
content: "Visual preview of base64 image with optional PNG export.",
component: ImageViewerNode
};

View File

@ -0,0 +1,175 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Image Processing/Node_Upload_Image.jsx
/**
* ==================================================
* Borealis - Image Upload Node (Raw Base64 Output)
* ==================================================
*
* COMPONENT ROLE:
* This node lets the user upload an image file (JPG/JPEG/PNG),
* reads it as a data URL, then strips off the "data:image/*;base64,"
* prefix, storing only the raw base64 data in BorealisValueBus.
*
* IMPORTANT:
* - No upstream connector (target handle) is provided.
* - The raw base64 is pushed out to downstream nodes via source handle.
* - Your viewer (or other downstream node) must prepend "data:image/png;base64,"
* or the appropriate MIME string for display.
*/
import React, { useEffect, useRef, useState } from "react";
import { Handle, Position, useReactFlow } from "reactflow";
// Global Shared Bus for Node Data Propagation
if (!window.BorealisValueBus) {
window.BorealisValueBus = {};
}
// Global Update Rate (ms) for All Data Nodes
if (!window.BorealisUpdateRate) {
window.BorealisUpdateRate = 100;
}
const ImageUploadNode = ({ id, data }) => {
const { setNodes } = useReactFlow();
const [renderValue, setRenderValue] = useState(data?.value || "");
const valueRef = useRef(renderValue);
// Handler for file uploads
const handleFileUpload = (event) => {
console.log("handleFileUpload triggered for node:", id);
// Get the file list
const files = event.target.files || event.currentTarget.files;
if (!files || files.length === 0) {
console.log("No files selected or files array is empty");
return;
}
const file = files[0];
if (!file) {
console.log("File object not found");
return;
}
// Debugging info
console.log("Selected file:", file.name, file.type, file.size);
// Validate file type
const validTypes = ["image/jpeg", "image/png"];
if (!validTypes.includes(file.type)) {
console.warn("Unsupported file type in node:", id, file.type);
return;
}
// Setup FileReader
const reader = new FileReader();
reader.onload = (loadEvent) => {
console.log("FileReader onload in node:", id);
const base64DataUrl = loadEvent?.target?.result || "";
// Strip off the data:image/...;base64, prefix to store raw base64
const rawBase64 = base64DataUrl.replace(/^data:image\/[a-zA-Z]+;base64,/, "");
console.log("Raw Base64 (truncated):", rawBase64.substring(0, 50));
valueRef.current = rawBase64;
setRenderValue(rawBase64);
window.BorealisValueBus[id] = rawBase64;
// Update node data
setNodes((nds) =>
nds.map((n) =>
n.id === id
? { ...n, data: { ...n.data, value: rawBase64 } }
: n
)
);
};
reader.onerror = (errorEvent) => {
console.error("FileReader error in node:", id, errorEvent);
};
// Read the file as a data URL
reader.readAsDataURL(file);
};
// Poll-based output (no upstream)
useEffect(() => {
let currentRate = window.BorealisUpdateRate || 100;
let intervalId = null;
const runNodeLogic = () => {
// Simply emit current value (raw base64) to the bus
window.BorealisValueBus[id] = valueRef.current;
};
const startInterval = () => {
intervalId = setInterval(runNodeLogic, currentRate);
};
startInterval();
// Monitor for global update rate changes
const monitor = setInterval(() => {
const newRate = window.BorealisUpdateRate || 100;
if (newRate !== currentRate) {
currentRate = newRate;
clearInterval(intervalId);
startInterval();
}
}, 250);
return () => {
clearInterval(intervalId);
clearInterval(monitor);
};
}, [id, setNodes]);
return (
<div className="borealis-node" style={{ minWidth: "160px" }}>
{/* No target handle because we don't accept upstream data */}
<div className="borealis-node-header">
{data?.label || "Raw Base64 Image Upload"}
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "8px", fontSize: "9px", color: "#ccc" }}>
{data?.content || "Upload a JPG/PNG, store only the raw base64 in ValueBus."}
</div>
<label style={{ fontSize: "9px", display: "block", marginBottom: "4px" }}>
Upload Image File
</label>
<input
type="file"
accept=".jpg,.jpeg,.png"
onChange={handleFileUpload}
style={{
fontSize: "9px",
padding: "4px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%",
marginBottom: "8px"
}}
/>
</div>
<Handle type="source" position={Position.Right} className="borealis-handle" />
</div>
);
};
export default {
type: "ImageUploadNode_RawBase64", // Unique ID for the node type
label: "Upload Image",
description: `
A node to upload an image (JPG/PNG) and store it in base64 format for later use downstream.
`.trim(),
content: "Upload an image, output only the raw base64 string.",
component: ImageUploadNode
};

View File

@ -0,0 +1,295 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Macro Automation/Node_Macro_KeyPress.jsx
import React, { useState, useRef } from "react";
import { Handle, Position } from "reactflow";
import Keyboard from "react-simple-keyboard";
import "react-simple-keyboard/build/css/index.css";
if (!window.BorealisValueBus) window.BorealisValueBus = {};
if (!window.BorealisUpdateRate) window.BorealisUpdateRate = 100;
/**
* KeyPressNode:
* - Full keyboard with SHIFT toggling
* - Press F-keys, digits, letters, or symbols
* - Single key stored, overlay closes
* - SHIFT or CAPS toggles "default" <-> "shift"
*/
const KeyPressNode = ({ id, data }) => {
const [selectedWindow, setSelectedWindow] = useState(data?.selectedWindow || "");
const [keyPressed, setKeyPressed] = useState(data?.keyPressed || "");
const [intervalMs, setIntervalMs] = useState(data?.intervalMs || 1000);
const [randomRangeEnabled, setRandomRangeEnabled] = useState(false);
const [randomMin, setRandomMin] = useState(750);
const [randomMax, setRandomMax] = useState(950);
// Keyboard overlay
const [showKeyboard, setShowKeyboard] = useState(false);
const [layoutName, setLayoutName] = useState("default");
// A simple set of Windows for demonstration
const fakeWindows = ["Notepad", "Chrome", "Discord", "Visual Studio Code"];
// This function is triggered whenever the user taps a key on the virtual keyboard
const onKeyPress = (button) => {
// SHIFT or CAPS toggling:
if (button === "{shift}" || button === "{lock}") {
handleShift();
return;
}
// Example skip list: these won't be stored as final single key
const skipKeys = [
"{bksp}", "{space}", "{tab}", "{enter}", "{escape}",
"{f1}", "{f2}", "{f3}", "{f4}", "{f5}", "{f6}",
"{f7}", "{f8}", "{f9}", "{f10}", "{f11}", "{f12}",
"{shift}", "{lock}"
];
// If the pressed button is not in skipKeys, let's store it and close
if (!skipKeys.includes(button)) {
setKeyPressed(button);
setShowKeyboard(false);
}
};
// Toggle between "default" layout and "shift" layout
const handleShift = () => {
setLayoutName((prev) => (prev === "default" ? "shift" : "default"));
};
return (
<div className="borealis-node" style={{ minWidth: 240, position: "relative" }}>
{/* React Flow Handles */}
<Handle type="target" position={Position.Left} className="borealis-handle" />
<Handle type="source" position={Position.Right} className="borealis-handle" />
{/* Node Header */}
<div className="borealis-node-header" style={{ position: "relative" }}>
Key Press
<div
style={{
position: "absolute",
top: "12px",
right: "6px",
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: "#333",
border: "1px solid #222"
}}
/>
</div>
{/* Node Content */}
<div className="borealis-node-content">
<div style={{ fontSize: "9px", color: "#ccc", marginBottom: "8px" }}>
Sends keypress to selected window on trigger.
</div>
{/* Window Selector */}
<label>Window:</label>
<select
value={selectedWindow}
onChange={(e) => setSelectedWindow(e.target.value)}
style={inputStyle}
>
<option value="">-- Choose --</option>
{fakeWindows.map((win) => (
<option key={win} value={win}>
{win}
</option>
))}
</select>
{/* Key: "Select Key" button & readOnly input */}
<label style={{ marginTop: "6px" }}>Key:</label>
<div style={{ display: "flex", gap: "6px", alignItems: "center", marginBottom: "6px" }}>
<button onClick={() => setShowKeyboard(true)} style={buttonStyle}>
Select Key
</button>
<input
type="text"
value={keyPressed}
disabled
readOnly
style={{
...inputStyle,
width: "60px",
backgroundColor: "#2a2a2a",
color: "#aaa",
cursor: "default"
}}
/>
</div>
{/* Interval Configuration */}
<label>Fixed Interval (ms):</label>
<input
type="number"
min="100"
step="50"
value={intervalMs}
onChange={(e) => setIntervalMs(Number(e.target.value))}
disabled={randomRangeEnabled}
style={{
...inputStyle,
backgroundColor: randomRangeEnabled ? "#2a2a2a" : "#1e1e1e"
}}
/>
{/* Random Interval */}
<label style={{ marginTop: "6px" }}>
<input
type="checkbox"
checked={randomRangeEnabled}
onChange={(e) => setRandomRangeEnabled(e.target.checked)}
style={{ marginRight: "6px" }}
/>
Randomize Interval (ms):
</label>
{randomRangeEnabled && (
<div style={{ display: "flex", gap: "4px", marginTop: "4px" }}>
<input
type="number"
min="100"
value={randomMin}
onChange={(e) => setRandomMin(Number(e.target.value))}
style={{ ...inputStyle, flex: 1 }}
/>
<input
type="number"
min="100"
value={randomMax}
onChange={(e) => setRandomMax(Number(e.target.value))}
style={{ ...inputStyle, flex: 1 }}
/>
</div>
)}
</div>
{/* Keyboard Overlay */}
{showKeyboard && (
<div style={keyboardOverlay}>
<div style={keyboardContainer}>
<div
style={{
fontSize: "11px",
color: "#ccc",
marginBottom: "6px",
textAlign: "center"
}}
>
Full Keyboard
</div>
<Keyboard
onKeyPress={onKeyPress}
layoutName={layoutName}
theme="hg-theme-dark hg-layout-default"
layout={{
default: [
"{escape} {f1} {f2} {f3} {f4} {f5} {f6} {f7} {f8} {f9} {f10} {f11} {f12}",
"` 1 2 3 4 5 6 7 8 9 0 - = {bksp}",
"{tab} q w e r t y u i o p [ ] \\",
"{lock} a s d f g h j k l ; ' {enter}",
"{shift} z x c v b n m , . / {shift}",
"{space}"
],
shift: [
"{escape} {f1} {f2} {f3} {f4} {f5} {f6} {f7} {f8} {f9} {f10} {f11} {f12}",
"~ ! @ # $ % ^ & * ( ) _ + {bksp}",
"{tab} Q W E R T Y U I O P { } |",
"{lock} A S D F G H J K L : \" {enter}",
"{shift} Z X C V B N M < > ? {shift}",
"{space}"
]
}}
display={{
"{bksp}": "⌫",
"{escape}": "esc",
"{tab}": "tab",
"{lock}": "caps",
"{enter}": "enter",
"{shift}": "shift",
"{space}": "space",
"{f1}": "F1",
"{f2}": "F2",
"{f3}": "F3",
"{f4}": "F4",
"{f5}": "F5",
"{f6}": "F6",
"{f7}": "F7",
"{f8}": "F8",
"{f9}": "F9",
"{f10}": "F10",
"{f11}": "F11",
"{f12}": "F12"
}}
/>
<div style={{ display: "flex", justifyContent: "center", marginTop: "8px" }}>
<button onClick={() => setShowKeyboard(false)} style={buttonStyle}>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
/* Basic styling objects */
const inputStyle = {
width: "100%",
fontSize: "9px",
padding: "4px",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
marginBottom: "6px"
};
const buttonStyle = {
fontSize: "9px",
padding: "4px 8px",
backgroundColor: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
cursor: "pointer"
};
const keyboardOverlay = {
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
zIndex: 1000,
backgroundColor: "rgba(0, 0, 0, 0.8)",
display: "flex",
justifyContent: "center",
alignItems: "center"
};
const keyboardContainer = {
backgroundColor: "#1e1e1e",
padding: "16px",
borderRadius: "6px",
border: "1px solid #444",
zIndex: 1001,
maxWidth: "650px"
};
export default {
type: "Macro_KeyPress",
label: "Key Press (GUI-ONLY)",
description: `
Press a single character or function key on a full keyboard overlay.
Shift/caps toggles uppercase/symbols.
F-keys are included, but pressing them won't store that value unless you remove them from "skip" logic, if desired.
`,
content: "Send Key Press to Foreground Window via Borealis Agent",
component: KeyPressNode
};

View File

@ -0,0 +1,134 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Organization/Node_Backdrop_Group_Box.jsx
/**
* ===========================================
* Borealis - Backdrop Group Box Node
* ===========================================
*
* COMPONENT ROLE:
* This node functions as a backdrop or grouping box.
* It's resizable and can be renamed by clicking its title.
* It doesn't connect to other nodes or pass data<74>it's purely visual.
*
* BEHAVIOR:
* - Allows renaming via single-click on the header text.
* - Can be resized by dragging from the bottom-right corner.
*
* NOTE:
* - No inputs/outputs: purely cosmetic for grouping and labeling.
*/
import React, { useState, useEffect, useRef } from "react";
import { Handle, Position } from "reactflow";
import { ResizableBox } from "react-resizable";
import "react-resizable/css/styles.css";
const BackdropGroupBoxNode = ({ id, data }) => {
const [title, setTitle] = useState(data?.label || "Backdrop Group Box");
const [isEditing, setIsEditing] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
const handleTitleClick = (e) => {
e.stopPropagation();
setIsEditing(true);
};
const handleTitleChange = (e) => {
const newTitle = e.target.value;
setTitle(newTitle);
window.BorealisValueBus[id] = newTitle;
};
const handleBlur = () => {
setIsEditing(false);
};
return (
<div style={{ pointerEvents: "auto" }}>
<ResizableBox
width={200}
height={120}
minConstraints={[120, 80]}
maxConstraints={[600, 600]}
resizeHandles={["se"]}
className="borealis-node"
handle={(h) => (
<span
className={`react-resizable-handle react-resizable-handle-${h}`}
style={{ pointerEvents: "auto" }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
/>
)}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: "rgba(44, 44, 44, 0.5)",
border: "1px solid #3a3a3a",
borderRadius: "4px",
boxShadow: "0 0 5px rgba(88, 166, 255, 0.15)",
overflow: "hidden",
position: "relative",
zIndex: 0
}}
>
<div
onClick={handleTitleClick}
style={{
backgroundColor: "rgba(35, 35, 35, 0.5)",
padding: "6px 10px",
fontWeight: "bold",
fontSize: "10px",
cursor: "pointer",
userSelect: "none"
}}
>
{isEditing ? (
<input
ref={inputRef}
type="text"
value={title}
onChange={handleTitleChange}
onBlur={handleBlur}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
style={{
fontSize: "10px",
padding: "2px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
width: "100%"
}}
/>
) : (
<span>{title}</span>
)}
</div>
<div style={{ padding: "10px", fontSize: "9px", height: "100%" }}>
{/* Empty space for grouping */}
</div>
</ResizableBox>
</div>
);
};
export default {
type: "BackdropGroupBoxNode",
label: "Backdrop Group Box",
description: `
Resizable Grouping Node
- Purely cosmetic, for grouping related nodes
- Resizable by dragging bottom-right corner
- Rename by clicking on title bar
`.trim(),
content: "Use as a visual group label",
component: BackdropGroupBoxNode
};

View File

@ -0,0 +1,145 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/nodes/Reporting/Node_Export_to_CSV.jsx
import React, { useRef, useState } from "react";
import { Handle, Position } from "reactflow";
import { Button, Snackbar } from "@mui/material";
/**
* ExportToCSVNode
* ----------------
* Simplified version:
* - No output connector
* - Removed "Export to Disk" checkbox
* - Only function is export to disk (manual trigger)
*/
const ExportToCSVNode = ({ data }) => {
const [exportPath, setExportPath] = useState("");
const [appendMode, setAppendMode] = useState(false);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const fileInputRef = useRef(null);
const handleExportClick = () => setSnackbarOpen(true);
const handleSnackbarClose = () => setSnackbarOpen(false);
const handlePathClick = async () => {
if (window.showDirectoryPicker) {
try {
const dirHandle = await window.showDirectoryPicker();
setExportPath(dirHandle.name || "Selected Directory");
} catch (err) {
console.warn("Directory Selection Cancelled:", err);
}
} else {
fileInputRef.current?.click();
}
};
const handleFakePicker = (event) => {
const files = event.target.files;
if (files.length > 0) {
const fakePath = files[0].webkitRelativePath?.split("/")[0];
setExportPath(fakePath || "Selected Folder");
}
};
return (
<div className="borealis-node">
<Handle type="target" position={Position.Left} className="borealis-handle" />
<div className="borealis-node-header">
{data.label}
</div>
<div className="borealis-node-content">
<div style={{ marginBottom: "8px" }}>
{data.content}
</div>
<label style={{ fontSize: "9px", display: "block", marginTop: "6px" }}>
Export Path:
</label>
<div style={{ display: "flex", gap: "4px", alignItems: "center", marginBottom: "6px" }}>
<input
type="text"
readOnly
value={exportPath}
placeholder="Click to Select Folder"
onClick={handlePathClick}
style={{
flex: 1,
fontSize: "9px",
padding: "3px",
background: "#1e1e1e",
color: "#ccc",
border: "1px solid #444",
borderRadius: "2px",
cursor: "pointer"
}}
/>
<Button
variant="outlined"
size="small"
onClick={handleExportClick}
sx={{
fontSize: "9px",
padding: "2px 8px",
minWidth: "unset",
borderColor: "#58a6ff",
color: "#58a6ff"
}}
>
Export
</Button>
</div>
<label style={{ fontSize: "9px", display: "block", marginTop: "4px" }}>
<input
type="checkbox"
checked={appendMode}
onChange={(e) => setAppendMode(e.target.checked)}
style={{ marginRight: "4px" }}
/>
Append CSV Data if Headers Match
</label>
</div>
<input
ref={fileInputRef}
type="file"
webkitdirectory="true"
directory=""
multiple
style={{ display: "none" }}
onChange={handleFakePicker}
/>
<Snackbar
open={snackbarOpen}
autoHideDuration={1000}
onClose={handleSnackbarClose}
message="Feature Coming Soon..."
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
/>
</div>
);
};
export default {
type: "ExportToCSVNode",
label: "Export to CSV",
description: `
Reporting Node
This node lets the user choose a folder to export CSV data to disk.
When the "Export" button is clicked, CSV content (from upstream logic) is intended to be saved
to the selected directory. This is a placeholder for future file system interaction.
Inputs:
- Structured Table Data (via upstream node)
Outputs:
- None (writes directly to disk in future)
`.trim(),
content: "Export Input Data to CSV File",
component: ExportToCSVNode
};