Files
Athena-Deck/catalog-ui.js
T

104 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */
window.CatalogUI = (() => {
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 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 '<p>Ausführbarkeit noch nicht geprüft.</p>';
return `<section class="note execution-setup"><strong>${escape(x.label)}</strong><p>Modellfamilie: ${escape(x.family||'Unbekannt')}${x.evidence?' · '+escape(x.evidence):''}</p>${x.runtime?`<p>${escape(x.runtime)} · ${x.installed?'bereits installiert – wird wiederverwendet':'noch nicht eingerichtet'}</p>`:''}<p>${escape(x.message)}</p>${x.components?.length?`<p>Benötigt: ${x.components.map(escape).join(' · ')}. Bei Profilen unter „Komponenten“ zuordnen; TTS enthält das komplette Paket.</p>`:''}${x.route?`<a class="link" href="${escape(x.route)}">${x.installed?'Einrichtung ansehen':'Ausführung einrichten'} →</a>`:''}${x.alternative?`<p><a class="link" href="${escape(x.alternative.route)}">${escape(x.alternative.label)} →</a></p>`:''}<p class="small">Installierte Laufzeit bedeutet nicht, dass Modell und Kontext in den Speicher passen. Ein Test startet nur auf deinen Auftrag.</p></section>`;
}
function html(library, downloads=false) {
return `<section id="live-catalog"><div class="section-heading"><div><span class="kicker">HUGGING FACE · LIVE-KATALOG</span><h2>${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}</h2><p>${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.'}</p></div><button class="secondary" id="hub-refresh">Aktualisieren</button></div>
<div id="hub-capacity" class="small muted"></div><div id="hub-job"></div><p id="hub-message" role="status" aria-live="polite"></p>
${downloads ? '' : library ? '<div id="hub-results" class="model-grid"></div>' : `<form id="hub-search" class="hub-search"><label>Modell, Anbieter oder Repository-Link<input name="q" maxlength="120" placeholder="Name, GGUF-Dateiname oder Hugging-Face-Link" autocomplete="off"></label><button>Suchen</button></form><label class="hub-base-filter"><input id="hub-base-only" type="checkbox"> Nur Basismodelle</label><p class="small muted">Blendet anhand der Hub-Metadaten Finetunes, LoRAs, Merges und separate Quantisierungen aus. Unvollständig gekennzeichnete Ableitungen können sichtbar bleiben.</p><div class="hub-layout"><div><div class="hub-list-heading"><span id="hub-count" class="small muted">Beliebte Modelle</span><label class="sr-only" for="hub-sort">Ergebnisse sortieren</label><select id="hub-sort"><option value="downloads">Meiste Downloads</option><option value="name">Name A–Z</option><option value="newest">Neu hinzugefügt</option></select></div><div id="hub-results" class="hub-results"></div></div><section id="hub-files" class="card hub-detail" aria-label="Modelldetails"><span class="kicker">01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN</span><h2>Welches Modell interessiert dich?</h2><p>Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.</p><p class="note">Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.</p></section></div>`}</section>`;
}
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 => `<button type="button" class="hub-model ${selected === m.repo ? 'selected' : ''}" data-repo="${escape(m.repo)}" aria-pressed="${selected === m.repo}"><span class="hub-author">${escape(m.repo.split('/')[0])}</span><strong>${escape(m.repo.split('/').slice(1).join('/'))}</strong>${kind==='chat'?`<span class="hub-model-meta">Vision: ${escape(m.capabilities?.vision||'Unbekannt')} · Tools: ${escape(m.capabilities?.tools||'Unbekannt')}</span>`:''}<span class="hub-model-meta">↓ ${number(m.downloads)} Downloads · ${m.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="hub-model-meta" data-capacity="${escape(m.repo)}">${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 …')}</span>${state.entries.some(x => x.repo === m.repo) ? '<span class="tag">Dateien in Bibliothek</span>' : ''}</button>`).join('') || '<div class="empty"><h3>Keine Treffer</h3><p>Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.</p></div>';
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
}
function renderLibrary() {
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${executionHTML(x.execution)}${x.profile_eligible===true?`<div class="card-actions"><button data-profile-model="${x.id}">Profil anlegen</button></div>`:""}</article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
}
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 `<section class="card hub-operation"><div class="card-top"><span class="kicker">${escape(labels[j.state]||j.state)}</span><strong>${percent.toFixed(1)} %</strong></div><h3>${escape(j.repo)}</h3><p class="hub-filename">${escape(j.file)}</p><progress max="100" value="${percent}" aria-label="Download-Fortschritt"></progress><p>${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.':'–'}`:''}</p>${j.error?`<p class="warning">${escape(j.error)}</p>`:''}${j.state==='downloading'?'<button class="secondary" data-cancel>Download abbrechen</button>':`<button class="secondary" data-dismiss="${escape(j.id)}">${j.state==='queued'?'Aus Warteschlange entfernen':'Aus Liste entfernen'}</button><span class="small muted">${j.state==='queued'?'Download noch nicht gestartet':'Datei bleibt erhalten'}</span>`}</section>`;}).join('')||(downloads?'<div class="empty"><h3>Keine Downloads in dieser Liste</h3><p>Heruntergeladene Modelldateien findest du weiterhin in der Bibliothek.</p></div>':'');
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; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } }
catch(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):'<p>Datei auswählen, um die passende Laufzeit zu prüfen.</p>';
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?`<strong>${escape(fitText(file.assessment))}</strong><p>${escape(file.assessment.explanation||'')}</p>${(file.assessment.gpus||[]).map(g=>`<p>${escape(g.name)}: ${size(g.capacity_bytes)} nutzbar insgesamt · ${size(g.free_bytes)} aktuell frei (nach Reserve)</p>`).join('')}${file.assessment.sampled_at?`<p>Messung: ${escape(new Date(file.assessment.sampled_at*1000).toLocaleTimeString('de-DE'))}</p>`:''}${file.assessment.deck_ram_limit_bytes?`<p>Deck-RAM-Limit: ${size(file.assessment.deck_ram_limit_bytes)}</p>`:''}`:'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 => `<label class="hub-variant"><input type="radio" name="hub-file" value="${f.index}" ${current === String(f.index) ? 'checked' : ''}><span><strong>${escape(f.name)}</strong><small>${f.name.toLowerCase().endsWith('.json') ? 'Konfiguration' : 'Modelldatei'}${installed(f) ? ' · In Bibliothek' : ''}${f.sha256 ? ' · SHA-256 verfügbar' : ''}</small></span><b>${size(f.size)}</b></label>`).join('') || '<p>Keine passenden Dateien. Unterstützt werden GGUF, Safetensors und JSON.</p>';
el('hub-variants').onchange = updateSelection; updateSelection();
}
async function loadFiles(repo) {
const sequence = ++detailSequence; selected = repo; detail = null; renderModels();
el('hub-files').innerHTML = '<p role="status">Modelldetails werden geladen …</p>';
try {
const result = await api('/files?'+new URLSearchParams({repo,kind})); if (!root.isConnected || sequence !== detailSequence) return; detail = result;
el('hub-files').innerHTML = `<span class="kicker">MODELLDETAILS</span><h2 class="hub-filename">${escape(repo)}</h2><div class="hub-badges"><span class="pill">${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="pill">${detail.files.length} unterstützte Dateien</span></div><p>Vision: ${escape(detail.capabilities?.vision||'Unbekannt')} · Tool-Calling: ${escape(detail.capabilities?.tools||'Unbekannt')}. Hub-Hinweise, keine Laufzeitgarantie; fehlende Angaben bedeuten nicht „nein“.</p><p>Lizenz: ${escape(detail.license || 'Nicht angegeben')} · <a class="link" href="${escape(detail.url)}" target="_blank" rel="noopener noreferrer">Modellkarte lesen ↗</a></p><div id="hub-execution"></div><div class="hub-fit" id="hub-fit-result">Wähle eine Datei für die Speicherprüfung.</div>${detail.gated ? '<p class="warning">Dieses Repository benötigt eine Freigabe. Freigabe auf der Modellkarte beantragen. Deinen Hugging-Face-Token unter <a class="link" href="#access">Einstellungen → Zugang</a> hinterlegen. Ein gespeicherter Token ersetzt keine Modellfreigabe.</p>' : ''}<h3>Variante oder Datei auswählen</h3><div class="hub-file-controls"><label>Dateiname filtern<input id="hub-file-filter" placeholder="z. B. Q4_K_M"></label><label>Format<select id="hub-format"><option value="">Alle Formate</option><option value=".gguf">GGUF</option><option value=".safetensors">Safetensors</option><option value=".json">JSON / Konfiguration</option></select></label></div><div id="hub-variants" class="hub-variants"></div><div class="hub-review"><p id="hub-selection" class="hub-filename"></p><button id="hub-download" disabled>Ausgewählte Datei herunterladen</button><p class="small">Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.</p><details><summary>Feste Quellversion</summary><p class="hub-filename">${escape(detail.revision)}</p></details></div>`;
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 = '<p>Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.</p>'; 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(''); }
refresh();
}
return {html,bind,executionHTML};
})();