diff --git a/README.md b/README.md index e7b2c22..4c7c09f 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,11 @@ Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht veränder ## Neu: Modellverwaltung und llama.cpp-Einstellungen -Katalog, Bibliothek, Profilentwürfe und eine Laufzeit-/Update-Ansicht sind als -bedienbarer GUI-Prototyp vorhanden. Entwürfe bleiben im Browser; Modellstarts, -Katalog, Datei-Downloads und llama.cpp-Buildverwaltung sind live. Modellstarts sind noch nicht angebunden; siehe DEVELOPMENT.md. Details: [GUI-Prototyp](STUDIO.md). +Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und +llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden +abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine +konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose. +Modellstarts sind noch nicht angebunden. Details: [Modellverwaltung](STUDIO.md). ## Zugang und API-Token @@ -85,7 +87,7 @@ Keine Historie und kein Hintergrund-Collector bei geschlossener Hardware-Seite. - `test_server.py`: Prozess-Lebenszyklus, Kontrollgrenzen, Hardware-Ausfall. Sprachmodelle/Chat, Bildgenerierung, Audio und Video besitzen jetzt eine -GUI-Vorschau; Weitere Dienste bleibt Platzhalter. Keine Downloads, Installation, Presets, Chats oder Modellwechsel. +gemeinsame Modellverwaltung; Weitere Dienste bleibt Platzhalter. Downloads und Profile sind aktiv, Chats und Modellwechsel noch nicht. Spätere Modellprofile und native llama.cpp-Worker sollten eigene Service-Adapter mit derselben Status-/Start-/Stopp-Trennung erhalten. Noch kein Worker-Registry, Scheduler oder produktionsreifer Worker-Supervisor. Die optionale Server-Instanz diff --git a/STUDIO.md b/STUDIO.md index 57d93a4..17deea5 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -1,103 +1,52 @@ -# Stand Laufzeitverwaltung +# Modellverwaltung in Athena Deck -llama.cpp-Prüfung, Releases, Builds, Abbruch und Standard-/Rückfallauswahl sind -jetzt live. Kein manuelles VRAM-Reservefeld mehr. Modellbezogene Prognosen verwenden die lesend eingebundenen alten Routermodelle -und llama-fit-params; ein Modellstart erfolgt nicht. Die folgenden -0.4/0.5-Beschreibungen der Laufzeitansicht sind historisch. +## Serverprofile, Downloads und Größen (2026-09-28) -# Stand 0.5 +Profile sind jetzt echte, serverseitig persistierte Konfigurationen in +`state/profiles.json`. Die Bibliothek bietet „Profil anlegen“ für heruntergeladene +Gewichtsdateien. Alle vier Bereiche unterstützen Anlegen, Bearbeiten und +Duplizieren. Namen sind global eindeutig; Änderungen werden anhand einer +Revisionsnummer gegen versehentliches Überschreiben geprüft. Frühere lokale +Browserentwürfe werden weder gelöscht noch automatisch als echte Profile importiert. -Katalog und Datei-Downloads sind jetzt live. Die folgende Beschreibung der -Katalogbeispiele ist historisch (0.4). Aktuelle API und Grenzen: DEVELOPMENT.md. -Profile und Laufzeitsteuerung bleiben Entwürfe. +Bildprofile speichern Auflösung, Schritte, Seed und Guidance. Chatprofile +speichern Kontextbudget, Slots, Threads, Batch und Microbatch; Audio derzeit +Sprechgeschwindigkeit, Video Auflösung, Frames, FPS, Schritte und Seed. +Ein Profil startet noch keinen Worker. Fehlende Laufzeiten und bei Qwen-Image-2.1 +Textencoder/VAE werden angezeigt. Auf Wunsch des Betreibers werden in diesem +Schritt keine Bildlaufzeiten oder Zusatzmodelle installiert. -# Modellverwaltung und llama.cpp · GUI-Prototyp 0.4 +Der Reiter Downloads zeigt die persistierte Downloadhistorie (`models/downloads.json`). +„Aus Liste entfernen“ blendet ausschließlich einen beendeten Eintrag aus, auch +über Neustarts hinweg. Es löscht keine Modelldatei und keinen Bibliothekseintrag. +Bestehende Bibliotheksdateien werden einmalig als abgeschlossene Downloads übernommen. +Unterbrochene Downloads bleiben erkennbar; noch kein Fortsetzen/Resume. +Abgeschlossene Downloads belegen nicht mehr dauerhaft die Entdecken-Ansicht. -Diese Erweiterung ist ausdrücklich rein optisch und lokal bedienbar. Keine -Modelldateien, Laufzeiten, Downloads, Builds oder produktiven Dienste werden verändert. +Entdecken lädt Dateigrößen mit zwei parallelen Metadatenanfragen nach. Angaben +stammen aus Hugging Face und werden fünf Minuten gecacht (maximal 64 Repositories). +Die Übersicht bevorzugt GGUF-Hauptgewichte, schließt erkennbare Encoder/VAE/LoRA- +Dateien aus und summiert vollständig vorhandene Shards einer Variante. +Die angegebene Spanne enthält keine zusätzlichen Pipeline-Komponenten. -## Ansichten +Die Speicherprüfung ist bewusst eine **Untergrenze für Gewichte**, keine vollständige +Lauffähigkeitsprognose: Dateigröße gegen jede GPU einzeln, insgesamt und aktuell frei, +mit 5 Prozent / mindestens 512 MiB Reserve. GPUs werden nicht pauschal addiert. +Die Dateiauswahl zeigt GPU-Werte, Messzeit und ein vorhandenes Deck-RAM-Limit. +KV-Cache, Kontext, Aktivierungen, Encoder/VAE und backendabhängige Formate bleiben +als unbekannter Zusatzbedarf gekennzeichnet. Ein positives Gewichts-Ergebnis +bedeutet nicht „Modell kann gestartet werden“. -- Sprachmodelle/Chat, Bild, Audio und Video: Entdecken, Bibliothek, Profile, Laufend. -- Katalog: Suche/Quellenfilter über gekennzeichnete Beispiele, Detailansicht mit - getrennter Prüfung von Unterstützung, grundsätzlicher Eignung und aktueller - Startmöglichkeit. Keine Online-Suche und keine berechneten Speicherwerte. -- Bibliothek: Beispiel einer einmaligen Modelldatei mit mehreren Profilen; - ausdrücklich keine bestätigte Installation. -- LLM-Profile: drei vorbelegte Beispiele, Anlegen, Bearbeiten, Duplizieren und - Entfernen lokaler Entwürfe. Name, Modellvariante, Kontextbudget, Slots, - GPU-Zuordnung, KV-Cache, gemeinsamer Pool, GPU-Gewichtung, Batch/Microbatch, - CPU-Threads, Flash Attention und vorgesehener Vision-Projektor. -- Einzigartige API-Profilnamen, gültige Zahlenbereiche, Microbatch <= Batch und - nicht ausschließlich nullwertige GPU-Gewichtung werden im Formular geprüft. -- API-Vorschau zeigt die vorgesehenen Profilnamen. `/v1/models` wird dadurch - **nicht** als funktionsfähiger Endpunkt implementiert. -- Laufend: leerer Zustand sowie geplanter Ablauf des Profilwechsels, ohne - simulierte laufende Modelle oder Warteschlangen. -- Bild/Audio/Video-Profile: vorbereitete, deaktivierte aufgabenspezifische Felder. +API (Administrator-Sitzung erforderlich, bestehender CSRF-Schutz): +- `GET /api/v1/catalog/assessment?repo=owner/name`: Größenbereich und Gewichtsprüfung. +- `GET /api/v1/catalog/files?repo=owner/name`: zusätzlich Prüfung pro Datei. +- `GET /api/v1/catalog`: Bibliotheks-IDs und `downloads` neben aktivem `job`. +- `POST /api/v1/catalog/dismiss` mit `{ "id": "download-id" }`: beendeten Eintrag ausblenden. +- `GET /api/v1/profiles`: Profile, Blocker und Parameterschemata. +- `POST /api/v1/profiles/save`: `id` (bei Neuanlage null), `revision` (anfangs 0), + `name`, `kind`, `model_id` und `parameters`. Die Modelldatei muss tatsächlich + vollständig in der Bibliothek liegen. Keine beliebigen Pfade/Startbefehle. -## Einstellungen → llama.cpp & Updates +Die eigenständig implementierte Galerie orientiert sich an [LocalAIs Explore-/Detailansicht](https://github.com/mudler/LocalAI/blob/master/core/http/react-ui/src/pages/Models.jsx). Kein LocalAI-Frontendcode wurde übernommen. llama.cpp-Buildverwaltung und Einpassung der alten Referenzprofile sind unter Einstellungen verfügbar; Details in DEVELOPMENT.md. -Build-Entwurf mit Backend (CUDA/CPU/Vulkan), gewünschtem Release oder Commit, -automatischer bzw. expliziter GPU-Architekturauswahl, Toolkit-Prüfung, parallelen -Build-Jobs und vorgesehener VRAM-Reserve. Für Athena sind RTX 5080 und RTX 3060 als -bekanntes Zielsystem benannt; eine Live-Build-Kompatibilitätsprüfung erfolgt nicht. - -Die Update-Ansicht lässt sich aufklappen. Sie zeigt Platz für aktuellen Build, -verfügbares Release, echte Release-Notes mit Quelle und Datum sowie Neubau und -spätere Wiederherstellung. Alle zustandsändernden Laufzeit-Aktionen und die -Live-Update-Suche sind deaktiviert. Keine erfundenen Versionsnummern oder Fixes. -Ein Link führt zu den offiziellen llama.cpp-Releases. - -## Speicherung und spätere Anbindung - -Entwürfe liegen ausschließlich im localStorage dieses Browsers und Origins unter -`athena-deck-studio-v1`. Sie bleiben nach Neuladen erhalten, enthalten keine -Zugangsdaten und werden nicht zum Server übertragen. Andere Browser oder die -Server-Instanz teilen diesen Stand nicht. Bei gesperrtem Browser-Speicher weist die -Oberfläche darauf hin, dass der Entwurf nur im aktuellen Speicher bleibt. - -Die neuen Ansichten in `studio.js` rufen keine API auf. Vorhandene Anmeldung, -Hardwareanzeige, Demo-Service und Netzwerkverwaltung behalten ihre bisherigen -Funktionen. `server.py` liefert zusätzlich nur die neue JavaScript-Datei aus. - -Vor echter Nutzung erforderlich: Modellkatalog-Adapter, Metadaten-/GGUF-Auswertung, -Downloadverwaltung, persistente Profil-API, GPU-/KV-Planung, Build-Manager und -Prozess-Supervisor mit Warteschlange. Gespeicherte Entwürfe dürfen nicht ungeprüft -als Shell-Befehle interpretiert werden. Die genaue Kontext-/Slot-Semantik muss an -die eingesetzte llama.cpp-Version gekoppelt und überprüft werden. - -## Prüfung - -JavaScript-Syntax geprüft. In einer isolierten statischen Browservorschau: -Profil bearbeitet (Kontext geändert), gespeichert und nach Reload wiedergefunden; -Build-Einstellungen und Update-Ansicht geöffnet, deaktivierte Aktionsknöpfe sowie -Layout kontrolliert. Die Testvorschau verwendet einen eigenen Origin, sodass keine -Entwürfe der eigentlichen Anwendung überschrieben wurden. Keine SSH-/Modell- oder -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. +Validiert: 54 Python-Tests; JavaScript-Syntaxprüfung; isolierter Browsertest mit echten Katalogdaten und einer temporären Testdatei für Profilanlage, Komponentenhinweise und Download-Ausblenden. Keine produktiven Modellstarts. diff --git a/capacity.py b/capacity.py new file mode 100644 index 0000000..d707fce --- /dev/null +++ b/capacity.py @@ -0,0 +1,55 @@ +"""Conservative file-weight checks, never a claim that a pipeline can execute.""" +from pathlib import Path +import re + +GIB=1024**3 + +def assess(size, hardware, filename=''): + if not isinstance(size,int) or size<1:return dict(label='Größe unbekannt',scope='unknown',gpus=[]) + if filename.lower().endswith('.json'):return dict(label='Konfigurationsdatei · kein Modell',scope='configuration',gpus=[]) + gpus=[] + for gpu in hardware.get('gpus',[]): + total=gpu.get('total_mib');used=gpu.get('used_mib') + reserve=max(512,(total or 0)*.05) + capacity=(total-reserve)*1024**2 if total is not None else None + free=max(0,total-used-reserve)*1024**2 if total is not None and used is not None else None + gpus.append(dict(name=gpu['name'],uuid=gpu.get('uuid'),capacity_bytes=capacity,free_bytes=free, + weights_fit_total=size<=capacity if capacity is not None else None, + weights_fit_now=size<=free if free is not None else None)) + fits=any(x['weights_fit_total'] for x in gpus);now=any(x['weights_fit_now'] for x in gpus) + label='Gewichte passen auf eine GPU; Gesamtbedarf offen' if fits else 'Gewichte benötigen Aufteilung/Auslagerung; Gesamtbedarf offen' if gpus else 'GPU-Speicher nicht ermittelbar' + ram=hardware.get('ram',{});total=ram.get('total_bytes');used=ram.get('used_bytes') + limit=None + try: + raw=Path('/sys/fs/cgroup/memory.max').read_text().strip() + if raw!='max':limit=int(raw) + except (OSError,ValueError):pass + return dict(label=label,scope='weights_only',weight_bytes=size,gpus=gpus,weights_fit_now=now if gpus else None, + ram_total_bytes=total,ram_available_bytes=total-used if total is not None and used is not None else None, + deck_ram_limit_bytes=limit,sampled_at=hardware.get('sampled_at'), + explanation='Untergrenze: nur diese Datei, 5 % GPU-Reserve (mindestens 512 MiB). Kontext/KV-Cache, Aktivierungen, Textencoder und VAE kommen hinzu. Kein Startversprechen; GPUs werden nicht addiert.') + +def overview(files,hardware): + # Do not rank a tiny LoRA, VAE or encoder as the base model. Prefer GGUF + # variants when available; otherwise inspect main safetensors weights. + weights=[f for f in files if f['name'].lower().endswith(('.gguf','.safetensors')) and not + re.search(r'(?:vae|text.encoder|tokenizer|mmproj|lora|adapter|clip|safety.checker|mlx)',f['name'],re.I)] + gguf=[f for f in weights if f['name'].lower().endswith('.gguf')] + if gguf:weights=gguf + groups={};variants=[] + for f in weights: + match=re.match(r'(.*)-(\d{5})-of-(\d{5})(\.(?:gguf|safetensors))$',f['name'],re.I) + if match: + prefix,idx,count,suffix=match.groups();key=(prefix,int(count),suffix) + groups.setdefault(key,[]).append((int(idx),f)) + else:variants.append(f) + incomplete=False + for (prefix,count,suffix),parts in groups.items(): + if {i for i,_ in parts}==set(range(1,count+1)): + variants.append(dict(name=prefix+' (alle '+str(count)+' Teile)',size=sum(f['size'] for _,f in parts))) + else:incomplete=True + if not variants:return dict(min_bytes=None,max_bytes=None,label='Modellgröße nicht eindeutig ermittelbar; Dateiauswahl öffnen',scope='unknown') + smallest=min(variants,key=lambda x:x['size']);largest=max(variants,key=lambda x:x['size']) + return dict(min_bytes=smallest['size'],max_bytes=largest['size'],file_count=len(variants), + **assess(smallest['size'],hardware,smallest['name']),basis=smallest['name'],incomplete_shards=incomplete, + size_note='Hauptgewichte (GGUF bevorzugt), vollständige Shards zusammengefasst; nicht Gesamtpaket. Zusatzmodelle und Laufzeitbedarf fehlen.') diff --git a/catalog-ui.js b/catalog-ui.js index 6375d19..0fff3f5 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -7,45 +7,52 @@ window.CatalogUI = (() => { 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 `
HUGGING FACE · LIVE-KATALOG

