Merge pull request 'Workflow 1.1 bug fixes' (#74) from develop into main
Reviewed-on: #74
This commit is contained in:
commit
b5f745fc3e
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export function WorkflowCanvas({
|
|||
className="workflow-canvas"
|
||||
minZoom={0.2}
|
||||
maxZoom={2}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
defaultEdgeOptions={{
|
||||
animated: false,
|
||||
style: { strokeWidth: 2 },
|
||||
|
|
|
|||
246
frontend/src/components/workflow/panels/ValidationPanel.jsx
Normal file
246
frontend/src/components/workflow/panels/ValidationPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user