Skip to content

Commit 5a78534

Browse files
committed
Add CUDA GPU nodes and editor debugging tools
Adds CUDAKernelLab (13 curated GPU ops) and CUDACustomKernel (compile and run your own CUDA C via NVRTC) running on the local NVIDIA GPU through CuPy, plus an Enum dropdown param type to drive op selection. The editor gains a Stop button to abort cooks, a Debug output panel (stdout/stderr, full node results, tracebacks), and right-click copy-value on nodes. Launchers auto-install cupy-cuda12x when an NVIDIA GPU is present.
1 parent 2ee9103 commit 5a78534

16 files changed

Lines changed: 1220 additions & 74 deletions

File tree

editor-server/server.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Blacknode editor backend — FastAPI server the React editor talks to."""
22
from __future__ import annotations
3-
import uuid, os, sys, json, threading, re, queue
3+
import uuid, os, sys, json, threading, re, queue, io, contextlib
44
from datetime import datetime
55
from pathlib import Path
66
from typing import Any
@@ -689,6 +689,7 @@ def _node_def_payload(name: str, fn: Any) -> dict[str, Any]:
689689
"input_types": getattr(fn, "_bn_input_types", {}),
690690
"output_types": getattr(fn, "_bn_output_types", {}),
691691
"input_defaults": getattr(fn, "_bn_input_defaults", {}),
692+
"input_choices": getattr(fn, "_bn_input_choices", {}),
692693
"doc": doc.split("\n", 1)[0] if doc else "",
693694
"source": getattr(fn, "_bn_source_path", ""),
694695
}
@@ -1551,10 +1552,21 @@ def cook_one(current_id: str, current_port: str):
15511552
ctx["__graph__"] = _session.graph
15521553
ctx["__node_id__"] = current_id
15531554
ctx["__run_logger__"] = logger
1555+
_out_buf, _err_buf = io.StringIO(), io.StringIO()
15541556
try:
1555-
result = fn(ctx)
1557+
with contextlib.redirect_stdout(_out_buf), contextlib.redirect_stderr(_err_buf):
1558+
result = fn(ctx)
15561559
finally:
15571560
yield from drain_logger()
1561+
for _stream, _buf in (("stdout", _out_buf), ("stderr", _err_buf)):
1562+
_text = _buf.getvalue()
1563+
if _text.strip():
1564+
yield _node_event({
1565+
"type": "log",
1566+
"node_id": current_id,
1567+
"stream": _stream,
1568+
"text": _text,
1569+
})
15581570
if not isinstance(result, dict):
15591571
result = {"output": result}
15601572

editor/src/App.tsx

Lines changed: 218 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export default function App() {
8989
beginAltDragCopy, finishAltDragCopy, undoGraph,
9090
checkServer, reset, newTab, insertTab, switchTab, closeTab, duplicateTab,
9191
openGraphAsTab, openWorkflowAsTab, renameTab, saveActiveWorkflow,
92-
diveIntoSubnet, exitSubnet, collapseToSubnet, organizeNodes, cookNode, dismissCookStatus, applyRunReplay,
92+
diveIntoSubnet, exitSubnet, collapseToSubnet, organizeNodes, cookNode, stopCook, dismissCookStatus, applyRunReplay,
9393
handleLearnedNodeEvent,
9494
} = useStore()
9595

@@ -102,6 +102,7 @@ export default function App() {
102102
const [savingWorkflow, setSavingWorkflow] = useState(false)
103103
const [saveOk, setSaveOk] = useState(false)
104104
const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
105+
const [nodeMenu, setNodeMenu] = useState<{ x: number; y: number; nodeId: string } | null>(null)
105106
const [notice, setNotice] = useState<NoticeState | null>(null)
106107
const [pendingClose, setPendingClose] = useState<PendingCloseState | null>(null)
107108
const [closeSaving, setCloseSaving] = useState(false)
@@ -171,6 +172,20 @@ export default function App() {
171172
}
172173
}, [tabMenu])
173174

175+
useEffect(() => {
176+
if (!nodeMenu) return
177+
const close = () => setNodeMenu(null)
178+
const onKeyDown = (e: KeyboardEvent) => {
179+
if (e.key === 'Escape') close()
180+
}
181+
window.addEventListener('mousedown', close)
182+
window.addEventListener('keydown', onKeyDown)
183+
return () => {
184+
window.removeEventListener('mousedown', close)
185+
window.removeEventListener('keydown', onKeyDown)
186+
}
187+
}, [nodeMenu])
188+
174189
useEffect(() => {
175190
if (!pendingClose || closeSaving) return
176191
const onKeyDown = (e: KeyboardEvent) => {
@@ -907,11 +922,11 @@ export default function App() {
907922

908923
<button
909924
className="bn-top-button bn-top-run-button"
910-
onClick={() => void handleRunGraph()}
911-
disabled={!serverOk || cookActive || nodes.length === 0}
912-
title="Run current graph"
925+
onClick={() => (cookActive ? stopCook() : void handleRunGraph())}
926+
disabled={!serverOk || (!cookActive && nodes.length === 0)}
927+
title={cookActive ? 'Stop the running cook' : 'Run current graph'}
913928
>
914-
{cookActive ? 'Running...' : 'Run'}
929+
{cookActive ? '■ Stop' : 'Run'}
915930
</button>
916931

917932
<button
@@ -1136,6 +1151,58 @@ export default function App() {
11361151
</div>
11371152
)}
11381153

1154+
{nodeMenu && (() => {
1155+
const menuNode = nodes.find(n => n.id === nodeMenu.nodeId)
1156+
if (!menuNode) return null
1157+
const data = menuNode.data
1158+
const hasValue = data.cookResult !== undefined
1159+
const hasError = Boolean(data.cookError)
1160+
return (
1161+
<div
1162+
onMouseDown={e => e.stopPropagation()}
1163+
onClick={e => e.stopPropagation()}
1164+
onContextMenu={e => e.preventDefault()}
1165+
style={{
1166+
position: 'fixed',
1167+
top: nodeMenu.y,
1168+
left: nodeMenu.x,
1169+
zIndex: 40,
1170+
minWidth: 168,
1171+
background: 'var(--panel)',
1172+
border: '1px solid var(--line2)',
1173+
borderRadius: 7,
1174+
padding: 4,
1175+
boxShadow: '0 8px 24px rgba(0,0,0,.28)',
1176+
}}
1177+
>
1178+
<button
1179+
className="bn-menu-item"
1180+
style={menuItemStyle(!hasValue)}
1181+
disabled={!hasValue}
1182+
onClick={() => { void copyToClipboard(stringifyValue(data.cookResult)); setNodeMenu(null) }}
1183+
>
1184+
Copy value
1185+
</button>
1186+
{hasError && (
1187+
<button
1188+
className="bn-menu-item"
1189+
style={menuItemStyle(false, 'var(--err)')}
1190+
onClick={() => { void copyToClipboard(String(data.cookError)); setNodeMenu(null) }}
1191+
>
1192+
Copy error
1193+
</button>
1194+
)}
1195+
<button
1196+
className="bn-menu-item"
1197+
style={menuItemStyle()}
1198+
onClick={() => { void copyToClipboard(menuNode.id); setNodeMenu(null) }}
1199+
>
1200+
Copy node ID
1201+
</button>
1202+
</div>
1203+
)
1204+
})()}
1205+
11391206
{pendingClose && pendingCloseTab && (
11401207
<div
11411208
role="dialog"
@@ -1369,8 +1436,14 @@ export default function App() {
13691436
selectNode(null)
13701437
setSearch(null)
13711438
setTabMenu(null)
1439+
setNodeMenu(null)
13721440
}}
13731441
onPaneContextMenu={onPaneContextMenu}
1442+
onNodeContextMenu={(e, node) => {
1443+
e.preventDefault()
1444+
selectNode(node.id)
1445+
setNodeMenu({ x: e.clientX, y: e.clientY, nodeId: node.id })
1446+
}}
13741447
fitView
13751448
fitViewOptions={{ padding: 0.24, maxZoom: 1 }}
13761449
deleteKeyCode={['Delete', 'Backspace']}
@@ -1496,9 +1569,11 @@ function CookStatusPanel({
14961569
raised: boolean
14971570
onDismiss: () => void
14981571
}) {
1572+
const [debug, setDebug] = useState(false)
14991573
if (hidden || entries.length === 0) return null
15001574
const latest = entries[entries.length - 1]
15011575
const recent = entries.slice(-8).reverse()
1576+
const debugRows = entries.slice(-300)
15021577
const statusColor = active ? 'var(--warn)' : latest.kind === 'error' ? 'var(--err)' : 'var(--ok)'
15031578

15041579
return (
@@ -1556,6 +1631,29 @@ function CookStatusPanel({
15561631
}}>
15571632
{active ? 'running' : 'idle'} · {entries.length}
15581633
</div>
1634+
<button
1635+
type="button"
1636+
aria-label="Toggle debug output"
1637+
title="Show full system output (stdout/stderr, node results, tracebacks)"
1638+
onClick={() => setDebug(d => !d)}
1639+
style={{
1640+
height: 20,
1641+
padding: '0 8px',
1642+
border: `1px solid ${debug ? 'var(--accent)' : 'var(--line2)'}`,
1643+
borderRadius: 6,
1644+
background: debug ? 'var(--accent)' : 'transparent',
1645+
color: debug ? '#fff' : 'var(--tx3)',
1646+
cursor: 'pointer',
1647+
fontSize: 10,
1648+
fontWeight: 700,
1649+
fontFamily: 'var(--font-ui)',
1650+
letterSpacing: '0.04em',
1651+
textTransform: 'uppercase',
1652+
flexShrink: 0,
1653+
}}
1654+
>
1655+
Debug
1656+
</button>
15591657
<button
15601658
type="button"
15611659
aria-label="Hide run status"
@@ -1581,49 +1679,126 @@ function CookStatusPanel({
15811679
</button>
15821680
</div>
15831681

1584-
<div style={{ maxHeight: 168, overflowY: 'auto', padding: '4px 0' }}>
1585-
{recent.map(entry => {
1586-
const color = entry.kind === 'error'
1587-
? 'var(--err)'
1588-
: entry.kind === 'start'
1589-
? 'var(--warn)'
1590-
: entry.kind === 'done'
1591-
? 'var(--accent)'
1592-
: 'var(--ok)'
1593-
return (
1594-
<div
1595-
key={entry.id}
1596-
style={{
1597-
display: 'grid',
1598-
gridTemplateColumns: '70px 1fr',
1599-
gap: 8,
1600-
padding: '4px 10px',
1601-
fontSize: 11,
1602-
lineHeight: 1.35,
1603-
fontFamily: 'var(--font-ui)',
1604-
}}
1605-
>
1606-
<span style={{ color, fontWeight: 700, textTransform: 'uppercase' }}>
1607-
{entry.kind}
1608-
</span>
1609-
<span style={{
1610-
color: entry.kind === 'error' ? 'var(--err)' : 'var(--tx2)',
1611-
fontFamily: 'var(--font-mono)',
1612-
whiteSpace: 'nowrap',
1613-
overflow: 'hidden',
1614-
textOverflow: 'ellipsis',
1615-
}}>
1616-
{entry.message}
1617-
</span>
1618-
</div>
1619-
)
1620-
})}
1621-
</div>
1682+
{debug ? (
1683+
<div style={{ maxHeight: 360, overflowY: 'auto', padding: '4px 0' }}>
1684+
{debugRows.map(entry => {
1685+
const color = cookEntryColor(entry.kind)
1686+
return (
1687+
<div
1688+
key={entry.id}
1689+
style={{ padding: '4px 10px', borderBottom: '1px solid var(--line)' }}
1690+
>
1691+
<div style={{
1692+
display: 'flex', gap: 8, alignItems: 'baseline',
1693+
fontSize: 11, fontFamily: 'var(--font-ui)',
1694+
}}>
1695+
<span style={{ color, fontWeight: 700, textTransform: 'uppercase', flexShrink: 0 }}>
1696+
{entry.stream ?? entry.kind}
1697+
</span>
1698+
<span style={{ color: 'var(--tx2)', fontFamily: 'var(--font-mono)', wordBreak: 'break-word' }}>
1699+
{entry.message}
1700+
</span>
1701+
</div>
1702+
{entry.detail && (
1703+
<pre style={{
1704+
margin: '4px 0 0',
1705+
padding: '6px 8px',
1706+
background: 'var(--lift)',
1707+
border: `1px solid ${entry.kind === 'error' ? 'var(--err)' : 'var(--line2)'}`,
1708+
borderRadius: 5,
1709+
color: entry.kind === 'error' ? 'var(--err)' : entry.stream === 'stderr' ? 'var(--warn)' : 'var(--tx2)',
1710+
fontFamily: 'var(--font-mono)',
1711+
fontSize: 11,
1712+
lineHeight: 1.4,
1713+
whiteSpace: 'pre-wrap',
1714+
wordBreak: 'break-word',
1715+
maxHeight: 180,
1716+
overflowY: 'auto',
1717+
}}>
1718+
{entry.detail}
1719+
</pre>
1720+
)}
1721+
</div>
1722+
)
1723+
})}
1724+
</div>
1725+
) : (
1726+
<div style={{ maxHeight: 168, overflowY: 'auto', padding: '4px 0' }}>
1727+
{recent.map(entry => {
1728+
const color = cookEntryColor(entry.kind)
1729+
return (
1730+
<div
1731+
key={entry.id}
1732+
style={{
1733+
display: 'grid',
1734+
gridTemplateColumns: '70px 1fr',
1735+
gap: 8,
1736+
padding: '4px 10px',
1737+
fontSize: 11,
1738+
lineHeight: 1.35,
1739+
fontFamily: 'var(--font-ui)',
1740+
}}
1741+
>
1742+
<span style={{ color, fontWeight: 700, textTransform: 'uppercase' }}>
1743+
{entry.kind}
1744+
</span>
1745+
<span style={{
1746+
color: entry.kind === 'error' ? 'var(--err)' : 'var(--tx2)',
1747+
fontFamily: 'var(--font-mono)',
1748+
whiteSpace: 'nowrap',
1749+
overflow: 'hidden',
1750+
textOverflow: 'ellipsis',
1751+
}}>
1752+
{entry.message}
1753+
</span>
1754+
</div>
1755+
)
1756+
})}
1757+
</div>
1758+
)}
16221759
</div>
16231760
</div>
16241761
)
16251762
}
16261763

1764+
function stringifyValue(value: unknown): string {
1765+
if (value === undefined || value === null) return ''
1766+
if (typeof value === 'string') return value
1767+
try {
1768+
return JSON.stringify(value, null, 2)
1769+
} catch {
1770+
return String(value)
1771+
}
1772+
}
1773+
1774+
async function copyToClipboard(text: string): Promise<void> {
1775+
if (!text) return
1776+
if (navigator.clipboard?.writeText) {
1777+
await navigator.clipboard.writeText(text)
1778+
return
1779+
}
1780+
const ta = document.createElement('textarea')
1781+
ta.value = text
1782+
ta.style.position = 'fixed'
1783+
ta.style.left = '-9999px'
1784+
document.body.appendChild(ta)
1785+
ta.focus()
1786+
ta.select()
1787+
document.execCommand('copy')
1788+
document.body.removeChild(ta)
1789+
}
1790+
1791+
function cookEntryColor(kind: CookLogEntry['kind']): string {
1792+
switch (kind) {
1793+
case 'error': return 'var(--err)'
1794+
case 'start': return 'var(--warn)'
1795+
case 'done': return 'var(--accent)'
1796+
case 'log': return 'var(--tx3)'
1797+
case 'info': return 'var(--tx3)'
1798+
default: return 'var(--ok)'
1799+
}
1800+
}
1801+
16271802
function getCompatibleNodeTypes(draft: ConnectionDraft, nodeDefs: Record<string, BnNodeDef>): string[] {
16281803
const types = Object.values(nodeDefs)
16291804
.filter(def => draft.handleType === 'source'

0 commit comments

Comments
 (0)