Speed up library loading with shared runtime checks and browser cache

This commit is contained in:
Mikei386 committed 2026-10-01 09:32:44 +02:00
1 parent e16e3dea38
commit a72ebe045a
4 files changed
+52 -8

No files matched your search

+11
View File
@@ -253,3 +253,14 @@ Die handy-computer-Konvertierung verwendet `qwen3_asr`, das der auf Athena getes
**STT → Testen**: Profil wählen, Audiodatei auswählen, Sprache (Deutsch, Englisch, automatisch) wählen und transkribieren. Der Browser dekodiert maximal 20 MiB / 120 Sekunden und wandelt in PCM16-Mono-WAV bei 16 kHz um. Eingabeformate hängen von den Browser-Codecs ab. Abbrechen beendet ausschließlich den eigenen STT-Prozess. CPU-Ausführung mit zwei Threads, keine GPU-Belegung; mindestens 4 GiB freier Deck-RAM werden vorausgesetzt. Ein API-Schlüssel schützt den kurzlebigen internen Loopback-Worker. Audio und Transkript werden nicht persistiert; das letzte Transkript liegt bis zum nächsten Auftrag oder Neustart im Speicher und ist nach Anmeldung einsehbar. Keine Mikrofonaufnahme, Zeitstempel oder Sprechertrennung in dieser Version. **STT → Testen**: Profil wählen, Audiodatei auswählen, Sprache (Deutsch, Englisch, automatisch) wählen und transkribieren. Der Browser dekodiert maximal 20 MiB / 120 Sekunden und wandelt in PCM16-Mono-WAV bei 16 kHz um. Eingabeformate hängen von den Browser-Codecs ab. Abbrechen beendet ausschließlich den eigenen STT-Prozess. CPU-Ausführung mit zwei Threads, keine GPU-Belegung; mindestens 4 GiB freier Deck-RAM werden vorausgesetzt. Ein API-Schlüssel schützt den kurzlebigen internen Loopback-Worker. Audio und Transkript werden nicht persistiert; das letzte Transkript liegt bis zum nächsten Auftrag oder Neustart im Speicher und ist nach Anmeldung einsehbar. Keine Mikrofonaufnahme, Zeitstempel oder Sprechertrennung in dieser Version.
Interne API: `GET /api/v1/stt`, `POST /api/v1/stt/setup` (`{}`), `/assign` (`id`, `revision`), `/start` (Multipart: `profile_id`, `file`, optional `language`), `/cancel` (`{}`). `/setup` verwendet die normale Downloadwarteschlange und startet kein Modell. Ausführung setzt ein vollständiges Profil voraus. Interne API: `GET /api/v1/stt`, `POST /api/v1/stt/setup` (`{}`), `/assign` (`id`, `revision`), `/start` (Multipart: `profile_id`, `file`, optional `language`), `/cancel` (`{}`). `/setup` verwendet die normale Downloadwarteschlange und startet kein Modell. Ausführung setzt ein vollständiges Profil voraus.
### Schneller Bibliothekswechsel
Der Katalog ermittelt den Installationsstatus der Laufzeiten einmal pro Abfrage;
für die reine Bibliotheksbewertung wird keine vollständige Video-Modellliste
aufgebaut. Das gilt auch für Profilbewertungen und die Dateiauswahl in Entdecken.
Die Oberfläche hält den letzten Katalog ausschließlich im Arbeitsspeicher der
aktuellen Browserseite. Beim erneuten Öffnen einer Bibliothek erscheinen deren
zuletzt geladene Dateien sofort mit Aktualisierungshinweis. Aktionen bleiben bis
zur frischen Serverantwort gesperrt; Löschschutz und Profilzuordnungen werden
weiterhin serverseitig geprüft. Ein Neuladen der Browserseite leert diesen Cache.
+10 -2
View File
@@ -1,5 +1,6 @@
/* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */ /* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */
window.CatalogUI = (() => { window.CatalogUI = (() => {
let cachedCatalog = null; // Memory only: refreshed on every visit; never stores access tokens.
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const size = n => { if (!Number.isFinite(n)) return 'Unbekannt'; if(n < 1024) return n+' B'; const unit = n >= 1024**3 ? 'GiB' : n >= 1024**2 ? 'MiB' : 'KiB'; return (n / (unit === 'GiB' ? 1024**3 : unit === 'MiB' ? 1024**2 : 1024)).toFixed(1)+' '+unit; }; const size = n => { if (!Number.isFinite(n)) return 'Unbekannt'; if(n < 1024) return n+' B'; const unit = n >= 1024**3 ? 'GiB' : n >= 1024**2 ? 'MiB' : 'KiB'; return (n / (unit === 'GiB' ? 1024**3 : unit === 'MiB' ? 1024**2 : 1024)).toFixed(1)+' '+unit; };
const number = n => Number.isFinite(n) ? new Intl.NumberFormat('de-DE', {notation:'compact'}).format(n) : '–'; const number = n => Number.isFinite(n) ? new Intl.NumberFormat('de-DE', {notation:'compact'}).format(n) : '–';
@@ -45,8 +46,8 @@ window.CatalogUI = (() => {
} }
async function refresh() { async function refresh() {
if (refreshing || !root.isConnected) return; refreshing = true; clearTimeout(timer); if (refreshing || !root.isConnected) return; refreshing = true; clearTimeout(timer);
try { const next = await api(''); if (!root.isConnected) return; const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(state.entries); const statusChanged = JSON.stringify(next) !== JSON.stringify(state); state = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>kind==='video'?fetch('/api/v1/video/service',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({id:b.dataset.profileModel})}).then(async r=>{const data=await r.json();if(!r.ok)throw Error(data.error);location.hash='video';VideoUI.runtime();}).catch(x=>alert(x.message)):Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } } try { const next = await api(''); if (!root.isConnected) return; const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(state.entries); const statusChanged = JSON.stringify(next) !== JSON.stringify(state); state = next; cachedCatalog = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; message(''); root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>kind==='video'?fetch('/api/v1/video/service',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({id:b.dataset.profileModel})}).then(async r=>{const data=await r.json();if(!r.ok)throw Error(data.error);location.hash='video';VideoUI.runtime();}).catch(x=>alert(x.message)):Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } }
catch(error) { message(error.message); } catch(error) { message(library && cachedCatalog ? 'Zuletzt geladene Dateien · Aktualisierung fehlgeschlagen: '+error.message : error.message); }
finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); } finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); }
} }
async function search(q) { async function search(q) {
@@ -99,6 +100,13 @@ window.CatalogUI = (() => {
} }
el('hub-refresh').onclick = refresh; el('hub-refresh').onclick = refresh;
if (!library && !downloads) { try { el('hub-base-only').checked=localStorage.getItem('deck-base-only')==='true'; } catch {} el('hub-base-only').onchange=()=>{try { localStorage.setItem('deck-base-only',String(el('hub-base-only').checked)); } catch {} search(new FormData(el('hub-search')).get('q').trim());}; el('hub-sort').onchange = () => search(new FormData(el('hub-search')).get('q').trim()); el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); } if (!library && !downloads) { try { el('hub-base-only').checked=localStorage.getItem('deck-base-only')==='true'; } catch {} el('hub-base-only').onchange=()=>{try { localStorage.setItem('deck-base-only',String(el('hub-base-only').checked)); } catch {} search(new FormData(el('hub-search')).get('q').trim());}; el('hub-sort').onchange = () => search(new FormData(el('hub-search')).get('q').trim()); el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); }
if (library) {
if (cachedCatalog) {
state = cachedCatalog; renderLibrary();
root.querySelectorAll('[data-delete-model], [data-profile-model]').forEach(b=>b.disabled=true);
message('Zuletzt geladene Dateien · wird aktualisiert …');
} else message('Bibliothek wird geladen …');
}
refresh(); refresh();
} }
return {html,bind,executionHTML}; return {html,bind,executionHTML};
+21 -6
View File
@@ -287,9 +287,21 @@ class Handler(BaseHTTPRequestHandler):
allowed.update(filter(None,os.environ.get('DECK_ALLOWED_HOSTS','').split(','))) allowed.update(filter(None,os.environ.get('DECK_ALLOWED_HOSTS','').split(',')))
return self.headers.get('Host') in allowed return self.headers.get('Host') in allowed
def execution_setup(self, model, profile=None): def execution_runtime_snapshot(self):
installed={'stt':bool(self.server.runtime.status().get('active')),'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False),'video':self.server.video.status()['runtime']['installed'],'audio_cpp':self.server.audio_cpp_runtime.status()['installed'],'music_worker':True} # Installation checks need no video model/profile scan or worker status.
return execution_assess(model,installed,profile) chat = bool(self.server.runtime.status().get('active'))
video_runtime = (self.server.video.original_runtime
if self.server.video.runtime_kind() == 'original'
else self.server.video.runtime)
return {'stt':chat,'chat':chat,
'image':self.server.image_runtime.status().get('installed',False),
'audio':self.server.tts_runtime.status().get('installed',False),
'video':video_runtime.status().get('installed',False),
'audio_cpp':self.server.audio_cpp_runtime.status()['installed'],
'music_worker':True}
def execution_setup(self, model, profile=None, installed=None):
return execution_assess(model, self.execution_runtime_snapshot() if installed is None else installed, profile)
def do_GET(self): def do_GET(self):
if not self.valid_host(): if not self.valid_host():
@@ -342,7 +354,8 @@ class Handler(BaseHTTPRequestHandler):
except Exception:return self.respond({'error':'Komponentenquelle nicht erreichbar. Bitte erneut versuchen.'},503) except Exception:return self.respond({'error':'Komponentenquelle nicht erreichbar. Bitte erneut versuchen.'},503)
if self.path == '/api/v1/profiles': if self.path == '/api/v1/profiles':
data=self.server.profiles.status() data=self.server.profiles.status()
for p in data['profiles']:p['execution']=self.execution_setup(p.get('model') or {},p) installed=self.execution_runtime_snapshot()
for p in data['profiles']:p['execution']=self.execution_setup(p.get('model') or {},p,installed)
return self.respond(data) return self.respond(data)
if self.path == '/api/v1/huggingface':return self.respond(self.server.catalog.hub_auth.status()) if self.path == '/api/v1/huggingface':return self.respond(self.server.catalog.hub_auth.status())
if self.path == '/api/v1/hardware': if self.path == '/api/v1/hardware':
@@ -382,7 +395,8 @@ class Handler(BaseHTTPRequestHandler):
return self.respond(overview(data['files'],self.server.hardware.snapshot())) return self.respond(overview(data['files'],self.server.hardware.snapshot()))
if path.path=='/api/v1/catalog/files': if path.path=='/api/v1/catalog/files':
data=dict(self.server.catalog.files(q.get('repo',[''])[0]));hardware=self.server.hardware.snapshot() data=dict(self.server.catalog.files(q.get('repo',[''])[0]));hardware=self.server.hardware.snapshot()
data['files']=[dict(f,assessment=assess(f['size'],hardware,f['name']),execution=self.execution_setup(dict(repo=data['repo'],revision=data['revision'],file=f['name'],kind=q.get('kind',['chat'])[0],runtime_metadata=data.get('runtime_metadata',{})))) for f in data['files']] installed=self.execution_runtime_snapshot()
data['files']=[dict(f,assessment=assess(f['size'],hardware,f['name']),execution=self.execution_setup(dict(repo=data['repo'],revision=data['revision'],file=f['name'],kind=q.get('kind',['chat'])[0],runtime_metadata=data.get('runtime_metadata',{})),installed=installed)) for f in data['files']]
from profiles import component_recipe from profiles import component_recipe
from catalog import file_role from catalog import file_role
for f in data['files']: for f in data['files']:
@@ -393,8 +407,9 @@ class Handler(BaseHTTPRequestHandler):
return self.respond(data) return self.respond(data)
if path.path=='/api/v1/catalog': if path.path=='/api/v1/catalog':
data=self.server.catalog.status() data=self.server.catalog.status()
installed=self.execution_runtime_snapshot()
for entry in data['entries']: for entry in data['entries']:
entry['execution']=self.execution_setup(entry) entry['execution']=self.execution_setup(entry,installed=installed)
entry['used_by_profiles']=self.server.profiles.references(entry['id']) entry['used_by_profiles']=self.server.profiles.references(entry['id'])
return self.respond(data) return self.respond(data)
except Exception as exc: except Exception as exc:
+10
View File
@@ -31,6 +31,16 @@ class Tests(unittest.TestCase):
def request(self,path,method='GET',headers=None): def request(self,path,method='GET',headers=None):
req=urllib.request.Request(self.url+'/api/v1/'+path,method=method,headers={"Cookie":self.cookie,**(headers or {})}) req=urllib.request.Request(self.url+'/api/v1/'+path,method=method,headers={"Cookie":self.cookie,**(headers or {})})
with urllib.request.urlopen(req) as r:return json.load(r) with urllib.request.urlopen(req) as r:return json.load(r)
def test_catalog_checks_runtime_once_without_video_model_scan(self):
entries=[dict(id=str(i),repo='test/model',file='model.gguf',kind='chat',size=8) for i in range(12)]
with patch.object(self.server.catalog,'status',return_value=dict(entries=entries)), \
patch.object(self.server.runtime,'status',return_value={'active':True}) as runtime, \
patch.object(self.server.video,'status',side_effect=AssertionError('No video model scan needed')):
result=self.request('catalog')
self.assertEqual(len(result['entries']),12)
self.assertEqual(runtime.call_count,1)
self.assertTrue(all('execution' in row and 'used_by_profiles' in row for row in result['entries']))
def test_demo_removed_from_status(self): def test_demo_removed_from_status(self):
state=self.request('status') state=self.request('status')
self.assertNotIn('demo',state) self.assertNotIn('demo',state)