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
550 lines
19 KiB
JavaScript
550 lines
19 KiB
JavaScript
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 (
|
|
<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
|
|
className="form-input"
|
|
value={template}
|
|
onChange={e => setTemplate(e.target.value)}
|
|
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 }}>
|
|
Verwende {{'{{'}}platzhalter{{'}}'}} für dynamische Werte (z.B. {{'{{'}}weight_data{{'}}'}}, {{'{{'}}nutrition_data{{'}}'}})
|
|
</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>
|
|
) : (
|
|
<textarea
|
|
className="form-input"
|
|
value={p.template || ''}
|
|
onChange={e => updateStagePrompt(stage.stage, pIdx, 'template', e.target.value)}
|
|
rows={3}
|
|
placeholder="Inline-Template mit {{placeholders}}..."
|
|
style={{ fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }}
|
|
/>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div style={{
|
|
display: 'flex', gap: 12, justifyContent: 'flex-end',
|
|
paddingTop: 16, borderTop: '1px solid var(--border)'
|
|
}}>
|
|
<button className="btn" onClick={onClose}>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleSave}
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Speichern...' : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|