shinkan-jinkendo/frontend/src/pages/AdminAiPromptsPage.jsx

446 lines
18 KiB
React
Raw Normal View History

import { useCallback, useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { Sparkles } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
/**
* Pflege von ai_prompts (OpenRouter-Vorlagen) — nur Superadmin.
*/
export default function AdminAiPromptsPage() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [prompts, setPrompts] = useState([])
const [catalog, setCatalog] = useState(null)
const [selectedId, setSelectedId] = useState(null)
const [detail, setDetail] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [draftName, setDraftName] = useState('')
const [draftDesc, setDraftDesc] = useState('')
const [draftTemplate, setDraftTemplate] = useState('')
const [draftOpenrouterModel, setDraftOpenrouterModel] = useState('')
const [draftActive, setDraftActive] = useState(true)
const [pvTitle, setPvTitle] = useState('Testübung')
const [pvGoal, setPvGoal] = useState('<p>Ziel hier</p>')
const [pvExec, setPvExec] = useState('<p>Ablauf hier</p>')
const [pvHint, setPvHint] = useState('')
const [pvFocusId, setPvFocusId] = useState('')
const [pvGoalQuery, setPvGoalQuery] = useState(
'Mae Geri vom Grundschritt bis zur kontrollierten Kumite-Nähe'
)
const [pvUserNotes, setPvUserNotes] = useState('Fokus Breitensport, ohne Wettkampfdruck.')
const [pvMaxSteps, setPvMaxSteps] = useState('5')
const [pvSearchQuery, setPvSearchQuery] = useState('')
const [pvPreview, setPvPreview] = useState(null)
const selectedSlug = (detail?.slug || '').trim().toLowerCase()
const isExercisePreviewSlug = [
'exercise_summary',
'exercise_skill_suggestions',
'exercise_instruction_rewrite',
].includes(selectedSlug)
const isPlanningPreviewSlug = selectedSlug.startsWith('planning_')
const loadList = useCallback(async () => {
const [pList, cat] = await Promise.all([
api.listAdminAiPrompts(),
api.getAdminAiPromptPlaceholdersCatalog(),
])
setPrompts(Array.isArray(pList) ? pList : [])
setCatalog(cat || null)
}, [])
useEffect(() => {
if (!isSuperadmin) return
let cancelled = false
;(async () => {
setLoading(true)
setError('')
try {
await loadList()
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, loadList])
useEffect(() => {
if (!isSuperadmin || !selectedId) {
setDetail(null)
return
}
let cancelled = false
;(async () => {
try {
const d = await api.getAdminAiPrompt(selectedId)
if (!cancelled) {
setDetail(d)
setDraftName(d.display_name || '')
setDraftDesc(d.description || '')
setDraftTemplate(d.template || '')
setDraftOpenrouterModel(
typeof d.openrouter_model === 'string' ? d.openrouter_model : ''
)
setDraftActive(!!d.active)
setPvPreview(null)
}
} catch (e) {
if (!cancelled) setError(e.message || String(e))
}
})()
return () => {
cancelled = true
}
}, [isSuperadmin, selectedId])
const save = async () => {
if (!detail?.id) return
setSaving(true)
setError('')
try {
await api.updateAdminAiPrompt(detail.id, {
template: draftTemplate,
display_name: draftName,
description: draftDesc,
active: draftActive,
openrouter_model: draftOpenrouterModel.trim(),
})
await loadList()
const nd = await api.getAdminAiPrompt(detail.id)
setDetail(nd)
setPvPreview(null)
} catch (e) {
setError(e.message || String(e))
} finally {
setSaving(false)
}
}
const resetTemplate = async () => {
if (!detail?.id || !detail.has_reference_template) return
if (!confirm('Template auf gespeicherten Referenztext zurücksetzen?')) return
setSaving(true)
try {
const nd = await api.resetAdminAiPromptTemplate(detail.id)
setDetail(nd)
setDraftTemplate(nd.template || '')
await loadList()
} catch (e) {
setError(e.message || String(e))
} finally {
setSaving(false)
}
}
const runPreview = async () => {
if (!detail?.id) return
setError('')
try {
const body = {}
if (isPlanningPreviewSlug) {
body.goal_query = pvGoalQuery.trim() || undefined
body.user_notes = pvUserNotes.trim() || undefined
const ms = parseInt(String(pvMaxSteps).trim(), 10)
if (Number.isFinite(ms) && ms >= 2 && ms <= 10) body.max_steps = ms
const sq = pvSearchQuery.trim()
if (sq) body.search_query = sq
} else if (isExercisePreviewSlug) {
body.title = pvTitle
body.goal = pvGoal
body.execution = pvExec
body.focus_hint = pvHint || undefined
const fid = parseInt(String(pvFocusId).trim(), 10)
if (Number.isFinite(fid) && fid >= 1) {
body.focus_areas_context = [{ focus_area_id: fid, is_primary: true }]
}
}
const r = await api.previewAdminAiPrompt(detail.id, body)
setPvPreview(r)
} catch (e) {
setError(e.message || String(e))
}
}
if (!isSuperadmin) return <Navigate to="/" replace />
if (loading) {
return (
<div style={{ padding: 16 }}>
<AdminPageNav />
<div className="card" style={{ marginTop: 16, padding: 24, textAlign: 'center' }}>
<div className="spinner" />
</div>
</div>
)
}
return (
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px', paddingBottom: 96 }}>
<AdminPageNav />
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<Sparkles size={22} />
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>KI Prompts</h1>
</div>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0 }}>
Datenbankvorlagen (<code>ai_prompts</code>) für Übungs- und Planungs-KI. Platzhalter im Mustache-Stil werden
serverseitig aufgelöst — die Vorschau unten ruft kein externes Modell auf.
</p>
{error ? <p style={{ color: 'var(--danger)' }}>{error}</p> : null}
<div style={{ display: 'grid', gridTemplateColumns: '260px minmax(0,1fr)', gap: 16 }}>
<div className="card" style={{ padding: 12 }}>
<strong style={{ fontSize: '13px' }}>Prompts</strong>
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0 0', maxHeight: '70vh', overflow: 'auto' }}>
{(prompts || []).map((p) => (
<li key={p.id} style={{ marginBottom: 8 }}>
<button
type="button"
className={`btn ${selectedId === p.id ? 'btn-primary' : 'btn-secondary'}`}
style={{
width: '100%',
justifyContent: 'flex-start',
fontSize: 12,
padding: '8px 10px',
alignItems: 'flex-start',
flexDirection: 'column',
textAlign: 'left',
}}
onClick={() => {
setSelectedId(p.id)
setError('')
}}
>
<span style={{ fontWeight: 600 }}>{p.display_name}</span>
<span style={{ opacity: 0.85, marginTop: 2 }}>{p.slug}</span>
{!p.active ? (
<span style={{ color: 'var(--danger)', fontSize: 11 }}>
inaktiv
</span>
) : null}
{p.openrouter_model ? (
<span style={{ fontSize: 11, color: 'var(--text3)' }} title="OpenRouter-Modell für diesen Prompt">
Model: <code>{p.openrouter_model}</code>
</span>
) : null}
{p.is_modified ? <span style={{ fontSize: 11 }}>(von Referenz abweichend)</span> : null}
</button>
</li>
))}
</ul>
</div>
<div>
{!selectedId ? (
<p style={{ color: 'var(--text3)' }}>Prompt links wählen.</p>
) : (
<div className="card" style={{ padding: 16 }}>
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: '12px', color: 'var(--text3)' }}>
slug: <code>{detail?.slug}</code> · Ausgabe:{' '}
<code>{detail?.output_format}</code> · Kategorie: <code>{detail?.category}</code>
</div>
<div className="form-row" style={{ marginTop: 10 }}>
<label className="form-label">Name</label>
<input className="form-input" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={draftDesc}
onChange={(e) => setDraftDesc(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">OpenRouter-Modell (optional)</label>
<input
className="form-input"
placeholder="Leer = Server-OPENROUTER_MODEL · z.B. anthropic/claude-3.5-haiku"
autoComplete="off"
spellCheck={false}
value={draftOpenrouterModel}
onChange={(e) => setDraftOpenrouterModel(e.target.value)}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<input type="checkbox" checked={draftActive} onChange={(e) => setDraftActive(e.target.checked)} />
Aktiv
</label>
<label className="form-label">Template (Mustache-Zeilen mit {'{{'}}name{'}'}})</label>
<textarea
className="form-input"
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
rows={22}
value={draftTemplate}
onChange={(e) => setDraftTemplate(e.target.value)}
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => save()}>
Speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={saving || !detail?.has_reference_template}
title={!detail?.has_reference_template ? 'Nach Migration 069 bzw. manuell default_template gesetzt' : ''}
onClick={() => resetTemplate()}
>
Auf Referenz zurücksetzen
</button>
</div>
<details style={{ marginTop: 20 }}>
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Platzhalter-Katalog (Übung)</summary>
{catalog?.placeholders ? (
<ul style={{ paddingLeft: 18, marginTop: 8 }}>
{catalog.placeholders.map((ph) => (
<li key={ph.key} style={{ marginBottom: 10, fontSize: 13 }}>
<code>{ph.placeholder}</code> — {ph.description}{' '}
<span style={{ color: 'var(--text3)' }}>
[{Array.isArray(ph.used_by_slugs) ? ph.used_by_slugs.join(', ') : ''}]
</span>
</li>
))}
</ul>
) : (
<p>Wird geladen …</p>
)}
</details>
<section style={{ marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 12px', fontSize: '15px' }}>Vorschau (ohne OpenRouter)</h4>
{isPlanningPreviewSlug ? (
<>
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 0 }}>
Beispielkontext für Planungs-Prompts — echte Katalog-Auszüge aus der Datenbank, übrige Felder
sind repräsentative Demo-Daten.
</p>
<div className="form-row">
<label className="form-label">Zielanfrage (goal_query)</label>
<textarea
className="form-input"
rows={3}
value={pvGoalQuery}
onChange={(e) => setPvGoalQuery(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Trainer-Notizen (user_notes)</label>
<textarea
className="form-input"
rows={2}
value={pvUserNotes}
onChange={(e) => setPvUserNotes(e.target.value)}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">max_steps (Roadmap)</label>
<input
className="form-input"
type="number"
min={2}
max={10}
value={pvMaxSteps}
onChange={(e) => setPvMaxSteps(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Suchanfrage (optional)</label>
<input
className="form-input"
placeholder="Leer = goal_query"
value={pvSearchQuery}
onChange={(e) => setPvSearchQuery(e.target.value)}
/>
</div>
</div>
</>
) : isExercisePreviewSlug ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">Titel</label>
<input className="form-input" value={pvTitle} onChange={(e) => setPvTitle(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Fokus-ID (optional, Retrieval‑Raster)</label>
<input
className="form-input"
placeholder="numerisch"
value={pvFocusId}
onChange={(e) => setPvFocusId(e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Fokus-Hinweistext</label>
<input className="form-input" value={pvHint} onChange={(e) => setPvHint(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Ziel (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvGoal} onChange={(e) => setPvGoal(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Durchführung (HTML möglich)</label>
<textarea className="form-input" rows={4} value={pvExec} onChange={(e) => setPvExec(e.target.value)} />
</div>
</>
) : (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>
Für diesen Slug ist noch kein Beispielkontext hinterlegt — es wird nur das Roh-Template ohne
Ersetzung angezeigt.
</p>
)}
<button type="button" className="btn btn-secondary" onClick={() => runPreview()}>
Platzhalter auflösen
</button>
{pvPreview?.warning ? (
<p style={{ marginTop: 10, color: 'var(--text3)', fontSize: 13 }}>{pvPreview.warning}</p>
) : null}
{pvPreview?.placeholders_remaining?.length ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>
Unbekannte Platzhalter im Ergebnis:{' '}
{pvPreview.placeholders_remaining.join(', ')}
</p>
) : null}
{pvPreview?.resolved_template != null ? (
<pre
style={{
marginTop: 12,
padding: 12,
background: 'var(--surface2)',
borderRadius: 8,
fontSize: 12,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 360,
overflow: 'auto',
}}
>
{pvPreview.resolved_template}
</pre>
) : null}
</section>
</div>
)}
</div>
</div>
</div>
)
}