feat: AI-Prompts flexibilisierung - Frontend complete (Issue #28, Part 2)
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Frontend components:
- PromptEditModal.jsx: Full editor with preview, generator, optimizer
- PromptGenerator.jsx: KI-assisted prompt creation from goal description
- Extended api.js with 10 new prompt endpoints

Navigation:
- Added /admin/prompts route to App.jsx
- Added KI-Prompts section to AdminPanel with navigation button

Features complete:
 Admin can create/edit/delete/duplicate prompts
 Category filtering and reordering
 Preview prompts with real user data
 KI generates prompts from goal + example data
 KI analyzes and optimizes existing prompts
 Side-by-side comparison original vs optimized

Ready for testing: http://dev.mitai.jinkendo.de/admin/prompts

Issue #28 Phase 2 complete - 13-18h estimated, ~14h actual

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-24 15:35:55 +01:00
parent 500de132b9
commit c8cf375399
5 changed files with 599 additions and 0 deletions

View File

@ -30,6 +30,7 @@ import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage' import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage' import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
import AdminPromptsPage from './pages/AdminPromptsPage'
import SubscriptionPage from './pages/SubscriptionPage' import SubscriptionPage from './pages/SubscriptionPage'
import SleepPage from './pages/SleepPage' import SleepPage from './pages/SleepPage'
import RestDaysPage from './pages/RestDaysPage' import RestDaysPage from './pages/RestDaysPage'
@ -184,6 +185,7 @@ function AppShell() {
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/> <Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/> <Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/> <Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/> <Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes> </Routes>
</main> </main>

View File

@ -0,0 +1,379 @@
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 [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 [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 || '')
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,
description,
category,
template,
active
})
} else {
// Create new
if (!slug.trim()) {
alert('Bitte Slug eingeben')
return
}
await api.createPrompt({
name,
slug,
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)
}
}
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:16, marginBottom:24}}>
<div>
<label className="form-label">Titel *</label>
<input
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="z.B. Protein-Analyse"
/>
</div>
{!prompt?.id && (
<div>
<label className="form-label">Slug * (eindeutig, keine Leerzeichen)</label>
<input
className="form-input"
value={slug}
onChange={e => setSlug(e.target.value)}
placeholder="z.B. protein_analyse"
/>
</div>
)}
<div>
<label className="form-label">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)"
/>
</div>
<div>
<label className="form-label">Kategorie</label>
<select
className="form-select"
value={category}
onChange={e => setCategory(e.target.value)}
>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.label}</option>
))}
</select>
</div>
<div>
<label className="form-label">Template *</label>
<textarea
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={{fontFamily:'monospace', fontSize:12}}
/>
<div style={{fontSize:11, color:'var(--text3)', marginTop:4}}>
Nutze Platzhalter wie {`{{weight_aktuell}}`}, {`{{protein_avg}}`}, etc.
</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'
}}>
{template}
</pre>
</div>
<div>
<strong>Optimiert:</strong>
<pre style={{
marginTop:4, padding:12, background:'#d4edda',
borderRadius:8, fontSize:11, maxHeight:200, overflow:'auto'
}}>
{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>
)
}

View File

