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
|
└── technical/ # MEMBERSHIP_SYSTEM.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Aktuelle Version: v9c (komplett) 🚀 Production seit 21.03.2026
|
## Aktuelle Version: v9d + Issue #28 🚀 Development seit 25.03.2026
|
||||||
|
|
||||||
### Implementiert ✅
|
### Implementiert ✅
|
||||||
- Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting
|
- 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`
|
📚 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
|
## Feature-Roadmap
|
||||||
|
|
||||||
> 📋 **Detaillierte Roadmap:** `.claude/docs/ROADMAP.md` (Phasen 0-3, Timeline, Abhängigkeiten)
|
> 📋 **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