Kansho/frontend/src/pages/AdminProvidersPage.jsx
Lars bef422a429
Some checks failed
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Failing after 2m40s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 16s
Add selectable LLM profiles and treat LAN Ollama as local.
Saved presets keep URL and model per stage so detect can switch to Ollama without re-entering settings or sending plaintext to OpenRouter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 12:48:25 +02:00

414 lines
15 KiB
JavaScript

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 (
<section className="card">
<h1>Schnittstellen</h1>
<p className="muted">
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.
</p>
{data && <p className="muted">Laufzeit: {data.runtime_env}</p>}
{banner && <p>{banner}</p>}
{error && <p className="error">{error}</p>}
{notice && <p>{notice}</p>}
{data && (
<>
{data.roles.map((role) => {
const form = forms[role.role] || emptyRole()
return (
<form
key={role.role}
className="stack"
onSubmit={(e) => { e.preventDefault(); saveRole(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') : (role.local ? 'nicht nötig (lokal)' : 'fehlt')}</dd>
<dt>Modus</dt>
<dd>{role.local ? 'lokal' : 'extern'} · {role.mode}</dd>
</dl>
<label>
Aktives Profil
<select
value={form.profile_id}
onChange={(e) => {
if (!e.target.value) return
applyRole(role.role, e.target.value)
}}
disabled={saving.startsWith('activate-') || !profiles.length}
>
<option value="">Profil wählen </option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.title} ({profile.local ? 'lokal' : 'extern'}{profile.model ? ` · ${profile.model}` : ''})
</option>
))}
</select>
</label>
<label>
Name
<input value={form.name} onChange={(e) => updateRole(role.role, 'name', e.target.value)} />
</label>
<label>
URL
<input value={form.url} onChange={(e) => updateRole(role.role, 'url', e.target.value)} />
</label>
<label>
Modell für diese Stufe
<input value={form.model} onChange={(e) => updateRole(role.role, 'model', e.target.value)} />
</label>
<label>
Als neues Profil speichern (optional)
<input
value={form.saveAsTitle}
onChange={(e) => updateRole(role.role, 'saveAsTitle', e.target.value)}
placeholder="z. B. Ollama Detect"
/>
</label>
<label className="check">
<input type="checkbox" checked={form.zdr} onChange={(e) => updateRole(role.role, 'zdr', e.target.checked)} />
Zero Data Retention
</label>
<label className="check">
<input
type="checkbox"
checked={form.no_train}
onChange={(e) => updateRole(role.role, 'no_train', e.target.checked)}
/>
kein Provider-Training
</label>
<label>
API-Key {role.key_present ? '(leer lassen, um den bestehenden zu behalten)' : '(für externe URL)'}
<input
type="password"
autoComplete="off"
value={form.key}
onChange={(e) => updateRole(role.role, 'key', e.target.value)}
placeholder={role.local ? 'lokal ohne Key' : (role.role === 'detect' ? 'leer = Key des Sprachmodells' : '')}
/>
</label>
<button type="submit" disabled={saving === role.role}>
{saving === role.role ? 'Speichert …' : 'Stufe speichern'}
</button>
</form>
)
})}
<h2>Gespeicherte Profile</h2>
<p className="muted">Ein Profil kann Maskierung, Sprachmodell oder beiden Stufen dienen. Löschen geht nur, wenn es gerade keiner Stufe zugeordnet ist.</p>
<ul className="stack">
{profiles.map((profile) => (
<li key={profile.id} className="row-item">
<button
type="button"
className="ghost"
onClick={() => setDraft({ ...profile })}
>
{profile.title}
<span className="muted"> · {profile.local ? 'lokal' : 'extern'} · {profile.model || 'kein Modell'} · {profile.url}</span>
</button>
<div className="row-actions">
<button type="button" className="ghost" onClick={() => setDraft({ ...profile })}>Bearbeiten</button>
<button
type="button"
className="ghost"
disabled={usedIds.has(profile.id) || saving === `delete-${profile.id}`}
onClick={() => removeProfile(profile.id)}
>
Löschen
</button>
</div>
</li>
))}
</ul>
<form className="stack" onSubmit={(e) => { e.preventDefault(); saveDraft() }}>
<h2>{draft.id ? 'Profil bearbeiten' : 'Neues Profil'}</h2>
<label>
Profilname
<input
value={draft.title}
onChange={(e) => setDraft((prev) => ({ ...prev, title: e.target.value }))}
placeholder="z. B. Ollama (LAN)"
/>
</label>
<label>
Anbietername
<input
value={draft.name}
onChange={(e) => setDraft((prev) => ({ ...prev, name: e.target.value }))}
placeholder="ollama oder openrouter"
/>
</label>
<label>
URL
<input
value={draft.url}
onChange={(e) => setDraft((prev) => ({ ...prev, url: e.target.value }))}
/>
</label>
<label>
Modell
<input
list="kansho-profile-models"
value={draft.model}
onChange={(e) => setDraft((prev) => ({ ...prev, model: e.target.value }))}
placeholder="Ollama-Tag oder OpenRouter-ID"
/>
<datalist id="kansho-profile-models">
{catalog.map((item) => (
<option key={item.id} value={item.id}>
{item.name && item.name !== item.id ? item.name : item.id}
</option>
))}
</datalist>
</label>
<label className="check">
<input
type="checkbox"
checked={draft.zdr}
onChange={(e) => setDraft((prev) => ({ ...prev, zdr: e.target.checked }))}
/>
Zero Data Retention
</label>
<label className="check">
<input
type="checkbox"
checked={draft.no_train}
onChange={(e) => setDraft((prev) => ({ ...prev, no_train: e.target.checked }))}
/>
kein Provider-Training
</label>
<div className="row-actions">
<button type="submit" disabled={saving === 'draft'}>
{saving === 'draft' ? 'Speichert …' : (draft.id ? 'Profil speichern' : 'Profil anlegen')}
</button>
{draft.id && (
<button type="button" className="ghost" onClick={() => setDraft(emptyDraft())}>
Neu
</button>
)}
</div>
</form>
<p className="muted">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.</p>
</>
)}
</section>
)
}