mitai-jinkendo/frontend/src/components/workflow/panels/PlaceholderPicker.jsx
Lars 8f6d60681e
Some checks failed
Deploy Development / deploy (push) Failing after 39s
Build Test / pytest-backend (push) Successful in 3s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Failing after 11s
fix: Inline Prompts - UX-Verbesserungen
Problem 1: Selbst-Referenzierung verhindern
- PlaceholderPicker erhält currentNodeId prop
- Node kann sich nicht mehr selbst in Placeholders sehen
- extractWorkflowPlaceholders() filtert aktuellen Node aus

Problem 2: Radio-Button State-Management
- IIFE mit Helper-Funktion für Mode-Bestimmung
- isInlineMode/isReferenceMode basierend auf data.inline_template
- Korrekte Conditional Rendering Logic
- Beim Wechsel Reference→Inline bleibt prompt_slug erhalten
- Beim Wechsel Inline→Reference bleibt inline_template erhalten

Problem 3: Layout-Breite optimiert
- Sidebar: 250px → 220px (schmaler)
- Config Panel: 400px → 520px (breiter für bessere Lesbarkeit)
- Responsive: Config Panel bei <1200px: 450px statt 350px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:58:46 +02:00

442 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 workflow-specific placeholders)
* - currentNodeId: ID des aktuellen Nodes (wird aus Placeholders ausgeschlossen)
* - onSelect: (placeholderString) => void - Callback when placeholder is selected
* - onClose: () => void
*
* Features:
* - Lädt registrierte Platzhalter vom Backend (~120+)
* - Extrahiert Workflow-spezifische Node-Outputs
* - Filtert Selbst-Referenzierung (Node kann sich nicht selbst referenzieren)
* - Zeigt Node-Namen (nicht nur IDs)
* - Kategorisiert: System + Workflow
* - Suchfunktion über alle Kategorien
*/
export function PlaceholderPicker({ nodes, currentNodeId, onSelect, onClose }) {
const [searchQuery, setSearchQuery] = useState('')
const [systemPlaceholders, setSystemPlaceholders] = useState([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(null)
// Lade Backend-Platzhalter beim Mount
useEffect(() => {
async function loadPlaceholders() {
try {
console.log('🔄 Loading placeholders from backend...')
const catalog = await api.listPlaceholders()
console.log('✅ Catalog received:', catalog)
console.log('📊 Catalog keys:', Object.keys(catalog))
// Konvertiere Katalog zu Flat-Liste
const flattened = []
Object.entries(catalog).forEach(([category, items]) => {
console.log(`📁 Category "${category}": ${items?.length || 0} items`)
if (!Array.isArray(items)) {
console.warn(`⚠️ Category "${category}" items is not an array:`, items)
return
}
items.forEach(item => {
flattened.push({
placeholder: `{{ ${item.key.trim()} }}`,
description: item.description || 'Keine Beschreibung',
example: item.example || '',
category: category,
icon: getCategoryIcon(category)
})
})
})
console.log(`✅ Loaded ${flattened.length} system placeholders`)
setSystemPlaceholders(flattened)
} catch (e) {
console.error('❌ Failed to load placeholders:', e)
setLoadError(e.message)
} finally {
setLoading(false)
}
}
loadPlaceholders()
}, [])
// Extrahiere Workflow-spezifische Platzhalter (ohne aktuellen Node)
const workflowPlaceholders = extractWorkflowPlaceholders(nodes, currentNodeId)
// Kombiniere beide Listen
const allPlaceholders = [
...workflowPlaceholders,
...systemPlaceholders
]
// Filtere basierend auf Suchquery
const filteredPlaceholders = allPlaceholders.filter(p =>
p.placeholder.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()
}
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: '700px',
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
{loading && <span style={{ fontSize: '14px', color: 'var(--text3)', marginLeft: '12px' }}>Lädt...</span>}
</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 Platzhalter, Kategorie oder Beschreibung..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--bg)',
color: 'var(--text1)',
fontSize: '14px',
marginBottom: '16px'
}}
/>
{/* 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,
overflowY: 'auto',
borderRadius: '8px',
border: '1px solid var(--border)'
}}
>
{loading ? (
<div
style={{
padding: '24px',
textAlign: 'center',
color: 'var(--text3)',
fontSize: '14px'
}}
>
Lade Platzhalter...
</div>
) : loadError ? (
<div
style={{
padding: '24px',
textAlign: 'center',
color: 'var(--danger)',
fontSize: '14px'
}}
>
Fehler beim Laden: {loadError}
<div style={{ marginTop: '12px', fontSize: '12px', color: 'var(--text3)' }}>
Workflow-Platzhalter sind trotzdem verfügbar.
</div>
</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>
{Object.entries(grouped).map(([category, items], catIdx) => (
<div key={catIdx}>
{/* Category Header */}
<div
style={{
padding: '8px 12px',
background: 'var(--surface2)',
borderBottom: '1px solid var(--border)',
fontSize: '12px',
fontWeight: 600,
color: 'var(--text2)',
position: 'sticky',
top: 0,
zIndex: 1
}}
>
{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>
)}
</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' }}>{'{{ placeholder_name }}'}</code>
<br />
Klicke auf einen Platzhalter um ihn einzufügen
</div>
</div>
</div>
)
}
/**
* Extrahiert Workflow-spezifische Platzhalter aus Nodes
*
* @param {Array} nodes - Alle Workflow-Nodes
* @param {string} currentNodeId - ID des aktuellen Nodes (wird ausgeschlossen)
*/
function extractWorkflowPlaceholders(nodes, currentNodeId) {
const placeholders = []
console.log('🔍 Extracting workflow placeholders from nodes:', nodes)
console.log('🚫 Excluding current node:', currentNodeId)
nodes.forEach(node => {
if (node.type === 'end') return // End Node hat keine Outputs
if (node.id === currentNodeId) return // Selbst-Referenzierung verhindern
const nodeId = node.id
const nodeLabel = node.data?.label || nodeId
console.log(`📦 Node ${nodeId}:`, {
type: node.type,
label: node.data?.label,
nodeLabel: nodeLabel,
data: node.data
})
// analysis_core für alle Analysis/Logic/Join Nodes
if (node.type === 'analysis' || node.type === 'logic' || node.type === 'join') {
const desc = `${nodeLabel} (${nodeId}) - Hauptausgabe`
console.log(` Adding placeholder: {{ ${nodeId}.analysis_core }} → "${desc}"`)
placeholders.push({
placeholder: `{{ ${nodeId}.analysis_core }}`,
description: desc,
icon: getNodeIcon(node.type),
category: 'Workflow - Node Outputs'
})
}
// Signals und Fragen für Analysis Nodes
if (node.type === 'analysis' && node.data.questions && node.data.questions.length > 0) {
node.data.questions.forEach((q, qIdx) => {
const questionId = q.id || `q${qIdx + 1}`
const questionType = q.type || 'unknown'
const questionText = q.question || `Frage ${qIdx + 1}`
// Signal-Platzhalter (Antwort) - VERWENDET ID für Eindeutigkeit!
placeholders.push({
placeholder: `{{ ${nodeId}.signal_${questionId} }}`,
description: `${nodeLabel} - ${questionId} (${questionType}): ${questionText.substring(0, 45)}${questionText.length > 45 ? '...' : ''}`,
icon: '📊',
category: 'Workflow - Signals'
})
// Frage-Text-Platzhalter
placeholders.push({
placeholder: `{{ ${nodeId}.question_${questionId} }}`,
description: `${nodeLabel} - ${questionId} (${questionType}): ${questionText.substring(0, 45)}${questionText.length > 45 ? '...' : ''}`,
icon: '❓',
category: 'Workflow - Questions'
})
})
}
})
return placeholders
}
/**
* Node-Typ zu Icon
*/
function getNodeIcon(type) {
const icons = {
start: '🚀',
analysis: '🤖',
logic: '⚡',
join: '🔀',
end: '🏁'
}
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] || '📦'
}