feat: Issue #28 complete - unified prompt system (Phase 4)
Cleanup & Documentation: - Removed deprecated components: PipelineConfigModal, PromptEditModal - Updated CLAUDE.md with Issue #28 summary - Kept old backend endpoints for backward-compatibility Summary of all 4 phases: ✅ Phase 1: DB Migration (unified schema) ✅ Phase 2: Backend Executor (universal execution engine) ✅ Phase 3: Frontend UI (consolidated interface) ✅ Phase 4: Cleanup & Docs Key improvements: - Unlimited dynamic stages (no hardcoded limit) - Multiple prompts per stage (parallel execution) - Base prompts (reusable) + Pipeline prompts (workflows) - Inline templates or references - JSON output enforceable - Cross-module correlations possible Ready for testing on dev.mitai.jinkendo.de
This commit is contained in:
parent
31e2c24a8a
commit
2f3314cd36
49
CLAUDE.md
49
CLAUDE.md
|
|
@ -56,7 +56,7 @@ frontend/src/
|
|||
└── technical/ # MEMBERSHIP_SYSTEM.md
|
||||
```
|
||||
|
||||
## Aktuelle Version: v9c (komplett) 🚀 Production seit 21.03.2026
|
||||
## Aktuelle Version: v9d + Issue #28 🚀 Development seit 25.03.2026
|
||||
|
||||
### Implementiert ✅
|
||||
- Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting
|
||||
|
|
@ -188,6 +188,53 @@ frontend/src/
|
|||
|
||||
📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md`
|
||||
|
||||
### Issue #28: Unified Prompt System ✅ (Development 25.03.2026)
|
||||
|
||||
**AI-Prompts Flexibilisierung - Komplett überarbeitet:**
|
||||
|
||||
- ✅ **Unified Prompt System (4 Phasen):**
|
||||
- **Phase 1:** DB-Migration - Schema erweitert
|
||||
- `ai_prompts` um `type`, `stages`, `output_format`, `output_schema` erweitert
|
||||
- Alle Prompts zu 1-stage Pipelines migriert
|
||||
- Pipeline-Configs in `ai_prompts` konsolidiert
|
||||
- **Phase 2:** Backend Executor
|
||||
- `prompt_executor.py` - universeller Executor für base + pipeline
|
||||
- Dynamische Placeholder-Auflösung (`{{stage_N_key}}`)
|
||||
- JSON-Output-Validierung
|
||||
- Multi-stage parallele Ausführung
|
||||
- Reference (Basis-Prompts) + Inline (Templates) Support
|
||||
- **Phase 3:** Frontend UI Consolidation
|
||||
- `UnifiedPromptModal` - ein Editor für beide Typen
|
||||
- `AdminPromptsPage` - Tab-Switcher entfernt, Type-Filter hinzugefügt
|
||||
- Stage-Editor mit Add/Remove/Reorder
|
||||
- Mobile-ready Design
|
||||
- **Phase 4:** Cleanup & Docs
|
||||
- Deprecated Komponenten entfernt (PipelineConfigModal, PromptEditModal)
|
||||
- Old endpoints behalten für Backward-Compatibility
|
||||
|
||||
**Features:**
|
||||
- Unbegrenzte dynamische Stages (keine 3-Stage Limitierung mehr)
|
||||
- Mehrere Prompts pro Stage (parallel)
|
||||
- Zwei Prompt-Typen: `base` (wiederverwendbar) + `pipeline` (Workflows)
|
||||
- Inline-Templates oder Referenzen zu Basis-Prompts
|
||||
- JSON-Output erzwingbar pro Prompt
|
||||
- Cross-Module Korrelationen möglich
|
||||
|
||||
**Migrations:**
|
||||
- Migration 020: Unified Prompt System Schema
|
||||
|
||||
**Backend Endpoints:**
|
||||
- `POST /api/prompts/execute` - Universeller Executor
|
||||
- `POST /api/prompts/unified` - Create unified prompt
|
||||
- `PUT /api/prompts/unified/{id}` - Update unified prompt
|
||||
|
||||
**UI:**
|
||||
- Admin → KI-Prompts: Type-Filter (Alle/Basis/Pipeline)
|
||||
- Neuer Prompt-Editor mit dynamischem Stage-Builder
|
||||
- Inline editing von Stages + Prompts
|
||||
|
||||
📚 Details: `.claude/docs/functional/AI_PROMPTS.md`
|
||||
|
||||
## Feature-Roadmap
|
||||
|
||||
> 📋 **Detaillierte Roadmap:** `.claude/docs/ROADMAP.md` (Phasen 0-3, Timeline, Abhängigkeiten)
|
||||
|
|
|
|||
|
|
@ -1,386 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const MODULES = [
|
||||
{ id: 'körper', label: 'Körper', defaultDays: 30 },
|
||||
{ id: 'ernährung', label: 'Ernährung', defaultDays: 30 },
|
||||
{ id: 'training', label: 'Training', defaultDays: 14 },
|
||||
{ id: 'schlaf', label: 'Schlaf', defaultDays: 14 },
|
||||
{ id: 'vitalwerte', label: 'Vitalwerte', defaultDays: 7 },
|
||||
{ id: 'mentales', label: 'Mentales', defaultDays: 7 },
|
||||
{ id: 'ziele', label: 'Ziele', defaultDays: null }, // No timeframe for goals
|
||||
]
|
||||
|
||||
export default function PipelineConfigModal({ config, onSave, onClose }) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isDefault, setIsDefault] = useState(false)
|
||||
const [active, setActive] = useState(true)
|
||||
|
||||
// Modules state: {körper: true, ernährung: false, ...}
|
||||
const [modules, setModules] = useState({})
|
||||
|
||||
// Timeframes state: {körper: 30, ernährung: 30, ...}
|
||||
const [timeframes, setTimeframes] = useState({})
|
||||
|
||||
// Stage prompts
|
||||
const [stage1Prompts, setStage1Prompts] = useState([])
|
||||
const [stage2Prompt, setStage2Prompt] = useState('')
|
||||
const [stage3Prompt, setStage3Prompt] = useState('')
|
||||
|
||||
// Available prompts (for dropdowns)
|
||||
const [availablePrompts, setAvailablePrompts] = useState([])
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAvailablePrompts()
|
||||
|
||||
if (config) {
|
||||
// Edit mode
|
||||
setName(config.name || '')
|
||||
setDescription(config.description || '')
|
||||
setIsDefault(config.is_default || false)
|
||||
setActive(config.active ?? true)
|
||||
setModules(config.modules || {})
|
||||
setTimeframes(config.timeframes || {})
|
||||
setStage1Prompts(config.stage1_prompts || [])
|
||||
setStage2Prompt(config.stage2_prompt || '')
|
||||
setStage3Prompt(config.stage3_prompt || '')
|
||||
} else {
|
||||
// New mode - set defaults
|
||||
const defaultModules = {}
|
||||
const defaultTimeframes = {}
|
||||
MODULES.forEach(m => {
|
||||
defaultModules[m.id] = false
|
||||
if (m.defaultDays) defaultTimeframes[m.id] = m.defaultDays
|
||||
})
|
||||
setModules(defaultModules)
|
||||
setTimeframes(defaultTimeframes)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const loadAvailablePrompts = async () => {
|
||||
try {
|
||||
const prompts = await api.listAdminPrompts()
|
||||
setAvailablePrompts(prompts)
|
||||
} catch (e) {
|
||||
setError('Fehler beim Laden der Prompts: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleModule = (moduleId) => {
|
||||
setModules(prev => ({ ...prev, [moduleId]: !prev[moduleId] }))
|
||||
}
|
||||
|
||||
const updateTimeframe = (moduleId, days) => {
|
||||
setTimeframes(prev => ({ ...prev, [moduleId]: parseInt(days) || 0 }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validation
|
||||
if (!name.trim()) {
|
||||
setError('Bitte Namen eingeben')
|
||||
return
|
||||
}
|
||||
|
||||
const activeModules = Object.entries(modules).filter(([_, active]) => active).map(([id]) => id)
|
||||
if (activeModules.length === 0) {
|
||||
setError('Mindestens ein Modul muss aktiviert sein')
|
||||
return
|
||||
}
|
||||
|
||||
if (stage1Prompts.length === 0) {
|
||||
setError('Mindestens ein Stage-1-Prompt erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
if (!stage2Prompt) {
|
||||
setError('Stage-2-Prompt (Synthese) erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const data = {
|
||||
name,
|
||||
description,
|
||||
is_default: isDefault,
|
||||
active,
|
||||
modules,
|
||||
timeframes,
|
||||
stage1_prompts: stage1Prompts,
|
||||
stage2_prompt: stage2Prompt,
|
||||
stage3_prompt: stage3Prompt || null
|
||||
}
|
||||
|
||||
if (config?.id) {
|
||||
await api.updatePipelineConfig(config.id, data)
|
||||
} else {
|
||||
await api.createPipelineConfig(data)
|
||||
}
|
||||
|
||||
onSave()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addStage1Prompt = (slug) => {
|
||||
if (slug && !stage1Prompts.includes(slug)) {
|
||||
setStage1Prompts(prev => [...prev, slug])
|
||||
}
|
||||
}
|
||||
|
||||
const removeStage1Prompt = (slug) => {
|
||||
setStage1Prompts(prev => prev.filter(s => s !== slug))
|
||||
}
|
||||
|
||||
// Filter prompts: only pipeline-type prompts
|
||||
const pipelinePrompts = availablePrompts.filter(p =>
|
||||
p.slug && p.slug.startsWith('pipeline_') && p.slug !== 'pipeline_synthesis' && p.slug !== 'pipeline_goals'
|
||||
)
|
||||
|
||||
const synthesisPrompts = availablePrompts.filter(p =>
|
||||
p.slug && (p.slug === 'pipeline_synthesis' || p.slug.includes('synthesis'))
|
||||
)
|
||||
|
||||
const goalsPrompts = availablePrompts.filter(p =>
|
||||
p.slug && (p.slug === 'pipeline_goals' || p.slug.includes('goals') || p.slug.includes('ziele'))
|
||||
)
|
||||
|
||||
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:900, 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}}>
|
||||
{config ? 'Pipeline-Konfiguration bearbeiten' : 'Neue Pipeline-Konfiguration'}
|
||||
</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>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>Name *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="z.B. Alltags-Check"
|
||||
style={{width:'100%', textAlign:'left'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>Beschreibung</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Kurze Beschreibung der Pipeline"
|
||||
style={{width:'100%', textAlign:'left', resize:'vertical'}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<label style={{display:'flex', alignItems:'center', gap:6, cursor:'pointer'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDefault}
|
||||
onChange={e => setIsDefault(e.target.checked)}
|
||||
/>
|
||||
<span style={{fontSize:13}}>Als Standard-Pipeline markieren</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Selection */}
|
||||
<div style={{marginBottom:24}}>
|
||||
<h3 style={{fontSize:16, fontWeight:600, marginBottom:12}}>Module & Zeiträume</h3>
|
||||
<div style={{
|
||||
background:'var(--surface)', padding:16, borderRadius:8,
|
||||
border:'1px solid var(--border)'
|
||||
}}>
|
||||
{MODULES.map(module => (
|
||||
<div key={module.id} style={{
|
||||
display:'flex', alignItems:'center', gap:12,
|
||||
padding:'8px 0', borderBottom:'1px solid var(--border)'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={modules[module.id] || false}
|
||||
onChange={() => toggleModule(module.id)}
|
||||
id={`module-${module.id}`}
|
||||
/>
|
||||
<label htmlFor={`module-${module.id}`} style={{flex:1, fontSize:13, cursor:'pointer'}}>
|
||||
{module.label}
|
||||
</label>
|
||||
|
||||
{module.defaultDays && (
|
||||
<div style={{display:'flex', alignItems:'center', gap:6}}>
|
||||
<input
|
||||
type="number"
|
||||
value={timeframes[module.id] || module.defaultDays}
|
||||
onChange={e => updateTimeframe(module.id, e.target.value)}
|
||||
disabled={!modules[module.id]}
|
||||
min="1"
|
||||
max="365"
|
||||
style={{
|
||||
width:60, padding:'4px 8px', fontSize:12,
|
||||
opacity: modules[module.id] ? 1 : 0.5
|
||||
}}
|
||||
/>
|
||||
<span style={{fontSize:11, color:'var(--text3)'}}>Tage</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage Configuration */}
|
||||
<div style={{marginBottom:24}}>
|
||||
<h3 style={{fontSize:16, fontWeight:600, marginBottom:12}}>Pipeline-Stufen</h3>
|
||||
|
||||
{/* Stage 1 */}
|
||||
<div style={{marginBottom:16}}>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>
|
||||
Stage 1: Parallel-Analysen *
|
||||
</label>
|
||||
<div style={{fontSize:11, color:'var(--text3)', marginBottom:8}}>
|
||||
Diese Prompts laufen parallel und liefern JSON-Summaries für Stage 2
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="form-select"
|
||||
onChange={e => { addStage1Prompt(e.target.value); e.target.value = '' }}
|
||||
style={{width:'100%', marginBottom:8}}
|
||||
>
|
||||
<option value="">+ Prompt hinzufügen</option>
|
||||
{pipelinePrompts.map(p => (
|
||||
<option key={p.slug} value={p.slug} disabled={stage1Prompts.includes(p.slug)}>
|
||||
{p.display_name || p.name} ({p.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{stage1Prompts.length > 0 && (
|
||||
<div style={{display:'flex', flexWrap:'wrap', gap:6}}>
|
||||
{stage1Prompts.map(slug => (
|
||||
<div key={slug} style={{
|
||||
padding:'4px 8px', background:'var(--surface2)', borderRadius:6,
|
||||
fontSize:11, display:'flex', alignItems:'center', gap:6
|
||||
}}>
|
||||
<span>{slug}</span>
|
||||
<X
|
||||
size={14}
|
||||
onClick={() => removeStage1Prompt(slug)}
|
||||
style={{cursor:'pointer', color:'var(--danger)'}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage 2 */}
|
||||
<div style={{marginBottom:16}}>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>
|
||||
Stage 2: Synthese *
|
||||
</label>
|
||||
<div style={{fontSize:11, color:'var(--text3)', marginBottom:8}}>
|
||||
Kombiniert alle Stage-1-Ergebnisse zu einer narrativen Analyse
|
||||
</div>
|
||||
<select
|
||||
className="form-select"
|
||||
value={stage2Prompt}
|
||||
onChange={e => setStage2Prompt(e.target.value)}
|
||||
style={{width:'100%'}}
|
||||
>
|
||||
<option value="">-- Bitte wählen --</option>
|
||||
{synthesisPrompts.map(p => (
|
||||
<option key={p.slug} value={p.slug}>
|
||||
{p.display_name || p.name} ({p.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stage 3 */}
|
||||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>
|
||||
Stage 3: Optional (z.B. Zielabgleich)
|
||||
</label>
|
||||
<div style={{fontSize:11, color:'var(--text3)', marginBottom:8}}>
|
||||
Optional: Zusätzliche Auswertung basierend auf Synthese
|
||||
</div>
|
||||
<select
|
||||
className="form-select"
|
||||
value={stage3Prompt}
|
||||
onChange={e => setStage3Prompt(e.target.value)}
|
||||
style={{width:'100%'}}
|
||||
>
|
||||
<option value="">-- Keine --</option>
|
||||
{goalsPrompts.map(p => (
|
||||
<option key={p.slug} value={p.slug}>
|
||||
{p.display_name || p.name} ({p.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,564 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import PromptGenerator from './PromptGenerator'
|
||||
|
||||
export default function PromptEditModal({ prompt, onSave, onClose }) {
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [category, setCategory] = useState('ganzheitlich')
|
||||
const [template, setTemplate] = useState('')
|
||||
const [active, setActive] = useState(true)
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [unknownPlaceholders, setUnknownPlaceholders] = useState([])
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
const [showPlaceholders, setShowPlaceholders] = useState(false)
|
||||
const [placeholders, setPlaceholders] = useState({})
|
||||
const [placeholderFilter, setPlaceholderFilter] = useState('')
|
||||
const [templateRef, setTemplateRef] = useState(null)
|
||||
const [optimization, setOptimization] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const categories = [
|
||||
{ id: 'körper', label: 'Körper' },
|
||||
{ id: 'ernährung', label: 'Ernährung' },
|
||||
{ id: 'training', label: 'Training' },
|
||||
{ id: 'schlaf', label: 'Schlaf' },
|
||||
{ id: 'vitalwerte', label: 'Vitalwerte' },
|
||||
{ id: 'ziele', label: 'Ziele' },
|
||||
{ id: 'ganzheitlich', label: 'Ganzheitlich' }
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (prompt) {
|
||||
setName(prompt.name || '')
|
||||
setSlug(prompt.slug || '')
|
||||
setDisplayName(prompt.display_name || '')
|
||||
setDescription(prompt.description || '')
|
||||
setCategory(prompt.category || 'ganzheitlich')
|
||||
setTemplate(prompt.template || '')
|
||||
setActive(prompt.active ?? true)
|
||||
}
|
||||
}, [prompt])
|
||||
|
||||
const handlePreview = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await api.previewPrompt(template)
|
||||
setPreview(result.resolved)
|
||||
setUnknownPlaceholders(result.unknown_placeholders || [])
|
||||
} catch (e) {
|
||||
alert('Fehler bei Vorschau: ' + e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!prompt?.id) {
|
||||
alert('Prompt muss erst gespeichert werden bevor er optimiert werden kann')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await api.optimizePrompt(prompt.id)
|
||||
setOptimization(result)
|
||||
} catch (e) {
|
||||
alert('Fehler bei Optimierung: ' + e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyOptimized = () => {
|
||||
if (optimization?.optimized_prompt) {
|
||||
setTemplate(optimization.optimized_prompt)
|
||||
setOptimization(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
alert('Bitte Titel eingeben')
|
||||
return
|
||||
}
|
||||
if (!template.trim()) {
|
||||
alert('Bitte Template eingeben')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
|
||||
if (prompt?.id) {
|
||||
// Update existing
|
||||
await api.updatePrompt(prompt.id, {
|
||||
name,
|
||||
display_name: displayName || null,
|
||||
description,
|
||||
category,
|
||||
template,
|
||||
active
|
||||
})
|
||||
} else {
|
||||
// Create new
|
||||
if (!slug.trim()) {
|
||||
alert('Bitte Slug eingeben')
|
||||
return
|
||||
}
|
||||
await api.createPrompt({
|
||||
name,
|
||||
slug,
|
||||
display_name: displayName || null,
|
||||
description,
|
||||
category,
|
||||
template,
|
||||
active
|
||||
})
|
||||
}
|
||||
|
||||
onSave()
|
||||
} catch (e) {
|
||||
alert('Fehler beim Speichern: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGeneratorResult = (generated) => {
|
||||
setName(generated.suggested_title)
|
||||
setCategory(generated.suggested_category)
|
||||
setTemplate(generated.template)
|
||||
setShowGenerator(false)
|
||||
|
||||
// Auto-generate slug if new prompt
|
||||
if (!prompt?.id) {
|
||||
const autoSlug = generated.suggested_title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
setSlug(autoSlug)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPlaceholders = async () => {
|
||||
try {
|
||||
const data = await api.listPlaceholders()
|
||||
setPlaceholders(data)
|
||||
setShowPlaceholders(true)
|
||||
setPlaceholderFilter('')
|
||||
} catch (e) {
|
||||
alert('Fehler beim Laden der Platzhalter: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const insertPlaceholder = (key) => {
|
||||
if (!templateRef) {
|
||||
// Fallback: append at end
|
||||
setTemplate(prev => prev + ` {{${key}}}`)
|
||||
} else {
|
||||
// Insert at cursor position
|
||||
const start = templateRef.selectionStart
|
||||
const end = templateRef.selectionEnd
|
||||
const text = template
|
||||
const before = text.substring(0, start)
|
||||
const after = text.substring(end)
|
||||
const inserted = `{{${key}}}`
|
||||
|
||||
setTemplate(before + inserted + after)
|
||||
|
||||
// Set cursor position after inserted placeholder
|
||||
setTimeout(() => {
|
||||
templateRef.selectionStart = templateRef.selectionEnd = start + inserted.length
|
||||
templateRef.focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
setShowPlaceholders(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
|
||||
}}>
|
||||
<div style={{
|
||||
background:'var(--bg)', borderRadius:12, maxWidth:900, width:'100%',
|
||||
maxHeight:'90vh', overflow:'auto', padding:24
|
||||
}}>
|
||||
<h2 style={{margin:'0 0 24px 0', fontSize:20, fontWeight:600}}>
|
||||
{prompt ? 'Prompt bearbeiten' : 'Neuer Prompt'}
|
||||
</h2>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div style={{display:'flex', gap:8, marginBottom:24, flexWrap:'wrap'}}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setShowGenerator(true)}
|
||||
style={{fontSize:13}}
|
||||
>
|
||||
🤖 Von KI erstellen lassen
|
||||
</button>
|
||||
{prompt?.id && (
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handleOptimize}
|
||||
disabled={loading}
|
||||
style={{fontSize:13}}
|
||||
>
|
||||
✨ Von KI optimieren lassen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={handlePreview}
|
||||
disabled={loading || !template.trim()}
|
||||
style={{fontSize:13}}
|
||||
>
|
||||
👁️ Vorschau
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div style={{display:'grid', gap:20, marginBottom:24}}>
|
||||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>Titel *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="z.B. Protein-Analyse"
|
||||
style={{width:'100%', textAlign:'left'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!prompt?.id && (
|
||||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>
|
||||
Slug * (eindeutig, keine Leerzeichen)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value)}
|
||||
placeholder="z.B. protein_analyse"
|
||||
style={{width:'100%', textAlign:'left'}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>
|
||||
Anzeigename (in der Anwendung sichtbar)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
placeholder="z.B. 🍽️ Protein-Analyse"
|
||||
style={{width:'100%', textAlign:'left'}}
|
||||
/>
|
||||
<div style={{fontSize:11, color:'var(--text3)', marginTop:4}}>
|
||||
Leer lassen = Titel wird verwendet
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>Beschreibung</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Wofür ist dieser Prompt? (für Admin sichtbar)"
|
||||
style={{width:'100%', textAlign:'left', resize:'vertical'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>Kategorie</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
style={{width:'100%'}}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{display:'block', marginBottom:6}}>Template *</label>
|
||||
<textarea
|
||||
ref={el => setTemplateRef(el)}
|
||||
className="form-input"
|
||||
value={template}
|
||||
onChange={e => setTemplate(e.target.value)}
|
||||
rows={15}
|
||||
placeholder="Du bist ein Ernährungsexperte. Analysiere folgende Daten: {{nutrition_summary}}"
|
||||
style={{width:'100%', textAlign:'left', fontFamily:'monospace', fontSize:12, resize:'vertical'}}
|
||||
/>
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginTop:6}}>
|
||||
<div style={{fontSize:11, color:'var(--text3)'}}>
|
||||
Nutze Platzhalter wie {`{{weight_aktuell}}`}, {`{{protein_avg}}`}, etc.
|
||||
</div>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={loadPlaceholders}
|
||||
type="button"
|
||||
style={{fontSize:12, padding:'4px 10px'}}
|
||||
>
|
||||
📋 Platzhalter einfügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Placeholders Dropdown */}
|
||||
{showPlaceholders && Object.keys(placeholders).length > 0 && (
|
||||
<div style={{
|
||||
marginTop:8, padding:16, background:'var(--surface)',
|
||||
border:'1px solid var(--border)', borderRadius:8,
|
||||
maxHeight:400, overflow:'auto'
|
||||
}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:12}}>
|
||||
<strong style={{fontSize:14}}>Platzhalter einfügen</strong>
|
||||
<button
|
||||
onClick={() => setShowPlaceholders(false)}
|
||||
style={{
|
||||
background:'none', border:'none', cursor:'pointer',
|
||||
fontSize:20, color:'var(--text3)', padding:0
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={placeholderFilter}
|
||||
onChange={e => setPlaceholderFilter(e.target.value)}
|
||||
style={{
|
||||
width:'100%', padding:'6px 10px', marginBottom:12,
|
||||
background:'var(--surface2)', border:'1px solid var(--border)',
|
||||
borderRadius:6, fontSize:12
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Grouped Placeholders */}
|
||||
{Object.entries(placeholders).map(([category, items]) => {
|
||||
// Filter items
|
||||
const filteredItems = items.filter(p =>
|
||||
placeholderFilter === '' ||
|
||||
p.key.toLowerCase().includes(placeholderFilter.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(placeholderFilter.toLowerCase())
|
||||
)
|
||||
|
||||
if (filteredItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={category} style={{marginBottom:16}}>
|
||||
<div style={{
|
||||
fontSize:11, fontWeight:600, color:'var(--text3)',
|
||||
textTransform:'uppercase', letterSpacing:'0.5px',
|
||||
marginBottom:6
|
||||
}}>
|
||||
{category}
|
||||
</div>
|
||||
<div style={{display:'grid', gap:6}}>
|
||||
{filteredItems.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => insertPlaceholder(p.key)}
|
||||
style={{
|
||||
textAlign:'left', padding:'8px 10px',
|
||||
background:'var(--surface2)', border:'1px solid var(--border)',
|
||||
borderRadius:6, cursor:'pointer',
|
||||
transition:'all 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.background = 'var(--accent-light)'
|
||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.background = 'var(--surface2)'
|
||||
e.currentTarget.style.borderColor = 'var(--border)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontWeight:600, fontSize:12, fontFamily:'monospace',
|
||||
color:'var(--accent)', marginBottom:2
|
||||
}}>
|
||||
{`{{${p.key}}}`}
|
||||
</div>
|
||||
<div style={{fontSize:11, color:'var(--text2)', marginBottom:2}}>
|
||||
{p.description}
|
||||
</div>
|
||||
<div style={{fontSize:10, color:'var(--text3)', fontStyle:'italic'}}>
|
||||
Beispiel: {p.example}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* No results */}
|
||||
{placeholderFilter && Object.values(placeholders).every(items =>
|
||||
items.filter(p =>
|
||||
p.key.toLowerCase().includes(placeholderFilter.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(placeholderFilter.toLowerCase())
|
||||
).length === 0
|
||||
) && (
|
||||
<div style={{textAlign:'center', padding:20, color:'var(--text3)', fontSize:12}}>
|
||||
Keine Platzhalter gefunden für "{placeholderFilter}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex', alignItems:'center', gap:8}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={e => setActive(e.target.checked)}
|
||||
id="active-checkbox"
|
||||
/>
|
||||
<label htmlFor="active-checkbox" style={{margin:0, cursor:'pointer'}}>
|
||||
Prompt ist aktiv (für Nutzer sichtbar)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unknown Placeholders Warning */}
|
||||
{unknownPlaceholders.length > 0 && (
|
||||
<div style={{
|
||||
padding:12, background:'#fff3cd', border:'1px solid #ffc107',
|
||||
borderRadius:8, marginBottom:16, fontSize:12
|
||||
}}>
|
||||
<strong>⚠️ Unbekannte Platzhalter:</strong> {unknownPlaceholders.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview && (
|
||||
<div style={{marginBottom:24}}>
|
||||
<h3 style={{fontSize:14, fontWeight:600, marginBottom:8}}>
|
||||
Vorschau (mit echten Daten):
|
||||
</h3>
|
||||
<pre style={{
|
||||
background:'var(--surface2)', padding:16, borderRadius:8,
|
||||
fontSize:12, lineHeight:1.6, whiteSpace:'pre-wrap',
|
||||
maxHeight:300, overflow:'auto'
|
||||
}}>
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optimization Results */}
|
||||
{optimization && (
|
||||
<div style={{
|
||||
marginBottom:24, padding:16, background:'var(--surface)',
|
||||
border:'1px solid var(--border)', borderRadius:8
|
||||
}}>
|
||||
<h3 style={{fontSize:16, fontWeight:600, marginBottom:12}}>
|
||||
✨ Optimierungsvorschläge
|
||||
</h3>
|
||||
|
||||
<div style={{display:'grid', gap:12, fontSize:13}}>
|
||||
<div>
|
||||
<strong>Score:</strong> {optimization.score}/100
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>✅ Stärken:</strong>
|
||||
<ul style={{marginTop:4, paddingLeft:20}}>
|
||||
{optimization.strengths?.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>⚠️ Verbesserungspotenzial:</strong>
|
||||
<ul style={{marginTop:4, paddingLeft:20}}>
|
||||
{optimization.weaknesses?.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>Änderungen:</strong>
|
||||
<p style={{marginTop:4}}>{optimization.changes_summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Side-by-side comparison */}
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:12}}>
|
||||
<div>
|
||||
<strong>Original:</strong>
|
||||
<pre style={{
|
||||
marginTop:4, padding:12, background:'var(--surface2)',
|
||||
borderRadius:8, fontSize:11, maxHeight:200, overflow:'auto',
|
||||
whiteSpace:'pre-wrap', wordBreak:'break-word'
|
||||
}}>
|
||||
{template}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Optimiert:</strong>
|
||||
<pre style={{
|
||||
marginTop:4, padding:12, background:'#d4edda',
|
||||
borderRadius:8, fontSize:11, maxHeight:200, overflow:'auto',
|
||||
whiteSpace:'pre-wrap', wordBreak:'break-word'
|
||||
}}>
|
||||
{optimization.optimized_prompt}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleApplyOptimized}
|
||||
>
|
||||
✅ Optimierte Version übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{display:'flex', gap:12, justifyContent:'flex-end'}}>
|
||||
<button className="btn" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generator Modal */}
|
||||
{showGenerator && (
|
||||
<PromptGenerator
|
||||
onGenerated={handleGeneratorResult}
|
||||
onClose={() => setShowGenerator(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user