diff --git a/STUDIO.md b/STUDIO.md index 3fce93d..4b6c2fe 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -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. diff --git a/catalog.py b/catalog.py index fea7c05..9cee36e 100644 --- a/catalog.py +++ b/catalog.py @@ -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 [])] diff --git a/profiles-ui.js b/profiles-ui.js index 4450844..f315785 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -32,13 +32,66 @@ window.ProfilesUI=(()=>{ const options=(value,empty)=>``+available.map(g=>``).join(''); return `

GPU-Auswahl und Verteilung

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.

`; } + 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=''+projectors.map(x=>``).join('');select.value=selected; + return c; + } + function showFiles(detail,target,n){ + const files=detail.files.filter(mmproj); + target.innerHTML=`

Quelle: ${e(detail.repo)} · Lizenz: ${e(detail.license||'nicht angegeben')}

`; + if(!files.length){target.insertAdjacentHTML('beforeend','

Keine mmproj-GGUF-Datei in diesem Repository gefunden.

');return;} + files.forEach(f=>{ + const row=document.createElement('div');row.className='note'; + row.innerHTML=`

${e(f.name)} · ${(f.size/1024**2).toFixed(1)} MiB

`;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='

Eigenes Modell-Repository wird auf Projektoren geprüft …

'; + 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=`

${hasOwn?'Projektor aus dem Modell-Repository':'Externen Vision-Projektor hinzufügen'}

${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.'}

Nur für kompatible Vision-Architekturen. Ein Projektor macht aus einem reinen Textmodell kein Vision-Modell. Modellkarte und Modellversion prüfen.

`; + 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='
'; + 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='

Eigener Projektor konnte nicht geprüft werden. Externe Suche bleibt bis zur Prüfung geschlossen.

';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=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='chat'?gpuEditor(data.parameters)+`

Vision-Projektor

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.

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.

`:''}

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.':''}

`; + el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='chat'?gpuEditor(data.parameters)+`

Vision-Projektor

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.

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.

`:''}

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.':''}

`; if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';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;} }; diff --git a/server.py b/server.py index b146d67..949274d 100644 --- a/server.py +++ b/server.py @@ -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())) diff --git a/test_catalog.py b/test_catalog.py index 146c506..ea71ace 100644 --- a/test_catalog.py +++ b/test_catalog.py @@ -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: