From 31e2c24a8a3b504a61dde5fd3b65fde144ab56bc Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 25 Mar 2026 14:55:25 +0100 Subject: [PATCH] feat: unified prompt UI - Phase 3 complete (Issue #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend Consolidation: - UnifiedPromptModal: Single editor for base + pipeline prompts - Type selector (base/pipeline) - Base: Template editor with placeholders - Pipeline: Dynamic stage editor - Add/remove stages with drag/drop - Inline or reference prompts per stage - Output key + format per prompt - AdminPromptsPage redesign: - Removed tab switcher (prompts/pipelines) - Added type filter (All/Base/Pipeline) - Type badge in table - Stage count column - Icon-based actions (Edit/Copy/Delete) - Category filter retained Changes: - Completely rewrote AdminPromptsPage (495 → 446 lines) - Single modal for all prompt types - Mobile-ready layout - Simplified state management Next: Phase 4 - Cleanup deprecated endpoints + docs --- .../src/components/UnifiedPromptModal.jsx | 549 ++++++++++++++ frontend/src/pages/AdminPromptsPage.jsx | 689 ++++++++---------- 2 files changed, 865 insertions(+), 373 deletions(-) create mode 100644 frontend/src/components/UnifiedPromptModal.jsx diff --git a/frontend/src/components/UnifiedPromptModal.jsx b/frontend/src/components/UnifiedPromptModal.jsx new file mode 100644 index 0000000..4211ed5 --- /dev/null +++ b/frontend/src/components/UnifiedPromptModal.jsx @@ -0,0 +1,549 @@ +import { useState, useEffect } from 'react' +import { api } from '../utils/api' +import { X, Plus, Trash2, MoveUp, MoveDown } from 'lucide-react' + +/** + * 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) + + 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) + } + } + + return ( +
+
+
+

+ {prompt ? 'Prompt bearbeiten' : 'Neuer Prompt'} +

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Basic Info */} +
+
+
+ + setName(e.target.value)} + placeholder="Interner Name" + style={{ width: '100%', textAlign: 'left' }} + /> +
+ +
+ + setSlug(e.target.value)} + placeholder="technischer_name" + style={{ width: '100%', textAlign: 'left' }} + disabled={!!prompt} + /> +
+
+ +
+ + setDisplayName(e.target.value)} + placeholder="Name für Benutzer (optional)" + style={{ width: '100%', textAlign: 'left' }} + /> +
+ +
+ +