Kansho/frontend/src/pages/AdminProvidersPage.jsx

232 lines
8.3 KiB
React
Raw Normal View History

2026-08-25 13:57:23 +02:00
import { useEffect, useState } from 'react'
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
const emptyRole = () => ({
role: '',
name: '',
url: '',
model: '',
zdr: true,
no_train: true,
key: ''
})
const FALLBACK_MODELS = {
generate: ['openai/gpt-5.4', 'openai/gpt-4o', 'openai/gpt-4o-mini', 'anthropic/claude-sonnet-4'],
detect: ['openai/gpt-4.1-nano', 'openai/gpt-4o-mini', 'llama3.1']
}
2026-08-25 13:57:23 +02:00
function statusLabel(role) {
if (role.role === 'detect' && !role.url) return 'nicht konfiguriert, fail-closed'
2026-08-25 13:57:23 +02:00
if (role.role === 'detect' && role.url && !role.local && role.ready) {
if (role.remote_plaintext_allowed === false) return 'extern konfiguriert, Produktiv blockiert'
if (role.remote_plaintext_reason === 'operator_override') {
return 'bereit (Übergang: externes Klartext-Detect)'
}
return 'bereit (Development/Test, extern)'
2026-08-25 13:57:23 +02:00
}
if (role.ready) return 'bereit'
if (!role.key_present && !role.local) return 'Key fehlt'
if (!role.url) return 'URL fehlt'
return 'nicht bereit'
}
function detectBanner(data) {
if (!data) return ''
if (data.runtime_env === 'production' && data.remote_plaintext_reason === 'operator_override') {
return 'Production erlaubt derzeit externes Klartext-Detect (KANSHO_ALLOW_REMOTE_DETECT). Das Gateway bleibt aktiv; Generate sieht nur maskierten Kontext. Ziel bleibt ein lokales Detect-Modell.'
}
if (data.runtime_env === 'production' && data.remote_plaintext_allowed === false) {
return 'Production blockiert externes Klartext-Detect. Maskierung braucht eine lokale URL oder die Operator-Freigabe, sonst entsteht kein Impuls.'
}
return ''
}
2026-08-25 13:57:23 +02:00
export default function AdminProvidersPage() {
const { session } = useAuth()
const [data, setData] = useState(null)
const [forms, setForms] = useState({})
const [catalog, setCatalog] = useState({ generate: [], detect: [] })
2026-08-25 13:57:23 +02:00
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [saving, setSaving] = useState('')
const load = () =>
api('/api/admin/providers', { token: session.token })
.then((payload) => {
setData(payload)
const next = {}
for (const role of payload.roles) {
next[role.role] = {
role: role.role,
name: role.name || '',
url: role.url || '',
model: role.model || '',
zdr: Boolean(role.zdr),
no_train: Boolean(role.no_train),
key: ''
}
}
setForms(next)
})
.catch((e) => setError(e.message))
const loadCatalog = () =>
api('/api/admin/providers/models', { token: session.token })
.then((payload) => {
setCatalog({
generate: payload.roles?.generate || [],
detect: payload.roles?.detect || []
})
})
.catch(() => {
setCatalog({ generate: [], detect: [] })
})
useEffect(() => {
load()
loadCatalog()
}, [session.token])
2026-08-25 13:57:23 +02:00
const update = (role, field, value) => {
setForms((prev) => ({ ...prev, [role]: { ...(prev[role] || emptyRole()), [field]: value } }))
}
const save = async (role) => {
const form = forms[role]
if (!form) return
setError('')
setNotice('')
setSaving(role)
try {
const body = {
name: form.name,
url: form.url,
model: form.model,
zdr: form.zdr,
no_train: form.no_train
}
if (form.key.trim()) body.key = form.key.trim()
const payload = await api(`/api/admin/providers/${role}`, {
token: session.token,
method: 'PUT',
body
})
setData(payload)
setForms((prev) => ({ ...prev, [role]: { ...prev[role], key: '' } }))
setNotice(form.key.trim() ? `${role}: gespeichert, Key in .env geschrieben.` : `${role}: gespeichert.`)
loadCatalog()
2026-08-25 13:57:23 +02:00
} catch (err) {
setError(err.message)
} finally {
setSaving('')
}
}
const modelOptions = (role) => {
const fromCatalog = catalog[role] || []
if (fromCatalog.length) return fromCatalog
return (FALLBACK_MODELS[role] || []).map((id) => ({ id, name: id }))
}
const banner = detectBanner(data)
2026-08-25 13:57:23 +02:00
return (
<section className="card">
<h1>Schnittstellen</h1>
<p className="muted">
Zwei Stufen, zwei eigene Modelle: Maskierung prüft den persönlichen Text, das Sprachmodell schreibt Dialogzug und Journalentwurf.
Pro Stufe URL und Modell getrennt wählen. Ohne Detection kein Generate. Der Secret-Key wird nie angezeigt und nie in der Datenbank gespeichert.
2026-08-25 13:57:23 +02:00
</p>
{data && (
<p className="muted">Laufzeit: {data.runtime_env}</p>
)}
{banner && <p>{banner}</p>}
2026-08-25 13:57:23 +02:00
{error && <p className="error">{error}</p>}
{notice && <p>{notice}</p>}
{data && (
<>
{data.roles.map((role) => {
const form = forms[role.role] || emptyRole()
const options = modelOptions(role.role)
const listId = `kansho-models-${role.role}`
2026-08-25 13:57:23 +02:00
return (
<form
key={role.role}
className="stack"
onSubmit={(e) => { e.preventDefault(); save(role.role) }}
>
<h2>{role.title || role.role}</h2>
{role.task && <p className="muted">{role.task}</p>}
<dl className="meta">
<dt>Status</dt>
<dd>{statusLabel(role)}</dd>
<dt>Key</dt>
<dd>{role.key_present ? (role.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : 'fehlt'}</dd>
<dt>Modus</dt>
<dd>{role.local ? 'lokal' : 'extern'} · {role.mode}</dd>
</dl>
<label>
Name
<input value={form.name} onChange={(e) => update(role.role, 'name', e.target.value)} />
</label>
<label>
URL
<input
value={form.url}
onChange={(e) => update(role.role, 'url', e.target.value)}
placeholder={role.role === 'detect' ? 'http://127.0.0.1:11434/v1/chat/completions' : ''}
/>
</label>
<label>
Modell für diese Stufe
<input
list={listId}
value={form.model}
onChange={(e) => update(role.role, 'model', e.target.value)}
placeholder={role.role === 'detect' ? 'openai/gpt-4.1-nano oder lokales Ollama-Modell' : 'openai/gpt-5.4'}
/>
<datalist id={listId}>
{options.map((item) => (
<option key={item.id} value={item.id}>
{item.name && item.name !== item.id ? item.name : item.id}
</option>
))}
</datalist>
2026-08-25 13:57:23 +02:00
</label>
<label className="check">
<input type="checkbox" checked={form.zdr} onChange={(e) => update(role.role, 'zdr', e.target.checked)} />
Zero Data Retention
</label>
<label className="check">
<input
type="checkbox"
checked={form.no_train}
onChange={(e) => update(role.role, 'no_train', e.target.checked)}
/>
kein Provider-Training
</label>
<label>
API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(pflichtig für externe URL)'}
<input
type="password"
autoComplete="off"
value={form.key}
onChange={(e) => update(role.role, 'key', e.target.value)}
placeholder={role.role === 'detect' ? 'leer = Key des Sprachmodells' : ''}
/>
</label>
<button type="submit" disabled={saving === role.role}>
{saving === role.role ? 'Speichert …' : 'Speichern'}
</button>
</form>
)
})}
<p className="muted">Ohne ZDR und ohne Trainingsverbot bleibt das Sprachmodell fail-closed. Maskierung wählt keine Operation. Gateway lässt sich darüber nicht umgehen.</p>
</>
)}
</section>
)
}