Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,16 @@ cd Blacknode
The launcher installs the local dependencies and opens
`http://localhost:3000`.

To try the graph first, follow the [Beginner Walkthrough](docs/walkthrough.md).
In the welcome screen, choose **Open templates**, open **Text Pipeline**, and
press **Run once** in the top bar. The **Output** node shows `Hello World`.
Change a Text node's value and run again to see your own result.

For a visual result, open **Generic Point Cloud Viewer** and press **Run once**
to inspect the included colored point cloud in 3D. Both workflows run locally
with the built-in nodes.

Follow the [Beginner Walkthrough](docs/walkthrough.md) to save your work and
choose the next task.

## Pair your first device

Expand Down Expand Up @@ -114,6 +123,18 @@ The optional robot-learning starter prepares collection, training, and
simulation workflows as the Project gains the required data and artifacts. It
never starts a physical or compute action automatically.

## Develop your own App

Build or customize a node workflow, press **Create App**, and choose the
parameters, results, and run buttons your team needs. **Save & open App** turns
that workflow into an operator interface. Switch back with **Edit workflow**
to change its behavior, or use **File → Package App…** to share it.

Projects organize your application alongside its robots, datasets, training
runs, policies, and deployments. Start with a working workflow and extend it
for your own task. See [Create your own App](docs/operator-apps.md#create-your-own-app)
and [Guided Projects](docs/guided-projects.md).

## Add capabilities

Blacknode core owns the graph, editor, runtime, replay, exports, package system,
Expand Down
30 changes: 30 additions & 0 deletions docs/operator-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,36 @@ Pressing a shortcut for a workflow with `metadata.operator_view` opens its App
surface. **Edit workflow** reveals the nodes and connections, and the **App**
control in the workflow tab bar returns to the operator surface.

## Create your own App

1. Build a workflow or open a template and run it to check its result.
2. Press **Create App** beside the workflow tabs, or choose **File → Create App…**.
3. Give the App a name. Use **+ Parameter** to expose a text or numeric task
setting, **+ Result** to display a workflow output, and **+ Run button** to
cook a chosen output. Choose each target from the workflow's available nodes.
4. Give the controls labels your operators understand. Results can display
text, metrics, status, images, or a web viewer. A run button can run once or
start a live service; add a confirmation message when the action requires it.
5. Press **Save & open App**. Configure the task and press your run button to
see the result. Saving and opening the App does not execute the workflow.
6. Use **Edit workflow** to change the nodes and **App** to return to the
controls. Press **Save** after changing parameters. **Edit App** reopens
the designer to change the name or controls you added.
7. Reopen the saved workflow from **Workflows** and select **App**, or use
**File → Package App…** to share an installable ZIP.

Parameters connected to other nodes are configured by those upstream nodes;
the designer offers unconnected text and numeric parameters. Credential fields
stay in the existing credential configuration. Existing App sections, primary
run targets, settings, and safety controls are retained when adding controls.
Advanced field types and direct service actions use the operator-view contract
below.

For a robot-learning application, link the saved workflow to a Project. Expose
its task, dataset, and training settings, then display its progress, results,
and viewer outputs. [Guided Projects](guided-projects.md) describes the existing
dataset, training, and policy workflow handoffs.

## Operator view contract

An operator view is declared inside workflow metadata:
Expand Down
33 changes: 24 additions & 9 deletions docs/walkthrough.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
# Blacknode Beginner Walkthrough

This is the click-by-click path for trying every main Blacknode feature. Start
with the no-key steps first, then add NVIDIA NIM, MCP, or Docker when the local
workflow is working.
Start with a visible result, then choose the capabilities your task needs.

## Your first result

1. Run `.\start.bat` on Windows or `./start.sh` on macOS/Linux. The launcher
installs dependencies and opens the editor.
2. Choose **Open templates** in the welcome screen.
3. Open **Text Pipeline** under **Core**.
4. Press **Run once** in the top bar. The **Output** node displays `Hello World`.
5. Select a **Text** node, change its `value` in **Properties**, and press
**Run once** again. Your changed text appears in the result.

For a 3D result, open **Generic Point Cloud Viewer** and press **Run once**.
The viewer displays the included colored point cloud; right-drag to orbit and scroll
to zoom. Both starting workflows use built-in nodes on your computer.

Open **Workflows** to name and save your graph, or **Runs** to inspect a previous
result. For a real robot, continue with [Pair your first device](../README.md#pair-your-first-device).
For simulation, install the Newton package from **Packages** and open one of
its templates. The sections below cover the rest of the editor as you need it.

## What You Need

Expand Down Expand Up @@ -59,12 +76,10 @@ What this does:
First-run time depends on network speed and whether pip/npm packages are already
cached. Later starts normally complete in less than one minute.

On the first launch of this Blacknode workspace, a welcome message opens the
**Packages** tab. Use it to install the official packages needed by robotics,
ROS 2, vision, CUDA, dataset, and training templates. Choose **Continue with
core graph** when you want to begin with built-in nodes. Blacknode records the
choice in `.blacknode/onboarding.json`; Packages stays available in the left
sidebar.
On the first launch, choose **Open templates** to start with built-in nodes.
Choose **Explore packages** when you already know which robotics, ROS 2, vision,
CUDA, dataset, or training capabilities you need. Blacknode records the choice
in `.blacknode/onboarding.json`; Packages stays available in the left sidebar.

The welcome message opens only after the backend confirms that onboarding has
not been completed. A backend startup or connectivity error keeps the current
Expand Down
40 changes: 40 additions & 0 deletions editor-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from blacknode.learned import registry as learned_registry
from blacknode.mcp import tools as mcp_tools
from blacknode.node import _NODE_REGISTRY
from blacknode.operator_views import OperatorViewValidationError, validate_operator_view
from blacknode.nodes import ai as ai_nodes
import blacknode.package_index as bn_package_index
from blacknode.packages import MANIFEST_NAME as BN_MANIFEST_NAME
Expand Down Expand Up @@ -2757,6 +2758,45 @@ def update_workflow_requirements(req: UpdateWorkflowRequirementsReq):
return {"metadata": dict(metadata)}


@app.patch("/graph/operator-view")
def update_workflow_operator_view(view: dict[str, Any]):
try:
validate_operator_view(view)
except OperatorViewValidationError as exc:
raise HTTPException(400, str(exc)) from exc
if not re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", str(view.get("id") or "")):
raise HTTPException(400, "App ID must start with a lowercase letter and use letters, numbers, hyphens, or underscores.")
nodes = {node["id"]: node for node in get_graph()["nodes"]}

def check_references(value):
if isinstance(value, list):
for item in value:
check_references(item)
elif isinstance(value, dict):
if "node_id" in value:
node = nodes.get(value["node_id"])
if node is None:
raise HTTPException(400, f"App references missing node: {value['node_id']}")
outputs = {"value"} if node["type"] == "Output" else set(node["outputs"])
if "port" in value and value["port"] not in outputs:
raise HTTPException(400, f"App references missing output: {value['node_id']}.{value['port']}")
if "param" in value:
definition = _NODE_REGISTRY.get(node["type"])
inputs = (set(node["inputs"]) | set(node.get("params", {}))
| set(getattr(definition, "_bn_inputs", []))
| set(getattr(definition, "_bn_input_defaults", {})))
if value["param"] not in inputs:
raise HTTPException(400, f"App references missing parameter: {value['node_id']}.{value['param']}")
for key, item in value.items():
if key not in {"value", "payload"}:
check_references(item)

check_references(view)
_session.metadata = {**_session.metadata, "operator_view": copy.deepcopy(view)}
_save()
return {"metadata": dict(_session.metadata)}


@app.post("/nodes")
def add_node(req: AddNodeReq):
if req.type_name in _SUBGRAPH_NODE_TYPES:
Expand Down
18 changes: 17 additions & 1 deletion editor/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const LocalFilePicker = lazy(() => import('./components/LocalFilePicker'))
const WorkflowOperatorView = lazy(() => import('./components/WorkflowOperatorView'))
const CustomerAppShell = lazy(() => import('./components/CustomerAppShell'))
const AppPackageDialog = lazy(() => import('./components/AppPackageDialog'))
const AppDesignerDialog = lazy(() => import('./components/AppDesignerDialog'))

const NODE_TYPES = {
blacknode: BlackNode,
Expand Down Expand Up @@ -273,7 +274,7 @@ function WorkspaceApp() {
beginAltDragCopy, finishAltDragCopy, undoGraph,
checkServer, reset, newTab, insertTab, switchTab, closeTab, duplicateTab,
openGraphAsTab, openWorkflowAsTab, setActiveTabSurface, renameTab, saveActiveWorkflow,
diveIntoSubnet, exitSubnet, collapseToSubnet, organizeNodes, cookNode, stopCook, stopRuntimeServices, dismissCookStatus, applyRunReplay,
subnetStack, diveIntoSubnet, exitSubnet, collapseToSubnet, organizeNodes, cookNode, stopCook, stopRuntimeServices, dismissCookStatus, applyRunReplay,
handleLearnedNodeEvent, updateParam,
} = useStore()

Expand Down Expand Up @@ -321,6 +322,7 @@ function WorkspaceApp() {
const [simulationViewerHeight, setSimulationViewerHeight] = useState(loadSimulationViewerHeight)
const [fileMenuOpen, setFileMenuOpen] = useState(false)
const [appPackageDialogOpen, setAppPackageDialogOpen] = useState(false)
const [appDesignerOpen, setAppDesignerOpen] = useState(false)
const [openingAppPackageDialog, setOpeningAppPackageDialog] = useState(false)
const [fileMenuPosition, setFileMenuPosition] = useState({ top: 0, left: 0 })
const [simulationViewerMenuOpen, setSimulationViewerMenuOpen] = useState(false)
Expand Down Expand Up @@ -2065,6 +2067,13 @@ function WorkspaceApp() {
<span>Import workflow…</span>
<small>JSON or Python</small>
</button>
{!hostedPreview && (
<button type="button" role="menuitem" disabled={!serverOk || nodes.length === 0 || subnetStack.length > 0}
onClick={() => { setFileMenuOpen(false); setAppDesignerOpen(true) }}>
<span>{operatorView ? 'Edit App…' : 'Create App…'}</span>
<small>Parameters, results & buttons</small>
</button>
)}
{!hostedPreview && (
<button
type="button"
Expand Down Expand Up @@ -2583,6 +2592,12 @@ function WorkspaceApp() {
+
</button>

{!hostedPreview && subnetStack.length === 0 && (
<button type="button" className="bn-workflow-surface-toggle" disabled={!serverOk || nodes.length === 0}
onClick={() => setAppDesignerOpen(true)}>
{operatorView ? 'Edit App' : 'Create App'}
</button>
)}
{operatorView && (
<button
type="button"
Expand Down Expand Up @@ -2730,6 +2745,7 @@ function WorkspaceApp() {
currentAppId={operatorView?.id}
onClose={() => setAppPackageDialogOpen(false)}
/>
{appDesignerOpen && <AppDesignerDialog key={activeTabId} onClose={() => setAppDesignerOpen(false)} />}

{pendingXacroEnvironment && (
<div
Expand Down
2 changes: 2 additions & 0 deletions editor/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1954,6 +1954,8 @@ export const api = {
required_capabilities: requiredCapabilities,
device_calibration: deviceCalibration,
}, 10000),
updateWorkflowOperatorView: (view: import('./operatorView').WorkflowOperatorView) =>
req<{ metadata: WorkflowMetadata }>('PATCH', '/graph/operator-view', view, 10000),
listGraphCalibrations: () =>
req<{
profiles: DeviceRobotProfile[]
Expand Down
53 changes: 53 additions & 0 deletions editor/src/appDesigner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { appTargets, buildAppView, designerControls, type AppControl } from './appDesigner'
import { isWorkflowOperatorView, type WorkflowOperatorView } from './operatorView'
import type { BnNodeMeta } from './types'

const node: BnNodeMeta = { id: 'task', type: 'Task', pos: [0, 0], params: {},
inputs: ['task', 'steps', 'api_key', 'dataset'], outputs: ['result', 'viewer_url'],
input_types: { task: 'Text', steps: 'Int', api_key: 'Text', dataset: 'Text' },
output_types: { result: 'Text', viewer_url: 'Text' }, input_defaults: {} }
const targets = appTargets([node], {}, new Set([JSON.stringify(['task', 'dataset'])]))
const controls: AppControl[] = [
{ id: 'task-input', kind: 'setting', target: JSON.stringify(['task', 'task']), label: 'Task' },
{ id: 'steps-input', kind: 'setting', target: JSON.stringify(['task', 'steps']), label: 'Steps' },
{ id: 'run', kind: 'action', target: JSON.stringify(['task', 'result']), label: 'Train', mode: 'live', confirm: 'Start training?' },
{ id: 'result', kind: 'result', target: JSON.stringify(['task', 'result']), label: 'Result', display: 'text' },
{ id: 'viewer', kind: 'result', target: JSON.stringify(['task', 'viewer_url']), label: 'Preview', display: 'viewer' },
]

describe('App designer', () => {
it('offers unconnected typed parameters and excludes credentials', () => {
expect(targets.settings.map(item => item.port)).toEqual(['task', 'steps'])
})
it('includes saved value-node parameters even when the node has no input ports', () => {
const value = { ...node, type: 'Text', inputs: [], input_types: {}, params: { value: 'Hello', _label: 'Greeting' } }
expect(appTargets([value], {}, new Set()).settings.map(item => item.port)).toEqual(['value'])
})
it('offers the cooked value of terminal Output nodes', () => {
expect(appTargets([{ ...node, type: 'Output', outputs: [] }], {}, new Set()).outputs[0].port).toBe('value')
})
it('builds a valid App with numeric fields, confirmed live actions, and visible outputs', () => {
const view = buildAppView('My task', 'my-task', controls, targets, null)
expect(isWorkflowOperatorView(view)).toBe(true)
expect(view.sections[1].widgets[1]).toMatchObject({ type: 'fields', items: [{ input: 'number' }] })
expect(view.sections[0].widgets[0]).toMatchObject({ type: 'actions', items: [{ confirm: 'Start training?', cook_target: { mode: 'live' } }] })
expect(designerControls(view)).toEqual([controls[2], controls[3], controls[4], controls[0], controls[1]])
})
it('preserves existing safety controls, settings, and primary run targets when adding controls', () => {
const existing: WorkflowOperatorView = { schema_version: 1, id: 'robot', title: 'Robot',
run_target: { node_id: 'task', port: 'result', confirm: 'Authorize?', mode: 'once' },
settings: { groups: [{ id: 'connection', title: 'Connection', items: [{ node_id: 'task', param: 'task', label: 'Task' }] }] },
sections: [{ id: 'safety', widgets: [{ type: 'actions', id: 'arm', items: [{ id: 'arm', label: 'Arm', confirm: 'Arm robot?', control: { node_id: 'task', action: 'arm' } }] }] }] }
const original = structuredClone(existing)
const view = buildAppView('Custom robot', 'robot', controls, targets, existing)
expect(view.sections[0]).toEqual(original.sections[0])
expect(view.run_target).toEqual(original.run_target)
expect(view.settings).toEqual(original.settings)
expect(existing).toEqual(original)
})
it('rejects missing references and empty apps before saving', () => {
expect(() => buildAppView('App', 'app', [{ ...controls[0], target: 'missing' }], targets, null)).toThrow('available workflow parameter')
expect(() => buildAppView('App', 'app', [], targets, null)).toThrow('Add a parameter')
})
})
Loading
Loading