Persist library profiles and download history with weight capacity checks
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 & Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & 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 & Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & 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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
window.ProfilesUI=(()=>{
|
||||||
|
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const labels={context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'};
|
||||||
|
const html=()=>'<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
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 & 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};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user