Improve model discovery, variant selection and download visibility
This commit is contained in:
+85
-12
@@ -1,15 +1,88 @@
|
||||
const CatalogUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const size=n=>(n/1024**3).toFixed(2)+' GiB';
|
||||
async function api(path,data){const r=await fetch('/api/v1/catalog'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
|
||||
function html(library){return `<section id="live-catalog" class="card"><span class="pill">LIVE · HUGGING FACE</span><h2>${library?'Dateien auf Athena':'Modelle suchen'}</h2><p>Öffentliche Repositories. Downloads laden kein Modell in RAM oder VRAM. Für Bild, Audio und Video können mehrere Dateien und ein eigener Worker nötig sein.</p>${library?'':'<form id="hub-search"><label>Suchbegriff<input name="q" maxlength="120" placeholder="Qwen, FLUX, Whisper …"></label><button>Suchen</button></form>'}<button class="secondary" id="hub-refresh">Downloadstatus aktualisieren</button><p id="hub-message" role="status"></p><div id="hub-job"></div><div id="hub-results"></div><div id="hub-files"></div></section>`;}
|
||||
function bind(kind,library){
|
||||
const root=document.querySelector('#live-catalog');if(!root)return;
|
||||
const message=t=>{if(root.isConnected)root.querySelector('#hub-message').textContent=t;};
|
||||
async function refresh(){try{const v=await api('');if(!root.isConnected)return;const j=v.job;root.querySelector('#hub-job').innerHTML=`<p>Freier Speicher: ${size(v.free_bytes)} · Reserve: 10 GiB</p>`+(j?`<p>${e(j.repo)} / ${e(j.file)} · ${e(j.state)} · ${size(j.bytes)} / ${size(j.total)}</p>${j.error?`<p>${e(j.error)}</p>`:''}${j.state==='downloading'?'<button id="hub-cancel" class="secondary">Download abbrechen</button>':''}`:'');root.querySelector('#hub-cancel')?.addEventListener('click',async()=>{await api('/cancel',{});message('Abbruch angefordert; laufende Netzwerkabfrage kann noch bis zu 20 Sekunden dauern.');});if(library)root.querySelector('#hub-results').innerHTML=v.entries.filter(x=>x.kind===kind).map(x=>`<article class="card"><h3>${e(x.repo)}</h3><p>${e(x.file)} · ${size(x.size)}</p><p>Heruntergeladen · Laufzeit noch nicht angebunden</p><small>Revision ${e(x.revision)} · ${x.upstream_hash_verified?'SHA-256 mit Quelle geprüft':'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</small></article>`).join('')||'<p>Noch keine Dateien für diesen Bereich heruntergeladen.</p>';if(j?.state==='downloading')setTimeout(()=>{if(root.isConnected)refresh();},2000);}catch(err){message(err.message);}}
|
||||
root.querySelector('#hub-refresh').onclick=refresh;
|
||||
root.querySelector('#hub-search')?.addEventListener('submit',async event=>{event.preventDefault();message('Suche …');try{const v=await api('/search?'+new URLSearchParams({q:new FormData(event.target).get('q'),kind}));if(!root.isConnected)return;root.querySelector('#hub-results').innerHTML=v.models.map(m=>`<article class="card"><h3>${e(m.repo)}</h3><p>${m.gated?'Zugangsbeschränkt':'Öffentlich'} · ${e(m.downloads)} Downloads</p><button data-repo="${e(m.repo)}">Dateien auswählen</button></article>`).join('');message(v.models.length+' Ergebnisse');root.querySelectorAll('[data-repo]').forEach(b=>b.onclick=()=>files(b.dataset.repo));}catch(err){message(err.message);}});
|
||||
async function files(repo){message('Dateien werden gelesen …');try{const v=await api('/files?'+new URLSearchParams({repo}));if(!root.isConnected)return;root.querySelector('#hub-files').innerHTML=`<h3>${e(repo)}</h3><p>Lizenz: ${e(v.license||'Nicht angegeben')} · <a target="_blank" rel="noopener noreferrer" href="${e(v.url)}">Modellkarte und Bedingungen lesen</a></p><p>VRAM- und Kontext-Eignung noch nicht berechnet. Dateigröße allein belegt keine Lauffähigkeit.</p>`+v.files.map((f,i)=>`<article class="card"><p>${e(f.name)} · ${size(f.size)}</p><button data-file="${i}" ${v.gated?'disabled':''}>Datei auf Athena herunterladen</button></article>`).join('');message(v.files.length+' unterstützte Dateien (GGUF, Safetensors, JSON).');root.querySelectorAll('[data-file]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/download',{repo,filename:v.files[Number(b.dataset.file)].name,revision:v.revision,kind});message('Download gestartet. Kein Modell wird ausgeführt.');refresh();}catch(err){message(err.message);}finally{b.disabled=false;}});}catch(err){message(err.message);}}
|
||||
/* 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) {
|
||||
return `<section id="live-catalog"><div class="section-heading"><div><span class="kicker">HUGGING FACE · LIVE-KATALOG</span><h2>${library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}</h2><p>${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>
|
||||
${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><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></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) {
|
||||
const root = document.querySelector('#live-catalog'); if (!root) return;
|
||||
const el = id => root.querySelector('#'+id);
|
||||
let state = {entries:[],job:null,free_bytes:null}, models = [], selected = null, detail = null, timer, refreshing = false, searchSequence = 0, detailSequence = 0, starting = false, libraryRendered = false;
|
||||
const message = text => { if (root.isConnected) el('hub-message').textContent = text; };
|
||||
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) : (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>${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">DATEI HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>Laufzeit noch nicht angebunden</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></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 j = state.job; if (!j) { el('hub-job').innerHTML = ''; return; }
|
||||
const labels = {downloading:'Wird heruntergeladen',complete:'Download abgeschlossen',cancelled:'Download abgebrochen',failed:'Download fehlgeschlagen'};
|
||||
const percent = j.total > 0 ? Math.min(100,Math.max(0,j.bytes/j.total*100)) : 0;
|
||||
el('hub-job').innerHTML = `<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 === 'complete' ? ' · geprüft und in der Bibliothek gespeichert' : ''}</p>${j.error ? `<p class="warning">${escape(j.error)}</p>` : ''}${j.state === 'downloading' ? '<button class="secondary" id="hub-cancel">Download abbrechen</button>' : ''}</section>`;
|
||||
el('hub-cancel')?.addEventListener('click', async event => { event.target.disabled = true; try { await api('/cancel',{}); message('Abbruch angefordert. Eine laufende Netzwerkabfrage kann noch bis zu 20 Sekunden dauern.'); } catch(error) { message(error.message); event.target.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; } } else { 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})); if (!root.isConnected || sequence !== searchSequence) return; models = result.models; renderModels(); message(kind === 'audio' ? 'Audio-Suche: derzeit Text-to-Speech-Modelle.' : 'Ergebnisse nach Beliebtheit. Wähle ein Modell für die Dateiauswahl.'); }
|
||||
catch(error) { if (sequence === searchSequence) message(error.message); }
|
||||
}
|
||||
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.';
|
||||
}
|
||||
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"><strong>Hardware-Eignung noch nicht ermittelt</strong><p>Dateigröße ist kein VRAM-Bedarf. Kontext, Quantisierung und Laufzeit müssen gemeinsam geprüft werden.</p></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) { el('hub-sort').onchange = renderModels; el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); }
|
||||
refresh();
|
||||
}
|
||||
return {html,bind};
|
||||
|
||||
Reference in New Issue
Block a user