393 lines
13 KiB
React
393 lines
13 KiB
React
|
|
import { useState, useCallback, useEffect } from 'react'
|
|||
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|||
|
|
import { useNodesState, useEdgesState, addEdge } from 'reactflow'
|
|||
|
|
import { api } from '../utils/api'
|
|||
|
|
import { validateWorkflowGraph } from '../utils/workflowValidation'
|
|||
|
|
import { serializeToWorkflowGraph, deserializeFromWorkflowGraph } from '../utils/workflowSerializer'
|
|||
|
|
import { WorkflowCanvas } from '../components/workflow/WorkflowCanvas'
|
|||
|
|
import { StartNode } from '../components/workflow/nodes/StartNode'
|
|||
|
|
import { EndNode } from '../components/workflow/nodes/EndNode'
|
|||
|
|
import { AnalysisNode } from '../components/workflow/nodes/AnalysisNode'
|
|||
|
|
import { LogicNode } from '../components/workflow/nodes/LogicNode'
|
|||
|
|
import { JoinNode } from '../components/workflow/nodes/JoinNode'
|
|||
|
|
import { QuestionAugmentationPanel } from '../components/workflow/panels/QuestionAugmentationPanel'
|
|||
|
|
import { LogicExpressionEditor } from '../components/workflow/panels/LogicExpressionEditor'
|
|||
|
|
import { FallbackConfig } from '../components/workflow/panels/FallbackConfig'
|
|||
|
|
import { JoinConfig } from '../components/workflow/panels/JoinConfig'
|
|||
|
|
import '../styles/workflowEditor.css'
|
|||
|
|
|
|||
|
|
// Node-Type Mapping
|
|||
|
|
const nodeTypes = {
|
|||
|
|
start: StartNode,
|
|||
|
|
end: EndNode,
|
|||
|
|
analysis: AnalysisNode,
|
|||
|
|
logic: LogicNode,
|
|||
|
|
join: JoinNode
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let nodeIdCounter = 1
|
|||
|
|
|
|||
|
|
export default function WorkflowEditorPage() {
|
|||
|
|
const navigate = useNavigate()
|
|||
|
|
const { id } = useParams() // prompt_id wenn vorhanden
|
|||
|
|
|
|||
|
|
// State
|
|||
|
|
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
|||
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
|||
|
|
const [selectedNode, setSelectedNode] = useState(null)
|
|||
|
|
const [currentPrompt, setCurrentPrompt] = useState(null)
|
|||
|
|
const [workflowName, setWorkflowName] = useState('Neuer Workflow')
|
|||
|
|
const [workflowDescription, setWorkflowDescription] = useState('')
|
|||
|
|
const [validationErrors, setValidationErrors] = useState([])
|
|||
|
|
const [validationWarnings, setValidationWarnings] = useState([])
|
|||
|
|
const [loading, setLoading] = useState(false)
|
|||
|
|
const [error, setError] = useState(null)
|
|||
|
|
|
|||
|
|
// Load workflow wenn ID vorhanden
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (id && id !== 'new') {
|
|||
|
|
loadWorkflow(parseInt(id))
|
|||
|
|
}
|
|||
|
|
}, [id])
|
|||
|
|
|
|||
|
|
// Auto-Validation
|
|||
|
|
useEffect(() => {
|
|||
|
|
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
|
|||
|
|
setValidationErrors(errors)
|
|||
|
|
setValidationWarnings(warnings)
|
|||
|
|
}, [nodes, edges])
|
|||
|
|
|
|||
|
|
// ── Handlers ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const onConnect = useCallback(
|
|||
|
|
(params) => setEdges((eds) => addEdge(params, eds)),
|
|||
|
|
[setEdges]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const onNodeClick = useCallback((event, node) => {
|
|||
|
|
setSelectedNode(node)
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
const handleAddNode = (nodeType) => {
|
|||
|
|
const newNode = {
|
|||
|
|
id: `node_${nodeIdCounter++}`,
|
|||
|
|
type: nodeType,
|
|||
|
|
position: { x: 250, y: 100 + nodes.length * 100 },
|
|||
|
|
data: {
|
|||
|
|
label: `${nodeType.charAt(0).toUpperCase() + nodeType.slice(1)} ${nodeIdCounter - 1}`
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setNodes((nds) => [...nds, newNode])
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleNodeUpdate = (nodeId, updates) => {
|
|||
|
|
setNodes((nds) =>
|
|||
|
|
nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...updates } } : n))
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleDeleteNode = () => {
|
|||
|
|
if (!selectedNode) return
|
|||
|
|
|
|||
|
|
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id))
|
|||
|
|
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id))
|
|||
|
|
setSelectedNode(null)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
try {
|
|||
|
|
setLoading(true)
|
|||
|
|
setError(null)
|
|||
|
|
|
|||
|
|
// Validierung
|
|||
|
|
const { errors, isValid } = validateWorkflowGraph(nodes, edges)
|
|||
|
|
if (!isValid) {
|
|||
|
|
setError(`Validierung fehlgeschlagen: ${errors.length} Fehler gefunden`)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Serialisieren
|
|||
|
|
const graph_data = serializeToWorkflowGraph(nodes, edges, {
|
|||
|
|
created_at: currentPrompt?.created_at,
|
|||
|
|
version: '1.0'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (currentPrompt) {
|
|||
|
|
// Update existing
|
|||
|
|
await api.updateUnifiedPrompt(currentPrompt.id, {
|
|||
|
|
type: 'workflow',
|
|||
|
|
name: workflowName,
|
|||
|
|
description: workflowDescription,
|
|||
|
|
graph_data
|
|||
|
|
})
|
|||
|
|
alert('Workflow gespeichert!')
|
|||
|
|
} else {
|
|||
|
|
// Create new
|
|||
|
|
const result = await api.createUnifiedPrompt({
|
|||
|
|
type: 'workflow',
|
|||
|
|
name: workflowName,
|
|||
|
|
description: workflowDescription,
|
|||
|
|
graph_data
|
|||
|
|
})
|
|||
|
|
setCurrentPrompt({ id: result.id, name: workflowName })
|
|||
|
|
navigate(`/workflow-editor/${result.id}`)
|
|||
|
|
alert('Workflow erstellt!')
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const loadWorkflow = async (promptId) => {
|
|||
|
|
try {
|
|||
|
|
setLoading(true)
|
|||
|
|
setError(null)
|
|||
|
|
|
|||
|
|
const prompt = await api.getPrompt(promptId)
|
|||
|
|
|
|||
|
|
if (prompt.type !== 'workflow') {
|
|||
|
|
throw new Error('Nicht ein Workflow')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Deserialisieren
|
|||
|
|
const { nodes: loadedNodes, edges: loadedEdges } = deserializeFromWorkflowGraph(prompt.graph_data)
|
|||
|
|
|
|||
|
|
setNodes(loadedNodes)
|
|||
|
|
setEdges(loadedEdges)
|
|||
|
|
setCurrentPrompt(prompt)
|
|||
|
|
setWorkflowName(prompt.name)
|
|||
|
|
setWorkflowDescription(prompt.description || '')
|
|||
|
|
|
|||
|
|
// nodeIdCounter aktualisieren
|
|||
|
|
const maxId = Math.max(
|
|||
|
|
...loadedNodes.map((n) => parseInt(n.id.replace('node_', '')) || 0),
|
|||
|
|
0
|
|||
|
|
)
|
|||
|
|
nodeIdCounter = maxId + 1
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleValidate = () => {
|
|||
|
|
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
|
|||
|
|
setValidationErrors(errors)
|
|||
|
|
setValidationWarnings(warnings)
|
|||
|
|
|
|||
|
|
if (errors.length === 0) {
|
|||
|
|
alert(`✅ Workflow ist valide!\n\n${warnings.length} Warnungen`)
|
|||
|
|
} else {
|
|||
|
|
alert(`❌ Validierung fehlgeschlagen!\n\n${errors.length} Fehler, ${warnings.length} Warnungen`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleNew = () => {
|
|||
|
|
if (confirm('Neuen Workflow erstellen? Ungespeicherte Änderungen gehen verloren.')) {
|
|||
|
|
setNodes([])
|
|||
|
|
setEdges([])
|
|||
|
|
setCurrentPrompt(null)
|
|||
|
|
setWorkflowName('Neuer Workflow')
|
|||
|
|
setWorkflowDescription('')
|
|||
|
|
setSelectedNode(null)
|
|||
|
|
navigate('/workflow-editor/new')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleDelete = async () => {
|
|||
|
|
if (!currentPrompt) return
|
|||
|
|
|
|||
|
|
if (!confirm(`Workflow "${workflowName}" wirklich löschen?`)) return
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
setLoading(true)
|
|||
|
|
await api.deletePrompt(currentPrompt.id)
|
|||
|
|
alert('Workflow gelöscht')
|
|||
|
|
navigate('/admin/prompts')
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="workflow-editor">
|
|||
|
|
{/* Toolbar */}
|
|||
|
|
<div className="workflow-toolbar">
|
|||
|
|
<button className="btn-secondary" onClick={() => navigate('/admin/prompts')}>
|
|||
|
|
← Zurück
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={workflowName}
|
|||
|
|
onChange={(e) => setWorkflowName(e.target.value)}
|
|||
|
|
placeholder="Workflow-Name"
|
|||
|
|
style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<button className="btn-secondary" onClick={handleNew}>
|
|||
|
|
Neu
|
|||
|
|
</button>
|
|||
|
|
<button className="btn-secondary" onClick={handleValidate}>
|
|||
|
|
Validieren
|
|||
|
|
</button>
|
|||
|
|
<button className="btn-primary" onClick={handleSave} disabled={loading}>
|
|||
|
|
{loading ? 'Speichern...' : 'Speichern'}
|
|||
|
|
</button>
|
|||
|
|
{currentPrompt && (
|
|||
|
|
<button className="btn-secondary" onClick={handleDelete} disabled={loading}>
|
|||
|
|
Löschen
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{error && (
|
|||
|
|
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white' }}>
|
|||
|
|
❌ {error}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Main Content */}
|
|||
|
|
<div className="workflow-content">
|
|||
|
|
{/* Sidebar */}
|
|||
|
|
<div className="workflow-sidebar">
|
|||
|
|
<div className="sidebar-section">
|
|||
|
|
<h3>Workflow-Knoten</h3>
|
|||
|
|
<div className="node-palette">
|
|||
|
|
<button className="node-palette-button" onClick={() => handleAddNode('start')}>
|
|||
|
|
<span className="icon">🚀</span> Start
|
|||
|
|
</button>
|
|||
|
|
<button className="node-palette-button" onClick={() => handleAddNode('analysis')}>
|
|||
|
|
<span className="icon">🤖</span> Analyse
|
|||
|
|
</button>
|
|||
|
|
<button className="node-palette-button" onClick={() => handleAddNode('logic')}>
|
|||
|
|
<span className="icon">⚡</span> Logik
|
|||
|
|
</button>
|
|||
|
|
<button className="node-palette-button" onClick={() => handleAddNode('join')}>
|
|||
|
|
<span className="icon">🔀</span> Join
|
|||
|
|
</button>
|
|||
|
|
<button className="node-palette-button" onClick={() => handleAddNode('end')}>
|
|||
|
|
<span className="icon">🏁</span> Ende
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{selectedNode && (
|
|||
|
|
<div className="sidebar-section">
|
|||
|
|
<h3>Aktionen</h3>
|
|||
|
|
<button className="btn-secondary btn-full" onClick={handleDeleteNode}>
|
|||
|
|
🗑️ Node löschen
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="sidebar-section">
|
|||
|
|
<h3>Info</h3>
|
|||
|
|
<div style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
|||
|
|
<div>Nodes: {nodes.length}</div>
|
|||
|
|
<div>Edges: {edges.length}</div>
|
|||
|
|
<div>Errors: {validationErrors.length}</div>
|
|||
|
|
<div>Warnings: {validationWarnings.length}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Canvas */}
|
|||
|
|
<div className="workflow-canvas-container">
|
|||
|
|
<WorkflowCanvas
|
|||
|
|
nodes={nodes}
|
|||
|
|
edges={edges}
|
|||
|
|
nodeTypes={nodeTypes}
|
|||
|
|
onNodesChange={onNodesChange}
|
|||
|
|
onEdgesChange={onEdgesChange}
|
|||
|
|
onConnect={onConnect}
|
|||
|
|
onNodeClick={onNodeClick}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Config Panel */}
|
|||
|
|
{selectedNode && (
|
|||
|
|
<div className="workflow-config-panel">
|
|||
|
|
<h2 style={{ margin: '0 0 16px 0' }}>{selectedNode.data.label}</h2>
|
|||
|
|
|
|||
|
|
{/* Basis-Konfiguration */}
|
|||
|
|
<div className="config-section">
|
|||
|
|
<label>Label</label>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={selectedNode.data.label || ''}
|
|||
|
|
onChange={(e) => handleNodeUpdate(selectedNode.id, { label: e.target.value })}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Type-spezifische Konfiguration */}
|
|||
|
|
{selectedNode.type === 'analysis' && (
|
|||
|
|
<>
|
|||
|
|
<QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
|
|||
|
|
<FallbackConfig node={selectedNode} edges={edges} onChange={handleNodeUpdate} />
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{selectedNode.type === 'logic' && (
|
|||
|
|
<>
|
|||
|
|
<LogicExpressionEditor
|
|||
|
|
node={selectedNode}
|
|||
|
|
nodes={nodes}
|
|||
|
|
edges={edges}
|
|||
|
|
onChange={handleNodeUpdate}
|
|||
|
|
/>
|
|||
|
|
<FallbackConfig node={selectedNode} edges={edges} onChange={handleNodeUpdate} />
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{selectedNode.type === 'join' && (
|
|||
|
|
<JoinConfig node={selectedNode} onChange={handleNodeUpdate} />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Validation Panel */}
|
|||
|
|
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
|
|||
|
|
<div className="validation-panel">
|
|||
|
|
{validationErrors.map((err, i) => (
|
|||
|
|
<div key={i} className="validation-error" onClick={() => {
|
|||
|
|
if (err.nodeId) {
|
|||
|
|
const node = nodes.find(n => n.id === err.nodeId)
|
|||
|
|
if (node) setSelectedNode(node)
|
|||
|
|
}
|
|||
|
|
}}>
|
|||
|
|
❌ {err.message}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
{validationWarnings.map((warn, i) => (
|
|||
|
|
<div key={i} className="validation-warning" onClick={() => {
|
|||
|
|
if (warn.nodeId) {
|
|||
|
|
const node = nodes.find(n => n.id === warn.nodeId)
|
|||
|
|
if (node) setSelectedNode(node)
|
|||
|
|
}
|
|||
|
|
}}>
|
|||
|
|
⚠️ {warn.message}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
{validationErrors.length === 0 && validationWarnings.length > 0 && (
|
|||
|
|
<div className="validation-success">
|
|||
|
|
✅ Workflow ist valide ({validationWarnings.length} Warnungen)
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|