From ba773e677b34b08737722bc6f3d9c12d0b790044 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 14:15:57 +0200 Subject: [PATCH 1/8] fix(workflow): Test-Suite Fixes - Issues #5, #8, #9, #11, #12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addressed test results from Test_status_Wkf.md: **Issue #5: End-Node Überschriften** - Fixed aggregate_results to show node labels instead of "Node 10" - Added graph lookup to get node.data.label from node objects - Modified backend/workflow_executor.py (2 locations) **Issue #8: Löschen-Taste funktioniert nicht** - Added Delete key support to WorkflowCanvas - Set deleteKeyCode={['Backspace', 'Delete']} - Frontend: WorkflowCanvas.jsx **Issue #9: Mehrere End-Nodes verhindern** - Added validation error when multiple End-Nodes exist - Backend supports only 1 End-Node (aggregate_results takes last) - Frontend: workflowValidation.js **Issue #11: Export Fehler "Internal Server Error"** - Added missing fields to export-all endpoint: - graph_data (workflow node graph) - question_augmentations (analysis prompts) - Added missing fields to import endpoint - Proper JSON serialization for all JSONB fields - Backend: routers/prompts.py **Issue #12: Workflow duplizieren funktioniert nicht** - Fixed duplicate endpoint to include all prompt fields: - type, stages, output_format, output_schema - question_augmentations, graph_data (critical for workflows!) - Backend: routers/prompts.py Files changed: - backend/workflow_executor.py: Node label lookup in aggregate_results - backend/routers/prompts.py: Export/import/duplicate fixes - frontend/src/components/workflow/WorkflowCanvas.jsx: Delete key - frontend/src/utils/workflowValidation.js: Max 1 End-Node validation Co-Authored-By: Claude Opus 4.6 --- backend/routers/prompts.py | 51 +++++++++++++++---- backend/workflow_executor.py | 17 ++++++- .../components/workflow/WorkflowCanvas.jsx | 1 + frontend/src/utils/workflowValidation.js | 8 +++ 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 5e7d76f..2303de2 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -215,10 +215,24 @@ def duplicate_prompt(prompt_id: str, session: dict=Depends(require_admin)): new_display_name = f"{original.get('display_name') or original['name']} (Kopie)" 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'), original.get('stages'), + original.get('output_format', 'text'), original.get('output_schema'), + original.get('question_augmentations'), original.get('graph_data'), + original.get('active', True), original.get('sort_order', 0)) ) return {"id": new_id, "slug": new_slug, "name": new_name} @@ -1635,6 +1649,8 @@ def export_all_prompts(session: dict = Depends(require_admin)): '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) } @@ -1689,26 +1705,39 @@ def import_prompts( skipped += 1 continue - # Prepare stages JSON if present + # 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, active=%s, sort_order=%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'), - p.get('output_schema'), p.get('active', True), - p.get('sort_order', 0), slug + output_schema_json, question_aug_json, graph_data_json, + p.get('active', True), p.get('sort_order', 0), slug )) updated += 1 else: @@ -1717,13 +1746,15 @@ def import_prompts( 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,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) + ) 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'), - p.get('output_schema'), p.get('active', True), p.get('sort_order', 0) + output_schema_json, question_aug_json, graph_data_json, + p.get('active', True), p.get('sort_order', 0) )) created += 1 diff --git a/backend/workflow_executor.py b/backend/workflow_executor.py index a516727..8809032 100644 --- a/backend/workflow_executor.py +++ b/backend/workflow_executor.py @@ -597,7 +597,14 @@ def execute_end_node( 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 +1006,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/utils/workflowValidation.js b/frontend/src/utils/workflowValidation.js index 67011ad..13aa1fc 100644 --- a/frontend/src/utils/workflowValidation.js +++ b/frontend/src/utils/workflowValidation.js @@ -49,6 +49,14 @@ function validateStructure(nodes, edges, errors, warnings) { message: 'Kein END-Node vorhanden', severity: 'error' }) + } else if (endNodes.length > 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 From 3b7f89a2149f132fdc244a3c2c68f12db0abb8b3 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 14:28:19 +0200 Subject: [PATCH 2/8] fix(workflow): UnboundLocalError in execute_end_node - graph not defined Critical bug fix from pytest failures: **Problem:** - execute_end_node() tried to use 'graph' variable without defining it - UnboundLocalError at line 602: "if graph:" - Caused 2 test failures in test_end_node_template.py **Root Cause:** - In Issue #5 fix, added graph lookup for node labels in AUTO mode - But forgot to get graph from context first - TEMPLATE mode already had: graph = context.get("graph") **Fix:** - Added: graph = context.get("graph") at start of AUTO mode block - Same pattern as TEMPLATE mode - graph is optional (None if not in context), so if-check is safe **Tests:** - test_auto_mode_concatenates_all_analyses - should pass now - test_auto_mode_skips_skipped_nodes - should pass now Files changed: - backend/workflow_executor.py: Added graph = context.get("graph") line 596 Co-Authored-By: Claude Opus 4.6 --- backend/workflow_executor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/workflow_executor.py b/backend/workflow_executor.py index 8809032..e5a7152 100644 --- a/backend/workflow_executor.py +++ b/backend/workflow_executor.py @@ -594,6 +594,9 @@ 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: From ff8104a533099e8aff453a0ca8d4cecdeadf44ad Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 14:42:55 +0200 Subject: [PATCH 3/8] fix(workflow): Route precedence - move export/import before path param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Root Cause:** - FastAPI route matching: /{prompt_id} caught ALL requests including /export-all - Specific routes MUST be defined BEFORE path parameter routes **Error:** ``` psycopg2.errors.InvalidTextRepresentation: invalid input syntax for type uuid: "export-all" LINE 1: SELECT * FROM ai_prompts WHERE id='export-all' ``` **Fix:** - Moved /export-all and /import endpoints to line 106 (BEFORE /{prompt_id} at ~260) - Added warning comments to both functions - Fixed typo: for r in → for row in **Affected:** - /export-all: Internal Server Error → now works ✅ - /import: Would have had same issue → preemptively fixed ✅ Files changed: - backend/routers/prompts.py: Reordered route definitions Co-Authored-By: Claude Opus 4.6 --- backend/routers/prompts.py | 296 +++++++++++++++++++------------------ 1 file changed, 150 insertions(+), 146 deletions(-) diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 2303de2..94d4c27 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).""" @@ -1621,149 +1771,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'), - '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) - """ - 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 - } From 10d24bbef70b60ccaec4c1ca5cf2d1d8d96c2ed9 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 14:46:13 +0200 Subject: [PATCH 4/8] fix(workflow): Duplicate - JSON-encode JSONB fields **Error:** ``` psycopg2.ProgrammingError: can't adapt type 'dict' ``` **Root Cause:** - duplicate_prompt passed Python dicts directly to SQL INSERT - JSONB fields from r2d() are already deserialized by psycopg2 - PostgreSQL expects JSON strings for JSONB columns **Fix:** - Added json.dumps() for all JSONB fields before INSERT: - stages, output_schema, question_augmentations, graph_data - Same pattern as import function Files changed: - backend/routers/prompts.py: JSON-encode JSONB in duplicate_prompt Co-Authored-By: Claude Opus 4.6 --- backend/routers/prompts.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 94d4c27..8112ece 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -364,6 +364,12 @@ 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, @@ -379,9 +385,9 @@ def duplicate_prompt(prompt_id: str, session: dict=Depends(require_admin)): (new_id, new_name, new_slug, new_display_name, original.get('description'), original.get('template'), original.get('category', 'ganzheitlich'), - original.get('type', 'pipeline'), original.get('stages'), - original.get('output_format', 'text'), original.get('output_schema'), - original.get('question_augmentations'), original.get('graph_data'), + 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)) ) From f3a61091c7b84f0e219ad28ee5fb0455dc2b98ca Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 14:54:53 +0200 Subject: [PATCH 5/8] fix: Import confirmation UX - two-step process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-Prompts Import hatte unklare Abbrechen-Logik: - Nutzer erwartete: Abbrechen = Import komplett abbrechen - Vorher: Abbrechen = overwrite=false, Import lief weiter Lösung: Zwei-Schritt-Bestätigung 1. "X Prompts importieren?" → Abbrechen = kompletter Abbruch 2. "Existierende überschreiben?" → Abbrechen = nur neue importieren UX: Klare Trennung zwischen "Import abbrechen" und "Modus wählen" Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/AdminPromptsPage.jsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/AdminPromptsPage.jsx b/frontend/src/pages/AdminPromptsPage.jsx index f4c5845..66b7edf 100644 --- a/frontend/src/pages/AdminPromptsPage.jsx +++ b/frontend/src/pages/AdminPromptsPage.jsx @@ -202,11 +202,25 @@ export default function AdminPromptsPage() { const text = await file.text() const data = JSON.parse(text) - // Ask user about overwrite + // Two-step confirmation for clarity + // Step 1: Confirm import + const shouldImport = confirm( + `${data.count || 0} Prompts importieren?\n\n` + + 'OK = Fortfahren\n' + + 'Abbrechen = Import abbrechen' + ) + + if (!shouldImport) { + setImporting(false) + event.target.value = '' + return + } + + // Step 2: Ask about overwrite const overwrite = confirm( - 'Bestehende Prompts überschreiben?\n\n' + - 'JA = Existierende Prompts aktualisieren\n' + - 'NEIN = Nur neue Prompts erstellen, Duplikate überspringen' + 'Existierende Prompts überschreiben?\n\n' + + 'OK = Ja, bestehende Prompts aktualisieren\n' + + 'Abbrechen = Nein, nur neue Prompts erstellen' ) const result = await api.importPrompts(data, overwrite) From c9357d4c0eecb1f8454da9acd3cb616f63659333 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 14:57:48 +0200 Subject: [PATCH 6/8] feat: Import Dialog mit 3 Buttons (Ja/Nein/Abbrechen) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt zwei aufeinanderfolgende confirm()-Dialoge durch einen Custom Dialog mit drei klaren Optionen: - "Ja, überschreiben" → bestehende Prompts aktualisieren - "Nein, nur neue" → existierende überspringen - "Abbrechen" → Import komplett abbrechen UX-Verbesserung: - Alle Optionen auf einen Blick sichtbar - Kein Raten mehr was "OK" oder "Abbrechen" bedeutet - Klare Beschreibungstexte unter jedem Button - Vollbildschirm-Modal mit Overlay Technisch: - importDialogData State für Dialog-Daten - handleImportChoice verarbeitet yes/no/cancel - Custom Modal-JSX statt Browser confirm() Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/AdminPromptsPage.jsx | 110 +++++++++++++++++++----- 1 file changed, 90 insertions(+), 20 deletions(-) diff --git a/frontend/src/pages/AdminPromptsPage.jsx b/frontend/src/pages/AdminPromptsPage.jsx index 66b7edf..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,28 +202,34 @@ export default function AdminPromptsPage() { const text = await file.text() const data = JSON.parse(text) - // Two-step confirmation for clarity - // Step 1: Confirm import - const shouldImport = confirm( - `${data.count || 0} Prompts importieren?\n\n` + - 'OK = Fortfahren\n' + - 'Abbrechen = Import abbrechen' - ) + // 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 + } + } - if (!shouldImport) { - setImporting(false) - event.target.value = '' - return - } + const handleImportChoice = async (choice) => { + if (!importDialogData) return - // Step 2: Ask about overwrite - const overwrite = confirm( - 'Existierende Prompts überschreiben?\n\n' + - 'OK = Ja, bestehende Prompts aktualisieren\n' + - 'Abbrechen = Nein, nur neue Prompts erstellen' - ) + const { fileData, event } = importDialogData + setImportDialogData(null) // Close dialog - const result = await api.importPrompts(data, overwrite) + 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) { @@ -621,6 +627,70 @@ export default function AdminPromptsPage() { }} /> )} + + {/* Import Dialog - 3 Button Choice */} + {importDialogData && ( +
+
+

