Persist library profiles and download history with weight capacity checks

This commit is contained in:
Mikei386
2026-09-28 17:26:21 +02:00
parent f326de18d1
commit 458047a1d3
15 changed files with 360 additions and 180 deletions
+6 -4
View File
@@ -13,9 +13,11 @@ Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht veränder
## Neu: Modellverwaltung und llama.cpp-Einstellungen ## Neu: Modellverwaltung und llama.cpp-Einstellungen
Katalog, Bibliothek, Profilentwürfe und eine Laufzeit-/Update-Ansicht sind als Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und
bedienbarer GUI-Prototyp vorhanden. Entwürfe bleiben im Browser; Modellstarts, llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
Katalog, Datei-Downloads und llama.cpp-Buildverwaltung sind live. Modellstarts sind noch nicht angebunden; siehe DEVELOPMENT.md. Details: [GUI-Prototyp](STUDIO.md). 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 ## 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. - `test_server.py`: Prozess-Lebenszyklus, Kontrollgrenzen, Hardware-Ausfall.
Sprachmodelle/Chat, Bildgenerierung, Audio und Video besitzen jetzt eine 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 Spätere Modellprofile und native llama.cpp-Worker sollten eigene Service-Adapter
mit derselben Status-/Start-/Stopp-Trennung erhalten. Noch kein Worker-Registry, mit derselben Status-/Start-/Stopp-Trennung erhalten. Noch kein Worker-Registry,
Scheduler oder produktionsreifer Worker-Supervisor. Die optionale Server-Instanz Scheduler oder produktionsreifer Worker-Supervisor. Die optionale Server-Instanz
+43 -94
View File
@@ -1,103 +1,52 @@
# Stand Laufzeitverwaltung # Modellverwaltung in Athena Deck
llama.cpp-Prüfung, Releases, Builds, Abbruch und Standard-/Rückfallauswahl sind ## Serverprofile, Downloads und Größen (2026-09-28)
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.
# 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 Bildprofile speichern Auflösung, Schritte, Seed und Guidance. Chatprofile
Katalogbeispiele ist historisch (0.4). Aktuelle API und Grenzen: DEVELOPMENT.md. speichern Kontextbudget, Slots, Threads, Batch und Microbatch; Audio derzeit
Profile und Laufzeitsteuerung bleiben Entwürfe. 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 Entdecken lädt Dateigrößen mit zwei parallelen Metadatenanfragen nach. Angaben
Modelldateien, Laufzeiten, Downloads, Builds oder produktiven Dienste werden verändert. 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. API (Administrator-Sitzung erforderlich, bestehender CSRF-Schutz):
- Katalog: Suche/Quellenfilter über gekennzeichnete Beispiele, Detailansicht mit - `GET /api/v1/catalog/assessment?repo=owner/name`: Größenbereich und Gewichtsprüfung.
getrennter Prüfung von Unterstützung, grundsätzlicher Eignung und aktueller - `GET /api/v1/catalog/files?repo=owner/name`: zusätzlich Prüfung pro Datei.
Startmöglichkeit. Keine Online-Suche und keine berechneten Speicherwerte. - `GET /api/v1/catalog`: Bibliotheks-IDs und `downloads` neben aktivem `job`.
- Bibliothek: Beispiel einer einmaligen Modelldatei mit mehreren Profilen; - `POST /api/v1/catalog/dismiss` mit `{ "id": "download-id" }`: beendeten Eintrag ausblenden.
ausdrücklich keine bestätigte Installation. - `GET /api/v1/profiles`: Profile, Blocker und Parameterschemata.
- LLM-Profile: drei vorbelegte Beispiele, Anlegen, Bearbeiten, Duplizieren und - `POST /api/v1/profiles/save`: `id` (bei Neuanlage null), `revision` (anfangs 0),
Entfernen lokaler Entwürfe. Name, Modellvariante, Kontextbudget, Slots, `name`, `kind`, `model_id` und `parameters`. Die Modelldatei muss tatsächlich
GPU-Zuordnung, KV-Cache, gemeinsamer Pool, GPU-Gewichtung, Batch/Microbatch, vollständig in der Bibliothek liegen. Keine beliebigen Pfade/Startbefehle.
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.
## 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, 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.
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.
+55
View File
@@ -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.')
+24 -16
View File
@@ -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 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; const result = await response.json(); if (!response.ok) throw Error(result.error || 'Anfrage fehlgeschlagen'); return result;
} }
function html(library) { function html(library, downloads=false) {
return `<section id="live-catalog"><div class="section-heading"><div><span class="kicker">HUGGING FACE · LIVE-KATALOG</span><h2>${library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}</h2><p>${library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}</p></div><button class="secondary" id="hub-refresh">Aktualisieren</button></div> return `<section id="live-catalog"><div class="section-heading"><div><span class="kicker">HUGGING FACE · LIVE-KATALOG</span><h2>${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}</h2><p>${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.'}</p></div><button class="secondary" id="hub-refresh">Aktualisieren</button></div>
<div id="hub-capacity" class="small muted"></div><div id="hub-job"></div><p id="hub-message" role="status" aria-live="polite"></p> <div id="hub-capacity" class="small muted"></div><div id="hub-job"></div><p id="hub-message" role="status" aria-live="polite"></p>
${library ? '<div id="hub-results" class="model-grid"></div>' : `<form id="hub-search" class="hub-search"><label>Modell oder Anbieter<input name="q" maxlength="120" placeholder="z. B. Qwen oder FLUX" autocomplete="off"></label><button>Suchen</button></form><div class="hub-layout"><div><div class="hub-list-heading"><span id="hub-count" class="small muted">Beliebte Modelle</span><label class="sr-only" for="hub-sort">Ergebnisse sortieren</label><select id="hub-sort"><option value="downloads">Meiste Downloads</option><option value="name">Name A–Z</option></select></div><div id="hub-results" class="hub-results"></div></div><section id="hub-files" class="card hub-detail" aria-label="Modelldetails"><span class="kicker">01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN</span><h2>Welches Modell interessiert dich?</h2><p>Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.</p><p class="note">Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.</p></section></div>`}</section>`; ${downloads ? '' : library ? '<div id="hub-results" class="model-grid"></div>' : `<form id="hub-search" class="hub-search"><label>Modell oder Anbieter<input name="q" maxlength="120" placeholder="z. B. Qwen oder FLUX" autocomplete="off"></label><button>Suchen</button></form><div class="hub-layout"><div><div class="hub-list-heading"><span id="hub-count" class="small muted">Beliebte Modelle</span><label class="sr-only" for="hub-sort">Ergebnisse sortieren</label><select id="hub-sort"><option value="downloads">Meiste Downloads</option><option value="name">Name A–Z</option></select></div><div id="hub-results" class="hub-results"></div></div><section id="hub-files" class="card hub-detail" aria-label="Modelldetails"><span class="kicker">01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN</span><h2>Welches Modell interessiert dich?</h2><p>Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.</p><p class="note">Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.</p></section></div>`}</section>`;
} }
function bind(kind, library) { function bind(kind, library, downloads=false) {
const root = document.querySelector('#live-catalog'); if (!root) return; const root = document.querySelector('#live-catalog'); if (!root) return;
const el = id => root.querySelector('#'+id); 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 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); const installed = file => detail && state.entries.some(x => x.repo === detail.repo && x.revision === detail.revision && x.file === file.name);
function renderModels() { 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)); 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-count').textContent = `${models.length} Ergebnisse · höchstens 20 pro Suche`;
el('hub-results').innerHTML = sorted.map(m => `<button type="button" class="hub-model ${selected === m.repo ? 'selected' : ''}" data-repo="${escape(m.repo)}" aria-pressed="${selected === m.repo}"><span class="hub-author">${escape(m.repo.split('/')[0])}</span><strong>${escape(m.repo.split('/').slice(1).join('/'))}</strong><span class="hub-model-meta">↓ ${number(m.downloads)} Downloads · ${m.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span>${state.entries.some(x => x.repo === m.repo) ? '<span class="tag">Dateien in Bibliothek</span>' : ''}</button>`).join('') || '<div class="empty"><h3>Keine Treffer</h3><p>Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.</p></div>'; el('hub-results').innerHTML = sorted.map(m => `<button type="button" class="hub-model ${selected === m.repo ? 'selected' : ''}" data-repo="${escape(m.repo)}" aria-pressed="${selected === m.repo}"><span class="hub-author">${escape(m.repo.split('/')[0])}</span><strong>${escape(m.repo.split('/').slice(1).join('/'))}</strong><span class="hub-model-meta">↓ ${number(m.downloads)} Downloads · ${m.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="hub-model-meta" data-capacity="${escape(m.repo)}">${escape(m.capacity ? `${size(m.capacity.min_bytes)} – ${size(m.capacity.max_bytes)} Hauptgewichte · kleinste Variante: ${fitText(m.capacity)}` : m.capacityError || 'Größe und Speicher werden ermittelt …')}</span>${state.entries.some(x => x.repo === m.repo) ? '<span class="tag">Dateien in Bibliothek</span>' : ''}</button>`).join('') || '<div class="empty"><h3>Keine Treffer</h3><p>Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.</p></div>';
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
} }
function renderLibrary() { function renderLibrary() {
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">DATEI HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>Laufzeit noch nicht angebunden</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details></article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>'; el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">DATEI HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>Laufzeit noch nicht angebunden</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${/\.(gguf|safetensors)$/i.test(x.file)?`<div class="card-actions"><button data-profile-model="${x.id}">Profil anlegen</button></div>`:""}</article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
} }
function renderJob() { function renderJob() {
el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`; 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 jobs=downloads ? (state.downloads||[]).filter(j=>j.kind===kind) : state.job?.state==='downloading' ? [state.job] : [];
const labels = {downloading:'Wird heruntergeladen',complete:'Download abgeschlossen',cancelled:'Download abgebrochen',failed:'Download fehlgeschlagen'}; const signature=JSON.stringify(jobs); if(signature===lastJobs)return; lastJobs=signature;
const percent = j.total > 0 ? Math.min(100,Math.max(0,j.bytes/j.total*100)) : 0; const labels={downloading:'Wird heruntergeladen',complete:'Download abgeschlossen',cancelled:'Download abgebrochen',failed:'Download fehlgeschlagen',interrupted:'Download unterbrochen'};
el('hub-job').innerHTML = `<section class="card hub-operation"><div class="card-top"><span class="kicker">${escape(labels[j.state] || j.state)}</span><strong>${percent.toFixed(1)} %</strong></div><h3>${escape(j.repo)}</h3><p class="hub-filename">${escape(j.file)}</p><progress max="100" value="${percent}" aria-label="Download-Fortschritt"></progress><p>${size(j.bytes)} von ${size(j.total)}${j.state === 'complete' ? ' · geprüft und in der Bibliothek gespeichert' : ''}</p>${j.error ? `<p class="warning">${escape(j.error)}</p>` : ''}${j.state === 'downloading' ? '<button class="secondary" id="hub-cancel">Download abbrechen</button>' : ''}</section>`; 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 `<section class="card hub-operation"><div class="card-top"><span class="kicker">${escape(labels[j.state]||j.state)}</span><strong>${percent.toFixed(1)} %</strong></div><h3>${escape(j.repo)}</h3><p class="hub-filename">${escape(j.file)}</p><progress max="100" value="${percent}" aria-label="Download-Fortschritt"></progress><p>${size(j.bytes)} von ${size(j.total)}</p>${j.error?`<p class="warning">${escape(j.error)}</p>`:''}${j.state==='downloading'?'<button class="secondary" data-cancel>Download abbrechen</button>':`<button class="secondary" data-dismiss="${escape(j.id)}">Aus Liste entfernen</button><span class="small muted">Datei bleibt erhalten</span>`}</section>`;}).join('')||(downloads?'<div class="empty"><h3>Keine Downloads in dieser Liste</h3><p>Heruntergeladene Modelldateien findest du weiterhin in der Bibliothek.</p></div>':'');
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; } }); 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() { async function refresh() {
if (refreshing || !root.isConnected) return; refreshing = true; clearTimeout(timer); 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); } catch(error) { message(error.message); }
finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); } finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); }
} }
async function search(q) { async function search(q) {
const sequence = ++searchSequence; message('Modelle werden gesucht …'); 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); } 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() { function updateSelection() {
if (!detail || !el('hub-download')) return; if (!detail || !el('hub-download')) return;
const chosen = el('hub-variants').querySelector('input:checked'); 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').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-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.'; 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?`<strong>${escape(fitText(file.assessment))}</strong><p>${escape(file.assessment.explanation||'')}</p>${(file.assessment.gpus||[]).map(g=>`<p>${escape(g.name)}: ${size(g.capacity_bytes)} nutzbar insgesamt · ${size(g.free_bytes)} aktuell frei (nach Reserve)</p>`).join('')}${file.assessment.sampled_at?`<p>Messung: ${escape(new Date(file.assessment.sampled_at*1000).toLocaleTimeString('de-DE'))}</p>`:''}${file.assessment.deck_ram_limit_bytes?`<p>Deck-RAM-Limit: ${size(file.assessment.deck_ram_limit_bytes)}</p>`:''}`:'Wähle eine Datei für die Speicherprüfung. Gesamtbedarf der Laufzeit noch nicht berechnet.';
} }
function renderFiles() { function renderFiles() {
const query = el('hub-file-filter').value.toLowerCase(), format = el('hub-format').value; const query = el('hub-file-filter').value.toLowerCase(), format = el('hub-format').value;
@@ -69,7 +77,7 @@ window.CatalogUI = (() => {
el('hub-files').innerHTML = '<p role="status">Modelldetails werden geladen …</p>'; el('hub-files').innerHTML = '<p role="status">Modelldetails werden geladen …</p>';
try { try {
const result = await api('/files?'+new URLSearchParams({repo})); if (!root.isConnected || sequence !== detailSequence) return; detail = result; const result = await api('/files?'+new URLSearchParams({repo})); if (!root.isConnected || sequence !== detailSequence) return; detail = result;
el('hub-files').innerHTML = `<span class="kicker">MODELLDETAILS</span><h2 class="hub-filename">${escape(repo)}</h2><div class="hub-badges"><span class="pill">${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="pill">${detail.files.length} unterstützte Dateien</span></div><p>Lizenz: ${escape(detail.license || 'Nicht angegeben')} · <a class="link" href="${escape(detail.url)}" target="_blank" rel="noopener noreferrer">Modellkarte lesen ↗</a></p><div class="hub-fit"><strong>Hardware-Eignung noch nicht ermittelt</strong><p>Dateigröße ist kein VRAM-Bedarf. Kontext, Quantisierung und Laufzeit müssen gemeinsam geprüft werden.</p></div>${detail.gated ? '<p class="warning">Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.</p>' : ''}<h3>Variante oder Datei auswählen</h3><div class="hub-file-controls"><label>Dateiname filtern<input id="hub-file-filter" placeholder="z. B. Q4_K_M"></label><label>Format<select id="hub-format"><option value="">Alle Formate</option><option value=".gguf">GGUF</option><option value=".safetensors">Safetensors</option><option value=".json">JSON / Konfiguration</option></select></label></div><div id="hub-variants" class="hub-variants"></div><div class="hub-review"><p id="hub-selection" class="hub-filename"></p><button id="hub-download" disabled>Ausgewählte Datei herunterladen</button><p class="small">Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.</p><details><summary>Feste Quellversion</summary><p class="hub-filename">${escape(detail.revision)}</p></details></div>`; el('hub-files').innerHTML = `<span class="kicker">MODELLDETAILS</span><h2 class="hub-filename">${escape(repo)}</h2><div class="hub-badges"><span class="pill">${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="pill">${detail.files.length} unterstützte Dateien</span></div><p>Lizenz: ${escape(detail.license || 'Nicht angegeben')} · <a class="link" href="${escape(detail.url)}" target="_blank" rel="noopener noreferrer">Modellkarte lesen ↗</a></p><div class="hub-fit" id="hub-fit-result">Wähle eine Datei für die Speicherprüfung.</div>${detail.gated ? '<p class="warning">Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.</p>' : ''}<h3>Variante oder Datei auswählen</h3><div class="hub-file-controls"><label>Dateiname filtern<input id="hub-file-filter" placeholder="z. B. Q4_K_M"></label><label>Format<select id="hub-format"><option value="">Alle Formate</option><option value=".gguf">GGUF</option><option value=".safetensors">Safetensors</option><option value=".json">JSON / Konfiguration</option></select></label></div><div id="hub-variants" class="hub-variants"></div><div class="hub-review"><p id="hub-selection" class="hub-filename"></p><button id="hub-download" disabled>Ausgewählte Datei herunterladen</button><p class="small">Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.</p><details><summary>Feste Quellversion</summary><p class="hub-filename">${escape(detail.revision)}</p></details></div>`;
el('hub-file-filter').oninput = renderFiles; el('hub-format').onchange = renderFiles; renderFiles(); el('hub-file-filter').oninput = renderFiles; el('hub-format').onchange = renderFiles; renderFiles();
el('hub-download').onclick = async () => { el('hub-download').onclick = async () => {
const chosen = el('hub-variants').querySelector('input:checked'); if (!chosen || starting) return; 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 = '<p>Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.</p>'; message(error.message); } } } catch(error) { if (sequence === detailSequence) { el('hub-files').innerHTML = '<p>Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.</p>'; message(error.message); } }
} }
el('hub-refresh').onclick = refresh; 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(); refresh();
} }
return {html,bind}; return {html,bind};
+48 -4
View File
@@ -7,6 +7,7 @@ import re
import shutil import shutil
import threading import threading
import time import time
import uuid
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -38,12 +39,47 @@ def repo_id(value):
class Catalog: class Catalog:
def __init__(self,root): def __init__(self,root):
self.root=Path(root);self.lock=threading.RLock();self.job=None;self.cancel=threading.Event() 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): def search(self,q,kind):
if kind not in KINDS or not isinstance(q,str) or len(q)>120:raise ValueError('Ungültige Suche.') 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))) 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]} return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False)) for x in rows]}
def files(self,repo): def files(self,repo):
repo=repo_id(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') data=metadata('/api/models/'+repo+'?blobs=true')
revision=data.get('sha','') revision=data.get('sha','')
if not re.fullmatch('[a-f0-9]{40}',revision):raise ValueError('Keine feste Repository-Version verfügbar.') 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')) size=f.get('size',f.get('lfs',{}).get('size'))
if not isinstance(size,int) or size<1:continue if not isinstance(size,int) or size<1:continue
files.append(dict(name=name,size=size,sha256=f.get('lfs',{}).get('sha256'))) 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): def status(self):
with self.lock: with self.lock:
entries=[] entries=[]
if self.root.exists(): if self.root.exists():
for p in self.root.glob('*/entry.json'): for p in self.root.glob('*/entry.json'):
try: 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 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): def start(self,repo,filename,revision,kind):
if kind not in KINDS:raise ValueError('Ungültiger Bereich.') if kind not in KINDS:raise ValueError('Ungültiger Bereich.')
with self.lock: with self.lock:
if self.job and self.job['state']=='downloading':raise ValueError('Ein Download läuft bereits.') 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) data=self.files(repo)
if data['gated']:raise ValueError('Zugangsbeschränkte Modelle werden noch nicht unterstützt.') 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.') 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.') if (target/'entry.json').exists():raise ValueError('Datei bereits in der Bibliothek.')
target.mkdir(exist_ok=True,mode=0o700) target.mkdir(exist_ok=True,mode=0o700)
self.cancel.clear() 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() threading.Thread(target=self._download,args=(data,item,target,kind),daemon=True).start()
return dict(self.job) return dict(self.job)
def stop(self): def stop(self):
@@ -111,3 +153,5 @@ class Catalog:
self.job['state']='cancelled' if isinstance(exc,InterruptedError) else 'failed' 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.') 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) partial.unlink(missing_ok=True)
finally:
with self.lock:self._save_history()
+2 -2
View File
@@ -1,8 +1,8 @@
FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 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/* 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 WORKDIR /app
COPY server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/ COPY profiles.py capacity.py 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-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/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
+1 -1
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' 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): def run(*args, check=True, interactive=False):
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/studio.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtime">llama.cpp &amp; Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.6 · Entwicklung</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html> <!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/studio.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtime">llama.cpp &amp; Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.6 · Entwicklung</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
+1 -1
View File
@@ -12,7 +12,7 @@ import sys
NAME = 'athena-deck-network' NAME = 'athena-deck-network'
BASE = Path('/opt/athena-deck') 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): def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, timeout=600, **kwargs) return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
+28
View File
@@ -0,0 +1,28 @@
window.ProfilesUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=()=>'<section id="live-profiles"><div class="section-heading"><div><h2>Deine Profile</h2><p>Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.</p></div><button id="profile-new">Neues Profil</button></div><p id="profile-message" role="status"></p><div id="profile-list" class="profile-list"></div><div id="profile-editor"></div></section>';
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
function bind(kind,modelId){
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={};
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=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k])}: ${e(v)}`).join(' · ')}</p><details><summary>Was fehlt zur Ausführung?</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button></div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
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=`<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'].includes(k)?'.05':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div><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-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};
})();
+53
View File
@@ -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
+20 -4
View File
@@ -7,6 +7,8 @@ import secrets
from http.cookies import SimpleCookie, CookieError from http.cookies import SimpleCookie, CookieError
from auth import CredentialStore, verify_password from auth import CredentialStore, verify_password
from catalog import Catalog from catalog import Catalog
from profiles import Profiles
from capacity import assess, overview
from runtime import Runtime from runtime import Runtime
from urllib.parse import urlsplit, parse_qs from urllib.parse import urlsplit, parse_qs
from network.client import NetworkClient 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) 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.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.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.demo = DemoService()
self.hardware = HardwareProvider() self.hardware = HardwareProvider()
self.started = time.time() self.started = time.time()
@@ -262,12 +265,14 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401) return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/': if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') 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: if self.path in routes:
name, mime = routes[self.path] name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime) return self.respond((ROOT/name).read_bytes(), mime=mime)
if self.path == '/api/v1/status': 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())) 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': if self.path == '/api/v1/hardware':
return self.respond(self.server.hardware.snapshot()) return self.respond(self.server.hardware.snapshot())
if self.path.startswith('/api/v1/runtime'): if self.path.startswith('/api/v1/runtime'):
@@ -279,7 +284,13 @@ class Handler(BaseHTTPRequestHandler):
try: try:
path=urlsplit(self.path);q=parse_qs(path.query) 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/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()) if path.path=='/api/v1/catalog':return self.respond(self.server.catalog.status())
except Exception as exc: except Exception as exc:
return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Katalog nicht erreichbar.'},400) 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.') raise ValueError('Ungültige Laufzeitaktion.')
except ValueError as exc:return self.respond({'error':str(exc)},400) 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) 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: try:
data=self.read_json() 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.') if set(data)!={'repo','filename','revision','kind'}:raise ValueError('Ungültige Downloadparameter.')
return self.respond(self.server.catalog.start(**data)) return self.respond(self.server.catalog.start(**data))
except Exception as exc: except Exception as exc:
+11 -53
View File
@@ -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 Studio=(()=>{
const key='athena-deck-studio-v1'; let section='discover',category='';
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.'}];
const labels={chat:'Sprachmodelle / Chat',image:'Bildgenerierung',audio:'Audio',video:'Video'}; const labels={chat:'Sprachmodelle / Chat',image:'Bildgenerierung',audio:'Audio',video:'Video'};
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); function render(page,force=false,modelId=null){
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=()=>'<div class="prototype-banner"><span class="pill">GUI-VORSCHAU</span><span>Katalog und Datei-Downloads sind live. Profile bleiben Entwürfe; Builds sind unter Einstellungen aktiv. Keine Modellstarts.</span></div>';
function header(title,description){return `<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${title}</h1><p>${description}</p>${banner()}`;}
function render(page,force=false){
if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';editing=null;} if(category!==page){category=page;section='discover';}
const view=document.querySelector('#view'); if(modelId)section='profiles';
view.innerHTML=`<div id="studio" data-page="${e(page)}">${page==='runtime'?runtime():workspace(page)}<p id="studio-notice" role="status" aria-live="polite"></p></div>`; const body=page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.</p><div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='profiles'?ProfilesUI.html():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Bild-, Audio- und Video-Worker sind noch nicht eingerichtet. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
bind(page); document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
}
function workspace(page){
return header(labels[page], 'Modelle entdecken, als Bibliothek organisieren und über Profile bereitstellen.')+`<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}${id==='profiles'&&page==='chat'?` <span>${state.profiles.length}</span>`:''}</button>`).join('')}</div><div role="tabpanel">${section==='discover'?discover(page):section==='library'?library(page):section==='profiles'?profiles(page):running(page)}</div>`;
}
function modelCard(m,library=false){return `<article class="card model-card"><div class="card-top"><span class="model-icon">${m.kind==='chat'?'Aa':m.kind==='image'?'▧':m.kind==='audio'?'≈':'▷'}</span><span class="pill">${library?'Bibliotheksbeispiel':'Katalogbeispiel'}</span></div><h2>${e(m.name)}</h2><div class="tag">${e(m.variant)}</div><p>${e(m.description)}</p><dl class="model-facts"><div><dt>Quelle</dt><dd>${e(m.source)} · nicht verbunden</dd></div><div><dt>Downloadgröße</dt><dd>Noch nicht ermittelt</dd></div><div><dt>Eignung für Athena</dt><dd><span class="unknown-dot"></span> Noch nicht geprüft</dd></div></dl><div class="card-actions"><button class="secondary" data-detail="${m.id}">Details &amp; Eignung</button>${library&&m.kind==='chat'?`<button data-create="${m.id}">Profil entwerfen</button>`:'<button disabled>Herunterladen</button>'}</div></article>`;}
function discover(page){return CatalogUI.html(false);}
function library(page){return CatalogUI.html(true);}
function profiles(page){
if(page!=='chat')return `<section class="card"><span class="pill">VORBEREITET</span><h2>${page==='image'?'Auflösung und Generierung':page==='audio'?'Sprache, Stimmen und Verarbeitung':'Auflösung und Videolänge'}</h2><div class="form-grid">${page==='image'?'<label>Breite / Höhe<input disabled value="1024 × 1024 · Beispiel"></label><label>Schritte<input disabled value="30 · Beispiel"></label>':page==='audio'?'<label>Aufgabe<select disabled><option>Sprachausgabe / Spracherkennung</option></select></label><label>Sprache<select disabled><option>Automatisch</option></select></label>':'<label>Auflösung<input disabled value="1280 × 720 · Beispiel"></label><label>Bildanzahl<input disabled value="Noch nicht festgelegt"></label>'}</div><p>Eigene Worker-Adapter und Eignungsregeln folgen. LLM-Kontextparameter werden hier nicht übernommen.</p><button disabled>Profil speichern</button></section>`;
return `<div class="section-heading"><div><h2>Ein Modell. Mehrere Möglichkeiten.</h2><p>API-Namen und Startparameter als lokale Profilentwürfe.</p></div><button id="new-profile">+ Neues Profil</button></div><div class="profile-list">${state.profiles.map(p=>`<article class="card profile-row"><div><span class="label">PROFILENTWURF</span><h2>${e(p.name)}</h2><p>${e(models.find(m=>m.id===p.model)?.variant||'Modell nicht zugeordnet')}</p></div><div><span class="label">Kontextbudget</span><strong>${count(p.context)}</strong></div><div><span class="label">Slots</span><strong>${e(p.slots)}</strong></div><div><span class="label">Zustand</span><strong class="muted">Nicht bereitgestellt</strong></div><div class="profile-actions"><button class="secondary" data-edit="${e(p.id)}">Bearbeiten</button><button class="secondary" data-duplicate="${e(p.id)}">Duplizieren</button></div></article>`).join('')||'<section class="empty">Noch keine Profilentwürfe. Lege dein erstes Profil an.</section>'}</div><section class="card endpoint-preview"><span class="label">GEPLANTE API · NICHT AKTIV</span><h2>Ein Endpunkt für deine Profile</h2><code>GET /v1/models</code><pre>${e(JSON.stringify({data:state.profiles.map(p=>({id:p.name,object:'model'}))},null,2))}</pre><p>Später wählt der Client den Profilnamen im Feld <code>model</code>. Deck verwaltet den passenden llama-server-Prozess.</p></section><div id="profile-editor"></div>`;
}
function running(page){return `<section class="card runtime-empty"><span class="model-icon">○</span><h2>Keine von Deck verwaltete Modelllaufzeit</h2><p>${page==='chat'?'llama.cpp ist in dieser Vorschau nicht angebunden. Gespeicherte Profile starten keinen Prozess.':'Für diesen Bereich ist noch kein Worker angebunden.'}</p><button disabled>Profil starten</button><a class="link" href="#runtime">Laufzeit-Einstellungen ansehen →</a></section><section class="card"><h2>So soll der Profilwechsel ablaufen</h2><ol class="flow"><li><b>Anfrage zuordnen</b><span>Profil anhand des API-Namens auswählen.</span></li><li><b>Arbeit abschließen</b><span>Laufende Anfragen beenden lassen; neue einreihen.</span></li><li><b>Prozess wechseln</b><span>llama-server beenden und mit dem Zielprofil starten.</span></li><li><b>Bereitschaft prüfen</b><span>Erst danach wartende Anfragen weiterleiten.</span></li></ol><p class="note">Mehrere angebotene Profile sind nicht automatisch mehrere geladene Modelle. Produktive Athena-Prozesse werden in dieser Vorschau nicht gesteuert.</p></section>`;}
function detail(id){const m=models.find(m=>m.id===id);document.querySelector('#model-detail').innerHTML=`<article class="card detail-card"><div class="card-top"><h2>${e(m.name)} · Eignungsprüfung</h2><span class="pill">NOCH NICHT BERECHNET</span></div><div class="fit-grid"><div><b>Unterstützt?</b><p>Format und Laufzeit-Kompatibilität noch nicht geprüft.</p></div><div><b>Passt grundsätzlich?</b><p>Gewichte, Zusatzkomponenten und Arbeitsspeicherbedarf noch nicht ermittelt.</p></div><div><b>Kann jetzt starten?</b><p>Keine Ressourcenreservierung oder Prozesssteuerung angebunden.</p></div></div><p class="note">${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.</p></article>`;document.querySelector('#model-detail').scrollIntoView({behavior:'smooth',block:'nearest'});}
function field(label,name,value,type='number',attrs=''){return `<label>${label}<input name="${name}" type="${type}" value="${e(value)}" ${attrs}></label>`;}
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=`<section class="card editor"><div class="card-top"><h2>${id?'Profil bearbeiten':'Neues Profil'}</h2><span class="pill">NUR ENTWURF</span></div><form id="profile-form"><div class="form-grid">${field('API-Profilname','name',p.name,'text','required pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}" maxlength="64"')}<label>Modelldatei / Quantisierung<select name="model">${models.filter(m=>m.kind==='chat').map(m=>`<option value="${m.id}" ${selected(m.id,p.model)}>${e(m.name)} · ${e(m.variant)} (Beispiel)</option>`).join('')}</select></label>${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"')}<label>GPU-Zuordnung<select name="gpu"><option value="both" ${selected(p.gpu,'both')}>RTX 5080 + RTX 3060</option><option value="5080" ${selected(p.gpu,'5080')}>Nur RTX 5080</option><option value="3060" ${selected(p.gpu,'3060')}>Nur RTX 3060</option><option value="cpu" ${selected(p.gpu,'cpu')}>CPU</option></select></label><label>KV-Cache-Format<select name="cache">${['f16','q8_0','q4_0'].map(v=>`<option ${selected(v,p.cache)}>${v}</option>`).join('')}</select></label></div><label class="checkbox"><input type="checkbox" name="unified" ${check(p.unified)}> Gemeinsamen KV-Pool vorsehen</label><p class="small">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.</p><details><summary>Erweiterte Startoptionen</summary><div class="form-grid">${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"')}</div><label class="checkbox"><input type="checkbox" name="flash" ${check(p.flash)}> Flash Attention vorsehen</label><label class="checkbox"><input type="checkbox" name="vision" ${check(p.vision)}> Vision-Projektor vorsehen (noch nicht zugeordnet)</label></details><div class="card-actions"><button>Entwurf speichern</button><button type="button" class="secondary" id="cancel-edit">Schließen</button>${id?'<button type="button" class="secondary danger" id="delete-profile">Entwurf entfernen</button>':''}</div><p id="profile-error" role="alert"></p></form></section>`;
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(page==='runtime'){RuntimeUI.bind();return;} 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-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};
})(); })();
+54
View File
@@ -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')
+13
View File
@@ -39,6 +39,19 @@ class Tests(unittest.TestCase):
self.assertEqual(self.request('demo/stop','POST',{'X-Athena-Deck':'1'})['state'],'stopped') self.assertEqual(self.request('demo/stop','POST',{'X-Athena-Deck':'1'})['state'],'stopped')
self.assertIsNotNone(child.poll()) self.assertIsNotNone(child.poll())
self.assertFalse(self.request('demo/stop','POST',{'X-Athena-Deck':'1'})['reachable']) 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): def test_control_guard(self):
for headers in ({},{'X-Athena-Deck':'1','Origin':'http://evil.invalid'}): 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) with self.assertRaises(urllib.error.HTTPError) as e:self.request('demo/start','POST',headers)