Files
Athena-Deck/profiles-ui.js
T

153 lines
36 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.
window.ProfilesUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const labels={video_device:'Videopipeline',text_encoder_device:'Textencoder-Gerät',gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',cache_type_k:'KV-Cache K',cache_type_v:'KV-Cache V',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'};
const html=()=>'<section id="live-profiles"><div class="section-heading"><div><h2>Deine Profile</h2><p>Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.</p></div><button id="profile-new">Neues Profil</button></div><p id="profile-message" role="status"></p><div id="profile-list" class="profile-list"></div><div id="profile-editor"></div></section>';
async function api(path,data){const r=await fetch('/api/v1/'+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 bind(kind,modelId){
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],entries=[],panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button><button class="secondary" data-components="${p.id}">Einrichtung & Komponenten</button>${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers)));
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});});
}catch(error){message(error.message);}}
async function enhancers(profile){
const sequence=++panelSequence,selected=profile.prompt_enhancer||{t2i:null,i2i:null,device:'auto'};
el('profile-editor').innerHTML=`<section class="card editor"><h2>Prompt-Aufwerter · ${e(profile.name)}</h2><p>Optional pro Bildprofil. Text-zu-Bild und Bearbeitung mit Referenzbildern verwenden getrennte offizielle Qwen-Modelle. Andere Bildfamilien können sie nur nach eigener Prüfung sinnvoll nutzen.</p><form id="enhancer-form"><div class="form-grid"><label>Text-zu-Bild<select name="t2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-T2I" ${selected.t2i?'selected':''}>Qwen Image 2.1 PE-T2I</option></select></label><label>Bildbearbeitung mit Referenzen<select name="i2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-I2I" ${selected.i2i?'selected':''}>Qwen Image 2.1 PE-I2I</option></select></label><label>Ausführungsgerät<select name="device">${[['auto','Automatisch · 5080, dann 3060, nötigenfalls RAM'],['5080','RTX 5080 · nötigenfalls RAM'],['3060','RTX 3060 · nötigenfalls RAM'],['cpu','Nur CPU · sehr langsam']].map(([v,t])=>`<option value="${v}" ${selected.device===v?'selected':''}>${t}</option>`).join('')}</select></label></div><p>Der Aufwerter wird vor der Bildpipeline geladen und danach vollständig entladen. Beide PE-Modelle benötigen je etwa 18,8 GB Download. Die Wahl einer einzelnen GPU garantiert keinen rein dortigen Betrieb; CPU-Auslagerung kann erforderlich sein. Die Bildauflösung bleibt im Profil gespeichert; ein Seitenverhältnis aus der PE-Antwort ist nur ein Vorschlag.</p><button>Profil-Einstellung speichern</button><button type="button" class="secondary" id="enhancer-close">Schließen</button></form><h3>Offizielle Modelle installieren</h3><div id="enhancer-models"></div><p id="enhancer-message" role="status"></p></section>`;
const note=el('enhancer-message');el('enhancer-close').onclick=()=>el('profile-editor').replaceChildren();
el('enhancer-form').onsubmit=async event=>{event.preventDefault();const form=event.target,values=new FormData(form),button=form.querySelector('button');button.disabled=true;try{await api('profiles/prompt-enhancer',{id:profile.id,revision:profile.revision,prompt_enhancer:{t2i:values.get('t2i')||null,i2i:values.get('i2i')||null,device:values.get('device')}});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Prompt-Aufwerter im Bildprofil gespeichert.');}catch(error){note.textContent=error.message;}finally{button.disabled=false;}};
async function refresh(){
try{const state=await api('prompt-enhancers');if(!root.isConnected||sequence!==panelSequence)return;
el('enhancer-models').innerHTML=['t2i','i2i'].map(task=>{const item=state.models[task];return `<p><strong>${task.toUpperCase()}</strong> · ${item.installed?'Installiert · '+e(item.revision?.slice(0,12)):'Nicht installiert'} · <a class="link" href="https://huggingface.co/${item.repo}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> <button type="button" class="secondary" data-install="${task}" ${item.installed||state.job?.state==='running'?'disabled':''}>Herunterladen</button></p>`;}).join('')+`<p>${e(state.job?.phase||'Kein Download läuft.')}</p>${state.job?.state==='running'?'<button type="button" class="secondary" id="enhancer-cancel">Download abbrechen</button>':''}`;
el('enhancer-models').querySelectorAll('[data-install]').forEach(button=>button.onclick=async()=>{button.disabled=true;try{await api('prompt-enhancers/install',{task:button.dataset.install});note.textContent='Download gestartet. Die Bildmodelle bleiben unberührt.';}catch(error){note.textContent=error.message;}});
el('enhancer-cancel')?.addEventListener('click',async()=>{try{await api('prompt-enhancers/cancel',{});}catch(error){note.textContent=error.message;}});
}catch(error){if(root.isConnected&&sequence===panelSequence)note.textContent=error.message;}
finally{if(root.isConnected&&sequence===panelSequence)setTimeout(refresh,3000);}
}
refresh();el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
}
async function components(profile,extraRepo){
const sequence=++panelSequence;
el('profile-editor').innerHTML='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>';
try{
const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id,...(extraRepo?{repo:extraRepo}:{})}));if(!root.isConnected||sequence!==panelSequence)return;
el('profile-editor').innerHTML=`<section class="card editor"><div class="card-top"><h2>Komponenten · ${e(profile.name)}</h2><button class="secondary" id="components-close">Schließen</button></div><p>${e(data.message)}</p><details><summary>Andere Komponentenquelle auswählen (ungeprüft)</summary><label>Hugging-Face-Link oder Repository<input id="component-source" placeholder="https://huggingface.co/Organisation/Modell"></label><button id="component-source-open" type="button">Dateien anzeigen</button></details>${data.supported?`<p>Quelle: <a class="link" href="${e(data.source)}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> · ${e(data.license||'Lizenz nicht angegeben')}</p><p>Laufzeit: ${e(data.runtime)} · ${data.manual?'Zuordnung ungeprüft':data.runtime_installed?'installiert':'noch nicht eingerichtet'}</p>${!data.manual?'<button type="button" id="components-download-all">Alle fehlenden Dateien herunterladen</button>':'<p>Nur benötigte Dateien einzeln auswählen; diese Liste ist kein automatischer Downloadplan.</p>'}<form id="components-form">${data.requirements.map(r=>`<section class="card component-card"><h3>${e(r.label)}</h3><label>Datei aus der Bibliothek<select name="${r.role}"><option value="">Noch nicht zugeordnet</option>${r.available.map(f=>`<option value="${f.id}" ${(profile.components?.[r.role]===f.id||(!data.manual&&!profile.components?.[r.role]&&r.available.length===1))?'selected':''}>${e(f.file)} · ${(f.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${r.candidates.map((f,i)=>`<div class="note"><p data-download-feedback="${r.role}:${i}" role="status"></p><p class="hub-filename">${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}</p>${f.gated&&!f.auth_configured?'<p>Hugging-Face-Zugang unter <a href="#access">Einstellungen → Zugang</a> hinterlegen und Modellfreigabe prüfen.</p>':''}<button type="button" class="secondary" data-component-download="${r.role}:${i}" ${(f.gated&&!f.auth_configured)||r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'disabled':''}>${r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'Bereits in Bibliothek':'Datei herunterladen'}</button></div>`).join('')||'<p>Keine passende Quelldatei verfügbar.</p>'}</section>`).join('')}<div class="card-actions"><button id="components-save">Zuordnung auf Athena speichern</button><button type="button" class="secondary" id="components-refresh">Bibliothek aktualisieren</button></div><p class="small">Lade je eine Datei pro benötigter Komponente. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.</p></form>`:''}<p id="components-message" role="status"></p></section>`;
const missing=data.requirements?.filter(r=>!r.available.length)||[];
if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{
const b=el('components-download-all');b.disabled=true;let count=0;
try{for(const r of missing){const f=r.candidates.find(f=>!f.gated||f.auth_configured);if(!f)throw Error('Keine herunterladbare Datei; HF-Zugang oder Quellversion prüfen für '+r.label);const catalog=await api('catalog');if((catalog.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===f.repo&&j.file===f.name&&j.revision===f.revision))continue;await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});count++;}if(el('components-message'))el('components-message').textContent='Dateien sind im Downloadplan. Nach Abschluss Bibliothek aktualisieren und Zuordnung speichern; passende Dateien werden vorausgewählt.';}
catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}
};}
el('component-source-open').onclick=()=>{const value=el('component-source').value.trim();if(value)components(profile,value);};
el('components-close').onclick=()=>el('profile-editor').replaceChildren();
el('components-refresh')?.addEventListener('click',()=>components(profile,extraRepo));
el('components-form')?.addEventListener('submit',async event=>{event.preventDefault();const button=el('components-save');button.disabled=true;try{await api('profiles/components',{id:profile.id,revision:profile.revision,components:Object.fromEntries(new FormData(event.target))});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Komponenten dem Profil zugeordnet. Kein Modell gestartet.');}catch(error){if(el('components-message'))el('components-message').textContent=error.message;}finally{button.disabled=false;}});
root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const feedback=b.parentElement.querySelector('[data-download-feedback]');feedback.textContent='Download wird angefragt …';const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});feedback.textContent='In Download-Warteschlange aufgenommen.';if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){feedback.textContent=error.message;feedback.className='warning';if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
}catch(error){message(error.message);el('profile-editor').innerHTML='<section class="card"><p>Komponenten konnten nicht geladen werden. Öffne die Ansicht erneut.</p></section>';}
}
function gpuEditor(params){
const selected=params.gpu_devices||[],available=[...gpus];for(const uuid of selected)if(!available.some(g=>g.uuid===uuid))available.push({uuid,name:'Derzeit nicht verfügbar'});
const options=(value,empty)=>`<option value="">${empty}</option>`+available.map(g=>`<option value="${e(g.uuid)}" ${value===g.uuid?'selected':''}>${e(g.name)} · ${e(g.uuid)}</option>`).join('');
return `<h3>GPU-Auswahl und Verteilung</h3><div class="form-grid"><label>GPU-Ausführung<select name="gpu_offload"><option value="auto" ${(params.gpu_offload||'auto')==='auto'?'selected':''}>Automatisch · CPU-Auslagerung bei Platzmangel</option><option value="full" ${params.gpu_offload==='full'?'selected':''}>Vollständig auf GPU · sonst Start abbrechen</option></select></label><label>Zusätzliche GPU-Reserve<select name="gpu_reserve_mode">${[['auto','Automatisch · mindestens 512 MiB je GPU'],['manual','Manuell je GPU'],['none','Keine zusätzliche Reserve']].map(([v,t])=>`<option value="${v}" ${(params.gpu_reserve_mode||'auto')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>Reserve erste GPU (MiB)<input name="reserve_first" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[0]]??512}"></label><label>Reserve zweite GPU (MiB)<input name="reserve_second" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[1]]??512}"></label><label>Erste GPU · CUDA0<select name="gpu_first">${options(selected[0],'Automatische Auswahl')}</select></label><label>Zweite GPU · CUDA1<select name="gpu_second">${options(selected[1],'Keine zweite GPU')}</select></label><label>GPU-Split<select name="split_mode">${[['none','Keine Aufteilung'],['layer','Layer-Split'],['row','Row-Split']].map(([v,t])=>`<option value="${v}" ${(params.split_mode||'none')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>GPU-Verteilung (erste, zweite GPU)<input name="tensor_split" placeholder="z. B. 85,15" value="${e((params.tensor_split||[]).join(','))}"></label></div><p class="small">Manuelle Reserven gelten für die jeweils ausgewählte GPU. Ohne Reserve entfällt nur der zusätzliche GPU-Puffer; Modell, KV-Cache und die konservative Projektor-Prognose bleiben berücksichtigt. OOM bleibt möglich. Für einen Abbruch statt CPU-Auslagerung „Vollständig auf GPU“ wählen. Die Reihenfolge legt die logischen CUDA-Geräte des Profils fest. Für Medium: RTX 5080 zuerst, RTX 3060 danach, Layer-Split und 85,15. Leere Verteilung bedeutet automatisch. Dies ist keine Zusage, dass Modell und Kontext in den verfügbaren Speicher passen.</p>`;
}
function bindProjectorDiscovery(){
const form=el('profile-form'),box=el('projector-discovery'),select=form.elements.vision_projector;
let sequence=0;
const live=n=>box.isConnected&&n===sequence;
const mmproj=f=>/(?:^|[\/_-])mmproj(?:[\/_.-]|$)/i.test(f.name)&&f.name.toLowerCase().endsWith('.gguf');
async function refreshLibrary(){
const c=await api('catalog');if(!box.isConnected)return;
const selected=select.value;entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));
select.innerHTML='<option value="">Kein Projektor zugeordnet · Vision aus</option>'+projectors.map(x=>`<option value="${e(x.id)}">${e(x.repo)} / ${e(x.file)}</option>`).join('');select.value=selected;
return c;
}
function showFiles(detail,target,n){
const files=detail.files.filter(mmproj);
target.innerHTML=`<p>Quelle: <a class="link" href="${e(detail.url)}" target="_blank" rel="noopener noreferrer">${e(detail.repo)}</a> · Lizenz: ${e(detail.license||'nicht angegeben')}</p>`;
if(!files.length){target.insertAdjacentHTML('beforeend','<p>Keine mmproj-GGUF-Datei in diesem Repository gefunden.</p>');return;}
files.forEach(f=>{
const row=document.createElement('div');row.className='note';
row.innerHTML=`<p class="hub-filename">${e(f.name)} · ${(f.size/1024**2).toFixed(1)} MiB</p><button type="button" class="secondary">Herunterladen</button><span role="status"></span>`;target.append(row);
const button=row.querySelector('button'),status=row.querySelector('span');
function update(){const entry=projectors.find(x=>x.repo===detail.repo&&x.revision===detail.revision&&x.file===f.name);button.textContent=entry?'Im Profil auswählen':'Herunterladen';button.disabled=!!detail.gated&&!detail.auth_configured;return entry;}
update();button.onclick=async()=>{button.disabled=true;try{
await refreshLibrary();if(!live(n))return;const entry=update();
if(entry){select.value=entry.id;status.textContent=' Ausgewählt. Profil speichern, um die Zuordnung zu übernehmen.';return;}
await api('catalog/download',{repo:detail.repo,filename:f.name,revision:detail.revision,kind:'chat'});
status.textContent=' Download läuft. Fortschritt unter Downloads; danach Bibliothek aktualisieren und hier auswählen.';
}catch(error){status.textContent=error.message;}finally{if(live(n))update();}};
});
}
async function check(){
const n=++sequence,model=models.find(x=>x.id===form.elements.model_id.value);
box.innerHTML='<p>Eigenes Modell-Repository wird auf Projektoren geprüft …</p>';
try{
let own=projectors.filter(x=>x.repo===model.repo);
const detail=await api('catalog/files?'+new URLSearchParams({repo:model.repo}));if(!live(n))return;
const hasOwn=own.length>0||detail.files.some(mmproj);
box.innerHTML=`<h4>${hasOwn?'Projektor aus dem Modell-Repository':'Externen Vision-Projektor hinzufügen'}</h4><p>${hasOwn?'Das Modell-Repository bietet einen eigenen Projektor. Wähle eine passende Variante.':'Im Modell-Repository wurde keine separate mmproj-Datei gefunden. Suche im Repository des ursprünglichen Vision-Modells.'}</p><p class="small">Nur für kompatible Vision-Architekturen. Ein Projektor macht aus einem reinen Textmodell kein Vision-Modell. Modellkarte und Modellversion prüfen.</p><button type="button" class="secondary" data-refresh>Bibliothek aktualisieren</button><p data-status role="status"></p><div data-search></div><div data-files></div>`;
const status=box.querySelector('[data-status]'),target=box.querySelector('[data-files]');
box.querySelector('[data-refresh]').onclick=async()=>{try{await refreshLibrary();if(live(n)){status.textContent='Bibliothek aktualisiert. Projektor oben auswählen und Profil speichern.';}}catch(error){status.textContent=error.message;}};
if(hasOwn){showFiles(detail,target,n);return;}
const search=box.querySelector('[data-search]');search.innerHTML='<label>Modellfamilie oder Hugging-Face-Repository-Link<input maxlength="120" placeholder="z. B. Qwen GGUF oder https://huggingface.co/Anbieter/Modell"></label><button type="button" class="secondary">Projektor suchen</button><div data-results></div>';
let searchSequence=0;
search.querySelector('button').onclick=async()=>{
const current=++searchSequence;status.textContent='Repositories werden gesucht …';target.replaceChildren();
try{const result=await api('catalog/search?'+new URLSearchParams({q:search.querySelector('input').value.trim(),kind:'chat',purpose:'projector'}));if(!live(n)||current!==searchSequence)return;
const results=search.querySelector('[data-results]');results.replaceChildren();status.textContent=result.models.length?'Repository auswählen; angezeigt werden anschließend ausschließlich Projektordateien.':'Keine Treffer. Versuche den direkten Repository-Link.';
result.models.forEach(m=>{const b=document.createElement('button');b.type='button';b.className='secondary';b.textContent=m.repo;results.append(b);b.onclick=async()=>{const request=++searchSequence;status.textContent='Projektordateien werden geprüft …';try{const d=await api('catalog/files?'+new URLSearchParams({repo:m.repo}));if(!live(n)||request!==searchSequence)return;showFiles(d,target,n);status.textContent='Kompatibilität anhand der verlinkten Modellkarte prüfen.';}catch(error){if(live(n)&&request===searchSequence)status.textContent=error.message;}};});
}catch(error){if(live(n)&&current===searchSequence)status.textContent=error.message;}
};
}catch(error){if(live(n)){box.innerHTML='<p>Eigener Projektor konnte nicht geprüft werden. Externe Suche bleibt bis zur Prüfung geschlossen.</p><button type="button" class="secondary">Erneut prüfen</button>';box.querySelector('p').append(' '+error.message);box.querySelector('button').onclick=check;}}
}
form.elements.model_id.addEventListener('change',check);check();
}
function videoDevices(data){
const params=data.parameters;
const options=(key,fallback,title)=>`<label>${title}<select name="${key}"><option value="${fallback}">${fallback==='auto'?'Automatisch · RTX 5080 bevorzugt':'Wie Videopipeline'}</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${params[key]===g.uuid?'selected':''}>${e(g.name)} · ${Number.isFinite(g.total_mib)?(g.total_mib/1024).toFixed(1)+' GiB':'Speicher unbekannt'}</option>`).join('')}${params[key]&&!['auto','same',...gpus.map(g=>g.uuid)].includes(params[key])?`<option selected value="${e(params[key])}">Nicht verfügbar: ${e(params[key])}</option>`:''}<option disabled>CPU · vom aktuellen Adapter nicht unterstützt</option></select></label>`;
return `<section class="card"><h3>Komponenten und Geräte</h3><div class="form-grid">${options('video_device','auto','Videomodell / Diffusion')}${options('text_encoder_device','same','Textencoder')}</div><p>Video-VAE, Audio-VAE und Spatial-Upsampler laufen derzeit auf der GPU der Videopipeline. Unabhängige Zuordnung dieser Komponenten ist noch nicht unterstützt.</p><div id="video-device-advice" role="status"></div><p>Disk-Streaming bleibt aktiv. Dateigröße ist keine VRAM-Messung: Aktivierungen, Auflösung und Bildanzahl benötigen zusätzlichen Speicher. Änderungen gelten nach erneutem Wechsel in den Videomodus.</p></section>`;
}
function bindVideoDevices(data){
const form=el('profile-form'),target=el('video-device-advice');
function refresh(){
const main=models.find(m=>m.id===form.elements.model_id.value),encoder=main?.id===data.model_id?entries.find(m=>m.id===data.components?.text_encoder):null;
const primary=form.elements.video_device.value==='auto'?(gpus.find(g=>g.name.includes('5080'))||[...gpus].sort((a,b)=>(b.total_mib||0)-(a.total_mib||0))[0]):gpus.find(g=>g.uuid===form.elements.video_device.value);
const secondary=form.elements.text_encoder_device.value==='same'?primary:gpus.find(g=>g.uuid===form.elements.text_encoder_device.value);
function assessment(label,file,gpu){
if(!file)return `<p>${label}: Komponente noch nicht zugeordnet; Größenvergleich nicht möglich.</p>`;
if(!gpu)return `<p>${label}: ausgewählte GPU nicht verfügbar. Hardware-Verbindung prüfen.</p>`;
const size=file.size/1024**3,total=gpu.total_mib/1024,free=Number.isFinite(gpu.used_mib)&&Number.isFinite(gpu.total_mib)?(gpu.total_mib-gpu.used_mib)/1024:null;
const valid=Number.isFinite(size)&&size>0&&Number.isFinite(total)&&total>0;
return `<p><strong>${label} → ${e(gpu.name)}</strong><br>Datei: ${valid?size.toFixed(2)+' GiB':'unbekannt'} · VRAM gesamt: ${Number.isFinite(total)?total.toFixed(1)+' GiB':'unbekannt'} · aktuell frei: ${free===null?'unbekannt':free.toFixed(1)+' GiB'}. ${!valid?'Speichereignung unbekannt.':size>total?'Datei größer als gesamter VRAM: vollständiges Laden in unverändertem BF16 ist nicht vorgesehen; Streaming nötig.':free!==null&&size>free?'Datei größer als aktuell freier VRAM. Laufende Belegung kann sich beim Moduswechsel ändern.':'Dateigröße unter VRAM-Kapazität; tatsächlicher Spitzenbedarf noch nicht gemessen.'}</p>`;
}
target.innerHTML='<p><strong>Empfehlung:</strong> RTX 5080 für die Videopipeline. Textencoder zunächst auf derselben GPU lassen; RTX 3060 ist eine optionale getrennte Ausführung mit Streaming, keine Zusage für mehr Geschwindigkeit oder vollständige VRAM-Residenz.</p>'+assessment('Videomodell',main,primary)+assessment('Textencoder',encoder,secondary)+'<p>Hardwarewerte sind eine Momentaufnahme beim Öffnen der Profilansicht. Ein erfolgreicher Test mit deiner Auflösung bleibt erforderlich.</p>';
}
for(const key of ['video_device','text_encoder_device','model_id'])form.elements[key].addEventListener('change',refresh);
refresh();
}
function editor(p=null,id=null){
panelSequence++;
if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))};
if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;}
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" placeholder="z. B. Qwen3.8 27B MEDIUM" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`<h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.repo)} / ${e(x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='<summary>Erweitert: CPU-Threads</summary><p>CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.</p>';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));}
if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();}
if(kind==='video')bindVideoDevices(data);
el('profile-close').onclick=()=>el('profile-editor').replaceChildren();
el('profile-form').onsubmit=async event=>{event.preventDefault();const form=event.target,values=new FormData(form),button=form.querySelector('button:not([type=button])');button.disabled=true;
try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='video'){parameters.video_device=values.get('video_device');parameters.text_encoder_device=values.get('text_encoder_device');}if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.cache_type_k=values.get('cache_type_k');parameters.cache_type_v=values.get('cache_type_v');parameters.gpu_offload=values.get('gpu_offload');if(values.get('gpu_second')&&!values.get('gpu_first'))throw Error('Bitte zuerst die erste GPU auswählen.');if((data.parameters.gpu_devices||[]).length>2)throw Error('Dieses Profil enthält mehr als zwei GPUs; der Editor unterstützt derzeit zwei.');parameters.gpu_devices=[values.get('gpu_first'),values.get('gpu_second')].filter(Boolean);parameters.gpu_reserve_mode=values.get('gpu_reserve_mode');parameters.gpu_reserve_mib=parameters.gpu_reserve_mode==='manual'?Object.fromEntries([['gpu_first','reserve_first'],['gpu_second','reserve_second']].filter(([g])=>values.get(g)).map(([g,r])=>[values.get(g),Number(values.get(r))])):{};parameters.split_mode=values.get('split_mode');const raw=String(values.get('tensor_split')).trim();parameters.tensor_split=raw?raw.split(',').map(x=>x.trim()?Number(x):NaN):[];}await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');}
catch(error){if(root.isConnected)el('profile-error').textContent=error.message;}finally{button.disabled=false;}
};
el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
}
el('profile-new').disabled=true;el('profile-new').onclick=()=>editor();
load().then(()=>{if(modelId&&root.isConnected)editor(null,modelId);});
}
return {html,bind};
})();