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>
414 lines
15 KiB
JavaScript
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>
|
|
)
|
|
}
|