Add conditional external vision projector discovery to profiles

This commit is contained in:
Mikei386
2026-09-28 22:12:50 +02:00
parent 0dbca8cb46
commit 6255f0b8ea
5 changed files with 72 additions and 6 deletions
+55 -2
View File
@@ -32,13 +32,66 @@ window.ProfilesUI=(()=>{
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>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">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;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;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 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]]))};
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" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" placeholder="z. B. qwen-image-test" 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==='chat'?gpuEditor(data.parameters)+`<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><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>`;
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" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" placeholder="z. B. qwen-image-test" 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==='chat'?gpuEditor(data.parameters)+`<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();
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');button.disabled=true;
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==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');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.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;}
};