98 lines
17 KiB
JavaScript
98 lines
17 KiB
JavaScript
/* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */
|
||
window.CatalogUI = (() => {
|
||
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 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 oder Anbieter<input name="q" maxlength="120" placeholder="z. B. Qwen oder FLUX" 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><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>${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={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)}</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)}">Aus Liste entfernen</button><span class="small muted">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.base_only ? `Basismodellfilter aktiv · ${result.scanned} Hub-Treffer geprüft, höchstens 20 angezeigt. Bei fehlenden Treffern Suche eingrenzen oder Filter ausschalten.` : kind === 'audio' ? 'Audio-Suche: derzeit Text-to-Speech-Modelle.' : 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;
|
||
const busy = starting || state.job?.state === 'downloading';
|
||
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 || busy || exists || !fitsDisk;
|
||
el('hub-download').textContent = starting ? 'Download wird vorbereitet …' : exists ? 'Bereits in Bibliothek' : busy ? 'Ein Download läuft bereits' : '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})); 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>Lizenz: ${escape(detail.license || 'Nicht angegeben')} · <a class="link" href="${escape(detail.url)}" target="_blank" rel="noopener noreferrer">Modellkarte lesen ↗</a></p><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. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.</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 { await api('/download', request); message('Download gestartet. Den Fortschritt siehst du oberhalb der Galerie.'); 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};
|
||
})();
|