feat: Part 3 - End Node Template Editor
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

**Neue Features:**
- End Node Output Mode: AUTO vs. TEMPLATE
- Jinja2 Template Editor mit Syntax-Beispiel
- Placeholder Picker Modal (dynamische Node-Liste)
- Template Serialisierung/Deserialisierung

**Komponenten (NEU):**
1. EndNodeConfig.jsx (~150 Zeilen)
   - Output Mode Toggle (AUTO/TEMPLATE)
   - Template Textarea (monospace, 12 Zeilen)
   - Placeholder-Button (öffnet Picker)
   - Help-Text mit Beispiel-Syntax
   - Auto-Insert Default Template beim Wechsel zu TEMPLATE

2. PlaceholderPicker.jsx (~260 Zeilen)
   - Modal mit Suchfunktion
   - Dynamische Placeholder-Liste aus Workflow-Nodes
   - Kategorien: Global, Node Outputs, Signals
   - Click-to-Insert (schließt Modal automatisch)
   - Icons pro Node-Typ (🚀🤖🔀🏁)

**Integration:**
- WorkflowEditorPage.jsx
  - EndNodeConfig im Config Panel (wenn type='end')
  - PlaceholderPicker State + Modal
  - handlePlaceholderSelect (fügt in Template ein)

**Serialisierung:**
- workflowSerializer.js
  - Serialize: output_mode + template für End Nodes
  - Deserialize: output_mode + template laden
  - Fallback: auto Mode wenn nicht gesetzt

**Backend Status:**
-  Backend bereits fertig (execute_end_node() in workflow_executor.py)
-  Beide Modi (AUTO/TEMPLATE) funktionieren
-  Jinja2 Template Rendering implementiert

**Part 3 Status:** Frontend Complete
-  End Node Config UI
-  Template Editor
-  Placeholder Picker
- ⏸️ Testing ausstehend

**Nächster Schritt:**
Browser-Test auf dev.mitai.jinkendo.de

Version: v0.9p
Date: 2026-04-09

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-04-09 15:52:19 +02:00
parent 2994df54ad
commit 228010a6d3
5 changed files with 467 additions and 1 deletions

View File

