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

347 lines
13 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 [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 [pvPreview, setPvPreview] = useState(null)
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 || '')
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,
})
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 = {
title: pvTitle,
goal: pvGoal,
execution: pvExec,
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-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.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>
<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>
<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>
<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>
)
}