feat(workflows): Implement core workflow editor components (canvas, inspector, palette)

This commit is contained in:
2026-06-15 15:16:42 +02:00
parent 920888b336
commit 2c2e712ba8
9 changed files with 480 additions and 0 deletions

View File

@@ -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 <div className="workflow-inspector empty">Select a node to edit it.</div>
const nodeType = node.data.nodeType
const patchConfig = (config) => onChange({ ...node, data: { ...node.data, config, tool: config.tool || '' } })
return (
<div className="workflow-inspector">
<div className="workflow-inspector-heading">
<strong>{node.id}</strong>
{!readOnly && <button type="button" className="button danger ghost" onClick={onDelete}>Delete</button>}
</div>
<label>Label
<input disabled={readOnly} value={node.data.label || ''} onChange={(event) => onChange({ ...node, data: { ...node.data, label: event.target.value } })} />
</label>
{nodeType === 'tool' && (
<label>Registered tool
<select
disabled={readOnly}
value={node.data.config?.tool || ''}
onChange={(event) => patchConfig({ ...node.data.config, tool: event.target.value, arguments: node.data.config?.arguments || {} })}
>
<option value="">Select a tool</option>
{tools.map(tool => <option key={tool.name} value={tool.name}>{tool.name}</option>)}
</select>
</label>
)}
{nodeType === 'condition' && (
<label>Operation
<select disabled={readOnly} value={node.data.config?.operation || 'equals'} onChange={(event) => patchConfig({ ...node.data.config, operation: event.target.value })}>
{CONDITION_OPERATIONS.map(operation => <option key={operation}>{operation}</option>)}
</select>
</label>
)}
<label>Configuration JSON
<textarea
rows={18}
disabled={readOnly}
value={configText}
onChange={(event) => {
const text = event.target.value
setConfigText(text)
try {
patchConfig(JSON.parse(text))
setConfigError('')
} catch (error) {
setConfigError(error.message)
}
}}
/>
</label>
{configError && <p className="workflow-validation-error">{configError}</p>}
</div>
)
}

View File

@@ -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 (
<div className="workflow-palette">
<strong>Nodes</strong>
{NODE_TYPES.map(type => (
<button
key={type}
type="button"
draggable
onDragStart={(event) => event.dataTransfer.setData('application/heimgeist-workflow-node', type)}
onClick={() => onAdd(type)}
>
{type}
</button>
))}
</div>
)
}

View File

@@ -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 (
<div className="workflow-canvas">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={setInstance}
onNodesChange={(changes) => 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
>
<Background gap={20} />
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
</div>
)
}

View File

@@ -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 <div className="workflow-empty">Select a workflow.</div>
return (
<div className="workflow-editor">
<div className="workflow-editor-toolbar">
<input value={metadata.name} disabled={readOnly} onChange={(event) => setMetadata(value => ({ ...value, name: event.target.value }))} />
<button className="button ghost" type="button" onClick={runValidation}>Validate</button>
{!readOnly && <button className="button" type="button" onClick={save}>Save revision</button>}
{readOnly && <span className="header-subtle">Built-in revisions are read-only.</span>}
{status && <span className="header-subtle">{status}</span>}
</div>
<div className="workflow-editor-meta">
<label>Description<textarea disabled={readOnly} value={metadata.description} onChange={(event) => setMetadata(value => ({ ...value, description: event.target.value }))} /></label>
<label>Routing description<textarea disabled={readOnly} value={metadata.routing_description} onChange={(event) => setMetadata(value => ({ ...value, routing_description: event.target.value }))} /></label>
<label>Routing examples<input disabled={readOnly} value={metadata.routing_examples.join(' | ')} onChange={(event) => setMetadata(value => ({ ...value, routing_examples: event.target.value.split('|').map(item => item.trim()).filter(Boolean) }))} /></label>
<label className="workflow-enabled"><input type="checkbox" disabled={readOnly} checked={metadata.enabled} onChange={(event) => setMetadata(value => ({ ...value, enabled: event.target.checked }))} /> Enabled for routing</label>
</div>
<div className="workflow-editor-grid">
<NodePalette onAdd={addNode} />
<WorkflowCanvas nodes={nodes} edges={edges} onNodesChange={setNodes} onEdgesChange={setEdges} onSelectNode={setSelectedNodeId} onDropNode={addNode} />
<NodeInspector
node={selectedNode}
tools={tools}
readOnly={readOnly}
onChange={(next) => 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)
}}
/>
</div>
<details className="workflow-schema-editor">
<summary>Workflow input/output schemas and limits</summary>
<div>
<label>Inputs JSON<textarea disabled={readOnly} rows={10} value={inputsText} onChange={(event) => setInputsText(event.target.value)} /></label>
<label>Outputs JSON<textarea disabled={readOnly} rows={10} value={outputsText} onChange={(event) => setOutputsText(event.target.value)} /></label>
<label>Limits JSON<textarea disabled={readOnly} rows={10} value={JSON.stringify(limits, null, 2)} onChange={(event) => { try { setLimits(JSON.parse(event.target.value)) } catch {} }} /></label>
</div>
</details>
<WorkflowValidationPanel result={validation} />
<div className="workflow-test-panel">
<input value={testPrompt} onChange={(event) => setTestPrompt(event.target.value)} />
<button className="button ghost" type="button" onClick={test} disabled={!model}>Run test</button>
</div>
<WorkflowRunInspector run={testRun} events={testEvents} />
</div>
)
}

