/* 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) : '–';
async function api(path, data) {
const response = await fetch('/api/v1/catalog'+path, data ? {method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)} : {});
const result = await response.json(); if (!response.ok) throw Error(result.error || 'Anfrage fehlgeschlagen'); return result;
}
function executionHTML(x) {
if(!x)return '
Ausführbarkeit noch nicht geprüft.
';
return `${escape(x.label)} Modellfamilie: ${escape(x.family||'Unbekannt')}${x.evidence?' · '+escape(x.evidence):''}
${x.runtime?`${escape(x.runtime)} · ${x.installed?'bereits installiert – wird wiederverwendet':'noch nicht eingerichtet'}
`:''}${escape(x.message)}
${x.components?.length?`Benötigt: ${x.components.map(escape).join(' · ')}. Modellabhängig zuordnen; benötigte Zusatzdateien gehören zum vollständigen Paket.
`:''}${x.suggested_components?.length?`Zusatzdateien in dieser Quelle (Kompatibilität ungeprüft): ${x.suggested_components.map(escape).join(' · ')}. Nach dem Download unter Profil → Einrichtung & Komponenten prüfen.
`:''}${x.route?`${x.installed?'Einrichtung ansehen':'Ausführung einrichten'} → `:''}${x.candidates?.length?`Mögliche Laufzeiten Diese Liste unterscheidet bekannte Zuordnungen von ungeprüften Kategorieoptionen. Eine Installation ersetzt keinen fehlenden Worker.
${x.candidates.map(c=>`${escape(c.label)} · ${c.installed?'bereits installiert – wiederverwenden':'nicht installiert'} ${escape(c.evidence)}${c.route?`${c.installed?'Laufzeit / Einrichtung ansehen':'Laufzeit herunterladen / bereitmachen'} → `:' Kein gepflegter Deck-Installer für diese Option verfügbar.'}
`).join('')}`:`${escape(x.candidate_message||'')}
`}${x.alternative?`${escape(x.alternative.label)} →
`:''}Installierte Laufzeit bedeutet nicht, dass Modell und Kontext in den Speicher passen. Ein Test startet nur auf deinen Auftrag.
`;
}
function html(library, downloads=false) {
return `HUGGING FACE · LIVE-KATALOG ${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'} ${downloads ? 'Abgeschlossene Einträge ausblenden. Die Dateien bleiben in deiner Bibliothek.' : library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}
Aktualisieren
${downloads ? '' : library ? '
' : ` Nur BasismodelleBlendet anhand der Hub-Metadaten Finetunes, LoRAs, Merges und separate Quantisierungen aus. Unvollständig gekennzeichnete Ableitungen können sichtbar bleiben.
Beliebte Modelle Ergebnisse sortieren Meiste Downloads Name A–Z Neu hinzugefügt
01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN Welches Modell interessiert dich? Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.
Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.
`} `;
}
function bind(kind, library, downloads=false) {
const root = document.querySelector('#live-catalog'); if (!root) return;
const el = id => root.querySelector('#'+id);
let state = {entries:[],job:null,free_bytes:null,downloads:[]}, models = [], selected = null, detail = null, timer, refreshing = false, searchSequence = 0, detailSequence = 0, starting = false, libraryRendered = false, lastJobs = null;
const message = text => { if (root.isConnected) el('hub-message').textContent = text; };
const fitText = a => a ? `${a.label}${a.weights_fit_now===true?' · Aktuell: Gewichte passen':a.weights_fit_now===false?' · Aktuell: keine GPU mit genug freiem Speicher für diese Datei':''}` : 'Größe und Speicher werden ermittelt …';
const installed = file => detail && state.entries.some(x => x.repo === detail.repo && x.revision === detail.revision && x.file === file.name);
function renderModels() {
const sorted = [...models].sort(el('hub-sort').value === 'name' ? (a,b) => a.repo.localeCompare(b.repo) : el('hub-sort').value === 'newest' ? (a,b) => (Date.parse(b.created_at)||0)-(Date.parse(a.created_at)||0) : (a,b) => (b.downloads||0)-(a.downloads||0));
el('hub-count').textContent = `${models.length} Ergebnisse · höchstens 20 pro Suche`;
el('hub-results').innerHTML = sorted.map(m => `${escape(m.repo.split('/')[0])} ${escape(m.repo.split('/').slice(1).join('/'))} ${kind==='chat'?`Vision: ${escape(m.capabilities?.vision||'Unbekannt')} · Tools: ${escape(m.capabilities?.tools||'Unbekannt')} `:''}↓ ${number(m.downloads)} Downloads · ${m.gated ? 'Zugang beschränkt' : 'Öffentlich'} ${escape(m.capacity ? `${size(m.capacity.min_bytes)} – ${size(m.capacity.max_bytes)} Hauptgewichte · kleinste Variante: ${fitText(m.capacity)}` : m.capacityError || 'Größe und Speicher werden ermittelt …')} ${state.entries.some(x => x.repo === m.repo) ? 'Dateien in Bibliothek ' : ''} `).join('') || 'Keine Treffer Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.
';
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
}
function renderLibrary() {
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId));
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `${escape(x.file)} ${escape(x.role_label)} ${size(x.size)} ${x.used_by_profiles?.length?`${x.used_by_profiles.length} Profil(e)`:'Nicht zugeordnet'} Quelle: ${escape(x.repo)}
${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}
Version und Integrität Revision ${escape(x.revision)}
${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}
${executionHTML(x.execution)}${x.used_by_profiles?.length?`
Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.
`:""}
${x.profile_eligible===true?`${kind==='video'?'Als Videomodell verwenden':'Profil anlegen'} `:""}Datei löschen
`).join('') || 'Deine Bibliothek ist noch leer Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.
';
root.querySelectorAll('[data-delete-model]').forEach(b=>b.onclick=async()=>{const entry=state.entries.find(x=>x.id===b.dataset.deleteModel);if(!entry||!confirm(`Datei „${entry.file}“ (${size(entry.size)}) dauerhaft von Athena löschen?`))return;b.disabled=true;try{await api('/delete',{id:entry.id});message('Datei aus der Bibliothek gelöscht.');await refresh();}catch(error){message(error.message);b.disabled=false;}});
}
function renderJob() {
el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`;
const jobs=downloads ? (state.downloads||[]).filter(j=>j.kind===kind) : state.job?.state==='downloading' ? [state.job] : [];
const signature=JSON.stringify(jobs); if(signature===lastJobs)return; lastJobs=signature;
const labels={queued:'Warteschlange',downloading:'Wird heruntergeladen',complete:'Download abgeschlossen',cancelled:'Download abgebrochen',failed:'Download fehlgeschlagen',interrupted:'Download unterbrochen'};
el('hub-job').innerHTML=jobs.map(j=>{const percent=j.total>0?Math.min(100,Math.max(0,j.bytes/j.total*100)):0;return `${escape(labels[j.state]||j.state)} ${percent.toFixed(1)} %
${escape(j.repo)} ${escape(j.file)}
${size(j.bytes)} von ${size(j.total)}${j.state==='downloading'?` · ${Number.isFinite(j.bytes_per_second)?size(j.bytes_per_second)+'/s':'Geschwindigkeit wird ermittelt …'} · Restzeit: ${Number.isFinite(j.eta_seconds)?Math.ceil(j.eta_seconds/60)+' Min.':'–'}`:''}
${j.error?`${escape(j.error)}
`:''}${j.state==='downloading'?'Download abbrechen ':`${j.state==='queued'?'Aus Warteschlange entfernen':'Aus Liste entfernen'} ${j.state==='queued'?'Download noch nicht gestartet':'Datei bleibt erhalten'} `} `;}).join('')||(downloads?'Keine Downloads in dieser Liste Heruntergeladene Modelldateien findest du weiterhin in der Bibliothek.
':'');
root.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/cancel',{});message('Abbruch angefordert; die Netzwerkabfrage kann bis zu 20 Sekunden dauern.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-dismiss]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/dismiss',{id:b.dataset.dismiss});message('Eintrag ausgeblendet. Die Modelldatei bleibt in der Bibliothek.');await refresh();}catch(error){message(error.message);b.disabled=false;}});
}
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; 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) {
const sequence = ++searchSequence; message('Modelle werden gesucht …');
try { const result = await api('/search?'+new URLSearchParams({q,kind,sort:el('hub-sort').value,base_only:String(el('hub-base-only').checked)})); if (!root.isConnected || sequence !== searchSequence) return; models = result.models; renderModels(); enrich(sequence); message(result.notice || (result.base_only ? `Basismodellfilter aktiv · ${result.scanned} Hub-Treffer geprüft, höchstens 20 angezeigt. Bei fehlenden Treffern Suche eingrenzen oder Filter ausschalten.` : kind === 'audio' ? 'Text-to-Speech: Modelle für Sprachausgabe.' : el('hub-sort').value==='newest'?'Neueste Hub-Repositories zuerst (Erstellungsdatum, nicht letztes Update).':'Wähle ein Modell für die Dateiauswahl. Name A–Z sortiert die angezeigten Treffer.')); }
catch(error) { if (sequence === searchSequence) message(error.message); }
}
async function enrich(sequence){
const pending=[...models];
async function worker(){while(pending.length&&root.isConnected&&sequence===searchSequence){const model=pending.shift();try{model.capacity=await api('/assessment?'+new URLSearchParams({repo:model.repo}));}catch(error){model.capacityError='Größe / Speicher nicht ermittelbar';}if(!root.isConnected||sequence!==searchSequence)return;const node=[...root.querySelectorAll('[data-capacity]')].find(n=>n.dataset.capacity===model.repo);if(node)node.textContent=model.capacity?`${size(model.capacity.min_bytes)} – ${size(model.capacity.max_bytes)} Hauptgewichte · kleinste Variante: ${fitText(model.capacity)}`:model.capacityError;}}
await Promise.all([worker(),worker()]);
}
function updateSelection() {
if (!detail || !el('hub-download')) return;
const chosen = el('hub-variants').querySelector('input:checked');
const file = chosen ? detail.files[Number(chosen.value)] : null;
if(el('hub-execution'))el('hub-execution').innerHTML=file?executionHTML(file.execution):'Datei auswählen, um die passende Laufzeit zu prüfen.
';
const busy = starting;
const queued=file && (state.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===detail.repo&&j.revision===detail.revision&&j.file===file.name);
const exists = file && installed(file);
const fitsDisk = file && Number.isFinite(state.free_bytes) && file.size <= state.free_bytes - 10*1024**3;
el('hub-download').disabled = !file || (detail.gated && !detail.auth_configured) || busy || queued || exists || !fitsDisk;
el('hub-download').textContent = starting ? 'Download wird vorbereitet …' : exists ? 'Bereits in Bibliothek' : queued ? 'Bereits im Downloadplan' : state.job?.state==='downloading' ? 'In Warteschlange einreihen' : 'Ausgewählte Datei herunterladen';
el('hub-selection').textContent = file ? `${file.name} · ${size(file.size)}${!fitsDisk ? ' · Speicherplatz noch nicht bestätigt oder Reserve unterschritten' : ''}` : 'Wähle eine Datei aus der Liste.';
if(el('hub-fit-result'))el('hub-fit-result').innerHTML=file?.assessment?`${escape(fitText(file.assessment))} ${escape(file.assessment.explanation||'')}
${(file.assessment.gpus||[]).map(g=>`${escape(g.name)}: ${size(g.capacity_bytes)} nutzbar insgesamt · ${size(g.free_bytes)} aktuell frei (nach Reserve)
`).join('')}${file.assessment.sampled_at?`Messung: ${escape(new Date(file.assessment.sampled_at*1000).toLocaleTimeString('de-DE'))}
`:''}${file.assessment.deck_ram_limit_bytes?`Deck-RAM-Limit: ${size(file.assessment.deck_ram_limit_bytes)}
`:''}`:'Wähle eine Datei für die Speicherprüfung. Gesamtbedarf der Laufzeit noch nicht berechnet.';
}
function renderFiles() {
const query = el('hub-file-filter').value.toLowerCase(), format = el('hub-format').value;
const current = el('hub-variants').querySelector('input:checked')?.value;
const files = detail.files.map((f,index) => ({...f,index})).filter(f => f.name.toLowerCase().includes(query) && (!format || f.name.toLowerCase().endsWith(format))).sort((a,b) => Number(a.name.toLowerCase().endsWith('.json'))-Number(b.name.toLowerCase().endsWith('.json')) || a.size-b.size);
el('hub-variants').innerHTML = files.map(f => `${escape(f.name)} ${f.name.toLowerCase().endsWith('.json') ? 'Konfiguration' : 'Modelldatei'}${installed(f) ? ' · In Bibliothek' : ''}${f.sha256 ? ' · SHA-256 verfügbar' : ''} ${size(f.size)} `).join('') || 'Keine passenden Dateien. Unterstützt werden GGUF, Safetensors und JSON.
';
el('hub-variants').onchange = updateSelection; updateSelection();
}
async function loadFiles(repo) {
const sequence = ++detailSequence; selected = repo; detail = null; renderModels();
el('hub-files').innerHTML = 'Modelldetails werden geladen …
';
try {
const result = await api('/files?'+new URLSearchParams({repo,kind})); if (!root.isConnected || sequence !== detailSequence) return; detail = result;
el('hub-files').innerHTML = `MODELLDETAILS ${escape(repo)} ${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'} ${detail.files.length} unterstützte Dateien
Vision: ${escape(detail.capabilities?.vision||'Unbekannt')} · Tool-Calling: ${escape(detail.capabilities?.tools||'Unbekannt')}. Hub-Hinweise, keine Laufzeitgarantie; fehlende Angaben bedeuten nicht „nein“.
Lizenz: ${escape(detail.license || 'Nicht angegeben')} · Modellkarte lesen ↗
Wähle eine Datei für die Speicherprüfung.
${detail.gated ? 'Dieses Repository benötigt eine Freigabe. Freigabe auf der Modellkarte beantragen. Deinen Hugging-Face-Token unter Einstellungen → Zugang hinterlegen. Ein gespeicherter Token ersetzt keine Modellfreigabe.
' : ''}Variante oder Datei auswählen Dateiname filtern FormatAlle Formate GGUF Safetensors JSON / Konfiguration
Ausgewählte Datei herunterladen Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.
Feste Quellversion ${escape(detail.revision)}
`;
el('hub-file-filter').oninput = renderFiles; el('hub-format').onchange = renderFiles; renderFiles();
el('hub-download').onclick = async () => {
const chosen = el('hub-variants').querySelector('input:checked'); if (!chosen || starting) return;
const request = {repo:detail.repo,revision:detail.revision,filename:detail.files[Number(chosen.value)].name,kind};
starting = true; updateSelection();
try { const job=await api('/download', request); message(job.state==='queued'?'In Warteschlange eingereiht. Übersicht unter Downloads.':'Download gestartet. Fortschritt unter Downloads.'); await refresh(); }
catch(error) { message(error.message); }
finally { starting = false; if (root.isConnected) updateSelection(); }
};
} catch(error) { if (sequence === detailSequence) { el('hub-files').innerHTML = 'Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.
'; message(error.message); } }
}
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};
})();