diff --git a/src/workflows/NodeInspector.jsx b/src/workflows/NodeInspector.jsx
new file mode 100644
index 0000000..22ec067
--- /dev/null
+++ b/src/workflows/NodeInspector.jsx
@@ -0,0 +1,65 @@
+import React from 'react'
+
+const CONDITION_OPERATIONS = ['equals', 'not_equals', 'exists', 'greater_than', 'less_than', 'contains', 'array_not_empty', 'score_at_least']
+
+export default function NodeInspector({ node, tools, onChange, onDelete, readOnly }) {
+ const [configText, setConfigText] = React.useState('{}')
+ const [configError, setConfigError] = React.useState('')
+
+ React.useEffect(() => {
+ setConfigText(JSON.stringify(node?.data?.config || {}, null, 2))
+ setConfigError('')
+ }, [node])
+
+ if (!node) return
Select a node to edit it.
+ const nodeType = node.data.nodeType
+ const patchConfig = (config) => onChange({ ...node, data: { ...node.data, config, tool: config.tool || '' } })
+ return (
+
+
+ {node.id}
+ {!readOnly && }
+
+
+ {nodeType === 'tool' && (
+
+ )}
+ {nodeType === 'condition' && (
+
+ )}
+
+ {configError &&
{configError}
}
+
+ )
+}
diff --git a/src/workflows/NodePalette.jsx b/src/workflows/NodePalette.jsx
new file mode 100644
index 0000000..3916dfc
--- /dev/null
+++ b/src/workflows/NodePalette.jsx
@@ -0,0 +1,22 @@
+import React from 'react'
+
+export const NODE_TYPES = ['input', 'output', 'tool', 'prompt', 'agent', 'condition', 'merge', 'template', 'select', 'limit']
+
+export default function NodePalette({ onAdd }) {
+ return (
+
+ Nodes
+ {NODE_TYPES.map(type => (
+
+ ))}
+
+ )
+}
diff --git a/src/workflows/WorkflowCanvas.jsx b/src/workflows/WorkflowCanvas.jsx
new file mode 100644
index 0000000..13d3dc4
--- /dev/null
+++ b/src/workflows/WorkflowCanvas.jsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import {
+ Background,
+ Controls,
+ MiniMap,
+ ReactFlow,
+ addEdge,
+ applyEdgeChanges,
+ applyNodeChanges,
+} from '@xyflow/react'
+import '@xyflow/react/dist/style.css'
+import WorkflowNode from './WorkflowNode'
+
+const nodeTypes = { workflow: WorkflowNode }
+
+export default function WorkflowCanvas({ edges, nodes, onEdgesChange, onNodesChange, onSelectNode, onDropNode }) {
+ const [instance, setInstance] = React.useState(null)
+ return (
+
+ onNodesChange(applyNodeChanges(changes, nodes))}
+ onEdgesChange={(changes) => onEdgesChange(applyEdgeChanges(changes, edges))}
+ onConnect={(connection) => onEdgesChange(addEdge({ ...connection, id: `${connection.source}-${connection.target}-${Date.now()}` }, edges))}
+ onNodeClick={(_event, node) => onSelectNode(node.id)}
+ onPaneClick={() => onSelectNode(null)}
+ onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move' }}
+ onDrop={(event) => {
+ event.preventDefault()
+ const type = event.dataTransfer.getData('application/heimgeist-workflow-node')
+ if (!type || !instance) return
+ onDropNode(type, instance.screenToFlowPosition({ x: event.clientX, y: event.clientY }))
+ }}
+ fitView
+ >
+
+
+
+
+
+ )
+}
diff --git a/src/workflows/WorkflowEditor.jsx b/src/workflows/WorkflowEditor.jsx
new file mode 100644
index 0000000..8787853
--- /dev/null
+++ b/src/workflows/WorkflowEditor.jsx
@@ -0,0 +1,193 @@
+import React from 'react'
+import NodePalette from './NodePalette'
+import NodeInspector from './NodeInspector'
+import WorkflowCanvas from './WorkflowCanvas'
+import WorkflowRunInspector from './WorkflowRunInspector'
+import WorkflowValidationPanel from './WorkflowValidationPanel'
+import {
+ createWorkflowRun,
+ getWorkflowRun,
+ saveWorkflowRevision,
+ streamWorkflowEvents,
+ updateWorkflow,
+ validateWorkflow,
+} from './workflowApi'
+
+function toCanvasNode(node) {
+ return {
+ id: node.id,
+ type: 'workflow',
+ position: node.position || { x: 0, y: 0 },
+ data: {
+ nodeType: node.type,
+ label: node.config?.label || node.id,
+ config: node.config || {},
+ tool: node.config?.tool || '',
+ },
+ }
+}
+
+function toGraphNode(node) {
+ const config = { ...(node.data.config || {}) }
+ if (node.data.label && node.data.label !== node.id) config.label = node.data.label
+ return { id: node.id, type: node.data.nodeType, position: node.position, config }
+}
+
+function nextNodeConfig(type) {
+ if (type === 'tool') return { tool: '', arguments: {} }
+ if (type === 'prompt') return { model_source: 'chat_model', system_template: '', user_template: '{{input.prompt}}', output_mode: 'text', temperature: 0.2 }
+ if (type === 'agent') return { model: { $ref: 'run.chat_model' }, messages: { $ref: 'run.messages' }, allowed_tool_names: [], maximum_rounds: 4 }
+ if (type === 'condition') return { value: { $ref: 'input.prompt' }, operation: 'exists', compare_to: null }
+ if (type === 'merge') return { values: [], deduplicate_by: 'url' }
+ if (type === 'template') return { template: '{{input.prompt}}', values: {} }
+ if (type === 'select') return { value: { $ref: 'input.prompt' } }
+ if (type === 'limit') return { value: { $ref: 'input.prompt' }, maximum_chars: 12000 }
+ if (type === 'output') return { value: { content: { $ref: 'input.prompt' }, sources: [], usage: {} } }
+ return {}
+}
+
+export default function WorkflowEditor({ apiBase, model, onChanged, tools, workflow }) {
+ const [nodes, setNodes] = React.useState([])
+ const [edges, setEdges] = React.useState([])
+ const [selectedNodeId, setSelectedNodeId] = React.useState(null)
+ const [inputsText, setInputsText] = React.useState('{}')
+ const [outputsText, setOutputsText] = React.useState('{}')
+ const [limits, setLimits] = React.useState({})
+ const [metadata, setMetadata] = React.useState({ name: '', description: '', routing_description: '', routing_examples: [], enabled: true })
+ const [validation, setValidation] = React.useState(null)
+ const [status, setStatus] = React.useState('')
+ const [testPrompt, setTestPrompt] = React.useState('Summarize this sample request.')
+ const [testRun, setTestRun] = React.useState(null)
+ const [testEvents, setTestEvents] = React.useState([])
+
+ React.useEffect(() => {
+ const graph = workflow?.graph || {}
+ setNodes((graph.nodes || []).map(toCanvasNode))
+ setEdges(graph.edges || [])
+ setInputsText(JSON.stringify(graph.inputs || {}, null, 2))
+ setOutputsText(JSON.stringify(graph.outputs || {}, null, 2))
+ setLimits(graph.limits || {})
+ setMetadata({
+ name: workflow?.name || '', description: workflow?.description || '',
+ routing_description: workflow?.routing_description || '', routing_examples: workflow?.routing_examples || [],
+ enabled: workflow?.enabled !== false,
+ })
+ setSelectedNodeId(null)
+ setValidation(null)
+ setTestRun(null)
+ setTestEvents([])
+ }, [workflow])
+
+ const readOnly = Boolean(workflow?.built_in)
+ const selectedNode = nodes.find(node => node.id === selectedNodeId) || null
+ const buildGraph = () => ({
+ schema_version: 1,
+ inputs: JSON.parse(inputsText || '{}'),
+ outputs: JSON.parse(outputsText || '{}'),
+ nodes: nodes.map(toGraphNode),
+ edges: edges.map(edge => ({
+ id: edge.id, source: edge.source, source_handle: edge.sourceHandle || edge.source_handle || 'output',
+ target: edge.target, target_handle: edge.targetHandle || edge.target_handle || 'input',
+ })),
+ limits,
+ })
+
+ const addNode = (type, position = { x: 120 + nodes.length * 35, y: 120 + nodes.length * 20 }) => {
+ if (readOnly) return
+ const id = `${type}-${Date.now().toString(36)}`
+ setNodes(previous => [...previous, toCanvasNode({ id, type, position, config: nextNodeConfig(type) })])
+ setSelectedNodeId(id)
+ }
+
+ const runValidation = async () => {
+ try {
+ const result = await validateWorkflow(apiBase, buildGraph())
+ setValidation(result)
+ return result
+ } catch (error) {
+ setValidation({ valid: false, errors: [{ code: 'editor', message: error.message }] })
+ return null
+ }
+ }
+
+ const save = async () => {
+ const result = await runValidation()
+ if (!result?.valid) return
+ setStatus('Saving…')
+ try {
+ await updateWorkflow(apiBase, workflow.id, metadata)
+ await saveWorkflowRevision(apiBase, workflow.id, buildGraph())
+ setStatus('Saved as a new revision.')
+ await onChanged(workflow.id)
+ } catch (error) {
+ setStatus(error.message)
+ }
+ }
+
+ const test = async () => {
+ const result = await runValidation()
+ if (!result?.valid) return
+ setTestRun({ status: 'starting', node_runs: [] })
+ setTestEvents([])
+ try {
+ const created = await createWorkflowRun(apiBase, {
+ message: testPrompt, model, selection_mode: 'explicit', workflow_id: workflow.id,
+ sample_inputs: { prompt: testPrompt }, stream: true, explicit_user_action: true,
+ })
+ await streamWorkflowEvents(apiBase, created.run_id, {
+ onEvent: event => setTestEvents(previous => [...previous, event]),
+ })
+ setTestRun(await getWorkflowRun(apiBase, created.run_id))
+ } catch (error) {
+ setTestRun({ status: 'failed', error: { message: error.message }, node_runs: [] })
+ }
+ }
+
+ if (!workflow) return Select a workflow.
+ return (
+
+
+ setMetadata(value => ({ ...value, name: event.target.value }))} />
+
+ {!readOnly && }
+ {readOnly && Built-in revisions are read-only.}
+ {status && {status}}
+
+
+
+
+
+
+
+
+
+
+ setNodes(previous => previous.map(node => node.id === next.id ? next : node))}
+ onDelete={() => {
+ setNodes(previous => previous.filter(node => node.id !== selectedNodeId))
+ setEdges(previous => previous.filter(edge => edge.source !== selectedNodeId && edge.target !== selectedNodeId))
+ setSelectedNodeId(null)
+ }}
+ />
+
+
+ Workflow input/output schemas and limits
+
+
+
+
+
+
+
+
+ setTestPrompt(event.target.value)} />
+
+
+
+
+ )
+}
diff --git a/src/workflows/WorkflowList.jsx b/src/workflows/WorkflowList.jsx
new file mode 100644
index 0000000..1022048
--- /dev/null
+++ b/src/workflows/WorkflowList.jsx
@@ -0,0 +1,24 @@
+import React from 'react'
+
+export default function WorkflowList({ activeId, onCreate, onDelete, onDuplicate, onSelect, workflows }) {
+ return (
+
+
+ Workflows
+
+
+ {workflows.map(workflow => (
+
onSelect(workflow.id)}>
+
+ {workflow.name}
+ {workflow.built_in ? 'Built-in' : `Revision ${workflow.current_version || 1}`}{workflow.enabled ? '' : ' · Disabled'}
+
+
+
+ {!workflow.built_in && }
+
+
+ ))}
+
+ )
+}
diff --git a/src/workflows/WorkflowNode.jsx b/src/workflows/WorkflowNode.jsx
new file mode 100644
index 0000000..606e377
--- /dev/null
+++ b/src/workflows/WorkflowNode.jsx
@@ -0,0 +1,19 @@
+import React from 'react'
+import { Handle, Position } from '@xyflow/react'
+
+export default function WorkflowNode({ data, selected }) {
+ return (
+
+ {data.nodeType !== 'input' &&
}
+
{data.nodeType}
+
{data.label}
+ {data.tool &&
{data.tool}}
+ {data.nodeType === 'condition' ? (
+ <>
+
+
+ >
+ ) : data.nodeType !== 'output' &&
}
+
+ )
+}
diff --git a/src/workflows/WorkflowRunInspector.jsx b/src/workflows/WorkflowRunInspector.jsx
new file mode 100644
index 0000000..90ae360
--- /dev/null
+++ b/src/workflows/WorkflowRunInspector.jsx
@@ -0,0 +1,19 @@
+import React from 'react'
+
+export default function WorkflowRunInspector({ run, events }) {
+ if (!run && !(events || []).length) return null
+ return (
+
+
Test run {run?.status || 'running'}
+ {(run?.node_runs || []).map(node => (
+
+ {node.node_id}: {node.status} {node.metrics?.duration_seconds != null ? `(${node.metrics.duration_seconds.toFixed(2)}s)` : ''}
+ {node.resolved_inputs && {JSON.stringify(node.resolved_inputs, null, 2)}}
+ {node.outputs && {JSON.stringify(node.outputs, null, 2)}}
+ {node.error && {JSON.stringify(node.error, null, 2)}}
+
+ ))}
+ {run?.error &&
{JSON.stringify(run.error, null, 2)}}
+
+ )
+}
diff --git a/src/workflows/WorkflowValidationPanel.jsx b/src/workflows/WorkflowValidationPanel.jsx
new file mode 100644
index 0000000..114d34c
--- /dev/null
+++ b/src/workflows/WorkflowValidationPanel.jsx
@@ -0,0 +1,15 @@
+import React from 'react'
+
+export default function WorkflowValidationPanel({ result }) {
+ if (!result) return null
+ return (
+
+
{result.valid ? 'Workflow is valid' : `${result.errors?.length || 0} validation error(s)`}
+ {(result.errors || []).map((error, index) => (
+
+ {error.node_id || error.field_path || error.code} {error.message}
+
+ ))}
+
+ )
+}
diff --git a/src/workflows/WorkflowsArea.jsx b/src/workflows/WorkflowsArea.jsx
new file mode 100644
index 0000000..302d157
--- /dev/null
+++ b/src/workflows/WorkflowsArea.jsx
@@ -0,0 +1,78 @@
+import React from 'react'
+import WorkflowEditor from './WorkflowEditor'
+import WorkflowList from './WorkflowList'
+import {
+ createWorkflow,
+ deleteWorkflow,
+ duplicateWorkflow,
+ fetchTools,
+ fetchWorkflow,
+ fetchWorkflows,
+} from './workflowApi'
+
+const DEFAULT_GRAPH = {
+ schema_version: 1,
+ inputs: { prompt: { type: 'string' }, session_id: { type: ['string', 'null'] } },
+ outputs: { content: { type: 'string' } },
+ nodes: [
+ { id: 'input', type: 'input', position: { x: 80, y: 120 }, config: {} },
+ { id: 'output', type: 'output', position: { x: 440, y: 120 }, config: { value: { content: { $ref: 'input.prompt' }, sources: [], usage: {} } } },
+ ],
+ edges: [{ id: 'input-output', source: 'input', source_handle: 'output', target: 'output', target_handle: 'input' }],
+ limits: { maximum_nodes: 40, maximum_tool_calls: 20, maximum_llm_calls: 8, maximum_runtime_seconds: 300, maximum_result_chars: 120000, maximum_concurrency: 4 },
+}
+
+export default function WorkflowsArea({ apiBase, model, onWorkflowsChanged }) {
+ const [workflows, setWorkflows] = React.useState([])
+ const [tools, setTools] = React.useState([])
+ const [activeId, setActiveId] = React.useState(null)
+ const [activeWorkflow, setActiveWorkflow] = React.useState(null)
+ const [error, setError] = React.useState('')
+
+ const refresh = React.useCallback(async (preferredId = null) => {
+ try {
+ const [workflowData, toolData] = await Promise.all([fetchWorkflows(apiBase), fetchTools(apiBase)])
+ const next = workflowData.workflows || []
+ setWorkflows(next)
+ setTools(toolData.tools || [])
+ const nextId = preferredId || activeId || next[0]?.id || null
+ setActiveId(nextId)
+ setActiveWorkflow(nextId ? await fetchWorkflow(apiBase, nextId) : null)
+ onWorkflowsChanged?.(next)
+ setError('')
+ } catch (nextError) {
+ setError(nextError.message)
+ }
+ }, [activeId, apiBase, onWorkflowsChanged])
+
+ React.useEffect(() => { refresh() }, [apiBase])
+
+ const select = async (id) => {
+ setActiveId(id)
+ try { setActiveWorkflow(await fetchWorkflow(apiBase, id)) } catch (nextError) { setError(nextError.message) }
+ }
+
+ return (
+
+
{
+ const created = await createWorkflow(apiBase, { name: 'Untitled Workflow', graph: DEFAULT_GRAPH })
+ await refresh(created.id)
+ }}
+ onDuplicate={async (id) => { const created = await duplicateWorkflow(apiBase, id); await refresh(created.id) }}
+ onDelete={async (id) => {
+ if (!window.confirm('Delete this workflow and its revisions?')) return
+ await deleteWorkflow(apiBase, id)
+ await refresh()
+ }}
+ />
+
+
+ )
+}