feat: Part 3 - End Node Template Editor
**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:
parent
2994df54ad
commit
228010a6d3
|
|
@ -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
|
||||
|
||||
|
|
|
|||
162
frontend/src/components/workflow/panels/EndNodeConfig.jsx
Normal file
162
frontend/src/components/workflow/panels/EndNodeConfig.jsx
Normal 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 {{ node_1.analysis_core }} {{ 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>
|
||||
)
|
||||
}
|
||||
265
frontend/src/components/workflow/panels/PlaceholderPicker.jsx
Normal file
265
frontend/src/components/workflow/panels/PlaceholderPicker.jsx
Normal 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] || '📦'
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user