shinkan-jinkendo/frontend/src/pages/AdminAiPromptsPage.jsx
Lars 2148d0aa7f
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m16s
Update AI Prompt System and Admin API
- Incremented version to 1.1 and updated the status to reflect the implementation of core features including `ai_prompts`, `prompt_resolver`, and the Superadmin HTTP API.
- Documented the current API endpoints for managing AI prompts, including CRUD operations and preview functionality.
- Introduced a new placeholder catalog and preview capabilities for the Superadmin interface.
- Enhanced the backend with new functions for handling AI prompt templates and integrated them into the API.
- Updated frontend components to include navigation and routing for the new Admin AI Prompts page.
- Incremented application version to 0.8.158 and updated changelog to reflect these changes.
2026-05-22 11:02:02 +02:00

347 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, RetrievalRaster)</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>
)
}