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.graphNodes()].find((n) => n.type === 'Input')?.id
const active = inputId ? reachableFrom(editor.readGraph(), inputId) : new Set<NodeId>()
const { order } = topoOrder(editor.readGraph())
const out = new Map<NodeId, string>()
for (const id of order) {
if (!active.has(id)) continue
const node = editor.getNode(id)
if (!node) continue
editor.setNodeStatus(id, 'running')
const ins = incomers(editor.readGraph(), 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) }
} {
"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"
}
}
]
} <script setup lang="ts">
import { XenolithGraph } from '@xenolithengine/graph-vue'
import RunPanel from './RunPanel.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false">
<RunPanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-vue'
const props = defineProps<WidgetProps>()
function onInput(event: Event): void {
props.setValue((event.target as HTMLTextAreaElement).value)
}
</script>
<template>
<textarea
spellcheck="false"
:value="String(value ?? '')"
style="width:100%;height:100%;box-sizing:border-box;font:inherit;font-size:11px;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);border-radius:6px;padding:6px;resize:none;"
@input="onInput"
/>
</template> <script setup lang="ts">
import { computed } from 'vue'
import type { WidgetProps } from '@xenolithengine/graph-vue'
const props = defineProps<WidgetProps>()
const text = computed(() => String(props.value ?? ''))
</script>
<template>
<div
:style="{
width: '100%', height: '100%', boxSizing: 'border-box', overflow: 'auto', whiteSpace: 'pre-wrap',
font: '11px ui-monospace, monospace', background: 'var(--xeno-bg)', border: '1px solid var(--xeno-border)',
borderRadius: '6px', padding: '6px', color: text ? 'var(--xeno-text)' : 'var(--xeno-muted)',
}"
>{{ text || 'Run to generate…' }}</div>
</template> <script setup lang="ts">
import { ref, watch } from 'vue'
import { XenolithPanel, XenolithButton, useEditor, vueWidget } from '@xenolithengine/graph-vue'
import { loadLLMGraph, runLLM } from '@xenolithengine/demo/llm-builder'
import PromptEditor from './PromptEditor.vue'
import OutputView from './OutputView.vue'
const editor = useEditor()
const running = ref(false)
const prompt = vueWidget(PromptEditor)
const output = vueWidget(OutputView)
let loaded = false
watch(editor, (ed) => {
if (!ed || loaded) return
loaded = true
ed.registerWidget('prompt-edit', prompt)
ed.registerWidget('output-view', output)
loadLLMGraph(ed)
}, { immediate: true })
async function onRun(): Promise<void> {
const ed = editor.value
if (!ed || running.value) return
running.value = true
try { await runLLM(ed) } finally { running.value = false }
}
</script>
<template>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:6px;max-width:220px;">
<XenolithButton :active="running" :disabled="running" style="width:100%" @click="onRun">
{{ running ? 'Running…' : '▶ Run' }}
</XenolithButton>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
Edit the Input / Prompt / Model, then Run — the active node glows and the completion streams in.
</span>
</div>
</XenolithPanel>
</template> // 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.graphNodes()].find((n) => n.type === 'Input')?.id
const active = inputId ? reachableFrom(editor.readGraph(), inputId) : new Set<NodeId>()
const { order } = topoOrder(editor.readGraph())
const out = new Map<NodeId, string>()
for (const id of order) {
if (!active.has(id)) continue
const node = editor.getNode(id)
if (!node) continue
editor.setNodeStatus(id, 'running')
const ins = incomers(editor.readGraph(), 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) }
} {
"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"
}
}
]
} <script lang="ts">
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton, svelteWidget } from '@xenolithengine/graph-svelte/components'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { loadLLMGraph, runLLM } from '@xenolithengine/demo/llm-builder'
import PromptEditor from './PromptEditor.svelte'
import OutputView from './OutputView.svelte'
const panelEditor = createXenolithEditorContext()
let editor = $state<XenolithEditor | null>(null)
let running = $state(false)
const prompt = svelteWidget(PromptEditor)
const output = svelteWidget(OutputView)
async function onRun(): Promise<void> {
if (!editor || running) return
running = true
try { await runLLM(editor) } finally { running = false }
}
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => {
editor = e.detail
panelEditor.set(e.detail)
e.detail.registerWidget('prompt-edit', prompt)
e.detail.registerWidget('output-view', output)
loadLLMGraph(e.detail)
}}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:6px;max-width:220px;">
<XenolithButton active={running} disabled={running} style="width:100%" onclick={() => void onRun()}>
{running ? 'Running…' : '▶ Run'}
</XenolithButton>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
Edit the Input / Prompt / Model, then Run — the active node glows and the completion streams in.
</span>
</div>
</XenolithPanel> <script lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
let { value, setValue }: WidgetProps = $props()
</script>
<textarea
spellcheck="false"
style="width:100%;height:100%;box-sizing:border-box;font:inherit;font-size:11px;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);border-radius:6px;padding:6px;resize:none;"
value={String(value ?? '')}
oninput={(e) => setValue((e.target as HTMLTextAreaElement).value)}
></textarea> <script lang="ts">
import type { WidgetProps } from '@xenolithengine/graph-svelte/components'
let { value }: WidgetProps = $props()
const text = $derived(String(value ?? ''))
</script>
<div style="width:100%;height:100%;box-sizing:border-box;overflow:auto;white-space:pre-wrap;font:11px ui-monospace,monospace;background:var(--xeno-bg);border:1px solid var(--xeno-border);border-radius:6px;padding:6px;color:{text ? 'var(--xeno-text)' : 'var(--xeno-muted)'};">
{text || 'Run to generate…'}
</div> // 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.graphNodes()].find((n) => n.type === 'Input')?.id
const active = inputId ? reachableFrom(editor.readGraph(), inputId) : new Set<NodeId>()
const { order } = topoOrder(editor.readGraph())
const out = new Map<NodeId, string>()
for (const id of order) {
if (!active.has(id)) continue
const node = editor.getNode(id)
if (!node) continue
editor.setNodeStatus(id, 'running')
const ins = incomers(editor.readGraph(), 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) }
} {
"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"
}
}
]
} // Solid has no widget bridge. prompt-edit and output-view are DOM controllers, registered before load.
import { createSignal } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import type { DomWidgetController, XenolithEditor } from '@xenolithengine/graph-editor'
import { loadLLMGraph, runLLM } from '@xenolithengine/demo/llm-builder'
const box = 'width:100%;height:100%;box-sizing:border-box;font:11px inherit;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);border-radius:6px;padding:6px;resize:none;'
function promptEdit(): DomWidgetController {
let area: HTMLTextAreaElement | null = null
return {
mount(el, c) {
area = document.createElement('textarea')
area.spellcheck = false
area.value = String(c.value ?? '')
area.style.cssText = box
area.addEventListener('input', () => c.setValue(area?.value ?? ''))
el.append(area)
return () => { area = null; el.replaceChildren() }
},
update(c) {
const next = String(c.value ?? '')
if (area && area.value !== next) area.value = next
},
}
}
function outputView(): DomWidgetController {
let view: HTMLDivElement | null = null
const paint = (value: unknown): void => {
if (!view) return
const text = String(value ?? '')
view.textContent = text || 'Run to generate…'
view.style.color = text ? 'var(--xeno-text)' : 'var(--xeno-muted)'
}
return {
mount(el, c) {
view = document.createElement('div')
view.style.cssText = box + 'overflow:auto;white-space:pre-wrap;font-family:ui-monospace,monospace;'
el.append(view)
paint(c.value)
return () => { view = null; el.replaceChildren() }
},
update(c) { paint(c.value) },
}
}
export function LLMBuilderDemo() {
let editor: XenolithEditor | null = null
const [running, setRunning] = createSignal(false)
const btn = (on: boolean): string =>
`font:inherit;font-size:13px;padding:7px 12px;border-radius:8px;cursor:${on ? 'default' : 'pointer'};opacity:${on ? '0.4' : '1'};width:100%;border:1px solid ${on ? 'var(--xeno-accent)' : 'var(--xeno-border)'};background:${on ? 'var(--xeno-accent)' : 'var(--xeno-elevated)'};color:${on ? 'var(--xeno-canvas)' : 'var(--xeno-text)'};`
const onRun = async (): Promise<void> => {
if (!editor || running()) return
setRunning(true)
try { await runLLM(editor) } finally { setRunning(false) }
}
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
editor = e.detail
e.detail.registerWidget('prompt-edit', promptEdit())
e.detail.registerWidget('output-view', outputView())
loadLLMGraph(e.detail)
}}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;z-index:5;display:flex;flex-direction:column;gap:6px;max-width:220px;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<button type="button" style={btn(running())} disabled={running()} onClick={() => void onRun()}>{running() ? 'Running…' : '▶ Run'}</button>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
Edit the Input / Prompt / Model, then Run — the active node glows and the completion streams in.
</span>
</div>
</div>
)
} // 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.graphNodes()].find((n) => n.type === 'Input')?.id
const active = inputId ? reachableFrom(editor.readGraph(), inputId) : new Set<NodeId>()
const { order } = topoOrder(editor.readGraph())
const out = new Map<NodeId, string>()
for (const id of order) {
if (!active.has(id)) continue
const node = editor.getNode(id)
if (!node) continue
editor.setNodeStatus(id, 'running')
const ins = incomers(editor.readGraph(), 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) }
} {
"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"
}
}
]
} // Angular has no widget bridge. prompt-edit and output-view are DOM controllers, registered before load.
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { DomWidgetController, XenolithEditor } from '@xenolithengine/graph-editor'
import { loadLLMGraph, runLLM } from '@xenolithengine/demo/llm-builder'
const box = 'width:100%;height:100%;box-sizing:border-box;font:11px inherit;background:var(--xeno-bg);color:var(--xeno-text);border:1px solid var(--xeno-border);border-radius:6px;padding:6px;resize:none;'
function promptEdit(): DomWidgetController {
let area: HTMLTextAreaElement | null = null
return {
mount(el, c) {
area = document.createElement('textarea')
area.spellcheck = false
area.value = String(c.value ?? '')
area.style.cssText = box
area.addEventListener('input', () => c.setValue(area?.value ?? ''))
el.append(area)
return () => { area = null; el.replaceChildren() }
},
update(c) {
const next = String(c.value ?? '')
if (area && area.value !== next) area.value = next
},
}
}
function outputView(): DomWidgetController {
let view: HTMLDivElement | null = null
const paint = (value: unknown): void => {
if (!view) return
const text = String(value ?? '')
view.textContent = text || 'Run to generate…'
view.style.color = text ? 'var(--xeno-text)' : 'var(--xeno-muted)'
}
return {
mount(el, c) {
view = document.createElement('div')
view.style.cssText = box + 'overflow:auto;white-space:pre-wrap;font-family:ui-monospace,monospace;'
el.append(view)
paint(c.value)
return () => { view = null; el.replaceChildren() }
},
update(c) { paint(c.value) },
}
}
@Component({
selector: 'llm-builder-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="card">
<button type="button" class="btn" [class.on]="running()" [disabled]="running()" (click)="onRun()">
{{ running() ? 'Running…' : '▶ Run' }}
</button>
<span class="note">Edit the Input / Prompt / Model, then Run — the active node glows and the completion streams in.</span>
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; left:12px; z-index:5; display:flex; flex-direction:column;
gap:6px; max-width:220px; padding:10px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:10px;
color:var(--xeno-text,#cfcfcf); font:13px system-ui,sans-serif; }
.btn { font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; border-radius:8px; width:100%;
border:1px solid var(--xeno-border,#333); background:var(--xeno-elevated,#262626); color:var(--xeno-text,#cfcfcf); }
.btn.on { border-color:var(--xeno-accent,#FCB400); background:var(--xeno-accent,#FCB400); color:var(--xeno-canvas,#111); }
.btn:disabled { opacity:.4; cursor:default; }
.note { color:var(--xeno-muted,#9a9a9a); font-size:11px; line-height:1.4; }
`],
})
export class LLMBuilderDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private editor: XenolithEditor | null = null
running = signal(false)
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
this.editor = editor
editor.registerWidget('prompt-edit', promptEdit())
editor.registerWidget('output-view', outputView())
loadLLMGraph(editor)
}
async onRun(): Promise<void> {
const editor = this.editor
if (!editor || this.running()) return
this.running.set(true)
try { await runLLM(editor) } finally { this.running.set(false) }
}
ngOnDestroy(): void { this.graph.destroy() }
} // 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.graphNodes()].find((n) => n.type === 'Input')?.id
const active = inputId ? reachableFrom(editor.readGraph(), inputId) : new Set<NodeId>()
const { order } = topoOrder(editor.readGraph())
const out = new Map<NodeId, string>()
for (const id of order) {
if (!active.has(id)) continue
const node = editor.getNode(id)
if (!node) continue
editor.setNodeStatus(id, 'running')
const ins = incomers(editor.readGraph(), 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) }
}