feat: display_name + placeholder picker for prompts (Issue #28)
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:
parent
7a8a5aee98
commit
0c4264de44
20
backend/migrations/018_prompt_display_name.sql
Normal file
20
backend/migrations/018_prompt_display_name.sql
Normal 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;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user