fix: Part 3 - PlaceholderPicker enhancements (4 critical fixes)
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

**User Feedback Issues Fixed:**

1. **Registrierte Platzhalter fehlten** 
   - PlaceholderPicker lädt jetzt ~120+ Platzhalter vom Backend
   - Endpoint: GET /api/prompts/placeholders (get_placeholder_catalog)
   - Kategorisiert nach: Profil, Körper, Ernährung, Training, etc.
   - Icons pro Kategorie (👤 💪 🍎 🏃 😴 ❤️ 🎯)

2. **Node-Namen nicht sichtbar** 
   - Vorher: "{{ node_2.analysis_core }}"
   - Jetzt: "Körper-Analyse (node_2) - Hauptausgabe"
   - Node-Label wird in Beschreibung angezeigt

3. **Cursor-Position ignoriert** 
   - Platzhalter werden jetzt an Cursor-Position eingefügt
   - textareaRef von WorkflowEditorPage an EndNodeConfig übergeben
   - textarea.selectionStart/End für exakte Position
   - Cursor wird nach Einfügen an korrekte Stelle gesetzt
   - Focus zurück auf Textarea

4. **Fragen-Kontext unklar** ⚠️
   - Signal-Platzhalter zeigen jetzt Frage-Text
   - Format: "Körper-Analyse - Signal: Ist Gewichtstrend positiv?"
   - Frage wird auf 50 Zeichen gekürzt wenn zu lang

**Komponenten-Änderungen:**

PlaceholderPicker.jsx:
- useEffect zum Laden von Backend-Platzhaltern
- Gruppierung nach Kategorien (System + Workflow)
- System-Platzhalter: ~120+ aus placeholder_registrations
- Workflow-Platzhalter: Node Outputs + Signals
- Bessere Beschreibungen mit Node-Label
- Stats-Anzeige: "X Platzhalter gefunden (Y Workflow, Z System)"
- Loading State während Backend-Call

EndNodeConfig.jsx:
- useRef für Textarea
- textareaRef Prop (optional, Fallback zu lokalem ref)
- ref an Textarea gebunden

WorkflowEditorPage.jsx:
- useRef Hook importiert
- endNodeTextareaRef erstellt
- handlePlaceholderSelect umgebaut:
  - Liest selectionStart vom Textarea
  - Fügt an Cursor-Position ein (before + placeholder + after)
  - Setzt Cursor nach Platzhalter
  - Fokussiert Textarea wieder
  - Fallback: Am Ende einfügen wenn ref nicht verfügbar
- textareaRef an EndNodeConfig übergeben

**UX-Verbesserungen:**
- Suchfunktion durchsucht jetzt auch Kategorie-Namen
- Sticky Category Headers beim Scrollen
- Example-Werte in Beschreibung (wenn vorhanden)
- AutoFocus auf Suchfeld beim Öffnen
- Gruppiert: Workflow-Outputs immer oben, dann System-Platzhalter

**Testing Required:**
- [ ] End Node öffnen → Template Mode → Platzhalter-Button klicken
- [ ] Prüfen: ~120+ Platzhalter sichtbar (nicht nur 2-3 Workflow-Outputs)
- [ ] Prüfen: Node-Namen in Beschreibungen ("Körper-Analyse (node_2)")
- [ ] Cursor an beliebige Stelle setzen → Platzhalter einfügen
- [ ] Prüfen: Einfügen an Cursor-Position (nicht am Ende)

Part 3 Bugfixes - User Feedback Complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-04-09 16:04:39 +02:00
parent 228010a6d3
commit b779c2f2a8
3 changed files with 236 additions and 86 deletions

View File

