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)
|
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}")
|
@router.get("/{prompt_id}")
|
||||||
def get_prompt(prompt_id: str, session: dict=Depends(require_auth)):
|
def get_prompt(prompt_id: str, session: dict=Depends(require_auth)):
|
||||||
"""Get single AI prompt by ID (UUID)."""
|
"""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)"
|
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(
|
cur.execute(
|
||||||
"""INSERT INTO ai_prompts (id, name, slug, display_name, description, template, category, active, sort_order, created, updated)
|
"""INSERT INTO ai_prompts (
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""",
|
id, name, slug, display_name, description, template, category,
|
||||||
(new_id, new_name, new_slug, new_display_name, original['description'], original['template'],
|
type, stages, output_format, output_schema,
|
||||||
original.get('category', 'ganzheitlich'), original['active'], original['sort_order'])
|
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}
|
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}
|
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
|
# AUTO mode: Concatenate all analysis_core values
|
||||||
logger.debug(f"End node {node.id}: Using AUTO output mode")
|
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 = []
|
combined_analysis = []
|
||||||
for node_id, node_state in context.get("node_results", {}).items():
|
for node_id, node_state in context.get("node_results", {}).items():
|
||||||
if node_state.status == NodeStatus.EXECUTED and node_state.analysis_core:
|
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]"
|
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
|
final_output = state.analysis_core
|
||||||
else:
|
else:
|
||||||
# Regular node - add to combined analysis
|
# 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:
|
if state.normalized_signals:
|
||||||
all_signals.extend([s.model_dump() for s in 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"
|
className="workflow-canvas"
|
||||||
minZoom={0.2}
|
minZoom={0.2}
|
||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
animated: false,
|
animated: false,
|
||||||
style: { strokeWidth: 2 },
|
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 [showNewPrompt, setShowNewPrompt] = useState(false)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const [importResult, setImportResult] = useState(null)
|
const [importResult, setImportResult] = useState(null)
|
||||||
|
const [importDialogData, setImportDialogData] = useState(null) // {count, fileData, event}
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'all', label: 'Alle Kategorien' },
|
{ id: 'all', label: 'Alle Kategorien' },
|
||||||
|
|
@ -194,7 +195,6 @@ export default function AdminPromptsPage() {
|
||||||
const file = event.target.files[0]
|
const file = event.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
setImporting(true)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setImportResult(null)
|
setImportResult(null)
|
||||||
|
|
||||||
|
|
@ -202,14 +202,34 @@ export default function AdminPromptsPage() {
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
const data = JSON.parse(text)
|
const data = JSON.parse(text)
|
||||||
|
|
||||||
// Ask user about overwrite
|
// Show custom 3-button dialog
|
||||||
const overwrite = confirm(
|
setImportDialogData({
|
||||||
'Bestehende Prompts überschreiben?\n\n' +
|
count: data.count || 0,
|
||||||
'JA = Existierende Prompts aktualisieren\n' +
|
fileData: data,
|
||||||
'NEIN = Nur neue Prompts erstellen, Duplikate überspringen'
|
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)
|
setImportResult(result)
|
||||||
await loadPrompts()
|
await loadPrompts()
|
||||||
} catch (e) {
|
} 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPick
|
||||||
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
|
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
|
||||||
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
|
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
|
||||||
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
|
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
|
||||||
|
import { ValidationPanel } from '../components/workflow/panels/ValidationPanel'
|
||||||
import { Toast } from '../components/Toast'
|
import { Toast } from '../components/Toast'
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||||
import '../styles/workflowEditor.css'
|
import '../styles/workflowEditor.css'
|
||||||
|
|
@ -77,11 +78,24 @@ export default function WorkflowEditorPage() {
|
||||||
const [placeholderPickerTarget, setPlaceholderPickerTarget] = useState('end') // 'end' | 'inline'
|
const [placeholderPickerTarget, setPlaceholderPickerTarget] = useState('end') // 'end' | 'inline'
|
||||||
const endNodeTextareaRef = useRef(null)
|
const endNodeTextareaRef = useRef(null)
|
||||||
const inlineTemplateTextareaRef = useRef(null)
|
const inlineTemplateTextareaRef = useRef(null)
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||||
|
const [showValidationPanel, setShowValidationPanel] = useState(true)
|
||||||
|
|
||||||
// Toast & Confirm Dialog
|
// Toast & Confirm Dialog
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
const [confirmDialog, setConfirmDialog] = 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
|
// Load available basis prompts for Analysis nodes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadPrompts() {
|
async function loadPrompts() {
|
||||||
|
|
@ -110,12 +124,33 @@ export default function WorkflowEditorPage() {
|
||||||
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
|
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
|
||||||
setValidationErrors(errors)
|
setValidationErrors(errors)
|
||||||
setValidationWarnings(warnings)
|
setValidationWarnings(warnings)
|
||||||
|
|
||||||
|
// Re-show validation panel if there are new errors/warnings
|
||||||
|
if (errors.length > 0 || warnings.length > 0) {
|
||||||
|
setShowValidationPanel(true)
|
||||||
|
}
|
||||||
}, [nodes, edges])
|
}, [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 ──────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(params) => setEdges((eds) => addEdge(params, eds)),
|
(params) => {
|
||||||
|
setEdges((eds) => addEdge(params, eds))
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
|
},
|
||||||
[setEdges]
|
[setEdges]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -144,6 +179,7 @@ export default function WorkflowEditorPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setNodes((nds) => [...nds, base])
|
setNodes((nds) => [...nds, base])
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNodeUpdate = (nodeId, updates) => {
|
const handleNodeUpdate = (nodeId, updates) => {
|
||||||
|
|
@ -153,6 +189,7 @@ export default function WorkflowEditorPage() {
|
||||||
console.log('📝 Nodes after update:', updated.find(n => n.id === nodeId))
|
console.log('📝 Nodes after update:', updated.find(n => n.id === nodeId))
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteNode = () => {
|
const handleDeleteNode = () => {
|
||||||
|
|
@ -161,6 +198,7 @@ export default function WorkflowEditorPage() {
|
||||||
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id))
|
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id))
|
||||||
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id))
|
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id))
|
||||||
setSelectedNodeId(null)
|
setSelectedNodeId(null)
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -217,6 +255,9 @@ export default function WorkflowEditorPage() {
|
||||||
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
|
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
|
||||||
navigate(`/workflow-editor/${result.id}`)
|
navigate(`/workflow-editor/${result.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear unsaved changes flag after successful save
|
||||||
|
setHasUnsavedChanges(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('❌ handleSave error:', e)
|
console.error('❌ handleSave error:', e)
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
|
|
@ -275,6 +316,9 @@ export default function WorkflowEditorPage() {
|
||||||
)
|
)
|
||||||
nodeIdCounter = maxId + 1
|
nodeIdCounter = maxId + 1
|
||||||
console.log('✅ Workflow loaded successfully, nodes:', loadedNodes.length, 'edges:', loadedEdges.length)
|
console.log('✅ Workflow loaded successfully, nodes:', loadedNodes.length, 'edges:', loadedEdges.length)
|
||||||
|
|
||||||
|
// Clear unsaved changes flag after successful load
|
||||||
|
setHasUnsavedChanges(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('❌ loadWorkflow error:', e)
|
console.error('❌ loadWorkflow error:', e)
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
|
|
@ -296,14 +340,18 @@ export default function WorkflowEditorPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNew = () => {
|
const handleNew = () => {
|
||||||
if (confirm('Neuen Workflow erstellen? Ungespeicherte Änderungen gehen verloren.')) {
|
if (hasUnsavedChanges) {
|
||||||
setNodes([])
|
if (!confirm('Neuen Workflow erstellen? Ungespeicherte Änderungen gehen verloren.')) {
|
||||||
setEdges([])
|
return
|
||||||
setCurrentPrompt(null)
|
}
|
||||||
setWorkflowName('Neuer Workflow')
|
|
||||||
setSelectedNodeId(null)
|
|
||||||
navigate('/workflow-editor/new')
|
|
||||||
}
|
}
|
||||||
|
setNodes([])
|
||||||
|
setEdges([])
|
||||||
|
setCurrentPrompt(null)
|
||||||
|
setWorkflowName('Neuer Workflow')
|
||||||
|
setSelectedNodeId(null)
|
||||||
|
setHasUnsavedChanges(false)
|
||||||
|
navigate('/workflow-editor/new')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
|
@ -328,6 +376,26 @@ export default function WorkflowEditorPage() {
|
||||||
setExecutionResult(result)
|
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) => {
|
const handlePlaceholderSelect = (placeholderString) => {
|
||||||
if (!selectedNode) return
|
if (!selectedNode) return
|
||||||
|
|
||||||
|
|
@ -392,14 +460,17 @@ export default function WorkflowEditorPage() {
|
||||||
<div className="workflow-editor">
|
<div className="workflow-editor">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="workflow-toolbar">
|
<div className="workflow-toolbar">
|
||||||
<button className="btn-secondary" onClick={() => navigate('/admin/prompts')}>
|
<button className="btn-secondary" onClick={handleBack}>
|
||||||
← Zurück
|
← Zurück
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={workflowName}
|
value={workflowName}
|
||||||
onChange={(e) => setWorkflowName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setWorkflowName(e.target.value)
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
|
}}
|
||||||
placeholder="Interner Workflow-Name (Slug-Basis)"
|
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."
|
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)' }}
|
style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }}
|
||||||
|
|
@ -498,8 +569,8 @@ export default function WorkflowEditorPage() {
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={handleNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={handleEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onNodeClick={onNodeClick}
|
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 Notification */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<Toast
|
<Toast
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,14 @@ function validateStructure(nodes, edges, errors, warnings) {
|
||||||
message: 'Kein END-Node vorhanden',
|
message: 'Kein END-Node vorhanden',
|
||||||
severity: 'error'
|
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
|
// Zyklen-Erkennung
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user