feat(workflows): Implement core workflow editor components (canvas, inspector, palette)
This commit is contained in:
65
src/workflows/NodeInspector.jsx
Normal file
65
src/workflows/NodeInspector.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/workflows/NodePalette.jsx
Normal file
22
src/workflows/NodePalette.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/workflows/WorkflowCanvas.jsx
Normal file
45
src/workflows/WorkflowCanvas.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
src/workflows/WorkflowEditor.jsx
Normal file
193
src/workflows/WorkflowEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/workflows/WorkflowList.jsx
Normal file
24
src/workflows/WorkflowList.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/workflows/WorkflowNode.jsx
Normal file
19
src/workflows/WorkflowNode.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/workflows/WorkflowRunInspector.jsx
Normal file
19
src/workflows/WorkflowRunInspector.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
src/workflows/WorkflowValidationPanel.jsx
Normal file
15
src/workflows/WorkflowValidationPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
src/workflows/WorkflowsArea.jsx
Normal file
78
src/workflows/WorkflowsArea.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user