- 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.
1235 lines
42 KiB
JavaScript
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>
|
|
)
|
|
}
|