Critical Bug Fixes:
1. Prompt IDs are UUIDs (strings), NOT numbers
2. parseInt(UUID) produces wrong results:
- parseInt("3b4d7d64-...") = 3 (truncates at first non-digit)
- parseInt("aa291dde-...") = NaN
3. This caused:
- Prompt selection: saved as NaN instead of UUID
- Load workflow: GET /api/prompts/3 instead of /api/prompts/3b4d7d64-...
- 405 Method Not Allowed errors
Changes:
- useEffect: loadWorkflow(id) instead of loadWorkflow(parseInt(id))
- Prompt onChange: prompt_id: promptId (string) instead of parseInt(promptId)
- Removed NaN check (unnecessary for UUID strings)
Root Cause: Backend uses UUID primary keys, frontend assumed integer IDs
Testing: Console logs still active for verification
504 lines
18 KiB
JavaScript
504 lines
18 KiB
JavaScript
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>
|
||
)
|
||
}
|