2026-09-08 12:48:25 +02:00
import { useEffect , useMemo , useState } from 'react'
2026-08-25 13:57:23 +02:00
import { api } from '../api.js'
import { useAuth } from '../context/AuthContext.jsx'
const emptyRole = ( ) => ( {
role : '' ,
name : '' ,
url : '' ,
model : '' ,
zdr : true ,
no _train : true ,
2026-09-08 12:48:25 +02:00
profile _id : '' ,
key : '' ,
saveAsTitle : ''
2026-08-25 13:57:23 +02:00
} )
2026-09-08 12:48:25 +02:00
const emptyDraft = ( ) => ( {
id : '' ,
title : '' ,
name : '' ,
url : 'http://192.168.2.144:11434/v1/chat/completions' ,
model : '' ,
zdr : true ,
no _train : true
} )
2026-09-08 10:02:28 +02:00
2026-08-25 13:57:23 +02:00
function statusLabel ( role ) {
2026-08-27 10:03:05 +02:00
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 ) {
2026-09-08 10:02:28 +02:00
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'
2026-09-08 12:48:25 +02:00
if ( role . local && ! role . model ) return 'Modell fehlt'
2026-08-25 13:57:23 +02:00
return 'nicht bereit'
}
2026-09-08 10:02:28 +02:00
function detectBanner ( data ) {
if ( ! data ) return ''
2026-09-08 12:48:25 +02:00
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.'
}
2026-09-08 10:02:28 +02:00
if ( data . runtime _env === 'production' && data . remote _plaintext _reason === 'operator_override' ) {
2026-09-08 12:48:25 +02:00
return 'Production erlaubt derzeit externes Klartext-Detect (KANSHO_ALLOW_REMOTE_DETECT). Ziel bleibt ein lokales Detect-Modell.'
2026-09-08 10:02:28 +02:00
}
if ( data . runtime _env === 'production' && data . remote _plaintext _allowed === false ) {
2026-09-08 12:48:25 +02:00
return 'Production blockiert externes Klartext-Detect. Maskierung braucht ein lokales Profil oder die Operator-Freigabe.'
2026-09-08 10:02:28 +02:00
}
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 ( { } )
2026-09-08 12:48:25 +02:00
const [ draft , setDraft ] = useState ( emptyDraft ( ) )
const [ catalog , setCatalog ] = useState ( [ ] )
2026-08-25 13:57:23 +02:00
const [ error , setError ] = useState ( '' )
const [ notice , setNotice ] = useState ( '' )
const [ saving , setSaving ] = useState ( '' )
2026-09-08 12:48:25 +02:00
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 )
}
2026-08-25 13:57:23 +02:00
const load = ( ) =>
api ( '/api/admin/providers' , { token : session . token } )
2026-09-08 12:48:25 +02:00
. then ( applyPayload )
2026-08-25 13:57:23 +02:00
. catch ( ( e ) => setError ( e . message ) )
2026-09-08 12:48:25 +02:00
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 ( [ ] ) )
}
2026-09-08 10:02:28 +02:00
2026-09-08 12:48:25 +02:00
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 ]
)
2026-08-25 13:57:23 +02:00
2026-09-08 12:48:25 +02:00
const updateRole = ( role , field , value ) => {
2026-08-25 13:57:23 +02:00
setForms ( ( prev ) => ( { ... prev , [ role ] : { ... ( prev [ role ] || emptyRole ( ) ) , [ field ] : value } } ) )
}
2026-09-08 12:48:25 +02:00
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 ) => {
2026-08-25 13:57:23 +02:00
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 ,
2026-09-08 12:48:25 +02:00
no _train : form . no _train ,
profile _id : form . profile _id || undefined ,
save _as _title : form . saveAsTitle . trim ( ) || undefined
2026-08-25 13:57:23 +02:00
}
if ( form . key . trim ( ) ) body . key = form . key . trim ( )
const payload = await api ( ` /api/admin/providers/ ${ role } ` , {
token : session . token ,
method : 'PUT' ,
body
} )
2026-09-08 12:48:25 +02:00
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.' )
2026-08-25 13:57:23 +02:00
} catch ( err ) {
setError ( err . message )
} finally {
setSaving ( '' )
}
}
2026-09-08 12:48:25 +02:00
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 ( '' )
}
2026-09-08 10:02:28 +02:00
}
const banner = detectBanner ( data )
2026-08-25 13:57:23 +02:00
return (
< section className = "card" >
< h1 > Schnittstellen < / h1 >
< p className = "muted" >
2026-09-08 12:48:25 +02:00
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 .
2026-08-25 13:57:23 +02:00
< / p >
2026-09-08 12:48:25 +02:00
{ data && < p className = "muted" > Laufzeit : { data . runtime _env } < / p > }
2026-09-08 10:02:28 +02:00
{ banner && < p > { banner } < / p > }
2026-08-25 13:57:23 +02:00
{ error && < p className = "error" > { error } < / p > }
{ notice && < p > { notice } < / p > }
2026-09-08 12:48:25 +02:00
2026-08-25 13:57:23 +02:00
{ data && (
< >
{ data . roles . map ( ( role ) => {
const form = forms [ role . role ] || emptyRole ( )
return (
< form
key = { role . role }
className = "stack"
2026-09-08 12:48:25 +02:00
onSubmit = { ( e ) => { e . preventDefault ( ) ; saveRole ( role . role ) } }
2026-08-25 13:57:23 +02:00
>
< 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 >
2026-09-08 12:48:25 +02:00
< dd > { role . key _present ? ( role . key _source === 'generate' ? 'über Sprachmodell' : 'gesetzt' ) : ( role . local ? 'nicht nötig (lokal)' : 'fehlt' ) } < / dd >
2026-08-25 13:57:23 +02:00
< dt > Modus < / dt >
< dd > { role . local ? 'lokal' : 'extern' } · { role . mode } < / dd >
< / dl >
2026-09-08 12:48:25 +02:00
< 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 >
2026-08-25 13:57:23 +02:00
< label >
Name
2026-09-08 12:48:25 +02:00
< input value = { form . name } onChange = { ( e ) => updateRole ( role . role , 'name' , e . target . value ) } / >
2026-08-25 13:57:23 +02:00
< / label >
< label >
URL
2026-09-08 12:48:25 +02:00
< input value = { form . url } onChange = { ( e ) => updateRole ( role . role , 'url' , e . target . value ) } / >
2026-08-25 13:57:23 +02:00
< / label >
< label >
2026-09-08 10:02:28 +02:00
Modell für diese Stufe
2026-09-08 12:48:25 +02:00
< input value = { form . model } onChange = { ( e ) => updateRole ( role . role , 'model' , e . target . value ) } / >
< / label >
< label >
Als neues Profil speichern ( optional )
2026-09-08 10:02:28 +02:00
< input
2026-09-08 12:48:25 +02:00
value = { form . saveAsTitle }
onChange = { ( e ) => updateRole ( role . role , 'saveAsTitle' , e . target . value ) }
placeholder = "z. B. Ollama Detect"
2026-09-08 10:02:28 +02:00
/ >
2026-08-25 13:57:23 +02:00
< / label >
< label className = "check" >
2026-09-08 12:48:25 +02:00
< input type = "checkbox" checked = { form . zdr } onChange = { ( e ) => updateRole ( role . role , 'zdr' , e . target . checked ) } / >
2026-08-25 13:57:23 +02:00
Zero Data Retention
< / label >
< label className = "check" >
< input
type = "checkbox"
checked = { form . no _train }
2026-09-08 12:48:25 +02:00
onChange = { ( e ) => updateRole ( role . role , 'no_train' , e . target . checked ) }
2026-08-25 13:57:23 +02:00
/ >
kein Provider - Training
< / label >
< label >
2026-09-08 12:48:25 +02:00
API - Key { role . key _present ? '(leer lassen, um den bestehenden zu behalten)' : '(für externe URL)' }
2026-08-25 13:57:23 +02:00
< input
type = "password"
autoComplete = "off"
value = { form . key }
2026-09-08 12:48:25 +02:00
onChange = { ( e ) => updateRole ( role . role , 'key' , e . target . value ) }
placeholder = { role . local ? 'lokal ohne Key' : ( role . role === 'detect' ? 'leer = Key des Sprachmodells' : '' ) }
2026-08-25 13:57:23 +02:00
/ >
< / label >
< button type = "submit" disabled = { saving === role . role } >
2026-09-08 12:48:25 +02:00
{ saving === role . role ? 'Speichert …' : 'Stufe speichern' }
2026-08-25 13:57:23 +02:00
< / button >
< / form >
)
} ) }
2026-09-08 12:48:25 +02:00
< 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 >
2026-08-25 13:57:23 +02:00
< / >
) }
< / section >
)
}