@ai-node-editor/core
v0.1.0
Published
A Blender-inspired React node editor for building AI workflows.
Maintainers
Readme
@ai-node-editor/core
A reusable React and TypeScript package for building AI workflow node editors. It provides a Blender-inspired dark node graph interface plus a pure TypeScript graph engine for AI pipelines, RAG workflows, agents, prompt engineering, fine-tuning, training, media processing, automation, evaluation, and deployment flows.
Important legal note: this project is Blender-inspired, but it does not include Blender logos, trademarks, icons, assets, source code, or proprietary UI resources. The components, CSS, SVG-style visuals, graph model, and implementation are original.
What You Get
- React node editor component:
AINodeEditor - Pure TypeScript graph model and graph utilities
- Node and plugin registries
- Port compatibility and validation logic
- Serialization and deserialization helpers
- Traversal helpers such as topological sort and upstream/downstream lookup
- Command stack foundation for undo/redo
- Async graph execution engine with logs, statuses, cancellation, retries, and dry-run validation
- Schema-driven node config UI
- Blender-inspired dark CSS theme
- Built-in AI node definitions for RAG, LLMs, agents, training, media, automation, evaluation, and outputs
- Demo workflows and Vite playground
Installation
Install the package and peer dependencies:
npm install @ai-node-editor/core react react-domImport the editor and theme CSS:
import { AINodeEditor, builtinAINodes, createGraph } from "@ai-node-editor/core";
import "@ai-node-editor/core/styles/blender-dark.css";React and React DOM are peer dependencies so the package can be used inside existing React apps without bundling a second React copy.
Quick Start
import { AINodeEditor, builtinAINodes, createGraph } from "@ai-node-editor/core";
import "@ai-node-editor/core/styles/blender-dark.css";
const initialGraph = createGraph({
name: "My AI Workflow"
});
export function App() {
return (
<AINodeEditor
nodes={builtinAINodes}
initialGraph={initialGraph}
theme="blender-dark"
onGraphChange={(graph) => console.log(graph)}
/>
);
}The editor fills its parent. Give the parent a real height:
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
}Controlled Usage
Use controlled mode when your app owns the graph state.
import { useState } from "react";
import { AINodeEditor, builtinAINodes, createGraph, type Graph } from "@ai-node-editor/core";
import "@ai-node-editor/core/styles/blender-dark.css";
export function WorkflowBuilder() {
const [graph, setGraph] = useState<Graph>(() =>
createGraph({
name: "Production RAG Pipeline"
})
);
return (
<AINodeEditor
nodes={builtinAINodes}
graph={graph}
onGraphChange={setGraph}
onNodeSelect={(node) => console.log("Selected node", node)}
theme="blender-dark"
/>
);
}Uncontrolled Usage
Use uncontrolled mode when you only need to provide an initial graph.
<AINodeEditor
nodes={builtinAINodes}
initialGraph={createGraph({ name: "Draft Workflow" })}
onGraphChange={(graph) => saveDraft(graph)}
theme="blender-dark"
/>Editor Props
interface AINodeEditorProps {
nodes?: NodeDefinition[];
initialGraph?: Graph;
graph?: Graph;
onGraphChange?: (nextGraph: Graph) => void;
onNodeSelect?: (node: NodeInstance | undefined) => void;
onSelectionChange?: (selection: SelectionState) => void;
onExecute?: (graph: Graph) => void | Promise<void>;
onValidate?: (issues: ValidationIssue[]) => void;
onError?: (error: unknown) => void;
theme?: "blender-dark" | string;
readonly?: boolean;
plugins?: Plugin[];
className?: string;
style?: React.CSSProperties;
themeVariables?: EditorThemeVariables;
nodeClassName?: (node: NodeInstance, definition?: NodeDefinition) => string | undefined;
nodeStyle?: (node: NodeInstance, definition?: NodeDefinition) => React.CSSProperties | undefined;
edgeClassName?: (edge: Edge, graph: Graph) => string | undefined;
edgeStyle?: (edge: Edge, graph: Graph) => React.CSSProperties | undefined;
renderNode?: (node: NodeInstance, definition: NodeDefinition | undefined, context: RenderNodeContext) => React.ReactNode;
renderSocket?: (node: NodeInstance, port: PortDefinition, context: RenderSocketContext) => React.ReactNode;
renderEdge?: (edge: Edge, context: RenderEdgeContext) => React.ReactNode;
renderInspectorField?: (fieldKey: string, field: ConfigField, node: NodeInstance) => React.ReactNode;
executionEngine?: GraphEngine;
registry?: NodeRegistry;
options?: EditorOptions;
}Editor Options
interface EditorOptions {
snapToGrid?: boolean;
gridSize?: number;
showMinimap?: boolean;
showNodeLibrary?: boolean;
showInspector?: boolean;
showExecutionLog?: boolean;
allowCycles?: boolean;
readonly?: boolean;
enableKeyboardShortcuts?: boolean;
enableContextMenu?: boolean;
enableAutoOffset?: boolean;
enableRerouteNodes?: boolean;
enableFrameNodes?: boolean;
}Example:
<AINodeEditor
nodes={builtinAINodes}
initialGraph={graph}
theme="blender-dark"
options={{
snapToGrid: true,
gridSize: 24,
showInspector: true,
enableContextMenu: true
}}
/>Creating A Custom Node
import { createNodeDefinition } from "@ai-node-editor/core";
export const sentimentNode = createNodeDefinition({
type: "custom.sentiment-analysis",
label: "Sentiment Analysis",
category: "Text",
description: "Analyzes sentiment from text.",
version: "1.0.0",
inputs: [
{
id: "text",
label: "Text",
direction: "input",
dataType: "text",
required: true,
multiple: false
}
],
outputs: [
{
id: "sentiment",
label: "Sentiment",
direction: "output",
dataType: "json",
required: false,
multiple: true
}
],
configSchema: {
model: {
type: "string",
label: "Model",
default: "gpt-4.1-mini",
required: true
},
temperature: {
type: "slider",
label: "Temperature",
default: 0.2,
min: 0,
max: 2,
step: 0.1
}
},
async execute({ inputs, config, signal }) {
if (signal.aborted) {
throw new Error("Cancelled");
}
return {
outputs: {
sentiment: {
label: "positive",
score: 0.94,
model: config.model,
source: inputs.text
}
},
metrics: {
latencyMs: 12
}
};
}
});Register it with built-ins:
<AINodeEditor
nodes={[...builtinAINodes, sentimentNode]}
initialGraph={graph}
theme="blender-dark"
/>NodeDefinition
interface NodeDefinition {
type: string;
label: string;
category: NodeCategory;
description?: string;
icon?: string;
version: string;
inputs: PortDefinition[];
outputs: PortDefinition[];
configSchema?: ConfigSchema;
defaultConfig?: NodeConfig;
ui?: {
headerColor?: string;
width?: number;
iconLabel?: string;
compact?: boolean;
};
tags?: string[];
deprecated?: boolean;
allowCycles?: boolean;
timeoutMs?: number;
retryPolicy?: {
retries: number;
delayMs?: number;
};
validate?: (config: NodeConfig, node: NodeInstance, graph: Graph) => ValidationIssue[] | void;
execute?: (context: NodeExecutionContext) => Promise<NodeExecutionResult> | NodeExecutionResult;
}PortDefinition
interface PortDefinition {
id: string;
label: string;
direction: "input" | "output";
dataType: PortDataType;
required: boolean;
multiple: boolean;
color?: string;
description?: string;
defaultValue?: unknown;
accepts?: PortDataType[];
ui?: {
hidden?: boolean;
compact?: boolean;
inlineControl?: boolean;
};
}Supported port data types:
type PortDataType =
| "string"
| "number"
| "boolean"
| "json"
| "text"
| "document"
| "documents"
| "image"
| "images"
| "video"
| "audio"
| "embedding"
| "embeddings"
| "vector-store"
| "model"
| "llm-response"
| "dataset"
| "training-config"
| "metrics"
| "prompt"
| "tool"
| "agent"
| "control"
| "any";Compatibility rules:
anyconnects to any data type unless explicitly restricted.acceptscan widen accepted input or output types.- Input ports accept one connection unless
multiple: true. - Output ports can fan out to multiple inputs.
- Invalid links are rejected by
connectPortsand reported by validation.
Config Schema
Config schemas drive the right-side inspector UI and validation.
configSchema: {
model: {
type: "string",
label: "Model",
default: "gpt-4.1-mini",
required: true,
placeholder: "provider-model-name"
},
topK: {
type: "number",
label: "Top K",
default: 5,
min: 1,
max: 50,
step: 1
},
mode: {
type: "select",
label: "Mode",
default: "balanced",
options: [
{ label: "Fast", value: "fast" },
{ label: "Balanced", value: "balanced" },
{ label: "Careful", value: "careful" }
]
}
}Supported field types:
stringnumberbooleanselectmultiselecttextareajsoncodeslidersecretfilecolor
Field options include label, description, default, required, min, max, step, options, placeholder, visibleWhen, validation, and ui.
Plugins
Plugins are collections of node definitions and optional metadata.
import {
createNodeRegistry,
createPlugin,
createPluginRegistry
} from "@ai-node-editor/core";
const plugin = createPlugin({
id: "my-company-ai-pack",
name: "My Company AI Pack",
version: "1.0.0",
description: "Private workflow nodes for internal tools.",
nodes: [sentimentNode]
});
const nodeRegistry = createNodeRegistry();
const pluginRegistry = createPluginRegistry(nodeRegistry);
pluginRegistry.register(plugin);Use the registry in the editor:
<AINodeEditor
registry={nodeRegistry}
nodes={nodeRegistry.list()}
initialGraph={graph}
theme="blender-dark"
/>Graph Utilities
import {
addNode,
cloneGraph,
connectPorts,
createGraph,
createNode,
deserializeGraph,
detectCycles,
getDownstreamNodes,
getUpstreamNodes,
removeEdge,
removeNode,
serializeGraph,
topologicalSort,
updateNode,
validateGraph
} from "@ai-node-editor/core";Example:
const graph = createGraph({ name: "Website RAG" });
const urlNode = createNode(urlDefinition, { position: { x: 0, y: 0 } });
const scraperNode = createNode(scraperDefinition, { position: { x: 280, y: 0 } });
let nextGraph = addNode(graph, urlNode);
nextGraph = addNode(nextGraph, scraperNode);
nextGraph = connectPorts(
nextGraph,
builtinAINodes,
urlNode.id,
"url",
scraperNode.id,
"url"
);
const issues = validateGraph(nextGraph, builtinAINodes);Serialization
Save a graph:
const json = serializeGraph(graph, { pretty: true });
localStorage.setItem("workflow", json);Load a graph:
const restored = deserializeGraph(localStorage.getItem("workflow")!);The serialized envelope includes:
{
"schema": "@ai-node-editor/graph",
"schemaVersion": "1.0.0",
"graph": {}
}Validation
const issues = validateGraph(graph, builtinAINodes, {
strictConfig: true,
allowCycles: false
});
const errors = issues.filter((issue) => issue.severity === "error");Validation checks include:
- Missing required inputs
- Unknown node types
- Invalid ports
- Incompatible data types
- Duplicate node and edge IDs
- Dangling edges
- Input multiplicity violations
- Cycles when cycles are not allowed
- Deprecated nodes
- Unknown config fields
- Invalid config values
- Missing required output node types when configured
Execution Engine
Use GraphEngine to validate and execute a graph.
import { GraphEngine, builtinAINodes } from "@ai-node-editor/core";
const engine = new GraphEngine({
registry: builtinAINodes,
graphTimeoutMs: 120000,
nodeTimeoutMs: 30000
});
const unsubscribe = engine.on((event) => {
console.log(event.type, event.nodeId, event.error);
});
const result = await engine.execute(graph);
unsubscribe();
if (!result.success) {
console.error(result.issues, result.logs, result.error);
}Dry-run validation:
const result = await engine.execute(graph, {
dryRun: true
});Cancellation:
const controller = new AbortController();
const promise = engine.execute(graph, {
signal: controller.signal
});
controller.abort();
const result = await promise;Partial execution:
await engine.execute(graph, {
fromNodeId: "node_123"
});
await engine.execute(graph, {
toNodeId: "output_node_456"
});Lifecycle events:
graph:startgraph:validategraph:errorgraph:successgraph:cancelnode:queuednode:startnode:successnode:errornode:skippededge:data
Built-In Nodes
Import all built-ins:
import { builtinAINodes } from "@ai-node-editor/core";Or import grouped node packs:
import {
audioNodes,
controlNodes,
dataNodes,
evaluationNodes,
imageNodes,
llmNodes,
outputNodes,
ragNodes,
scratchTrainingNodes,
textNodes,
trainingNodes,
videoNodes
} from "@ai-node-editor/core";Built-in categories:
- Data sources: website URL, web scraper, sitemap crawler, file upload, PDF extractor, CSV loader, JSON loader, database query, API request, webhook trigger
- Text processing: cleaner, chunker, entity extraction, summarization, translation, regex extraction, metadata extraction
- RAG: embedding model, vector store upsert/search, retriever, reranker, context builder, RAG answer generator
- LLM and agents: prompt template, system prompt, chat model, completion model, tool calling, structured output parser, JSON schema validator, routers, planner, executor, memory
- Training: dataset loader, validator, splitter, tokenizer, config, fine-tuning job, prompt tuning, LoRA config, metrics, registry push
- Training from scratch: corpus loader, tokenizer trainer, pretraining config, distributed config, checkpoint saver, loss monitor, validation loop, artifact export
- Image, video, and audio processing
- Automation and control flow
- Evaluation
- Output and deployment
Built-in executors are intentionally mock/stub implementations. Real providers should be injected through your own custom nodes or a plugin package.
Demo Workflows
The package exports demo graphs:
import {
agentWorkflow,
fineTuningWorkflow,
imageVideoWorkflow,
ragWorkflow,
sampleWorkflow,
scratchTrainingWorkflow
} from "@ai-node-editor/core";Render one:
<AINodeEditor
nodes={builtinAINodes}
initialGraph={ragWorkflow}
theme="blender-dark"
/>Styling And Theming
Default theme import:
import "@ai-node-editor/core/styles/blender-dark.css";Base-only import:
import "@ai-node-editor/core/styles/base.css";The theme is based on CSS variables scoped to .aine-theme-blender-dark. Override variables from your app:
.my-workflow-editor.aine-theme-blender-dark {
--aine-bg: #181818;
--aine-accent: #6fa0da;
--aine-blue-selection: #477dca;
--aine-node-bg: #2c2c2c;
}Use a custom class:
<AINodeEditor
className="my-workflow-editor"
nodes={builtinAINodes}
initialGraph={graph}
theme="blender-dark"
/>CSS classes are prefixed with aine- to reduce collisions.
You can also override theme variables directly from React:
<AINodeEditor
nodes={builtinAINodes}
initialGraph={graph}
theme="blender-dark"
themeVariables={{
"--aine-bg": "#111315",
"--aine-node-bg": "#25282c",
"--aine-blue-selection": "#5b9cff",
"--aine-socket-text": "#5fd0d6"
}}
/>Custom Node Rendering
<AINodeEditor
nodes={nodes}
initialGraph={graph}
nodeClassName={(node) => (node.status === "error" ? "my-node-error" : undefined)}
nodeStyle={(node, definition) => ({
borderColor: node.customColor ?? definition?.ui?.headerColor
})}
renderNode={(node, definition, ctx) => (
<div className="my-node-shell">
{ctx.renderDefaultNode()}
<footer>{definition?.category}</footer>
</div>
)}
/>renderNode receives a context object so custom nodes can keep first-class editor behavior:
ctx.renderDefaultNode()renders the package's default compact node UI.ctx.renderSocket(port)renders a socket with the correct data attributes and pointer handling.ctx.inputPortsandctx.outputPortslet you build a fully custom layout while preserving connection behavior.
Custom sockets and edges can be styled or replaced too:
<AINodeEditor
nodes={nodes}
initialGraph={graph}
renderSocket={(node, port, ctx) => (
<span className={`my-socket my-socket-${port.dataType}`}>
{ctx.defaultSocket}
</span>
)}
edgeStyle={(edge) => ({
strokeWidth: edge.selected ? 4 : 2
})}
renderEdge={(edge, ctx) => (
<>
{ctx.defaultEdge}
{edge.status === "running" ? <circle r={3} cx={ctx.source.x} cy={ctx.source.y} className="my-edge-pulse" /> : null}
</>
)}
/>For data manipulation, keep graph state controlled with graph and onGraphChange, or use the pure graph helpers such as addNode, updateNode, connectPorts, removeEdge, validateGraph, serializeGraph, and GraphEngine.
Custom Inspector Fields
<AINodeEditor
nodes={nodes}
initialGraph={graph}
renderInspectorField={(fieldKey, field, node) => {
if (fieldKey === "apiKey") {
return <input type="password" aria-label={field.label} />;
}
return undefined;
}}
/>Command Stack
import {
AddNodeCommand,
CommandStack,
MoveNodeCommand,
type Graph
} from "@ai-node-editor/core";
const stack = new CommandStack<Graph>();
let graph = stack.execute(new AddNodeCommand(node), currentGraph);
graph = stack.execute(
new MoveNodeCommand(node.id, node.position, { x: 240, y: 80 }),
graph
);
graph = stack.undo(graph);
graph = stack.redo(graph);Local Development
Clone the project and install dependencies:
npm installRun the demo playground:
npm run devRun tests:
npm testTypecheck:
npm run typecheckBuild the library:
npm run buildThe build emits:
dist/ai-node-editor.jsdist/ai-node-editor.cjsdist/index.d.tsdist/styles/base.cssdist/styles/blender-dark.css
Publishing To npm
The package is configured for public scoped npm publishing:
{
"publishConfig": {
"access": "public"
}
}Recommended release check:
npm run release:checkThis runs:
- TypeScript typecheck
- Vitest tests
- Clean production build
- Package artifact/export validation
npm pack --dry-run --ignore-scripts
Publish:
npm login
npm publishFor a scoped package like @ai-node-editor/core, the first public publish must use public access. This repository already sets publishConfig.access to public, so plain npm publish is enough.
To inspect the package before publishing:
npm run pack:dry-runUsing From A Local Checkout
Build and pack locally:
npm run build
npm packInstall the generated tarball in another app:
npm install ../ai-node-editor/ai-node-editor-core-0.1.0.tgzThen import it normally:
import { AINodeEditor } from "@ai-node-editor/core";
import "@ai-node-editor/core/styles/blender-dark.css";Package Exports
import {
AINodeEditor,
GraphEngine,
NodeRegistry,
PluginRegistry,
addNode,
builtinAINodes,
cloneGraph,
connectPorts,
createGraph,
createNode,
createNodeDefinition,
createPlugin,
deserializeGraph,
detectCycles,
getDownstreamNodes,
getUpstreamNodes,
removeEdge,
removeNode,
serializeGraph,
topologicalSort,
updateNode,
validateGraph
} from "@ai-node-editor/core";CSS exports:
import "@ai-node-editor/core/styles/base.css";
import "@ai-node-editor/core/styles/blender-dark.css";Current Implementation Status
Implemented:
- Package build and npm-ready exports
- Strict TypeScript graph engine
- Registries, commands, validation, compatibility, serialization, traversal
- Async execution engine
- Built-in AI node definitions and demo workflows
- React editor shell with toolbar, breadcrumbs, dark canvas, nodes, edges, inspector, and right-click Add menu
- Node selection, dragging, panning, canvas-confined wheel zoom, schema-driven inspector fields
- Socket-to-socket connection dragging with compatibility checks and invalid-link feedback
- Connection removal through the small midpoint remove handle
- Unit tests for core behavior
Planned next:
- Box select and multi-select drag interactions
- Copy, cut, paste, duplicate, delete, undo, and redo wired into editor shortcuts
- Richer compatible socket highlighting
- Reroute nodes, frame nodes, minimap polish, and execution log UI wiring
- More React component tests
Troubleshooting
The editor is invisible
Make sure the parent element has a height:
#root {
height: 100vh;
}Styles are missing
Import one of the CSS exports:
import "@ai-node-editor/core/styles/blender-dark.css";Connections fail
Check port data types and accepts:
const result = checkConnectionCompatibility(
graph,
builtinAINodes,
sourceNodeId,
sourcePortId,
targetNodeId,
targetPortId
);
if (!result.compatible) {
console.warn(result.reason);
}Validation reports missing inputs
Required input ports must either have an incoming edge, a defaultValue, or a node config value matching that input ID.
npm publish fails for a scoped package
Confirm you are logged in and the package is public:
npm whoami
npm publish --access publicThe package already includes publishConfig.access = "public".
License
MIT.
