import { useState, useEffect, useRef } from 'react' import { api } from '../utils/api' import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react' import PlaceholderPicker from './PlaceholderPicker' /** * 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) 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) 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) { setError('Test-Fehler: ' + parsed.detail) 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 } }) 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 debug = testResult.debug || testResult 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 (
{JSON.stringify(testResult.debug || testResult, null, 2)}