shinkan-jinkendo/frontend/src/pages/AdminAiPromptsPage.jsx
Lars 93b8d09d05
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
Implement OpenRouter Model Support in AI Prompt System
- 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.
2026-05-22 12:37:43 +02:00

368 lines
14 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 [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, 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>
)
}