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')}

${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.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')}

${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.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.'}

`; + el('profile-list').insertAdjacentHTML('afterbegin',`

${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'):