diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 5e7d76f..8112ece 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -103,6 +103,156 @@ def list_placeholders_endpoint(session: dict=Depends(require_auth)): return get_placeholder_catalog(profile_id) +@router.get("/export-all") +def export_all_prompts(session: dict = Depends(require_admin)): + """ + Export all prompts as JSON array. + Admin only. Used for backup and dev→prod sync. + + IMPORTANT: Must be defined BEFORE /{prompt_id} route to avoid routing conflict. + """ + from datetime import datetime + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug") + prompts = [r2d(row) for row in cur.fetchall()] + + # Convert to export format (clean up DB-specific fields) + export_data = [] + for p in prompts: + export_item = { + 'slug': p['slug'], + 'name': p['name'], + 'display_name': p.get('display_name'), + 'description': p.get('description'), + 'type': p.get('type', 'pipeline'), + 'category': p.get('category', 'ganzheitlich'), + 'template': p.get('template'), + 'stages': p.get('stages'), + 'output_format': p.get('output_format', 'text'), + 'output_schema': p.get('output_schema'), + 'question_augmentations': p.get('question_augmentations'), + 'graph_data': p.get('graph_data'), + 'active': p.get('active', True), + 'sort_order': p.get('sort_order', 0) + } + export_data.append(export_item) + + return { + 'export_date': datetime.now().isoformat(), + 'count': len(export_data), + 'prompts': export_data + } + + +@router.post("/import") +def import_prompts( + data: dict, + overwrite: bool = False, + session: dict = Depends(require_admin) +): + """ + Import prompts from JSON export. + + Args: + data: Export data from /export-all endpoint + overwrite: If true, update existing prompts. If false, skip duplicates. + + Returns: + Summary of import results (created, updated, skipped) + + IMPORTANT: Must be defined BEFORE /{prompt_id} route to avoid routing conflict. + """ + if 'prompts' not in data: + raise HTTPException(400, "Invalid import data: missing 'prompts' key") + + prompts = data['prompts'] + created = 0 + updated = 0 + skipped = 0 + errors = [] + + with get_db() as conn: + cur = get_cursor(conn) + + for p in prompts: + slug = p.get('slug') + if not slug: + errors.append('Prompt without slug skipped') + continue + + # Check if exists + cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (slug,)) + existing = cur.fetchone() + + if existing and not overwrite: + skipped += 1 + continue + + # Prepare JSON fields if present + stages_json = None + if p.get('stages'): + stages_json = json.dumps(p['stages']) if isinstance(p['stages'], list) else p['stages'] + + output_schema_json = None + if p.get('output_schema'): + output_schema_json = json.dumps(p['output_schema']) if isinstance(p['output_schema'], dict) else p['output_schema'] + + question_aug_json = None + if p.get('question_augmentations'): + question_aug_json = json.dumps(p['question_augmentations']) if isinstance(p['question_augmentations'], (dict, list)) else p['question_augmentations'] + + graph_data_json = None + if p.get('graph_data'): + graph_data_json = json.dumps(p['graph_data']) if isinstance(p['graph_data'], dict) else p['graph_data'] + + if existing: + # Update existing + cur.execute(""" + UPDATE ai_prompts SET + name=%s, display_name=%s, description=%s, type=%s, + category=%s, template=%s, stages=%s, output_format=%s, + output_schema=%s, question_augmentations=%s, graph_data=%s, + active=%s, sort_order=%s, + updated=CURRENT_TIMESTAMP + WHERE slug=%s + """, ( + p.get('name'), p.get('display_name'), p.get('description'), + p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), + p.get('template'), stages_json, p.get('output_format', 'text'), + output_schema_json, question_aug_json, graph_data_json, + p.get('active', True), p.get('sort_order', 0), slug + )) + updated += 1 + else: + # Create new + cur.execute(""" + INSERT INTO ai_prompts ( + slug, name, display_name, description, type, category, + template, stages, output_format, output_schema, + question_augmentations, graph_data, + active, sort_order, created, updated + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) + """, ( + slug, p.get('name'), p.get('display_name'), p.get('description'), + p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), + p.get('template'), stages_json, p.get('output_format', 'text'), + output_schema_json, question_aug_json, graph_data_json, + p.get('active', True), p.get('sort_order', 0) + )) + created += 1 + + conn.commit() + + return { + 'success': True, + 'created': created, + 'updated': updated, + 'skipped': skipped, + 'errors': errors if errors else None + } + + @router.get("/{prompt_id}") def get_prompt(prompt_id: str, session: dict=Depends(require_auth)): """Get single AI prompt by ID (UUID).""" @@ -214,11 +364,31 @@ def duplicate_prompt(prompt_id: str, session: dict=Depends(require_admin)): new_display_name = f"{original.get('display_name') or original['name']} (Kopie)" + # Prepare JSONB fields (convert dict/list to JSON string if needed) + stages_json = json.dumps(original['stages']) if original.get('stages') else None + output_schema_json = json.dumps(original['output_schema']) if original.get('output_schema') else None + question_aug_json = json.dumps(original['question_augmentations']) if original.get('question_augmentations') else None + graph_data_json = json.dumps(original['graph_data']) if original.get('graph_data') else None + cur.execute( - """INSERT INTO ai_prompts (id, name, slug, display_name, description, template, category, active, sort_order, created, updated) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""", - (new_id, new_name, new_slug, new_display_name, original['description'], original['template'], - original.get('category', 'ganzheitlich'), original['active'], original['sort_order']) + """INSERT INTO ai_prompts ( + id, name, slug, display_name, description, template, category, + type, stages, output_format, output_schema, + question_augmentations, graph_data, + active, sort_order, created, updated + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, + %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + )""", + (new_id, new_name, new_slug, new_display_name, + original.get('description'), original.get('template'), + original.get('category', 'ganzheitlich'), + original.get('type', 'pipeline'), stages_json, + original.get('output_format', 'text'), output_schema_json, + question_aug_json, graph_data_json, + original.get('active', True), original.get('sort_order', 0)) ) return {"id": new_id, "slug": new_slug, "name": new_name} @@ -1607,132 +1777,3 @@ def update_unified_prompt(prompt_id: str, p: UnifiedPromptUpdate, session: dict ) return {"ok": True} - - -@router.get("/export-all") -def export_all_prompts(session: dict = Depends(require_admin)): - """ - Export all prompts as JSON array. - Admin only. Used for backup and dev→prod sync. - """ - from datetime import datetime - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug") - prompts = [r2d(row) for row in cur.fetchall()] - - # Convert to export format (clean up DB-specific fields) - export_data = [] - for p in prompts: - export_item = { - 'slug': p['slug'], - 'name': p['name'], - 'display_name': p.get('display_name'), - 'description': p.get('description'), - 'type': p.get('type', 'pipeline'), - 'category': p.get('category', 'ganzheitlich'), - 'template': p.get('template'), - 'stages': p.get('stages'), - 'output_format': p.get('output_format', 'text'), - 'output_schema': p.get('output_schema'), - 'active': p.get('active', True), - 'sort_order': p.get('sort_order', 0) - } - export_data.append(export_item) - - return { - 'export_date': datetime.now().isoformat(), - 'count': len(export_data), - 'prompts': export_data - } - - -@router.post("/import") -def import_prompts( - data: dict, - overwrite: bool = False, - session: dict = Depends(require_admin) -): - """ - Import prompts from JSON export. - - Args: - data: Export data from /export-all endpoint - overwrite: If true, update existing prompts. If false, skip duplicates. - - Returns: - Summary of import results (created, updated, skipped) - """ - if 'prompts' not in data: - raise HTTPException(400, "Invalid import data: missing 'prompts' key") - - prompts = data['prompts'] - created = 0 - updated = 0 - skipped = 0 - errors = [] - - with get_db() as conn: - cur = get_cursor(conn) - - for p in prompts: - slug = p.get('slug') - if not slug: - errors.append('Prompt without slug skipped') - continue - - # Check if exists - cur.execute("SELECT id FROM ai_prompts WHERE slug=%s", (slug,)) - existing = cur.fetchone() - - if existing and not overwrite: - skipped += 1 - continue - - # Prepare stages JSON if present - stages_json = None - if p.get('stages'): - stages_json = json.dumps(p['stages']) if isinstance(p['stages'], list) else p['stages'] - - if existing: - # Update existing - cur.execute(""" - UPDATE ai_prompts SET - name=%s, display_name=%s, description=%s, type=%s, - category=%s, template=%s, stages=%s, output_format=%s, - output_schema=%s, active=%s, sort_order=%s, - updated=CURRENT_TIMESTAMP - WHERE slug=%s - """, ( - p.get('name'), p.get('display_name'), p.get('description'), - p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), - p.get('template'), stages_json, p.get('output_format', 'text'), - p.get('output_schema'), p.get('active', True), - p.get('sort_order', 0), slug - )) - updated += 1 - else: - # Create new - cur.execute(""" - INSERT INTO ai_prompts ( - slug, name, display_name, description, type, category, - template, stages, output_format, output_schema, - active, sort_order, created, updated - ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) - """, ( - slug, p.get('name'), p.get('display_name'), p.get('description'), - p.get('type', 'pipeline'), p.get('category', 'ganzheitlich'), - p.get('template'), stages_json, p.get('output_format', 'text'), - p.get('output_schema'), p.get('active', True), p.get('sort_order', 0) - )) - created += 1 - - conn.commit() - - return { - 'success': True, - 'created': created, - 'updated': updated, - 'skipped': skipped, - 'errors': errors if errors else None - } diff --git a/backend/workflow_executor.py b/backend/workflow_executor.py index a516727..e5a7152 100644 --- a/backend/workflow_executor.py +++ b/backend/workflow_executor.py @@ -594,10 +594,20 @@ def execute_end_node( # AUTO mode: Concatenate all analysis_core values logger.debug(f"End node {node.id}: Using AUTO output mode") + # Get graph from context for node label lookup + graph = context.get("graph") + combined_analysis = [] for node_id, node_state in context.get("node_results", {}).items(): if node_state.status == NodeStatus.EXECUTED and node_state.analysis_core: - combined_analysis.append(f"## {node_id}\n{node_state.analysis_core}") + # Get node label from graph (fallback to node_id if not found) + node_label = node_id # default + if graph: + node_obj = next((n for n in graph.nodes if n.id == node_id), None) + if node_obj and hasattr(node_obj, 'data') and node_obj.data: + node_label = node_obj.data.get('label', node_id) + + combined_analysis.append(f"## {node_label}\n{node_state.analysis_core}") final_output = "\n\n".join(combined_analysis) if combined_analysis else "[No analysis generated]" @@ -999,7 +1009,13 @@ def aggregate_results(node_states: List[NodeExecutionState], graph) -> Dict[str, final_output = state.analysis_core else: # Regular node - add to combined analysis - combined_analysis.append(f"## {state.node_id}\n{state.analysis_core}") + # Get node label from graph (fallback to node_id) + node_label = state.node_id # default + node_obj = next((n for n in graph.nodes if n.id == state.node_id), None) + if node_obj and hasattr(node_obj, 'data') and node_obj.data: + node_label = node_obj.data.get('label', state.node_id) + + combined_analysis.append(f"## {node_label}\n{state.analysis_core}") if state.normalized_signals: all_signals.extend([s.model_dump() for s in state.normalized_signals]) diff --git a/frontend/src/components/workflow/WorkflowCanvas.jsx b/frontend/src/components/workflow/WorkflowCanvas.jsx index 447d68c..930f8c4 100644 --- a/frontend/src/components/workflow/WorkflowCanvas.jsx +++ b/frontend/src/components/workflow/WorkflowCanvas.jsx @@ -39,6 +39,7 @@ export function WorkflowCanvas({ className="workflow-canvas" minZoom={0.2} maxZoom={2} + deleteKeyCode={['Backspace', 'Delete']} defaultEdgeOptions={{ animated: false, style: { strokeWidth: 2 }, diff --git a/frontend/src/components/workflow/panels/ValidationPanel.jsx b/frontend/src/components/workflow/panels/ValidationPanel.jsx new file mode 100644 index 0000000..804a3e4 --- /dev/null +++ b/frontend/src/components/workflow/panels/ValidationPanel.jsx @@ -0,0 +1,246 @@ +import { useState } from 'react' +import { AlertCircle, AlertTriangle, ChevronDown, ChevronRight, X } from 'lucide-react' + +/** + * ValidationPanel - Zeigt Fehler und Warnungen mit Details + * + * Features: + * - Aufklappbar (collapsible) + * - Click-to-Jump zu betroffener Node + * - Gruppierung nach Severity + * - Klare visuelle Trennung + */ +export function ValidationPanel({ errors, warnings, onNodeClick, onClose }) { + const [isExpanded, setIsExpanded] = useState(true) + const [showErrors, setShowErrors] = useState(true) + const [showWarnings, setShowWarnings] = useState(true) + + const totalCount = errors.length + warnings.length + + if (totalCount === 0) return null + + const handleItemClick = (item) => { + if (item.nodeId && onNodeClick) { + onNodeClick(item.nodeId) + } + } + + return ( +
+ {/* Header */} +
setIsExpanded(!isExpanded)} + style={{ + padding: '12px 16px', + borderBottom: isExpanded ? '1px solid var(--border)' : 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + cursor: 'pointer', + userSelect: 'none', + background: errors.length > 0 ? 'rgba(216, 90, 48, 0.1)' : 'rgba(255, 193, 7, 0.1)' + }} + > +
+ {isExpanded ? : } + 0 ? 'var(--danger)' : '#f59e0b'} /> + + Validierung + + + {errors.length > 0 && `${errors.length} Fehler`} + {errors.length > 0 && warnings.length > 0 && ', '} + {warnings.length > 0 && `${warnings.length} Warnung${warnings.length > 1 ? 'en' : ''}`} + +
+ +
+ + {/* Content */} + {isExpanded && ( +
+ {/* Errors */} + {errors.length > 0 && ( +
+
setShowErrors(!showErrors)} + style={{ + padding: '8px 16px', + background: 'rgba(216, 90, 48, 0.05)', + display: 'flex', + alignItems: 'center', + gap: 8, + cursor: 'pointer', + userSelect: 'none', + borderBottom: '1px solid var(--border)' + }} + > + {showErrors ? : } + + + Fehler ({errors.length}) + +
+ {showErrors && ( +
+ {errors.map((error, idx) => ( +
handleItemClick(error)} + style={{ + padding: '10px 16px', + borderBottom: idx < errors.length - 1 ? '1px solid var(--border)' : 'none', + cursor: error.nodeId ? 'pointer' : 'default', + background: error.nodeId ? 'transparent' : 'transparent', + transition: 'background 0.15s', + }} + onMouseEnter={(e) => { + if (error.nodeId) e.currentTarget.style.background = 'rgba(216, 90, 48, 0.05)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'transparent' + }} + > +
+ {error.message} +
+ {error.nodeId && ( +
+ → Klicken um zu Node zu springen +
+ )} + {error.type && ( +
+ {error.type} +
+ )} +
+ ))} +
+ )} +
+ )} + + {/* Warnings */} + {warnings.length > 0 && ( +
+
setShowWarnings(!showWarnings)} + style={{ + padding: '8px 16px', + background: 'rgba(255, 193, 7, 0.05)', + display: 'flex', + alignItems: 'center', + gap: 8, + cursor: 'pointer', + userSelect: 'none', + borderBottom: '1px solid var(--border)' + }} + > + {showWarnings ? : } + + + Warnungen ({warnings.length}) + +
+ {showWarnings && ( +
+ {warnings.map((warning, idx) => ( +
handleItemClick(warning)} + style={{ + padding: '10px 16px', + borderBottom: idx < warnings.length - 1 ? '1px solid var(--border)' : 'none', + cursor: warning.nodeId ? 'pointer' : 'default', + background: 'transparent', + transition: 'background 0.15s', + }} + onMouseEnter={(e) => { + if (warning.nodeId) e.currentTarget.style.background = 'rgba(255, 193, 7, 0.05)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'transparent' + }} + > +
+ {warning.message} +
+ {warning.nodeId && ( +
+ → Klicken um zu Node zu springen +
+ )} + {warning.type && ( +
+ {warning.type} +
+ )} +
+ ))} +
+ )} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/AdminPromptsPage.jsx b/frontend/src/pages/AdminPromptsPage.jsx index f4c5845..f364c1b 100644 --- a/frontend/src/pages/AdminPromptsPage.jsx +++ b/frontend/src/pages/AdminPromptsPage.jsx @@ -21,6 +21,7 @@ export default function AdminPromptsPage() { const [showNewPrompt, setShowNewPrompt] = useState(false) const [importing, setImporting] = useState(false) const [importResult, setImportResult] = useState(null) + const [importDialogData, setImportDialogData] = useState(null) // {count, fileData, event} const categories = [ { id: 'all', label: 'Alle Kategorien' }, @@ -194,7 +195,6 @@ export default function AdminPromptsPage() { const file = event.target.files[0] if (!file) return - setImporting(true) setError(null) setImportResult(null) @@ -202,14 +202,34 @@ export default function AdminPromptsPage() { const text = await file.text() const data = JSON.parse(text) - // Ask user about overwrite - const overwrite = confirm( - 'Bestehende Prompts überschreiben?\n\n' + - 'JA = Existierende Prompts aktualisieren\n' + - 'NEIN = Nur neue Prompts erstellen, Duplikate überspringen' - ) + // Show custom 3-button dialog + setImportDialogData({ + count: data.count || 0, + fileData: data, + event: event + }) + } catch (e) { + setError('Import-Fehler: ' + e.message) + event.target.value = '' // Reset file input + } + } - const result = await api.importPrompts(data, overwrite) + const handleImportChoice = async (choice) => { + if (!importDialogData) return + + const { fileData, event } = importDialogData + setImportDialogData(null) // Close dialog + + if (choice === 'cancel') { + event.target.value = '' // Reset file input + return + } + + setImporting(true) + + try { + const overwrite = choice === 'yes' // 'yes' = overwrite, 'no' = skip existing + const result = await api.importPrompts(fileData, overwrite) setImportResult(result) await loadPrompts() } catch (e) { @@ -607,6 +627,70 @@ export default function AdminPromptsPage() { }} /> )} + + {/* Import Dialog - 3 Button Choice */} + {importDialogData && ( +
+
+

+ {importDialogData.count} Prompts importieren? +

+

+ Wie sollen existierende Prompts behandelt werden? +

+ +
+ + + + + +
+
+
+ )} ) } diff --git a/frontend/src/pages/WorkflowEditorPage.jsx b/frontend/src/pages/WorkflowEditorPage.jsx index 42a875a..35392d8 100644 --- a/frontend/src/pages/WorkflowEditorPage.jsx +++ b/frontend/src/pages/WorkflowEditorPage.jsx @@ -19,6 +19,7 @@ import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPick import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel' import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer' import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor' +import { ValidationPanel } from '../components/workflow/panels/ValidationPanel' import { Toast } from '../components/Toast' import { ConfirmDialog } from '../components/ConfirmDialog' import '../styles/workflowEditor.css' @@ -77,11 +78,24 @@ export default function WorkflowEditorPage() { const [placeholderPickerTarget, setPlaceholderPickerTarget] = useState('end') // 'end' | 'inline' const endNodeTextareaRef = useRef(null) const inlineTemplateTextareaRef = useRef(null) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [showValidationPanel, setShowValidationPanel] = useState(true) // Toast & Confirm Dialog const [toast, setToast] = useState(null) const [confirmDialog, setConfirmDialog] = useState(null) + // Wrapped handlers to track unsaved changes + const handleNodesChange = useCallback((changes) => { + onNodesChange(changes) + setHasUnsavedChanges(true) + }, [onNodesChange]) + + const handleEdgesChange = useCallback((changes) => { + onEdgesChange(changes) + setHasUnsavedChanges(true) + }, [onEdgesChange]) + // Load available basis prompts for Analysis nodes useEffect(() => { async function loadPrompts() { @@ -110,12 +124,33 @@ export default function WorkflowEditorPage() { const { errors, warnings } = validateWorkflowGraph(nodes, edges) setValidationErrors(errors) setValidationWarnings(warnings) + + // Re-show validation panel if there are new errors/warnings + if (errors.length > 0 || warnings.length > 0) { + setShowValidationPanel(true) + } }, [nodes, edges]) + // Warn on browser back/refresh if unsaved changes + useEffect(() => { + const handleBeforeUnload = (e) => { + if (hasUnsavedChanges) { + e.preventDefault() + e.returnValue = '' // Chrome requires returnValue to be set + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [hasUnsavedChanges]) + // ── Handlers ────────────────────────────────────────────────────────────── const onConnect = useCallback( - (params) => setEdges((eds) => addEdge(params, eds)), + (params) => { + setEdges((eds) => addEdge(params, eds)) + setHasUnsavedChanges(true) + }, [setEdges] ) @@ -144,6 +179,7 @@ export default function WorkflowEditorPage() { } } setNodes((nds) => [...nds, base]) + setHasUnsavedChanges(true) } const handleNodeUpdate = (nodeId, updates) => { @@ -153,6 +189,7 @@ export default function WorkflowEditorPage() { console.log('📝 Nodes after update:', updated.find(n => n.id === nodeId)) return updated }) + setHasUnsavedChanges(true) } const handleDeleteNode = () => { @@ -161,6 +198,7 @@ export default function WorkflowEditorPage() { setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)) setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id)) setSelectedNodeId(null) + setHasUnsavedChanges(true) } const handleSave = async () => { @@ -217,6 +255,9 @@ export default function WorkflowEditorPage() { console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`) navigate(`/workflow-editor/${result.id}`) } + + // Clear unsaved changes flag after successful save + setHasUnsavedChanges(false) } catch (e) { console.error('❌ handleSave error:', e) setError(e.message) @@ -275,6 +316,9 @@ export default function WorkflowEditorPage() { ) nodeIdCounter = maxId + 1 console.log('✅ Workflow loaded successfully, nodes:', loadedNodes.length, 'edges:', loadedEdges.length) + + // Clear unsaved changes flag after successful load + setHasUnsavedChanges(false) } catch (e) { console.error('❌ loadWorkflow error:', e) setError(e.message) @@ -296,14 +340,18 @@ export default function WorkflowEditorPage() { } const handleNew = () => { - if (confirm('Neuen Workflow erstellen? Ungespeicherte Änderungen gehen verloren.')) { - setNodes([]) - setEdges([]) - setCurrentPrompt(null) - setWorkflowName('Neuer Workflow') - setSelectedNodeId(null) - navigate('/workflow-editor/new') + if (hasUnsavedChanges) { + if (!confirm('Neuen Workflow erstellen? Ungespeicherte Änderungen gehen verloren.')) { + return + } } + setNodes([]) + setEdges([]) + setCurrentPrompt(null) + setWorkflowName('Neuer Workflow') + setSelectedNodeId(null) + setHasUnsavedChanges(false) + navigate('/workflow-editor/new') } const handleDelete = async () => { @@ -328,6 +376,26 @@ export default function WorkflowEditorPage() { setExecutionResult(result) } + const handleBack = () => { + if (hasUnsavedChanges) { + const confirm = window.confirm( + 'Du hast ungespeicherte Änderungen.\n\n' + + 'Möchtest du wirklich zurück gehen? Alle Änderungen gehen verloren.' + ) + if (!confirm) return + } + navigate('/admin/prompts') + } + + const handleValidationNodeClick = (nodeId) => { + // Select the node to show its config panel + setSelectedNodeId(nodeId) + + // TODO: Optional - scroll to node in canvas + // ReactFlow doesn't expose a direct scrollTo API, but we could use fitView + // or manual DOM manipulation if needed + } + const handlePlaceholderSelect = (placeholderString) => { if (!selectedNode) return @@ -392,14 +460,17 @@ export default function WorkflowEditorPage() {
{/* Toolbar */}
- setWorkflowName(e.target.value)} + onChange={(e) => { + setWorkflowName(e.target.value) + setHasUnsavedChanges(true) + }} placeholder="Interner Workflow-Name (Slug-Basis)" title="Technischer Name in der Datenbank. Den sichtbaren Titel für die KI-Analyse setzt du in der Start-Node." style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }} @@ -498,8 +569,8 @@ export default function WorkflowEditorPage() { nodes={nodes} edges={edges} nodeTypes={nodeTypes} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} + onNodesChange={handleNodesChange} + onEdgesChange={handleEdgesChange} onConnect={onConnect} onNodeClick={onNodeClick} /> @@ -815,6 +886,16 @@ export default function WorkflowEditorPage() { /> )} + {/* Validation Panel */} + {showValidationPanel && (validationErrors.length > 0 || validationWarnings.length > 0) && ( + setShowValidationPanel(false)} + /> + )} + {/* Toast Notification */} {toast && ( 1) { + // Backend unterstützt aktuell nur 1 End-Node (aggregate_results nimmt letzten) + // Future: Multi-Exit Support würde Backend-Anpassung erfordern + errors.push({ + type: 'structure', + message: `${endNodes.length} END-Nodes gefunden (max. 1 erlaubt)`, + severity: 'error' + }) } // Zyklen-Erkennung