All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
- Added `openrouter_model` field to the `ai_prompts` table, allowing for optional model overrides per prompt. - Updated the `exercise_ai` module to utilize the effective OpenRouter model based on prompt-specific settings, enhancing flexibility in AI interactions. - Enhanced the admin interface to support OpenRouter model configuration for prompts, improving usability for Superadmins. - Incremented application version to 0.8.161 and updated changelog to reflect these changes, including migration details and new functionality.
368 lines
14 KiB
JavaScript
368 lines
14 KiB
JavaScript
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 [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 || '')
|
||
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 = {
|
||
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.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>
|
||
<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>
|
||
)
|
||
}
|