@ -1,3 +1,5 @@
import { useRef } from 'react'
/**
* EndNodeConfig - Konfiguration für End Nodes
*
@ -5,12 +7,17 @@
* - node: React Flow Node object (type='end')
* - onChange: (nodeId, updates) => void
* - onOpenPlaceholderPicker: () => void (optional, für späteren Placeholder Picker)
* - textareaRef: React ref für Textarea (für Cursor-Position beim Einfügen)
*
* Features:
* - Output Mode: AUTO (concatenate all analyses) vs. TEMPLATE (custom Jinja2 template)
* - Template Editor (Textarea für Jinja2 syntax)
* - Cursor-Positionserhaltung beim Platzhalter-Einfügen
*/
export function EndNodeConfig({ node, onChange, onOpenPlaceholderPicker }) {
export function EndNodeConfig({ node, onChange, onOpenPlaceholderPicker, textareaRef }) {
const localTextareaRef = useRef(null)
const activeRef = textareaRef || localTextareaRef
const outputMode = node.data.output_mode || 'auto'
const template = node.data.template || ''
@ -99,6 +106,7 @@ export function EndNodeConfig({ node, onChange, onOpenPlaceholderPicker }) {
)}
</label>
<textarea
ref={activeRef}
value={template}
onChange={handleTemplateChange}
placeholder="# Finale Analyse&#10;&#10;{{ node_1.analysis_core }}&#10;&#10;{{ node_2.analysis_core }}"

View File

@ -1,33 +1,81 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { api } from '../../../utils/api'
/**
* PlaceholderPicker - Modal zur Auswahl von Template-Platzhaltern
*
* Props:
* - nodes: Array of workflow nodes (to extract available placeholders)
* - nodes: Array of workflow nodes (to extract workflow-specific 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
* - Lädt registrierte Platzhalter vom Backend (~120+)
* - Extrahiert Workflow-spezifische Node-Outputs
* - Zeigt Node-Namen (nicht nur IDs)
* - Kategorisiert: System + Workflow
* - Suchfunktion über alle Kategorien
*/
export function PlaceholderPicker({ nodes, onSelect, onClose }) {
const [searchQuery, setSearchQuery] = useState('')
const [systemPlaceholders, setSystemPlaceholders] = useState([])
const [loading, setLoading] = useState(true)
// Extrahiere verfügbare Platzhalter aus Nodes
const placeholders = extractPlaceholders(nodes)
// Lade Backend-Platzhalter beim Mount
useEffect(() => {
async function loadPlaceholders() {
try {
const catalog = await api.listPlaceholders()
// Konvertiere Katalog zu Flat-Liste
const flattened = []
Object.entries(catalog).forEach(([category, items]) => {
items.forEach(item => {
flattened.push({
placeholder: `{{ ${item.key.trim()} }}`,
description: item.description || 'Keine Beschreibung',
example: item.example || '',
category: category,
icon: getCategoryIcon(category)
})
})
})
setSystemPlaceholders(flattened)
} catch (e) {
console.error('Failed to load placeholders:', e)
} finally {
setLoading(false)
}
}
loadPlaceholders()
}, [])
// Extrahiere Workflow-spezifische Platzhalter
const workflowPlaceholders = extractWorkflowPlaceholders(nodes)
// Kombiniere beide Listen
const allPlaceholders = [
...workflowPlaceholders,
...systemPlaceholders
]
// Filtere basierend auf Suchquery
const filteredPlaceholders = placeholders.filter(p =>
const filteredPlaceholders = allPlaceholders.filter(p =>
p.placeholder.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase())
p.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
(p.category && p.category.toLowerCase().includes(searchQuery.toLowerCase()))
)
// Gruppiere nach Kategorie
const grouped = {}
filteredPlaceholders.forEach(p => {
const cat = p.category || 'Sonstige'
if (!grouped[cat]) grouped[cat] = []
grouped[cat].push(p)
})
const handleSelect = (placeholderString) => {
onSelect(placeholderString)
onClose() // Schließe Modal nach Auswahl
onClose()
}
return (
@ -51,7 +99,7 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
background: 'var(--surface)',
borderRadius: '12px',
padding: '24px',
maxWidth: '600px',
maxWidth: '700px',
width: '90%',
maxHeight: '80vh',
display: 'flex',
@ -69,7 +117,10 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
marginBottom: '16px'
}}
>
<h2 style={{ margin: 0, fontSize: '18px' }}>Platzhalter auswählen</h2>
<h2 style={{ margin: 0, fontSize: '18px' }}>
Platzhalter auswählen
{loading && <span style={{ fontSize: '14px', color: 'var(--text3)', marginLeft: '12px' }}>Lädt...</span>}
</h2>
<button
onClick={onClose}
style={{
@ -89,9 +140,10 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
{/* Search */}
<input
type="text"
placeholder="Suche nach Node oder Variable..."
placeholder="Suche nach Platzhalter, Kategorie oder Beschreibung..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
style={{
width: '100%',
padding: '8px 12px',
@ -104,7 +156,19 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
}}
/>
{/* Placeholder List */}
{/* Stats */}
<div
style={{
fontSize: '12px',
color: 'var(--text3)',
marginBottom: '12px'
}}
>
{filteredPlaceholders.length} Platzhalter gefunden
{!searchQuery && ` (${workflowPlaceholders.length} Workflow, ${systemPlaceholders.length} System)`}
</div>
{/* Placeholder List (Grouped) */}
<div
style={{
flex: 1,
@ -113,7 +177,7 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
border: '1px solid var(--border)'
}}
>
{filteredPlaceholders.length === 0 ? (
{loading ? (
<div
style={{
padding: '24px',
@ -122,60 +186,107 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
fontSize: '14px'
}}
>
{searchQuery ? 'Keine Platzhalter gefunden' : 'Keine Nodes im Workflow'}
Lade Platzhalter...
</div>
) : Object.keys(grouped).length === 0 ? (
<div
style={{
padding: '24px',
textAlign: 'center',
color: 'var(--text3)',
fontSize: '14px'
}}
>
{searchQuery ? 'Keine Platzhalter gefunden' : 'Keine Platzhalter verfügbar'}
</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>
{Object.entries(grouped).map(([category, items], catIdx) => (
<div key={catIdx}>
{/* Category Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
padding: '8px 12px',
background: 'var(--surface2)',
borderBottom: '1px solid var(--border)',
fontSize: '12px',
fontWeight: 600,
color: 'var(--text2)',
position: 'sticky',
top: 0,
zIndex: 1
}}
>
<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>
{category} ({items.length})
</div>
{/* Category Items */}
{items.map((p, idx) => (
<div
key={`${catIdx}-${idx}`}
onClick={() => handleSelect(p.placeholder)}
style={{
padding: '12px',
borderBottom: '1px solid var(--border)',
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: 'flex-start',
gap: '12px'
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: 'monospace',
fontSize: '13px',
color: 'var(--accent)',
marginBottom: '4px',
wordBreak: 'break-all'
}}
>
{p.placeholder}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text2)',
marginBottom: p.example ? '4px' : '0'
}}
>
{p.description}
</div>
{p.example && (
<div
style={{
fontSize: '11px',
color: 'var(--text3)',
fontStyle: 'italic'
}}
>
Beispiel: {p.example}
</div>
)}
</div>
<div
style={{
fontSize: '20px',
flexShrink: 0,
color: 'var(--text3)'
}}
>
{p.icon}
</div>
</div>
</div>
))}
</div>
))}
</div>
@ -193,9 +304,9 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
color: 'var(--text3)'
}}
>
💡 <strong>Syntax:</strong> <code style={{ background: 'var(--surface2)', padding: '2px 6px', borderRadius: '4px' }}>{'{{ node_id.field }}'}</code>
💡 <strong>Syntax:</strong> <code style={{ background: 'var(--surface2)', padding: '2px 6px', borderRadius: '4px' }}>{'{{ placeholder_name }}'}</code>
<br />
Verfügbare Felder: <code>analysis_core</code>, <code>signal_*</code> (wenn Fragen vorhanden)
Klicke auf einen Platzhalter um ihn einzufügen
</div>
</div>
</div>
@ -203,45 +314,37 @@ export function PlaceholderPicker({ nodes, onSelect, onClose }) {
}
/**
* Extrahiert verfügbare Platzhalter aus Workflow-Nodes
* Extrahiert Workflow-spezifische Platzhalter aus Nodes
*/
function extractPlaceholders(nodes) {
function extractWorkflowPlaceholders(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
const nodeLabel = 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`,
description: `${nodeLabel} (${nodeId}) - Hauptausgabe`,
icon: getNodeIcon(node.type),
category: 'node_outputs'
category: 'Workflow - 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}`
node.data.questions.forEach((q, qIdx) => {
const questionId = q.id || `q${qIdx + 1}`
const questionText = q.question || `Frage ${qIdx + 1}`
placeholders.push({
placeholder: `{{ ${nodeId}.signal_${questionId} }}`,
description: `${label} - Signal: ${q.question?.substring(0, 40)}...`,
description: `${nodeLabel} - Signal: ${questionText.substring(0, 50)}${questionText.length > 50 ? '...' : ''}`,
icon: '📊',
category: 'signals'
category: 'Workflow - Signals'
})
})
}
@ -263,3 +366,21 @@ function getNodeIcon(type) {
}
return icons[type] || '📦'
}
/**
* Kategorie zu Icon
*/
function getCategoryIcon(category) {
const icons = {
'Profil': '👤',
'Körper': '💪',
'Ernährung': '🍎',
'Training': '🏃',
'Schlaf': '😴',
'Vitalwerte': '❤️',
'Ziele': '🎯',
'Scores': '📊',
'Korrelationen': '📈'
}
return icons[category] || '📦'
}

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNodesState, useEdgesState, addEdge } from 'reactflow'
import { api } from '../utils/api'
@ -50,6 +50,7 @@ export default function WorkflowEditorPage() {
const [availablePrompts, setAvailablePrompts] = useState([])
const [executionResult, setExecutionResult] = useState(null)
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
const endNodeTextareaRef = useRef(null)
// Load available basis prompts for Analysis nodes
useEffect(() => {
@ -263,10 +264,29 @@ export default function WorkflowEditorPage() {
const handlePlaceholderSelect = (placeholderString) => {
if (!selectedNode || selectedNode.type !== 'end') return
const textarea = endNodeTextareaRef.current
const currentTemplate = selectedNode.data.template || ''
const newTemplate = currentTemplate + placeholderString
handleNodeUpdate(selectedNode.id, { template: newTemplate })
// Wenn Textarea Ref verfügbar, an Cursor-Position einfügen
if (textarea) {
const cursorPos = textarea.selectionStart || currentTemplate.length
const before = currentTemplate.substring(0, cursorPos)
const after = currentTemplate.substring(cursorPos)
const newTemplate = before + placeholderString + after
handleNodeUpdate(selectedNode.id, { template: newTemplate })
// Cursor nach eingefügtem Platzhalter positionieren
setTimeout(() => {
const newPos = cursorPos + placeholderString.length
textarea.setSelectionRange(newPos, newPos)
textarea.focus()
}, 0)
} else {
// Fallback: Am Ende einfügen
const newTemplate = currentTemplate + placeholderString
handleNodeUpdate(selectedNode.id, { template: newTemplate })
}
}
// Render
@ -498,6 +518,7 @@ export default function WorkflowEditorPage() {
node={selectedNode}
onChange={handleNodeUpdate}
onOpenPlaceholderPicker={() => setShowPlaceholderPicker(true)}
textareaRef={endNodeTextareaRef}
/>
)}
</div>