Merge pull request 'Workflow 1.1 bug fixes' (#74) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m5s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

Reviewed-on: #74
This commit is contained in:
Lars 2026-04-11 15:39:23 +02:00
commit b5f745fc3e
7 changed files with 632 additions and 155 deletions

View File

@ -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 devprod 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 devprod 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
}

View File

@ -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])

View File

@ -39,6 +39,7 @@ export function WorkflowCanvas({
className="workflow-canvas"
minZoom={0.2}
maxZoom={2}
deleteKeyCode={['Backspace', 'Delete']}
defaultEdgeOptions={{
animated: false,
style: { strokeWidth: 2 },

View File

@ -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 (
<div style={{
position: 'fixed',
bottom: 90,
right: 20,
width: 400,
maxHeight: '60vh',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
display: 'flex',
flexDirection: 'column',
zIndex: 100
}}>
{/* Header */}
<div
onClick={() => 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)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
<AlertCircle size={18} color={errors.length > 0 ? 'var(--danger)' : '#f59e0b'} />
<span style={{ fontWeight: 600, fontSize: 14 }}>
Validierung
</span>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{errors.length > 0 && `${errors.length} Fehler`}
{errors.length > 0 && warnings.length > 0 && ', '}
{warnings.length > 0 && `${warnings.length} Warnung${warnings.length > 1 ? 'en' : ''}`}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
onClose && onClose()
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
display: 'flex',
alignItems: 'center'
}}
>
<X size={16} color="var(--text3)" />
</button>
</div>
{/* Content */}
{isExpanded && (
<div style={{
overflowY: 'auto',
maxHeight: 'calc(60vh - 60px)'
}}>
{/* Errors */}
{errors.length > 0 && (
<div>
<div
onClick={() => 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 ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<AlertCircle size={16} color="var(--danger)" />
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--danger)' }}>
Fehler ({errors.length})
</span>
</div>
{showErrors && (
<div>
{errors.map((error, idx) => (
<div
key={idx}
onClick={() => 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'
}}
>
<div style={{
fontSize: 13,
color: 'var(--text1)',
marginBottom: 4
}}>
{error.message}
</div>
{error.nodeId && (
<div style={{
fontSize: 11,
color: 'var(--text3)',
fontFamily: 'monospace'
}}>
Klicken um zu Node zu springen
</div>
)}
{error.type && (
<div style={{
fontSize: 10,
color: 'var(--text3)',
marginTop: 4,
textTransform: 'uppercase'
}}>
{error.type}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div>
<div
onClick={() => 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 ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<AlertTriangle size={16} color="#f59e0b" />
<span style={{ fontWeight: 600, fontSize: 13, color: '#f59e0b' }}>
Warnungen ({warnings.length})
</span>
</div>
{showWarnings && (
<div>
{warnings.map((warning, idx) => (
<div
key={idx}
onClick={() => 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'
}}
>
<div style={{
fontSize: 13,
color: 'var(--text1)',
marginBottom: 4
}}>
{warning.message}
</div>
{warning.nodeId && (
<div style={{
fontSize: 11,
color: 'var(--text3)',
fontFamily: 'monospace'
}}>
Klicken um zu Node zu springen
</div>
)}
{warning.type && (
<div style={{
fontSize: 10,
color: 'var(--text3)',
marginTop: 4,
textTransform: 'uppercase'
}}>
{warning.type}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}

View File

@ -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 && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999
}}>
<div style={{
background: 'var(--bg)',
borderRadius: 12,
padding: 24,
maxWidth: 500,
width: '90%',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)'
}}>
<h3 style={{ marginTop: 0, marginBottom: 16, fontSize: 18 }}>
{importDialogData.count} Prompts importieren?
</h3>
<p style={{ marginBottom: 24, color: 'var(--text2)', lineHeight: 1.5 }}>
Wie sollen existierende Prompts behandelt werden?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<button
className="btn btn-primary"
onClick={() => handleImportChoice('yes')}
style={{ width: '100%' }}
>
Ja, überschreiben
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 4 }}>
Bestehende Prompts aktualisieren
</div>
</button>
<button
className="btn"
onClick={() => handleImportChoice('no')}
style={{ width: '100%' }}
>
Nein, nur neue
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 4 }}>
Nur neue Prompts erstellen, bestehende überspringen
</div>
</button>
<button
className="btn btn-secondary"
onClick={() => handleImportChoice('cancel')}
style={{ width: '100%' }}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -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() {
<div className="workflow-editor">
{/* Toolbar */}
<div className="workflow-toolbar">
<button className="btn-secondary" onClick={() => navigate('/admin/prompts')}>
<button className="btn-secondary" onClick={handleBack}>
Zurück
</button>
<input
type="text"
value={workflowName}
onChange={(e) => 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) && (
<ValidationPanel
errors={validationErrors}
warnings={validationWarnings}
onNodeClick={handleValidationNodeClick}
onClose={() => setShowValidationPanel(false)}
/>
)}
{/* Toast Notification */}
{toast && (
<Toast

View File

@ -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