+ {importDialogData.count} Prompts importieren? +

+

+ Wie sollen existierende Prompts behandelt werden? +

+ +
+ + + + + +
+
+
+ )} ) } From 3fa01dd68660c32138b870bc47e69a666033cb77 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 15:21:31 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20Warnung=20bei=20ungespeicherten=20W?= =?UTF-8?q?orkflow-=C3=84nderungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #0: Ungespeicherte Änderungen gehen verloren beim "Zurück"-Klick Implementiert: - hasUnsavedChanges State tracking - Warnung beim "Zurück"-Button (navigate zu /admin/prompts) - Warnung beim "Neu"-Button (nur wenn unsaved changes) - Browser beforeunload Event (warnt bei Browser-Back/Refresh) Tracking für alle Änderungen: - onNodesChange/onEdgesChange (Node-Bewegung, Löschen via Delete-Taste) - onConnect (neue Edges) - handleAddNode (Node hinzufügen) - handleNodeUpdate (Node-Daten ändern) - handleDeleteNode (Node löschen via Button) - workflowName onChange (Titel ändern) Flag wird cleared: - Nach erfolgreichem Save (Update/Create) - Nach erfolgreichem Load - Bei "Neu" (nach User-Bestätigung) UX: - Klare Warnung: "Du hast ungespeicherte Änderungen" - Kein Datenverlust mehr durch versehentliches Zurück - Browser warnt auch bei Refresh/Close Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/WorkflowEditorPage.jsx | 79 +++++++++++++++++++---- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/WorkflowEditorPage.jsx b/frontend/src/pages/WorkflowEditorPage.jsx index 42a875a..fb1f4a5 100644 --- a/frontend/src/pages/WorkflowEditorPage.jsx +++ b/frontend/src/pages/WorkflowEditorPage.jsx @@ -77,11 +77,23 @@ export default function WorkflowEditorPage() { const [placeholderPickerTarget, setPlaceholderPickerTarget] = useState('end') // 'end' | 'inline' const endNodeTextareaRef = useRef(null) const inlineTemplateTextareaRef = useRef(null) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) // 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() { @@ -112,10 +124,26 @@ export default function WorkflowEditorPage() { setValidationWarnings(warnings) }, [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 +172,7 @@ export default function WorkflowEditorPage() { } } setNodes((nds) => [...nds, base]) + setHasUnsavedChanges(true) } const handleNodeUpdate = (nodeId, updates) => { @@ -153,6 +182,7 @@ export default function WorkflowEditorPage() { console.log('📝 Nodes after update:', updated.find(n => n.id === nodeId)) return updated }) + setHasUnsavedChanges(true) } const handleDeleteNode = () => { @@ -161,6 +191,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 +248,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 +309,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 +333,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 +369,17 @@ 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 handlePlaceholderSelect = (placeholderString) => { if (!selectedNode) return @@ -392,14 +444,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 +553,8 @@ export default function WorkflowEditorPage() { nodes={nodes} edges={edges} nodeTypes={nodeTypes} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} + onNodesChange={handleNodesChange} + onEdgesChange={handleEdgesChange} onConnect={onConnect} onNodeClick={onNodeClick} /> From 549c31431e1e0fcdd55bee3a0f66602cb7b35350 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 15:27:14 +0200 Subject: [PATCH 8/8] feat: Validierungs-Panel mit Details und Click-to-Jump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1 + #3: Validierung zeigt keine Details / Fehler-Lokalisierung NEU: ValidationPanel Component - Zeigt alle Fehler und Warnungen mit vollständigen Details - Gruppiert nach Severity (Fehler/Warnungen) - Aufklappbar pro Gruppe (collapsible) - Click-to-Jump: Fehler mit nodeId sind klickbar - Klick selektiert betroffene Node → Config-Panel öffnet sich - Schließbar, öffnet sich automatisch bei neuen Fehler/Warnungen Features: - Fixed position (bottom-right, über Canvas) - Farb-Kodierung: Rot (Errors) / Gelb (Warnings) - Hover-Effekt auf klickbare Items - Type-Labels (structure, isolation, etc.) - Scrollbar bei vielen Fehler/Warnungen - X-Button zum Schließen UX-Verbesserungen: - Kein Raten mehr: Zeigt WAS der Fehler ist - Kein Suchen mehr: Klick springt direkt zur Node - Übersichtlich auch bei vielen Fehler/Warnungen - Funktioniert in großen Workflows Technisch: - handleValidationNodeClick: setSelectedNodeId - Auto-show bei errors.length > 0 || warnings.length > 0 - State: showValidationPanel (closable) Co-Authored-By: Claude Opus 4.6 --- .../workflow/panels/ValidationPanel.jsx | 246 ++++++++++++++++++ frontend/src/pages/WorkflowEditorPage.jsx | 26 ++ 2 files changed, 272 insertions(+) create mode 100644 frontend/src/components/workflow/panels/ValidationPanel.jsx 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/WorkflowEditorPage.jsx b/frontend/src/pages/WorkflowEditorPage.jsx index fb1f4a5..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' @@ -78,6 +79,7 @@ export default function WorkflowEditorPage() { 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) @@ -122,6 +124,11 @@ 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 @@ -380,6 +387,15 @@ export default function WorkflowEditorPage() { 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 @@ -870,6 +886,16 @@ export default function WorkflowEditorPage() { /> )} + {/* Validation Panel */} + {showValidationPanel && (validationErrors.length > 0 || validationWarnings.length > 0) && ( + setShowValidationPanel(false)} + /> + )} + {/* Toast Notification */} {toast && (