feat: display_name + placeholder picker for prompts (Issue #28)
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 14s

Migration 018:
- Add display_name column to ai_prompts
- Migrate existing prompts from hardcoded SLUG_LABELS
- Fallback: name if display_name is NULL

Backend:
- PromptCreate/Update models with display_name field
- create/update/duplicate endpoints handle display_name
- Fallback: use name if display_name not provided

Frontend:
- PromptEditModal: display_name input field
- Placeholder picker: button + dropdown with all placeholders
- Shows example values, inserts {{placeholder}} on click
- Analysis.jsx: use display_name instead of SLUG_LABELS

User-facing changes:
- Prompts now show custom display names (e.g. '🍽️ Ernährung')
- Admin can edit display names instead of hardcoded labels
- Template editor has 'Platzhalter einfügen' button
- No more hardcoded SLUG_LABELS in frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-25 06:31:25 +01:00
parent 7a8a5aee98
commit 0c4264de44
5 changed files with 133 additions and 11 deletions

View File

@ -0,0 +1,20 @@
-- Migration 018: Add display_name to ai_prompts for user-facing labels
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS display_name VARCHAR(100);
-- Migrate existing prompts from hardcoded SLUG_LABELS
UPDATE ai_prompts SET display_name = '🔍 Gesamtanalyse' WHERE slug = 'gesamt' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🫧 Körperkomposition' WHERE slug = 'koerper' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🍽️ Ernährung' WHERE slug = 'ernaehrung' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🏋️ Aktivität' WHERE slug = 'aktivitaet' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '❤️ Gesundheitsindikatoren' WHERE slug = 'gesundheit' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🎯 Zielfortschritt' WHERE slug = 'ziele' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🔬 Mehrstufige Gesamtanalyse' WHERE slug = 'pipeline' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Körper-Analyse (JSON)' WHERE slug = 'pipeline_body' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Ernährungs-Analyse (JSON)' WHERE slug = 'pipeline_nutrition' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Aktivitäts-Analyse (JSON)' WHERE slug = 'pipeline_activity' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Synthese' WHERE slug = 'pipeline_synthesis' AND display_name IS NULL;
UPDATE ai_prompts SET display_name = '🔬 Pipeline: Zielabgleich' WHERE slug = 'pipeline_goals' AND display_name IS NULL;
-- Fallback: use name as display_name if still NULL
UPDATE ai_prompts SET display_name = name WHERE display_name IS NULL;

View File

@ -134,6 +134,7 @@ class AdminProfileUpdate(BaseModel):
class PromptCreate(BaseModel): class PromptCreate(BaseModel):
name: str name: str
slug: str slug: str
display_name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
template: str template: str
category: str = 'ganzheitlich' category: str = 'ganzheitlich'
@ -143,6 +144,7 @@ class PromptCreate(BaseModel):
class PromptUpdate(BaseModel): class PromptUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
display_name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
template: Optional[str] = None template: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None

View File

@ -61,9 +61,9 @@ def create_prompt(p: PromptCreate, session: dict=Depends(require_admin)):
prompt_id = str(uuid.uuid4()) prompt_id = str(uuid.uuid4())
cur.execute( cur.execute(
"""INSERT INTO ai_prompts (id, name, slug, description, template, category, active, sort_order, created, updated) """INSERT INTO ai_prompts (id, name, slug, display_name, description, template, category, active, sort_order, created, updated)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""", VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""",
(prompt_id, p.name, p.slug, p.description, p.template, p.category, p.active, p.sort_order) (prompt_id, p.name, p.slug, p.display_name or p.name, p.description, p.template, p.category, p.active, p.sort_order)
) )
return {"id": prompt_id, "slug": p.slug} return {"id": prompt_id, "slug": p.slug}
@ -82,6 +82,9 @@ def update_prompt(prompt_id: str, p: PromptUpdate, session: dict=Depends(require
if p.name is not None: if p.name is not None:
updates.append('name=%s') updates.append('name=%s')
values.append(p.name) values.append(p.name)
if p.display_name is not None:
updates.append('display_name=%s')
values.append(p.display_name)
if p.description is not None: if p.description is not None:
updates.append('description=%s') updates.append('description=%s')
values.append(p.description) values.append(p.description)
@ -140,10 +143,12 @@ def duplicate_prompt(prompt_id: str, session: dict=Depends(require_admin)):
new_name = f"{original['name']} (Kopie)" new_name = f"{original['name']} (Kopie)"
new_slug = f"{original['slug']}_copy_{uuid.uuid4().hex[:6]}" new_slug = f"{original['slug']}_copy_{uuid.uuid4().hex[:6]}"
new_display_name = f"{original.get('display_name') or original['name']} (Kopie)"
cur.execute( cur.execute(
"""INSERT INTO ai_prompts (id, name, slug, description, template, category, active, sort_order, created, updated) """INSERT INTO ai_prompts (id, name, slug, display_name, description, template, category, active, sort_order, created, updated)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""", VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)""",
(new_id, new_name, new_slug, original['description'], original['template'], (new_id, new_name, new_slug, new_display_name, original['description'], original['template'],
original.get('category', 'ganzheitlich'), original['active'], original['sort_order']) original.get('category', 'ganzheitlich'), original['active'], original['sort_order'])
) )

View File

@ -5,6 +5,7 @@ import PromptGenerator from './PromptGenerator'
export default function PromptEditModal({ prompt, onSave, onClose }) { export default function PromptEditModal({ prompt, onSave, onClose }) {
const [name, setName] = useState('') const [name, setName] = useState('')
const [slug, setSlug] = useState('') const [slug, setSlug] = useState('')
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [category, setCategory] = useState('ganzheitlich') const [category, setCategory] = useState('ganzheitlich')
const [template, setTemplate] = useState('') const [template, setTemplate] = useState('')
@ -13,6 +14,8 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
const [preview, setPreview] = useState(null) const [preview, setPreview] = useState(null)
const [unknownPlaceholders, setUnknownPlaceholders] = useState([]) const [unknownPlaceholders, setUnknownPlaceholders] = useState([])
const [showGenerator, setShowGenerator] = useState(false) const [showGenerator, setShowGenerator] = useState(false)
const [showPlaceholders, setShowPlaceholders] = useState(false)
const [placeholders, setPlaceholders] = useState([])
const [optimization, setOptimization] = useState(null) const [optimization, setOptimization] = useState(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -31,6 +34,7 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
if (prompt) { if (prompt) {
setName(prompt.name || '') setName(prompt.name || '')
setSlug(prompt.slug || '') setSlug(prompt.slug || '')
setDisplayName(prompt.display_name || '')
setDescription(prompt.description || '') setDescription(prompt.description || '')
setCategory(prompt.category || 'ganzheitlich') setCategory(prompt.category || 'ganzheitlich')
setTemplate(prompt.template || '') setTemplate(prompt.template || '')
@ -92,6 +96,7 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
// Update existing // Update existing
await api.updatePrompt(prompt.id, { await api.updatePrompt(prompt.id, {
name, name,
display_name: displayName || null,
description, description,
category, category,
template, template,
@ -106,6 +111,7 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
await api.createPrompt({ await api.createPrompt({
name, name,
slug, slug,
display_name: displayName || null,
description, description,
category, category,
template, template,
@ -137,6 +143,28 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
} }
} }
const loadPlaceholders = async () => {
try {
const data = await api.listPlaceholders()
// Flatten nested structure into simple list
const flatList = []
Object.entries(data).forEach(([category, items]) => {
Object.entries(items).forEach(([key, value]) => {
flatList.push({ key, value, category })
})
})
setPlaceholders(flatList)
setShowPlaceholders(true)
} catch (e) {
alert('Fehler beim Laden der Platzhalter: ' + e.message)
}
}
const insertPlaceholder = (key) => {
setTemplate(prev => prev + ` {{${key}}}`)
setShowPlaceholders(false)
}
return ( return (
<div style={{ <div style={{
position:'fixed', inset:0, background:'rgba(0,0,0,0.5)', position:'fixed', inset:0, background:'rgba(0,0,0,0.5)',
@ -208,6 +236,22 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
</div> </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> <div>
<label className="form-label" style={{display:'block', marginBottom:6}}>Beschreibung</label> <label className="form-label" style={{display:'block', marginBottom:6}}>Beschreibung</label>
<textarea <textarea
@ -244,9 +288,60 @@ export default function PromptEditModal({ prompt, onSave, onClose }) {
placeholder="Du bist ein Ernährungsexperte. Analysiere folgende Daten: {{nutrition_summary}}" placeholder="Du bist ein Ernährungsexperte. Analysiere folgende Daten: {{nutrition_summary}}"
style={{width:'100%', textAlign:'left', fontFamily:'monospace', fontSize:12, resize:'vertical'}} style={{width:'100%', textAlign:'left', fontFamily:'monospace', fontSize:12, resize:'vertical'}}
/> />
<div style={{fontSize:11, color:'var(--text3)', marginTop:4}}> <div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginTop:6}}>
Nutze Platzhalter wie {`{{weight_aktuell}}`}, {`{{protein_avg}}`}, etc. <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> </div>
{/* Placeholders Dropdown */}
{showPlaceholders && placeholders.length > 0 && (
<div style={{
marginTop:8, padding:12, background:'var(--surface)',
border:'1px solid var(--border)', borderRadius:8,
maxHeight:300, overflow:'auto'
}}>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:8}}>
<strong style={{fontSize:13}}>Verfügbare Platzhalter:</strong>
<button
onClick={() => setShowPlaceholders(false)}
style={{
background:'none', border:'none', cursor:'pointer',
fontSize:18, color:'var(--text3)'
}}
>
×
</button>
</div>
<div style={{display:'grid', gap:6}}>
{placeholders.map((p, i) => (
<button
key={i}
onClick={() => insertPlaceholder(p.key)}
style={{
textAlign:'left', padding:'6px 10px',
background:'var(--surface2)', border:'1px solid var(--border)',
borderRadius:6, cursor:'pointer', fontSize:12,
fontFamily:'monospace'
}}
>
<div style={{fontWeight:600, color:'var(--accent)'}}>{`{{${p.key}}}`}</div>
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
Beispiel: {p.value}
</div>
</button>
))}
</div>
</div>
)}
</div> </div>
<div style={{display:'flex', alignItems:'center', gap:8}}> <div style={{display:'flex', alignItems:'center', gap:8}}>

View File

@ -31,7 +31,7 @@ function InsightCard({ ins, onDelete, defaultOpen=false }) {
onClick={()=>setOpen(o=>!o)}> onClick={()=>setOpen(o=>!o)}>
<div style={{flex:1}}> <div style={{flex:1}}>
<div style={{fontSize:13,fontWeight:600}}> <div style={{fontSize:13,fontWeight:600}}>
{SLUG_LABELS[ins.scope] || ins.scope} {ins.display_name || SLUG_LABELS[ins.scope] || ins.scope}
</div> </div>
<div style={{fontSize:11,color:'var(--text3)'}}> <div style={{fontSize:11,color:'var(--text3)'}}>
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')} {dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
@ -310,7 +310,7 @@ export default function Analysis() {
<div style={{display:'flex',alignItems:'flex-start',gap:12}}> <div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}> <div style={{flex:1}}>
<div className="badge-container-right" style={{fontWeight:600,fontSize:15}}> <div className="badge-container-right" style={{fontWeight:600,fontSize:15}}>
<span>{SLUG_LABELS[p.slug]||p.name}</span> <span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />} {aiUsage && <UsageBadge {...aiUsage} />}
</div> </div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>} {p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
@ -398,7 +398,7 @@ export default function Analysis() {
<div style={{display:'flex',alignItems:'center',gap:10}}> <div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}> <div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}> <div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
{SLUG_LABELS[p.slug]||p.name} {p.display_name || SLUG_LABELS[p.slug] || p.name}
{!p.active && <span style={{fontSize:10,color:'#D85A30', {!p.active && <span style={{fontSize:10,color:'#D85A30',
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}> Deaktiviert</span>} background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}> Deaktiviert</span>}
</div> </div>