Add conditional external vision projector discovery to profiles
This commit is contained in:
@@ -185,3 +185,9 @@ Entdecken zeigt positive Hub-Tags als Hinweise, Projektordateien separat und ans
|
||||
Passende mmproj-GGUF über Entdecken herunterladen, anschließend im Chat-Profil auswählen. „Deaktiviert“ schaltet Vision aus. Projektorziel CPU oder eine ausgewählte GPU; für ein GPU-Ziel muss diese Karte auch in der GPU-Auswahl enthalten sein. Die Reihenfolge legt CUDA0/CUDA1 fest, die GUI benennt die tatsächlichen Karten. Modell und Projektor müssen zusammenpassen; der Dateiname reicht als Nachweis nicht.
|
||||
|
||||
Zusätzliche konservative Speicherplanung: Projektordateigröße plus 2 GiB Arbeitsreserve auf dem Zielgerät, zusätzlich im Lade-RAM. Das ist keine exakte Vision-Peak-Prognose. Die bisherigen Auto-Tests bleiben reine Texttests ohne Projektor. Der interne Chat-Test ist weiterhin Text; Vision ist über /v1/chat/completions mit einem eingebetteten PNG/JPEG/WebP-Bild im image_url-Feld möglich. Externe URLs sind gesperrt, die gesamte JSON-Anfrage bleibt auf 1 MiB beschränkt.
|
||||
|
||||
### Projektoren direkt im Profil finden
|
||||
|
||||
Der LLM-Profileditor prüft das Hugging-Face-Repository der ausgewählten Modelldatei auf separate mmproj-GGUF-Dateien. Eigene Projektoren werden direkt angeboten; nur wenn dort keine gefunden werden, erscheint die externe Repository-Suche. Bei nicht erreichbaren Metadaten bleibt die Suche bis zur erneuten Prüfung geschlossen. Dies erkennt separate Dateien, nicht eingebettete Vision-Gewichte oder garantierte Architekturkompatibilität.
|
||||
|
||||
Eine Modellfamilie oder ein vollständiger Hugging-Face-Repository-Link sucht unabhängig vom Textgenerierungs-Tag (`GET /api/v1/catalog/search?kind=chat&purpose=projector&q=…`). Nach Auswahl eines Repositorys zeigt der Editor nur mmproj-GGUF-Dateien mit Größe, Quelle und Lizenz. Herunterladen startet keinen Worker. Fortschritt steht unter Downloads. Danach „Bibliothek aktualisieren“, Projektor auswählen, Gerät wählen und Profil speichern. Vorhandene Profilwerte bleiben beim Suchen erhalten. Externe Projektoren müssen zur Modellarchitektur und Version passen; sie erweitern reine Textarchitekturen nicht um Vision.
|
||||
|
||||
+4
-3
@@ -102,7 +102,8 @@ class Catalog:
|
||||
path=self.root/ident/('model'+PurePosixPath(entry['file']).suffix)
|
||||
if not path.is_file() or path.stat().st_size!=entry['size']:raise ValueError('Modelldatei fehlt oder ist unvollständig.')
|
||||
return entry
|
||||
def search(self,q,kind,sort="downloads",base_only=False):
|
||||
def search(self,q,kind,sort="downloads",base_only=False,purpose="model"):
|
||||
if purpose not in ('model','projector'):raise ValueError('Ungültiger Suchzweck.')
|
||||
if kind not in KINDS or not isinstance(q,str) or len(q)>120:raise ValueError('Ungültige Suche.')
|
||||
if sort not in ('downloads','name','newest'):raise ValueError('Ungültige Sortierung.')
|
||||
if not isinstance(base_only,bool):raise ValueError('Ungültiger Basismodellfilter.')
|
||||
@@ -117,10 +118,10 @@ class Catalog:
|
||||
return dict(models=[dict(repo=item['id'],downloads=item.get('downloads'),gated=item.get('gated',False),created_at=item.get('createdAt'),capabilities=capabilities(item))],base_only=base_only,scanned=1,notice='Direkt ausgewähltes Repository: Kategorie- und Basismodellfilter werden für diesen Treffer nicht angewendet.')
|
||||
if q.lower().endswith('.gguf'):
|
||||
q=q[:-5];notice='GGUF-Dateiname als Repository-Suchbegriff verwendet. Die Datei wählst du anschließend in den Modelldetails.'
|
||||
query=dict(search=q,filter=KINDS[kind],limit=100 if base_only else 20,sort='createdAt' if sort=='newest' else 'downloads',direction=-1)
|
||||
query=dict(search=q,filter='gguf' if purpose=='projector' else KINDS[kind],limit=100 if base_only else 20,sort='createdAt' if sort=='newest' else 'downloads',direction=-1)
|
||||
query['expand']=['downloads','createdAt','gated','cardData','tags','pipeline_tag']
|
||||
rows=metadata('/api/models?'+urllib.parse.urlencode(query,doseq=True))
|
||||
if not rows and q and kind=='chat':
|
||||
if not rows and q and kind=='chat' and purpose=='model':
|
||||
fallback=dict(query,filter='gguf',expand=['downloads','createdAt','gated','cardData','tags','pipeline_tag'])
|
||||
candidates=metadata('/api/models?'+urllib.parse.urlencode(fallback,doseq=True))
|
||||
rows=[x for x in candidates if x.get('pipeline_tag')=='text-generation' or 'conversational' in (x.get('tags') or [])]
|
||||
|
||||
+55
-2
@@ -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)&¤t===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;}
|
||||
};
|
||||
|
||||
@@ -279,7 +279,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path.path=='/api/v1/catalog/search':
|
||||
base=q.get('base_only',['false'])[0]
|
||||
if base not in ('true','false'):raise ValueError('Ungültiger Basismodellfilter.')
|
||||
return self.respond(self.server.catalog.search(q.get('q',[''])[0],q.get('kind',['chat'])[0],q.get('sort',['downloads'])[0],base=='true'))
|
||||
return self.respond(self.server.catalog.search(q.get('q',[''])[0],q.get('kind',['chat'])[0],q.get('sort',['downloads'])[0],base=='true',q.get('purpose',['model'])[0]))
|
||||
if path.path=='/api/v1/catalog/assessment':
|
||||
data=self.server.catalog.files(q.get('repo',[''])[0])
|
||||
return self.respond(overview(data['files'],self.server.hardware.snapshot()))
|
||||
|
||||
@@ -41,6 +41,12 @@ class CatalogTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as d,patch('catalog.metadata',side_effect=[[],[item]]) as fetch:
|
||||
result=Catalog(d).search('model.gguf','chat')
|
||||
self.assertEqual(result['models'][0]['repo'],item['id']);self.assertIn('search=model&',fetch.call_args_list[0].args[0]);self.assertIn('filter=gguf',fetch.call_args.args[0])
|
||||
def test_projector_search_includes_vision_repositories(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch('catalog.metadata',return_value=[{'id':'maker/vision','pipeline_tag':'image-text-to-text'}]) as fetch:
|
||||
result=Catalog(d).search('vision','chat',purpose='projector')
|
||||
self.assertEqual(result['models'][0]['repo'],'maker/vision')
|
||||
self.assertIn('filter=gguf',fetch.call_args.args[0])
|
||||
with self.assertRaises(ValueError):Catalog(d).search('vision','chat',purpose='invalid')
|
||||
def test_download_integrity_and_library(self):
|
||||
for valid in (True,False):
|
||||
with self.subTest(valid=valid),tempfile.TemporaryDirectory() as directory:
|
||||
|
||||
Reference in New Issue
Block a user