${library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}

${library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}

+ function html(library, downloads=false) { + return `
HUGGING FACE · LIVE-KATALOG

${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}

${downloads ? 'Abgeschlossene Einträge ausblenden. Die Dateien bleiben in deiner Bibliothek.' : library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}

- ${library ? '
' : `
Beliebte Modelle
01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN

Welches Modell interessiert dich?

Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.

Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.

`}
`; + ${downloads ? '' : library ? '
' : `
Beliebte Modelle
01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN

Welches Modell interessiert dich?

Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.

Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.

`}
`; } - function bind(kind, library) { + function bind(kind, library, downloads=false) { const root = document.querySelector('#live-catalog'); if (!root) return; const el = id => root.querySelector('#'+id); - let state = {entries:[],job:null,free_bytes:null}, models = [], selected = null, detail = null, timer, refreshing = false, searchSequence = 0, detailSequence = 0, starting = false, libraryRendered = false; + let state = {entries:[],job:null,free_bytes:null,downloads:[]}, models = [], selected = null, detail = null, timer, refreshing = false, searchSequence = 0, detailSequence = 0, starting = false, libraryRendered = false, lastJobs = null; const message = text => { if (root.isConnected) el('hub-message').textContent = text; }; + const fitText = a => a ? `${a.label}${a.weights_fit_now===true?' · Aktuell: Gewichte passen':a.weights_fit_now===false?' · Aktuell: keine GPU mit genug freiem Speicher für diese Datei':''}` : 'Größe und Speicher werden ermittelt …'; const installed = file => detail && state.entries.some(x => x.repo === detail.repo && x.revision === detail.revision && x.file === file.name); 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 => ``).join('') || '

Keine Treffer

Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.

'; + el('hub-results').innerHTML = sorted.map(m => ``).join('') || '

Keine Treffer

Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.

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

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

Laufzeit noch nicht angebunden

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

`).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; + el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `
DATEI HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

Laufzeit noch nicht angebunden

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${/\.(gguf|safetensors)$/i.test(x.file)?`
`:""}
`).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; } 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 = `
${escape(labels[j.state] || j.state)}${percent.toFixed(1)} %

${escape(j.repo)}

${escape(j.file)}

${size(j.bytes)} von ${size(j.total)}${j.state === 'complete' ? ' · geprüft und in der Bibliothek gespeichert' : ''}

${j.error ? `

${escape(j.error)}

` : ''}${j.state === 'downloading' ? '' : ''}
`; - 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; } }); + const jobs=downloads ? (state.downloads||[]).filter(j=>j.kind===kind) : state.job?.state==='downloading' ? [state.job] : []; + const signature=JSON.stringify(jobs); if(signature===lastJobs)return; lastJobs=signature; + const labels={downloading:'Wird heruntergeladen',complete:'Download abgeschlossen',cancelled:'Download abgebrochen',failed:'Download fehlgeschlagen',interrupted:'Download unterbrochen'}; + el('hub-job').innerHTML=jobs.map(j=>{const percent=j.total>0?Math.min(100,Math.max(0,j.bytes/j.total*100)):0;return `
${escape(labels[j.state]||j.state)}${percent.toFixed(1)} %

${escape(j.repo)}

${escape(j.file)}

${size(j.bytes)} von ${size(j.total)}

${j.error?`

${escape(j.error)}

`:''}${j.state==='downloading'?'':`Datei bleibt erhalten`}
`;}).join('')||(downloads?'

Keine Downloads in dieser Liste

Heruntergeladene Modelldateien findest du weiterhin in der Bibliothek.

':''); + root.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/cancel',{});message('Abbruch angefordert; die Netzwerkabfrage kann bis zu 20 Sekunden dauern.');}catch(error){message(error.message);b.disabled=false;}}); + root.querySelectorAll('[data-dismiss]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/dismiss',{id:b.dataset.dismiss});message('Eintrag ausgeblendet. Die Modelldatei bleibt in der Bibliothek.');await refresh();}catch(error){message(error.message);b.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(); } } + 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; root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { 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.'); } + try { const result = await api('/search?'+new URLSearchParams({q,kind})); if (!root.isConnected || sequence !== searchSequence) return; models = result.models; renderModels(); enrich(sequence); 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); } } + async function enrich(sequence){ + const pending=[...models]; + async function worker(){while(pending.length&&root.isConnected&&sequence===searchSequence){const model=pending.shift();try{model.capacity=await api('/assessment?'+new URLSearchParams({repo:model.repo}));}catch(error){model.capacityError='Größe / Speicher nicht ermittelbar';}if(!root.isConnected||sequence!==searchSequence)return;const node=[...root.querySelectorAll('[data-capacity]')].find(n=>n.dataset.capacity===model.repo);if(node)node.textContent=model.capacity?`${size(model.capacity.min_bytes)} – ${size(model.capacity.max_bytes)} Hauptgewichte · kleinste Variante: ${fitText(model.capacity)}`:model.capacityError;}} + await Promise.all([worker(),worker()]); + } function updateSelection() { if (!detail || !el('hub-download')) return; const chosen = el('hub-variants').querySelector('input:checked'); @@ -56,6 +63,7 @@ window.CatalogUI = (() => { 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.'; + if(el('hub-fit-result'))el('hub-fit-result').innerHTML=file?.assessment?`${escape(fitText(file.assessment))}

${escape(file.assessment.explanation||'')}

${(file.assessment.gpus||[]).map(g=>`

${escape(g.name)}: ${size(g.capacity_bytes)} nutzbar insgesamt · ${size(g.free_bytes)} aktuell frei (nach Reserve)

`).join('')}${file.assessment.sampled_at?`

Messung: ${escape(new Date(file.assessment.sampled_at*1000).toLocaleTimeString('de-DE'))}

`:''}${file.assessment.deck_ram_limit_bytes?`

Deck-RAM-Limit: ${size(file.assessment.deck_ram_limit_bytes)}

`:''}`:'Wähle eine Datei für die Speicherprüfung. Gesamtbedarf der Laufzeit noch nicht berechnet.'; } function renderFiles() { const query = el('hub-file-filter').value.toLowerCase(), format = el('hub-format').value; @@ -69,7 +77,7 @@ window.CatalogUI = (() => { el('hub-files').innerHTML = '

Modelldetails werden geladen …

'; try { const result = await api('/files?'+new URLSearchParams({repo})); if (!root.isConnected || sequence !== detailSequence) return; detail = result; - el('hub-files').innerHTML = `MODELLDETAILS

${escape(repo)}

${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}${detail.files.length} unterstützte Dateien

Lizenz: ${escape(detail.license || 'Nicht angegeben')} · Modellkarte lesen ↗

Hardware-Eignung noch nicht ermittelt

Dateigröße ist kein VRAM-Bedarf. Kontext, Quantisierung und Laufzeit müssen gemeinsam geprüft werden.

${detail.gated ? '

Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.

' : ''}

Variante oder Datei auswählen

Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.

Feste Quellversion

${escape(detail.revision)}

`; + el('hub-files').innerHTML = `MODELLDETAILS

${escape(repo)}

${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}${detail.files.length} unterstützte Dateien

Lizenz: ${escape(detail.license || 'Nicht angegeben')} · Modellkarte lesen ↗

Wähle eine Datei für die Speicherprüfung.
${detail.gated ? '

Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.

' : ''}

Variante oder Datei auswählen

Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.

Feste Quellversion

${escape(detail.revision)}

`; 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; @@ -82,7 +90,7 @@ window.CatalogUI = (() => { } catch(error) { if (sequence === detailSequence) { el('hub-files').innerHTML = '

Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.

'; 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(''); } + if (!library && !downloads) { el('hub-sort').onchange = renderModels; el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); } refresh(); } return {html,bind}; diff --git a/catalog.py b/catalog.py index 0a01ea0..f8f2718 100644 --- a/catalog.py +++ b/catalog.py @@ -7,6 +7,7 @@ import re import shutil import threading import time +import uuid import urllib.parse import urllib.request @@ -38,12 +39,47 @@ def repo_id(value): class Catalog: def __init__(self,root): self.root=Path(root);self.lock=threading.RLock();self.job=None;self.cancel=threading.Event() + self.cache={};self.cache_lock=threading.Lock();self.history=[] + path=self.root/'downloads.json' + if path.exists(): + self.history=json.loads(path.read_text()) + for job in self.history: + if job['state']=='downloading':job.update(state='interrupted',error='Deck wurde neu gestartet. Datei erneut auswählen und herunterladen.') + else: + for entry in self.root.glob('*/entry.json'): + try: + x=json.loads(entry.read_text()) + self.history.append(dict(id='import-'+entry.parent.name,repo=x['repo'],file=x['file'],kind=x['kind'],state='complete',bytes=x['size'],total=x['size'],created_at=x.get('downloaded_at'),error=None)) + except (OSError,ValueError,KeyError):pass + if self.history:self._save_history() + def _save_history(self): + self.root.mkdir(parents=True,exist_ok=True,mode=0o700) + temp=self.root/'downloads.tmp';temp.write_text(json.dumps(self.history));temp.replace(self.root/'downloads.json') + def dismiss(self,job_id): + with self.lock: + job=next((x for x in self.history if x['id']==job_id),None) + if not job:raise ValueError('Download nicht gefunden.') + if job['state']=='downloading':raise ValueError('Laufenden Download zuerst abbrechen.') + job['dismissed']=True + if self.job and self.job.get('id')==job_id:self.job=None + self._save_history() + return {'dismissed':True,'model_deleted':False} + def entry(self,ident): + if not isinstance(ident,str) or not re.fullmatch('[a-f0-9]{64}',ident):raise ValueError('Ungültige Modelldatei-ID.') + entry=next((x for x in self.status()['entries'] if x['id']==ident),None) + if not entry:raise ValueError('Modelldatei nicht in der Bibliothek.') + 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): if kind not in KINDS or not isinstance(q,str) or len(q)>120:raise ValueError('Ungültige Suche.') rows=metadata('/api/models?'+urllib.parse.urlencode(dict(search=q,filter=KINDS[kind],limit=20,sort='downloads',direction=-1))) return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False)) for x in rows]} def files(self,repo): repo=repo_id(repo) + with self.cache_lock: + cached=self.cache.get(repo) + if cached and time.monotonic()-cached[0]<300:return cached[1] data=metadata('/api/models/'+repo+'?blobs=true') revision=data.get('sha','') if not re.fullmatch('[a-f0-9]{40}',revision):raise ValueError('Keine feste Repository-Version verfügbar.') @@ -54,20 +90,25 @@ class Catalog: size=f.get('size',f.get('lfs',{}).get('size')) if not isinstance(size,int) or size<1:continue files.append(dict(name=name,size=size,sha256=f.get('lfs',{}).get('sha256'))) - return dict(repo=repo,revision=revision,files=files,gated=data.get('gated',False),license=(data.get('cardData') or {}).get('license'),url='https://huggingface.co/'+repo) + result=dict(repo=repo,revision=revision,files=files,gated=data.get('gated',False),license=(data.get('cardData') or {}).get('license'),url='https://huggingface.co/'+repo) + with self.cache_lock: + if len(self.cache)>=64:self.cache.pop(next(iter(self.cache))) + self.cache[repo]=(time.monotonic(),result) + return result def status(self): with self.lock: entries=[] if self.root.exists(): for p in self.root.glob('*/entry.json'): try: - item=json.loads(p.read_text());entries.append(item) + item=json.loads(p.read_text());item['id']=p.parent.name;entries.append(item) except (OSError,ValueError):pass - return dict(entries=entries,job=dict(self.job) if self.job else None,free_bytes=shutil.disk_usage(self.root if self.root.exists() else self.root.parent).free) + return dict(entries=entries,downloads=[dict(x) for x in reversed(self.history) if not x.get("dismissed")],job=dict(self.job) if self.job else None,free_bytes=shutil.disk_usage(self.root if self.root.exists() else self.root.parent).free) def start(self,repo,filename,revision,kind): if kind not in KINDS:raise ValueError('Ungültiger Bereich.') with self.lock: if self.job and self.job['state']=='downloading':raise ValueError('Ein Download läuft bereits.') + with self.cache_lock:self.cache.pop(repo,None) data=self.files(repo) if data['gated']:raise ValueError('Zugangsbeschränkte Modelle werden noch nicht unterstützt.') if revision!=data['revision']:raise ValueError('Repository wurde geändert. Dateiliste neu laden.') @@ -80,7 +121,8 @@ class Catalog: if (target/'entry.json').exists():raise ValueError('Datei bereits in der Bibliothek.') target.mkdir(exist_ok=True,mode=0o700) self.cancel.clear() - self.job=dict(id=ident,state='downloading',repo=repo,file=filename,bytes=0,total=item['size'],error=None) + self.job=dict(id=uuid.uuid4().hex,entry_id=ident,state='downloading',repo=repo,file=filename,kind=kind,revision=revision,created_at=time.time(),bytes=0,total=item['size'],error=None) + self.history.append(self.job);self._save_history() threading.Thread(target=self._download,args=(data,item,target,kind),daemon=True).start() return dict(self.job) def stop(self): @@ -111,3 +153,5 @@ class Catalog: self.job['state']='cancelled' if isinstance(exc,InterruptedError) else 'failed' self.job['error']=str(exc) if isinstance(exc,ValueError) else ('Abgebrochen.' if isinstance(exc,InterruptedError) else 'Download fehlgeschlagen; Verbindung oder Anbieter prüfen.') partial.unlink(missing_ok=True) + finally: + with self.lock:self._save_history() diff --git a/deploy/Dockerfile b/deploy/Dockerfile index eca979e..9d72c8f 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,8 +1,8 @@ FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3 git cmake g++ make ca-certificates libssl-dev && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ -COPY index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ +COPY profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ +COPY profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ diff --git a/deploy/install.py b/deploy/install.py index 1bdf50f..0980717 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile'] +FILES = ['profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile'] def run(*args, check=True, interactive=False): diff --git a/index.html b/index.html index 4940520..b1fac5c 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.6 · Entwicklung
+Athena Deck
ATHENA CONTROL SURFACEv0.6 · Entwicklung
diff --git a/network/install_remote.py b/network/install_remote.py index 4f3d7bd..76f7875 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -12,7 +12,7 @@ import sys NAME = 'athena-deck-network' BASE = Path('/opt/athena-deck') -FILES = {'runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} +FILES = {'profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} def run(*args, **kwargs): return subprocess.run(args, capture_output=True, timeout=600, **kwargs) diff --git a/profiles-ui.js b/profiles-ui.js new file mode 100644 index 0000000..872653b --- /dev/null +++ b/profiles-ui.js @@ -0,0 +1,28 @@ +window.ProfilesUI=(()=>{ + const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const labels={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=()=>'

Deine Profile

Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.

'; + 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={}; + const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; + async function load(){try{const [p,c]=await Promise.all([api('profiles'),api('catalog')]);if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&/\.(gguf|safetensors)$/i.test(x.file));schema=p.schemas[kind];el('profile-new').disabled=!models.length; + el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k])}: ${e(v)}`).join(' · ')}

Was fehlt zur Ausführung?${p.blockers.map(t=>`

${e(t)}

`).join('')}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + 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);}} + function editor(p=null,id=null){ + 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('')}

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-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; + try{await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters:Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]))});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}; +})(); diff --git a/profiles.py b/profiles.py new file mode 100644 index 0000000..d268362 --- /dev/null +++ b/profiles.py @@ -0,0 +1,53 @@ +"""Server-persisted library-backed profiles with validated, typed parameters.""" +import json +import re +import threading +import time +import uuid +from pathlib import Path + +SCHEMAS={ + 'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128)}, + 'image':{'width':(256,2048,1024),'height':(256,2048,1024),'steps':(1,100,25),'seed':(-1,2147483647,-1),'guidance':(0,30,1)}, + 'audio':{'speed':(.25,4,1)}, + 'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)} +} +class Profiles: + def __init__(self,path,catalog): + self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() + self.rows=json.loads(self.path.read_text()) if self.path.exists() else [] + def status(self): + with self.lock: + rows=json.loads(json.dumps(self.rows)) + for p in rows: + try: + p['model']=self.catalog.entry(p['model_id']) + p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.'] + if p['kind']=='image' and 'qwen-image-2.1' in p['model']['repo'].lower(): + p['blockers']=['Qwen-Image-2.1 benötigt zusätzlich einen kompatiblen Textencoder und VAE.','Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.'] + except ValueError as exc:p['model']=None;p['blockers']=[str(exc)] + p['state']='configured';p['runnable']=False + return {'profiles':rows,'schemas':SCHEMAS} + def save(self,data): + if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.') + kind=data['kind'];name=data['name'];params=data['parameters'] + if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not re.fullmatch('[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}',name):raise ValueError('API-Name: 2–64 Buchstaben, Ziffern, Bindestrich oder Unterstrich.') + model=self.catalog.entry(data['model_id']) + if model['kind']!=kind or not model['file'].lower().endswith(('.gguf','.safetensors')):raise ValueError('Eine Gewichtsdatei dieses Bereichs auswählen, keine Konfiguration.') + if not isinstance(params,dict) or set(params)!=set(SCHEMAS[kind]):raise ValueError('Unvollständige oder unbekannte Profilparameter.') + for key,(lo,hi,_) in SCHEMAS[kind].items(): + value=params[key] + floating=key in ('guidance','speed') + if isinstance(value,bool) or not isinstance(value,(float,int) if floating else int) or not lo<=value<=hi:raise ValueError('Ungültiger Parameter: '+key) + if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Breite und Höhe müssen durch 64 teilbar sein.') + if kind=='chat' and params['ubatch']>params['batch']:raise ValueError('Microbatch darf nicht größer als Batch sein.') + with self.lock: + existing=next((p for p in self.rows if p['id']==data['id']),None) + if data['id'] is not None and not existing:raise ValueError('Profil nicht gefunden.') + if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.') + if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.') + row=dict(id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time()) + rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row] + self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700) + temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows + return row diff --git a/server.py b/server.py index c1dc630..fba5a5e 100644 --- a/server.py +++ b/server.py @@ -7,6 +7,8 @@ import secrets from http.cookies import SimpleCookie, CookieError from auth import CredentialStore, verify_password from catalog import Catalog +from profiles import Profiles +from capacity import assess, overview from runtime import Runtime from urllib.parse import urlsplit, parse_qs from network.client import NetworkClient @@ -99,6 +101,7 @@ class Server(ThreadingHTTPServer): super().__init__((os.environ.get('DECK_BIND_HOST','127.0.0.1'), port), Handler) self.catalog = Catalog(Path(state_dir or os.environ.get("DECK_STATE_DIR", ROOT/".state"))/"models") self.runtime = Runtime(Path(state_dir or os.environ.get("DECK_STATE_DIR", ROOT/".state"))/"runtime") + self.profiles = Profiles(self.catalog.root.parent/"profiles.json",self.catalog) self.demo = DemoService() self.hardware = HardwareProvider() self.started = time.time() @@ -262,12 +265,14 @@ class Handler(BaseHTTPRequestHandler): return self.respond({'error':'Anmeldung erforderlich.'},401) if not self.authenticated() and self.path == '/': return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') - routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript')} + routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript')} if self.path in routes: name, mime = routes[self.path] return self.respond((ROOT/name).read_bytes(), mime=mime) if self.path == '/api/v1/status': return self.respond(dict(name='Athena Deck', version='0.6.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), demo=self.server.demo.status())) + if self.path == '/api/v1/profiles': + return self.respond(self.server.profiles.status()) if self.path == '/api/v1/hardware': return self.respond(self.server.hardware.snapshot()) if self.path.startswith('/api/v1/runtime'): @@ -279,7 +284,13 @@ class Handler(BaseHTTPRequestHandler): try: path=urlsplit(self.path);q=parse_qs(path.query) if path.path=='/api/v1/catalog/search':return self.respond(self.server.catalog.search(q.get('q',[''])[0],q.get('kind',['chat'])[0])) - if path.path=='/api/v1/catalog/files':return self.respond(self.server.catalog.files(q.get('repo',[''])[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())) + if path.path=='/api/v1/catalog/files': + data=dict(self.server.catalog.files(q.get('repo',[''])[0]));hardware=self.server.hardware.snapshot() + data['files']=[dict(f,assessment=assess(f['size'],hardware,f['name'])) for f in data['files']] + return self.respond(data) if path.path=='/api/v1/catalog':return self.respond(self.server.catalog.status()) except Exception as exc: return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Katalog nicht erreichbar.'},400) @@ -329,10 +340,15 @@ class Handler(BaseHTTPRequestHandler): raise ValueError('Ungültige Laufzeitaktion.') except ValueError as exc:return self.respond({'error':str(exc)},400) except (OSError, subprocess.SubprocessError):return self.respond({'error':'Laufzeitaktion fehlgeschlagen; Speicher und Werkzeuge prüfen.'},503) - if self.path in ('/api/v1/catalog/download','/api/v1/catalog/cancel'): + if self.path == '/api/v1/profiles/save': + try:return self.respond(self.server.profiles.save(self.read_json())) + except ValueError as exc:return self.respond({'error':str(exc)},400) + except OSError:return self.respond({'error':'Profil konnte nicht gespeichert werden.'},503) + if self.path in ('/api/v1/catalog/download','/api/v1/catalog/cancel','/api/v1/catalog/dismiss'): try: data=self.read_json() - if self.path.endswith('/cancel'):return self.respond(self.server.catalog.stop()) + if self.path.endswith('/dismiss') and set(data)=={'id'}:return self.respond(self.server.catalog.dismiss(data['id'])) + if self.path.endswith('/cancel') and not data:return self.respond(self.server.catalog.stop()) if set(data)!={'repo','filename','revision','kind'}:raise ValueError('Ungültige Downloadparameter.') return self.respond(self.server.catalog.start(**data)) except Exception as exc: diff --git a/studio.js b/studio.js index aab148d..3d868ae 100644 --- a/studio.js +++ b/studio.js @@ -1,60 +1,18 @@ -/* Live catalogue/downloads; runtime and profile drafts remain previews. */ +/* Server-backed model management. Legacy browser drafts remain untouched. */ const Studio=(()=>{ - const key='athena-deck-studio-v1'; - const seed={profiles:[{id:'alltag',name:'qwen-alltag',model:'qwen-iq4',context:76800,slots:1,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'parallel',name:'qwen-parallel',model:'qwen-iq4',context:160000,slots:2,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'lang',name:'qwen-langkontext',model:'qwen-iq4',context:262144,slots:1,gpu:'both',split:'80,20',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false}],runtime:{backend:'CUDA',revision:'',architectures:'auto',custom:'',jobs:4,toolkit:'auto'}}; - let state=structuredClone(seed);try{const saved=JSON.parse(localStorage.getItem(key));if(saved?.profiles&&Array.isArray(saved.profiles)&&saved.runtime)state=saved;}catch{} - let section='discover',category='',editing=null; - const models=[{id:'qwen-iq4',kind:'chat',name:'Qwen 27B',variant:'IQ4_XS · GGUF',source:'Hugging Face',description:'Sprachmodell · Beispiel für mehrere Profile mit einer Modelldatei.'},{id:'qwen-q4',kind:'chat',name:'Qwen 27B',variant:'Q4_K_M · GGUF',source:'Hugging Face',description:'Alternative Quantisierung · eigener Bibliothekseintrag.'},{id:'image-example',kind:'image',name:'Bildmodell',variant:'Modellpaket · Beispiel',source:'Modellkatalog',description:'Textencoder, Diffusionsmodell und VAE als zusammengehöriges Paket.'},{id:'tts-example',kind:'audio',name:'Sprachausgabe',variant:'TTS · Beispiel',source:'Modellkatalog',description:'Sprachmodell und Stimmen mit passender Audio-Laufzeit.'},{id:'asr-example',kind:'audio',name:'Spracherkennung',variant:'ASR · Beispiel',source:'Modellkatalog',description:'Audio in Text umwandeln · CPU-/GPU-Eignung später prüfen.'},{id:'video-example',kind:'video',name:'Videomodell',variant:'Modellpaket · Beispiel',source:'Modellkatalog',description:'Auflösung, Bildanzahl und Zusatzkomponenten gemeinsam planen.'}]; + let section='discover',category=''; const labels={chat:'Sprachmodelle / Chat',image:'Bildgenerierung',audio:'Audio',video:'Video'}; - const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - const count=n=>Number(n).toLocaleString('de-DE'); - const selected=(a,b)=>a===b?'selected':''; - const check=v=>v?'checked':''; - function save(){try{localStorage.setItem(key,JSON.stringify(state));return true;}catch{notice('Speichern im Browser nicht möglich. Entwurf bleibt nur für diese Ansicht erhalten.');return false;}} - function notice(message){const node=document.querySelector('#studio-notice');if(node)node.textContent=message;} - const banner=()=>'
GUI-VORSCHAUKatalog und Datei-Downloads sind live. Profile bleiben Entwürfe; Builds sind unter Einstellungen aktiv. Keine Modellstarts.
'; - function header(title,description){return `
ATHENA / MODELLVERWALTUNG

${title}

${description}

${banner()}`;} - function render(page,force=false){ + function render(page,force=false,modelId=null){ if(!force&&document.querySelector('#studio')?.dataset.page===page)return; - if(category!==page){category=page;section='discover';editing=null;} - const view=document.querySelector('#view'); - view.innerHTML=`
${page==='runtime'?runtime():workspace(page)}

`; - bind(page); - } - function workspace(page){ - return header(labels[page], 'Modelle entdecken, als Bibliothek organisieren und über Profile bereitstellen.')+`
${[['discover','Entdecken'],['library','Bibliothek'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>``).join('')}
${section==='discover'?discover(page):section==='library'?library(page):section==='profiles'?profiles(page):running(page)}
`; - } - function modelCard(m,library=false){return `
${m.kind==='chat'?'Aa':m.kind==='image'?'▧':m.kind==='audio'?'≈':'▷'}${library?'Bibliotheksbeispiel':'Katalogbeispiel'}

${e(m.name)}

${e(m.variant)}

${e(m.description)}

Quelle
${e(m.source)} · nicht verbunden
Downloadgröße
Noch nicht ermittelt
Eignung für Athena
Noch nicht geprüft
${library&&m.kind==='chat'?``:''}
`;} - function discover(page){return CatalogUI.html(false);} - function library(page){return CatalogUI.html(true);} - function profiles(page){ - if(page!=='chat')return `
VORBEREITET

${page==='image'?'Auflösung und Generierung':page==='audio'?'Sprache, Stimmen und Verarbeitung':'Auflösung und Videolänge'}

${page==='image'?'':page==='audio'?'':''}

Eigene Worker-Adapter und Eignungsregeln folgen. LLM-Kontextparameter werden hier nicht übernommen.

`; - return `

Ein Modell. Mehrere Möglichkeiten.

API-Namen und Startparameter als lokale Profilentwürfe.

${state.profiles.map(p=>`
PROFILENTWURF

${e(p.name)}

${e(models.find(m=>m.id===p.model)?.variant||'Modell nicht zugeordnet')}

Kontextbudget${count(p.context)}
Slots${e(p.slots)}
ZustandNicht bereitgestellt
`).join('')||'
Noch keine Profilentwürfe. Lege dein erstes Profil an.
'}
GEPLANTE API · NICHT AKTIV

Ein Endpunkt für deine Profile

GET /v1/models
${e(JSON.stringify({data:state.profiles.map(p=>({id:p.name,object:'model'}))},null,2))}

Später wählt der Client den Profilnamen im Feld model. Deck verwaltet den passenden llama-server-Prozess.

`; - } - function running(page){return `
○

Keine von Deck verwaltete Modelllaufzeit

${page==='chat'?'llama.cpp ist in dieser Vorschau nicht angebunden. Gespeicherte Profile starten keinen Prozess.':'Für diesen Bereich ist noch kein Worker angebunden.'}

Laufzeit-Einstellungen ansehen →

So soll der Profilwechsel ablaufen

  1. Anfrage zuordnenProfil anhand des API-Namens auswählen.
  2. Arbeit abschließenLaufende Anfragen beenden lassen; neue einreihen.
  3. Prozess wechselnllama-server beenden und mit dem Zielprofil starten.
  4. Bereitschaft prüfenErst danach wartende Anfragen weiterleiten.

Mehrere angebotene Profile sind nicht automatisch mehrere geladene Modelle. Produktive Athena-Prozesse werden in dieser Vorschau nicht gesteuert.

`;} - function detail(id){const m=models.find(m=>m.id===id);document.querySelector('#model-detail').innerHTML=`

${e(m.name)} · Eignungsprüfung

NOCH NICHT BERECHNET
Unterstützt?

Format und Laufzeit-Kompatibilität noch nicht geprüft.

Passt grundsätzlich?

Gewichte, Zusatzkomponenten und Arbeitsspeicherbedarf noch nicht ermittelt.

Kann jetzt starten?

Keine Ressourcenreservierung oder Prozesssteuerung angebunden.

${m.kind==='chat'?'Kontextlänge, KV-Cache, Slots und GPU-Aufteilung beeinflussen den Speicherbedarf. Die beiden GPUs werden nicht pauschal als ein gemeinsamer VRAM-Pool gerechnet.':'Die spätere Einschätzung berücksichtigt die zur Aufgabe passenden Parameter und den jeweiligen Worker.'} Es gibt in dieser Vorschau keine erfundenen Speicherwerte.

`;document.querySelector('#model-detail').scrollIntoView({behavior:'smooth',block:'nearest'});} - function field(label,name,value,type='number',attrs=''){return ``;} - function editor(id,model){editing=id||null;const p=id?state.profiles.find(p=>p.id===id):{...seed.profiles[0],name:'qwen-neues-profil',model:model||'qwen-iq4'};if(!p)return; - document.querySelector('#profile-editor').innerHTML=`

${id?'Profil bearbeiten':'Neues Profil'}

NUR ENTWURF
${field('API-Profilname','name',p.name,'text','required pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" maxlength="64"')}${field('Gesamtes Kontextbudget (Token)','context',p.context,'number','required min="512" max="2097152" step="1"')}${field('Parallele Slots','slots',p.slots,'number','required min="1" max="16" step="1"')}

Das Kontextbudget gilt für das Profil, nicht automatisch je Slot. Die genaue Slot-Zuteilung und Unterstützung werden später mit der gewählten llama.cpp-Version geprüft.

Erweiterte Startoptionen
${field('Gewichtung 5080 : 3060','split',p.split,'text','required pattern="[0-9]+(\\.[0-9]+)?,[0-9]+(\\.[0-9]+)?"')}${field('Batch-Größe','batch',p.batch,'number','required min="1" max="8192"')}${field('Microbatch-Größe','ubatch',p.ubatch,'number','required min="1" max="8192"')}${field('CPU-Threads','threads',p.threads,'number','required min="1" max="256"')}
${id?'':''}
`; - document.querySelector('#profile-form').onsubmit=event=>{event.preventDefault();const data=new FormData(event.target),value=Object.fromEntries(data);for(const n of ['context','slots','batch','ubatch','threads'])value[n]=Number(value[n]);for(const n of ['unified','flash','vision'])value[n]=data.has(n);const out=document.querySelector('#profile-error');if(state.profiles.some(q=>q.id!==editing&&q.name===value.name)){out.textContent='Dieser API-Profilname ist bereits vergeben.';return;}if(value.ubatch>value.batch){out.textContent='Microbatch darf nicht größer als Batch sein.';return;}if(value.gpu==='both'&&!value.split.split(',').some(v=>Number(v)>0)){out.textContent='Mindestens eine GPU-Gewichtung muss größer als null sein.';return;}value.id=editing||crypto.randomUUID();const idx=state.profiles.findIndex(q=>q.id===editing);if(idx<0)state.profiles.push(value);else state.profiles[idx]=value;const stored=save();render(category,true);notice(stored?'Profilentwurf in diesem Browser gespeichert. Kein Modell gestartet.':'Entwurf nur im Speicher gehalten; Browser-Speicher nicht verfügbar.');}; - document.querySelector('#cancel-edit').onclick=()=>document.querySelector('#profile-editor').replaceChildren(); - if(id)document.querySelector('#delete-profile').onclick=()=>{state.profiles=state.profiles.filter(q=>q.id!==id);save();render(category,true);notice('Lokalen Profilentwurf entfernt. Modelldateien wurden nicht verändert.');}; - document.querySelector('#profile-editor').scrollIntoView({behavior:'smooth',block:'start'}); - } - function runtime(){return RuntimeUI.html();} - function bind(page){ + if(category!==page){category=page;section='discover';} + if(modelId)section='profiles'; + const body=page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.

${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>``).join('')}
${section==='profiles'?ProfilesUI.html():section==='running'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Bild-, Audio- und Video-Worker sind noch nicht eingerichtet. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; + document.querySelector('#view').innerHTML=`
${body}
`; if(page==='runtime'){RuntimeUI.bind();return;} - if(section==='discover'||section==='library')CatalogUI.bind(page,section==='library'); + if(section==='profiles')ProfilesUI.bind(page,modelId); + else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads'); document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);}); - document.querySelectorAll('[data-detail]').forEach(b=>b.onclick=()=>detail(b.dataset.detail)); - document.querySelectorAll('[data-create]').forEach(b=>b.onclick=()=>{const model=b.dataset.create;section='profiles';render(page,true);editor(null,model);}); - document.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(b.dataset.edit)); - document.querySelectorAll('[data-duplicate]').forEach(b=>b.onclick=()=>{const old=state.profiles.find(p=>p.id===b.dataset.duplicate);let n=2,name=old.name.slice(0,55)+'-kopie';while(state.profiles.some(p=>p.name===name))name=old.name.slice(0,50)+'-kopie-'+n++;state.profiles.push({...old,id:crypto.randomUUID(),name});save();render(page,true);notice('Profil lokal dupliziert. Kein zusätzlicher Prozess gestartet.');}); - document.querySelector('#new-profile')?.addEventListener('click',()=>editor()); - const filter=()=>{const q=document.querySelector('#model-search').value.toLowerCase(),source=document.querySelector('#source-filter').value;const filtered=models.filter(m=>m.kind===page&&(source==='all'||source===m.source)&&(m.name+' '+m.variant).toLowerCase().includes(q));document.querySelector('#model-results').innerHTML=filtered.map(m=>modelCard(m)).join('');document.querySelector('#no-models').hidden=!!filtered.length;document.querySelectorAll('[data-detail]').forEach(b=>b.onclick=()=>detail(b.dataset.detail));}; - document.querySelector('#model-search')?.addEventListener('input',filter);document.querySelector('#source-filter')?.addEventListener('change',filter); - document.querySelector('#release-preview')?.addEventListener('click',()=>{const p=document.querySelector('#release-panel');p.hidden=!p.hidden;}); - document.querySelector('#build-form')?.addEventListener('submit',event=>{event.preventDefault();const value=Object.fromEntries(new FormData(event.target));value.jobs=Number(value.jobs);value.reserve=Number(value.reserve);if(value.architectures==='custom'&&!value.custom.trim()){notice('Bitte explizite Architekturziele eintragen oder automatische Erkennung wählen.');return;}state.runtime=value;notice(save()?'Build-Entwurf im Browser gespeichert. Kein Download oder Build ausgeführt.':'Entwurf nur für diese Sitzung gespeichert.');}); } - return {render}; + function openProfile(page,modelId){render(page,true,modelId);} + return {render,openProfile}; })(); diff --git a/test_model_management.py b/test_model_management.py new file mode 100644 index 0000000..406adfb --- /dev/null +++ b/test_model_management.py @@ -0,0 +1,54 @@ +import hashlib +import json +import tempfile +import unittest +from pathlib import Path +from capacity import assess,overview,GIB +from catalog import Catalog +from profiles import Profiles + +class ManagementTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name);self.model_id='a'*64 + target=self.root/'models'/self.model_id;target.mkdir(parents=True);(target/'model.gguf').write_bytes(b'GGUFtest') + self.entry=dict(repo='test/Qwen-Image-2.1',file='test.gguf',kind='image',size=8,revision='b'*40,sha256=hashlib.sha256(b'GGUFtest').hexdigest(),downloaded_at=1) + (target/'entry.json').write_text(json.dumps(self.entry));self.catalog=Catalog(self.root/'models');self.profiles=Profiles(self.root/'profiles.json',self.catalog) + def tearDown(self):self.tmp.cleanup() + def request(self):return dict(id=None,revision=0,name='image-test',kind='image',model_id=self.model_id,parameters=dict(width=1024,height=1024,steps=25,seed=-1,guidance=1)) + def test_profiles_persist_and_update_conflicts(self): + saved=self.profiles.save(self.request());other=Profiles(self.root/'profiles.json',self.catalog) + row=other.status()['profiles'][0];self.assertEqual(row['parameters']['width'],1024);self.assertFalse(row['runnable']);self.assertIn('Textencoder',row['blockers'][0]) + change=self.request();change.update(id=saved['id'],revision=saved['revision']);change['parameters']['width']=512;other.save(change) + with self.assertRaises(ValueError):other.save(change) + self.assertEqual(other.status()['profiles'][0]['parameters']['width'],512) + def test_profile_rejects_invalid_model_kind_parameters(self): + for change in [dict(model_id='../secrets'),dict(kind='chat'),dict(name='bad name')]: + with self.subTest(change=change),self.assertRaises(ValueError):self.profiles.save(dict(self.request(),**change)) + for key,value in [('width',1000),('steps',True),('guidance',float('nan')),('seed',-2)]: + req=self.request();req['parameters'][key]=value + with self.subTest(key=key),self.assertRaises(ValueError):self.profiles.save(req) + (self.root/'models'/self.model_id/'model.gguf').unlink() + with self.assertRaises(ValueError):self.profiles.save(self.request()) + def test_clear_history_keeps_models_and_survives_restart(self): + old=self.catalog.status()['downloads'][0];self.catalog.dismiss(old['id']) + restart=Catalog(self.root/'models');self.assertEqual(restart.status()['downloads'],[]);self.assertEqual(len(restart.status()['entries']),1) + self.assertEqual(restart.entry(self.model_id)['size'],8) + def test_running_download_cannot_be_dismissed_and_recovers(self): + job=dict(id='active',state='downloading',kind='image',repo='test/model',file='a.gguf',bytes=3,total=8) + self.catalog.history.append(job);self.catalog._save_history() + with self.assertRaises(ValueError):self.catalog.dismiss('active') + restarted=Catalog(self.root/'models');self.assertEqual(restarted.status()['downloads'][0]['state'],'interrupted') + def test_capacity_does_not_sum_gpu_vram_or_claim_runtime_fit(self): + hw=dict(gpus=[dict(name='A',total_mib=8192,used_mib=4096),dict(name='B',total_mib=8192,used_mib=0)],ram={}) + result=assess(10*GIB,hw,'model.gguf');self.assertFalse(any(x['weights_fit_total'] for x in result['gpus']));self.assertEqual(result['scope'],'weights_only') + result=assess(6*GIB,hw,'model.gguf');self.assertTrue(result['weights_fit_now']);self.assertFalse(result['gpus'][0]['weights_fit_now']) + self.assertIn('Gesamtbedarf offen',result['label']);self.assertEqual(assess(1,hw,'config.json')['scope'],'configuration') + self.assertIsNone(assess(6*GIB,{'gpus':[]})['weights_fit_now']) + def test_size_range_uses_weight_files_and_labels_scope(self): + result=overview([dict(name='config.json',size=1),dict(name='a.gguf',size=4*GIB),dict(name='b.gguf',size=8*GIB)],{'gpus':[]}) + self.assertEqual(result['min_bytes'],4*GIB);self.assertEqual(result['max_bytes'],8*GIB);self.assertIn('nicht Gesamtpaket',result['size_note']) + + def test_variant_sizes_exclude_companions_and_sum_shards(self): + files=[dict(name='vae/vae.safetensors',size=1),dict(name='model-lora.safetensors',size=2),dict(name='model-00001-of-00002.gguf',size=3*GIB),dict(name='model-00002-of-00002.gguf',size=2*GIB)] + result=overview(files,{'gpus':[]});self.assertEqual(result['min_bytes'],5*GIB) + self.assertEqual(overview(files[:-1],{'gpus':[]})['scope'],'unknown') diff --git a/test_server.py b/test_server.py index c1b5152..e53b9c7 100644 --- a/test_server.py +++ b/test_server.py @@ -39,6 +39,19 @@ class Tests(unittest.TestCase): self.assertEqual(self.request('demo/stop','POST',{'X-Athena-Deck':'1'})['state'],'stopped') self.assertIsNotNone(child.poll()) self.assertFalse(self.request('demo/stop','POST',{'X-Athena-Deck':'1'})['reachable']) + def test_profile_and_download_routes(self): + from pathlib import Path + target=Path(self.state.name)/'models'/('a'*64);target.mkdir(parents=True) + (target/'model.gguf').write_bytes(b'GGUFtest') + (target/'entry.json').write_text(json.dumps(dict(repo='test/image',file='model.gguf',size=8,kind='image',revision='b'*40))) + payload=dict(id=None,revision=0,name='image-test',kind='image',model_id='a'*64,parameters=dict(width=512,height=512,steps=10,seed=-1,guidance=1)) + req=urllib.request.Request(self.url+'/api/v1/profiles/save',data=json.dumps(payload).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'}) + with urllib.request.urlopen(req) as r:self.assertEqual(r.status,200) + self.assertEqual(self.request('profiles')['profiles'][0]['name'],'image-test') + with self.assertRaises(urllib.error.HTTPError) as error: + urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/profiles',headers={'Authorization':'Bearer '+self.api_token})) + self.assertEqual(error.exception.code,401);error.exception.close() + def test_control_guard(self): for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}): with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)