View File

@@ -0,0 +1,24 @@
import React from 'react'
export default function WorkflowList({ activeId, onCreate, onDelete, onDuplicate, onSelect, workflows }) {
return (
<div className="workflow-list">
<div className="workflow-list-heading">
<strong>Workflows</strong>
<button className="button" type="button" onClick={onCreate}>New</button>
</div>
{workflows.map(workflow => (
<div key={workflow.id} className={`workflow-list-item${workflow.id === activeId ? ' active' : ''}`} onClick={() => onSelect(workflow.id)}>
<div>
<strong>{workflow.name}</strong>
<small>{workflow.built_in ? 'Built-in' : `Revision ${workflow.current_version || 1}`}{workflow.enabled ? '' : ' · Disabled'}</small>
</div>
<div className="workflow-list-actions">
<button type="button" className="icon-button" title="Duplicate" onClick={(event) => { event.stopPropagation(); onDuplicate(workflow.id) }}></button>
{!workflow.built_in && <button type="button" className="icon-button" title="Delete" onClick={(event) => { event.stopPropagation(); onDelete(workflow.id) }}>×</button>}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { Handle, Position } from '@xyflow/react'
export default function WorkflowNode({ data, selected }) {
return (
<div className={`workflow-node workflow-node-${data.nodeType}${selected ? ' selected' : ''}`}>
{data.nodeType !== 'input' && <Handle type="target" position={Position.Left} id="input" />}
<div className="workflow-node-type">{data.nodeType}</div>
<strong>{data.label}</strong>
{data.tool && <small>{data.tool}</small>}
{data.nodeType === 'condition' ? (
<>
<Handle type="source" position={Position.Right} id="true" style={{ top: '38%' }} />
<Handle type="source" position={Position.Right} id="false" style={{ top: '72%' }} />
</>
) : data.nodeType !== 'output' && <Handle type="source" position={Position.Right} id="output" />}
</div>
)
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
export default function WorkflowRunInspector({ run, events }) {
if (!run && !(events || []).length) return null
return (
<div className="workflow-run-inspector">
<strong>Test run {run?.status || 'running'}</strong>
{(run?.node_runs || []).map(node => (
<details key={node.id || node.node_id}>
<summary>{node.node_id}: {node.status} {node.metrics?.duration_seconds != null ? `(${node.metrics.duration_seconds.toFixed(2)}s)` : ''}</summary>
{node.resolved_inputs && <pre>{JSON.stringify(node.resolved_inputs, null, 2)}</pre>}
{node.outputs && <pre>{JSON.stringify(node.outputs, null, 2)}</pre>}
{node.error && <pre className="workflow-validation-error">{JSON.stringify(node.error, null, 2)}</pre>}
</details>
))}
{run?.error && <pre className="workflow-validation-error">{JSON.stringify(run.error, null, 2)}</pre>}
</div>
)
}

View File

@@ -0,0 +1,15 @@
import React from 'react'
export default function WorkflowValidationPanel({ result }) {
if (!result) return null
return (
<div className={`workflow-validation ${result.valid ? 'valid' : 'invalid'}`}>
<strong>{result.valid ? 'Workflow is valid' : `${result.errors?.length || 0} validation error(s)`}</strong>
{(result.errors || []).map((error, index) => (
<div key={`${error.code}-${index}`}>
<code>{error.node_id || error.field_path || error.code}</code> {error.message}
</div>
))}
</div>
)
}

View File

@@ -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 (
<div className="workflows-area">
<WorkflowList
workflows={workflows}
activeId={activeId}
onSelect={select}
onCreate={async () => {
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()
}}
/>
<div className="workflows-main">
{error && <div className="workflow-validation-error">{error}</div>}
<WorkflowEditor apiBase={apiBase} model={model} tools={tools} workflow={activeWorkflow} onChanged={refresh} />
</div>
</div>
)
}