LLM workflow builder
Input → Prompt → Model → Output. Press Run; the chain walks in topological order, the active node glows, the completion streams into the Output. A prettier LangFlow, on Xenolith.
{
"version": "xenolith.v1",
"nodes": [
{
"id": "input",
"type": "Input",
"position": {
"x": 0,
"y": 40
},
"render": {
"title": "Input"
},
"state": {
"value": "autumn leaves"
},
"pins": [
{
"id": "input:out",
"kind": "data",
"direction": "out",
"type": "text",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "value",
"type": "text",
"key": "value",
"label": "Value",
"freeFloating": true
}
]
},
{
"id": "prompt",
"type": "Prompt",
"position": {
"x": 250,
"y": 40
},
"render": {
"title": "Prompt"
},
"state": {
"template": "Write a haiku about {input}."
},
"pins": [
{
"id": "prompt:in",
"kind": "data",
"direction": "in",
"type": "text",
"multiple": false,
"label": "In"
},
{
"id": "prompt:out",
"kind": "data",
"direction": "out",
"type": "text",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "template",
"type": "custom",
"renderer": "prompt-edit",
"key": "template",
"label": "Template",
"height": 60,
"freeFloating": true
}
]
},
{
"id": "model",
"type": "Model",
"position": {
"x": 510,
"y": 40
},
"render": {
"title": "Model"
},
"state": {
"model": "GPT-4o mini",
"temp": 0.7
},
"pins": [
{
"id": "model:in",
"kind": "data",
"direction": "in",
"type": "text",
"multiple": false,
"label": "In"
},
{
"id": "model:out",
"kind": "data",
"direction": "out",
"type": "text",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "model",
"type": "combo",
"key": "model",
"label": "Model",
"values": [
"GPT-4o mini",
"Claude 3.5",
"Llama 3"
],
"freeFloating": true
},
{
"id": "temp",
"type": "slider",
"key": "temp",
"label": "Temp",
"min": 0,
"max": 1,
"step": 0.05,
"freeFloating": true
}
]
},
{
"id": "output",
"type": "Output",
"position": {
"x": 770,
"y": 40
},
"render": {
"title": "Output"
},
"state": {
"result": ""
},
"pins": [
{
"id": "output:in",
"kind": "data",
"direction": "in",
"type": "text",
"multiple": false,
"label": "In"
}
],
"widgets": [
{
"id": "result",
"type": "custom",
"renderer": "output-view",
"key": "result",
"label": "Result",
"height": 92,
"freeFloating": true
}
]
}
],
"edges": [
{
"id": "e-ip",
"from": {
"node": "input",
"pin": "input:out"
},
"to": {
"node": "prompt",
"pin": "prompt:in"
}
},
{
"id": "e-pm",
"from": {
"node": "prompt",
"pin": "prompt:out"
},
"to": {
"node": "model",
"pin": "model:in"
}
},
{
"id": "e-mo",
"from": {
"node": "model",
"pin": "model:out"
},
"to": {
"node": "output",
"pin": "output:in"
}
}
]
} import { useState } from 'react'
import { XenolithGraph, XenolithPanel, XenolithButton, reactWidget, useEditor, type WidgetProps } from '@xenolithengine/graph-react'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { loadLLMGraph, runLLM } from '@xenolithengine/demo/llm-builder'
import { DemoStage } from '../Layout.js'
// LLM workflow builder built on the graph: Input → Prompt → Model → Output.
// Canon: the Run button owns its own state + side-effect; `useEditor()` gives it the editor;
// the runner is a pure function over the editor — no handle, no subscriptions, no instance.
const box: React.CSSProperties = {
width: '100%', height: '100%', boxSizing: 'border-box', font: 'inherit', fontSize: 11,
background: 'var(--xeno-bg)', color: 'var(--xeno-text)', border: '1px solid var(--xeno-border)',
borderRadius: 6, padding: 6, resize: 'none',
}
function PromptEditor({ value, setValue }: WidgetProps) {
return <textarea style={box} spellCheck={false} value={String(value ?? '')} onChange={(e) => setValue(e.target.value)} />
}
function OutputView({ value }: WidgetProps) {
const text = String(value ?? '')
return (
<div style={{ ...box, overflow: 'auto', whiteSpace: 'pre-wrap', fontFamily: 'ui-monospace, monospace', color: text ? 'var(--xeno-text)' : 'var(--xeno-muted)' }}>
{text || 'Run to generate…'}
</div>
)
}
function setupLLM(editor: XenolithEditor): void {
editor.registerWidget('prompt-edit', reactWidget(PromptEditor))
editor.registerWidget('output-view', reactWidget(OutputView))
loadLLMGraph(editor)
}
function RunPanel() {
const editor = useEditor()
const [running, setRunning] = useState(false)
const onRun = async (): Promise<void> => {
if (running) return
setRunning(true)
try { await runLLM(editor) } finally { setRunning(false) }
}
return (
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 6, maxWidth: 220 }}>
<XenolithButton active={running} disabled={running} onClick={() => void onRun()} style={{ width: '100%' }}>
{running ? 'Running…' : '▶ Run'}
</XenolithButton>
<span style={{ color: 'var(--xeno-muted)', fontSize: 11, lineHeight: 1.4 }}>
Edit the Input / Prompt / Model, then Run — the active node glows and the completion streams in.
</span>
</XenolithPanel>
)
}
/** Showcase: an LLM workflow builder driven by the graph. */
export function LLMBuilderDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={setupLLM}>
<RunPanel />
</XenolithGraph>
</DemoStage>
)
} // LLM workflow builder (a prettier LangFlow) built ON the graph: Input → Prompt → Model → Output.
// The graph itself is DATA (llm-builder.json, loaded with editor.loadJSON) — only the two custom
// widget renderers ('prompt-edit', 'output-view') are framework components the host registers first.
//
// The runner is pure editor API, so it lives here and works on any framework: it walks the graph in
// topological order, feeds each node its upstream outputs, lights the active node (setNodeStatus),
// and streams the completion into the Output node. reachableFrom keeps a disconnected / just-deleted
// node out of the run — it must not light up or be processed.
import { topoOrder, incomers, reachableFrom } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './llm-builder.json'
export interface LLMBuilderHandle {
/** Walk the active chain and stream the completion into Output. Resolves when the run finishes. */
run(): Promise<void>
}
function fakeComplete(prompt: string, model: string): string {
const topic = (prompt.match(/about ([^.\n]+)/i)?.[1] ?? prompt.split('\n').pop() ?? 'the graph').trim()
return `${topic} drifting down —\nbright wires hum across the nodes,\nthe graph dreams in code.\n\n— ${model}`
}
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
function streamText(full: string, onChunk: (s: string) => void): Promise<void> {
return new Promise((res) => {
let i = 0
const step = (): void => { i += 2; onChunk(full.slice(0, i)); if (i < full.length) setTimeout(step, 18); else res() }
step()
})
}
/** Load the LLM workflow graph into the editor. Pass to `<XenolithGraph onReady>` so the canvas
* paints with the graph on the first frame. Custom widgets ('prompt-edit', 'output-view') must
* be registered by the host BEFORE this is called (they're framework components). */
export function loadLLMGraph(editor: XenolithEditor): void {
editor.loadJSON(graph)
editor.fitView({ padding: 56, maxZoom: 1 })
}
/** Walk the active chain and stream the completion into Output. Pure editor API — no instance,
* no subscriptions, no handle. Call it directly from the panel that owns the Run button. */
export async function runLLM(editor: XenolithEditor): Promise<void> {
editor.clearNodeStatuses()
const inputId = [...editor.graph.nodes()].find((n) => n.type === 'Input')?.id
const active = inputId ? reachableFrom(editor.graph, inputId) : new Set<NodeId>()
const { order } = topoOrder(editor.graph)
const out = new Map<NodeId, string>()
for (const id of order) {
if (!active.has(id)) continue
const node = editor.graph.getNode(id)
if (!node) continue
editor.setNodeStatus(id, 'running')
const ins = incomers(editor.graph, id).map((n) => out.get(n.id) ?? '').join('\n').trim()
let result = ''
if (node.type === 'Input') result = String(node.state['value'] ?? '')
else if (node.type === 'Prompt') result = String(node.state['template'] ?? '').replace(/\{[^}]*\}/g, ins || '…')
else if (node.type === 'Model') result = fakeComplete(ins, String(node.state['model'] ?? 'model'))
else if (node.type === 'Output') result = ins
out.set(id, result)
if (node.type === 'Output') await streamText(result, (p) => editor.setWidgetValue(id, 'result', p))
else await delay(node.type === 'Model' ? 480 : 200)
editor.setNodeStatus(id, 'ok')
}
}
/** @deprecated Combined loader+runner for back-compat with non-React hosts. Prefer
* `loadLLMGraph` (in `onReady`) + `runLLM(editor)` called directly from your handler. */
export function buildLLMBuilder(editor: XenolithEditor): LLMBuilderHandle {
loadLLMGraph(editor)
return { run: () => runLLM(editor) }
}