Improve model discovery, variant selection and download visibility

This commit is contained in:
Mikei386
2026-09-28 17:04:42 +02:00
parent 8a92cdcde3
commit f326de18d1
3 changed files with 117 additions and 12 deletions
+26
View File
@@ -75,3 +75,29 @@ Build-Einstellungen und Update-Ansicht geöffnet, deaktivierte Aktionsknöpfe so
Layout kontrolliert. Die Testvorschau verwendet einen eigenen Origin, sodass keine Layout kontrolliert. Die Testvorschau verwendet einen eigenen Origin, sodass keine
Entwürfe der eigentlichen Anwendung überschrieben wurden. Keine SSH-/Modell- oder Entwürfe der eigentlichen Anwendung überschrieben wurden. Keine SSH-/Modell- oder
Build-Aktion für diese GUI-Erweiterung ausgeführt. Build-Aktion für diese GUI-Erweiterung ausgeführt.
## Modellgalerie und Download-Ablauf (2026-09-28)
Die Live-Galerie verwendet eine eigene Deck-Oberfläche, inspiriert von LocalAIs
[Modellgalerie](https://github.com/mudler/LocalAI/blob/master/core/http/react-ui/src/pages/Models.jsx)
und [Download-Anzeige](https://github.com/mudler/LocalAI/blob/master/core/http/react-ui/src/components/OperationCard.jsx).
Es wurde kein LocalAI-Frontendcode übernommen.
- Beim Öffnen erscheinen beliebte Hugging-Face-Repositories des Bereichs.
- Suche liefert bis zu 20 Treffer; Sortierung nach Downloads oder Namen.
- Ergebnis auswählen: Modelldetails, Lizenzlink und feste Quellrevision.
- Dateien nach Name/Quantisierungsbezeichnung und Format filtern; genau eine Datei auswählen.
- Download explizit starten. Zugangsbeschränkte Repositories, bereits vorhandene
Dateien, laufende Downloads und unzureichender Speicher sperren den Button.
- Fortschritt, Bytes, Abbruch und Fehler werden aus der vorhandenen API angezeigt.
- Bibliothek zeigt abgeschlossene Dateien einschließlich Version und Integritätsprüfung.
Grenzen: weiterhin Einzeldateien, keine vollständige Pipeline-Installation,
keine Warteschlange, kein Resume, keine automatischen Modellstarts. Audio-Suche
ist derzeit auf Text-to-Speech begrenzt. Hardware-/Kontext-Eignung wird in dieser
Galerie noch nicht berechnet; Dateigröße wird ausdrücklich nicht als VRAM-Bedarf
angezeigt. Der separate llama.cpp-Einpassungstest bleibt unter Einstellungen.
Validierung: 46 bestehende Python-Tests bestanden; JavaScript-Syntax geprüft;
Browserprüfung mit echten Hugging-Face-Such- und Dateidaten, Dateifilter,
Auswahl und Speicherreserve-Sperre. Keine Modellgewichte für den UI-Test geladen.
+83 -10
View File
@@ -1,15 +1,88 @@
const CatalogUI=(()=>{ /* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); window.CatalogUI = (() => {
const size=n=>(n/1024**3).toFixed(2)+' GiB'; const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
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;} 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; };
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>`;} 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) { function bind(kind, library) {
const root = document.querySelector('#live-catalog'); if (!root) return; const root = document.querySelector('#live-catalog'); if (!root) return;
const message=t=>{if(root.isConnected)root.querySelector('#hub-message').textContent=t;}; const el = id => root.querySelector('#'+id);
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);}} let state = {entries:[],job:null,free_bytes:null}, models = [], selected = null, detail = null, timer, refreshing = false, searchSequence = 0, detailSequence = 0, starting = false, libraryRendered = false;
root.querySelector('#hub-refresh').onclick=refresh; const message = text => { if (root.isConnected) el('hub-message').textContent = text; };
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);}}); const installed = file => detail && state.entries.some(x => x.repo === detail.repo && x.revision === detail.revision && x.file === file.name);
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);}} 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(); refresh();
} }
return {html,bind}; return {html,bind};
+6
View File
@@ -6,3 +6,9 @@ label{display:block;margin:18px 0 8px;font-size:13px;color:#c4d1c6}input,select{
aside{overflow-y:auto}nav{gap:3px}nav a{padding:11px 12px}.prototype-banner{display:flex;align-items:center;gap:14px;background:#202b23;border:1px solid #40503f;border-radius:9px;padding:15px 18px;margin:24px 0;color:#bdd0ba;font-size:12px}.studio-tabs{display:flex;gap:7px;border-bottom:1px solid #344038;padding-bottom:10px;margin:28px 0}.studio-tabs button{font-size:13px}.studio-tabs button span{margin-left:8px;opacity:.6}.model-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.model-card h2{margin-bottom:8px}.model-icon{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;background:#29372b;color:#d0e5c4;border-radius:10px;font-size:23px}.model-facts{font-size:12px;line-height:1.6}.model-facts>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #2a3430}.model-facts dt{color:#819386}.model-facts dd{margin:0;text-align:right;color:#c9d3ca}.unknown-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#a2aea5;margin-right:5px}.card-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:22px}.card-actions button{margin-right:0;font-size:12px}.studio-toolbar{display:grid;grid-template-columns:2fr 1fr;gap:18px}.studio-toolbar label{margin-top:0}.studio-toolbar input,.studio-toolbar select{max-width:none;margin-bottom:0}.small{font-size:12px}.section-heading{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:20px}.section-heading h2{margin-bottom:3px}.section-heading p{margin-top:5px}.section-heading button{white-space:nowrap}.profile-list{display:grid;gap:12px}.profile-row{display:grid;grid-template-columns:1.5fr 1fr .5fr 1fr;gap:18px;align-items:center;padding:22px}.profile-row h2{font-size:18px;margin:9px 0 4px;overflow-wrap:anywhere}.profile-row p{font-size:12px;margin:0}.profile-row strong{display:block;margin-top:10px;font-size:14px;font-weight:500}.profile-actions{grid-column:1/-1;display:flex;border-top:1px solid #303a37;padding-top:14px}.profile-actions button{font-size:12px;margin-bottom:0}.endpoint-preview,.detail-card,.editor{margin-top:24px}pre{background:#101718;padding:20px;border-radius:8px;overflow:auto;font-size:12px;line-height:1.6;color:#bed4b8;max-height:280px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.form-grid input,.form-grid select{max-width:none}.checkbox{display:flex;align-items:center;gap:10px}.checkbox input{width:16px;height:16px;margin:0;accent-color:#bfdda9}.editor details{border-top:1px solid #303a37;margin-top:22px;padding-top:18px}summary{cursor:pointer;color:#ccdbc7;font-size:14px}.danger{color:#e9aaa0!important}#studio-notice{color:#c7e4b6}#profile-error{color:#ffc1a7}.runtime-empty{text-align:center;padding:40px;margin-bottom:20px}.runtime-empty a{display:block;margin-top:16px;font-size:13px}.flow{list-style:none;counter-reset:steps;padding:0;display:grid;grid-template-columns:repeat(2,1fr);gap:20px;margin:28px 0}.flow li{counter-increment:steps;padding:0 0 0 35px;position:relative}.flow li:before{content:counter(steps,decimal-leading-zero);position:absolute;left:0;color:#b6d2a8;font-size:12px;top:2px}.flow b{font-size:13px;font-weight:500}.flow span{display:block;color:#82968a;font-size:12px;line-height:1.7;margin-top:7px}.runtime-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-bottom:22px}.runtime-stats .card{padding:22px}.runtime-stats h2{font-size:18px}.runtime-stats p{font-size:12px}#studio[data-page=runtime]>.card{margin-top:20px}.release-comparison{display:flex;gap:35px;align-items:center;margin:25px 0}.fit-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}.fit-grid b{font-size:13px}.fit-grid p{font-size:12px}@media(max-width:1100px){.model-grid{grid-template-columns:1fr}.runtime-stats{grid-template-columns:1fr}.profile-row{grid-template-columns:1fr 1fr}.fit-grid{grid-template-columns:1fr}}@media(max-width:600px){.form-grid,.studio-toolbar,.flow{grid-template-columns:1fr}.prototype-banner,.section-heading{align-items:flex-start;flex-direction:column}.studio-tabs{flex-wrap:wrap}.studio-tabs button{padding:10px 12px}.profile-row{gap:18px}.card{padding:20px}.model-facts>div{flex-direction:column;gap:3px}.model-facts dd{text-align:left}} aside{overflow-y:auto}nav{gap:3px}nav a{padding:11px 12px}.prototype-banner{display:flex;align-items:center;gap:14px;background:#202b23;border:1px solid #40503f;border-radius:9px;padding:15px 18px;margin:24px 0;color:#bdd0ba;font-size:12px}.studio-tabs{display:flex;gap:7px;border-bottom:1px solid #344038;padding-bottom:10px;margin:28px 0}.studio-tabs button{font-size:13px}.studio-tabs button span{margin-left:8px;opacity:.6}.model-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.model-card h2{margin-bottom:8px}.model-icon{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;background:#29372b;color:#d0e5c4;border-radius:10px;font-size:23px}.model-facts{font-size:12px;line-height:1.6}.model-facts>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #2a3430}.model-facts dt{color:#819386}.model-facts dd{margin:0;text-align:right;color:#c9d3ca}.unknown-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#a2aea5;margin-right:5px}.card-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:22px}.card-actions button{margin-right:0;font-size:12px}.studio-toolbar{display:grid;grid-template-columns:2fr 1fr;gap:18px}.studio-toolbar label{margin-top:0}.studio-toolbar input,.studio-toolbar select{max-width:none;margin-bottom:0}.small{font-size:12px}.section-heading{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:20px}.section-heading h2{margin-bottom:3px}.section-heading p{margin-top:5px}.section-heading button{white-space:nowrap}.profile-list{display:grid;gap:12px}.profile-row{display:grid;grid-template-columns:1.5fr 1fr .5fr 1fr;gap:18px;align-items:center;padding:22px}.profile-row h2{font-size:18px;margin:9px 0 4px;overflow-wrap:anywhere}.profile-row p{font-size:12px;margin:0}.profile-row strong{display:block;margin-top:10px;font-size:14px;font-weight:500}.profile-actions{grid-column:1/-1;display:flex;border-top:1px solid #303a37;padding-top:14px}.profile-actions button{font-size:12px;margin-bottom:0}.endpoint-preview,.detail-card,.editor{margin-top:24px}pre{background:#101718;padding:20px;border-radius:8px;overflow:auto;font-size:12px;line-height:1.6;color:#bed4b8;max-height:280px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}.form-grid input,.form-grid select{max-width:none}.checkbox{display:flex;align-items:center;gap:10px}.checkbox input{width:16px;height:16px;margin:0;accent-color:#bfdda9}.editor details{border-top:1px solid #303a37;margin-top:22px;padding-top:18px}summary{cursor:pointer;color:#ccdbc7;font-size:14px}.danger{color:#e9aaa0!important}#studio-notice{color:#c7e4b6}#profile-error{color:#ffc1a7}.runtime-empty{text-align:center;padding:40px;margin-bottom:20px}.runtime-empty a{display:block;margin-top:16px;font-size:13px}.flow{list-style:none;counter-reset:steps;padding:0;display:grid;grid-template-columns:repeat(2,1fr);gap:20px;margin:28px 0}.flow li{counter-increment:steps;padding:0 0 0 35px;position:relative}.flow li:before{content:counter(steps,decimal-leading-zero);position:absolute;left:0;color:#b6d2a8;font-size:12px;top:2px}.flow b{font-size:13px;font-weight:500}.flow span{display:block;color:#82968a;font-size:12px;line-height:1.7;margin-top:7px}.runtime-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-bottom:22px}.runtime-stats .card{padding:22px}.runtime-stats h2{font-size:18px}.runtime-stats p{font-size:12px}#studio[data-page=runtime]>.card{margin-top:20px}.release-comparison{display:flex;gap:35px;align-items:center;margin:25px 0}.fit-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}.fit-grid b{font-size:13px}.fit-grid p{font-size:12px}@media(max-width:1100px){.model-grid{grid-template-columns:1fr}.runtime-stats{grid-template-columns:1fr}.profile-row{grid-template-columns:1fr 1fr}.fit-grid{grid-template-columns:1fr}}@media(max-width:600px){.form-grid,.studio-toolbar,.flow{grid-template-columns:1fr}.prototype-banner,.section-heading{align-items:flex-start;flex-direction:column}.studio-tabs{flex-wrap:wrap}.studio-tabs button{padding:10px 12px}.profile-row{gap:18px}.card{padding:20px}.model-facts>div{flex-direction:column;gap:3px}.model-facts dd{text-align:left}}
.nav-heading{font-size:10px;letter-spacing:1.5px;color:#75867b;margin:15px 12px 5px} .nav-heading{font-size:10px;letter-spacing:1.5px;color:#75867b;margin:15px 12px 5px}
/* Live model catalogue: browse, inspect a file, download, then library. */
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
.hub-search{display:flex;align-items:end;gap:12px;margin:24px 0}.hub-search label{flex:1;margin:0}.hub-search input{max-width:none;margin-bottom:0}.hub-search button{margin:0}
.hub-layout{display:grid;grid-template-columns:minmax(240px,.8fr) minmax(0,1.4fr);gap:24px;align-items:start}.hub-list-heading{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.hub-list-heading select{width:auto;max-width:160px;font-size:12px;margin:0;padding:8px}.hub-results{display:grid;gap:8px;max-height:850px;overflow-y:auto;padding:3px}.hub-model{display:grid;gap:9px;width:100%;margin:0;text-align:left;padding:18px;background:#181f21;color:#e4eee5;border:1px solid #303a37}.hub-model:hover{background:#222d27}.hub-model.selected{border-color:#bddda9;background:#26342b}.hub-model strong{font-size:14px;overflow-wrap:anywhere}.hub-author,.hub-model-meta{font-size:11px;color:#96a89c}.hub-detail{min-width:0}.hub-detail h2{font-size:22px}.hub-badges{display:flex;gap:8px;flex-wrap:wrap}.hub-fit{padding:16px;background:#202a25;border-radius:9px;font-size:13px;margin:22px 0}.hub-fit p{font-size:12px;margin-bottom:0}.hub-file-controls{display:grid;grid-template-columns:1.3fr 1fr;gap:12px}.hub-file-controls label{margin-top:0}.hub-file-controls input,.hub-file-controls select{font-size:12px;margin-bottom:0}.hub-variants{max-height:380px;overflow:auto;border-top:1px solid #303a37}.hub-variant{display:flex;align-items:center;gap:12px;padding:16px 8px;margin:0;border-bottom:1px solid #303a37;cursor:pointer}.hub-variant:has(input:checked){background:#28362b}.hub-variant input{width:16px;min-width:16px;margin:0;accent-color:#c5edaf}.hub-variant span{flex:1;min-width:0}.hub-variant strong{display:block;font-weight:500;font-size:12px;overflow-wrap:anywhere}.hub-variant small{display:block;margin-top:7px;font-size:10px;color:#95a69b}.hub-variant b{font-size:11px;white-space:nowrap}.hub-review{margin-top:22px}.hub-review button{width:100%;font-size:13px}.hub-filename{overflow-wrap:anywhere}.hub-operation{margin-top:20px;border-color:#647e58}.hub-operation h3{font-size:16px}.hub-operation p{margin:10px 0;font-size:12px}.hub-operation progress{height:9px}.hub-operation .card-top strong{font-size:20px}
@media(max-width:1100px){.hub-layout{grid-template-columns:1fr}.hub-results{max-height:360px}.hub-file-controls{grid-template-columns:1fr 1fr}}@media(max-width:600px){.hub-search{align-items:stretch;flex-direction:column}.hub-file-controls{grid-template-columns:1fr}.hub-variant{gap:8px}}