mitai-jinkendo/frontend/src/components/UnifiedPromptModal.jsx

1235 lines
42 KiB
React
Raw Normal View History

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>
)
}