import { useEffect, useMemo, 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, profile_id: '', key: '', saveAsTitle: '' }) const emptyDraft = () => ({ id: '', title: '', name: '', url: 'http://192.168.2.144:11434/v1/chat/completions', model: '', zdr: true, no_train: true }) function statusLabel(role) { if (role.role === 'detect' && !role.url) return 'nicht konfiguriert, fail-closed' 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)' } if (role.ready) return 'bereit' if (!role.key_present && !role.local) return 'Key fehlt' if (!role.url) return 'URL fehlt' if (role.local && !role.model) return 'Modell fehlt' return 'nicht bereit' } function detectBanner(data) { if (!data) return '' const detect = (data.roles || []).find((role) => role.role === 'detect') if (detect?.local) { return 'Maskierung läuft lokal (LAN/Ollama). Generate sieht nur den maskierten Kontext.' } if (data.runtime_env === 'production' && data.remote_plaintext_reason === 'operator_override') { return 'Production erlaubt derzeit externes Klartext-Detect (KANSHO_ALLOW_REMOTE_DETECT). Ziel bleibt ein lokales Detect-Modell.' } if (data.runtime_env === 'production' && data.remote_plaintext_allowed === false) { return 'Production blockiert externes Klartext-Detect. Maskierung braucht ein lokales Profil oder die Operator-Freigabe.' } return '' } export default function AdminProvidersPage() { const { session } = useAuth() const [data, setData] = useState(null) const [forms, setForms] = useState({}) const [draft, setDraft] = useState(emptyDraft()) const [catalog, setCatalog] = useState([]) const [error, setError] = useState('') const [notice, setNotice] = useState('') const [saving, setSaving] = useState('') const applyPayload = (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), profile_id: role.profile_id || '', key: '', saveAsTitle: '' } } setForms(next) } const load = () => api('/api/admin/providers', { token: session.token }) .then(applyPayload) .catch((e) => setError(e.message)) const loadModels = (url) => { if (!url) { setCatalog([]) return } api(`/api/admin/providers/models?url=${encodeURIComponent(url)}`, { token: session.token }) .then((payload) => setCatalog(payload.models || payload.roles?.generate || [])) .catch(() => setCatalog([])) } useEffect(() => { load() }, [session.token]) useEffect(() => { loadModels(draft.url) }, [draft.url, session.token]) const profiles = data?.profiles || [] const usedIds = useMemo( () => new Set((data?.roles || []).map((role) => role.profile_id).filter(Boolean)), [data] ) const updateRole = (role, field, value) => { setForms((prev) => ({ ...prev, [role]: { ...(prev[role] || emptyRole()), [field]: value } })) } const applyRole = async (role, profileId) => { setError('') setNotice('') setSaving(`activate-${role}`) try { const payload = await api(`/api/admin/providers/${role}/activate`, { token: session.token, method: 'POST', body: { profile_id: profileId } }) applyPayload(payload) setNotice(`${role === 'detect' ? 'Maskierung' : 'Sprachmodell'}: Profil übernommen.`) } catch (err) { setError(err.message) } finally { setSaving('') } } const saveRole = 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, profile_id: form.profile_id || undefined, save_as_title: form.saveAsTitle.trim() || undefined } if (form.key.trim()) body.key = form.key.trim() const payload = await api(`/api/admin/providers/${role}`, { token: session.token, method: 'PUT', body }) applyPayload(payload) setNotice(form.saveAsTitle.trim() ? 'Neues Profil gespeichert und dieser Stufe zugeordnet.' : 'Stufe gespeichert. Zugeordnetes Profil wurde mitaktualisiert.') } catch (err) { setError(err.message) } finally { setSaving('') } } const saveDraft = async () => { setError('') setNotice('') setSaving('draft') try { const body = { title: draft.title, name: draft.name, url: draft.url, model: draft.model, zdr: draft.zdr, no_train: draft.no_train } const payload = draft.id ? await api(`/api/admin/llm-profiles/${draft.id}`, { token: session.token, method: 'PUT', body }) : await api('/api/admin/llm-profiles', { token: session.token, method: 'POST', body }) applyPayload(payload) const saved = payload.profile || profiles.find((item) => item.id === draft.id) setDraft(saved ? { ...saved } : emptyDraft()) setNotice(draft.id ? 'Profil aktualisiert.' : 'Profil angelegt. Du kannst es den Stufen zuordnen, ohne die Felder neu einzugeben.') } catch (err) { setError(err.message) } finally { setSaving('') } } const removeProfile = async (profileId) => { setError('') setNotice('') setSaving(`delete-${profileId}`) try { const payload = await api(`/api/admin/llm-profiles/${profileId}`, { token: session.token, method: 'DELETE' }) applyPayload(payload) if (draft.id === profileId) setDraft(emptyDraft()) setNotice('Profil gelöscht.') } catch (err) { setError(err.message) } finally { setSaving('') } } const banner = detectBanner(data) return (

Schnittstellen

LLM-Profile merken URL, Modell und Policy. Maskierung und Sprachmodell wählen je ein Profil. Beim Wechsel musst du die Daten nicht neu eintippen. Keys bleiben in der Umgebung, nicht im Profil.

{data &&

Laufzeit: {data.runtime_env}

} {banner &&

{banner}

} {error &&

{error}

} {notice &&

{notice}

} {data && ( <> {data.roles.map((role) => { const form = forms[role.role] || emptyRole() return (
{ e.preventDefault(); saveRole(role.role) }} >

{role.title || role.role}

{role.task &&

{role.task}

}
Status
{statusLabel(role)}
Key
{role.key_present ? (role.key_source === 'generate' ? 'über Sprachmodell' : 'gesetzt') : (role.local ? 'nicht nötig (lokal)' : 'fehlt')}
Modus
{role.local ? 'lokal' : 'extern'} · {role.mode}
) })}

Gespeicherte Profile

Ein Profil kann Maskierung, Sprachmodell oder beiden Stufen dienen. Löschen geht nur, wenn es gerade keiner Stufe zugeordnet ist.

{ e.preventDefault(); saveDraft() }}>

{draft.id ? 'Profil bearbeiten' : 'Neues Profil'}

{draft.id && ( )}

Ohne ZDR und ohne Trainingsverbot bleibt ein externes Sprachmodell fail-closed. Gateway lässt sich darüber nicht umgehen. Ollama auf der LAN-Adresse gilt als lokal.

)}
) }