From a72ebe045a3fe4f9fe2e7bc30f0b984c8ba6aa8a Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:32:44 +0200 Subject: [PATCH] Speed up library loading with shared runtime checks and browser cache --- STUDIO.md | 11 +++++++++++ catalog-ui.js | 12 ++++++++++-- server.py | 27 +++++++++++++++++++++------ test_server.py | 10 ++++++++++ 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/STUDIO.md b/STUDIO.md index 864ea54..0537140 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -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. 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. diff --git a/catalog-ui.js b/catalog-ui.js index 269603c..2adf72f 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -1,5 +1,6 @@ /* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */ window.CatalogUI = (() => { + let cachedCatalog = null; // Memory only: refreshed on every visit; never stores access tokens. const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 number = n => Number.isFinite(n) ? new Intl.NumberFormat('de-DE', {notation:'compact'}).format(n) : '–'; @@ -45,8 +46,8 @@ window.CatalogUI = (() => { } async function refresh() { 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(); } } - catch(error) { message(error.message); } + 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(library && cachedCatalog ? 'Zuletzt geladene Dateien · Aktualisierung fehlgeschlagen: '+error.message : error.message); } finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); } } async function search(q) { @@ -99,6 +100,13 @@ window.CatalogUI = (() => { } 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) { + 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(); } return {html,bind,executionHTML}; diff --git a/server.py b/server.py index a2dd586..7310eeb 100644 --- a/server.py +++ b/server.py @@ -287,9 +287,21 @@ class Handler(BaseHTTPRequestHandler): allowed.update(filter(None,os.environ.get('DECK_ALLOWED_HOSTS','').split(','))) return self.headers.get('Host') in allowed - def execution_setup(self, model, profile=None): - 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} - return execution_assess(model,installed,profile) + def execution_runtime_snapshot(self): + # Installation checks need no video model/profile scan or worker status. + 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): 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) if self.path == '/api/v1/profiles': 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) if self.path == '/api/v1/huggingface':return self.respond(self.server.catalog.hub_auth.status()) if self.path == '/api/v1/hardware': @@ -382,7 +395,8 @@ class Handler(BaseHTTPRequestHandler): return self.respond(overview(data['files'],self.server.hardware.snapshot())) if path.path=='/api/v1/catalog/files': 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 catalog import file_role for f in data['files']: @@ -393,8 +407,9 @@ class Handler(BaseHTTPRequestHandler): return self.respond(data) if path.path=='/api/v1/catalog': data=self.server.catalog.status() + installed=self.execution_runtime_snapshot() 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']) return self.respond(data) except Exception as exc: diff --git a/test_server.py b/test_server.py index 2f32712..c3bf382 100644 --- a/test_server.py +++ b/test_server.py @@ -31,6 +31,16 @@ class Tests(unittest.TestCase): 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 {})}) 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): state=self.request('status') self.assertNotIn('demo',state)