Show endpoint profile checkboxes and enforce exclusive image selection
This commit is contained in:
@@ -156,3 +156,5 @@ Ein nicht eindeutig diagnostizierter Prozessabbruch nennt Speicher, Modell/Build
|
|||||||
oder Zeitlimit als mögliche Ursachen. Bestehende Anwendungslogs werden nicht gelesen.
|
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.
|
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.
|
||||||
|
|||||||
+9
-2
@@ -23,6 +23,10 @@ class Endpoint:
|
|||||||
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
|
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
|
||||||
path=self.root/'endpoint.json'
|
path=self.root/'endpoint.json'
|
||||||
if path.exists():self.config.update(json.loads(path.read_text()))
|
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):
|
def persist(self):
|
||||||
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
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')
|
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()
|
return self.status()
|
||||||
def enable(self,data):
|
def enable(self,data):
|
||||||
if set(data)!={'id','enabled'} or type(data['enabled']) is not bool:raise ValueError('Profil-ID und Aktivierung erforderlich.')
|
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 not row:raise ValueError('Profil nicht gefunden.')
|
||||||
if data['enabled'] and not row['runnable']:raise ValueError('Profil nicht ausführbar: '+' '.join(row['blockers']))
|
if data['enabled'] and not row['runnable']:raise ValueError('Profil nicht ausführbar: '+' '.join(row['blockers']))
|
||||||
with self.lock:
|
with self.lock:
|
||||||
enabled=set(self.config['enabled_profiles'])
|
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'])
|
else:enabled.discard(data['id'])
|
||||||
self.config['enabled_profiles']=sorted(enabled);self.persist()
|
self.config['enabled_profiles']=sorted(enabled);self.persist()
|
||||||
return self.status()
|
return self.status()
|
||||||
|
|||||||
+3
-2
@@ -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 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;};
|
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;
|
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=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p><details><summary>${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><button class="secondary" data-publish="${p.id}" ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}>${enabled.includes(p.id)?'Am Endpunkt deaktivieren':'Am Endpunkt aktivieren'}</button><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p><details><summary>${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
||||||
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').insertAdjacentHTML('afterbegin',`<p>${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.</p>`);
|
||||||
|
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-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-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)));
|
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
|
||||||
|
|||||||
@@ -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}
|
.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;}
|
.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}
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ class EndpointTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):self.enable('audio')
|
with self.assertRaises(ValueError):self.enable('audio')
|
||||||
self.assertEqual(self.request('/v1/audio/speech',{})[0],501)
|
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)
|
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):
|
def test_profile_switch_and_sampling_defaults(self):
|
||||||
for name in ('alpha','beta'):self.enable(name)
|
for name in ('alpha','beta'):self.enable(name)
|
||||||
for name in ('alpha','beta'):
|
for name in ('alpha','beta'):
|
||||||
|
|||||||
Reference in New Issue
Block a user