mitai-jinkendo/frontend/src/pages/WorkflowEditorPage.jsx

504 lines
18 KiB
React
Raw Normal View History

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 [selectedNodeId, setSelectedNodeId] = useState(null)
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) : 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)
const [availablePrompts, setAvailablePrompts] = useState([])
// Load available basis prompts for Analysis nodes
useEffect(() => {
async function loadPrompts() {
try {
const prompts = await api.listAdminPrompts()
// Filter nur type='base' Prompts
const basisPrompts = prompts.filter(p => p.type === 'base')
setAvailablePrompts(basisPrompts)
} catch (e) {
console.error('Failed to load prompts:', e)
}
}
loadPrompts()
}, [])
// Load workflow wenn ID vorhanden
useEffect(() => {
if (id && id !== 'new') {
console.log('🔍 useEffect: Loading workflow with ID:', id)
loadWorkflow(id) // UUID as string, no parseInt!
}
}, [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) => {
setSelectedNodeId(node.id)
}, [])
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) => {
console.log('🔧 handleNodeUpdate:', { nodeId, updates })
setNodes((nds) => {
const updated = nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...updates } } : n))
console.log('📝 Nodes after update:', updated.find(n => n.id === nodeId))
return updated
})
}
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))
setSelectedNodeId(null)
}
const handleSave = async () => {
console.log('💾 handleSave called')
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'
})
console.log('📊 Serialized graph_data:', graph_data)
if (currentPrompt) {
// Update existing
console.log('📝 Updating existing workflow:', currentPrompt.id)
await api.updateUnifiedPrompt(currentPrompt.id, {
type: 'workflow',
name: workflowName,
description: workflowDescription,
graph_data
})
alert('Workflow gespeichert!')
} else {
// Create new
console.log('✨ Creating new workflow')
const result = await api.createUnifiedPrompt({
type: 'workflow',
name: workflowName,
description: workflowDescription,
graph_data
})
console.log('✅ Workflow created:', result)
setCurrentPrompt({ id: result.id, name: workflowName })
alert('Workflow erstellt!')
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
navigate(`/workflow-editor/${result.id}`)
}
} catch (e) {
console.error('❌ handleSave error:', e)
setError(e.message)
} finally {
setLoading(false)
}
}
const loadWorkflow = async (promptId) => {
console.log('📦 loadWorkflow called with:', promptId)
try {
setLoading(true)
setError(null)
const prompt = await api.getPrompt(promptId)
console.log('✅ Prompt loaded:', prompt)
console.log('📊 graph_data:', prompt.graph_data)
if (prompt.type !== 'workflow') {
throw new Error('Nicht ein Workflow')
}
// Deserialisieren
const { nodes: loadedNodes, edges: loadedEdges } = deserializeFromWorkflowGraph(prompt.graph_data)
console.log('🎯 Deserialized:', { nodes: loadedNodes, edges: loadedEdges })
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
console.log('✅ Workflow loaded successfully, nodes:', loadedNodes.length, 'edges:', loadedEdges.length)
} catch (e) {
console.error('❌ loadWorkflow error:', 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('')
setSelectedNodeId(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 {validationErrors.length > 0 ? `(${validationErrors.length} ⚠️)` : ''}
</button>
<button
className="btn-primary"
onClick={handleSave}
disabled={loading}
title={validationErrors.length > 0
? `Speichern blockiert: ${validationErrors.length} Validierungsfehler`
: 'Workflow in Datenbank speichern'}
>
{loading ? 'Speichern...' : validationErrors.length > 0 ? '🔒 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', borderRadius: '4px', marginBottom: '8px' }}>
❌ {error}
{validationErrors.length > 0 && (
<div style={{ marginTop: 8, fontSize: 12 }}>
Tipp: Behebe die Validierungsfehler unten, um speichern zu können.
</div>
)}
</div>
)}
{/* Main Content */}
<div className="workflow-content">
{/* Sidebar */}
<div className="workflow-sidebar" style={{ display: selectedNode ? 'none' : 'block' }}>
<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">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>Node-Konfiguration</h2>
<button
onClick={() => setSelectedNodeId(null)}
style={{
background: 'none',
border: 'none',
fontSize: 24,
cursor: 'pointer',
color: 'var(--text3)',
padding: 4,
lineHeight: 1
}}
title="Schließen"
>
×
</button>
</div>
{/* Basis-Konfiguration */}
<div className="config-section">
<label>Node-Name</label>
<input
type="text"
value={selectedNode.data.label || ''}
onChange={(e) => handleNodeUpdate(selectedNode.id, { label: e.target.value })}
placeholder="z.B. Gewichtsanalyse"
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)',
fontSize: '14px'
}}
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--text3)' }}>
Änderungen werden automatisch übernommen
</div>
</div>
{/* Type-spezifische Konfiguration */}
{selectedNode.type === 'analysis' && (
<>
<div className="config-section">
<label>KI-Prompt auswählen</label>
<select
value={selectedNode.data.prompt_id ? String(selectedNode.data.prompt_id) : ''}
onChange={(e) => {
const promptId = e.target.value
console.log('🎯 Prompt selected:', promptId, 'Type:', typeof promptId)
const selectedPrompt = availablePrompts.find(p => String(p.id) === promptId)
console.log('📋 Selected prompt object:', selectedPrompt)
handleNodeUpdate(selectedNode.id, {
prompt_id: promptId || null, // UUID as string, no parseInt!
prompt_name: selectedPrompt?.name || null
})
}}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)'
}}
>
<option value="">-- Basis-Prompt wählen --</option>
{availablePrompts.map(prompt => (
<option key={prompt.id} value={String(prompt.id)}>
{prompt.name}
</option>
))}
</select>
{selectedNode.data.prompt_id && (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text3)' }}>
Prompt ID: {selectedNode.data.prompt_id} ({selectedNode.data.prompt_name || 'unbekannt'})
</div>
)}
</div>
<QuestionAugmentationPanel node={selectedNode} onChange={handleNodeUpdate} />
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} onChange={handleNodeUpdate} />
</>
)}
{selectedNode.type === 'logic' && (
<>
<LogicExpressionEditor
node={selectedNode}
nodes={nodes}
edges={edges}
onChange={handleNodeUpdate}
/>
<FallbackConfig node={selectedNode} edges={edges} nodes={nodes} 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) {
setSelectedNodeId(err.nodeId)
}
}}>
❌ {err.message}
</div>
))}
{validationWarnings.map((warn, i) => (
<div key={i} className="validation-warning" onClick={() => {
if (warn.nodeId) {
setSelectedNodeId(warn.nodeId)
}
}}>
⚠️ {warn.message}
</div>
))}
{validationErrors.length === 0 && validationWarnings.length > 0 && (
<div className="validation-success">
✅ Workflow ist valide ({validationWarnings.length} Warnungen)
</div>
)}
</div>
)}
</div>
)
}