From a36a151c944eaa070df8a44500a89a79f5a9f4a7 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Mon, 28 Sep 2026 21:58:03 +0200
Subject: [PATCH] Show endpoint profile checkboxes and enforce exclusive image
selection
---
ENDPOINT.md | 2 ++
endpoint.py | 11 +++++++++--
profiles-ui.js | 5 +++--
style.css | 2 ++
test_endpoint.py | 11 +++++++++++
5 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/ENDPOINT.md b/ENDPOINT.md
index dc064f9..7294d05 100644
--- a/ENDPOINT.md
+++ b/ENDPOINT.md
@@ -156,3 +156,5 @@ Ein nicht eindeutig diagnostizierter Prozessabbruch nennt Speicher, Modell/Build
oder Zeitlimit als mögliche Ursachen. Bestehende Anwendungslogs werden nicht gelesen.
Der interne Chat-Test liefert `job.timings` (Prefill/Generierung: Token, Millisekunden und Token/s) und `job.usage` aus der finalen llama.cpp-Streamantwort. Fehlende Messwerte bleiben leer, insbesondere bei Abbruch. Prefill zählt berechnete Token, usage die gesamte Eingabe inklusive wiederverwendetem Kontext. Modellladen und Warteschlange sind nicht Teil der Token/s.
+
+Profilfreigaben: mehrere Chatprofile, höchstens ein Bildprofil. Die Auswahl eines Bildprofils entfernt atomar alle anderen Bildfreigaben; laufende Antworten werden dadurch nicht abgebrochen. Der Haken in der Profilkarte zeigt die gespeicherte Freigabe, nicht den Ladezustand. Beim Laden alter Konfigurationen mit mehreren Bildfreigaben bleibt die erste gespeicherte Bild-ID ausgewählt.
diff --git a/endpoint.py b/endpoint.py
index 5074004..7277151 100644
--- a/endpoint.py
+++ b/endpoint.py
@@ -23,6 +23,10 @@ class Endpoint:
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
path=self.root/'endpoint.json'
if path.exists():self.config.update(json.loads(path.read_text()))
+ image_ids={p['id'] for p in self.profiles.status()['profiles'] if p['kind']=='image'}
+ selected=[i for i in self.config['enabled_profiles'] if i in image_ids]
+ if len(selected)>1:
+ self.config['enabled_profiles']=[i for i in self.config['enabled_profiles'] if i not in image_ids or i==selected[0]];self.persist()
def persist(self):
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
path=self.root/'endpoint.tmp';path.write_text(json.dumps(self.config));path.replace(self.root/'endpoint.json')
@@ -51,12 +55,15 @@ class Endpoint:
return self.status()
def enable(self,data):
if set(data)!={'id','enabled'} or type(data['enabled']) is not bool:raise ValueError('Profil-ID und Aktivierung erforderlich.')
- row=next((p for p in self.rows() if p['id']==data['id']),None)
+ rows=self.rows()
+ row=next((p for p in rows if p['id']==data['id']),None)
if not row:raise ValueError('Profil nicht gefunden.')
if data['enabled'] and not row['runnable']:raise ValueError('Profil nicht ausführbar: '+' '.join(row['blockers']))
with self.lock:
enabled=set(self.config['enabled_profiles'])
- if data['enabled']:enabled.add(data['id'])
+ if data['enabled']:
+ if row['kind']=='image':enabled.difference_update(p['id'] for p in rows if p['kind']=='image')
+ enabled.add(data['id'])
else:enabled.discard(data['id'])
self.config['enabled_profiles']=sorted(enabled);self.persist()
return self.status()
diff --git a/profiles-ui.js b/profiles-ui.js
index 42b82e4..6d9bd34 100644
--- a/profiles-ui.js
+++ b/profiles-ui.js
@@ -7,8 +7,9 @@ window.ProfilesUI=(()=>{
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
- el('profile-list').innerHTML=rows.map(p=>`GESPEICHERT AUF ATHENA
${e(p.name)}
${e(p.model?.file||'Modelldatei nicht verfügbar')}
${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`
${e(t)}
`).join('')}
${kind==='image'?``:''}
`).join('')||`
Noch keine Profile
${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}
`;
- root.querySelectorAll('[data-publish]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('endpoint/profile',{id:b.dataset.publish,enabled:!enabled.includes(b.dataset.publish)});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){message(error.message);b.disabled=false;}});
+ el('profile-list').innerHTML=rows.map(p=>`GESPEICHERT AUF ATHENA
${e(p.name)}
${e(p.model?.file||'Modelldatei nicht verfügbar')}
${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.
`);
+ root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
diff --git a/style.css b/style.css
index 8e15d30..11675b8 100644
--- a/style.css
+++ b/style.css
@@ -20,3 +20,5 @@ nav a.nav-sub{padding-left:28px;font-size:12px;color:#a4bda8}.component-card{mar
.hub-base-filter{display:flex;align-items:center;gap:.6rem;margin-top:1rem}.hub-base-filter input{width:auto;margin:0}
.chat-text,#chat-transcript pre{white-space:pre-wrap;overflow-wrap:anywhere;}
+
+.endpoint-choice{display:flex;align-items:center;gap:.6rem}.endpoint-choice input{width:auto;margin:0}
diff --git a/test_endpoint.py b/test_endpoint.py
index 194772e..5320b00 100644
--- a/test_endpoint.py
+++ b/test_endpoint.py
@@ -55,6 +55,17 @@ class EndpointTests(unittest.TestCase):
with self.assertRaises(ValueError):self.enable('audio')
self.assertEqual(self.request('/v1/audio/speech',{})[0],501)
self.record['api_token_hash']=hashlib.sha256(b'B'*32).hexdigest();self.assertEqual(self.request()[0],401);self.assertEqual(self.request(token='B'*32)[0],200)
+ def test_single_image_selection_preserves_multiple_llms(self):
+ self.rows.append(dict(self.rows[2],id='image2',name='image2'))
+ self.enable('alpha');self.enable('beta');self.enable('image');self.enable('image2')
+ self.assertEqual(set(self.ep.config['enabled_profiles']),{'alpha','beta','image2'})
+ self.assertEqual({r['id'] for r in self.request()[1]['data']},{'alpha','beta','image2'})
+ self.ep.enable(dict(id='image2',enabled=False));self.assertEqual(set(self.ep.config['enabled_profiles']),{'alpha','beta'})
+ def test_invalid_image_selection_keeps_previous(self):
+ self.rows.append(dict(self.rows[2],id='badimage',name='badimage',runnable=False,blockers=['missing']))
+ self.enable('image')
+ with self.assertRaises(ValueError):self.enable('badimage')
+ self.assertEqual(self.ep.config['enabled_profiles'],['image'])
def test_profile_switch_and_sampling_defaults(self):
for name in ('alpha','beta'):self.enable(name)
for name in ('alpha','beta'):