@ -0,0 +1,189 @@
import { useState } from 'react'
import { api } from '../utils/api'
export default function PromptGenerator({ onGenerated, onClose }) {
const [goal, setGoal] = useState('')
const [dataCategories, setDataCategories] = useState(['körper', 'ernährung'])
const [exampleOutput, setExampleOutput] = useState('')
const [exampleData, setExampleData] = useState(null)
const [generating, setGenerating] = useState(false)
const [loadingExample, setLoadingExample] = useState(false)
const categories = [
{ id: 'körper', label: 'Körper (Gewicht, KF, Umfänge)' },
{ id: 'ernährung', label: 'Ernährung (Kalorien, Makros)' },
{ id: 'training', label: 'Training (Volumen, Typen)' },
{ id: 'schlaf', label: 'Schlaf (Dauer, Qualität)' },
{ id: 'vitalwerte', label: 'Vitalwerte (RHR, HRV, VO2max)' },
{ id: 'ziele', label: 'Ziele (Fortschritt, Prognose)' }
]
const handleToggleCategory = (catId) => {
if (dataCategories.includes(catId)) {
setDataCategories(dataCategories.filter(c => c !== catId))
} else {
setDataCategories([...dataCategories, catId])
}
}
const handleShowExampleData = async () => {
try {
setLoadingExample(true)
const placeholders = await api.listPlaceholders()
setExampleData(placeholders)
} catch (e) {
alert('Fehler: ' + e.message)
} finally {
setLoadingExample(false)
}
}
const handleGenerate = async () => {
if (!goal.trim()) {
alert('Bitte Ziel beschreiben')
return
}
if (dataCategories.length === 0) {
alert('Bitte mindestens einen Datenbereich wählen')
return
}
try {
setGenerating(true)
const result = await api.generatePrompt({
goal,
data_categories: dataCategories,
example_output: exampleOutput || null
})
onGenerated(result)
} catch (e) {
alert('Fehler beim Generieren: ' + e.message)
} finally {
setGenerating(false)
}
}
return (
<div style={{
position:'fixed', inset:0, background:'rgba(0,0,0,0.6)',
display:'flex', alignItems:'center', justifyContent:'center',
zIndex:1001, padding:20
}}>
<div style={{
background:'var(--bg)', borderRadius:12, maxWidth:700, width:'100%',
maxHeight:'90vh', overflow:'auto', padding:24
}}>
<h2 style={{margin:'0 0 24px 0', fontSize:18, fontWeight:600}}>
🤖 KI-Prompt generieren
</h2>
{/* Step 1: Goal */}
<div style={{marginBottom:24}}>
<label className="form-label">
1 Was möchtest du analysieren?
</label>
<textarea
className="form-input"
value={goal}
onChange={e => setGoal(e.target.value)}
rows={3}
placeholder="Beispiel: Ich möchte wissen ob meine Proteinzufuhr ausreichend ist für Muskelaufbau und wie ich sie optimieren kann."
/>
</div>
{/* Step 2: Data Categories */}
<div style={{marginBottom:24}}>
<label className="form-label">
2 Welche Daten sollen analysiert werden?
</label>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:8}}>
{categories.map(cat => (
<label
key={cat.id}
style={{
display:'flex', alignItems:'center', gap:8,
padding:8, background:'var(--surface)', borderRadius:6,
cursor:'pointer', fontSize:13
}}
>
<input
type="checkbox"
checked={dataCategories.includes(cat.id)}
onChange={() => handleToggleCategory(cat.id)}
/>
{cat.label}
</label>
))}
</div>
<button
onClick={handleShowExampleData}
disabled={loadingExample}
style={{
marginTop:12, fontSize:12, padding:'6px 12px',
background:'var(--surface2)', border:'1px solid var(--border)',
borderRadius:6, cursor:'pointer'
}}
>
{loadingExample ? 'Lädt...' : '📊 Beispieldaten anzeigen'}
</button>
</div>
{/* Example Data */}
{exampleData && (
<div style={{
marginBottom:24, padding:12, background:'var(--surface2)',
borderRadius:8, fontSize:11, fontFamily:'monospace',
maxHeight:200, overflow:'auto'
}}>
<strong style={{fontFamily:'var(--font)'}}>Deine aktuellen Daten:</strong>
<pre style={{marginTop:8, whiteSpace:'pre-wrap'}}>
{JSON.stringify(exampleData, null, 2)}
</pre>
</div>
)}
{/* Step 3: Desired Format */}
<div style={{marginBottom:24}}>
<label className="form-label">
3 Gewünschtes Antwort-Format (optional)
</label>
<textarea
className="form-input"
value={exampleOutput}
onChange={e => setExampleOutput(e.target.value)}
rows={4}
placeholder={'Beispiel:\n## Analyse\n[Bewertung]\n\n## Empfehlungen\n- Punkt 1\n- Punkt 2'}
style={{fontFamily:'monospace', fontSize:12}}
/>
</div>
{/* Info Box */}
<div style={{
marginBottom:24, padding:12, background:'var(--surface)',
border:'1px solid var(--border)', borderRadius:8, fontSize:12,
color:'var(--text3)'
}}>
<strong style={{color:'var(--text2)'}}>💡 Tipp:</strong> Je präziser deine Zielbeschreibung,
desto besser der generierte Prompt. Die KI wählt automatisch passende Platzhalter
und strukturiert die Analyse optimal.
</div>
{/* Actions */}
<div style={{display:'flex', gap:12}}>
<button
className="btn btn-primary"
onClick={handleGenerate}
disabled={generating || !goal.trim() || dataCategories.length === 0}
style={{flex:1}}
>
{generating ? '⏳ Generiere...' : '🚀 Prompt generieren'}
</button>
<button className="btn" onClick={onClose}>
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@ -451,6 +451,23 @@ export default function AdminPanel() {
</Link> </Link>
</div> </div>
</div> </div>
{/* KI-Prompts Section */}
<div className="card">
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> KI-Prompts (v9f)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte AI-Prompts mit KI-Unterstützung: Generiere, optimiere und organisiere Prompts.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/prompts">
<button className="btn btn-secondary btn-full">
🤖 KI-Prompts verwalten
</button>
</Link>
</div>
</div>
</div> </div>
) )
} }

View File

@ -282,4 +282,16 @@ export const api = {
fd.append('file', file) fd.append('file', file)
return req('/blood-pressure/import/omron', {method:'POST', body:fd}) return req('/blood-pressure/import/omron', {method:'POST', body:fd})
}, },
// AI Prompts Management (Issue #28)
listAdminPrompts: () => req('/prompts'),
createPrompt: (d) => req('/prompts', json(d)),
updatePrompt: (id,d) => req(`/prompts/${id}`, jput(d)),
deletePrompt: (id) => req(`/prompts/${id}`, {method:'DELETE'}),
duplicatePrompt: (id) => req(`/prompts/${id}/duplicate`, json({})),
reorderPrompts: (order) => req('/prompts/reorder', jput(order)),
previewPrompt: (tpl) => req('/prompts/preview', json({template:tpl})),
generatePrompt: (d) => req('/prompts/generate', json(d)),
optimizePrompt: (id) => req(`/prompts/${id}/optimize`, json({})),
listPlaceholders: () => req('/prompts/placeholders'),
} }