mitai-jinkendo/frontend/src/pages/Analysis.jsx
Lars 0c4264de44
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
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>
2026-03-25 06:31:25 +01:00

504 lines
24 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-react'
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import Markdown from '../utils/Markdown'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
const SLUG_LABELS = {
gesamt: '🔍 Gesamtanalyse',
koerper: '🫧 Körperkomposition',
ernaehrung: '🍽️ Ernährung',
aktivitaet: '🏋️ Aktivität',
gesundheit: '❤️ Gesundheitsindikatoren',
ziele: '🎯 Zielfortschritt',
pipeline: '🔬 Mehrstufige Gesamtanalyse',
pipeline_body: '🔬 Pipeline: Körper-Analyse (JSON)',
pipeline_nutrition: '🔬 Pipeline: Ernährungs-Analyse (JSON)',
pipeline_activity: '🔬 Pipeline: Aktivitäts-Analyse (JSON)',
pipeline_synthesis: '🔬 Pipeline: Synthese',
pipeline_goals: '🔬 Pipeline: Zielabgleich',
}
function InsightCard({ ins, onDelete, defaultOpen=false }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="card section-gap" style={{borderLeft:`3px solid var(--accent)`}}>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:open?12:0,cursor:'pointer'}}
onClick={()=>setOpen(o=>!o)}>
<div style={{flex:1}}>
<div style={{fontSize:13,fontWeight:600}}>
{ins.display_name || SLUG_LABELS[ins.scope] || ins.scope}
</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
</div>
</div>
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)',padding:4}}
onClick={e=>{e.stopPropagation();onDelete(ins.id)}}>
<Trash2 size={13}/>
</button>
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
</div>
{open && <Markdown text={ins.content}/>}
</div>
)
}
function PromptEditor({ prompt, onSave, onCancel }) {
const [template, setTemplate] = useState(prompt.template)
const [name, setName] = useState(prompt.name)
const [desc, setDesc] = useState(prompt.description||'')
const VARS = ['{{name}}','{{geschlecht}}','{{height}}','{{goal_weight}}','{{goal_bf_pct}}',
'{{weight_trend}}','{{weight_aktuell}}','{{kf_aktuell}}','{{caliper_summary}}',
'{{circ_summary}}','{{nutrition_summary}}','{{nutrition_detail}}',
'{{protein_ziel_low}}','{{protein_ziel_high}}','{{activity_summary}}',
'{{activity_kcal_summary}}','{{activity_detail}}',
'{{sleep_summary}}','{{sleep_detail}}','{{sleep_avg_duration}}','{{sleep_avg_quality}}',
'{{rest_days_summary}}','{{rest_days_count}}','{{rest_days_types}}',
'{{vitals_summary}}','{{vitals_detail}}','{{vitals_avg_hr}}','{{vitals_avg_hrv}}',
'{{vitals_avg_bp}}','{{vitals_vo2_max}}','{{bp_summary}}']
return (
<div className="card section-gap">
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12}}>
<div className="card-title" style={{margin:0}}>Prompt bearbeiten</div>
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)'}}
onClick={onCancel}><X size={16}/></button>
</div>
<div className="form-row">
<label className="form-label">Name</label>
<input type="text" className="form-input" value={name} onChange={e=>setName(e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<input type="text" className="form-input" value={desc} onChange={e=>setDesc(e.target.value)}/>
<span className="form-unit"/>
</div>
<div style={{marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>
Variablen (antippen zum Einfügen):
</div>
<div style={{display:'flex',flexWrap:'wrap',gap:4}}>
{VARS.map(v=>(
<button key={v} onClick={()=>setTemplate(t=>t+v)}
style={{fontSize:10,padding:'2px 7px',borderRadius:4,border:'1px solid var(--border2)',
background:'var(--surface2)',cursor:'pointer',fontFamily:'monospace',color:'var(--accent)'}}>
{v}
</button>
))}
</div>
</div>
<textarea value={template} onChange={e=>setTemplate(e.target.value)}
style={{width:'100%',minHeight:280,padding:10,fontFamily:'monospace',fontSize:12,
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
color:'var(--text1)',resize:'vertical',lineHeight:1.5,boxSizing:'border-box'}}/>
<div style={{display:'flex',gap:8,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}}
onClick={()=>onSave({name,description:desc,template})}>
<Check size={14}/> Speichern
</button>
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>Abbrechen</button>
</div>
</div>
)
}
export default function Analysis() {
const { canUseAI, isAdmin } = useAuth()
const [prompts, setPrompts] = useState([])
const [allInsights, setAllInsights] = useState([])
const [loading, setLoading] = useState(null)
const [error, setError] = useState(null)
const [editing, setEditing] = useState(null)
const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null)
const [pipelineLoading, setPipelineLoading] = useState(false)
const [aiUsage, setAiUsage] = useState(null) // Phase 3: Usage badge
const loadAll = async () => {
const [p, i] = await Promise.all([
api.listPrompts(),
api.listInsights()
])
setPrompts(Array.isArray(p)?p:[])
setAllInsights(Array.isArray(i)?i:[])
}
useEffect(()=>{
loadAll()
// Load feature usage for badges
api.getFeatureUsage().then(features => {
const aiFeature = features.find(f => f.feature_id === 'ai_calls')
setAiUsage(aiFeature)
}).catch(err => console.error('Failed to load usage:', err))
},[])
const runPipeline = async () => {
setPipelineLoading(true); setError(null); setNewResult(null)
try {
const result = await api.insightPipeline()
setNewResult(result)
await loadAll()
setTab('run')
} catch(e) {
setError('Pipeline-Fehler: ' + e.message)
} finally { setPipelineLoading(false) }
}
const runPrompt = async (slug) => {
setLoading(slug); setError(null); setNewResult(null)
try {
const result = await api.runInsight(slug)
setNewResult(result) // show immediately
await loadAll() // refresh lists
setTab('run') // stay on run tab to see result
} catch(e) {
setError('Fehler: ' + e.message)
} finally { setLoading(null) }
}
const savePrompt = async (promptId, data) => {
const token = localStorage.getItem('bodytrack_token')||''
await fetch(`/api/prompts/${promptId}`, {
method:'PUT',
headers:{'Content-Type':'application/json', 'X-Auth-Token': token},
body:JSON.stringify(data)
})
setEditing(null); await loadAll()
}
const deleteInsight = async (id) => {
if (!confirm('Analyse löschen?')) return
const pid = localStorage.getItem('bodytrack_active_profile')||''
await fetch(`/api/insights/${id}`, {
method:'DELETE', headers: pid ? {'X-Profile-Id':pid} : {}
})
if (newResult?.id === id) setNewResult(null)
await loadAll()
}
// Group insights by scope for history view
const grouped = {}
allInsights.forEach(ins => {
const key = ins.scope || 'sonstige'
grouped[key] = grouped[key] || []
grouped[key].push(ins)
})
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_') && p.slug !== 'pipeline')
// Pipeline is available if the "pipeline" prompt is active
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
const pipelineAvailable = pipelinePrompt?.active ?? true // Default to true if not found (backwards compatibility)
return (
<div>
<h1 className="page-title">KI-Analyse</h1>
<div className="tabs">
<button className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
<button className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
Verlauf
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
</button>
{isAdmin && <button className={'tab'+(tab==='prompts'?' active':'')} onClick={()=>setTab('prompts')}>Prompts</button>}
</div>
{error && (
<div style={{padding:'10px 14px',background:'#FCEBEB',borderRadius:8,fontSize:13,
color:'#D85A30',marginBottom:12,lineHeight:1.5}}>
{error.includes('nicht aktiviert') || error.includes('Limit')
? <>🔒 <strong>KI-Zugang eingeschränkt</strong><br/>
Dein Profil hat keinen Zugang zu KI-Analysen oder das Tageslimit wurde erreicht.
Bitte den Admin kontaktieren.</>
: error}
</div>
)}
{/* ── Analysen starten ── */}
{tab==='run' && (
<div>
{/* Fresh result shown immediately */}
{newResult && (
<div style={{marginBottom:16}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--accent)',marginBottom:8}}>
Neue Analyse erstellt:
</div>
<InsightCard
ins={{...newResult, created: new Date().toISOString()}}
onDelete={deleteInsight}
defaultOpen={true}
/>
</div>
)}
{/* Pipeline button - only if all sub-prompts are active */}
{pipelineAvailable && (
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
<span>🔬 Mehrstufige Gesamtanalyse</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
</div>
{allInsights.find(i=>i.scope==='pipeline') && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Analyse: {dayjs(allInsights.find(i=>i.scope==='pipeline').created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<div
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={runPipeline}
disabled={!!loading||pipelineLoading||(aiUsage && !aiUsage.allowed)}
>
{pipelineLoading
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
</div>
{pipelineLoading && (
<div style={{marginTop:10,padding:'8px 12px',background:'var(--accent-light)',
borderRadius:8,fontSize:12,color:'var(--accent-dark)'}}>
Stufe 1: 3 parallele Analyse-Calls dann Synthese dann Zielabgleich
</div>
)}
</div>
)}
{!canUseAI && (
<div style={{padding:'14px 16px',background:'#FCEBEB',borderRadius:10,
border:'1px solid #D85A3033',marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:'#D85A30',marginBottom:4}}>
🔒 KI-Analysen nicht freigeschaltet
</div>
<div style={{fontSize:13,color:'var(--text2)',lineHeight:1.5}}>
Dein Profil hat keinen Zugang zu KI-Analysen.
Bitte den Admin bitten, KI für dein Profil zu aktivieren
(Einstellungen Admin Profil bearbeiten).
</div>
</div>
)}
{canUseAI && <p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Oder wähle eine Einzelanalyse:
</p>}
{activePrompts.map(p => {
// Show latest existing insight for this prompt
const existing = allInsights.find(i=>i.scope===p.slug)
return (
<div key={p.id} className="card section-gap">
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<div className="badge-container-right" style={{fontWeight:600,fontSize:15}}>
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
{existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Auswertung: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<div
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{flexShrink:0,minWidth:90, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={()=>runPrompt(p.slug)}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
</button>
</div>
</div>
{/* Show existing result collapsed */}
{existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false}/>
</div>
)}
</div>
)
})}
{activePrompts.length===0 && (
<div className="empty-state"><p>Keine aktiven Prompts. Aktiviere im Tab "Prompts".</p></div>
)}
</div>
)}
{/* ── Verlauf gruppiert ── */}
{tab==='history' && (
<div>
{allInsights.length===0
? <div className="empty-state"><h3>Noch keine Analysen</h3></div>
: Object.entries(grouped).map(([scope, ins]) => (
<div key={scope} style={{marginBottom:20}}>
<div style={{fontSize:13,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
{SLUG_LABELS[scope]||scope} ({ins.length})
</div>
{ins.map(i => <InsightCard key={i.id} ins={i} onDelete={deleteInsight}/>)}
</div>
))
}
</div>
)}
{/* ── Prompts ── */}
{tab==='prompts' && (
<div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Passe Prompts an. Variablen wie{' '}
<code style={{fontSize:11,background:'var(--surface2)',padding:'1px 4px',borderRadius:3}}>{'{{name}}'}</code>{' '}
werden automatisch mit deinen Daten befüllt.
</p>
{editing ? (
<PromptEditor prompt={editing}
onSave={(data)=>savePrompt(editing.id,data)}
onCancel={()=>setEditing(null)}/>
) : (() => {
const singlePrompts = prompts.filter(p=>!p.slug.startsWith('pipeline_'))
const pipelinePrompts = prompts.filter(p=>p.slug.startsWith('pipeline_'))
const jsonSlugs = ['pipeline_body','pipeline_nutrition','pipeline_activity']
return (
<>
{/* Single prompts */}
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em',marginBottom:8}}>
Einzelanalysen
</div>
{singlePrompts.map(p=>(
<div key={p.id} className="card section-gap" style={{opacity:p.active?1:0.6}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
{p.display_name || SLUG_LABELS[p.slug] || p.name}
{!p.active && <span style={{fontSize:10,color:'#D85A30',
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}> Deaktiviert</span>}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
</div>
<button className="btn btn-secondary" style={{padding:'5px 8px',fontSize:12}}
onClick={()=>{
const token = localStorage.getItem('bodytrack_token')||''
fetch(`/api/prompts/${p.id}`,{
method:'PUT',
headers:{'Content-Type':'application/json','X-Auth-Token':token},
body:JSON.stringify({active:!p.active})
}).then(loadAll)
}}>
{p.active?'Deaktivieren':'Aktivieren'}
</button>
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
</div>
<div style={{marginTop:8,padding:'8px 10px',background:'var(--surface2)',borderRadius:6,
fontSize:11,fontFamily:'monospace',color:'var(--text3)',maxHeight:60,overflow:'hidden',lineHeight:1.4}}>
{p.template.slice(0,200)}
</div>
</div>
))}
{/* Pipeline prompts */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',margin:'20px 0 8px'}}>
<div style={{fontSize:12,fontWeight:700,color:'var(--text3)',
textTransform:'uppercase',letterSpacing:'0.05em'}}>
Mehrstufige Pipeline
</div>
{(() => {
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
return pipelinePrompt && (
<button className="btn btn-secondary" style={{padding:'5px 12px',fontSize:12}}
onClick={()=>{
const token = localStorage.getItem('bodytrack_token')||''
fetch(`/api/prompts/${pipelinePrompt.id}`,{
method:'PUT',
headers:{'Content-Type':'application/json','X-Auth-Token':token},
body:JSON.stringify({active:!pipelinePrompt.active})
}).then(loadAll)
}}>
{pipelinePrompt.active ? 'Gesamte Pipeline deaktivieren' : 'Gesamte Pipeline aktivieren'}
</button>
)
})()}
</div>
{(() => {
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
const isPipelineActive = pipelinePrompt?.active ?? true
return (
<div style={{padding:'10px 12px',
background: isPipelineActive ? 'var(--warn-bg)' : '#FCEBEB',
borderRadius:8,fontSize:12,
color: isPipelineActive ? 'var(--warn-text)' : '#D85A30',
marginBottom:12,lineHeight:1.6}}>
{isPipelineActive ? (
<> <strong>Hinweis:</strong> Pipeline-Stage-1-Prompts müssen valides JSON zurückgeben.
Halte das JSON-Format im Prompt erhalten. Stage 2 + 3 können frei angepasst werden.</>
) : (
<> <strong>Pipeline deaktiviert:</strong> Die mehrstufige Gesamtanalyse ist aktuell nicht verfügbar.
Aktiviere sie mit dem Schalter oben, um sie auf der Analyse-Seite zu nutzen.</>
)}
</div>
)
})()}
{pipelinePrompts.map(p=>{
const isJson = jsonSlugs.includes(p.slug)
return (
<div key={p.id} className="card section-gap"
style={{borderLeft:`3px solid ${isJson?'var(--warn)':'var(--accent)'}`,opacity:p.active?1:0.6}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:8}}>
{p.name}
{isJson && <span style={{fontSize:10,background:'var(--warn-bg)',
color:'var(--warn-text)',padding:'1px 6px',borderRadius:4}}>JSON-Output</span>}
{!p.active && <span style={{fontSize:10,color:'#D85A30',
background:'#FCEBEB',padding:'2px 8px',borderRadius:4,fontWeight:600}}> Deaktiviert</span>}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:1}}>{p.description}</div>}
</div>
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
onClick={()=>setEditing(p)}><Pencil size={13}/></button>
</div>
<div style={{marginTop:8,padding:'8px 10px',background:'var(--surface2)',borderRadius:6,
fontSize:11,fontFamily:'monospace',color:'var(--text3)',maxHeight:80,overflow:'hidden',lineHeight:1.4}}>
{p.template.slice(0,300)}
</div>
</div>
)
})}
</>
)
})()}
</div>
)}
</div>
)
}