mitai-jinkendo/frontend/src/components/UnifiedPromptModal.jsx
Lars 08c9cccdcc
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
feat: Add expandable collapsible component for improved content display
- Introduced `ExpandableCollapsible` component to manage the visibility of lengthy content, allowing users to toggle between expanded and collapsed views.
- Updated `renderTestOutput` to utilize the new component for displaying test results, JSON outputs, and object representations, enhancing user experience by reducing clutter.
- Enhanced `Markdown` component to support fenced code blocks, improving the rendering of code snippets with language labels and better styling.

These changes improve the readability and organization of content within the application, providing users with a more interactive and manageable interface.
2026-04-12 11:10:39 +02:00

1235 lines
42 KiB
JavaScript

import { useState, useEffect, useRef, useLayoutEffect } from 'react'
import { api } from '../utils/api'
import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react'
import PlaceholderPicker from './PlaceholderPicker'
import Markdown from '../utils/Markdown'
/** Debug aus erfolgreicher Ausführung oder aus FastAPI-Fehlerdetail */
function extractExecutionDebug(testResult) {
if (!testResult) return null
const raw = testResult.debug ?? testResult
if (raw && typeof raw === 'object' && raw.detail && raw.detail.debug) {
return { ...raw.detail.debug, ...raw.detail }
}
return raw
}
/** Vollständiger Prompt-Text für die Anzeige (Basis oder Pipeline-Stufen) */
function buildPromptTextForDisplay(testResult) {
if (!testResult || testResult.error) return ''
const dbg = extractExecutionDebug(testResult)
const t = testResult.type
if (t === 'base' && dbg?.final_prompt) return dbg.final_prompt
if (t === 'pipeline' && dbg?.stages?.length) {
const parts = []
for (const st of dbg.stages) {
for (const p of st.prompts || []) {
const label =
p.source === 'reference'
? `Stage ${st.stage} · Ref: ${p.ref_slug || '?'}`
: `Stage ${st.stage} · Inline`
const text =
p.final_prompt ||
(p.ref_debug && typeof p.ref_debug === 'object' ? p.ref_debug.final_prompt : '') ||
''
if (text) parts.push(`── ${label} ──\n${text}`)
}
}
return parts.join('\n\n')
}
return dbg?.final_prompt || ''
}
function sanitizeDebugForPanel(dbg, resultType) {
if (!dbg) return {}
const o = { ...dbg }
delete o.final_prompt
delete o.template
delete o.ai_response_preview
if (o.stages && resultType === 'pipeline') {
o.stages = o.stages.map((s) => ({
stage: s.stage,
available_variables: s.available_variables,
output: s.output,
prompts: (s.prompts || []).map((p) => ({
source: p.source,
ref_slug: p.ref_slug,
output_key: p.output_key,
context_var_key: p.context_var_key,
resolved_placeholders: p.resolved_placeholders || p.ref_debug?.resolved_placeholders,
unresolved_placeholders: p.unresolved_placeholders || p.ref_debug?.unresolved_placeholders,
warnings: p.warnings || p.ref_debug?.warnings,
ai_response_length: p.ai_response_length
}))
}))
}
return o
}
/** Langer Inhalt: zunächst auf maxRem eingeklappt, „Mehr/Weniger anzeigen“ */
function ExpandableCollapsible({ children, contentKey, maxRem = 28 }) {
const [expanded, setExpanded] = useState(false)
const wrapRef = useRef(null)
const [showToggle, setShowToggle] = useState(false)
useEffect(() => {
setExpanded(false)
}, [contentKey])
useLayoutEffect(() => {
const el = wrapRef.current
if (!el) return
if (expanded) {
setShowToggle(true)
return
}
setShowToggle(el.scrollHeight > el.clientHeight + 2)
}, [contentKey, expanded])
return (
<div>
<div
ref={wrapRef}
style={{
maxHeight: expanded ? 'none' : `${maxRem}rem`,
overflow: expanded ? 'visible' : 'hidden'
}}
>
{children}
</div>
{showToggle && (
<div style={{ marginTop: 10, textAlign: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => setExpanded((e) => !e)}
>
{expanded ? 'Weniger anzeigen' : 'Mehr anzeigen'}
</button>
</div>
)}
</div>
)
}
function renderTestOutput(testResult) {
if (!testResult) return null
if (testResult.error) {
const d = testResult.debug?.detail
const msg =
typeof d === 'string'
? d
: d?.error || d?.message || testResult.error_message || 'Unbekannter Fehler'
return (
<div style={{ color: 'var(--danger)', fontSize: 14, lineHeight: 1.5 }}>
{typeof msg === 'string' ? msg : JSON.stringify(msg, null, 2)}
</div>
)
}
const out = testResult.output
const fmt = testResult.output_format
const typ = testResult.type
if (out == null) {
return <span style={{ color: 'var(--text3)' }}>Keine Ausgabe</span>
}
const proseBox = (content, contentKey) => (
<div
style={{
padding: 16,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 14,
lineHeight: 1.55,
color: 'var(--text1)',
textAlign: 'left'
}}
>
{contentKey != null ? (
<ExpandableCollapsible contentKey={contentKey}>{content}</ExpandableCollapsible>
) : (
content
)}
</div>
)
if (typ === 'pipeline' && out && typeof out === 'object' && !Array.isArray(out)) {
return (
<div style={{ display: 'grid', gap: 16 }}>
{Object.entries(out).map(([key, val]) => (
<div key={key}>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--text2)',
marginBottom: 8,
textAlign: 'left'
}}
>
{key}
</div>
{typeof val === 'string' ? (
proseBox(
<Markdown text={val} />,
`pipe-${key}-${val.length}-${val.slice(0, 120)}`
)
) : (
<ExpandableCollapsible contentKey={`pipe-json-${key}-${JSON.stringify(val).slice(0, 200)}`} maxRem={22}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{JSON.stringify(val, null, 2)}
</pre>
</ExpandableCollapsible>
)}
</div>
))}
</div>
)
}
if (typeof out === 'string') {
if (fmt === 'json') {
try {
const parsed = JSON.parse(out)
const jsonStr = JSON.stringify(parsed, null, 2)
return (
<ExpandableCollapsible contentKey={`json-out-${jsonStr.length}-${jsonStr.slice(0, 80)}`} maxRem={26}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{jsonStr}
</pre>
</ExpandableCollapsible>
)
} catch {
return proseBox(
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{out}</pre>,
`json-fail-${out.length}-${out.slice(0, 80)}`
)
}
}
return proseBox(<Markdown text={out} />, `text-out-${out.length}-${out.slice(0, 120)}`)
}
if (typeof out === 'object') {
const jsonStr = JSON.stringify(out, null, 2)
return (
<ExpandableCollapsible contentKey={`obj-out-${jsonStr.length}-${jsonStr.slice(0, 120)}`} maxRem={26}>
<pre
style={{
margin: 0,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
fontSize: 12,
overflow: 'auto',
textAlign: 'left'
}}
>
{jsonStr}
</pre>
</ExpandableCollapsible>
)
}
return <span>{String(out)}</span>
}
/**
* Unified Prompt Editor Modal (Issue #28 Phase 3)
*
* Supports both prompt types:
* - Base: Single reusable template
* - Pipeline: Multi-stage workflow with dynamic stages
*/
export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [type, setType] = useState('pipeline') // 'base' or 'pipeline'
const [category, setCategory] = useState('ganzheitlich')
const [active, setActive] = useState(true)
const [sortOrder, setSortOrder] = useState(0)
// Base prompt fields
const [template, setTemplate] = useState('')
const [outputFormat, setOutputFormat] = useState('text')
// Pipeline prompt fields
const [stages, setStages] = useState([
{
stage: 1,
prompts: []
}
])
// Available prompts for reference selection
const [availablePrompts, setAvailablePrompts] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
const [pickerTarget, setPickerTarget] = useState(null) // 'base' or {stage, promptIdx}
const [cursorPosition, setCursorPosition] = useState(null) // Track cursor position for insertion
const baseTemplateRef = useRef(null)
const stageTemplateRefs = useRef({}) // Map of stage_promptIdx -> ref
// Test functionality
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState(null)
const [showDebug, setShowDebug] = useState(false)
/** 'response' | 'prompt' | 'debug' */
const [testResultTab, setTestResultTab] = useState('response')
useEffect(() => {
loadAvailablePrompts()
if (prompt) {
// Edit mode
setName(prompt.name || '')
setSlug(prompt.slug || '')
setDisplayName(prompt.display_name || '')
setDescription(prompt.description || '')
setType(prompt.type || 'pipeline')
setCategory(prompt.category || 'ganzheitlich')
setActive(prompt.active ?? true)
setSortOrder(prompt.sort_order || 0)
setTemplate(prompt.template || '')
setOutputFormat(prompt.output_format || 'text')
// Parse stages if editing pipeline
if (prompt.type === 'pipeline' && prompt.stages) {
try {
const parsedStages = typeof prompt.stages === 'string'
? JSON.parse(prompt.stages)
: prompt.stages
setStages(parsedStages.length > 0 ? parsedStages : [{ stage: 1, prompts: [] }])
} catch (e) {
console.error('Failed to parse stages:', e)
setStages([{ stage: 1, prompts: [] }])
}
}
}
}, [prompt])
const loadAvailablePrompts = async () => {
try {
const prompts = await api.listAdminPrompts()
setAvailablePrompts(prompts)
} catch (e) {
setError('Fehler beim Laden der Prompts: ' + e.message)
}
}
const addStage = () => {
const nextStageNum = Math.max(...stages.map(s => s.stage), 0) + 1
setStages([...stages, { stage: nextStageNum, prompts: [] }])
}
const removeStage = (stageNum) => {
if (stages.length === 1) {
setError('Mindestens eine Stage erforderlich')
return
}
setStages(stages.filter(s => s.stage !== stageNum))
}
const moveStage = (stageNum, direction) => {
const idx = stages.findIndex(s => s.stage === stageNum)
if (idx === -1) return
const newStages = [...stages]
if (direction === 'up' && idx > 0) {
[newStages[idx], newStages[idx - 1]] = [newStages[idx - 1], newStages[idx]]
} else if (direction === 'down' && idx < newStages.length - 1) {
[newStages[idx], newStages[idx + 1]] = [newStages[idx + 1], newStages[idx]]
}
// Renumber stages
newStages.forEach((s, i) => s.stage = i + 1)
setStages(newStages)
}
const addPromptToStage = (stageNum) => {
setStages(stages.map(s => {
if (s.stage === stageNum) {
return {
...s,
prompts: [...s.prompts, {
source: 'inline',
template: '',
output_key: `output_${Date.now()}`,
output_format: 'text'
}]
}
}
return s
}))
}
const removePromptFromStage = (stageNum, promptIdx) => {
setStages(stages.map(s => {
if (s.stage === stageNum) {
return {
...s,
prompts: s.prompts.filter((_, i) => i !== promptIdx)
}
}
return s
}))
}
const updateStagePrompt = (stageNum, promptIdx, field, value) => {
setStages(stages.map(s => {
if (s.stage === stageNum) {
const newPrompts = [...s.prompts]
newPrompts[promptIdx] = { ...newPrompts[promptIdx], [field]: value }
return { ...s, prompts: newPrompts }
}
return s
}))
}
const handleSave = async () => {
// Validation
if (!name.trim() || !slug.trim()) {
setError('Name und Slug sind Pflichtfelder')
return
}
if (type === 'base' && !template.trim()) {
setError('Basis-Prompts benötigen ein Template')
return
}
if (type === 'pipeline' && stages.length === 0) {
setError('Pipeline-Prompts benötigen mindestens eine Stage')
return
}
if (type === 'pipeline') {
// Validate all stages have at least one prompt
const emptyStages = stages.filter(s => s.prompts.length === 0)
if (emptyStages.length > 0) {
setError(`Stage ${emptyStages[0].stage} hat keine Prompts`)
return
}
// Validate all prompts have required fields
for (const stage of stages) {
for (const p of stage.prompts) {
if (!p.output_key) {
setError(`Stage ${stage.stage}: Output-Key fehlt`)
return
}
if (p.source === 'inline' && !p.template) {
setError(`Stage ${stage.stage}: Inline-Prompt ohne Template`)
return
}
if (p.source === 'reference' && !p.slug) {
setError(`Stage ${stage.stage}: Referenz-Prompt ohne Slug`)
return
}
}
}
}
setLoading(true)
setError(null)
try {
const data = {
name,
slug,
display_name: displayName,
description,
type,
category,
active,
sort_order: sortOrder,
output_format: outputFormat
}
if (type === 'base') {
data.template = template
data.stages = null
} else {
data.template = null
data.stages = stages
}
if (prompt?.id) {
await api.updateUnifiedPrompt(prompt.id, data)
} else {
await api.createUnifiedPrompt(data)
}
onSave()
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
const handleExport = () => {
// Export complete prompt configuration as JSON
const exportData = {
name,
slug,
display_name: displayName,
description,
type,
category,
active,
sort_order: sortOrder,
output_format: outputFormat,
template: type === 'base' ? template : null,
stages: type === 'pipeline' ? stages : null
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `prompt-${slug || 'new'}-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const handleTest = async () => {
// Can only test existing prompts (need slug in database)
if (!prompt?.slug) {
setError('Bitte erst speichern, dann testen')
return
}
setTesting(true)
setError(null)
setTestResult(null)
try {
const result = await api.executeUnifiedPrompt(prompt.slug, null, null, true)
setTestResult(result)
setTestResultTab('response')
setShowDebug(true)
} catch (e) {
// Show error AND try to extract debug info from error
const errorMsg = e.message
let debugData = null
// Try to parse error message for embedded debug info
try {
const parsed = JSON.parse(errorMsg)
if (parsed.detail) {
const d = parsed.detail
const msg =
typeof d === 'string'
? d
: d.error || d.message || d.msg || JSON.stringify(d).slice(0, 500)
setError('Test-Fehler: ' + msg)
debugData = parsed
} else {
setError('Test-Fehler: ' + errorMsg)
}
} catch {
setError('Test-Fehler: ' + errorMsg)
}
// Set result with error info so debug viewer shows it
setTestResult({
error: true,
error_message: errorMsg,
debug: debugData || { error: errorMsg }
})
setTestResultTab('response')
setShowDebug(true) // ALWAYS show debug on test, even on error
} finally {
setTesting(false)
}
}
const handleExportPlaceholders = () => {
if (!testResult) return
// Extract all placeholder data from test result
const raw = testResult.debug || testResult
// API-Fehler: FastAPI liefert { detail: { error, debug: { resolved_placeholders, ... } } }
const debug =
raw && typeof raw === 'object' && raw.detail && raw.detail.debug
? { ...raw.detail.debug, ...raw.detail }
: raw
const exportData = {
export_date: new Date().toISOString(),
prompt_slug: prompt?.slug || 'unknown',
prompt_name: name || 'Unnamed Prompt',
placeholders: {}
}
// For pipeline prompts, collect from all stages
if (debug.stages && Array.isArray(debug.stages)) {
debug.stages.forEach(stage => {
exportData.placeholders[`stage_${stage.stage}`] = {
available_variables: stage.available_variables || [],
prompts: stage.prompts?.map(p => ({
source: p.source,
resolved: p.resolved_placeholders || p.ref_debug?.resolved_placeholders || {},
unresolved: p.unresolved_placeholders || p.ref_debug?.unresolved_placeholders || []
})) || []
}
})
}
// For base prompts or direct execution
if (debug.resolved_placeholders) {
exportData.placeholders.resolved = debug.resolved_placeholders
}
if (debug.unresolved_placeholders) {
exportData.placeholders.unresolved = debug.unresolved_placeholders
}
if (debug.available_variables) {
exportData.available_variables = debug.available_variables
}
if (debug.initial_variables) {
exportData.initial_variables = debug.initial_variables
}
// Download as JSON
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `placeholders-${prompt?.slug || 'test'}-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000, padding: 20, overflow: 'auto'
}}>
<div style={{
background: 'var(--bg)', borderRadius: 12, maxWidth: 1000, width: '100%',
maxHeight: '90vh', overflow: 'auto', padding: 24
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>
{prompt ? 'Prompt bearbeiten' : 'Neuer Prompt'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<X size={24} color="var(--text3)" />
</button>
</div>
{error && (
<div style={{
padding: 12, background: '#fee', color: '#c00', borderRadius: 8, marginBottom: 16, fontSize: 13
}}>
{error}
</div>
)}
{/* Basic Info */}
<div style={{ display: 'grid', gap: 16, marginBottom: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label">Name *</label>
<input
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Interner Name"
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<div>
<label className="form-label">Slug *</label>
<input
className="form-input"
value={slug}
onChange={e => setSlug(e.target.value)}
placeholder="technischer_name"
style={{ width: '100%', textAlign: 'left' }}
disabled={!!prompt}
/>
</div>
</div>
<div>
<label className="form-label">Anzeigename</label>
<input
className="form-input"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="Name für Benutzer (optional)"
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<div>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="Kurze Beschreibung des Prompts"
style={{ width: '100%', textAlign: 'left', resize: 'vertical' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<label className="form-label">Typ *</label>
<select
className="form-select"
value={type}
onChange={e => setType(e.target.value)}
style={{ width: '100%' }}
disabled={!!prompt}
>
<option value="base">Basis-Prompt</option>
<option value="pipeline">Pipeline</option>
</select>
</div>
<div>
<label className="form-label">Kategorie</label>
<select
className="form-select"
value={category}
onChange={e => setCategory(e.target.value)}
style={{ width: '100%' }}
>
<option value="ganzheitlich">Ganzheitlich</option>
<option value="körper">Körper</option>
<option value="ernährung">Ernährung</option>
<option value="training">Training</option>
<option value="schlaf">Schlaf</option>
<option value="pipeline">Pipeline</option>
</select>
</div>
<div>
<label className="form-label">Output-Format</label>
<select
className="form-select"
value={outputFormat}
onChange={e => setOutputFormat(e.target.value)}
style={{ width: '100%' }}
>
<option value="text">Text</option>
<option value="json">JSON</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox"
checked={active}
onChange={e => setActive(e.target.checked)}
/>
<span style={{ fontSize: 13 }}>Aktiv</span>
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label className="form-label" style={{ margin: 0 }}>Sortierung:</label>
<input
type="number"
className="form-input"
value={sortOrder}
onChange={e => setSortOrder(parseInt(e.target.value) || 0)}
style={{ width: 80, padding: '4px 8px' }}
/>
</div>
</div>
</div>
{/* Type-specific editor */}
{type === 'base' && (
<div style={{ marginBottom: 24 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Template</h3>
<textarea
ref={baseTemplateRef}
className="form-input"
value={template}
onChange={e => setTemplate(e.target.value)}
onClick={e => setCursorPosition(e.target.selectionStart)}
onKeyUp={e => setCursorPosition(e.target.selectionStart)}
rows={12}
placeholder="Prompt-Template mit {{placeholders}}..."
style={{ width: '100%', textAlign: 'left', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
<button
className="btn"
onClick={() => {
setPickerTarget('base')
setShowPlaceholderPicker(true)
}}
style={{
fontSize: 12,
padding: '6px 12px',
display: 'flex',
alignItems: 'center',
gap: 6
}}
>
<Code size={14} />
Platzhalter einfügen
</button>
</div>
</div>
)}
{type === 'pipeline' && (
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>Stages ({stages.length})</h3>
<button className="btn" onClick={addStage} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Plus size={16} /> Stage hinzufügen
</button>
</div>
{stages.map((stage, sIdx) => (
<div key={stage.stage} style={{
background: 'var(--surface)', padding: 16, borderRadius: 8,
border: '1px solid var(--border)', marginBottom: 12
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Stage {stage.stage}</h4>
<div style={{ display: 'flex', gap: 4 }}>
{sIdx > 0 && (
<button
className="btn"
onClick={() => moveStage(stage.stage, 'up')}
style={{ padding: '4px 8px' }}
>
<MoveUp size={14} />
</button>
)}
{sIdx < stages.length - 1 && (
<button
className="btn"
onClick={() => moveStage(stage.stage, 'down')}
style={{ padding: '4px 8px' }}
>
<MoveDown size={14} />
</button>
)}
<button
className="btn"
onClick={() => removeStage(stage.stage)}
style={{ padding: '4px 8px', color: 'var(--danger)' }}
disabled={stages.length === 1}
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Prompts in this stage */}
{stage.prompts.map((p, pIdx) => (
<div key={pIdx} style={{
background: 'var(--bg)', padding: 12, borderRadius: 6, marginBottom: 8,
border: '1px solid var(--border)'
}}>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr 120px auto', gap: 8, alignItems: 'center' }}>
<select
className="form-select"
value={p.source || 'inline'}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'source', e.target.value)}
style={{ fontSize: 12 }}
>
<option value="inline">Inline</option>
<option value="reference">Referenz</option>
</select>
<input
className="form-input"
value={p.output_key || ''}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'output_key', e.target.value)}
placeholder="output_key"
style={{ fontSize: 12, textAlign: 'left' }}
/>
<select
className="form-select"
value={p.output_format || 'text'}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'output_format', e.target.value)}
style={{ fontSize: 12 }}
>
<option value="text">Text</option>
<option value="json">JSON</option>
</select>
<button
onClick={() => removePromptFromStage(stage.stage, pIdx)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<Trash2 size={16} color="var(--danger)" />
</button>
</div>
{p.source === 'reference' ? (
<select
className="form-select"
value={p.slug || ''}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'slug', e.target.value)}
style={{ fontSize: 12 }}
>
<option value="">-- Prompt wählen --</option>
{availablePrompts
.filter(ap => ap.type === 'base' || !ap.type)
.map(ap => (
<option key={ap.slug} value={ap.slug}>
{ap.display_name || ap.name} ({ap.slug})
</option>
))}
</select>
) : (
<div>
<textarea
ref={el => stageTemplateRefs.current[`${stage.stage}_${pIdx}`] = el}
className="form-input"
value={p.template || ''}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'template', e.target.value)}
onClick={e => {
setCursorPosition(e.target.selectionStart)
setPickerTarget({ stage: stage.stage, promptIdx: pIdx })
}}
onKeyUp={e => {
setCursorPosition(e.target.selectionStart)
setPickerTarget({ stage: stage.stage, promptIdx: pIdx })
}}
rows={3}
placeholder="Inline-Template mit {{placeholders}}..."
style={{ width: '100%', fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }}
/>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
<button
className="btn"
onClick={() => {
setPickerTarget({ stage: stage.stage, promptIdx: pIdx })
setShowPlaceholderPicker(true)
}}
style={{
fontSize: 11,
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
gap: 4
}}
>
<Code size={12} />
Platzhalter
</button>
</div>
</div>
)}
</div>
</div>
))}
<button
className="btn"
onClick={() => addPromptToStage(stage.stage)}
style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}
>
<Plus size={14} /> Prompt hinzufügen
</button>
</div>
))}
</div>
)}
{/* Test-Ergebnis: Antwort · Prompt · Debug */}
{showDebug && testResult && (
<div style={{
marginTop: 16,
padding: 16,
background: 'var(--surface2)',
borderRadius: 8,
border: '1px solid var(--border)'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
flexWrap: 'wrap',
gap: 8
}}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
Test-Ergebnis
</h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button
className="btn"
onClick={handleExportPlaceholders}
style={{
padding: '4px 8px',
fontSize: 11,
background: 'var(--accent)',
color: 'white'
}}
title="Exportiere Platzhalter / Kontext als JSON"
>
📋 Kontext exportieren
</button>
<button
onClick={() => setShowDebug(false)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
aria-label="Schließen"
>
<X size={16} color="var(--text3)" />
</button>
</div>
</div>
<div
role="tablist"
style={{
display: 'flex',
gap: 6,
marginBottom: 12,
flexWrap: 'wrap',
borderBottom: '1px solid var(--border)',
paddingBottom: 8
}}
>
{[
{ id: 'response', label: 'Antwort' },
{ id: 'prompt', label: 'Prompt (an LLM)' },
{ id: 'debug', label: 'Debug' }
].map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={testResultTab === tab.id}
onClick={() => setTestResultTab(tab.id)}
style={{
padding: '6px 12px',
fontSize: 12,
borderRadius: 6,
border: '1px solid var(--border)',
background: testResultTab === tab.id ? 'var(--accent)' : 'var(--surface)',
color: testResultTab === tab.id ? '#fff' : 'var(--text2)',
cursor: 'pointer',
fontWeight: testResultTab === tab.id ? 600 : 400
}}
>
{tab.label}
</button>
))}
</div>
<div
role="tabpanel"
style={{
minHeight: 120,
maxHeight: testResultTab === 'prompt' ? 480 : 420,
overflow: 'auto',
textAlign: 'left'
}}
>
{testResultTab === 'response' && (
<div style={{ paddingRight: 4 }}>
{renderTestOutput(testResult)}
</div>
)}
{testResultTab === 'prompt' && (
<pre
style={{
margin: 0,
fontSize: 12,
fontFamily: 'ui-monospace, monospace',
lineHeight: 1.45,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'var(--text1)',
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)'
}}
>
{buildPromptTextForDisplay(testResult) || '(Kein Prompt-Text in der Antwort — evtl. Fehler vor Ausführung.)'}
</pre>
)}
{testResultTab === 'debug' && (
<pre
style={{
margin: 0,
fontSize: 11,
fontFamily: 'ui-monospace, monospace',
lineHeight: 1.45,
padding: 12,
background: 'var(--bg)',
borderRadius: 8,
border: '1px solid var(--border)',
color: 'var(--text2)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{JSON.stringify(
sanitizeDebugForPanel(
extractExecutionDebug(testResult),
testResult.error ? null : testResult.type
),
null,
2
)}
</pre>
)}
</div>
</div>
)}
{/* Actions */}
<div style={{
display: 'flex', gap: 12, justifyContent: 'space-between',
paddingTop: 16, borderTop: '1px solid var(--border)'
}}>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn"
onClick={handleTest}
disabled={testing || loading}
style={{
background: testing ? 'var(--surface)' : 'var(--accent)',
color: testing ? 'var(--text3)' : 'white'
}}
title={!prompt?.slug ? 'Bitte erst speichern, dann testen' : 'Test mit Debug-Modus ausführen'}
>
{testing ? '🔬 Teste...' : '🔬 Test ausführen'}
</button>
<button
className="btn"
onClick={handleExport}
disabled={loading}
title="Exportiere Prompt-Konfiguration als JSON"
>
📥 Export
</button>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button className="btn" onClick={onClose}>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={loading}
>
{loading ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/* Placeholder Picker */}
{showPlaceholderPicker && (
<PlaceholderPicker
onSelect={(placeholder) => {
if (pickerTarget === 'base') {
// Insert into base template at cursor position
const pos = cursorPosition ?? template.length
const newTemplate = template.slice(0, pos) + placeholder + template.slice(pos)
setTemplate(newTemplate)
// Restore focus and cursor position after insertion
setTimeout(() => {
if (baseTemplateRef.current) {
baseTemplateRef.current.focus()
const newPos = pos + placeholder.length
baseTemplateRef.current.setSelectionRange(newPos, newPos)
setCursorPosition(newPos)
}
}, 0)
} else if (pickerTarget && typeof pickerTarget === 'object') {
// Insert into pipeline stage template at cursor position
const { stage: stageNum, promptIdx } = pickerTarget
setStages(stages.map(s => {
if (s.stage === stageNum) {
const newPrompts = [...s.prompts]
const currentTemplate = newPrompts[promptIdx].template || ''
const pos = cursorPosition ?? currentTemplate.length
const newTemplate = currentTemplate.slice(0, pos) + placeholder + currentTemplate.slice(pos)
newPrompts[promptIdx] = {
...newPrompts[promptIdx],
template: newTemplate
}
// Restore focus and cursor position
setTimeout(() => {
const refKey = `${stageNum}_${promptIdx}`
const textarea = stageTemplateRefs.current[refKey]
if (textarea) {
textarea.focus()
const newPos = pos + placeholder.length
textarea.setSelectionRange(newPos, newPos)
setCursorPosition(newPos)
}
}, 0)
return { ...s, prompts: newPrompts }
}
return s
}))
}
}}
onClose={() => {
setShowPlaceholderPicker(false)
setPickerTarget(null)
}}
/>
)}
</div>
)
}