@ -7,7 +7,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
- PATCH: Bugfix, kleine Änderung, Refactor
"""
APP_VERSION = "0.9o"
APP_VERSION = "0.9p"
BUILD_DATE = "2026-04-09"
DB_SCHEMA_VERSION = "20260406e" # Migration 041

View File

@ -0,0 +1,162 @@
/**
* EndNodeConfig - Konfiguration für End Nodes
*
* Props:
* - node: React Flow Node object (type='end')
* - onChange: (nodeId, updates) => void
* - onOpenPlaceholderPicker: () => void (optional, für späteren Placeholder Picker)
*
* Features:
* - Output Mode: AUTO (concatenate all analyses) vs. TEMPLATE (custom Jinja2 template)
* - Template Editor (Textarea für Jinja2 syntax)
*/
export function EndNodeConfig({ node, onChange, onOpenPlaceholderPicker }) {
const outputMode = node.data.output_mode || 'auto'
const template = node.data.template || ''
const handleModeChange = (e) => {
const newMode = e.target.value
onChange(node.id, { output_mode: newMode })
// Wenn zu TEMPLATE gewechselt wird und kein Template vorhanden, Beispiel einfügen
if (newMode === 'template' && !template) {
onChange(node.id, {
output_mode: newMode,
template: '# Finale Analyse\n\n{{ analysis_core }}\n\n---\nGeneriert mit Workflow Engine'
})
}
}
const handleTemplateChange = (e) => {
onChange(node.id, { template: e.target.value })
}
return (
<div className="config-section">
<h3>End Node Konfiguration</h3>
<label>Output-Modus</label>
<select
value={outputMode}
onChange={handleModeChange}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)'
}}
>
<option value="auto">AUTO - Automatisch zusammenfassen</option>
<option value="template">TEMPLATE - Eigenes Template</option>
</select>
<div
className="help-text"
style={{
marginTop: '8px',
fontSize: '12px',
color: 'var(--text3)',
lineHeight: '1.4'
}}
>
{outputMode === 'auto' && (
<>
<strong>AUTO-Modus:</strong> Alle analysis_core Werte werden automatisch
zusammengefasst (backward compatible).
</>
)}
{outputMode === 'template' && (
<>
<strong>TEMPLATE-Modus:</strong> Verwende Jinja2-Syntax für eigenes Output-Format.
Verfügbare Variablen: Alle Node-IDs und deren Outputs.
</>
)}
</div>
{outputMode === 'template' && (
<>
<label style={{ marginTop: '16px' }}>
Jinja2 Template
{onOpenPlaceholderPicker && (
<button
onClick={onOpenPlaceholderPicker}
style={{
marginLeft: '8px',
padding: '4px 8px',
fontSize: '11px',
background: 'var(--accent)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
title="Placeholder-Auswahl öffnen"
>
📋 Platzhalter
</button>
)}
</label>
<textarea
value={template}
onChange={handleTemplateChange}
placeholder="# Finale Analyse&#10;&#10;{{ node_1.analysis_core }}&#10;&#10;{{ node_2.analysis_core }}"
rows={12}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--text1)',
fontFamily: 'monospace',
fontSize: '13px',
lineHeight: '1.6',
resize: 'vertical'
}}
/>
<div
className="help-text"
style={{
marginTop: '8px',
fontSize: '11px',
color: 'var(--text3)'
}}
>
💡 Beispiel-Syntax:
<pre
style={{
marginTop: '4px',
padding: '6px',
background: 'var(--surface2)',
borderRadius: '4px',
fontSize: '10px',
overflow: 'auto'
}}
>
{`{{ node_1.analysis_core }}
{% if node_2.signal_intensity == "hoch" %}
Hohe Intensität erkannt!
{% endif %}`}
</pre>
</div>
</>
)}
<div
className="help-text"
style={{
marginTop: '16px',
fontSize: '11px',
color: 'var(--text3)',
paddingTop: '12px',
borderTop: '1px solid var(--border)'
}}
>
🏁 Part 3: End Node Template Engine
</div>
</div>
)
}

View File

@ -0,0 +1,265 @@
import { useState } from 'react'
/**
* PlaceholderPicker - Modal zur Auswahl von Template-Platzhaltern
*
* Props:
* - nodes: Array of workflow nodes (to extract available placeholders)
* - onSelect: (placeholderString) => void - Callback when placeholder is selected
* - onClose: () => void
*
* Features:
* - Lists all available node outputs
* - Copy to clipboard or insert into template
* - Kategorisiert nach Node-Typ
*/
export function PlaceholderPicker({ nodes, onSelect, onClose }) {
const [searchQuery, setSearchQuery] = useState('')
// Extrahiere verfügbare Platzhalter aus Nodes
const placeholders = extractPlaceholders(nodes)
// Filtere basierend auf Suchquery
const filteredPlaceholders = placeholders.filter(p =>
p.placeholder.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase())
)
const handleSelect = (placeholderString) => {
onSelect(placeholderString)
onClose() // Schließe Modal nach Auswahl
}
return (
<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: 2000
}}
onClick={onClose}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '24px',
maxWidth: '600px',
width: '90%',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px'
}}
>
<h2 style={{ margin: 0, fontSize: '18px' }}>Platzhalter auswählen</h2>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: 'var(--text3)',
padding: '4px 8px',
lineHeight: 1
}}
>
×
</button>
</div>
{/* Search */}
<input
type="text"
placeholder="Suche nach Node oder Variable..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--text1)',
fontSize: '14px',
marginBottom: '16px'
}}
/>
{/* Placeholder List */}
<div
style={{
flex: 1,
overflowY: 'auto',
borderRadius: '8px',
border: '1px solid var(--border)'
}}
>
{filteredPlaceholders.length === 0 ? (
<div
style={{
padding: '24px',
textAlign: 'center',
color: 'var(--text3)',
fontSize: '14px'
}}
>
{searchQuery ? 'Keine Platzhalter gefunden' : 'Keine Nodes im Workflow'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{filteredPlaceholders.map((p, idx) => (
<div
key={idx}
onClick={() => handleSelect(p.placeholder)}
style={{
padding: '12px',
borderBottom: idx < filteredPlaceholders.length - 1 ? '1px solid var(--border)' : 'none',
cursor: 'pointer',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--surface2)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div style={{ flex: 1 }}>
<div
style={{
fontFamily: 'monospace',
fontSize: '13px',
color: 'var(--accent)',
marginBottom: '4px'
}}
>
{p.placeholder}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text3)'
}}
>
{p.description}
</div>
</div>
<div
style={{
fontSize: '20px',
marginLeft: '12px',
color: 'var(--text3)'
}}
>
{p.icon}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div
style={{
marginTop: '16px',
padding: '12px',
background: 'var(--bg)',
borderRadius: '8px',
fontSize: '12px',
color: 'var(--text3)'
}}
>
💡 <strong>Syntax:</strong> <code style={{ background: 'var(--surface2)', padding: '2px 6px', borderRadius: '4px' }}>{'{{ node_id.field }}'}</code>
<br />
Verfügbare Felder: <code>analysis_core</code>, <code>signal_*</code> (wenn Fragen vorhanden)
</div>
</div>
</div>
)
}
/**
* Extrahiert verfügbare Platzhalter aus Workflow-Nodes
*/
function extractPlaceholders(nodes) {
const placeholders = []
// Globale Platzhalter
placeholders.push({
placeholder: '{{ analysis_core }}',
description: 'Finale Analyse (AUTO-Modus Fallback)',
icon: '🏁',
category: 'global'
})
// Node-spezifische Platzhalter
nodes.forEach(node => {
if (node.type === 'end') return // End Node hat keine Outputs
const nodeId = node.id
const label = node.data.label || nodeId
// analysis_core für alle Analysis/Logic/Join Nodes
if (node.type === 'analysis' || node.type === 'logic' || node.type === 'join') {
placeholders.push({
placeholder: `{{ ${nodeId}.analysis_core }}`,
description: `${getNodeIcon(node.type)} ${label} - Hauptausgabe`,
icon: getNodeIcon(node.type),
category: 'node_outputs'
})
}
// Signals für Analysis Nodes mit Fragen
if (node.type === 'analysis' && node.data.questions && node.data.questions.length > 0) {
node.data.questions.forEach(q => {
const questionId = q.id || `q${placeholders.length}`
placeholders.push({
placeholder: `{{ ${nodeId}.signal_${questionId} }}`,
description: `${label} - Signal: ${q.question?.substring(0, 40)}...`,
icon: '📊',
category: 'signals'
})
})
}
})
return placeholders
}
/**
* Node-Typ zu Icon
*/
function getNodeIcon(type) {
const icons = {
start: '🚀',
analysis: '🤖',
logic: '⚡',
join: '🔀',
end: '🏁'
}
return icons[type] || '📦'
}

View File

@ -14,6 +14,8 @@ import { QuestionAugmentationPanel } from '../components/workflow/panels/Questio
import { LogicExpressionEditor } from '../components/workflow/panels/LogicExpressionEditor'
import { FallbackConfig } from '../components/workflow/panels/FallbackConfig'
import { JoinConfig } from '../components/workflow/panels/JoinConfig'
import { EndNodeConfig } from '../components/workflow/panels/EndNodeConfig'
import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPicker'
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
import '../styles/workflowEditor.css'
@ -47,6 +49,7 @@ export default function WorkflowEditorPage() {
const [error, setError] = useState(null)
const [availablePrompts, setAvailablePrompts] = useState([])
const [executionResult, setExecutionResult] = useState(null)
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
// Load available basis prompts for Analysis nodes
useEffect(() => {
@ -257,6 +260,15 @@ export default function WorkflowEditorPage() {
setExecutionResult(result)
}
const handlePlaceholderSelect = (placeholderString) => {
if (!selectedNode || selectedNode.type !== 'end') return
const currentTemplate = selectedNode.data.template || ''
const newTemplate = currentTemplate + placeholderString
handleNodeUpdate(selectedNode.id, { template: newTemplate })
}
// Render
return (
@ -480,6 +492,14 @@ export default function WorkflowEditorPage() {
{selectedNode.type === 'join' && (
<JoinConfig node={selectedNode} onChange={handleNodeUpdate} />
)}
{selectedNode.type === 'end' && (
<EndNodeConfig
node={selectedNode}
onChange={handleNodeUpdate}
onOpenPlaceholderPicker={() => setShowPlaceholderPicker(true)}
/>
)}
</div>
)}
</div>
@ -522,6 +542,15 @@ export default function WorkflowEditorPage() {
onClose={() => setExecutionResult(null)}
/>
)}
{/* Placeholder Picker Modal */}
{showPlaceholderPicker && (
<PlaceholderPicker
nodes={nodes}
onSelect={handlePlaceholderSelect}
onClose={() => setShowPlaceholderPicker(false)}
/>
)}
</div>
)
}

View File

@ -36,6 +36,11 @@ export function serializeToWorkflowGraph(nodes, edges, metadata = {}) {
join_strategy: node.data.join_strategy || 'wait_all',
skip_handling: node.data.skip_handling || 'ignore_skipped',
min_paths: node.data.min_paths || 2
}),
...(node.type === 'end' && {
output_mode: node.data.output_mode || 'auto',
template: node.data.template || null
})
}))
@ -93,6 +98,11 @@ export function deserializeFromWorkflowGraph(jsonbData) {
join_strategy: node.join_strategy || 'wait_all',
skip_handling: node.skip_handling || 'ignore_skipped',
min_paths: node.min_paths || 2
}),
...(node.type === 'end' && {
output_mode: node.output_mode || 'auto',
template: node.template || null
})
}
}))