diff --git a/backend/version.py b/backend/version.py
index 14cd65c..d29eab6 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -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
diff --git a/frontend/src/components/workflow/panels/EndNodeConfig.jsx b/frontend/src/components/workflow/panels/EndNodeConfig.jsx
new file mode 100644
index 0000000..fcaedf6
--- /dev/null
+++ b/frontend/src/components/workflow/panels/EndNodeConfig.jsx
@@ -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 (
+
+
End Node Konfiguration
+
+
+
+
+
+ {outputMode === 'auto' && (
+ <>
+ AUTO-Modus: Alle analysis_core Werte werden automatisch
+ zusammengefasst (backward compatible).
+ >
+ )}
+ {outputMode === 'template' && (
+ <>
+ TEMPLATE-Modus: Verwende Jinja2-Syntax für eigenes Output-Format.
+ Verfügbare Variablen: Alle Node-IDs und deren Outputs.
+ >
+ )}
+
+
+ {outputMode === 'template' && (
+ <>
+
+
+
+
+ 💡 Beispiel-Syntax:
+
+{`{{ node_1.analysis_core }}
+{% if node_2.signal_intensity == "hoch" %}
+ Hohe Intensität erkannt!
+{% endif %}`}
+
+
+ >
+ )}
+
+
+ 🏁 Part 3: End Node Template Engine
+
+
+ )
+}
diff --git a/frontend/src/components/workflow/panels/PlaceholderPicker.jsx b/frontend/src/components/workflow/panels/PlaceholderPicker.jsx
new file mode 100644
index 0000000..2568a99
--- /dev/null
+++ b/frontend/src/components/workflow/panels/PlaceholderPicker.jsx
@@ -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 (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
Platzhalter auswählen
+
+
+
+ {/* Search */}
+
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 */}
+
+ {filteredPlaceholders.length === 0 ? (
+
+ {searchQuery ? 'Keine Platzhalter gefunden' : 'Keine Nodes im Workflow'}
+
+ ) : (
+
+ {filteredPlaceholders.map((p, idx) => (
+
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'}
+ >
+
+
+
+ {p.placeholder}
+
+
+ {p.description}
+
+
+
+ {p.icon}
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Footer */}
+
+ 💡 Syntax: {'{{ node_id.field }}'}
+
+ Verfügbare Felder: analysis_core, signal_* (wenn Fragen vorhanden)
+
+
+
+ )
+}
+
+/**
+ * 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] || '📦'
+}
diff --git a/frontend/src/pages/WorkflowEditorPage.jsx b/frontend/src/pages/WorkflowEditorPage.jsx
index 0aae0c2..4325da8 100644
--- a/frontend/src/pages/WorkflowEditorPage.jsx
+++ b/frontend/src/pages/WorkflowEditorPage.jsx
@@ -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' && (
)}
+
+ {selectedNode.type === 'end' && (
+ setShowPlaceholderPicker(true)}
+ />
+ )}
)}
@@ -522,6 +542,15 @@ export default function WorkflowEditorPage() {
onClose={() => setExecutionResult(null)}
/>
)}
+
+ {/* Placeholder Picker Modal */}
+ {showPlaceholderPicker && (
+ setShowPlaceholderPicker(false)}
+ />
+ )}
)
}
diff --git a/frontend/src/utils/workflowSerializer.js b/frontend/src/utils/workflowSerializer.js
index f84453c..008f2e9 100644
--- a/frontend/src/utils/workflowSerializer.js
+++ b/frontend/src/utils/workflowSerializer.js
@@ -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
})
}
}))