Compare commits

..
33 Commits
Author SHA1 Message Date
Mikei386 37b5394df1 Add editable LLM penalties and preserve sampling through backup restore 2026-10-01 21:40:44 +02:00
Mikei386 3779c846db Add music test duration control and preserve audio player during polling 2026-10-01 21:12:00 +02:00
Mikei386 96c394cd6c Restore readable vocals and instrumental ZIP filenames 2026-10-01 20:46:40 +02:00
Mikei386 932284a831 Add separation profiles with activation and explicit GPU assignment 2026-10-01 20:43:56 +02:00
Mikei386 356abbeb9d Audit backup recovery coverage and preserve separator container isolation 2026-10-01 17:10:17 +02:00
Mikei386 bd5aea7656 Unify separation tools and add exclusive control mode 2026-10-01 16:56:13 +02:00
Mikei386 088f5f6f91 Align separator navigation and server checks with four packages 2026-10-01 16:22:07 +02:00
Mikei386 433e6709cf Add audio separation web app and verified recovery sources 2026-10-01 16:19:33 +02:00
Mikei386 b5ee7c1e01 Centralize image family runtime and workflow adapters 2026-10-01 14:13:47 +02:00
Mikei386 7826388cde Fix Qwen reference image autogrow input names 2026-10-01 14:06:59 +02:00
Mikei386 508a3f4176 Support FLUX reference images through real edit workflow 2026-10-01 13:47:36 +02:00
Mikei386 66e974291d Restore free VRAM labels beneath dashboard GPU bars 2026-10-01 13:20:19 +02:00
Mikei386 9add6d0547 Separate vision projector device from LLM GPU split 2026-10-01 12:58:45 +02:00
Mikei386 69927ca5e6 Group tool libraries by model and component role 2026-10-01 12:27:12 +02:00
Mikei386 6c3658bbfa Complete separator metadata in restore test fixture 2026-10-01 12:02:26 +02:00
Mikei386 66b035c8d7 Install native audio separator with model packages WAV tests and restore recipe 2026-10-01 12:00:36 +02:00
Mikei386 c3db35dada Add runtime catalogue with live status and llama.cpp release selection 2026-10-01 11:34:17 +02:00
Mikei386 f66e7fbf71 Add scoped start stop and delete actions for application containers 2026-10-01 10:11:58 +02:00
Mikei386 88c6957dac Link YuE2 music control to LadyPoly web tool 2026-10-01 10:05:35 +02:00
Mikei386 7b96d4b3bc Simplify control into LLM video and music modes 2026-10-01 09:59:32 +02:00
Mikei386 a72ebe045a Speed up library loading with shared runtime checks and browser cache 2026-10-01 09:32:44 +02:00
Mikei386 e16e3dea38 Organize navigation and compact model detail lists 2026-10-01 09:21:36 +02:00
Mikei386 df2d55c838 Clarify token copying and add clipboard fallback 2026-09-30 23:08:04 +02:00
Mikei386 5c458bff06 Fix deadlock between endpoint and video status queries 2026-09-30 22:44:06 +02:00
Mikei386 a603a223c4 Allow LadyPoly port check after recently closed connections 2026-09-30 22:33:36 +02:00
Mikei386 cf3e354434 Add managed LadyPoly WebUI using Deck music API 2026-09-30 22:32:17 +02:00
Mikei386 3aa2301a53 Add exclusive music GPU mode beside LLM and video 2026-09-30 22:13:31 +02:00
Mikei386 0ca40bc624 Run YuE2 music profiles through audio.cpp with WAV API and test UI 2026-09-30 22:03:48 +02:00
Mikei386 94bfb1ac78 Unify component setup across categories and retain two installation backups 2026-09-30 21:36:47 +02:00
Mikei386 b731811482 Reuse component downloads and assignments for YuE2 music profiles 2026-09-30 21:21:04 +02:00
Mikei386 d700c68394 Offer runtime candidates across model categories and prepare audio.cpp with six build jobs 2026-09-30 21:04:13 +02:00
Mikei386 1dcd71b128 Document current feature gaps compared with legacy Athena router 2026-09-30 20:07:39 +02:00
Mikei386 487b4e1ea5 Document approved legacy LTX removal and verified retained services 2026-09-30 20:01:52 +02:00
78 changed files with 2547 additions and 163 deletions

No files matched your search

+4 -2
View File
@@ -13,8 +13,10 @@ Zugangsdaten fest:
Kennwort und Token dürfen nicht identisch sein. Der Token kann während der Eingabe
angezeigt/kopiert werden. **Vor dem Speichern im Passwortmanager sichern.** Ein
vorhandener Token ist anschließend nicht abrufbar; bei Verlust einen neuen setzen.
Auf HTTP-Adressen kann die Browser-Zwischenablage gesperrt sein. Dann „Token anzeigen“
verwenden und manuell kopieren. Im ersten Schritt wird noch keine Sitzung erzeugt:
„Neuen Token kopieren“ ist nur bei gefülltem Eingabefeld verfügbar und kopiert
ausschließlich diesen neuen Wert, niemals den gespeicherten Prüfwert. Bei gesperrter
Clipboard-API wird die browserinterne Kopieralternative versucht. Lehnt der Browser
auch diese ab, „Token anzeigen“ verwenden und manuell kopieren. Im ersten Schritt wird noch keine Sitzung erzeugt:
nach dem Einrichten mit dem gewählten Kennwort anmelden.
Der Debian-Installer richtet die Zugangsdaten vor dem Start ein. Speicherung unter
+40
View File
@@ -0,0 +1,40 @@
# Alter Athena Router und Athena Deck
Stand 30.09.2026. Vergleich der aktuellen Quellen und dokumentierten Prüfungen;
keine neue vollständige Live-Abnahme sämtlicher Funktionen. Installierte alte
Container bedeuten keine Anbindung an Deck. Kategorien und Docker-Listen allein
sind kein ausführbarer Worker.
| Funktion | Athena Deck | Offener Umfang |
|---|---|---|
| Chat, Profilwechsel, Streaming, Tools, Vision | vorhanden | weitere Client-/Modellkombinationen prüfen |
| Kontext, Slots, GPU-Split, Batch, KV-Quantisierung, MTP | vorhanden | Modellabhängigkeit beachten |
| Bildgenerierung und bis vier Referenzbilder | vorhanden | Referenzbildpfad nicht universell für alle Familien |
| Qwen PE-T2I/PE-I2I Prompt Enhancer | vorhanden | modellabhängige Auswahl und Speicherbedarf |
| TTS, STT und warme/dynamische Audio-Policy | vorhanden | nicht jede Sprachmodellfamilie ausführbar |
| LTX Original + DeskWEB, exklusiver Videomodus | vorhanden, Basisgeneration geprüft | Extend/Retake, Audio, LoRAs und alle Desktop-Erweiterungen nicht vollständig geprüft |
| Dashboard, History, Themes | vorhanden | kein neuer vollständiger Migrationsaudit |
| Modellkatalog, Komponenten, Downloads, automatische Speicheranpassung | vorhanden | Kompatibilitätsrezepte nicht universell |
| ACE-Step Musikstudio | fehlt als Deck-Worker | Laufzeit, Profile/API, GPU-Modus und Oberfläche |
| YuE2 Musikgenerierung | fehlt als Deck-Worker | Laufzeit, Profile/API, GPU-Modus und Oberfläche |
| BS-RoFormer Stem-Separation | fehlt | Laufzeit und Job-/Dateiverarbeitung |
| OmniVoice Voice Studio | fehlt | eigene Worker-/UI-Anbindung |
| X-VC Voice Changer | fehlt | eigene Worker-/UI-Anbindung |
| Applio/RVC Konvertierung und Training | fehlt | eigene Worker-/UI-Anbindung |
| TRELLIS.2 3D | fehlt | eigene Laufzeit, GPU-Modus und Oberfläche |
| EmbeddingGemma / Embeddings | fehlt im Deck-Endpunkt | Embedding-Worker und Route |
| Realtime Voice / WebRTC / OpenClaw Talk | fehlt in Deck | Brücke, Echtzeitaudio und Clientintegration |
| Responses API | fehlt in Deck | /v1/responses-Kompatibilität |
| PCM-Streaming für TTS | fehlt in Deck | Streaming-Audiopfad |
| Spezialmodi für Musik/Voice/3D | fehlt | mehr als die vorhandenen LLM-/Video-Modi |
| Markierte zusätzliche Docker-Anwendungen | vorhanden | allein noch keine GPU-Worker-Orchestrierung |
| WireGuard-Einstellungen | implementiert, im Athena-Test deaktiviert | Ersatz des vorhandenen Gateway nicht migriert/abgenommen |
| Backup/Restore | implementiert | kompletter Neuaufbau auf leerem Debian nicht durchgetestet; Anwendungsdaten zusätzlicher Studios brauchen Rezepte |
| Nativer Debian-/systemd-Installer | unvollständig | frischer Host, Voraussetzungen und vollständige Abnahme |
Empfohlene Reihenfolge: Embeddings und API-Lücken; gemeinsame Steuerung weiterer
GPU-Spezialworker; Musik/Voice/3D je nach Bedarf; native Installation und vollständiger
Restore-Test. LTX-spezifische optionalen Funktionen separat gegen DeskWEB prüfen.
Die zuvor angefragte Löschung der unbenutzten alten Video-Laufzeit und ihrer
22 Backup-Kopien ist noch nicht ausgeführt: die konkrete Bestätigung steht aus.
+2 -1
View File
@@ -4,7 +4,8 @@ Eigenständige Installation ohne WireGuard-Modul und ohne Änderungen am bestehe
Athena-Router. Der Installer installiert den aktuellen Deck-Anwendungsstand;
Modellkatalog, Downloads und llama.cpp-Buildverwaltung sind aktiv. Das Docker-Image
enthält das CUDA-Build-Toolkit; der Debian-Host wird nicht verändert. Zielprodukt
bleibt ein nativer systemd-Dienst. Modellstarts sind noch nicht angebunden.
bleibt ein nativer systemd-Dienst. Modellstarts sind über die angebundenen
LLM-, Bild-, Sprach- und Video-Worker verfügbar.
## Ziel: native Installation auf frischem Debian
+51
View File
@@ -0,0 +1,51 @@
# LLM-Sampling und Penalties
Unter Chat & Sprachmodelle → Profile → Bearbeiten sind Temperatur, Top-p,
Top-k und im Abschnitt **Penalties** diese Werte editierbar:
| Feld | Neutral | Bedeutung |
|---|---:|---|
| Wiederholungs-Penalty (`repeat_penalty`) | 1,0 | Über 1 dämpft bereits erzeugte Tokens |
| Presence-Penalty | 0 | Berücksichtigt, ob ein Token bereits vorkam |
| Frequency-Penalty | 0 | Berücksichtigt, wie oft ein Token bereits vorkam |
Die Profilwerte gelten im Testchat und am OpenAI-kompatiblen Chat-Endpunkt.
Explizite Werte eines API-Clients haben Vorrang. `repeat_penalty` ist eine
llama.cpp-Erweiterung, Presence und Frequency sind OpenAI-Felder. Es gibt keine
Hermes-spezifische Übersetzung. Neue Felder werden bei älteren Profilen mit
neutralen Werten ergänzt; das Speichern verändert keine GPU-Einstellungen.
Backup/Restore übernimmt die Parameter zusammen mit den Profilen.
## Geprüfte Empfehlungen (2026-10-01)
| Gewichte | Temperatur | Top-p | Top-k | Wiederholung | Presence | Frequency |
|---|---:|---:|---:|---:|---:|---:|
| Qwen3.8 27B, Thinking | 1,0 | 0,95 | 20 | 1,0 | 0 | 0 |
| Gemma 4 31B IT | 1,0 | 0,95 | 64 | 1,0 | 0 | 0 |
Quellen: [Qwen](https://huggingface.co/Qwen/Qwen3.8-27B#best-practices),
[Gemma](https://huggingface.co/google/gemma-4-31B-it#best-practices),
[llama.cpp-API](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md).
Qwen nennt Wiederholung 1,0 und Presence 0 für Thinking ausdrücklich. Frequency
0 ist ein neutraler Deck-Wert, keine gesonderte Modellkarten-Empfehlung.
Gemma nennt keine abweichenden Penalties; dort bleiben sie neutral.
Die verwendeten MIX- und JonathanColetti-Quantisierungen nennen keine eigenen
Sampling-Empfehlungen; die Werte des Qwen-Basismodells werden übernommen.
Für Qwen ohne Thinking empfiehlt die Modellkarte stattdessen Temperatur 0,7,
Top-p 0,8, Top-k 20, Presence 1,5 und Wiederholung 1,0. Deck schaltet die
Profilwerte bei einer einzelnen Anfrage mit ausgeschaltetem Thinking nicht
automatisch um. Bei Bedarf ein eigenes Profil mit diesen Werten speichern.
OBLITERATUS-Empfehlungen werden nicht auf andere unzensierte Varianten übertragen.
## Bestehende Installation gezielt konfigurieren
`python3 deploy/configure_llm_sampling.py --installation /opt/athena-deck-dev/runtime`
zeigt die geplanten Änderungen. `--apply` schreibt sie ausschließlich, wenn der
zugehörige Deck-Container gestoppt ist, und sichert vorher die Profildatei.
Unbekannte Modellquellen und Nicht-Chat-Profile bleiben unberührt. Das optionale
`--add-large-ultra` ergänzt die vom alten Router angeforderten Large-/Ultra-Profile
auf Basis des geprüften Medium-Modells; API-Freigaben werden nicht geändert.
Bild-, Video-, Trenn-, STT- und derzeitige Audio-Profile besitzen keine solchen
Sampling-Felder. Die Implementierung fügt ihnen keine wirkungslosen Optionen hinzu.
+13
View File
@@ -112,3 +112,16 @@ Triton- und Torch-Caches sowie temporäre Dateien liegen nun unter
`video/original-work`, auf dem Datenträger statt im kleinen tmpfs.
Der Docker-Helfer erhält sein Socket-Verzeichnis über Dienstneustarts hinweg,
damit dessen Verzeichnis-Mount im Deck-Testcontainer gültig bleibt.
## Rückbau des alten Studio-Containers am 30.09.2026
Nach ausdrücklicher Freigabe entfernt: Container `mike-ai-ltx2-studio`,
Image `mike-ai/ltx2-studio:1.2.7` sowie unter `/data/video/ltx-desktop`
die alten Verzeichnisse `LTXDesktop/models`, `LTXDesktop/python`, `cache`,
`home/.nv` und `home/.triton`. Das alte Compose-Manifest wurde zu
`compose.yaml.retired-20260930` umbenannt. Historischer Quellcode bleibt erhalten.
Alte Projekte, Eingaben und Ausgaben wurden nicht entfernt. Die neue Deck-Laufzeit,
Deck-Gewichte und DeskWEB bleiben erhalten; beide Oberflächen antworteten mit HTTP 200.
Der freie Hostspeicher stieg dabei von ungefähr 17 auf 19 GiB; die großen
Verzeichnisgrößen dürfen nicht als exklusiv freigegebener Speicher ausgelegt werden.
Weitere Backups oder gemeinsame Daten wurden nicht gelöscht.
+57
View File
@@ -7,6 +7,28 @@ Die Navigation beginnt mit dem [Dashboard](DASHBOARD.md). Es zeigt die
Live-Telemetrie und die aus dem alten Athena-Dashboard übernommene History;
die frühere separate Hardware-Seite wurde entfernt.
## Navigation und Detailansichten
Die **Übersicht** enthält Dashboard und Steuerung (Endpunkt und GPU-Modi).
**KI-Werkzeuge** trennt Chat & Sprachmodelle, Bildgenerierung, Musikgenerierung,
Sprachausgabe, Spracherkennung, Stimmwerkzeuge und Videogenerierung.
Zusätzliche Oberflächen stehen unter **Anwendungen → Weitere Dienste**.
Unter **Einstellungen → Laufzeiten** führt eine aufklappbare Liste zu den
Installations-, Versions- und Statusseiten. Der Laufzeiten-Katalog zeigt den
aktuellen Installationsstatus, lässt sich nach Name und Bereich filtern und
bietet eine direkte llama.cpp-Releaseprüfung mit Änderungsnotizen. „Build
vorbereiten“ übernimmt das Release in das Buildformular; erst dessen
Bestätigung startet den Build. Audio Separator besitzt einen eigenen Installer und WAV-Trenntest. Unterstützte
BS-/MelBand-RoFormer-Pakete werden einschließlich passender YAML-Konfiguration
geladen; ungeprüfte Hub-Dateien werden dadurch nicht freigeschaltet. Die einzelnen Laufzeiten stehen
nicht mehr separat in der Seitenleiste. Bibliothek und Profile verwenden
kompakte, aufklappbare Zeilen. Bibliotheken gruppieren die Dateien nach Hauptgewichten, Vision-/Audio-Projektoren, Textencodern, VAE, Konfigurationen und weiteren Zusatzkomponenten; nur vorhandene Gruppen werden angezeigt. Unbekannte Rollen bleiben separat sichtbar. In den Zeilen: Details, API-Freigabe, Komponenten und Aktionen
stehen in der geöffneten Zeile. Downloads behalten Fortschritt, Geschwindigkeit,
Restzeit und Warteschlangenaktionen. Der laufende Betrieb wird zentral unter
**Steuerung** angezeigt; Testbereiche behalten ihre jeweiligen Formulare.
Bestehende URL-Fragmente bleiben gültig.
## Vorläufige Testinstallation auf Debian
Zielprodukt: nativer systemd-Dienst auf Debian. Der derzeitige Docker-Installer dient ausschließlich der isolierten Testphase.
@@ -17,6 +39,9 @@ Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht veränder
## Neu: Modellverwaltung und llama.cpp-Einstellungen
LLM-Profile bieten editierbare [Sampling-Werte und Penalties](LLM_SAMPLING.md),
die im Testchat und am API-Endpunkt verwendet und mit den Profilen gesichert werden.
Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
@@ -200,3 +225,35 @@ Für reine App-Updates kann der Debian-Installer mit `--update --reuse-runtime`
Optionales SwarmUI mit vorhandener ComfyUI-Laufzeit: [Installation und Betrieb](deploy/swarm-ui/README.md).
Backup und Wiederherstellung unter **Einstellungen → Backup & Restore**: [Umfang, Bedienung, API und Grenzen](BACKUP.md).
## LadyPoly YuE2
Die optionale LadyPoly-WebUI läuft als markierter Anwendungscontainer unter
Weitere Dienste und nutzt Decks Musikworker statt einer eigenen GPU-Laufzeit.
Installation, Verbindung und Einschränkungen: [LadyPoly](deploy/ladypoly/README.md).
Sprachmodell-GPUs und Vision-Projektor werden unabhängig zugeordnet. Der Modell-Split verteilt ausschließlich LLM-Gewichte; eine zusätzliche Projektor-GPU wird nur für mmproj sichtbar gemacht, nicht für Modell-Offload. Die Speicherprüfung berücksichtigt beide Geräte einschließlich Projektorbedarf.
Referenzbilder: Alle derzeit ausführbaren Bildrezepte (Qwen Image 2.1 und FLUX.2 Klein 9B) erlauben bis zu vier Bilder pro Bearbeitungsauftrag. FLUX nutzt VAEEncode und verkettete ReferenceLatent-Nodes für beide Conditioning-Zweige nach dem offiziellen ComfyUI-Workflow: https://github.com/Comfy-Org/workflow_templates/blob/main/templates/image_flux2_klein_image_edit_9b_distilled.json . Neue Modellfamilien benötigen einen passenden Workflow; hochgeladene Referenzen werden niemals stillschweigend verworfen.
Bildadapter werden zentral in `profiles.IMAGE_ADAPTERS` definiert: Modellfamilie, unterstütztes Gewichtsformat, Komponenten, Laufzeit, Workflow, Referenzbildlimit und Speicherpolitik. Bibliothek, Profile und Bildworker verwenden denselben Resolver `image_adapter`. Bekannte Quellen bleiben kompatibel; neue Varianten können über gespeicherte präzise Architektur-Metadaten oder die Hugging-Face-Basismodell-Zuordnung dieselbe Anbindung wiederverwenden. Ein Name mit „Qwen“ oder „Flux“ genügt nicht. Allgemeine FLUX-Architekturangaben unterscheiden Klein 9B nicht sicher von anderen FLUX-Varianten und schalten daher keinen Worker frei. Metadaten sind eine Zuordnungshilfe, keine Garantie für den Inhalt oder die Qualität veränderter Gewichte. Neue Architekturen und Dateiformate benötigen einen registrierten Adapter. Bereits heruntergeladene Varianten ohne ausreichende Metadaten bleiben ungeprüft; bestehende bekannte Profile bleiben erhalten.
### Audio-Trennung
Eigener Werkzeugbereich `#separation` mit BS-Roformer, MelBand und Demucs FT/6s. Die alte Web-GUI läuft optional als CPU-only Container unter Weitere Dienste und nutzt den nativen Deck-Worker. [Installation und Grenzen](deploy/separator-web/README.md). Modellquellen, alle Ensemble-Dateien und SHA-256 sind in `separator_sources.json` festgelegt. Paket-Downloads und Backup-Restore verwenden diese geprüften Rezepte; Nutzmedien werden nicht exportiert. Bei lokalen Bibliotheksdateien wird eine belegte Originalquelle separat vom Übernahmeort angezeigt, ohne IDs oder Profilreferenzen zu ändern. Unbekannte lokale Quellen werden nicht geraten.
Audio-Trennung ist auch ein exklusiver Steuerungsmodus neben LLM, Video und Musik. Der Wechsel beendet Deck-eigene GPU-Worker; fremde Dienste bleiben unangetastet. Lange Trennaufträge über die Web-GUI benötigen diesen Modus, der kurze Test bleibt auch im LLM-Modus nutzbar. Sprach-/Bild-/Musik-API-Anfragen erhalten im Trennmodus eine klare 503-Meldung. Der Werkzeugbereich verwendet die gemeinsamen Tabs Entdecken, Bibliothek, Downloads und Testen; Laufzeitinstallation bleibt unter Einstellungen. Weitere Dienste bietet Links und Beschreibung für Separator Web (8128), LadyPoly (8127), LTX DeskWEB (8118) und SwarmUI (8125).
Unter **Audio-Trennung → Profile** gibt es ein aufklappbares Profil je geprüftem
Modellpaket: Aktivierung für Trennaufträge und Auswahl einer festen GPU oder
„Automatisch · RTX 5080 zuerst“. Automatik nimmt anschließend die RTX 3060, wenn
die 5080 nicht frei genug ist. Eine feste Auswahl fällt nicht still auf eine
andere Karte zurück. Auch die Separator-Weboberfläche beachtet diese Einstellungen.
Profileinstellungen werden im verschlüsselten Backup gespeichert. Die GPU-Prüfung
ist eine Vorprüfung; ein tatsächlicher OOM wird weiterhin als Auftragsfehler angezeigt.
Der YuE2-Test bietet eine maximale Länge in Sekunden (1,28–360 s). Bei der aktuellen
VAE-Konfiguration entsprechen 25 semantische Tokens einer Sekunde; die erweiterten
Token- und Sekundenfelder werden synchronisiert. Das Modell kann vor dem Limit enden.
Der Ergebnisplayer bleibt beim Status-Polling bestehen, damit die Wiedergabe nicht
alle 1,5 Sekunden zurückgesetzt wird.
+79
View File
@@ -253,3 +253,82 @@ Die handy-computer-Konvertierung verwendet `qwen3_asr`, das der auf Athena getes
**STT → Testen**: Profil wählen, Audiodatei auswählen, Sprache (Deutsch, Englisch, automatisch) wählen und transkribieren. Der Browser dekodiert maximal 20 MiB / 120 Sekunden und wandelt in PCM16-Mono-WAV bei 16 kHz um. Eingabeformate hängen von den Browser-Codecs ab. Abbrechen beendet ausschließlich den eigenen STT-Prozess. CPU-Ausführung mit zwei Threads, keine GPU-Belegung; mindestens 4 GiB freier Deck-RAM werden vorausgesetzt. Ein API-Schlüssel schützt den kurzlebigen internen Loopback-Worker. Audio und Transkript werden nicht persistiert; das letzte Transkript liegt bis zum nächsten Auftrag oder Neustart im Speicher und ist nach Anmeldung einsehbar. Keine Mikrofonaufnahme, Zeitstempel oder Sprechertrennung in dieser Version.
Interne API: `GET /api/v1/stt`, `POST /api/v1/stt/setup` (`{}`), `/assign` (`id`, `revision`), `/start` (Multipart: `profile_id`, `file`, optional `language`), `/cancel` (`{}`). `/setup` verwendet die normale Downloadwarteschlange und startet kein Modell. Ausführung setzt ein vollständiges Profil voraus.
### Schneller Bibliothekswechsel
Der Katalog ermittelt den Installationsstatus der Laufzeiten einmal pro Abfrage;
für die reine Bibliotheksbewertung wird keine vollständige Video-Modellliste
aufgebaut. Das gilt auch für Profilbewertungen und die Dateiauswahl in Entdecken.
Die Oberfläche hält den letzten Katalog ausschließlich im Arbeitsspeicher der
aktuellen Browserseite. Beim erneuten Öffnen einer Bibliothek erscheinen deren
zuletzt geladene Dateien sofort mit Aktualisierungshinweis. Aktionen bleiben bis
zur frischen Serverantwort gesperrt; Löschschutz und Profilzuordnungen werden
weiterhin serverseitig geprüft. Ein Neuladen der Browserseite leert diesen Cache.
### Vereinfachte Steuerung
Unter Steuerung zuerst LLM, Video oder Musik auswählen und anschließend starten.
Die Auswahl allein verändert den laufenden Betrieb nicht. Start wechselt bei
Bedarf die Betriebsart und öffnet den gemeinsamen API-Listener. Stoppen schließt
den Listener und beendet bei Video/Musik den exklusiven Betrieb durch Rückkehr
auf LLM; die bisherigen Schutzprüfungen und Wartebedingungen bleiben bestehen.
Während eines Wechsels erscheint ein unbestimmter Ladebalken mit der echten
Wechselphase, ohne erfundene Prozentangabe. Bereit bedeutet API-erreichbar; der
Hinweis zu Gewichten unterscheidet geladene Modelle vom Laden bei Anfrage.
API-Adressen und der Port werden für die ausgewählte Betriebsart angezeigt.
Technische Konfiguration und Profilfreigaben bleiben unter Einstellungen → Endpunkt.
Für die derzeit unterstützten YuE2-Musikprofile führt die Steuerung direkt zu
LadyPoly auf Port 8127, analog zum LTX-DeskWEB-Link bei Video. Die Oberfläche
kann bereits vor dem Start geöffnet werden; Generierung benötigt Musikbetrieb.
### Anwendungscontainer verwalten
Weitere Dienste bietet Starten, Stoppen und Löschen für Container mit beiden
Deck-Anwendungslabels. Der begrenzte Systemhelfer prüft die aktuelle Zuordnung
bei jeder Aktion anhand der vollständigen Container-ID. Registrierte Video-
Laufzeiten müssen weiter über die Steuerung verwaltet werden. Löschen benötigt
einen gestoppten Container und eine Bestätigung; weder Images noch Volumes
werden gelöscht. Daten nur in der beschreibbaren Container-Schicht gehen verloren.
Es gibt keine pauschalen Stop-, Prune- oder Löschaktionen für den Docker-Stack.
## Audio Separator
Unter Einstellungen → Laufzeiten → Audio Separator steht ein nativer Installer
für `audio-separator[gpu]==0.47.0` bereit. PyTorch/Torchaudio 2.11.0 mit CUDA 12.8
und ONNX Runtime GPU 1.22.0 werden in einer eigenen Python-Umgebung installiert.
FFmpeg und libsndfile werden vom Debian-Testinstaller im Deck-Image bereitgestellt.
Die Laufzeit wird pro Server, nicht pro Modell installiert.
Unterstützte Pakete: BS-RoFormer (`model_bs_roformer_ep_317_sdr_12.9755.ckpt`)
und MelBand RoFormer (`vocals_mel_band_roformer.ckpt`). „Modell und Konfiguration
herunterladen“ verwendet den Audio-Separator-Modellkatalog und lädt die passende
YAML mit. Diese Pakete liegen in `separator-runtime/models`; sie werden derzeit
auf der Laufzeitseite verwaltet, getrennt von ungeprüften Hub-Downloads in der
Bibliothek. CoreML-, ONNX- und Safetensors-Abwandlungen werden nicht automatisch
als kompatibel freigegeben. Für unbekannte RoFormer-Dateien zeigt Deck den Weg
zu diesem Einrichtungsbereich, behauptet aber keine Kompatibilität.
Stimmwerkzeuge → Audio trennen bzw. die Laufzeitseite bieten einen Trenntest:
16-Bit-PCM-WAV, Mono/Stereo, 8–48 kHz, maximal 30 Sekunden/8 MiB. Der Worker
nutzt bevorzugt eine freie RTX 3060, sonst eine freie RTX 5080. Deck koordiniert
eigene LLM/TTS-Prozesse über den GPU-Scheduler; fremde Prozesse werden nicht
beendet. Video-/Musikmodus sperren diesen Test. Es gibt keinen stillen CPU-Fallback.
Nach Abschluss/Abbruch wird der Prozess beendet und die Eingabedatei gelöscht.
Ergebnis-WAVs bleiben unter `separator-tests/<job-id>` auf Athena.
Interne, nur mit GUI-Anmeldung erreichbare API:
- `GET /api/v1/separator-runtime`: Status, Installationsauftrag, Modellpakete.
- `POST /api/v1/separator-runtime/install` / `cancel`: leeres JSON-Objekt.
- `POST /api/v1/separator-runtime/download`: `{"model":"<Paket-Dateiname>"}`.
- `GET /api/v1/separator-tests`: Teststatus und Ergebnisliste.
- `POST /api/v1/separator-tests/start`: Multipart-Felder `model` und `file`.
- `POST /api/v1/separator-tests/cancel`: leeres JSON-Objekt.
- `GET /api/v1/separator-tests/audio?id=<job-id>&index=<Spur>`: WAV.
Backups enthalten Version und ausgewählte Modellpakete als Restore-Rezept,
keine Laufzeit-Binaries, Gewichte, Eingabe- oder Ergebnis-Audiodateien. Restore
installiert eine fehlende Laufzeit und lädt die Modellpakete erneut. Der
Audio-Separator-Upstream-Katalog und seine öffentlichen Modellquellen können
sich ändern; Wiederherstellung ist von deren Verfügbarkeit abhängig.
Quelle: https://github.com/nomadkaraoke/python-audio-separator/tree/v0.47.0
+16 -4
View File
@@ -1,16 +1,28 @@
async function copyTokenValue(value){
if(!value)throw Error('empty');
try{if(navigator.clipboard?.writeText){await navigator.clipboard.writeText(value);return;}}catch{}
const active=document.activeElement,selection=window.getSelection(),ranges=[];
if(selection)for(let i=0;i<selection.rangeCount;i++)ranges.push(selection.getRangeAt(i).cloneRange());
const area=document.createElement('textarea');area.value=value;area.readOnly=true;
area.style.cssText='position:fixed;left:-9999px;top:0;opacity:0';document.body.append(area);
try{area.focus();area.select();if(!document.execCommand('copy'))throw Error('clipboard');}
finally{area.value='';area.remove();active?.focus();if(selection){selection.removeAllRanges();for(const range of ranges)selection.addRange(range);}}
}
let accessBusy=false;
function accessToken(){const bytes=crypto.getRandomValues(new Uint8Array(32));return 'ad_'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
async function accessPage(){
const view=document.querySelector('#view');
if(document.querySelector('#access-page'))return;
view.innerHTML=`<div id="access-page"><div class="kicker">EINSTELLUNGEN / ZUGANG</div><h1>Dein Zugang. Deine Schlüssel.</h1><p>Ein Administratorzugang für die Oberfläche und ein separater Token für API-Clients.</p><p id="access-info"></p><section class="card"><h2>Oberflächenkennwort ändern</h2><form id="password-form"><label for="current-password">Aktuelles Kennwort</label><input id="current-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-password">Neues Kennwort · mindestens 16 Zeichen</label><input id="new-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><label for="repeat-password">Neues Kennwort wiederholen</label><input id="repeat-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><button>Kennwort ändern</button></form><p class="note">Alle bestehenden Browser-Sitzungen werden abgemeldet. Der API-Token bleibt gültig.</p></section><section class="card"><h2>API-Token ändern</h2><p id="token-info"></p><form id="token-form"><label for="token-password">Aktuelles Oberflächenkennwort zur Bestätigung</label><input id="token-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-token">Neuer API-Token</label><input id="new-token" type="password" autocomplete="off" spellcheck="false" required minlength="32" maxlength="256"><button id="token-generate" type="button" class="secondary">Sicheren Token erzeugen</button><button id="token-show" type="button" class="secondary">Token anzeigen</button><button id="token-copy" type="button" class="secondary">Token kopieren</button><button>Neuen Token speichern</button></form><p class="note">Vor dem Speichern kopieren. Der bisherige Token wird sofort ungültig. Bestehende Browser-Sitzungen bleiben angemeldet. Verwende in API-Clients: Authorization: Bearer &lt;Token&gt;.</p></section><p id="access-message" role="status" aria-live="polite"></p><button id="logout" class="secondary">Abmelden</button><p class="note">Der Token berechtigt zum Dienststatus, Hardwareabruf und Demo-Start/Stopp. Er erlaubt keine Kennwort-, Token- oder Netzwerkeinstellungen. Weitere Benutzer und Rollen sind noch nicht enthalten.</p></div>`;
view.innerHTML=`<div id="access-page"><div class="kicker">EINSTELLUNGEN / ZUGANG</div><h1>Dein Zugang. Deine Schlüssel.</h1><p>Ein Administratorzugang für die Oberfläche und ein separater Token für API-Clients.</p><p id="access-info"></p><section class="card"><h2>Oberflächenkennwort ändern</h2><form id="password-form"><label for="current-password">Aktuelles Kennwort</label><input id="current-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-password">Neues Kennwort · mindestens 16 Zeichen</label><input id="new-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><label for="repeat-password">Neues Kennwort wiederholen</label><input id="repeat-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><button>Kennwort ändern</button></form><p class="note">Alle bestehenden Browser-Sitzungen werden abgemeldet. Der API-Token bleibt gültig.</p></section><section class="card"><h2>API-Token ändern</h2><p id="token-info"></p><p>Der gespeicherte Token kann nicht zurückgelesen werden. Kopiert wird nur der neue Token im Eingabefeld. Einen zuvor gespeicherten Token findest du in deinem Passwortmanager.</p><form id="token-form"><label for="token-password">Aktuelles Oberflächenkennwort zur Bestätigung</label><input id="token-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-token">Neuer API-Token</label><input id="new-token" type="password" autocomplete="off" spellcheck="false" required minlength="32" maxlength="256"><button id="token-generate" type="button" class="secondary">Sicheren Token erzeugen</button><button id="token-show" type="button" class="secondary">Token anzeigen</button><button id="token-copy" type="button" class="secondary" disabled>Neuen Token kopieren</button><button>Neuen Token speichern</button></form><p class="note">Vor dem Speichern kopieren. Der bisherige Token wird sofort ungültig. Bestehende Browser-Sitzungen bleiben angemeldet. Verwende in API-Clients: Authorization: Bearer &lt;Token&gt;.</p></section><p id="access-message" role="status" aria-live="polite"></p><button id="logout" class="secondary">Abmelden</button><p class="note">Der Token berechtigt zum Dienststatus, Hardwareabruf und Demo-Start/Stopp. Er erlaubt keine Kennwort-, Token- oder Netzwerkeinstellungen. Weitere Benutzer und Rollen sind noch nicht enthalten.</p></div>`;
try{const r=await fetch('/api/v1/auth/status'),s=await r.json();if(!s.authenticated){location.reload();return}document.querySelector('#access-info').textContent=`Administrator · Diese Instanz: ${s.location}`;document.querySelector('#token-info').textContent=s.api_token_configured?('API-Token eingerichtet'+(s.token_changed_at?' · geändert am '+new Date(s.token_changed_at*1000).toLocaleString('de-DE'):'')+'. Der Wert wird nicht angezeigt.'):'Noch kein API-Token eingerichtet. API-Clients haben keinen Zugang.';}catch{document.querySelector('#access-message').textContent='Zugangsstatus nicht erreichbar.'}
async function save(kind,data,form){if(accessBusy)return;accessBusy=true;view.querySelectorAll('button').forEach(b=>b.disabled=true);try{const r=await fetch('/api/v1/auth/'+kind,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}),s=await r.json();if(r.status===401){location.reload();return}if(!r.ok)throw Error(s.error);form.reset();if(s.login_required){location.reload();return}document.querySelector('#access-message').textContent='Neuer API-Token gespeichert. Der alte Token ist jetzt ungültig.';document.querySelector('#token-info').textContent='API-Token gerade geändert. Der Wert wird nicht angezeigt.';}catch(e){document.querySelector('#access-message').textContent=e.message}finally{accessBusy=false;view.querySelectorAll('button').forEach(b=>b.disabled=false)}}
async function save(kind,data,form){if(accessBusy)return;accessBusy=true;view.querySelectorAll('button').forEach(b=>b.disabled=true);try{const r=await fetch('/api/v1/auth/'+kind,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}),s=await r.json();if(r.status===401){location.reload();return}if(!r.ok)throw Error(s.error);form.reset();if(s.login_required){location.reload();return}document.querySelector('#access-message').textContent='Neuer API-Token gespeichert. Der alte Token ist jetzt ungültig.';document.querySelector('#token-info').textContent='API-Token gerade geändert. Der Wert wird nicht angezeigt.';}catch(e){document.querySelector('#access-message').textContent=e.message}finally{accessBusy=false;view.querySelectorAll('button').forEach(b=>b.disabled=false);syncTokenCopy()}}
document.querySelector('#password-form').onsubmit=e=>{e.preventDefault();const value=document.querySelector('#new-password').value;if(value!==document.querySelector('#repeat-password').value){document.querySelector('#access-message').textContent='Die neuen Kennwörter stimmen nicht überein.';return}save('password',{current_password:document.querySelector('#current-password').value,new_password:value},e.target)};
document.querySelector('#token-form').onsubmit=e=>{e.preventDefault();save('token',{current_password:document.querySelector('#token-password').value,new_token:document.querySelector('#new-token').value},e.target)};
document.querySelector('#token-generate').onclick=()=>{document.querySelector('#new-token').value=accessToken();document.querySelector('#access-message').textContent='Neuen Token vor dem Speichern kopieren.'};
const syncTokenCopy=()=>{document.querySelector('#token-copy').disabled=accessBusy||!document.querySelector('#new-token').value;};
document.querySelector('#new-token').addEventListener('input',syncTokenCopy);syncTokenCopy();
document.querySelector('#token-generate').onclick=()=>{document.querySelector('#new-token').value=accessToken();syncTokenCopy();document.querySelector('#access-message').textContent='Neuen Token vor dem Speichern kopieren.'};
document.querySelector('#token-show').onclick=()=>{const input=document.querySelector('#new-token');input.type=input.type==='password'?'text':'password';document.querySelector('#token-show').textContent=input.type==='password'?'Token anzeigen':'Token verbergen';};
document.querySelector('#token-copy').onclick=async()=>{try{const token=document.querySelector('#new-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);document.querySelector('#access-message').textContent='Token kopiert. Bitte sicher speichern.';}catch{document.querySelector('#access-message').textContent='Kopieren nicht möglich. Bitte einen eigenen Token aus dem Passwortmanager verwenden.'}};
document.querySelector('#token-copy').onclick=async()=>{try{const token=document.querySelector('#new-token').value;if(!token)throw Error();await copyTokenValue(token);document.querySelector('#access-message').textContent='Token kopiert. Bitte sicher speichern.';}catch{document.querySelector('#access-message').textContent='Der Browser hat das Kopieren nicht erlaubt. Bitte „Token anzeigen“ wählen und den neuen Token manuell kopieren.'}};
const hub=document.createElement('section');hub.className='card';hub.innerHTML=`<h2>Hugging Face · Modelldownloads</h2><p id="hf-status" role="status">Zugang wird geprüft …</p><p>Für private oder freigabepflichtige Modelle: <a class="link" href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer">Hugging-Face-Token erstellen ↗</a> mit Leserechten für das gewünschte Repository. Die Freigabe beantragst du auf dessen Modellkarte mit demselben Konto.</p><form id="hf-form"><label>Hugging-Face-Token<input id="hf-token" type="password" autocomplete="off" spellcheck="false" required maxlength="255" placeholder="hf_…"></label><button>Token speichern / ersetzen</button><button id="hf-remove" type="button" class="secondary">Gespeicherten Token entfernen</button></form><p class="note">Separater Zugang für Hugging Face, nicht der Deck-API-Token. Speicherung auf Athena in einer Datei mit Zugriff nur für den Dienstbenutzer (0600), unverschlüsselt. Der gespeicherte Wert wird nie an den Browser zurückgegeben. Bereits laufende Downloads können nach Entfernen weiterlaufen.</p>`;
document.querySelector('#access-page').insertBefore(hub,document.querySelector('#access-message'));
const hfStatus=hub.querySelector('#hf-status');
+10 -2
View File
@@ -3,7 +3,9 @@
Effort is a template hint, not a guaranteed compute budget. llama.cpp forwards
positive levels to the model template; it handles 'none' as thinking disabled.
"""
CHAT_FIELDS=frozenset({'model','messages','stream','stream_options','temperature','top_p','top_k','max_tokens','max_completion_tokens','stop','seed','tools','tool_choice','parallel_tool_calls','response_format','presence_penalty','frequency_penalty','logprobs','top_logprobs','user','n','reasoning_effort'})
import math
CHAT_FIELDS=frozenset({'model','messages','stream','stream_options','temperature','top_p','top_k','max_tokens','max_completion_tokens','stop','seed','tools','tool_choice','parallel_tool_calls','response_format','repeat_penalty','presence_penalty','frequency_penalty','logprobs','top_logprobs','user','n','reasoning_effort'})
LLAMA_EFFORTS=frozenset({'none','low','medium','high','xhigh'})
# The installed llama.cpp server rejects minimal and max. Use nearest supported hints.
EFFORT_ALIASES={'minimal':'low','max':'xhigh','ultra':'xhigh'}
@@ -14,7 +16,13 @@ def normalize_chat(data):
"""Return a new request and non-sensitive translation metadata; never mutate input."""
unknown=set(data)-CHAT_FIELDS
if unknown:raise CompatibilityError('Nicht unterstützte Chat-Felder: '+', '.join(sorted(unknown)))
body=dict(data);requested=body.get('reasoning_effort')
body=dict(data)
for key,lo,hi in [('repeat_penalty',0,2),('presence_penalty',-2,2),('frequency_penalty',-2,2)]:
if key in body:
value=body[key]
if isinstance(value,bool) or not isinstance(value,(int,float)) or not math.isfinite(value) or not lo<=value<=hi:
raise CompatibilityError('Ungültiger '+key+'-Wert.')
requested=body.get('reasoning_effort')
if requested is None:
body.pop('reasoning_effort',None)
return body,{}
+2
View File
@@ -24,6 +24,8 @@ function appearancePage(){
sync();
}
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'dashboard';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
if(['separator-runtime','separator-test','separation'].includes(page)){SeparatorUI.render(page!=='separator-runtime');return;}
if(page==='audio-cpp-runtime'){AudioCppUI.render();return;}
if(page==='ltx-original-runtime'){LTXOriginalUI.render();return;}
if(['video','video-runtime'].includes(page)){VideoUI.runtime();return;}
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
+10
View File
@@ -0,0 +1,10 @@
window.AudioCppUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(path='',data){const r=await fetch('/api/v1/audio-cpp-runtime'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
function render(){const view=document.querySelector('#view');if(view.querySelector('#audio-cpp-page'))return;
view.innerHTML='<div id="audio-cpp-page"><h1>audio.cpp</h1><p>Gemeinsame C++-/CUDA-Laufzeit für unterstützte Audio-Modellfamilien. Passendes GGUF-Paket und ein Deck-Worker sind zusätzlich erforderlich.</p><section class="card"><h2>Herunterladen und bereitmachen</h2><div id="ac-status"></div><button id="ac-install">audio.cpp herunterladen &amp; CUDA-Build erstellen</button><button id="ac-cancel" class="secondary">Build abbrechen</button><p id="ac-message" role="status"></p><details><summary>Eigene Build-Ausgabe</summary><pre id="ac-log" style="white-space:pre-wrap;max-height:320px;overflow:auto"></pre></details></section><section class="card"><h2>YuE2 und Ladypoly WebUI</h2><p>Modellfamilie erkannt. Für Generierung das Musikprofil und seine Komponenten einrichten, dann Musik → Testen verwenden. Die Ladypoly-Oberfläche ist separat. Dieser Build lädt keine Gewichte, startet kein Modell und gibt kein Profil am Endpunkt frei.</p><a href="#music">Zur Musik-Bibliothek →</a></section></div>';
const root=view.firstChild;let timer;
async function poll(){if(!root.isConnected)return;try{const s=await api();if(!root.isConnected)return;const running=s.job?.state==='running';root.querySelector('#ac-install').disabled=s.installed||running;root.querySelector('#ac-cancel').disabled=!running;root.querySelector('#ac-status').innerHTML=`<p><strong>${s.installed?'Installiert · YuE2-Musikworker verfügbar':'Noch nicht installiert'}</strong></p><p>Version ${e(s.version)} · Commit <code>${e(s.revision)}</code></p><p>Mindestens ${e(s.required_disk_gib)} GiB freier Speicher. Eigener Build mit sechs Jobs, CUDA-Ziele werden aus den vorhandenen GPUs ermittelt. Bestehende Laufzeiten werden nicht verändert.</p>${s.job?`<p>${e(s.job.state)} · ${e(s.job.phase)}</p>${running?'<progress aria-label="audio.cpp-Build läuft"></progress>':''}`:''}`;if(s.job){const log=await api('/log');if(root.isConnected)root.querySelector('#ac-log').textContent=log.text;}}catch(x){if(root.isConnected)root.querySelector('#ac-message').textContent=x.message;}if(root.isConnected){clearTimeout(timer);timer=setTimeout(poll,3000);}}
for(const [id,path] of [['ac-install','/install'],['ac-cancel','/cancel']])root.querySelector('#'+id).onclick=async()=>{root.querySelector('#'+id).disabled=true;try{await api(path,{});root.querySelector('#ac-message').textContent='Aktion angenommen.';}catch(x){root.querySelector('#ac-message').textContent=x.message;}poll();};poll();}
return {render};
})();
+73
View File
@@ -0,0 +1,73 @@
"""Pinned audio.cpp build, isolated from model execution and existing runtimes."""
import json
import os
from pathlib import Path
import shutil
import sys
import time
from ltx_original_runtime import LTXOriginalRuntime
REVISION='f2b4937306daa25f5c78520f3c626ed31495a37a'
VERSION='v0.8.1'
class AudioCppRuntime(LTXOriginalRuntime):
name="audio.cpp"
def __init__(self,root):
super().__init__(root,None)
request=self.root/'resume-request'
if request.is_file():
request.unlink()
self.start()
def paths(self):
p=self.root/'active.json'
if p.is_file():
ident=json.loads(p.read_text()).get('id','')
if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):
base=self.root/ident
if (base/'ready').is_file():return base/'build/bin/audiocpp_cli',base/'source'
return self.root/'missing-cli',self.root/'missing-source'
def status(self):
cli,source=self.paths()
return dict(installed=cli.is_file() and (source/'model_specs/yue2.json').is_file(),revision=REVISION,version=VERSION,job=dict(self.job) if self.job else None,required_disk_gib=6,adapter_ready=True,origin='0xShug0/audio.cpp',message='Gemeinsame Laufzeit. Ein Build startet kein Modell; Der YuE2-Musikworker ist angebunden; Profil und Komponenten werden benötigt. Andere Familien brauchen eigene geprüfte Anbindungen.')
def start(self):
if sys.platform!='linux':raise ValueError('audio.cpp wird auf dem Debian-Server gebaut.')
missing=[name for name in ('git','cmake','g++','nvcc','nvidia-smi') if not shutil.which(name)]
if missing:raise ValueError('Build-Werkzeuge fehlen: '+', '.join(missing)+'. Keine automatische Host-Paket- oder Treiberänderung.')
self.resume_id=(self.job or {}).get('id')
return super().start()
def _run(self):
base=self.root/self.job['id'];state='failed';phase='audio.cpp-Build fehlgeschlagen.'
try:
previous=self.root/getattr(self,'resume_id','missing') if getattr(self,'resume_id',None) else None
if previous and (previous/'source/.git').is_dir() and not (previous/'ready').exists():
base=previous;self.job['id']=previous.name;self._save()
else:base.mkdir(mode=0o700)
tmp=base/'tmp';tmp.mkdir(exist_ok=True)
env=dict(os.environ,TMPDIR=str(tmp),GIT_TERMINAL_PROMPT='0',OMP_NUM_THREADS='2')
source=base/'source';build=base/'build'
self._phase('Offizielle audio.cpp-Quelle herunterladen')
if not (source/'.git').is_dir():
self._command(['git','init',source],env)
self._command(['git','-C',source,'fetch','--depth','1','https://github.com/0xShug0/audio.cpp.git',REVISION],env)
self._command(['git','-C',source,'checkout','--detach','FETCH_HEAD'],env)
else:self._command(['git','-C',source,'rev-parse','--verify',REVISION+'^{commit}'],env)
# Compile for the actual available GPUs; no weight allocation or inference.
import subprocess
raw=subprocess.check_output(['nvidia-smi','--query-gpu=compute_cap','--format=csv,noheader'],text=True)
arch=sorted({line.strip().replace('.','') for line in raw.splitlines() if line.strip()})
if not arch or any(not a.isdigit() for a in arch):raise ValueError('CUDA-Architekturen nicht ermittelbar.')
self._phase('CUDA-Build konfigurieren · GPU-Ziele '+', '.join(arch))
self._command(['cmake','-S',source,'-B',build,'-DCMAKE_BUILD_TYPE=Release','-DENGINE_ENABLE_CUDA=ON','-DCMAKE_CUDA_ARCHITECTURES='+';'.join(arch),'-DAUDIOCPP_DEPLOYMENT_BUILD=ON','-DAUDIOCPP_BUILD_SERVER_FRONTENDS=OFF','-DENGINE_BUILD_TESTS=OFF'],env)
self._phase('audio.cpp bauen · sechs parallele Build-Jobs')
self._command(['cmake','--build',build,'--target','audiocpp_cli','audiocpp_server','--parallel','6'],env)
self._phase('Programmstart ohne Modell prüfen')
self._command([build/'bin/audiocpp_cli','--help'],env)
if not (source/'model_specs/yue2.json').is_file():raise ValueError('YuE2-Modellspezifikation fehlt.')
if self.cancel.is_set():raise InterruptedError()
(base/'ready').write_text(REVISION)
marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json')
state='complete';phase='audio.cpp installiert. Kein Modell gestartet; Deck-Worker noch erforderlich.'
except InterruptedError:state='cancelled';phase='Build abgebrochen.'
except Exception as exc:phase=str(exc)
finally:
# Keep unfinished compiler artifacts so retry resumes the same pinned build.
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
+47 -10
View File
@@ -6,8 +6,8 @@ from pathlib import Path,PurePosixPath
from auth import atomic_write,validate_record
from backup_codec import seal,open_backup
from catalog import repo_id,file_role,KINDS
from profiles import SCHEMAS,CHAT_GPU_DEFAULTS,chat_parameters
SETTINGS=('profiles.json','endpoint.json','audio-policy.json','video/comfy-selection.json','models/huggingface.json','video/comfy-client-token','video/original-work/settings.json')
from profiles import SCHEMAS,CHAT_GPU_DEFAULTS,chat_parameters,FLOAT_PARAMETERS
SETTINGS=('separator-profiles.json','profiles.json','endpoint.json','audio-policy.json','video/comfy-selection.json','models/huggingface.json','video/comfy-client-token','video/original-work/settings.json')
MAX_HISTORY=40*1024**2
VERSION=1
# Exact byte hash verified against official Hub metadata, not a filename heuristic.
@@ -47,11 +47,11 @@ def validate(doc):
if set(params)!=(set(SCHEMAS[kind])|(set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(params)&{'video_device','text_encoder_device'} if kind=='video' else set())):raise ValueError('Unbekannte Profilparameter.')
for key,(lo,hi,_) in SCHEMAS[kind].items():
value=params[key]
if isinstance(value,bool) or not isinstance(value,(int,float)) or not lo<=value<=hi or key not in ('temperature','top_p','guidance','speed','mtp_min_p') and type(value) is not int:raise ValueError('Ungültiger Profilparameter: '+key)
if isinstance(value,bool) or not isinstance(value,(int,float)) or not lo<=value<=hi or key not in FLOAT_PARAMETERS and type(value) is not int:raise ValueError('Ungültiger Profilparameter: '+key)
if kind=='chat' and (params['ubatch']>params['batch'] or params.get('vision_projector') and params['vision_projector'] not in ids):raise ValueError('Ungültige Chat-Zuordnung.')
if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Ungültige Bildgröße.')
components=p.get('components',{})
if not isinstance(components,dict) or any(k not in ('text_encoder','vae','video_vae','audio_vae','spatial_upsampler') or v not in ids for k,v in components.items()):raise ValueError('Ungültige Komponentenreferenz.')
if not isinstance(components,dict) or any((k not in ('text_encoder','vae','video_vae','audio_vae','spatial_upsampler','model_config','generation_config','tokenizer','vae_config','manual_text_encoder','manual_vae','manual_tokenizer','manual_configuration','manual_projector','manual_auxiliary') and not re.fullmatch(r'runtime_file_[0-9]{1,2}',k)) or v not in ids for k,v in components.items()):raise ValueError('Ungültige Komponentenreferenz.')
pids.add(p['id']);names.add(name)
settings=doc.get('settings')
if not isinstance(settings,dict) or set(settings)-set(SETTINGS):raise ValueError('Unbekannte Einstellungsdateien.')
@@ -70,9 +70,20 @@ def validate(doc):
if not isinstance(hf,dict) or set(hf)!={'token'} or hf['token'] is not None and (not isinstance(hf['token'],str) or not re.fullmatch(r'hf_[A-Za-z0-9]{10,252}',hf['token'])):raise ValueError('Ungültiger Hugging-Face-Zugang.')
if 'video/comfy-client-token' in settings and not re.fullmatch(r'[A-Za-z0-9_-]{32,256}',settings['video/comfy-client-token'].strip()):raise ValueError('Ungültiger Diensttoken.')
if 'video/original-work/settings.json' in settings and (not isinstance(settings['video/original-work/settings.json'],dict) or len(json.dumps(settings['video/original-work/settings.json']))>65536):raise ValueError('Ungültige LTX-Einstellungen.')
if 'separator-profiles.json' in settings:
from separator import validate_profiles
validate_profiles(settings['separator-profiles.json'])
validate_record(doc.get('credentials'))
runtimes=doc.get('runtimes')
if not isinstance(runtimes,dict) or set(runtimes)-{'llama','llama_builds','image','tts','enhancers','swarm_nodes','ltx_original'} or not {'llama','llama_builds','image','tts','enhancers','swarm_nodes'}<=set(runtimes):raise ValueError('Ungültiges Laufzeitrezept.')
if not isinstance(runtimes,dict) or set(runtimes)-{'llama','llama_builds','image','tts','enhancers','swarm_nodes','ltx_original','audio_cpp','separator'} or not {'llama','llama_builds','image','tts','enhancers','swarm_nodes'}<=set(runtimes):raise ValueError('Ungültiges Laufzeitrezept.')
if 'separator' in runtimes:
from separator_runtime import MODELS
value=runtimes['separator']
if not isinstance(value,dict) or set(value)-{'installed','models','sources'} or not {'installed','models'}<=set(value) or type(value['installed']) is not bool or not isinstance(value['models'],list) or len(value['models'])>len(MODELS) or any(not isinstance(m,str) or m not in MODELS for m in value['models']) or len(set(value['models']))!=len(value['models']):raise ValueError('Ungültiges Audio-Separator-Rezept.')
if 'sources' in value:
from separator_runtime import SOURCES
if value['sources']!={name:SOURCES[name] for name in value['models']}:raise ValueError('Audio-Separator-Quellen stimmen nicht mit dem geprüften Rezept überein.')
if 'audio_cpp' in runtimes and type(runtimes['audio_cpp']) is not bool:raise ValueError('Ungültige audio.cpp-Laufzeit.')
if 'ltx_original' in runtimes and type(runtimes['ltx_original']) is not bool:raise ValueError('Ungültige LTX-Laufzeit.')
if not isinstance(runtimes['llama_builds'],list) or len(runtimes['llama_builds'])>50 or any(not isinstance(b,dict) or set(b)!={'commit','backend'} or not re.fullmatch('[a-f0-9]{40}',b.get('commit','')) or b.get('backend') not in ('CUDA','CPU') for b in runtimes['llama_builds']):raise ValueError('Ungültige Buildrezepte.')
if runtimes['llama'] is not None:
@@ -137,7 +148,13 @@ class Backup:
if not s.network.disabled:
network=s.network.call('backup-export')
else:warnings.append('Deck-Netzwerkmodul deaktiviert; fremder WireGuard-Gateway wird nicht gesichert.')
document=dict(format='athena-deck',version=VERSION,created_at=time.time(),settings=settings,models=models,credentials=s.credentials.read(),theme=theme,network=network,services=services,runtime_versions=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']),runtimes=dict(ltx_original=bool(getattr(s,'ltx_original_runtime',None) and s.ltx_original_runtime.status()['installed']),llama=llama,llama_builds=[dict(commit=b['commit'],backend=b['backend']) for b in state['builds']],image=s.image_runtime.status()['installed'],tts=s.tts_runtime.status()['installed'],enhancers={task:data['revision'] for task in ('t2i','i2i') if (data:=s.prompt_enhancer.installed(task))},swarm_nodes=(self.root/'video/swarm-comfy-nodes').is_dir()),warnings=warnings)
document=dict(format='athena-deck',version=VERSION,created_at=time.time(),settings=settings,models=models,credentials=s.credentials.read(),theme=theme,network=network,services=services,runtime_versions=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']),runtimes=dict(audio_cpp=bool(getattr(s,'audio_cpp_runtime',None) and s.audio_cpp_runtime.status()['installed']),ltx_original=bool(getattr(s,'ltx_original_runtime',None) and s.ltx_original_runtime.status()['installed']),llama=llama,llama_builds=[dict(commit=b['commit'],backend=b['backend']) for b in state['builds']],image=s.image_runtime.status()['installed'],tts=s.tts_runtime.status()['installed'],enhancers={task:data['revision'] for task in ('t2i','i2i') if (data:=s.prompt_enhancer.installed(task))},swarm_nodes=(self.root/'video/swarm-comfy-nodes').is_dir()),warnings=warnings)
if getattr(s,'separator_runtime',None):
from separator_runtime import SOURCES
separator=s.separator_runtime.status();document['runtimes']['separator']=dict(installed=separator['installed'],models=[m['id'] for m in separator['models'] if m['installed']],sources={m['id']:SOURCES[m['id']] for m in separator['models'] if m['installed']});document['runtime_versions']['separator']=separator['version']
if document['runtimes']['audio_cpp']:
from audio_cpp_runtime import REVISION
document['runtime_versions']['audio_cpp']=REVISION
if document['runtimes']['ltx_original']:
from ltx_original_runtime import DESKTOP_REV,LTX_REV
document['runtime_versions']['ltx_original']=dict(desktop=DESKTOP_REV,inference=LTX_REV)
@@ -167,12 +184,18 @@ class Backup:
elif s.network.status().get('enabled') or s.network.status().get('mode')!='lan':blockers.append('Deck-Tunnel vor Netzwerk-Restore im bestätigten LAN-Modus deaktivieren.')
if doc['network']['mode']!='lan':warnings.append('Tunnelzugang wird mit Rückfallzeit aktiviert und muss über den Tunnel bestätigt werden.')
versions=doc.get('runtime_versions',{})
if versions.get('separator'):
from separator_runtime import VERSION as SEPARATOR_VERSION
if versions['separator']!=SEPARATOR_VERSION:blockers.append('Backup verlangt eine andere Audio-Separator-Version.')
if versions.get('audio_cpp'):
from audio_cpp_runtime import REVISION
if versions['audio_cpp']!=REVISION:blockers.append('Backup verlangt eine andere audio.cpp-Version. Passende Deck-Version verwenden.')
if versions.get('ltx_original'):
from ltx_original_runtime import DESKTOP_REV,LTX_REV
if versions['ltx_original']!=dict(desktop=DESKTOP_REV,inference=LTX_REV):blockers.append('Backup verlangt eine andere originale LTX-Version. Passende Deck-Version verwenden.')
if versions and {k:v for k,v in versions.items() if k!='ltx_original'}!=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']):blockers.append('Backup verlangt andere Bild-/TTS-Laufzeitversionen. Passende Deck-Version oder geprüftes Runtime-Upgrade verwenden.')
if versions and {k:v for k,v in versions.items() if k not in ('ltx_original','audio_cpp','separator')}!=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']):blockers.append('Backup verlangt andere Bild-/TTS-Laufzeitversionen. Passende Deck-Version oder geprüftes Runtime-Upgrade verwenden.')
for c in doc['services']:
if not c.get('registry') and c.get('build_recipe')!='swarm-ui':warnings.append(c['name']+': Image nur lokal vorhanden; Registry-/Buildquelle fehlt.')
if not c.get('registry') and not c.get('build_recipe'):warnings.append(c['name']+': Image nur lokal vorhanden; Registry-/Buildquelle fehlt.')
ports=s.endpoint.allowed_ports
if ports and doc['settings'].get('endpoint.json',{}).get('port',8120) not in ports:blockers.append('API-Port aus Backup ist nicht veröffentlicht. Installer zuerst für diesen Port konfigurieren.')
ident=secrets.token_hex(16);summary=dict(id=ident,created_at=doc.get('created_at'),models=[dict(id=m['id'],name=m['file'],bytes=m['size'],present=m['id'] in existing) for m in doc['models']],profiles=[p['name'] for p in doc['settings'].get('profiles.json',[])],services=[dict(name=c['name'],image=c['image'],registry=c.get('registry'),build_recipe=c.get('build_recipe'),restorable=bool(c.get('registry') or c.get('build_recipe')),running=c['running']) for c in doc['services']],download_bytes=need,runtimes=doc['runtimes'],wireguard=bool(doc['network'] and doc['network']['config']),warnings=warnings,blockers=blockers,theme=doc.get('theme','deck'))
@@ -195,7 +218,7 @@ class Backup:
if len(set(data['services']))!=len(data['services']) or any(name not in services for name in data['services']):raise ValueError('Unbekannter Zusatzdienst.')
if any(not services[name].get('registry') and not services[name].get('build_recipe') for name in data['services']):raise ValueError('Gewählter Dienst hat keine Wiederherstellungsquelle.')
s=self.server
if s.catalog.pending or s.catalog.job and s.catalog.job['state']=='downloading' or s.runtime.busy or any((getattr(o,'job',None) or {}).get('state')=='running' for o in (s.image_runtime,s.tts_runtime,s.ltx_original_runtime,s.prompt_enhancer,s.auto_tests,s.chat_tests,s.image_tests,s.tts_tests,s.stt)):raise ValueError('Zuerst laufende Downloads, Builds und Tests abschließen oder abbrechen.')
if s.catalog.pending or s.catalog.job and s.catalog.job['state']=='downloading' or s.runtime.busy or any((getattr(o,'job',None) or {}).get('state')=='running' for o in (s.image_runtime,s.tts_runtime,s.ltx_original_runtime,getattr(s,'audio_cpp_runtime',None),getattr(s,'music',None),getattr(s,'separator_runtime',None),getattr(s,'separator_tests',None),s.prompt_enhancer,s.auto_tests,s.chat_tests,s.image_tests,s.tts_tests,s.stt)):raise ValueError('Zuerst laufende Downloads, Builds und Tests abschließen oder abbrechen.')
self.cancel.clear();self.job=dict(id=secrets.token_hex(16),state='running',phase='Wiederherstellung vorbereiten',completed=0,total=len(doc['models'])+len(data['services'])+5,items=[],started_at=time.time(),theme=doc.get('theme','deck'))
self._save();threading.Thread(target=self._restore,args=(copy.deepcopy(doc),data['services'],data['restore_credentials']),daemon=True).start();self.plan=None
return self.status()
@@ -221,8 +244,17 @@ class Backup:
self._phase('Vorhandene Bild-/Videolaufzeit prüfen oder installieren');s.image_runtime.start();self._wait(s.image_runtime.status)
if r['tts'] and not s.tts_runtime.status()['installed']:
self._phase('TTS-Laufzeit und Zusatzdateien installieren');s.tts_runtime.start();self._wait(s.tts_runtime.status)
if r.get('audio_cpp') and not s.audio_cpp_runtime.status()['installed']:
self._phase('audio.cpp-Laufzeit installieren');s.audio_cpp_runtime.start();self._wait(s.audio_cpp_runtime.status)
if r.get('ltx_original') and not s.ltx_original_runtime.status()['installed']:
self._phase('Originale LTX-Laufzeit installieren');s.ltx_original_runtime.start();self._wait(s.ltx_original_runtime.status)
if r.get('separator',{}).get('installed'):
if not s.separator_runtime.status()['installed']:
self._phase('Audio Separator installieren');s.separator_runtime.start();self._wait(s.separator_runtime.status)
from separator_runtime import verify_package
for name in r['separator']['models']:
if not s.separator_runtime.model_ready(name) or not verify_package(s.separator_runtime.root/'models',name):
self._phase('Audio-Separator-Modellpaket laden');s.separator_runtime.download(name);self._wait(s.separator_runtime.status)
targets=r['llama_builds']+([r['llama']] if r['llama'] and r['llama'] not in r['llama_builds'] else [])
for target in targets:
state=s.runtime.status();matching=next((b for b in state['builds'] if b['commit']==target['commit'] and b['backend']==target['backend']),None)
@@ -360,5 +392,10 @@ class Backup:
with self.lock:
self.cancel.set()
if self.busy():
self.server.catalog.stop();self.server.runtime.stop();self.server.image_runtime.stop();self.server.tts_runtime.stop();self.server.ltx_original_runtime.stop();self.server.prompt_enhancer.stop()
self.server.catalog.stop();self.server.runtime.stop();self.server.image_runtime.stop();self.server.tts_runtime.stop();self.server.ltx_original_runtime.stop();
if getattr(self.server,'separator_runtime',None):self.server.separator_runtime.stop()
if getattr(self.server,'separator_tests',None):self.server.separator_tests.stop()
if getattr(self.server,'music',None):self.server.music.stop()
if getattr(self.server,'audio_cpp_runtime',None):self.server.audio_cpp_runtime.stop()
self.server.prompt_enhancer.stop()
return {'cancellation_requested':True}
+20 -6
View File
@@ -1,5 +1,6 @@
/* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */
window.CatalogUI = (() => {
let cachedCatalog = null; // Memory only: refreshed on every visit; never stores access tokens.
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const size = n => { if (!Number.isFinite(n)) return 'Unbekannt'; if(n < 1024) return n+' B'; const unit = n >= 1024**3 ? 'GiB' : n >= 1024**2 ? 'MiB' : 'KiB'; return (n / (unit === 'GiB' ? 1024**3 : unit === 'MiB' ? 1024**2 : 1024)).toFixed(1)+' '+unit; };
const number = n => Number.isFinite(n) ? new Intl.NumberFormat('de-DE', {notation:'compact'}).format(n) : '–';
@@ -9,12 +10,12 @@ window.CatalogUI = (() => {
}
function executionHTML(x) {
if(!x)return '<p>Ausführbarkeit noch nicht geprüft.</p>';
return `<section class="note execution-setup"><strong>${escape(x.label)}</strong><p>Modellfamilie: ${escape(x.family||'Unbekannt')}${x.evidence?' · '+escape(x.evidence):''}</p>${x.runtime?`<p>${escape(x.runtime)} · ${x.installed?'bereits installiert – wird wiederverwendet':'noch nicht eingerichtet'}</p>`:''}<p>${escape(x.message)}</p>${x.components?.length?`<p>Benötigt: ${x.components.map(escape).join(' · ')}. Bei Profilen unter „Komponenten“ zuordnen; TTS enthält das komplette Paket.</p>`:''}${x.route?`<a class="link" href="${escape(x.route)}">${x.installed?'Einrichtung ansehen':'Ausführung einrichten'} →</a>`:''}${x.alternative?`<p><a class="link" href="${escape(x.alternative.route)}">${escape(x.alternative.label)} →</a></p>`:''}<p class="small">Installierte Laufzeit bedeutet nicht, dass Modell und Kontext in den Speicher passen. Ein Test startet nur auf deinen Auftrag.</p></section>`;
return `<section class="note execution-setup"><strong>${escape(x.label)}</strong><p>Modellfamilie: ${escape(x.family||'Unbekannt')}${x.evidence?' · '+escape(x.evidence):''}</p>${x.runtime?`<p>${escape(x.runtime)} · ${x.installed?'bereits installiert – wird wiederverwendet':'noch nicht eingerichtet'}</p>`:''}<p>${escape(x.message)}</p>${x.components?.length?`<p>Benötigt: ${x.components.map(escape).join(' · ')}. Modellabhängig zuordnen; benötigte Zusatzdateien gehören zum vollständigen Paket.</p>`:''}${x.suggested_components?.length?`<p>Zusatzdateien in dieser Quelle (Kompatibilität ungeprüft): ${x.suggested_components.map(escape).join(' · ')}. Nach dem Download unter Profil → Einrichtung &amp; Komponenten prüfen.</p>`:''}${x.route?`<a class="link" href="${escape(x.route)}">${x.installed?'Einrichtung ansehen':'Ausführung einrichten'} →</a>`:''}${x.candidates?.length?`<h4>Mögliche Laufzeiten</h4><p class="small">Diese Liste unterscheidet bekannte Zuordnungen von ungeprüften Kategorieoptionen. Eine Installation ersetzt keinen fehlenden Worker.</p>${x.candidates.map(c=>`<p><strong>${escape(c.label)}</strong> · ${c.installed?'bereits installiert – wiederverwenden':'nicht installiert'}<br>${escape(c.evidence)}${c.route?`<br><a class="link" href="${escape(c.route)}">${c.installed?'Laufzeit / Einrichtung ansehen':'Laufzeit herunterladen / bereitmachen'} →</a>`:'<br>Kein gepflegter Deck-Installer für diese Option verfügbar.'}</p>`).join('')}`:`<p>${escape(x.candidate_message||'')}</p>`}${x.alternative?`<p><a class="link" href="${escape(x.alternative.route)}">${escape(x.alternative.label)} →</a></p>`:''}<p class="small">Installierte Laufzeit bedeutet nicht, dass Modell und Kontext in den Speicher passen. Ein Test startet nur auf deinen Auftrag.</p></section>`;
}
function html(library, downloads=false) {
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>
${downloads ? '' : library ? '<div id="hub-results" class="model-grid"></div>' : `<form id="hub-search" class="hub-search"><label>Modell, Anbieter oder Repository-Link<input name="q" maxlength="120" placeholder="Name, GGUF-Dateiname oder Hugging-Face-Link" autocomplete="off"></label><button>Suchen</button></form><label class="hub-base-filter"><input id="hub-base-only" type="checkbox"> Nur Basismodelle</label><p class="small muted">Blendet anhand der Hub-Metadaten Finetunes, LoRAs, Merges und separate Quantisierungen aus. Unvollständig gekennzeichnete Ableitungen können sichtbar bleiben.</p><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><option value="newest">Neu hinzugefügt</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="detail-list"></div>' : `<form id="hub-search" class="hub-search"><label>Modell, Anbieter oder Repository-Link<input name="q" maxlength="120" placeholder="Name, GGUF-Dateiname oder Hugging-Face-Link" autocomplete="off"></label><button>Suchen</button></form><label class="hub-base-filter"><input id="hub-base-only" type="checkbox"> Nur Basismodelle</label><p class="small muted">Blendet anhand der Hub-Metadaten Finetunes, LoRAs, Merges und separate Quantisierungen aus. Unvollständig gekennzeichnete Ableitungen können sichtbar bleiben.</p><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><option value="newest">Neu hinzugefügt</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, downloads=false) {
const root = document.querySelector('#live-catalog'); if (!root) return;
@@ -30,7 +31,13 @@ window.CatalogUI = (() => {
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
}
function renderLibrary() {
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</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>${executionHTML(x.execution)}${x.used_by_profiles?.length?`<p class="warning">Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.</p>`:""}<div class="card-actions">${x.profile_eligible===true?`<button data-profile-model="${x.id}">${kind==='video'?'Als Videomodell verwenden':'Profil anlegen'}</button>`:""}<button type="button" class="secondary" data-delete-model="${x.id}" ${x.used_by_profiles?.length?"disabled":""}>Datei löschen</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>';
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId));
const entries = state.entries.filter(x => x.kind === kind);
const groups = [['model', kind==='chat'?'Sprachmodelle · Hauptgewichte':'Modelle · Hauptgewichte'],['vision_projector','Vision-Projektoren'],['audio_projector','Audio-Projektoren'],['text_encoder','Textencoder'],['vae','VAE · Decoder'],['configuration','Konfigurationen'],['auxiliary','Tokenizer und weitere Zusatzkomponenten']];
const row = x => `<details class="detail-row" data-row-id="${escape(x.id)}" ${openRows.has(x.id)?'open':''}><summary class="detail-summary"><strong class="hub-filename">${escape(x.file)}</strong><span>${escape(x.role_label)}</span><span>${size(x.size)}</span><span>${x.used_by_profiles?.length?`${x.used_by_profiles.length} Profil(e)`:'Nicht zugeordnet'}</span></summary><div class="detail-body"><p class="hub-filename">Quelle: ${escape(x.download_source?.repo||x.repo)}</p>${x.download_source?`<p class="small">Lokal übernommen · Originaldatei: ${escape(x.download_source.file)} · Quellrevision: ${escape(x.download_source.revision)}</p>`:""}<p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</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>${executionHTML(x.execution)}${x.used_by_profiles?.length?`<p class="warning">Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.</p>`:""}<div class="card-actions">${x.profile_eligible===true?`<button data-profile-model="${x.id}">${kind==='video'?'Als Videomodell verwenden':'Profil anlegen'}</button>`:""}<button type="button" class="secondary" data-delete-model="${x.id}" ${x.used_by_profiles?.length?"disabled":""}>Datei löschen</button></div></div></details>`;
const known = new Set(groups.map(([role])=>role));
const sections = [...groups, ['other','Weitere Dateien · Zuordnung unbekannt']];
el('hub-results').innerHTML = sections.map(([role,label])=>{const files=entries.filter(x=>role==='other'?!known.has(x.role):x.role===role);return files.length?`<section class="library-group" aria-label="${escape(label)}"><h3>${escape(label)} <span class="small muted">(${files.length})</span></h3><div class="detail-list">${files.map(row).join('')}</div></section>`:'';}).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>';
root.querySelectorAll('[data-delete-model]').forEach(b=>b.onclick=async()=>{const entry=state.entries.find(x=>x.id===b.dataset.deleteModel);if(!entry||!confirm(`Datei „${entry.file}“ (${size(entry.size)}) dauerhaft von Athena löschen?`))return;b.disabled=true;try{await api('/delete',{id:entry.id});message('Datei aus der Bibliothek gelöscht.');await refresh();}catch(error){message(error.message);b.disabled=false;}});
}
function renderJob() {
@@ -38,14 +45,14 @@ window.CatalogUI = (() => {
const jobs=downloads ? (state.downloads||[]).filter(j=>j.kind===kind) : state.job?.state==='downloading' ? [state.job] : [];
const signature=JSON.stringify(jobs); if(signature===lastJobs)return; lastJobs=signature;
const labels={queued:'Warteschlange',downloading:'Wird heruntergeladen',complete:'Download abgeschlossen',cancelled:'Download abgebrochen',failed:'Download fehlgeschlagen',interrupted:'Download unterbrochen'};
el('hub-job').innerHTML=jobs.map(j=>{const percent=j.total>0?Math.min(100,Math.max(0,j.bytes/j.total*100)):0;return `<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==='downloading'?` · ${Number.isFinite(j.bytes_per_second)?size(j.bytes_per_second)+'/s':'Geschwindigkeit wird ermittelt …'} · Restzeit: ${Number.isFinite(j.eta_seconds)?Math.ceil(j.eta_seconds/60)+' Min.':'–'}`:''}</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)}">${j.state==='queued'?'Aus Warteschlange entfernen':'Aus Liste entfernen'}</button><span class="small muted">${j.state==='queued'?'Download noch nicht gestartet':'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-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 download-row"><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==='downloading'?` · ${Number.isFinite(j.bytes_per_second)?size(j.bytes_per_second)+'/s':'Geschwindigkeit wird ermittelt …'} · Restzeit: ${Number.isFinite(j.eta_seconds)?Math.ceil(j.eta_seconds/60)+' Min.':'–'}`:''}</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)}">${j.state==='queued'?'Aus Warteschlange entfernen':'Aus Liste entfernen'}</button><span class="small muted">${j.state==='queued'?'Download noch nicht gestartet':'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>':'');
root.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/cancel',{});message('Abbruch angefordert; die Netzwerkabfrage kann bis zu 20 Sekunden dauern.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-dismiss]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/dismiss',{id:b.dataset.dismiss});message('Eintrag ausgeblendet. Die Modelldatei bleibt in der Bibliothek.');await refresh();}catch(error){message(error.message);b.disabled=false;}});
}
async function refresh() {
if (refreshing || !root.isConnected) return; refreshing = true; clearTimeout(timer);
try { const next = await api(''); if (!root.isConnected) return; const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(state.entries); const statusChanged = JSON.stringify(next) !== JSON.stringify(state); state = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>kind==='video'?fetch('/api/v1/video/service',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({id:b.dataset.profileModel})}).then(async r=>{const data=await r.json();if(!r.ok)throw Error(data.error);location.hash='video';VideoUI.runtime();}).catch(x=>alert(x.message)):Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } }
catch(error) { message(error.message); }
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; cachedCatalog = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; message(''); root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>kind==='video'?fetch('/api/v1/video/service',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({id:b.dataset.profileModel})}).then(async r=>{const data=await r.json();if(!r.ok)throw Error(data.error);location.hash='video';VideoUI.runtime();}).catch(x=>alert(x.message)):Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } }
catch(error) { message(library && cachedCatalog ? 'Zuletzt geladene Dateien · Aktualisierung fehlgeschlagen: '+error.message : error.message); }
finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); }
}
async function search(q) {
@@ -98,6 +105,13 @@ window.CatalogUI = (() => {
}
el('hub-refresh').onclick = refresh;
if (!library && !downloads) { try { el('hub-base-only').checked=localStorage.getItem('deck-base-only')==='true'; } catch {} el('hub-base-only').onchange=()=>{try { localStorage.setItem('deck-base-only',String(el('hub-base-only').checked)); } catch {} search(new FormData(el('hub-search')).get('q').trim());}; el('hub-sort').onchange = () => search(new FormData(el('hub-search')).get('q').trim()); el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); }
if (library) {
if (cachedCatalog) {
state = cachedCatalog; renderLibrary();
root.querySelectorAll('[data-delete-model], [data-profile-model]').forEach(b=>b.disabled=true);
message('Zuletzt geladene Dateien · wird aktualisiert …');
} else message('Bibliothek wird geladen …');
}
refresh();
}
return {html,bind,executionHTML};
+11 -3
View File
@@ -89,9 +89,13 @@ def runtime_metadata(model):
architecture=next((x for x in architectures if isinstance(x,str) and 0<len(x)<=100),None)
architecture=architecture or next((config.get(k) for k in ('_class_name','model_type') if isinstance(config.get(k),str) and 0<len(config[k])<=100),None)
tags=[x for x in model.get('tags',[]) if isinstance(x,str) and len(x)<=80][:32] if isinstance(model.get('tags'),list) else []
card=model.get('cardData') if isinstance(model.get('cardData'),dict) else {}
base=card.get('base_model',[])
base=[base] if isinstance(base,str) else base if isinstance(base,list) else []
base_models=[x for x in base if isinstance(x,str) and re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+',x)][:8]
return dict(pipeline_tag=model.get('pipeline_tag') if isinstance(model.get('pipeline_tag'),str) and len(model['pipeline_tag'])<=80 else None,
library_name=model.get('library_name') if isinstance(model.get('library_name'),str) and len(model['library_name'])<=80 else None,
architecture=architecture,tags=tags)
architecture=architecture,tags=tags,base_models=base_models)
class Catalog:
def __init__(self,root):
@@ -197,7 +201,7 @@ class Catalog:
files=[]
for f in data.get('siblings',[]):
name=f['rfilename'];p=PurePosixPath(name)
if p.is_absolute() or '..' in p.parts or p.suffix.lower() not in ('.gguf','.safetensors','.json'):continue
if p.is_absolute() or '..' in p.parts or p.suffix.lower() not in ('.gguf','.safetensors','.json','.tiktoken'):continue
size=f.get('size',f.get('lfs',{}).get('size'))
if not isinstance(size,int) or size<1:continue
files.append(dict(name=name,size=size,sha256=f.get('lfs',{}).get('sha256')))
@@ -212,7 +216,11 @@ class Catalog:
if self.root.exists():
for p in self.root.glob('*/entry.json'):
try:
item=json.loads(p.read_text());item['id']=p.parent.name;item.update(file_role(item['file']));entries.append(item)
item=json.loads(p.read_text());item['id']=p.parent.name;item.update(file_role(item['file']))
if item['repo'].startswith('local/'):
from backup import LOCAL_SOURCES
item['download_source']=item.get('download_source') or LOCAL_SOURCES.get(item.get('sha256'))
entries.append(item)
except (OSError,ValueError):pass
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):
+2 -1
View File
@@ -6,6 +6,7 @@ import threading
import time
import uuid
from inference import InferenceError
from profiles import generation_parameters
class ChatTests:
def __init__(self,profiles,worker,scheduler):
@@ -52,7 +53,7 @@ class ChatTests:
with self.scheduler.lease(key,profile['parameters']['slots'],prepare,allowed=allowed):
if self.cancel.is_set():raise InterruptedError()
self.phase('Antwort wird erzeugt')
body=dict(model=profile['name'],messages=messages,max_tokens=limit,stream=True,stream_options={"include_usage":True},**{k:profile['parameters'][k] for k in ('temperature','top_p','top_k')})
body=dict(model=profile['name'],messages=messages,max_tokens=limit,stream=True,stream_options={"include_usage":True},**generation_parameters(profile['parameters']))
conn,token=self.worker.connect()
conn.request('POST','/v1/chat/completions',json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+token})
with self.lock:self.socket=conn.sock
+1 -1
View File
@@ -17,7 +17,7 @@ function applyDashboardStyles(container=root){
}
const imagePhaseLabel=p=>({'running':'Bildauftrag läuft','loading':'Bildmodell lädt','complete':'bereit','failed':'Fehler'})[p]||p||'inaktiv';
function cleanup(){for(const timer of timers)clearInterval(timer);timers=[];controller?.abort();controller=null;host=null;root=null}
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total&&g.memory_used_mib!=null?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0)),memoryText=total&&g.memory_used_mib!=null?`${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB`:'–';return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${esc(g.index)}</div><div class="value">${esc(g.name)}</div></div><span class="badge">${esc(g.pstate)}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${memoryText}</b><span>VRAM</span></div><div class="metric"><b>${esc(g.temperature_c)} °C</b><span>Temperatur</span></div><div class="metric"><b>${esc(g.power_w)} / ${esc(g.power_limit_w)} W</b><span>Leistung</span></div><div class="metric"><b>${esc(g.memory_controller_percent)} %</b><span>Speichercontroller</span></div><div class="metric"><b>${esc(g.graphics_clock_mhz)} MHz</b><span>GPU-Takt</span></div><div class="metric"><b>${esc(g.memory_clock_mhz)} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${esc(g.fan_percent)} %</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${pct(load)}</span></div><div class="bar"><div class="fill gpu-load" data-bar-percent="${load}"></div></div><div class="bar-label"><span>VRAM</span><span>${pct(p)}</span></div><div class="bar"><div class="fill" data-bar-percent="${p}"></div></div></article>`}
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total&&g.memory_used_mib!=null?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0)),memoryText=total&&g.memory_used_mib!=null?`${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB`:'–';return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${esc(g.index)}</div><div class="value">${esc(g.name)}</div></div><span class="badge">${esc(g.pstate)}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${memoryText}</b><span>VRAM</span></div><div class="metric"><b>${esc(g.temperature_c)} °C</b><span>Temperatur</span></div><div class="metric"><b>${esc(g.power_w)} / ${esc(g.power_limit_w)} W</b><span>Leistung</span></div><div class="metric"><b>${esc(g.memory_controller_percent)} %</b><span>Speichercontroller</span></div><div class="metric"><b>${esc(g.graphics_clock_mhz)} MHz</b><span>GPU-Takt</span></div><div class="metric"><b>${esc(g.memory_clock_mhz)} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${esc(g.fan_percent)} %</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${pct(load)}</span></div><div class="bar"><div class="fill gpu-load" data-bar-percent="${load}"></div></div><div class="bar-label"><span>VRAM-Belegung</span><span>${pct(p)}</span></div><div class="bar"><div class="fill" data-bar-percent="${p}"></div></div><div class="sub">${g.memory_free_mib==null?'Freier VRAM nicht verfügbar':`${(g.memory_free_mib/1024).toFixed(1)} GiB VRAM frei`}</div></article>`}
function mount(){
historyColors=document.documentElement.dataset.theme==='athena-classic'?['#45d7ff','#ffc65c','#66e3a4','#bda7ff']:['#c5edaf','#ffb454','#7fc8b6','#c39bff'];
controller=new AbortController();
+3 -3
View File
@@ -11,13 +11,13 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
&& /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt accelerate==1.15.0 \
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography ffmpeg libsndfile1 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock
COPY ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY separator_sources.json separator.py separator_runtime.py separator_worker.py music.py audio_cpp_runtime.py ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY separator-ui.js music-ui.js audio-cpp-ui.js ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY deploy/docker_backup.py /app/deploy/docker_backup.py
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
+3 -3
View File
@@ -1,13 +1,13 @@
ARG DECK_RUNTIME_IMAGE
FROM ${DECK_RUNTIME_IMAGE}
USER root
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography ffmpeg libsndfile1 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock
COPY ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY separator_sources.json separator.py separator_runtime.py separator_worker.py music.py audio_cpp_runtime.py ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY separator-ui.js music-ui.js audio-cpp-ui.js ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY deploy/docker_backup.py /app/deploy/docker_backup.py
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Explicit offline application of reviewed model-card defaults; never load weights."""
import argparse
import copy
import json
import os
from pathlib import Path
import subprocess
import time
import uuid
QWEN_SOURCES={
'jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF',
'vmarcelo/Qwen3.8-27B-MIX_GGUF',
'JonathanColetti/Qwen3.8-27B-Uncensored-GGUF',
}
NEUTRAL=dict(repeat_penalty=1.0,presence_penalty=0.0,frequency_penalty=0.0)
def recommendations(entry):
repo=entry.get('repo')
if repo in QWEN_SOURCES:
return dict(temperature=1.0,top_p=.95,top_k=20,**NEUTRAL)
if repo=='ggml-org/gemma-4-31B-it-GGUF':
return dict(temperature=1.0,top_p=.95,top_k=64,**NEUTRAL)
return None
def configure(rows,entries,add_large_ultra=False):
result=copy.deepcopy(rows);report=[]
for row in result:
if row.get('kind')!='chat':continue
recommended=recommendations(entries.get(row['model_id'],{}))
if recommended is None:
report.append(dict(name=row['name'],state='unknown-source'));continue
changed=any(row['parameters'].get(k)!=v for k,v in recommended.items())
if changed:
row['parameters'].update(recommended);row['revision']+=1;row['updated_at']=time.time()
report.append(dict(name=row['name'],state='updated' if changed else 'unchanged',sampling=recommended))
if add_large_ultra:
medium=next((r for r in result if r.get('name')=='Qwen3.8 27B - Medium' and r.get('kind')=='chat'),None)
if not medium or entries.get(medium['model_id'],{}).get('repo')!='jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF':
raise ValueError('Das geprüfte Medium-Profil fehlt; keine Profile erstellt.')
for suffix,context,split,micro in [('Large',192000,[86,14],256),('Ultra',262144,[80,20],128)]:
name='Qwen3.8 27B - '+suffix
if any(r['name']==name for r in result):continue
row=copy.deepcopy(medium);row.update(id=uuid.uuid4().hex,revision=1,name=name,updated_at=time.time())
row['parameters'].update(context=context,slots=1,batch=2048,ubatch=micro,tensor_split=split,split_mode='layer',gpu_offload='full',gpu_reserve_mode='none',gpu_reserve_mib={})
if suffix=='Ultra':row['parameters']['vision_device']='cpu'
result.append(row);report.append(dict(name=name,state='created',sampling=recommendations(entries[medium['model_id']])))
return result,report
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--installation',type=Path,required=True)
parser.add_argument('--apply',action='store_true')
parser.add_argument('--add-large-ultra',action='store_true')
args=parser.parse_args();base=args.installation.resolve();state=base/'state';path=state/'profiles.json'
original=path.read_bytes();rows=json.loads(original)
entries={row['model_id']:json.loads((state/'models'/row['model_id']/'entry.json').read_text()) for row in rows}
configured,report=configure(rows,entries,args.add_large_ultra)
if args.apply and configured!=rows:
manifest=json.loads((base/'installation.json').read_text())
container=json.loads(subprocess.check_output(['docker','inspect',manifest['name']],text=True))[0]
if container['Config'].get('Labels',{}).get('de.casaderoll.athena-deck.standalone')!=str(base) or container['State']['Running']:
raise RuntimeError('Nur bei gestopptem, dieser Installation zugeordnetem Deck-Container anwenden.')
if path.read_bytes()!=original:raise RuntimeError('Profile wurden zwischenzeitlich geändert.')
backup=base/'backups'/str(time.time_ns());backup.mkdir(parents=True,mode=0o700)
(backup/'profiles.json').write_bytes(original)
temporary=path.with_suffix('.sampling.tmp');temporary.write_text(json.dumps(configured))
metadata=path.stat();os.chmod(temporary,metadata.st_mode);os.chown(temporary,metadata.st_uid,metadata.st_gid)
temporary.replace(path)
print(json.dumps(dict(applied=args.apply,profiles=report),ensure_ascii=False,indent=2))
if __name__=='__main__':main()
+16 -7
View File
@@ -20,6 +20,8 @@ def config_files(source,destination):
p=Path(source)
if destination.startswith('/run/secrets/'):
return {'file':blob(p)} if p.exists() else {}
if destination=='/data' and p.is_dir():
return {name:blob(p/name) for name in ('settings.json','deck-connection.json') if (p/name).is_file()}
if destination.endswith('/Data') and p.is_dir():
files={}
for name in ('Settings.fds','Backends.fds'):
@@ -43,7 +45,7 @@ def export(manager):
for row in manager.inventory():
x=json.loads(command(['inspect',row['id']]))[0];c=x['Config'];h=x['HostConfig'];image=json.loads(command(['image','inspect',x['Image']]))[0]
registry=next((d for d in image.get('RepoDigests',[]) if '/' in d.split('@')[0] and ('.' in d.split('/')[0] or ':' in d.split('/')[0])),None)
if not registry and row['name']!='athena-swarm-ui':registry=next(iter(image.get('RepoDigests',[])),None)
if not registry and row['name'] not in ('athena-swarm-ui','athena-ladypoly'):registry=next(iter(image.get('RepoDigests',[])),None)
mounts=[]
for i,m in enumerate(x['Mounts']):
source=m['Source'];relative=None
@@ -51,7 +53,7 @@ def export(manager):
try:relative=str(Path(source).relative_to(deck))
except ValueError:pass
mounts.append(dict(index=i,target=m['Destination'],read_only=not m['RW'],deck_path=relative,files=config_files(source,m['Destination']),was_file=Path(source).is_file()))
result.append(dict(name=row['name'],image=c['Image'],registry=registry,application=c.get('Labels',{}).get(APP,''),labels={k:v for k,v in (c.get('Labels') or {}).items() if k.startswith('io.athena-deck.')},env=c.get('Env') or [],entrypoint=c.get('Entrypoint'),cmd=c.get('Cmd'),uid=app_uid(row['id'],c.get('User') or ''),user=c.get('User') or '',workdir=c.get('WorkingDir') or '',network=h['NetworkMode'],ports=h.get('PortBindings') or {},restart=h.get('RestartPolicy',{}).get('Name') or 'no',memory=h.get('Memory') or 0,nanocpus=h.get('NanoCpus') or 0,running=x['State']['Running'],mounts=mounts,build_recipe='swarm-ui' if row['name']=='athena-swarm-ui' else None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd'))))
result.append(dict(name=row['name'],image=c['Image'],registry=registry,application=c.get('Labels',{}).get(APP,''),labels={k:v for k,v in (c.get('Labels') or {}).items() if k.startswith('io.athena-deck.')},env=c.get('Env') or [],entrypoint=c.get('Entrypoint'),cmd=c.get('Cmd'),uid=app_uid(row['id'],c.get('User') or ''),user=c.get('User') or '',workdir=c.get('WorkingDir') or '',network=h['NetworkMode'],ports=h.get('PortBindings') or {},restart=h.get('RestartPolicy',{}).get('Name') or 'no',memory=h.get('Memory') or 0,nanocpus=h.get('NanoCpus') or 0,running=x['State']['Running'],mounts=mounts,build_recipe='swarm-ui' if row['name']=='athena-swarm-ui' else 'ladypoly' if row['name']=='athena-ladypoly' else 'separator-web' if row['name']=='athena-separator-web' else None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd'))))
return {'services':result,'configured':bool(deck)}
def validate(c):
@@ -59,8 +61,9 @@ def validate(c):
if any(c.get('labels',{}).get(k)!=v for k,v in LABELS.items()):raise ValueError('Nur ausdrücklich Deck zugeordnete Anwendungscontainer erlaubt.')
if c.get('unsupported'):raise ValueError('GPU-/privilegierter Container benötigt manuelle Einrichtung.')
if c.get('network') not in ('bridge','default','host'):raise ValueError('Benutzerdefiniertes Docker-Netzwerk benötigt manuelle Einrichtung.')
if c.get('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.')
if c.get('build_recipe') not in (None,'swarm-ui') or c.get('build_recipe')=='swarm-ui' and (c['name']!='athena-swarm-ui' or c.get('image')!='athena-swarm-ui:de7b834'):raise ValueError('Unbekanntes Build-Rezept.')
if c.get('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb','athena-ladypoly','athena-separator-web'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.')
recipes={'separator-web':('athena-separator-web','athena-separator-web:0.1.0'),'swarm-ui':('athena-swarm-ui','athena-swarm-ui:de7b834'),'ladypoly':('athena-ladypoly','athena-ladypoly:3960afc-deck1')}
if c.get('build_recipe') is not None and recipes.get(c['build_recipe'])!=(c['name'],c.get('image')):raise ValueError('Unbekanntes Build-Rezept.')
ref=c.get('registry') or c.get('image','')
if not isinstance(ref,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/@:-]{0,300}',ref):raise ValueError('Ungültige Image-Referenz.')
if c.get('restart') not in ('no','always','unless-stopped','on-failure'):raise ValueError('Ungültige Neustartregel.')
@@ -93,7 +96,9 @@ def validate(c):
native_media=rel in media and c['name']=='ltx-deskweb' and (target,m['read_only'])==media[rel] and not m['was_file']
if rel is not None and not native_media and (rel not in ('models','video/comfy-work/models','video/comfy-client-token') or not m['read_only']):raise ValueError('Deck-Volume nicht freigegeben; nur bekannte Modell-/Tokenpfade und LTX-Medienordner erlaubt.')
for name,data in m.get('files',{}).items():
if name not in ('file','Settings.fds','Backends.fds'):raise ValueError('Unbekannte Konfigurationsdatei.')
allowed=('file','Settings.fds','Backends.fds')
if c['name'] in ('athena-ladypoly','athena-separator-web') and m['target']=='/data':allowed+=('settings.json','deck-connection.json')
if name not in allowed:raise ValueError('Unbekannte Konfigurationsdatei.')
if not isinstance(data,str) or len(base64.b64decode(data,validate=True))>MAX_FILE:raise ValueError('Ungültige Konfigurationsdatei.')
return c
@@ -110,8 +115,10 @@ def restore(manager,c):
# Never replace or restart an existing service during a restore.
if x['Config']['Image'] not in (c['image'],c.get('registry')):raise ValueError('Vorhandener Container verwendet ein anderes Image; manuell prüfen.')
return {'state':'reused','message':'Vorhandener markierter Container unverändert übernommen.'}
if c.get('build_recipe')=='swarm-ui':
source=Path(__file__).parent/'swarm-ui'
if c['name'] in ('athena-ladypoly','athena-separator-web'):
with socket.socket() as probe:probe.bind(('127.0.0.1',8127 if c['name']=='athena-ladypoly' else 8128))
if c.get('build_recipe') in ('swarm-ui','ladypoly','separator-web'):
source=Path(__file__).parent/c['build_recipe']
if not (source/'Dockerfile').is_file():raise ValueError('Gepinntes Swarm-Build-Rezept fehlt im Systemhelfer.')
command(['build','-t',c['image'],str(source)],1800)
else:
@@ -135,6 +142,8 @@ def restore(manager,c):
if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.')
base.mkdir(parents=True,exist_ok=True,mode=0o700)
args=['create','--name',name,'--network',c['network'],'--restart',c['restart'],'--cap-drop','ALL','--security-opt','no-new-privileges:true','--pids-limit','256']
if c['name'] in ('athena-ladypoly','athena-separator-web'):
args+=['--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size='+('1g' if c['name']=='athena-separator-web' else '128m')+',uid=65534,gid=65534']
if c['memory']:args+=['--memory',str(c['memory'])]
if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)]
for k,v in c['labels'].items():
+13
View File
@@ -141,8 +141,21 @@ class Manager:
result=run(['/usr/bin/docker','start',config['container_id']] if start else ['/usr/bin/docker','stop','--time','30',config['container_id']],timeout=45)
if result.returncode:raise ValueError('Videodienst konnte nicht gestartet oder beendet werden.')
return self.video_status(config)
def container_action(self,ident,action):
if action not in ('start','stop','delete') or not isinstance(ident,str) or not re.fullmatch('[a-f0-9]{64}',ident):raise ValueError('Ungültige Containeraktion.')
with self.lock:
row=next((c for c in self.inventory() if c['id']==ident),None)
if row is None:raise ValueError('Container nicht für Deck freigegeben oder nicht mehr vorhanden.')
if any(c['container_id']==ident for c in self.video_configs()):raise ValueError('Registrierte Video-Laufzeiten über die Steuerung verwalten.')
if action=='delete' and row['state'] not in ('exited','created','dead'):raise ValueError('Container vor dem Löschen stoppen.')
args=['/usr/bin/docker']+(['stop','--time','30',ident] if action=='stop' else ['rm',ident] if action=='delete' else ['start',ident])
result=run(args,timeout=45)
if result.returncode:raise ValueError('Containeraktion fehlgeschlagen. Status erneut prüfen.')
return dict(changed=True,action=action,id=ident)
def dispatch(self,data):
if not isinstance(data,dict):raise ValueError('Ungültige Helferanfrage.')
if set(data)=={'action','service'} and data['action'] in ('container-start','container-stop','container-delete'):
return self.container_action(data['service'],data['action'].removeprefix('container-'))
if data.get('action')=='backup-export' and set(data)=={'action'}:
import docker_backup
return docker_backup.export(self)
+6 -3
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.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','deploy/image-requirements.lock']
FILES = ['separator_sources.json','deploy/separator-web/Dockerfile','deploy/separator-web/app.py','deploy/separator-web/index.html','separator.py','separator_runtime.py','separator_worker.py','separator-ui.js','deploy/ladypoly/Dockerfile','deploy/ladypoly/bridge.py','music.py','music-ui.js','audio_cpp_runtime.py','audio-cpp-ui.js','ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.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','deploy/image-requirements.lock']
def run(*args, check=True, interactive=False):
@@ -95,7 +95,7 @@ def launch(config):
base=Path(config['base'])
args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base),
'--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true',
'--pids-limit','256' if config.get('image_runtime') else '128','--memory','32g' if config.get('image_runtime') else '8g','--cpus','2', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m',
'--pids-limit','256' if config.get('image_runtime') else '128','--memory','32g' if config.get('image_runtime') else '8g','--cpus','6', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m',
'-v',str(base/'state')+':/var/lib/deck:rw','--mount','type=bind,src=/proc,dst=/host/proc,readonly','-e','DECK_HOST_PROC=/host/proc','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']),
'-p','127.0.0.1:'+str(config['port'])+':8108',
'--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"',
@@ -204,7 +204,7 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals
backup_name=config['name']+'-previous'
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
stamp=str(time.time_ns())
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*'))
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('music-verification','music-test-temp','music-jobs','separator-runtime','separator-tests','audio-cpp-runtime','ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*'))
was_running=previous['State']['Running']
if was_running:run('docker','stop','--time','15',config['name'])
run('docker','rename',config['name'],backup_name)
@@ -218,6 +218,9 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals
if was_running:run('docker','start',config['name'])
raise RuntimeError('Update fehlgeschlagen. Vorheriger Deck-Container wiederhergestellt.') from None
run('docker','rm',backup_name)
# Keep only two automatic pre-update state snapshots after a successful update.
snapshots=sorted((p for p in (base/'backups').iterdir() if p.is_dir() and not p.is_symlink() and p.name.isdigit()),key=lambda p:int(p.name))
for snapshot in snapshots[:-2]:shutil.rmtree(snapshot)
print('Nur Athena Deck wurde aktualisiert. Vorheriges Image bleibt für --rollback erhalten.')
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-slim
ARG UPSTREAM_COMMIT=3960afc309efcef5f4f570209cf01160b96b1ea3
RUN apt-get update && apt-get install -y --no-install-recommends git libsndfile1 && rm -rf /var/lib/apt/lists/* \
&& git init /app && cd /app && git remote add origin https://github.com/Ladypoly/YuE2_WebUI.git \
&& git fetch --depth 1 origin ${UPSTREAM_COMMIT} && git checkout --detach FETCH_HEAD \
&& rm -rf /app/.git \
&& pip install --no-cache-dir fastapi==0.115.12 uvicorn==0.34.2 python-multipart==0.0.20 numpy==2.2.6 soundfile==0.13.1
COPY bridge.py /app/webui/deck_bridge.py
RUN python -c "from pathlib import Path; p=Path('/app/webui/server.py'); s=p.read_text(); s=s.replace('SETTINGS_FILE = Path(__file__).resolve().parent / \"settings.json\"','SETTINGS_FILE = Path(\"/data/settings.json\")'); s=s.replace('from loras import Loras', 'from deck_bridge import Loras'); s=s.replace('if __name__ == \"__main__\":','from deck_bridge import install\ninstall(globals())\n\nif __name__ == \"__main__\":'); p.write_text(s)"
RUN python -c "from pathlib import Path; p=Path('/app/webui/static/index.html'); s=p.read_text().replace('<body>', '<body><div style=\"padding:12px;background:#193249;color:white\">LadyPoly mit Athena Deck · <a style=\"color:#8de8ff\" href=\"/deck-connection\">Deck-Verbindung einrichten</a> · In Deck Musik aktivieren. Worker-Abbruch in Deck.</div>'); s=s.replace('value=\"full\" checked', 'value=\"full\" disabled').replace('value=\"melody\"', 'value=\"melody\" disabled').replace('value=\"off\"', 'value=\"off\" checked'); s=s.replace('</head>', '<style>#engineToggle,#muse,#coverDrawer,#scoreDrawer,#advDrawer,#cancelRun{display:none!important}</style></head>'); p.write_text(s)"
ENV PYTHONPATH=/app/src YUE2_HOST=127.0.0.1 YUE2_PORT=8127 YUE2_OUTPUTS=/data/outputs HOME=/data
WORKDIR /app
USER 65534:65534
CMD ["python", "webui/server.py"]
+52
View File
@@ -0,0 +1,52 @@
# LadyPoly YuE2 für Athena Deck
Die gepinnte LadyPoly-WebUI (Commit 3960afc309efcef5f4f570209cf01160b96b1ea3)
läuft separat ohne Torch, CUDA, Modellmounts oder Docker-Socket. Die schmale
Anbindung nutzt Decks authentifizierte Musik-API auf 127.0.0.1:8120.
Deck steuert audio.cpp, Gewichte und den exklusiven Musikmodus.
Auf Debian mit vorhandenem Docker, im Athena-Deck-Repository:
```sh
docker build -t athena-ladypoly:3960afc-deck1 deploy/ladypoly
sudo python3 deploy/ladypoly/install.py
```
Container: `athena-ladypoly`. Frei zu prüfender Port: **8127**, nur Host-Loopback.
Host-Netzwerk verbindet zur lokalen Deck-API; keine öffentliche Portfreigabe.
Labels: `io.athena-deck.managed=true`, `io.athena-deck.role=application`,
`io.athena-deck.application=ladypoly`. Diese machen ihn unter Weitere Dienste sichtbar.
Volume: `/opt/athena-ladypoly/data:/data:rw`, UID/GID 65534, Verzeichnis 0700.
Enthält Verbindungsdaten/Token (0600), UI-Einstellungen und generierte Musik.
Keine Modelle oder Runtime im Container. Image wird aus diesem Rezept gebaut;
noch kein öffentliches Registry-Image/Pull-Befehl verfügbar.
```sh
ssh -i ~/.ssh/athena_key -o BatchMode=yes -o ExitOnForwardFailure=yes -N -L 8127:127.0.0.1:8127 root@192.168.1.212
```
Dann http://127.0.0.1:8127/deck-connection öffnen. Eigenen Deck-API-Token und
exakten freigegebenen YuE2-Profilnamen eingeben. Die Verbindung wird gegen die
Musik-Modellliste geprüft. Bei Tokenwechsel hier erneut speichern.
In Deck auf Musik umschalten, anschließend in LadyPoly Stil und Liedtext
angeben und generieren. Deck-Endpunkt muss gestartet sein.
Unterstützt: Titel/Bibliothek, Liedtext, Stil, Seed, WAV-Erzeugung über Deck,
FLAC-Bibliothek und Wiedergabe. Derzeit Direct-Modus, bis 9000 semantische Tokens
und sechs Syntheseschritte. Kein Versprechen einer bestimmten Songdauer.
Writer, Cover-Erzeugung, Score, LoRA und Sampling-Overrides sind nicht angebunden;
die betreffenden Bedienelemente sind ausgeblendet/gesperrt. Abbruch eines laufenden
GPU-Jobs erfolgt in Deck; die WebUI ist kein zweiter Prozessmanager.
```sh
docker stop athena-ladypoly
docker start athena-ladypoly
```
Der Deck-Backuphelfer kennt das gepinnte Build-Rezept und sichert die beiden
Konfigurationsdateien im verschlüsselten Backup. Ergebnisse unter outputs sind
Nutzdaten und werden nicht exportiert. Beim Restore wird das Image wieder gebaut;
der Port muss frei sein. Deck-API-Portänderungen erfordern derzeit eine Anpassung
von `BASE` in bridge.py und einen Image-Neubau.
Upstream-Lizenz und Hinweise verbleiben unter /app im Image.
+73
View File
@@ -0,0 +1,73 @@
"""LadyPoly UI -> authenticated Deck music API. No local model engine."""
import json, os, time, urllib.request, urllib.error
from pathlib import Path
from fastapi import HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
CONNECTION = Path('/data/deck-connection.json')
BASE = 'http://127.0.0.1:8120'
def request(path, data=None, connection=None):
c = connection or (json.loads(CONNECTION.read_text()) if CONNECTION.exists() else {})
if not c.get('token'): raise RuntimeError('Zuerst unter Deck-Verbindung den API-Token hinterlegen.')
req = urllib.request.Request(BASE + path, data=json.dumps(data).encode() if data is not None else None,
headers={'Authorization': 'Bearer ' + c['token'], 'Content-Type':'application/json'})
try:
with urllib.request.urlopen(req, timeout=1850) as response: return response.read()
except urllib.error.HTTPError as exc:
try: message=json.loads(exc.read()).get('error', {}).get('message', 'Deck-Anfrage abgelehnt')
except Exception: message='Deck-Anfrage abgelehnt'
raise RuntimeError(message) from None
class Loras:
def __init__(self, root):self.extra_dirs=[]
def resolve(self, values):return [], []
def status(self, values):return {'adapters':[], 'selected':[], 'missing':[], 'dirs':[]}
def discover(self):return []
class Connection(BaseModel):
token: str
model: str
PAGE='''<!doctype html><meta charset="utf-8"><style>body{background:#12151c;color:#eee;font:18px system-ui;max-width:720px;margin:50px auto}input,button{padding:12px;display:block;margin:15px 0;width:90%}a{color:#72d6ff}</style><h1>LadyPoly · Deck-Verbindung</h1><p>In Deck das YuE2-Profil am API-Endpunkt freigeben und den Musik-Modus aktivieren. Hier den API-Token aus „Zugang & API“ und den genauen Profilnamen eingeben.</p><form id="f"><input id="token" type="password" placeholder="Deck API-Token" required autocomplete="off"><input id="model" placeholder="API-Profilname, z. B. YuE2 Studio" required><button>Verbindung prüfen und speichern</button></form><p id="msg"></p><a href="/">LadyPoly öffnen →</a><p>Diese Anbindung unterstützt Liedtext, Stil, Seed und Syntheseschritte. Score, LoRA, eigener Writer und Cover-Generator sind hier noch nicht angebunden. Deck lädt und entlädt die Gewichte.</p><script>f.onsubmit=async e=>{e.preventDefault();msg.textContent='Prüfe …';try{let r=await fetch('/api/deck/connection',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:token.value,model:model.value})});let d=await r.json();msg.textContent=r.ok?'Verbunden. Jetzt LadyPoly öffnen.':d.detail;token.value=''}catch(e){msg.textContent='Verbindung fehlgeschlagen'}}</script>'''
def install(g):
app=g['app'];g['SETTINGS'].song_engine='gguf';g['SETTINGS'].art_auto=False;g['SETTINGS'].stall_timeout=0
@app.get('/deck-connection', response_class=HTMLResponse)
def connection_page():return PAGE
@app.post('/api/deck/connection')
def connection_save(body: Connection):
c=body.model_dump()
if not 1<=len(body.token)<=512:raise HTTPException(400,'Ungültiger Token.')
try:
models=json.loads(request('/v1/audio/music/models',connection=c))['data']
if body.model not in [x['id'] for x in models]:raise RuntimeError('Profil nicht freigegeben oder nicht ausführbar. Bitte in Deck prüfen.')
except Exception as e:raise HTTPException(400,str(e)) from None
CONNECTION.write_text(json.dumps(c));CONNECTION.chmod(0o600)
return {'ok':True}
def render(job,spec):
if spec.get('abc') or spec.get('cfg_scale') is not None or spec.get('abc_sampling') or spec.get('semantic_sampling') or g['SETTINGS'].loras:
raise RuntimeError('Score, Sampling-Overrides und LoRA sind in der Deck-Anbindung noch nicht verfügbar.')
c=json.loads(CONNECTION.read_text()) if CONNECTION.exists() else {}
spec['cot']='off'
job.setup='Deck lädt YuE2 und erzeugt Musik · Musik-Modus erforderlich';job.setup_at=time.time();job.push(force=True)
start=time.monotonic()
audio=request('/v1/audio/music',dict(model=c.get('model',''),lyrics=spec['lyrics'],style=spec['style'],seed=spec['seed'],steps=g['SETTINGS'].ode_steps,max_tokens=9000))
if job.cancel_flag.is_set():raise InterruptedError('UI-Auftrag abgebrochen. Laufenden Deck-Job in Deck stoppen.')
work=g['OUTPUTS']/('.pending-'+job.id);work.mkdir(parents=True,exist_ok=True)
wav=work/'audio.wav';wav.write_bytes(audio)
return g['GgufSong'](spec,wav,{'e2e_seconds':time.monotonic()-start},'',{'runtime':'Athena Deck audio.cpp','profile':c.get('model')})
g['render_gguf']=render;g['render_torch']=render
g['ENGINE'].detail='Remote-Laufzeit in Athena Deck · Musik-Modus erforderlich'
@app.middleware('http')
async def deck_guard(req,call_next):
path=req.url.path
origin=req.headers.get('Origin')
if req.method!='GET' and origin and origin not in ('http://'+req.headers.get('Host',''),'https://'+req.headers.get('Host','')):
return g['JSONResponse']({'detail':'Cross-Origin-Zugriff nicht erlaubt.'},status_code=403)
if req.method!='GET' and not (path in {'/api/generate','/api/deck/connection','/api/unlock'} or (req.method=='DELETE' and path.startswith('/api/library/') and path.count('/')==3)):
return g['JSONResponse']({'detail':'Laufzeiten, Modelle und Einstellungen werden in Athena Deck verwaltet.'},status_code=409)
response=await call_next(req)
response.headers['Cache-Control']='no-store'
return response
+17
View File
@@ -0,0 +1,17 @@
"""Run as root on Athena. Only creates the explicit optional LadyPoly UI."""
import os, socket, subprocess
from pathlib import Path
base=Path('/opt/athena-ladypoly/data');name='athena-ladypoly';image='athena-ladypoly:3960afc-deck1'
if subprocess.run(['docker','inspect',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:
raise SystemExit('LadyPoly container already exists; no automatic replacement.')
with socket.socket() as s:
s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
s.bind(('127.0.0.1',8127))
base.mkdir(parents=True,exist_ok=True);os.chown(base,65534,65534);base.chmod(0o700)
subprocess.check_call(['docker','run','-d','--name',name,'--network','host','--restart','unless-stopped','--read-only',
'--cap-drop','ALL','--security-opt','no-new-privileges:true','--memory','1g','--cpus','2',
'--tmpfs','/tmp:rw,noexec,nosuid,size=128m,uid=65534,gid=65534',
'--label','io.athena-deck.managed=true','--label','io.athena-deck.role=application',
'--label','io.athena-deck.application=ladypoly','--label','io.athena-deck.port=8127',
'-v',str(base)+':/data:rw',image])
print('LadyPoly UI: http://127.0.0.1:8127/deck-connection')
+30
View File
@@ -0,0 +1,30 @@
"""Synthetic bridge contract; no real API token or GPU job."""
import io,json,tempfile,unittest,wave
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import server
import deck_bridge as bridge
class BridgeTest(unittest.TestCase):
def test_forward_and_audio_library(self):
with tempfile.TemporaryDirectory() as temp:
root=Path(temp);connection=root/'connection.json';connection.write_text(json.dumps({'model':'Synthetic Music','token':'synthetic'}))
raw=io.BytesIO()
with wave.open(raw,'wb') as wav:wav.setnchannels(2);wav.setsampwidth(2);wav.setframerate(48000);wav.writeframes(b'\0'*192000)
job=SimpleNamespace(id='synthetic',cancel_flag=SimpleNamespace(is_set=lambda:False),push=lambda **k:None)
spec={'style':'synthetic piano','lyrics':'synthetic test','seed':123,'id':'synthetic','cot':'off'}
with patch.object(bridge,'CONNECTION',connection),patch.object(server,'OUTPUTS',root),patch.object(bridge,'request',return_value=raw.getvalue()) as request:
result=server.render_gguf(job,spec)
payload=request.call_args.args[1]
self.assertEqual(payload['model'],'Synthetic Music');self.assertEqual(payload['steps'],6)
self.assertEqual(payload['max_tokens'],9000);self.assertNotIn('abc',payload)
saved=result.save_artifacts(root/'take');self.assertEqual(saved['audio_seconds'],1)
self.assertTrue((root/'take/audio.flac').is_file())
def test_unsupported_score_does_not_start_deck(self):
with patch.object(bridge,'request') as request:
with self.assertRaisesRegex(RuntimeError,'Score'):
server.render_gguf(None,{'abc':'synthetic'})
request.assert_not_called()
if __name__=='__main__':unittest.main()
+6
View File
@@ -0,0 +1,6 @@
FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY app.py index.html ./
USER 65534:65534
CMD ["python","app.py"]
+33
View File
@@ -0,0 +1,33 @@
# Athena Audio-Trennung Web-GUI
Die bisherige Athena-GUI als CPU-only Container. Keine Modelle, CUDA oder Docker-Socket im Container. Native Verarbeitung durch Decks audio-separator 0.47.0.
Build: `docker build -t athena-separator-web:0.1.0 deploy/separator-web`
Start auf Debian nach Prüfung des freien Ports:
```sh
sudo install -d -o 65534 -g 65534 -m 700 /opt/athena-separator-web/data
docker run -d --name athena-separator-web --network host --restart unless-stopped \
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
--memory 2g --cpus 2 --tmpfs /tmp:rw,noexec,nosuid,size=1g,uid=65534,gid=65534 \
--label io.athena-deck.managed=true --label io.athena-deck.role=application \
--label io.athena-deck.application=separator-web --label io.athena-deck.port=8128 \
-v /opt/athena-separator-web/data:/data:rw athena-separator-web:0.1.0
```
GUI bindet nur 127.0.0.1:8128; Verbindung zu Decks Management-API auf 127.0.0.1:8108. In der Steuerung zuerst Audio-Trennung starten; der Modus beendet Deck-eigene GPU-Worker und sperrt konkurrierende KI-Aufträge. In der GUI einmal Deck-API-Token eintragen. Er wird serverseitig mit Modus 0600 gespeichert und niemals zurückgegeben. Loopback/SSH-Tunnel benutzen.
Mac-Tunnel: `ssh -i ~/.ssh/athena_key -o BatchMode=yes -N -L 8128:127.0.0.1:8128 root@192.168.1.212`
URL http://127.0.0.1:8128/ . Stoppen/starten/löschen über Weitere Dienste oder docker stop/start/rm athena-separator-web. Volume enthält ausschließlich Verbindungsdaten. Audio wird temporär konvertiert; Deck-Ergebnisse sind nicht im Konfigurationsbackup enthalten.
Unterstützt Gesang (BS-Roformer), Schlagzeug/Bass (Demucs FT), Gitarre/Klavier/Rest (Demucs 6s). Kein ClearVoice. Upload bis 256 MiB, maximal 20 Minuten. Native Worker laufen pro Auftrag; dauerhaftes Warmhalten ist noch nicht implementiert. Bei fremder GPU-Belegung wird abgelehnt, keine fremden Dienste werden beendet.
Modellquellen einschließlich aller Ensemble-Dateien und SHA-256 stehen in separator_sources.json. Paket-Downloads und Restore prüfen alle Hashes. Quelle darf GitHub/UVR oder Meta sein; es wird keine Hugging-Face-Herkunft erfunden. Lokale Übernahme erhält IDs und wird separat als Übernahme dargestellt. Unbekannte lokale Dateien benötigen weiterhin einen belegten Herkunftsabgleich.
Backup speichert Paketrezepte, Quellen und Container-Konfiguration. Der Docker-Helfer kann das hier hinterlegte Image-Rezept auf einer leeren Installation wieder bauen; Gewichte werden von den geprüften Quellen erneut bezogen.
ZIP-Ausgaben heißen bei Gesang `Vocals.wav` und `Instrumental.wav`; andere Ziele
verwenden `Drums.wav`, `Bass.wav`, `Guitar.wav`, `Piano.wav` oder `Other.wav`
zusammen mit `Rest.wav`, ohne technische Modell- oder Eingabedateinamen.
+82
View File
@@ -0,0 +1,82 @@
"""Old Athena separation UI, remote Deck processing; no CUDA or models here."""
import io,json,os,secrets,subprocess,tempfile,time,urllib.request,urllib.error,zipfile
from pathlib import Path
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from email import policy
from email.parser import BytesParser
BASE='http://127.0.0.1:8108'
CONFIG=Path('/data/deck-connection.json')
TARGETS={'vocals':'model_bs_roformer_ep_317_sdr_12.9755.ckpt','drums':'htdemucs_ft.yaml','bass':'htdemucs_ft.yaml','guitar':'htdemucs_6s.yaml','piano':'htdemucs_6s.yaml','other':'htdemucs_6s.yaml'}
def archive_name(target,filename):
if target not in TARGETS:raise ValueError('Unbekannte Zielspur.')
if Path(filename).name=='rest.wav':return 'Instrumental.wav' if target=='vocals' else 'Rest.wav'
return {'vocals':'Vocals','drums':'Drums','bass':'Bass','guitar':'Guitar','piano':'Piano','other':'Other'}[target]+'.wav'
def request(path,body=None,ctype='application/json',token=None):
if token is None:token=json.loads(CONFIG.read_text())['token'] if CONFIG.exists() else ''
if not token:raise ValueError('Zuerst die Deck-Verbindung einrichten.')
req=urllib.request.Request(BASE+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':ctype})
try:
with urllib.request.urlopen(req,timeout=90) as response:return response.read()
except urllib.error.HTTPError as exc:
try:message=json.loads(exc.read()).get('error','Deck-Anfrage abgelehnt')
except Exception:message='Deck-Anfrage abgelehnt'
raise ValueError(message if isinstance(message,str) else message.get('message','Deck-Anfrage abgelehnt')) from None
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def send(self,body,status=200,mime='application/json'):
if isinstance(body,dict):body=json.dumps(body).encode()
self.send_response(status);self.send_header('Content-Type',mime);self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff');self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'");self.end_headers();self.wfile.write(body)
def do_GET(self):
if self.path=='/health':return self.send({'status':'ok','connected':CONFIG.exists()})
if self.path=='/':return self.send(Path('/app/index.html').read_bytes(),mime='text/html; charset=utf-8')
if self.path=='/api/status':
try:return self.send(request('/api/v1/separator-runtime'))
except ValueError as exc:return self.send({'detail':str(exc)},400)
return self.send({'detail':'Nicht gefunden'},404)
def do_POST(self):
origin=self.headers.get('Origin');host=self.headers.get('Host','')
if self.headers.get('X-Athena-UI')!='1' or origin and origin!='http://'+host:return self.send({'detail':'Same-origin erforderlich'},403)
try:
length=int(self.headers.get('Content-Length','0'))
if self.headers.get('Transfer-Encoding') or not 0<length<=256*1024**2:raise ValueError('Upload maximal 256 MiB.')
self.connection.settimeout(60);raw=self.rfile.read(length)
if len(raw)!=length:raise ValueError('Upload unvollständig.')
if self.path=='/api/connection':
if length>4096:raise ValueError('Verbindungsdaten zu groß.')
value=json.loads(raw);token=value.get('token','')
if not isinstance(token,str) or not 1<=len(token)<=256:raise ValueError('Ungültiger Token.')
request('/api/v1/separator-runtime',token=token)
CONFIG.parent.mkdir(exist_ok=True);tmp=CONFIG.with_suffix('.tmp');tmp.write_text(json.dumps({'token':token}));tmp.chmod(0o600);tmp.replace(CONFIG)
return self.send({'ok':True})
if self.path!='/v1/separate':return self.send({'detail':'Nicht gefunden'},404)
ctype=self.headers.get('Content-Type','')
if not ctype.startswith('multipart/form-data;'):raise ValueError('Audiodatei fehlt.')
msg=BytesParser(policy=policy.default).parsebytes(b'Content-Type: '+ctype.encode()+b'\r\n\r\n'+raw)
fields={}
if not msg.is_multipart() or msg.defects:raise ValueError('Ungültiger Upload.')
for part in msg.iter_parts():
name=part.get_param('name',header='content-disposition')
if name not in ('file','target') or name in fields:raise ValueError('Ungültiges Upload-Feld.')
fields[name]=part.get_payload(decode=True)
target=fields.get('target',b'').decode()
if target not in TARGETS:raise ValueError('Zielspur noch nicht angebunden.')
with tempfile.TemporaryDirectory() as work:
source=Path(work)/'input';source.write_bytes(fields['file']);wav=Path(work)/'input.wav'
subprocess.run(['ffmpeg','-nostdin','-hide_banner','-loglevel','error','-y','-i',str(source),'-t','1201','-ac','2','-ar','44100','-c:a','pcm_s16le',str(wav)],check=True,timeout=120,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
data=wav.read_bytes();boundary=secrets.token_hex(24)
body=b''
for name,value in [('model',TARGETS[target]),('target',target)]:body+=f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
body+=f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="input.wav"\r\nContent-Type: audio/wav\r\n\r\n'.encode()+data+f'\r\n--{boundary}--\r\n'.encode()
job=json.loads(request('/api/v1/separator-jobs/start',body,'multipart/form-data; boundary='+boundary));ident=job['id'];deadline=time.monotonic()+7250
while time.monotonic()<deadline:
time.sleep(2);current=json.loads(request('/api/v1/separator-tests'))['job']
if not current or current['id']!=ident:raise ValueError('Auftrag wurde ersetzt.')
if current['state']=='running':continue
if current['state']!='complete':raise ValueError(current['phase'])
result=io.BytesIO()
with zipfile.ZipFile(result,'w',zipfile.ZIP_STORED) as bundle:
for f in current['files']:bundle.writestr(archive_name(target,f['name']),request(f"/api/v1/separator-tests/audio?id={ident}&index={f['index']}"))
return self.send(result.getvalue(),mime='application/zip')
raise ValueError('Trennauftrag hat das Zeitlimit überschritten.')
except Exception as exc:return self.send({'detail':str(exc) if isinstance(exc,ValueError) else 'Verarbeitung fehlgeschlagen; Verbindung und Audioformat prüfen.'},400)
if __name__=='__main__':ThreadingHTTPServer(('127.0.0.1',8128),Handler).serve_forever()
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Athena · Spuren herauslösen</title><style>
:root{color-scheme:dark;--bg:#07111c;--card:#101d2b;--line:#26384b;--cyan:#48d7f5;--mint:#63e6be;--text:#ecf5ff;--muted:#91a4b7}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#142a42 0,#07111c 42%);font:16px system-ui,sans-serif;color:var(--text);min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(880px,100%);padding:32px;border:1px solid var(--line);border-radius:22px;background:rgba(16,29,43,.96);box-shadow:0 25px 70px #0008}.eyebrow{color:var(--cyan);font-weight:800;letter-spacing:.14em;text-transform:uppercase;font-size:12px}h1{font-size:clamp(30px,5vw,52px);margin:.3em 0 .15em}p{color:var(--muted);line-height:1.6}.targets{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin:24px 0}.target{display:block;border:1px solid var(--line);border-radius:14px;padding:14px 10px;text-align:center;cursor:pointer}.target:has(input:checked){border-color:var(--cyan);background:#48d7f510;box-shadow:0 0 0 1px #48d7f528}.target input{display:none}.target b,.target span{display:block}.target span{color:var(--muted);font-size:12px;margin-top:5px;line-height:1.35}.section{grid-column:1/-1;color:var(--cyan);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;margin-top:8px}.drop{display:block;margin:20px 0;padding:36px 24px;border:2px dashed #3f5a72;border-radius:18px;text-align:center;cursor:pointer;transition:.2s}.drop:hover,.drop.drag{border-color:var(--cyan);background:#48d7f50b}.drop input{display:none}.file{color:var(--mint);font-weight:700;margin-top:8px}button{width:100%;border:0;border-radius:13px;padding:15px;font-weight:800;font-size:16px;background:linear-gradient(90deg,var(--cyan),var(--mint));color:#05202a;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.status{min-height:28px;margin-top:18px;color:var(--muted)}.bar{height:7px;background:#07111c;border-radius:9px;overflow:hidden;margin-top:12px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--mint));transition:.4s}.run .fill{width:85%;animation:pulse 1.5s infinite alternate}@keyframes pulse{to{opacity:.45}}small{display:block;color:#71879a;margin-top:20px}@media(max-width:760px){.targets{grid-template-columns:repeat(2,1fr)}}
</style></head><body><main class="card"><div class="eyebrow">Athena Audio Lab</div><h1>Was möchtest du herauslösen?</h1><p>Der Download enthält immer die gewählte Spur separat und zusätzlich den vollständigen Rest ohne diese Spur.</p>
<details><summary>Deck-Verbindung einrichten</summary><p>API-Token aus Athena Deck → Zugang &amp; API-Token. Er bleibt nur auf dem Server gespeichert.</p><input id="token" type="password" autocomplete="off" placeholder="Deck API-Token"><button id="connect">Verbinden</button></details><p id="packages">Modellpakete werden geprüft …</p><div class="targets">
<div class="section">Musik</div>
<label class="target"><input type="radio" name="target" value="vocals" checked><b>Gesang</b><span>BS‑RoFormer<br>Gesang / Instrumental</span></label>
<label class="target"><input type="radio" name="target" value="drums"><b>Schlagzeug</b><span>HTDemucs FT</span></label>
<label class="target"><input type="radio" name="target" value="bass"><b>Bass</b><span>HTDemucs FT</span></label>
<label class="target"><input type="radio" name="target" value="guitar"><b>Gitarre</b><span>HTDemucs 6s<br>experimentell</span></label>
<label class="target"><input type="radio" name="target" value="piano"><b>Piano</b><span>HTDemucs 6s<br>experimentell</span></label>
<label class="target"><input type="radio" name="target" value="other"><b>Sonstiges</b><span>Synths, Streicher etc.<br>gemischte Spur</span></label>
</div>
<label class="drop" id="drop">Audio auswählen oder hier ablegen<input id="file" type="file" accept="audio/*"><div class="file" id="name">Noch keine Datei gewählt</div></label><button id="start" disabled>Ausgewählte Spur und Rest erzeugen</button><div class="status" id="status">Bereit.</div><div class="bar" id="bar"><div class="fill"></div></div><small>Verarbeitung durch Athena Deck. Bis 20 Minuten und 256 MiB; Ausgabe als WAV-ZIP. Modell wird nach dem Auftrag entladen. Synthesizer, Streicher sowie elektrische und akustische Gitarre separat benötigen zusätzliche Spezialmodelle.</small></main><script>
const file=document.querySelector('#file'),drop=document.querySelector('#drop'),name=document.querySelector('#name'),start=document.querySelector('#start'),status=document.querySelector('#status'),bar=document.querySelector('#bar');let selected;async function packages(){let r=await fetch('/api/status'),s=await r.json();document.querySelector('#packages').textContent=r.ok?s.models.map(m=>`${m.name}: ${m.installed?'bereit':'fehlt – in Deck herunterladen'}`).join(' · '):s.detail}document.querySelector('#connect').onclick=async()=>{let token=document.querySelector('#token');let r=await fetch('/api/connection',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-UI':'1'},body:JSON.stringify({token:token.value})});let s=await r.json();token.value='';status.textContent=r.ok?'Deck verbunden.':s.detail;packages()};packages();const names={vocals:'gesang',drums:'schlagzeug',bass:'bass',guitar:'gitarre',piano:'piano',other:'sonstiges',speech:'sprache-und-hintergrund'};function choose(f){selected=f;name.textContent=f?`${f.name} · ${(f.size/1048576).toFixed(1)} MiB`:'Noch keine Datei gewählt';start.disabled=!f}file.onchange=()=>choose(file.files[0]);drop.ondragover=e=>{e.preventDefault();drop.classList.add('drag')};drop.ondragleave=()=>drop.classList.remove('drag');drop.ondrop=e=>{e.preventDefault();drop.classList.remove('drag');choose(e.dataTransfer.files[0])};start.onclick=async()=>{const target=document.querySelector('input[name=target]:checked').value;start.disabled=true;bar.classList.add('run');status.textContent=target==='speech'?'MossFormer2 trennt Sprache und Hintergrund – das kann einige Minuten dauern …':'Modell löst die gewählte Spur heraus – das kann einige Minuten dauern …';let body=new FormData();body.append('file',selected);body.append('target',target);try{let r=await fetch('/v1/separate',{method:'POST',headers:{'X-Athena-UI':'1'},body});if(!r.ok)throw Error((await r.json()).detail||`HTTP ${r.status}`);let blob=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=target==='vocals'?'athena-vocals-instrumental.zip':`athena-${names[target]}-und-rest.zip`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status.textContent='Fertig – ZIP mit der ausgewählten Spur und dem Rest wurde geladen.'}catch(e){status.textContent=`Fehler: ${e.message}`}finally{bar.classList.remove('run');start.disabled=false}};
</script></body></html>
+6
View File
@@ -25,6 +25,12 @@ def main():
(base/'swarm-ui').mkdir(exist_ok=True)
for name in ('Dockerfile','patch-source.py','proxy.py','entrypoint.py'):
shutil.copyfile(Path(__file__).parent/'swarm-ui'/name,base/'swarm-ui'/name)
(base/'ladypoly').mkdir(exist_ok=True)
for name in ('Dockerfile','bridge.py'):
shutil.copyfile(Path(__file__).parent/'ladypoly'/name,base/'ladypoly'/name)
(base/'separator-web').mkdir(exist_ok=True)
for name in ('Dockerfile','app.py','index.html'):
shutil.copyfile(Path(__file__).parent/'separator-web'/name,base/'separator-web'/name)
if args.deck_state:
registered=Path(args.deck_state).resolve()
if not registered.is_dir():raise SystemExit('Deck-Zustandsverzeichnis fehlt.')
+21 -4
View File
@@ -1,14 +1,31 @@
window.DockerUI=(()=>{
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const apps={
'athena-ladypoly':{title:'LadyPoly YuE2',description:'Weboberfläche für Decks Musiklaufzeit.',port:8127,hint:'In der Steuerung Musik starten. Deck-Verbindung in LadyPoly einrichten.'},
'athena-separator-web':{title:'Athena Separator Web',description:'Gesang oder Instrumente herauslösen; Verarbeitung durch Decks native Audio-Separator-Laufzeit.',port:8128,hint:'In der Steuerung Audio-Trennung starten. Modelle unter Audio-Trennung einrichten.'},
'ltx-deskweb':{title:'LTX DeskWEB',description:'LTX-Webstudio für Videoerzeugung über Decks originale LTX-Laufzeit.',port:8118,hint:'Videoprofil freigeben und in der Steuerung Video starten.'},
'athena-swarm-ui':{title:'SwarmUI',description:'API By URL · Oberfläche für Decks ComfyUI-Laufzeit.',port:8125,hint:'In der Steuerung Video mit ComfyUI starten.'}
};
function appInfo(c){const a=apps[c.name];return a?`<p>${esc(a.description)}</p><a class="link" href="${esc(location.protocol+'//'+location.hostname+':'+a.port+'/')}" target="_blank" rel="noopener">${esc(a.title)} öffnen →</a><p class="small">${esc(a.hint)} SSH-Tunnel: Port ${a.port}.</p>`:'';}
async function api(action='',data){const r=await fetch('/api/v1/docker'+action,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});if(r.status===401){location.reload();throw Error('Bitte erneut anmelden.');}const value=await r.json();if(!r.ok)throw Error(value.error||'Docker-Anfrage fehlgeschlagen');return value;}
function html(services=false){return `<div class="kicker">${services?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}</div><h1>${services?'Weitere Dienste':'Docker'}</h1><p>${services?'Zusätzliche Oberflächen werden getrennt von Modellen und Laufzeiten verwaltet. Hier erscheinen ausschließlich ausdrücklich für Deck markierte Anwendungscontainer.':'Optionale Umgebung für zusätzliche Studios. Native Modelllaufzeiten bleiben unabhängig davon.'}</p><section id="docker-panel"></section><p id="docker-message" role="status"></p>`;}
function bind(services=false){const panel=document.querySelector('#docker-panel'),message=document.querySelector('#docker-message');let signature='',pending=false;
async function refresh(){try{const s=await api();if(!panel.isConnected)return;const next=JSON.stringify(s);if(signature!==next){signature=next;const running=s.job?.state==='running';
if(services){panel.innerHTML=`<section class="card"><h2>Explizite Zuordnung</h2><p>Beide Container-Labels müssen gesetzt sein:</p><pre><code>io.athena-deck.managed=true\nio.athena-deck.role=application</code></pre><p class="small">Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.</p><a class="link" href="#docker-runtime">Docker-Verbindung prüfen →</a></section>${!s.helper_available||!s.reachable?'<section class="empty"><h2>Docker nicht verbunden</h2><p>'+esc(s.message)+'</p></section>':s.services.length?'<div class="grid">'+s.services.map(c=>`<section class="card"><span class="pill">DOCKER · DECK-ANWENDUNG</span><h2>${esc(c.name)}</h2><p>${esc(c.image)}</p><p><strong>${esc(c.state)}</strong> · ${esc(c.status)}</p>${c.name==='athena-swarm-ui'?`<p>API By URL · Deck steuert die Video-Laufzeit</p><a class="link" href="${esc(location.protocol+'//'+location.hostname+':8125/')}" target="_blank" rel="noopener">SwarmUI öffnen →</a><p class="small">In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.</p>`:''}</section>`).join('')+'</div>':'<section class="empty"><h2>Noch keine Deck-Anwendungen</h2><p>Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.</p></section>'}`;}
function bind(services=false){const panel=document.querySelector('#docker-panel'),message=document.querySelector('#docker-message');let signature='',pending=false,timer;
async function refresh(){clearTimeout(timer);try{if(pending)return;const s=await api();if(!panel.isConnected)return;const next=JSON.stringify(s);if(signature!==next){signature=next;const running=s.job?.state==='running';
if(services){panel.innerHTML=`<section class="card"><h2>Explizite Zuordnung</h2><p>Beide Container-Labels müssen gesetzt sein:</p><pre><code>io.athena-deck.managed=true\nio.athena-deck.role=application</code></pre><p class="small">Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Starten und Stoppen betrifft nur markierte Container. Löschen entfernt den Container einschließlich seiner beschreibbaren Schicht; Images und Volumes bleiben erhalten.</p><a class="link" href="#docker-runtime">Docker-Verbindung prüfen →</a></section>${!s.helper_available||!s.reachable?'<section class="empty"><h2>Docker nicht verbunden</h2><p>'+esc(s.message)+'</p></section>':s.services.length?'<div class="grid">'+s.services.map(c=>`<section class="card"><span class="pill">DOCKER · DECK-ANWENDUNG</span><h2>${esc(c.name)}</h2><p>${esc(c.image)}</p><p><strong>${esc(c.state)}</strong> · ${esc(c.status)}</p>${appInfo(c)}<div class="card-actions"><button data-container-action="start" data-container-id="${esc(c.id)}" ${c.state==='running'?'disabled':''}>Starten</button><button class="secondary" data-container-action="stop" data-container-id="${esc(c.id)}" ${c.state!=='running'?'disabled':''}>Stoppen</button><button class="secondary danger" data-container-action="delete" data-container-id="${esc(c.id)}" ${!['exited','created','dead'].includes(c.state)?'disabled':''}>Löschen</button></div></section>`).join('')+'</div>' :'<section class="empty"><h2>Noch keine Deck-Anwendungen</h2><p>Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.</p></section>'}`;}
else {panel.innerHTML=`<section class="card"><span class="pill">${s.installed?'INSTALLIERT':s.installed===false?'NICHT INSTALLIERT':'NICHT ERMITTELBAR'}</span><h2>Docker Engine ${esc(s.version||'')}</h2><p>${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}</p><p>${esc(s.message)}</p>${s.job?`<p role="status">${esc(s.job.state)} · ${esc(s.job.phase)}</p>`:''}${!s.installed&&s.install_supported&&!running?'<label><input type="checkbox" id="docker-install-ack"> Docker Engine, Compose und Buildx auf diesem Server installieren. Docker richtet einen Dienst und Netzwerk-/Firewallregeln ein.</label><p><button id="docker-install" disabled>Docker installieren</button></p>':`<button disabled>${running?'Installation läuft …':s.installed?'Docker bereits vorhanden':'Installation hier nicht verfügbar'}</button>`}<p class="small">Keine automatische Aktualisierung vorhandener Docker-Installationen. NVIDIA Container Toolkit und Treiber sind separate Voraussetzungen für GPU-Container. Die Paketinstallation wird nicht mitten im Vorgang abgebrochen.</p><a class="link" href="#services">Weitere Dienste öffnen →</a></section>`;
const button=panel.querySelector('#docker-install'),ack=panel.querySelector('#docker-install-ack');if(button){ack.onchange=()=>button.disabled=pending||!ack.checked;button.onclick=async()=>{pending=true;button.disabled=true;try{await api('/install',{confirm:true});message.textContent='Docker-Erstinstallation gestartet.';}catch(error){message.textContent=error.message;}finally{pending=false;signature='';}};}
}
}}catch(error){if(panel.isConnected){message.textContent=error.message;signature='';}}finally{if(panel.isConnected)setTimeout(refresh,5000);}}
}
if(services)panel.querySelectorAll('[data-container-action]').forEach(b=>b.onclick=async()=>{
const action=b.dataset.containerAction,id=b.dataset.containerId,c=s.services.find(c=>c.id===id);
if(pending||!c)return;
if(action==='delete'&&!confirm(`Container „${c.name}“ endgültig löschen? Die beschreibbare Container-Schicht geht verloren. Images und Volumes bleiben erhalten.`))return;
pending=true;panel.querySelectorAll('button').forEach(x=>x.disabled=true);message.textContent=action==='stop'?'Container wird gestoppt (bis zu 30 Sekunden) …':action==='start'?'Container wird gestartet …':'Container wird gelöscht …';
try{await api('/'+action,{id,...(action==='delete'?{confirm:true}:{})});message.textContent=action==='delete'?'Container gelöscht. Images und Volumes bleiben erhalten.':action==='start'?'Container gestartet.':'Container gestoppt.';}
catch(error){message.textContent=error.message;}
finally{pending=false;signature='';if(panel.isConnected)refresh();}
});
}catch(error){if(panel.isConnected){message.textContent=error.message;signature='';}}finally{if(panel.isConnected)timer=setTimeout(refresh,5000);}}
refresh();
}
return {html,bind};
+4
View File
@@ -20,3 +20,7 @@ class DockerSupport:
try:return dict(self.call('status'),helper_available=True)
except (OSError,ValueError):return dict(helper_available=False,installed=None,reachable=False,services=[],install_supported=False,job=None,message='Docker-Systemhelfer nicht verbunden. Der Deck-Installer muss ihn auf dem Debian-Host einrichten.',labels=[MANAGED_LABEL,ROLE_LABEL])
def install(self):return self.call('install')
def container_action(self,action,ident):
if action not in ('start','stop','delete'):raise ValueError('Ungültige Containeraktion.')
return self.call('container-'+action,ident)
+74
View File
@@ -0,0 +1,74 @@
# Backup-/Restore-Prüfung — 2026-10-01
## Ergebnis am laufenden Athena
Lesende Prüfung der realen Metadaten: 30 Bibliotheksdateien, 10 Profile,
keine lokale Datei ohne belegte Downloadquelle. Alle vorhandenen Einstellungen,
Profilparameter und Komponentenreferenzen bestehen die Backup-Schemavalidierung.
Keine Chats, Prompts, Nutzmedien oder Anwendungslogs wurden gelesen.
Die drei markierten Zusatzdienste bestehen die Container-Validierung:
| Dienst | Wiederbeschaffung | Gesicherte Verbindung |
|---|---|---|
| Athena Separator Web | Build-Rezept im Deck-Repository | deck-connection.json |
| Athena LadyPoly | Build-Rezept im Deck-Repository | deck-connection.json |
| LTX DeskWEB | Registry-Image mit Digest | Token-/Kennwortdateien, Umgebungsvariablen, Mount-Ziele |
## Umfang
- Profile aller Kategorien einschließlich Komponenten, GPU-Zuordnung, Sampling,
MTP, KV-Cache und API-Freigaben.
- API-Port und Autostart; TTS/STT-Ladepolitik, sofern eingestellt.
- Administratorzugang und API-Token; Hugging-Face-Token; Deck-WireGuard-Konfiguration,
sofern das Deck-Netzwerkmodul angebunden ist.
- Modellkatalog mit Revision und SHA-256; lokale Herkunft zusätzlich mit belegter
Originalquelle. Restore erhält bestehende IDs und damit Profilreferenzen.
- llama.cpp-Builds, Bild-, TTS-, audio.cpp-, LTX- und Separator-Laufzeitrezepte,
Prompt-Enhancer und unterstützte Zusatznodes. Laufzeiten werden installiert/gebaut;
ihre Binärdateien gehören nicht in den Export.
- Separator-Pakete einschließlich YAML/Ensemble-Dateien aus geprüften Quellen.
- Markierte Anwendungscontainer einschließlich Konfiguration, Ports, Labels,
Ressourcenlimits und Startzustand. Bilder werden gepullt oder nach Rezept gebaut.
- Dashboard-History und das beim Export ausgewählte Theme.
Exportdateien sind passwortverschlüsselt. Das Exportkennwort separat aufbewahren.
Gewichte, Image-Layer, Projekte, Ergebnisse, Uploads, Chats und Logs sind ausgeschlossen.
Download-Verläufe, Warteschlangen und laufende Jobs werden nicht wieder aufgenommen.
Die zuletzt aktive GPU-Betriebsart wird nicht automatisch wieder gestartet.
## Grenzen eines neuen Servers
Der alte WireGuard-Gateway und unmarkierte alte Router-Container sind ausdrücklich
kein Teil des Deck-Backups. Docker-Registry-Anmeldungen des Hosts sind ebenfalls
nicht enthalten. Private Images können eine erneute Anmeldung benötigen.
Debian, Treiber, Build-Voraussetzungen, Systemhelfer und veröffentlichte API-Ports
müssen zunächst über den passenden Deck-Installer eingerichtet sein.
Die Restore-Vorschau prüft Quellen, Referenzen und Laufzeitversionen. Sie ist kein
Nachweis, dass externe Quellen in Zukunft erreichbar bleiben oder Zugänge weiterhin
berechtigt sind. Der Platzbedarf der Vorschau zählt fehlende Kataloggewichte;
zusätzliche Laufzeit-/Build- und Separator-Pakete brauchen weiteren Speicher.
Versionabweichungen verlangen eine passende Deck-Version oder ein geprüftes Upgrade.
Vorhandene Container werden nicht überschrieben; ihre Einstellungen bleiben erhalten.
## Prüfung und Korrekturen
Isolierte Tests mit temporären Zustandsverzeichnissen prüfen verschlüsselten Export,
Manipulationserkennung, Restore auf ein leeres Ziel mit simuliertem Download,
Prüfsummenfehler, lokale Originalquelle bei unveränderten Profil-IDs, alle vier
Separator-Pakete, Container-Schutz und nachbaubare lokale Images.
Live wurden die tatsächlichen Profil-/Quellenmetadaten und Container-Exports geprüft.
Ein vollständiger Restore mit echten Downloads auf einem frisch installierten Server
wurde nicht durchgeführt. Der laufende Server wurde nicht neu gestartet/restauriert.
Korrigiert: falsche Warnung über fehlende Quellen bei lokalen Build-Rezepten;
Separator-Web erhält beim Restore wieder eine schreibgeschützte Rootfs und seine
beschreibbare 1-GiB-Tmpfs. Änderungen benötigen zur Live-Aktivierung das nächste
Deck-App-Update und die Aktualisierung des Docker-Systemhelfers.
Audio-Trennprofile werden zusätzlich in `separator-profiles.json` gesichert:
Aktivierung und GPU-UUID je geprüftem Modellpaket. Alte Backups ohne diese Datei
verwenden aktivierte Profile mit automatischer Auswahl (RTX 5080 zuerst). Bei
geänderter Hardware muss eine fest gespeicherte UUID im Profil neu gewählt werden;
Deck wechselt bei fester Auswahl nicht unbemerkt auf eine andere GPU.
+40
View File
@@ -0,0 +1,40 @@
# YuE2 über audio.cpp
Deck führt die offizielle YuE2-GGUF-Familie über die installierte audio.cpp-CLI aus. Keine neue Laufzeitinstallation oder Docker-Container nötig. BF16, Q8_0 und Q4_0-Hauptdateien aus audio-cpp/Yue2-3B-GGUF werden mit zugeordnetem VAE und vier Sidecars unterstützt.
## Bedienung
Musik → Profile → Einrichtung & Komponenten: fehlende Dateien herunterladen und zuordnen. Danach kann das Profil am API-Endpunkt freigegeben werden. Unter Musik → Testen ein ausführbares Profil wählen, Musikstil und Songtext eingeben und Musik erzeugen. Status, Laufzeit, Abbruch, Fehler und WAV-Ergebnis sind sichtbar. Englische Texte werden empfohlen. Standardtest: cot=off, 400 semantische Tokens, 8 NAR-Schritte, Seed 1234. Tokenlimit ist keine garantierte Sekundenlänge. Bis 9000 Tokens und 32 NAR-Schritte sind einstellbar.
Deck reserviert exklusiv seinen GPU-Auftrag, entlädt sein LLM und verdrängt eigenes TTS. Fremde Prozesse werden nicht beendet. Die freie RTX 5080 wird verwendet; keine automatische CPU-Auslagerung. Video-Modus sperrt Musik. Nach Abschluss/Fehler/Abbruch endet der CLI-Prozess und gibt GPU-Speicher frei. Prompts und vorübergehende Worker-Ausgabe werden nicht dauerhaft gespeichert; WAV-Ergebnisse bleiben in music-jobs. Installation und Profilrezept sind Bestandteil der vorhandenen Wiederherstellung; Ergebnis-Audio nicht.
## API
Dies ist eine Deck-Musik-Erweiterung, kein standardisierter OpenAI-Musikendpunkt. Gleicher API-Port und Bearer-Token wie Chat, getrennte Modellliste:
- GET /v1/audio/music/models: nur aktivierte ausführbare Musikprofile.
- POST /v1/audio/music: JSON mit model (Profilname), lyrics, style und optional seed, max_tokens, steps; gibt audio/wav zurück. Authentifiziert und GPU-koordiniert. Maximales Worker-Zeitlimit 30 Minuten.
- GET /v1/models bleibt ausschließlich für Chatmodelle.
Interne GUI-API mit Deck-Anmeldung/CSRF: GET /api/v1/music; POST /api/v1/music/start mit profile_id, lyrics, style und optionalen Testparametern; POST /api/v1/music/cancel mit {}; GET /api/v1/music/audio?id=JOB_ID.
Die Ladypoly-WebUI ist hiermit noch nicht verbunden. Der Deck-Testbereich und die Musik-API funktionieren unabhängig davon. Weitere Musikfamilien oder LoRA/ABC-Funktionen sind noch nicht angebunden.
## Verifikation auf Athena
2026-09-30: YuE2 BF16 mit zugeordnetem F16-VAE und Sidecars erzeugte bei 200 semantischen Tokens / 2 NAR-Schritten eine gültige stereo WAV von 8 Sekunden in etwa 18 Sekunden auf der RTX 5080. Danach Prozess beendet und GPU-Speicher freigegeben. Dies prüft Ausführung und Dateiformat, nicht musikalische Qualität oder Stabilität langer Aufträge.
## Exklusiver Musik-Modus
Übersicht → GPU-Modus bietet LLM, Video und Musik. Musik setzt mindestens ein am Endpunkt freigegebenes ausführbares Musikprofil voraus. Beim Wechsel werden eigene Deck-GPU-Worker beendet und laufende GPU-Reservierungen freigegeben; fremde Videodienste werden geprüft, aber nie gestoppt. Musikgewichte laden weiterhin erst bei Anfrage. Im Musikmodus werden Chat/Bild/TTS/STT blockiert; für diese Aufträge auf LLM zurückschalten. Musik → Testen zeigt einen Umschalthinweis, solange ein anderer Modus aktiv ist.
## Statusabfragen und parallele GUI-Zugriffe
Endpunkt- und Video-Status können gleichzeitig von Übersicht, Bibliothek und
Profilen angefragt werden. Der Video-Status liest auch die freigegebenen
Musikprofile. Deshalb darf der Endpunkt seine eigene Sperre nicht halten,
während er den Video-Status abfragt: die umgekehrte Reihenfolge würde beide
Abfragen gegenseitig blockieren. Ein Regressionstest prüft den gleichzeitigen
Profilzugriff während der Video-Statusabfrage. Der Fehler ließ vorhandene
Bibliotheken leer erscheinen; Gewichte, Downloadhistorie und Profile wurden
dabei nicht verändert.
+29
View File
@@ -0,0 +1,29 @@
# Laufzeiten erkennen und vorbereiten
In **Bibliothek → Modell** und bei der Einrichtung von Profilen zeigt Deck die erkannte Modellfamilie, den Ausführungsstatus und **Mögliche Laufzeiten**. Das gilt für Sprache/Chat, Bild, TTS, STT, Musik, Stimme und Video.
Eine vorhandene Laufzeit wird wiederverwendet. Ein fehlender Worker oder Workflow bleibt ausdrücklich sichtbar. Unbekannte Familien erhalten nur gekennzeichnete Recherchehinweise aus ihrer Kategorie; das ist keine Kompatibilitätszusage. Es wird nichts automatisch installiert.
Über den Link „Laufzeit herunterladen / bereitmachen“ gelangt man zur jeweiligen Laufzeitseite. Dort wird die Installation bewusst gestartet. Ohne gepflegten Installer zeigt Deck dies an, statt eine Installation vorzutäuschen.
## YuE2 GGUF / audio.cpp
`audio-cpp/Yue2-3B-GGUF`, Hauptdateien `yue2-3b-*.gguf`, werden als YuE2 erkannt. Die VAE-Dateien sind Komponenten, keine eigenständigen Modelle. Das Paket braucht zusätzlich VAE, Modell-/Generierungskonfiguration, VAE-Konfiguration und Qwen-Tokenizer; `.tiktoken` ist herunterladbar.
**Einstellungen → Laufzeiten → audio.cpp** bietet einen isolierten CUDA-Build aus `0xShug0/audio.cpp`, Version v0.8.1, Commit `f2b4937306daa25f5c78520f3c626ed31495a37a`. Voraussetzung: Debian/Linux, Git, CMake, g++, nvcc, nvidia-smi und mindestens 6 GiB frei. GPU-Architekturen werden gelesen; gebaut wird mit sechs Jobs. Keine Host-Pakete, Treiber oder bestehenden Laufzeiten werden verändert. Fehlende Voraussetzungen werden gemeldet.
Installation liegt im Deck-Zustandsverzeichnis unter `audio-cpp-runtime`. Status, Abbruch und eigene Build-Ausgabe sind in der GUI verfügbar. Der CLI-Start wird ohne Modell geprüft. Dieser Build lädt **keine Gewichte**. Der ausführbare YuE2-Deck-Musikworker ist angebunden; Bedienung und API sind in MUSIC_API.md dokumentiert. Die Ladypoly-WebUI-Anbindung ist separat. Auch andere audio.cpp-Familien brauchen geprüfte modellspezifische Anbindungen.
Interne Admin-API: `GET /api/v1/audio-cpp-runtime`, `GET /api/v1/audio-cpp-runtime/log`, `POST /api/v1/audio-cpp-runtime/install` und `/cancel` (JSON `{}`, vorhandene Sitzung und CSRF-Header). Backup/Restore enthält das gepinnte Laufzeitrezept und baut es bei Bedarf neu; keine Binärdateien im Backup.
## YuE2-Komponenten in der GUI
Unter **Musik → Profile → Komponenten** bietet Deck für das offizielle YuE2-GGUF-Hauptmodell fünf Rollen: VAE (F16 empfohlen oder F32), Modellkonfiguration, Generierungskonfiguration, Qwen-Tokenizer und VAE-Konfiguration. „Alle fehlenden Dateien herunterladen“ reiht je eine Datei pro Rolle in die Download-Warteschlange ein. Danach „Bibliothek aktualisieren“ und „Zuordnung auf Athena speichern“. Bereits vorhandene Dateien werden wiederverwendet. Quelle und Zuweisung müssen dieselbe Hub-Revision wie das Hauptmodell haben. Diese Zusatzdateien sind keine eigenständigen Modelle. Die Ausführungsprüfung bleibt auch nach vollständiger Zuweisung als Blocker sichtbar.
## Gemeinsame Einrichtung in allen Kategorien
Alle Profile haben „Einrichtung & Komponenten“. Zuerst wird ein gepflegtes Modellrezept verwendet. Fehlt dieses, können installierte audio.cpp-Paketdefinitionen für exakt passende audio-cpp-Repositories Zusatzdateien liefern. Ohne vollständiges Rezept zeigt Deck Dateinamen-Hinweise der Modellquelle als **ungeprüft** an. Ein anderer Hugging-Face-Link kann als Komponentenquelle ausgewählt werden. Vorhandene Zusatzdateien lassen sich in allen Kategorien zuordnen. Manuelle Rollen sind optional und werden niemals automatisch als kompatibel angenommen; ihre Zuordnung blockiert die Ausführung bis zur passenden Worker-Anbindung. Beim Wechsel zur manuellen Quelle bleiben vorhandene Rezept-Zuordnungen erhalten.
Die Entdecken-Dateiansicht zeigt benötigte Komponenten aus bekannten Rezepten und Laufzeit-Paketen bereits vor dem Hauptdownload; weitere sichtbare Projektoren, Encoder und VAE werden als ungeprüfte Hinweise aufgeführt. Es gibt keine universelle, vollständige Erkennung aller Hub-Modelle.
Download-Annahme und Fehler erscheinen direkt neben der betroffenen Komponente. Die 10-GiB-Diskreserve bleibt erhalten. Der Installer behält nach erfolgreichen Updates nur die zwei neuesten automatischen Zustands-Sicherungen; manuell exportierte Backup-Dateien werden nicht angefasst.
+44 -5
View File
@@ -1,22 +1,61 @@
window.EndpointUI=(()=>{
const e=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const stateName={running:'Läuft',stopped:'Gestoppt',stopping:'Wird gestoppt · laufende Aufträge werden beendet',loading:'Modell lädt',ready:'Modell bereit',failed:'Fehlgeschlagen'};
let pending=false,sequence=0;
let pending=false,sequence=0,selectedMode=null,controlTimer=null;
async function api(path='',data){const r=await fetch('/api/v1/endpoint'+path,data!==undefined?{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||'Endpunkt nicht erreichbar');return v;}
function put(el,html){if(el._deckHtml!==html){el.innerHTML=html;el._deckHtml=html;}}
function summary(s){return `<div class="grid"><section class="card wide"><div class="card-top"><h2>OpenAI-kompatibler Endpunkt</h2><span class="pill">${e(stateName[s.state])}</span></div><div class="metrics"><div><small>Erreichbarkeit</small><strong>${s.reachable?'Listener bereit':'Nicht erreichbar'}</strong></div><div><small>Lokaler API-Port</small><strong>${s.port}</strong></div><div><small>Laufende / wartende Aufträge</small><strong>${s.scheduler.active_requests} / ${s.scheduler.waiting_requests}</strong></div></div><p><code>${e(s.base_url)}</code> · <a class="link" href="#endpoint">Port und Profile einstellen →</a></p><p>Geladen: ${e(s.worker.profile_name||'Kein Sprachmodell')} · ${e(stateName[s.worker.state]||s.worker.state)}</p>${s.worker.error||s.error?`<p role="alert">${e(s.worker.error||s.error)}</p>`:''}<p>Aktivierte Profile werden angeboten. Erst eine Anfrage lädt das Modell. Wechsel warten auf laufende Antworten; Bildaufträge entladen vorher das eigene Sprachmodell.</p></section>${[['llm','Sprachmodelle'],['image','Bildgenerierung'],['tts','Text → Sprache (TTS)'],['stt','Sprache → Text (STT)']].map(([key,label])=>{const c=s.counts[key];return `<section class="card"><h3>${label}</h3><p>${c.supported?`${c.enabled} Profile aktiviert · ${c.available} ausführbar`:'Noch keine eigene Laufzeit eingerichtet'}</p><span class="pill">${c.loaded?'AKTIV IM SPEICHER':c.available?'BEI ANFRAGE LADEN':'NICHT AKTIV'}</span></section>`}).join('')}</div>`;}
function summary(s){return `<div class="grid"><section class="card wide"><div class="card-top"><h2>OpenAI-kompatibler Endpunkt</h2><span class="pill">${e(stateName[s.state])}</span></div><div class="metrics"><div><small>Erreichbarkeit</small><strong>${s.reachable?'Listener bereit':'Nicht erreichbar'}</strong></div><div><small>Lokaler API-Port</small><strong>${s.port}</strong></div><div><small>Laufende / wartende Aufträge</small><strong>${s.scheduler.active_requests} / ${s.scheduler.waiting_requests}</strong></div></div><p><code>${e(s.base_url)}</code> · <a class="link" href="#endpoint">Port und Profile einstellen →</a></p><p>Geladen: ${e(s.worker.profile_name||'Kein Sprachmodell')} · ${e(stateName[s.worker.state]||s.worker.state)}</p>${s.worker.error||s.error?`<p role="alert">${e(s.worker.error||s.error)}</p>`:''}<p>Aktivierte Profile werden angeboten. Erst eine Anfrage lädt das Modell. Wechsel warten auf laufende Antworten; Bildaufträge entladen vorher das eigene Sprachmodell.</p></section>${[['llm','Sprachmodelle'],['image','Bildgenerierung'],['tts','Text → Sprache (TTS)'],['stt','Sprache → Text (STT)'],['music','Musik']].map(([key,label])=>{const c=s.counts[key];return `<section class="card"><h3>${label}</h3><p>${c.supported?`${c.enabled} Profile aktiviert · ${c.available} ausführbar`:'Noch keine eigene Laufzeit eingerichtet'}</p><span class="pill">${c.loaded?'AKTIV IM SPEICHER':c.available?'BEI ANFRAGE LADEN':'NICHT AKTIV'}</span></section>`}).join('')}</div>`;}
const modeLabels={llm:'LLM',video:'Video',music:'Musik',separator:'Audio-Trennung'};
function control(s,root){
const v=s.video||{},active=v.mode||'llm';
if(!selectedMode)selectedMode=active==='switching'?(v.target_mode||'llm'):active;
const selected=selectedMode,label=modeLabels[selected],switching=v.state==='switching',busy=pending||switching||s.state==='stopping';
const current=selected===active,available=selected==='video'?Boolean(v.service?.id):selected==='separator'?Boolean(v.separator?.installed&&v.separator.models?.some(m=>m.installed)):Boolean(s.counts[selected]?.available);
const ready=current&&(selected==='separator'||s.reachable)&&!switching&&(selected!=='video'||v.service?.ready);
const loading=current&&selected==='llm'&&s.worker.state==='loading';
const address=location.protocol+'//'+location.hostname+':'+s.port;
const routes=selected==='llm'?[['Chat',address+'/v1/chat/completions'],['Modelle',address+'/v1/models']]:selected==='separator'?[['Trennauftrag',location.protocol+'//'+location.hostname+':8108/api/v1/separator-jobs/start'],['Status',location.protocol+'//'+location.hostname+':8108/api/v1/separator-tests']]:selected==='music'?[['Musik erzeugen',address+'/v1/audio/music'],['Musikmodelle',address+'/v1/audio/music/models']]:[['Video-API',address+'/']];
const reason=available?'':selected==='video'?'Wähle zuerst ein eingerichtetes Videomodell.':selected==='separator'?'Installiere zuerst die Laufzeit und ein Trennmodellpaket.':'Gib zuerst ein ausführbares Profil am API-Endpunkt frei.';
const state=switching?'Wechsel läuft':loading?'Modell wird geladen':ready?'Bereit':current&&s.state==='stopping'?'Wird gestoppt':current&&s.reachable?'Startet':current?'Gestoppt':'Nicht aktiv';
put(root.querySelector('#control-modes'),`<div class="control-modes" role="tablist" aria-label="Betriebsart">${Object.entries(modeLabels).map(([id,name])=>`<button role="tab" aria-selected="${selected===id}" data-control-mode="${id}" class="${selected===id?'chosen':'secondary'}" ${busy?'disabled':''}>${name}</button>`).join('')}</div>`);
put(root.querySelector('#endpoint-summary'),`<section class="card control-card" role="tabpanel"><div class="control-heading"><h2>${label}</h2><span class="pill">${e(state)}</span></div><p>${active==='switching'?`Wechsel zu ${e(modeLabels[v.target_mode]||v.target_mode)}`:(s.reachable||active==='separator')?`Aktiver Betrieb: ${e(modeLabels[active]||active)}`:'Betrieb gestoppt'}</p><div class="card-actions"><button data-control-start ${busy||!available||ready?'disabled':''}>${label} starten</button><button data-control-stop class="secondary" ${busy||!current||(s.state==='stopped'&&active==='llm')?'disabled':''}>Stoppen</button></div>${switching||loading||pending||s.state==='stopping'?`<div class="video-activity" role="status" aria-live="polite"><strong>${e(switching?v.switch_phase:loading?s.worker.phase||'Modell wird geladen':s.state==='stopping'?'Laufende Aufträge werden beendet':'Start wird angefordert')}</strong><progress aria-label="Fortschritt"></progress></div>`:''}${reason?`<p class="warning">${e(reason)} <a class="link" href="#${selected==='video'?'video':selected==='music'?'music':selected==='separator'?'separation':'chat'}">Einrichten →</a></p>`:''}${(current&&(v.error||s.error||selected==='llm'&&s.worker.error))?`<p class="video-error" role="alert">${e(v.error||s.error||s.worker.error)}</p>`:''}<div class="control-api"><h3>API-Adresse</h3>${routes.map(([name,url])=>`<div><span>${name}</span><code>${e(url)}</code></div>`).join('')}<p class="small">${ready?'Erreichbar':'Erreichbar, sobald dieser Betrieb bereit ist'} · Port ${selected==='separator'?8108:s.port}</p></div>${selected==='separator'?`<p class="small">${v.separator?.models?.filter(m=>m.installed).length||0} Modellpakete bereit · Gewichte laden beim Trennauftrag und werden anschließend entladen.</p><a class="link" href="${e(location.protocol+'//'+location.hostname+':8128/')}" target="_blank" rel="noopener">Audio-Trennoberfläche öffnen →</a><p><a class="link" href="#separation">Modelle und Aufträge →</a></p>`:selected==='llm'?`<p class="small">${s.counts.llm.available} Chatprofile freigegeben · ${e(s.worker.profile_name||'Gewichte laden bei der ersten Anfrage')}</p>`:selected==='music'?`<p class="small">${e(v.music_profiles?.map(p=>p.name).join(', ')||'Kein Musikprofil freigegeben')} · Gewichte laden bei Anfrage</p>${available?`<a class="link" href="${e(location.protocol+'//'+location.hostname+':8127/')}" target="_blank" rel="noopener">LadyPoly YuE2 öffnen →</a>${!ready?'<p class="small">Vor dem Generieren Musik starten.</p>':''}`:''}`:`<p class="small">${e(v.service?.name||'Kein Videomodell ausgewählt')} · ${v.runtime_kind==='original'?'LTX Original':'ComfyUI'} · Gewichte laden bei Generierung</p>${ready?`<a class="link" href="${e(v.runtime_kind==='original'?location.protocol+'//'+location.hostname+':8118/':address+'/')}" target="_blank" rel="noopener">${v.runtime_kind==='original'?'LTX DeskWEB':'ComfyUI'} öffnen →</a>`:''}`}<details class="control-details"><summary>Weitere Dienste & Einstellungen</summary><p>Im LLM-Betrieb stehen auch freigegebene Bild-, TTS- und STT-Profile bereit.</p><p><a class="link" href="#endpoint">Endpunkt und Port einstellen →</a></p><p>${s.scheduler.active_requests} laufende · ${s.scheduler.waiting_requests} wartende Aufträge</p></details></section>`);
root.querySelectorAll('[data-control-mode]').forEach(b=>b.onclick=()=>{selectedMode=b.dataset.controlMode;control(s,root);});
root.querySelector('[data-control-start]').onclick=()=>controlAction('start',selected);
root.querySelector('[data-control-stop]').onclick=()=>controlAction('stop',selected);
}
async function switchMode(mode){
const r=await fetch('/api/v1/video/mode',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({mode})});
const result=await r.json();if(!r.ok)throw Error(result.error||'Wechsel fehlgeschlagen');return result;
}
async function controlAction(action,mode){
if(pending)return;pending=true;const root=document.querySelector('#endpoint-page');
root.querySelectorAll('button').forEach(b=>b.disabled=true);
root.querySelector('#endpoint-message').innerHTML=`<div class="video-activity" role="status"><strong>${action==='start'?e(modeLabels[mode])+' wird gestartet':'Betrieb wird gestoppt'}</strong><progress aria-label="Fortschritt"></progress></div>`;
try{
const s=await api();
if(action==='start'){
if((s.video?.mode||'llm')!==mode||(mode==='video'&&!s.video?.service?.ready))await switchMode(mode);
if(mode!=='separator'&&s.state==='stopped')await api('/start',{});
}else{
if(mode!=='separator')await api('/stop',{});
if(s.video&&s.video.mode!=='llm')await switchMode('llm');
}
if(root.isConnected)root.querySelector('#endpoint-message').textContent='';
}catch(error){if(root.isConnected)root.querySelector('#endpoint-message').textContent=error.message;}
finally{pending=false;if(root.isConnected){root.querySelector('#control-modes')._deckHtml=null;root.querySelector('#endpoint-summary')._deckHtml=null;render(false);}}
}
async function render(settings=false){
const view=document.querySelector('#view');let root=view.querySelector('#endpoint-page');
if(!root||root.dataset.settings!==String(settings)){
sequence++;view.innerHTML=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt & Profile':'Dein Modell-Endpunkt'}</h1><p>Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></div><p id="endpoint-message" role="status"></p><div id="video-mode-panel"></div><div id="endpoint-summary"></div>${settings?'<section class="card"><h2>Lokaler API-Port</h2><form id="endpoint-port-form"><label>Port<input id="endpoint-port" name="port" type="number" required min="1024" max="65535"></label><button id="endpoint-port-save">Port speichern</button></form><p id="endpoint-port-help"></p><p>Der Zugriff verwendet den API-Token aus <a class="link" href="#access">Zugang & API</a>. Verwaltungsoberfläche und API haben getrennte Ports.</p></section><section class="card"><h2>Am Endpunkt angebotene Profile</h2><p>Nur explizit aktivierte, ausführbare Profile erscheinen in <code>GET /v1/models</code>. Aktivieren lädt noch kein Modell.</p><div id="endpoint-profiles"></div></section><section class="card"><h2>API-Routen</h2><p><code>POST /v1/chat/completions</code> · Text und Streaming<br><code>POST /v1/images/generations</code> · Qwen-Image-Rezept, ein Bild als b64_json<br><code>POST /v1/audio/speech</code> und <code>POST /v1/audio/transcriptions</code> · noch nicht eingerichtet (501)</p><p>Alle Routen nutzen denselben Port und Bearer-Token. Bei Bildaufträgen bestimmt das Profil Auflösung, Schritte, Guidance und Seed. Noch keine Bildbearbeitung oder Vision-Eingaben.</p></section>':''}</section>`;
sequence++;clearTimeout(controlTimer);selectedMode=null;view.innerHTML=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt':'Steuerung'}</h1>${settings?'<p>API-Port und freigegebene Profile verwalten.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></div>':'<p>Betriebsart wählen und starten.</p><div id="control-modes"></div>'}<p id="endpoint-message" role="status"></p><div id="video-mode-panel"></div><div id="endpoint-summary"></div>${settings?'<section class="card"><h2>Lokaler API-Port</h2><form id="endpoint-port-form"><label>Port<input id="endpoint-port" name="port" type="number" required min="1024" max="65535"></label><button id="endpoint-port-save">Port speichern</button></form><p id="endpoint-port-help"></p><p>Der Zugriff verwendet den API-Token aus <a class="link" href="#access">Zugang & API-Token</a>. Verwaltungsoberfläche und API haben getrennte Ports.</p></section><section class="card"><h2>Am Endpunkt angebotene Profile</h2><p>Nur explizit aktivierte, ausführbare Profile erscheinen in <code>GET /v1/models</code>. Aktivieren lädt noch kein Modell.</p><div id="endpoint-profiles"></div></section><section class="card"><h2>API-Routen</h2><p><code>POST /v1/chat/completions</code> · Text und Streaming<br><code>POST /v1/images/generations</code> · Qwen-Image-Rezept, ein Bild als b64_json<br><code>POST /v1/audio/speech</code> und <code>POST /v1/audio/transcriptions</code> · noch nicht eingerichtet (501)</p><p>Alle Routen nutzen denselben Port und Bearer-Token. Bei Bildaufträgen bestimmt das Profil Auflösung, Schritte, Guidance und Seed. Noch keine Bildbearbeitung oder Vision-Eingaben.</p></section>':''}</section>`;
root=view.querySelector('#endpoint-page');
for(const action of ['start','stop'])root.querySelector('#endpoint-'+action).onclick=()=>mutate('/'+action,{});
if(settings)for(const action of ['start','stop'])root.querySelector('#endpoint-'+action).onclick=()=>mutate('/'+action,{});
root.querySelector('#endpoint-port-form')?.addEventListener('submit',event=>{event.preventDefault();mutate('/config',{port:Number(root.querySelector('#endpoint-port').value)});});
}
if(pending)return;const id=sequence;
try{const s=await api();if(!root.isConnected||id!==sequence)return;
if(!settings){control(s,root);clearTimeout(controlTimer);if(s.video?.state==='switching'||s.worker.state==='loading'||s.state==='stopping')controlTimer=setTimeout(()=>{if(root.isConnected)render(false);},1000);return;}
put(root.querySelector('#endpoint-summary'),summary(s));
if(s.video)VideoUI.mode(root.querySelector('#video-mode-panel'),s.video,s.profiles,s.port);
root.querySelector('#endpoint-start').disabled=s.state!=='stopped';root.querySelector('#endpoint-stop').disabled=s.state!=='running';
if(settings){const port=root.querySelector('#endpoint-port');if(document.activeElement!==port)port.value=s.port;port.disabled=s.state!=='stopped';root.querySelector('#endpoint-port-save').disabled=s.state!=='stopped';
root.querySelector('#endpoint-port-help').textContent=s.allowed_ports.length?'Docker-Testinstallation: veröffentlichte Loopback-Ports '+s.allowed_ports.join(', ')+'. Portwechsel nur bei gestopptem Endpunkt. Der SSH-Tunnel muss denselben Port weiterleiten.':'Nativer Dienst: freie Ports zwischen 1024 und 65535, nur bei gestopptem Endpunkt.';
+35 -10
View File
@@ -12,6 +12,7 @@ import threading
import time
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from api_compat import normalize_chat,CompatibilityError
from profiles import generation_parameters
from inference import InferenceError
from stt import read_upload
@@ -39,7 +40,7 @@ def chat_upstream_error(status, raw):
class Endpoint:
def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port):
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.stt=None;self.video=None;self.credentials=credentials
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.stt=None;self.music=None;self.video=None;self.credentials=credentials
self.management_port=management_port;self.lock=threading.RLock();self.http=None;self.thread=None;self.state='stopped';self.error=None;self.inflight=0
self.allowed_ports=[int(p) for p in os.environ.get('DECK_API_PORTS','').split(',') if p]
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
@@ -58,11 +59,14 @@ class Endpoint:
return [dict(p,enabled=p['id'] in enabled) for p in rows if p['kind']!='video']
def status(self):
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt')]:
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt'),('music','music')]:
subset=[p for p in rows if p['kind']==kind]
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status().get('loaded')) if kind=='audio' else bool(self.stt and self.stt.status().get('loaded')) if kind=='stt' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None) or (kind=='stt' and self.stt is not None))
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status().get('loaded')) if kind=='audio' else bool(self.stt and self.stt.status().get('loaded')) if kind=='stt' else bool(self.music and self.music.status().get('loaded')) if kind=='music' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None) or (kind=='stt' and self.stt is not None) or (kind=='music' and self.music is not None))
scheduler_status=self.scheduler.status()
with self.lock:return dict(video=self.video.status() if self.video else None,state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
# Video status can call back into endpoint.rows(); never hold our lock
# while acquiring another service's lock. Concurrent GUI requests otherwise deadlock.
video_status=self.video.status() if self.video else None
with self.lock:return dict(video=video_status,state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
def configure(self,data):
if set(data)!={'port'} or type(data['port']) is not int or not 1024<=data['port']<=65535:raise ValueError('Port zwischen 1024 und 65535 erforderlich.')
port=data['port']
@@ -127,6 +131,7 @@ class Endpoint:
# Process shutdown does not change the user's autostart preference.
with self.lock:self.state='stopping';http=self.http
if http:http.shutdown();http.server_close()
if self.music:self.music.stop()
if self.tts:self.tts.stop()
if self.stt:self.stt.stop()
self.images.stop();self.worker.stop()
@@ -196,19 +201,22 @@ class APIHandler(BaseHTTPRequestHandler):
with ep.lock:
if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping')
ep.inflight+=1;admitted=True
if ep.video and ep.scheduler.gpu_mode!='llm':
if ep.scheduler.gpu_mode=='separator':raise APIError('Audio-Trennung aktiv. Für Chat, Bilder oder Musik die Betriebsart wechseln.',503,'separator_mode_active')
if ep.video and ep.scheduler.gpu_mode not in ('llm','music'):
if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching')
video=ep.video.status()
if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready')
try:ep.video.relay(self) if callable(getattr(type(ep.video),'relay',None)) else relay(self,video['selected'])
except ValueError as exc:raise APIError(str(exc)) from None
return
model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt'}
model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt','/v1/audio/music/models':'music'}
if self.command=='GET' and self.path in model_routes:return self.send(ep.model_list(model_routes[self.path]))
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
if self.video_route(ep):return
if self.command!='POST':raise APIError('Route nicht gefunden.',404)
if ep.video and ep.scheduler.gpu_mode!='llm' and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active')
if ep.scheduler.gpu_mode=='separator':raise APIError('Audio-Trennung aktiv. Für Chat, Bilder oder Musik die Betriebsart wechseln.',503,'separator_mode_active')
if ep.scheduler.gpu_mode=='music' and self.path!='/v1/audio/music':raise APIError('Musik-Modus aktiv. Für andere KI-Aufträge auf LLM wechseln.',503,'music_mode_active')
if ep.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active')
if self.path=='/v1/audio/transcriptions':return self.transcription(ep)
if self.path=='/v1/images/edits':
from image_upload import read_image_upload
@@ -219,7 +227,7 @@ class APIHandler(BaseHTTPRequestHandler):
fields['n']=int(fields.get('n','1'))
return self.image(ep,fields,images)
except ValueError as exc:raise APIError(str(exc)) from None
if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech'):raise APIError('Route nicht implementiert.',404)
if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech','/v1/audio/music'):raise APIError('Route nicht implementiert.',404)
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
try:length=int(self.headers.get('Content-Length','0'))
except ValueError:raise APIError('Ungültige Content-Length.') from None
@@ -228,6 +236,7 @@ class APIHandler(BaseHTTPRequestHandler):
except (ValueError,UnicodeError):raise APIError('Ungültiges JSON.') from None
if not isinstance(data,dict):raise APIError('JSON-Objekt erforderlich.')
if self.path=='/v1/chat/completions':return self.chat(ep,data)
if self.path=='/v1/audio/music':return self.music_generation(ep,data)
if self.path=='/v1/audio/speech':return self.speech(ep,data)
return self.image(ep,data)
except (APIError,InferenceError) as exc:
@@ -272,6 +281,21 @@ class APIHandler(BaseHTTPRequestHandler):
except (ValueError,OSError):time.sleep(.25);continue
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);return
raise APIError('TTS-Zeitlimit überschritten.',504)
def music_generation(self,ep,data):
if ep.scheduler.gpu_mode!='music':raise APIError('Bitte in der Übersicht auf Musik umschalten.',503,'music_mode_required')
if not ep.music:raise APIError('Musikworker nicht eingerichtet.',501)
if set(data)-{'model','lyrics','style','seed','max_tokens','steps'}:raise APIError('Nicht unterstützte Musikfelder.')
profile=ep.find_profile(data.get('model'),'music')
try:job=ep.music.start(profile['id'],data.get('lyrics'),data.get('style'),seed=data.get('seed',1234),max_tokens=data.get('max_tokens',400),steps=data.get('steps',8),wait=True)
except ValueError as exc:raise APIError(str(exc)) from None
end=time.monotonic()+1850
while time.monotonic()<end:
current=ep.music.status()['job']
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503)
try:body=ep.music.audio(job['id'])
except (ValueError,OSError):time.sleep(.5);continue
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.end_headers();self.wfile.write(body);return
ep.music.stop();raise APIError('Musik-Zeitlimit überschritten.',504)
def chat(self,ep,data):
try:data,self.compatibility=normalize_chat(data)
except CompatibilityError as exc:raise APIError(str(exc)) from None
@@ -302,7 +326,7 @@ class APIHandler(BaseHTTPRequestHandler):
def allowed():return ep.allowed() and any(p['id']==profile['id'] and p['revision']==profile['revision'] and p['enabled'] for p in ep.rows())
with ep.scheduler.lease(key,profile['parameters']['slots'],lambda:ep.worker.ensure(profile),allowed=allowed):
body=dict(data)
for field in ('temperature','top_p','top_k'):body.setdefault(field,profile['parameters'][field])
for field,value in generation_parameters(profile['parameters']).items():body.setdefault(field,value)
conn,key=ep.worker.connect()
try:
conn.request('POST','/v1/chat/completions',body=json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+key})
@@ -327,7 +351,8 @@ class APIHandler(BaseHTTPRequestHandler):
if data.get('n',1)!=1 or data.get('response_format','b64_json')!='b64_json':raise APIError('Unterstützt werden n=1 und response_format=b64_json.')
profile=ep.find_profile(data.get('model'),'image');params=profile['parameters']
from profiles import image_capabilities
if len(reference_images or [])>image_capabilities(profile.get('model'))['reference_images']:raise APIError('Das aktive Bildprofil unterstützt diese Anzahl Referenzbilder nicht.')
limit=image_capabilities(profile.get('model'))['reference_images']
if len(reference_images or [])>limit:raise APIError(f"Bildprofil {profile['name']}: {len(reference_images or [])} Referenzbilder erhalten; die Deck-Laufzeitanbindung erlaubt maximal {limit}.")
size=data.get('size')
if size is not None and (not isinstance(size,str) or (size!='auto' and not re.fullmatch(r'[1-9][0-9]{1,4}x[1-9][0-9]{1,4}',size))):raise APIError('size muss auto oder eine Auflösung wie 1024x1024 sein.')
# The selected profile owns resource limits; client size is only a preference.
+44 -4
View File
@@ -1,10 +1,10 @@
"""Read-only, conservative mapping to Deck's maintained execution adapters."""
from catalog import file_role
from stt import supported as stt_supported
from profiles import image_recipe, video_recipe
from profiles import image_recipe, image_adapter, video_recipe
from tts_runtime import REPO, REVISION
def assess(model, installed, profile=None):
def _assess(model, installed, profile=None):
kind=model.get('kind'); filename=model.get('file',''); repo=model.get('repo','')
meta=model.get('runtime_metadata') if isinstance(model.get('runtime_metadata'),dict) else {}
architecture=meta.get('architecture') if isinstance(meta.get('architecture'),str) else None
@@ -16,8 +16,8 @@ def assess(model, installed, profile=None):
result['family']='Qwen3-ASR 0.6B'
result.update(runtime='llama.cpp · Qwen3-ASR auf CPU',route='#stt-runtime',components=['Passender Audio-Projektor (unter Einrichten herunterladen)'])
elif kind=='image' and image_recipe(model):
result['family']='FLUX.2 Klein 9B' if 'FLUX' in filename.upper() else 'Qwen Image 2.1'
result.update(runtime='ComfyUI + ComfyUI-GGUF',route='#image-runtime',components=[x['label'] for x in image_recipe(model).values()])
result['family']=image_adapter(model)['label']
result.update(runtime=image_adapter(model)['runtime'],route='#image-runtime',components=[x['label'] for x in image_recipe(model).values()])
elif kind=='audio' and repo==REPO and filename=='model.safetensors' and model.get('revision')==REVISION:
result['family']='Qwen3-TTS CustomVoice'
result.update(runtime='Qwen3-TTS CustomVoice',route='#tts-runtime',components=['Vollständiges Modellpaket und Sprach-Tokenizer (im Einrichtungsassistenten enthalten)'])
@@ -46,3 +46,43 @@ def assess(model, installed, profile=None):
elif result['installed']:
result.update(state='profile_incomplete',label='Profil prüfen – Voraussetzungen fehlen')
return result
def assess(model, installed, profile=None):
result=_assess(model,installed,profile)
result['candidates']=[]
if result['state']=='component':return result
kind=model.get('kind');repo=model.get('repo','').lower();name=model.get('file','').lower()
meta=model.get('runtime_metadata') or {};family=str(meta.get('architecture') or '').lower()
def add(id,label,key,route,reason,maintained=True):
result['candidates'].append(dict(id=id,label=label,installed=bool(installed.get(key)),route=route,installation_available=maintained,evidence=reason))
if result.get('runtime'):
add('current',result['runtime'],kind,result['route'],'Vorhandene Deck-Zuordnung')
if kind=='music' and repo=='audio-cpp/yue2-3b-gguf' and name.startswith('yue2-3b-') and name.endswith('.gguf'):
result.update(family='YuE2',evidence='audio-cpp/Yue2-3B-GGUF · Hauptmodell',state='adapter_missing',label='YuE2 erkannt · Deck-Worker fehlt',message='Passende Laufzeit: audio.cpp. Sie kann einmalig heruntergeladen und gebaut werden. Zur Generierung fehlen noch der Deck-Musikworker und die Ladypoly-Anbindung. Installation allein schaltet dieses Modell nicht frei.',components=['YuE2-VAE GGUF','Modell-/Generierungskonfiguration, VAE-Konfiguration und Qwen-Tokenizer (sidecars)'])
if installed.get('music_worker'):
result.update(state='setup',label='YuE2-Musikworker verfügbar · Komponenten prüfen',message='audio.cpp-Laufzeit und benötigte Komponenten einrichten. Danach Musik → Testen verwenden oder Profil am Musik-API-Endpunkt freigeben.',runtime='audio.cpp',installed=bool(installed.get('audio_cpp')),route='#music')
if profile:
result.update(state='ready' if profile.get('runnable') else 'profile_incomplete',label='Ausführbar · audio.cpp-Musikworker' if profile.get('runnable') else 'Musikprofil einrichten',message='YuE2 wird mit den zugeordneten Komponenten über audio.cpp ausgeführt. Testen im Musikbereich; Ergebnisse als WAV. Ladypoly-WebUI ist separat.',blockers=profile.get('blockers',[]))
add('audio_cpp','audio.cpp','audio_cpp','#audio-cpp-runtime','Geprüfte GGUF-Quelle für die YuE2-Modellfamilie')
elif kind=='music' and ('yue2' in repo or 'yue2' in family):
result.update(family='YuE2',evidence='Repository-/Architekturhinweis; Ausführung ungeprüft')
add('yue2','YuE2 Python / PyTorch','yue2',None,'Originale YuE2-Pipeline; Originalgewichte erforderlich',False)
if kind in ('audio','stt','music','voice') and not result['candidates']:
if 'audio-cpp/' in repo and name.endswith('.gguf'):
add('audio_cpp','audio.cpp','audio_cpp','#audio-cpp-runtime','audio-cpp-Repository; konkrete Familie und Zusatzdateien noch prüfen')
elif meta.get('library_name')=='transformers':
add('transformers','Hugging Face Transformers / PyTorch','transformers',None,'Hub nennt Transformers; modellabhängiger Worker erforderlich',False)
if kind=='voice' and any(token in (repo+' '+name+' '+family) for token in ('roformer','demucs')):
add('separator','Audio Separator','separator','#separator-runtime','Trennmodell-Hinweis; exakte Datei und Konfiguration müssen zum unterstützten Paket passen')
result['candidate_message']='Unter Audio Separator sind geprüfte Modellpakete mit Konfiguration verfügbar. Beliebige CoreML-, ONNX- oder Safetensors-Abwandlungen sind nicht automatisch kompatibel.'
if kind=='video' and not result['candidates'] and meta.get('library_name')=='diffusers':
add('diffusers','Diffusers / PyTorch','diffusers',None,'Hub nennt Diffusers; passende Video-Pipeline noch prüfen',False)
if not result['candidates']:
# Category-level options are explicit research hints, never compatibility claims.
options={'chat':('llama.cpp','chat','#runtime'), 'image':('ComfyUI','image','#image-runtime'), 'video':('ComfyUI','image','#image-runtime'), 'audio':('audio.cpp','audio_cpp','#audio-cpp-runtime'), 'stt':('audio.cpp','audio_cpp','#audio-cpp-runtime'), 'music':('audio.cpp','audio_cpp','#audio-cpp-runtime'), 'voice':('audio.cpp','audio_cpp','#audio-cpp-runtime')}
if kind in options:
label,key,route=options[kind]
add('category',label,key,route,'Option für diese Kategorie; Kompatibilität mit dieser unbekannten Modellfamilie NICHT bestätigt')
result['candidate_message']='Keine passende Laufzeit zuverlässig ermittelt. Modellkarte und Architektur müssen geprüft werden; keine automatische Installation auf Verdacht.'
return result
+23 -8
View File
@@ -13,7 +13,7 @@ import threading
import time
import urllib.request
import uuid
from profiles import QWEN_REPO, FLUX_REPO, image_recipe, image_capabilities
from profiles import image_adapter, image_recipe, image_capabilities
PYTHON=Path('/opt/deck-image-python/bin/python')
COMFY=Path('/opt/deck-comfy')
@@ -46,9 +46,10 @@ def select_gpus(devices,model_size,encoder_size,vae_size,offload=False):
return max(images,key=lambda g:g['free_mib']),encoders[0]
def workflow(prompt,params,seed,family='qwen',references=()):
if references and family!='qwen':raise ValueError('Dieses Bildprofil unterstützt keine Referenzbilder.')
if family not in ('qwen','flux'):raise ValueError('Für diese Modellfamilie fehlt eine Bildlaufzeit-Anbindung.')
if len(references)>4:raise ValueError('Maximal vier Referenzbilder pro Auftrag.')
if family=='flux':
return {
graph={
'1':{'class_type':'UNETLoader','inputs':{'unet_name':'model.safetensors','weight_dtype':'default'}},
'2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors','family':'flux2'}},
'3':{'class_type':'VAELoader','inputs':{'vae_name':'vae.safetensors'}},
@@ -64,6 +65,20 @@ def workflow(prompt,params,seed,family='qwen',references=()):
'13':{'class_type':'ConditioningZeroOut','inputs':{'conditioning':['4',0]}}
}
# FLUX.2 editing uses reference latents on both conditioning branches,
# with fresh output noise; never silently discard uploaded references.
positive=['4',0];negative=['13',0]
for index,name in enumerate(references):
load,scale,encode,pos,neg=map(str,range(20+index*5,25+index*5))
graph[load]={'class_type':'LoadImage','inputs':{'image':name}}
graph[scale]={'class_type':'ImageScaleToTotalPixels','inputs':{'image':[load,0],'upscale_method':'lanczos','megapixels':1.0,'resolution_steps':1}}
graph[encode]={'class_type':'VAEEncode','inputs':{'pixels':[scale,0],'vae':['3',0]}}
graph[pos]={'class_type':'ReferenceLatent','inputs':{'conditioning':positive,'latent':[encode,0]}}
graph[neg]={'class_type':'ReferenceLatent','inputs':{'conditioning':negative,'latent':[encode,0]}}
positive=[pos,0];negative=[neg,0]
graph['10']['inputs'].update(positive=positive,negative=negative)
return graph
graph={
'1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}},
'2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors'}},
@@ -78,7 +93,7 @@ def workflow(prompt,params,seed,family='qwen',references=()):
graph['4']['inputs']['vae']=['3',0]
for index,name in enumerate(references,1):
graph[str(20+index)]={'class_type':'LoadImage','inputs':{'image':name}}
graph['4']['inputs'][f'image_{index}']=[str(20+index),0]
graph['4']['inputs'][f'images.image_{index}']=[str(20+index),0]
graph['6']['inputs']['latent_image']=['4',2]
return graph
@@ -145,11 +160,11 @@ class ImageTests:
encoder=self.profiles._component(model,'text_encoder',profile.get('components',{}).get('text_encoder'))
vae=self.profiles._component(model,'vae',profile.get('components',{}).get('vae'))
mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith(('MemAvailable:','MemTotal:'))}
required_ram=max(20*GIB,encoder['size']*2+4*GIB,model['size']+8*GIB if model['repo']==FLUX_REPO else 0)
required_ram=max(20*GIB,encoder['size']*2+4*GIB,model['size']+8*GIB if image_adapter(model)['offload'] else 0)
headroom=cgroup_headroom()
if headroom is not None and headroom<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
if mem.get('MemAvailable',0)<required_ram+4*GIB:raise ValueError('Aktuell zu wenig freier System-RAM; produktive Dienste bleiben unverändert.')
gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size'],offload=model['repo']==FLUX_REPO);self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size'],offload=image_adapter(model)['offload']);self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
if shutil.disk_usage(self.root).free<10*GIB:raise ValueError('Weniger als 10 GiB freier Plattenspeicher.')
job_id=uuid.uuid4().hex;seed=params['seed'] if params['seed']>=0 else secrets.randbelow(2147483648)
self.cancel.clear();self.job=dict(id=job_id,state='running',phase='Bildlaufzeit startet',profile_id=profile_id,profile_name=profile['name'],started_at=time.time(),gpu=gpu['name'],encoder_gpu=encoder_gpu['name'],seed=seed,parameters=params,reference_count=len(references))
@@ -166,7 +181,7 @@ class ImageTests:
if self.cancel.is_set():raise InterruptedError()
self._phase('Prompt-Aufwerter entladen; Bildlaufzeit startet')
directory.mkdir(mode=0o700)
for role,item,filename in [('unet',model,'model.safetensors' if model['repo']==FLUX_REPO else 'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]:
for role,item,filename in [('unet',model,image_adapter(model)['model_filename']),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]:
dest=directory/'models'/role;dest.mkdir(parents=True);(dest/filename).symlink_to(self.model_path(item))
for folder in ('output','temp','user','input'):(directory/folder).mkdir()
names=[]
@@ -203,7 +218,7 @@ class ImageTests:
if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.')
time.sleep(1)
nodes=request('/object_info')
graph=workflow(prompt,params,seed,'flux' if model['repo']==FLUX_REPO else 'qwen',names)
graph=workflow(prompt,params,seed,image_adapter(model)['workflow'],names)
required={node['class_type'] for node in graph.values()}
if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Nodes für dieses Bildmodell.')
self._phase('Auftrag wird verarbeitet · Textencoder auf RTX 3060 · Bildberechnung auf RTX 5080')
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/dashboard.css"><link rel="stylesheet" href="/themes.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="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/ltx-original-ui.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/dashboard-ui.js" defer></script><script src="/backup-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#dashboard"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#dashboard">Dashboard <span>01</span></a><a href="#home">Übersicht <span>02</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#backup">Backup &amp; Restore <span>↗</span></a><a href="#appearance">Darstellung <span>↗</span></a><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#ltx-original-runtime">LTX Original</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt &amp; Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</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"><link rel="stylesheet" href="/dashboard.css"><link rel="stylesheet" href="/themes.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="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/separator-ui.js" defer></script><script src="/music-ui.js" defer></script><script src="/audio-cpp-ui.js" defer></script><script src="/ltx-original-ui.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/dashboard-ui.js" defer></script><script src="/backup-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#dashboard"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><p class="nav-heading">Übersicht</p><a href="#dashboard">Dashboard</a><a href="#home">Steuerung</a><p class="nav-heading">KI-Werkzeuge</p><a href="#chat">Chat &amp; Sprachmodelle</a><a href="#image">Bildgenerierung</a><a href="#music">Musikgenerierung</a><a href="#separation">Audio-Trennung</a><a href="#audio">Sprachausgabe (TTS)</a><a href="#stt">Spracherkennung (STT)</a><a href="#voice">Stimmwerkzeuge</a><a href="#video">Videogenerierung</a><p class="nav-heading">Anwendungen</p><a href="#services">Weitere Dienste</a><p class="nav-heading">Einstellungen</p><a href="#runtimes">Laufzeiten</a><a href="#endpoint">Endpunkt</a><a href="#network">Netzwerk</a><a href="#access">Zugang &amp; API-Token</a><a href="#appearance">Darstellung</a><a href="#backup">Backup &amp; Wiederherstellung</a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
+15 -8
View File
@@ -23,14 +23,14 @@ class Scheduler:
def status(self):
with self.cv:return dict(active_requests=self.active+self.tts_active,waiting_requests=len(self.queue),switching=self.transition)
@contextlib.contextmanager
def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True):
def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True, modes=("llm",)):
ticket=object();deadline=time.monotonic()+timeout;claimed=False
with self.cv:
if len(self.queue)>=16:raise InferenceError('Warteschlange voll. Bitte später erneut versuchen.')
self.queue.append(ticket)
try:
while True:
if self.gpu_mode!="llm":raise InferenceError("Video-Modus aktiv oder Moduswechsel läuft; GPU-Aufträge sind gesperrt.")
if self.gpu_mode not in modes:raise InferenceError("Andere Betriebsart aktiv oder Moduswechsel läuft; GPU-Auftrag ist gesperrt.")
if not allowed():raise InferenceError('Endpunkt wird gestoppt oder Profil ist nicht mehr aktiviert.')
if self.queue[0] is ticket and not self.transition and (not self.active or (self.key==key and self.active<slots)) and (not self.tts_active or self.key==key and key!=('image',)):
self.queue.pop(0);self.active+=1;claimed=True
@@ -47,7 +47,7 @@ class Scheduler:
if self.transition:
try:
if switch:self.worker.stop()
if key==('image',) or switch:self.evict_tts()
if key in (('image',),('separator',)) or switch:self.evict_tts()
if prepare:prepare()
except Exception:
self.worker.stop()
@@ -60,7 +60,7 @@ class Scheduler:
if claimed:
with self.cv:self.active-=1;self.cv.notify_all()
def image_reservation(self, wait=False,kind='image'):
lease=self.lease((kind,),timeout=600 if wait else 0)
lease=self.lease((kind,),timeout=600 if wait else 0,modes=("music",) if kind=="music" else ("llm","separator") if kind=="separator" else ("llm",))
lease.__enter__()
return lambda:lease.__exit__(None,None,None)
def tts_reservation(self,wait=False):
@@ -158,6 +158,7 @@ class LlamaWorker:
if params['split_mode']=='none':selected=selected[:1]
if not selected or any(g['processes'] or g['free_mib']<2048 for g in selected):raise InferenceError('Benötigte GPU ist durch einen anderen Dienst belegt. Deck stoppt keine fremden Prozesse.')
mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('MemAvailable:')}
model_devices=list(selected)
projector=None;vision_args=[];vision_bytes=0;vision_device=params.get('vision_device','cpu')
if params.get('vision_projector'):
projector=self.catalog.entry(params['vision_projector'])
@@ -166,7 +167,11 @@ class LlamaWorker:
vision_args=['--mmproj',str((self.catalog.root/projector['id']/'model.gguf').resolve())]
if vision_device=='cpu':vision_args+=['--no-mmproj-offload']
else:
if vision_device not in [g['uuid'] for g in selected]:raise InferenceError('Projektor-GPU ist nicht ausgewählt.')
if vision_device not in [g['uuid'] for g in selected]:
target=next((g for g in devices if g['uuid']==vision_device),None)
if target is None:raise InferenceError('Projektor-GPU ist nicht verfügbar.')
if target['processes'] or target['free_mib']<2048:raise InferenceError('Projektor-GPU ist durch einen anderen Dienst belegt.')
selected.append(target)
vision_args+=['--mmproj-device','CUDA'+str([g['uuid'] for g in selected].index(vision_device)),'--mmproj-offload']
staging=entry['size']+4*GIB+vision_bytes
if mem.get('MemAvailable',0)<staging+4*GIB:raise InferenceError('Zu wenig freier RAM für Modellladen und Host-Reserve.')
@@ -177,7 +182,8 @@ class LlamaWorker:
env.update(CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in selected),HF_HUB_OFFLINE='1',OMP_NUM_THREADS=str(params['threads']))
reserve_mode=params.get('gpu_reserve_mode','auto')
margins=[0 if reserve_mode=='none' else params.get('gpu_reserve_mib',{}).get(g['uuid'],512) if reserve_mode=='manual' else max(512,round(g['total_mib']*.025)) for g in selected]
common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k',params.get('cache_type_k','q4_0'),'--cache-type-v',params.get('cache_type_v','q4_0'),'--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(map(str,margins))]
common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k',params.get('cache_type_k','q4_0'),'--cache-type-v',params.get('cache_type_v','q4_0'),'--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(map(str,margins[:len(model_devices)]))]
common+=['--device',','.join('CUDA'+str(i) for i in range(len(model_devices)))]
if params['tensor_split']:common+=['--tensor-split',','.join(map(str,params['tensor_split']))]
tool=str(directory/'build/bin/llama-fit-params')
mtp_fit=['--deck-mtp'] if mtp else []
@@ -188,7 +194,8 @@ class LlamaWorker:
for line in output.splitlines():
parts=line.split()
if len(parts)==4 and all(x.isdigit() for x in parts[1:]):rows[parts[0]]=sum(map(int,parts[1:]))
if 'Host' not in rows or any('CUDA'+str(i) not in rows for i in range(len(selected))):raise InferenceError('Speicherprognose unvollständig; Start abgebrochen.')
if 'Host' not in rows or any('CUDA'+str(i) not in rows for i in range(len(model_devices))):raise InferenceError('Speicherprognose unvollständig; Start abgebrochen.')
for i in range(len(model_devices),len(selected)):rows.setdefault('CUDA'+str(i),0)
if projector:
target='Host' if vision_device=='cpu' else 'CUDA'+str([g['uuid'] for g in selected].index(vision_device))
rows[target]+=round(vision_bytes/1024**2)
@@ -226,7 +233,7 @@ class LlamaWorker:
layers=int(fit['-ngl'])
if '-ts' in fit:
parts=fit['-ts'].split(',')
if len(parts)!=len(selected) or any(not x.replace('.','',1).isdigit() for x in parts):raise InferenceError('Ungültige automatische GPU-Verteilung.')
if len(parts)!=len(model_devices) or any(not x.replace('.','',1).isdigit() for x in parts):raise InferenceError('Ungültige automatische GPU-Verteilung.')
extra=['--tensor-split',fit['-ts']]
fits,memory=estimate(layers,extra)
if not fits:raise InferenceError('Speicherprognose überschreitet die GPU-/RAM-Reserve.')
+15 -3
View File
@@ -1,9 +1,21 @@
async function copyTokenValue(value){
if(!value)throw Error('empty');
try{if(navigator.clipboard?.writeText){await navigator.clipboard.writeText(value);return;}}catch{}
const active=document.activeElement,selection=window.getSelection(),ranges=[];
if(selection)for(let i=0;i<selection.rangeCount;i++)ranges.push(selection.getRangeAt(i).cloneRange());
const area=document.createElement('textarea');area.value=value;area.readOnly=true;
area.style.cssText='position:fixed;left:-9999px;top:0;opacity:0';document.body.append(area);
try{area.focus();area.select();if(!document.execCommand('copy'))throw Error('clipboard');}
finally{area.value='';area.remove();active?.focus();if(selection){selection.removeAllRanges();for(const range of ranges)selection.addRange(range);}}
}
let firstSetup=false;
const form=document.querySelector('#login'),message=document.querySelector('#error');
function freshToken(){const bytes=crypto.getRandomValues(new Uint8Array(32));return 'ad_'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
async function load(){try{const r=await fetch('/api/v1/auth/status'),s=await r.json();firstSetup=!s.initialized;document.querySelector('#title').textContent=firstSetup?'Dein Zugang zu Athena.':'Willkommen zurück.';document.querySelector('#intro').textContent=firstSetup?'Lege das Kennwort für diese Oberfläche und einen separaten Token für API-Clients fest.':'Melde dich mit deinem Oberflächenkennwort an.';document.querySelector('#setup-fields').hidden=!firstSetup;document.querySelector('#password').autocomplete=firstSetup?'new-password':'current-password';document.querySelector('#submit').textContent=firstSetup?'Zugang einrichten':'Anmelden';form.hidden=false;}catch(e){message.textContent='Zugangsstatus nicht erreichbar.'}}
document.querySelector('#generate').onclick=()=>{document.querySelector('#api-token').value=freshToken();message.textContent='Token erzeugt. Vor dem Einrichten kopieren und sicher speichern.';};
const syncSetupCopy=()=>document.querySelector('#copy').disabled=!document.querySelector('#api-token').value;
document.querySelector('#api-token').addEventListener('input',syncSetupCopy);syncSetupCopy();
document.querySelector('#generate').onclick=()=>{document.querySelector('#api-token').value=freshToken();syncSetupCopy();message.textContent='Token erzeugt. Vor dem Einrichten kopieren und sicher speichern.';};
document.querySelector('#show-token').onclick=()=>{const input=document.querySelector('#api-token');input.type=input.type==='password'?'text':'password';document.querySelector('#show-token').textContent=input.type==='password'?'Token anzeigen':'Token verbergen';};
document.querySelector('#copy').onclick=async()=>{try{const token=document.querySelector('#api-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);message.textContent='Token kopiert. Bitte sicher speichern.';}catch{message.textContent='Token konnte nicht kopiert werden. Bitte einen eigenen Token aus deinem Passwortmanager einfügen.'}};
form.addEventListener('submit',async event=>{event.preventDefault();const input=document.querySelector('#password'),button=document.querySelector('#submit');button.disabled=true;try{let data={password:input.value},path='login';if(firstSetup){if(input.value!==document.querySelector('#repeat').value)throw Error('Die Kennwörter stimmen nicht überein.');data.api_token=document.querySelector('#api-token').value;path='auth/setup';}const r=await fetch('/api/v1/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const value=await r.json();if(!r.ok)throw Error(value.error);form.reset();if(firstSetup){await load();message.textContent='Zugang eingerichtet. Bitte jetzt anmelden.';}else location.reload();}catch(e){message.textContent=e.message}finally{button.disabled=false}});
document.querySelector('#copy').onclick=async()=>{try{const token=document.querySelector('#api-token').value;if(!token)throw Error();await copyTokenValue(token);message.textContent='Token kopiert. Bitte sicher speichern.';}catch{message.textContent='Der Browser hat das Kopieren nicht erlaubt. Bitte „Token anzeigen“ wählen und manuell kopieren.'}};
form.addEventListener('submit',async event=>{event.preventDefault();const input=document.querySelector('#password'),button=document.querySelector('#submit');button.disabled=true;try{let data={password:input.value},path='login';if(firstSetup){if(input.value!==document.querySelector('#repeat').value)throw Error('Die Kennwörter stimmen nicht überein.');data.api_token=document.querySelector('#api-token').value;path='auth/setup';}const r=await fetch('/api/v1/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const value=await r.json();if(!r.ok)throw Error(value.error);form.reset();syncSetupCopy();if(firstSetup){await load();message.textContent='Zugang eingerichtet. Bitte jetzt anmelden.';}else location.reload();}catch(e){message.textContent=e.message}finally{button.disabled=false}});
load();
+3 -2
View File
@@ -14,6 +14,7 @@ DESKTOP_REV='68cd86c15e5fd25f56229ea63c0dbcb0338f7812'
LTX_REV='d151147788a9284cca791edc6ce898007e727fe6'
class LTXOriginalRuntime:
name="LTX Original"
def __init__(self,root,image_runtime):
self.root=Path(root);self.image_runtime=image_runtime;self.lock=threading.RLock();self.cancel=threading.Event();self.process=None;self.worker=None;self.job=None
path=self.root/'installation.json'
@@ -37,8 +38,8 @@ class LTXOriginalRuntime:
with self.lock:self.job['phase']=phase;self._save()
def start(self):
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('LTX-Installation läuft bereits.')
if self.status()['installed']:raise ValueError('LTX Original ist bereits installiert.')
if self.job and self.job['state']=='running':raise ValueError(self.name+'-Installation läuft bereits.')
if self.status()['installed']:raise ValueError(self.name+' ist bereits installiert.')
if sys.platform!='linux' or not shutil.which('git'):raise ValueError('Debian/Linux mit Git und Python venv erforderlich.')
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
need=self.status()['required_disk_gib']
+12
View File
@@ -0,0 +1,12 @@
window.MusicUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const x=await r.json();if(!r.ok)throw Error(x.error||'Musikanfrage fehlgeschlagen');return x;}
function html(){return '<section id="music-test" class="card"><h2>Musik ausprobieren</h2><p>YuE2 über audio.cpp. Englische Songtexte werden empfohlen. Die maximale Länge begrenzt den Test. YuE2 kann früher enden; eine exakt feste Titellänge ist damit nicht garantiert.</p><p id="music-state" role="status"></p><p id="music-error" role="alert"></p><div id="music-progress"></div><form id="music-form"><label>Profil<select id="music-profile" required></select></label><label>Musikstil<textarea id="music-style" maxlength="2000" required placeholder="English, acoustic pop, warm vocals, gentle guitar"></textarea></label><label>Songtext<textarea id="music-lyrics" maxlength="8000" rows="6" required placeholder="[Verse] … [Chorus] …"></textarea></label><label>Maximale Länge (Sekunden)<input type="number" id="music-duration" min="2" max="360" step="1" value="16" required></label><p id="music-length-hint" class="small">Bis etwa 16 Sekunden · 400 semantische Tokens. YuE2 verwendet hier ungefähr 25 Tokens pro Sekunde.</p><details><summary>Erweiterte Testparameter</summary><label>Maximale semantische Tokens<input type="number" id="music-tokens" min="32" max="9000" value="400"></label><label>NAR-Schritte<input type="number" id="music-steps" min="1" max="32" value="8"></label><label>Seed<input type="number" id="music-seed" min="0" max="2147483647" value="1234"></label></details><button id="music-start">Musik erzeugen</button><button type="button" class="secondary" id="music-cancel">Abbrechen</button></form><div id="music-result"></div><p>RTX 5080 bevorzugt. Deck-Aufträge werden koordiniert. Nach der Anfrage wird das Modell entladen. Text und Stil werden nicht dauerhaft gespeichert.</p></section>';}
function bind(){const root=document.querySelector('#music-test'),el=id=>root.querySelector('#'+id);let ready=false,busy=false,resultId=null;
async function poll(){if(!root.isConnected)return;try{const s=await api('music');if(!root.isConnected)return;const j=s.job,running=j?.state==='running';el('music-state').textContent=(s.mode!=='music'?'Bitte in der Übersicht auf Musik umschalten.':j?.phase||'Profil wählen und Musikstil sowie Songtext eingeben.')+(running?' · '+Math.round((Date.now()/1000)-j.started_at)+' s':'');el('music-progress').innerHTML=running?'<progress aria-label="Musikgenerierung läuft"></progress>':'';el('music-start').disabled=busy||running||!ready||s.mode!=='music';el('music-cancel').disabled=!running;if(!busy&&j?.state==='complete'&&j.id!==resultId){resultId=j.id;el('music-result').innerHTML=`<h3>Ergebnis · ${e(j.duration_seconds)} Sekunden</h3><audio controls src="/api/v1/music/audio?id=${e(j.id)}"></audio><p><a download="athena-musik.wav" href="/api/v1/music/audio?id=${e(j.id)}">WAV herunterladen</a></p>`;}if(j?.state==='failed')el('music-error').textContent=j.phase;}catch(x){if(root.isConnected)el('music-error').textContent=x.message;}if(root.isConnected)setTimeout(poll,1500);}
function updateLength(fromTokens=false){const tokens=fromTokens?Number(el('music-tokens').value):Math.round(Number(el('music-duration').value)*25);if(!fromTokens)el('music-tokens').value=tokens;else el('music-duration').value=(tokens/25).toFixed(2);el('music-length-hint').textContent=`Bis etwa ${(tokens/25).toFixed(2)} Sekunden · ${tokens} semantische Tokens. Das Modell kann früher enden.`;}
el('music-duration').step='0.04';el('music-duration').min='1.28';el('music-duration').oninput=()=>updateLength();el('music-tokens').oninput=()=>updateLength(true);
api('profiles').then(s=>{if(!root.isConnected)return;const profiles=s.profiles.filter(p=>p.kind==='music'&&p.runnable);ready=profiles.length>0;el('music-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst Profil und Komponenten einrichten</option>';}).catch(x=>el('music-error').textContent=x.message);
el('music-form').onsubmit=async event=>{event.preventDefault();busy=true;el('music-start').disabled=true;el('music-error').textContent='';el('music-result').replaceChildren();resultId=null;try{await api('music/start',{profile_id:el('music-profile').value,lyrics:el('music-lyrics').value,style:el('music-style').value,seed:Number(el('music-seed').value),max_tokens:Number(el('music-tokens').value),steps:Number(el('music-steps').value)});}catch(x){el('music-error').textContent=x.message;}finally{busy=false;}};el('music-cancel').onclick=()=>api('music/cancel',{}).catch(x=>el('music-error').textContent=x.message);poll();}
return {html,bind};
})();
+86
View File
@@ -0,0 +1,86 @@
"""Owned audio.cpp music jobs, coordinated GPU lease, no persistent prompts."""
import os,signal,subprocess,threading,time,uuid,wave
from pathlib import Path
from profiles import music_recipe
from image_test import probe
class Music:
def __init__(self,root,profiles,catalog,runtime,scheduler):
self.root=Path(root);self.profiles=profiles;self.catalog=catalog;self.runtime=runtime;self.scheduler=scheduler;self.lock=threading.RLock();self.job=None;self.process=None;self.cancel=threading.Event()
def blockers(self,p):
if not music_recipe(p['model']):return ['Für diese Musik-Modellfamilie fehlt noch eine geprüfte audio.cpp-Anbindung.']
result=[] if self.runtime.status()['installed'] else ['audio.cpp zuerst unter Laufzeiten installieren.']
for role,info in music_recipe(p['model']).items():
try:self.profiles._component(p['model'],role,p.get('components',{}).get(role))
except ValueError:result.append(info['label']+' fehlt oder ist nicht zugeordnet.')
return result
def status(self):
with self.lock:return dict(job=dict(self.job) if self.job else None,loaded=bool(self.process and self.process.poll() is None),mode=self.scheduler.gpu_mode,runtime_installed=self.runtime.status()['installed'])
def start(self,profile_id,lyrics,style,seed=1234,max_tokens=400,steps=8,wait=False):
if not isinstance(lyrics,str) or not 1<=len(lyrics.strip())<=8000:raise ValueError('Songtext mit 1–8000 Zeichen eingeben.')
if not isinstance(style,str) or not 1<=len(style.strip())<=2000:raise ValueError('Musikstil mit 1–2000 Zeichen eingeben.')
for value,lo,hi in ((seed,0,2**31-1),(max_tokens,32,9000),(steps,1,32)):
if type(value) is not int or not lo<=value<=hi:raise ValueError('Ungültige Musikparameter.')
if self.scheduler.gpu_mode!='music':raise ValueError('Bitte in der Übersicht auf Musik umschalten.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Musikauftrag läuft bereits.')
p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='music'),None)
if not p or not p['runnable']:raise ValueError('Musikprofil noch nicht vollständig eingerichtet.')
self.cancel.clear();self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf Deck-GPU-Freigabe',profile_id=profile_id,started_at=time.time(),max_tokens=max_tokens)
threading.Thread(target=self._run,args=(p,lyrics,style,seed,max_tokens,steps,wait),daemon=True).start();return dict(self.job)
def _run(self,p,lyrics,style,seed,max_tokens,steps,wait):
release=None;state='failed';phase='Musikgenerierung fehlgeschlagen.';directory=self.root/self.job['id'];log=directory/'worker.tmp'
try:
release=self.scheduler.image_reservation(wait=wait,kind="music")
if self.cancel.is_set():raise InterruptedError()
required=int(p['model']['size']/1024**2)+1800
gpu=next((g for g in probe() if 'RTX 5080' in g['name'] and not g['processes'] and g['free_mib']>required),None)
if not gpu:raise ValueError('RTX 5080 nicht ausreichend frei. Fremde GPU-Prozesse werden nicht beendet.')
directory.mkdir(mode=0o700);bundle=directory/'package';bundle.mkdir();(bundle/'sidecars').mkdir()
def link(entry,name):
source=self.catalog.root/entry['id']/('model'+Path(entry['file']).suffix)
(bundle/name).symlink_to(source.resolve())
link(p['model'],p['model']['file'])
for role in music_recipe(p['model']):
entry=self.profiles._component(p['model'],role,p['components'][role]);link(entry,entry['file'])
vae=self.catalog.entry(p['components']['vae'])['file'];cli=self.runtime.paths()[0]
args=[str(cli),'--task','gen','--family','yue2','--model',str(bundle),'--backend','cuda','--device','0','--threads','6','--session-option','yue2.model_gguf='+p['model']['file'],'--session-option','yue2.vae_gguf='+vae,'--lyrics',lyrics,'--request-option','style='+style,'--request-option','cot=off','--request-option','semantic_min_tokens=0','--request-option','semantic_max_tokens='+str(max_tokens),'--request-option','num_inference_steps='+str(steps),'--seed',str(seed),'--out',str(directory/'result.wav'),'--log']
with self.lock:self.job.update(phase='YuE2 wird auf der RTX 5080 geladen · anschließend Musik erzeugen',gpu=gpu['name'])
with log.open('w') as out:
with self.lock:
if self.cancel.is_set():raise InterruptedError()
self.process=subprocess.Popen(args,stdout=out,stderr=subprocess.STDOUT,start_new_session=True,env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid'],OMP_NUM_THREADS='6'))
deadline=time.monotonic()+1800
while self.process.poll() is None:
if self.cancel.wait(.5):raise InterruptedError()
if time.monotonic()>deadline:raise ValueError('Zeitlimit von 30 Minuten erreicht.')
with self.lock:self.job['elapsed_seconds']=round(time.time()-self.job['started_at'])
if self.process.returncode:
output=log.read_text(errors='replace').lower()
if 'out of memory' in output or 'cuda error 2' in output:raise ValueError('GPU-Speicher reicht nicht (Out of Memory). Kleinere Quantisierung oder kürzeren Test wählen.')
raise ValueError('audio.cpp hat die Generierung abgebrochen. Modellpaket oder Laufzeit prüfen.')
with wave.open(str(directory/'result.wav'),'rb') as wav:
if wav.getnframes()<1 or wav.getnchannels() not in (1,2):raise ValueError('Ungültige WAV-Ausgabe.')
duration=wav.getnframes()/wav.getframerate()
(directory/'complete').touch();state='complete';phase='Musik fertig · Modell entladen'
with self.lock:self.job['duration_seconds']=round(duration,2)
except InterruptedError:state='cancelled';phase='Musikgenerierung abgebrochen · Modell entladen'
except Exception as exc:phase=str(exc) if isinstance(exc,ValueError) else 'Musikgenerierung fehlgeschlagen. Modellpaket und Laufzeit prüfen.'
finally:
process=self.process
if process and process.poll() is None:
try:os.killpg(process.pid,signal.SIGTERM);process.wait(5)
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
except ProcessLookupError:pass
log.unlink(missing_ok=True)
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time())
if release:release()
def stop(self):
self.cancel.set();return {'cancellation_requested':True}
def audio(self,ident):
if not isinstance(ident,str) or len(ident)!=32 or any(c not in '0123456789abcdef' for c in ident):raise ValueError('Ungültige Auftrag-ID.')
directory=self.root/ident
if not (directory/'complete').is_file():raise ValueError('Musik noch nicht fertig.')
wav=directory/'result.wav'
if wav.stat().st_size>256*1024**2:raise ValueError('Audioausgabe zu groß.')
return wav.read_bytes()
+14 -12
View File
@@ -1,15 +1,16 @@
window.ProfilesUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const labels={video_device:'Videopipeline',text_encoder_device:'Textencoder-Gerät',gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',cache_type_k:'KV-Cache K',cache_type_v:'KV-Cache V',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',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 labels={video_device:'Videopipeline',text_encoder_device:'Textencoder-Gerät',gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',cache_type_k:'KV-Cache K',cache_type_v:'KV-Cache V',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',repeat_penalty:'Wiederholungs-Penalty',presence_penalty:'Presence-Penalty',frequency_penalty:'Frequency-Penalty',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',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={},enabled=[],gpus=[],projectors=[],entries=[],panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);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]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${['image','video'].includes(kind)?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</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>`;
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId));
el('profile-list').innerHTML=rows.map(p=>`<details class="detail-row" data-row-id="${e(p.id)}" ${openRows.has(p.id)?'open':''}><summary class="detail-summary"><strong>${e(p.name)}</strong><span class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</span><span>${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}</span><span>${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}<small>${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}</small></span></summary><div class="detail-body"><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button><button class="secondary" data-components="${p.id}">Einrichtung & Komponenten</button>${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></div></details>`).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>`;
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Steuerung lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':kind==='music'?'Gib ein eingerichtetes Musikprofil frei, um es in der Steuerung verwenden zu können.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Steuerung lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers)));
@@ -31,28 +32,29 @@ window.ProfilesUI=(()=>{
}
refresh();el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
}
async function components(profile){
async function components(profile,extraRepo){
const sequence=++panelSequence;
el('profile-editor').innerHTML='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>';
try{
const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id}));if(!root.isConnected||sequence!==panelSequence)return;
el('profile-editor').innerHTML=`<section class="card editor"><div class="card-top"><h2>Komponenten · ${e(profile.name)}</h2><button class="secondary" id="components-close">Schließen</button></div><p>${e(data.message)}</p>${data.supported?`<p>Quelle: <a class="link" href="${e(data.source)}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> · ${e(data.license||'Lizenz nicht angegeben')}</p><p>${profile.kind==='image'?'<a class="link" href="#image-runtime">Bildlaufzeit →</a> · ':'Videolaufzeit · '}${e(data.runtime)} · ${data.runtime_installed?'installiert':'noch nicht eingerichtet'}</p><button type="button" id="components-download-all">Alle fehlenden Dateien herunterladen</button><form id="components-form">${data.requirements.map(r=>`<section class="card component-card"><h3>${e(r.label)}</h3><label>Datei aus der Bibliothek<select name="${r.role}"><option value="">Noch nicht zugeordnet</option>${r.available.map(f=>`<option value="${f.id}" ${(profile.components?.[r.role]===f.id||(!profile.components?.[r.role]&&r.available.length===1))?'selected':''}>${e(f.file)} · ${(f.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${r.candidates.map((f,i)=>`<div class="note"><p class="hub-filename">${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}</p>${f.gated&&!f.auth_configured?'<p>Hugging-Face-Zugang unter <a href="#access">Einstellungen → Zugang</a> hinterlegen und Modellfreigabe prüfen.</p>':''}<button type="button" class="secondary" data-component-download="${r.role}:${i}" ${(f.gated&&!f.auth_configured)||r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'disabled':''}>${r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'Bereits in Bibliothek':'Datei herunterladen'}</button></div>`).join('')||'<p>Keine passende Quelldatei verfügbar.</p>'}</section>`).join('')}<div class="card-actions"><button id="components-save">Zuordnung auf Athena speichern</button><button type="button" class="secondary" id="components-refresh">Bibliothek aktualisieren</button></div><p class="small">Lade je eine Datei pro benötigter Komponente. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.</p></form>`:''}<p id="components-message" role="status"></p></section>`;
const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id,...(extraRepo?{repo:extraRepo}:{})}));if(!root.isConnected||sequence!==panelSequence)return;
el('profile-editor').innerHTML=`<section class="card editor"><div class="card-top"><h2>Komponenten · ${e(profile.name)}</h2><button class="secondary" id="components-close">Schließen</button></div><p>${e(data.message)}</p><details><summary>Andere Komponentenquelle auswählen (ungeprüft)</summary><label>Hugging-Face-Link oder Repository<input id="component-source" placeholder="https://huggingface.co/Organisation/Modell"></label><button id="component-source-open" type="button">Dateien anzeigen</button></details>${data.supported?`<p>Quelle: <a class="link" href="${e(data.source)}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> · ${e(data.license||'Lizenz nicht angegeben')}</p><p>Laufzeit: ${e(data.runtime)} · ${data.manual?'Zuordnung ungeprüft':data.runtime_installed?'installiert':'noch nicht eingerichtet'}</p>${!data.manual?'<button type="button" id="components-download-all">Alle fehlenden Dateien herunterladen</button>':'<p>Nur benötigte Dateien einzeln auswählen; diese Liste ist kein automatischer Downloadplan.</p>'}<form id="components-form">${data.requirements.map(r=>`<section class="card component-card"><h3>${e(r.label)}</h3><label>Datei aus der Bibliothek<select name="${r.role}"><option value="">Noch nicht zugeordnet</option>${r.available.map(f=>`<option value="${f.id}" ${(profile.components?.[r.role]===f.id||(!data.manual&&!profile.components?.[r.role]&&r.available.length===1))?'selected':''}>${e(f.file)} · ${(f.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${r.candidates.map((f,i)=>`<div class="note"><p data-download-feedback="${r.role}:${i}" role="status"></p><p class="hub-filename">${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}</p>${f.gated&&!f.auth_configured?'<p>Hugging-Face-Zugang unter <a href="#access">Einstellungen → Zugang</a> hinterlegen und Modellfreigabe prüfen.</p>':''}<button type="button" class="secondary" data-component-download="${r.role}:${i}" ${(f.gated&&!f.auth_configured)||r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'disabled':''}>${r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'Bereits in Bibliothek':'Datei herunterladen'}</button></div>`).join('')||'<p>Keine passende Quelldatei verfügbar.</p>'}</section>`).join('')}<div class="card-actions"><button id="components-save">Zuordnung auf Athena speichern</button><button type="button" class="secondary" id="components-refresh">Bibliothek aktualisieren</button></div><p class="small">Lade je eine Datei pro benötigter Komponente. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.</p></form>`:''}<p id="components-message" role="status"></p></section>`;
const missing=data.requirements?.filter(r=>!r.available.length)||[];
if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{
const b=el('components-download-all');b.disabled=true;let count=0;
try{for(const r of missing){const f=r.candidates.find(f=>!f.gated||f.auth_configured);if(!f)throw Error('Keine herunterladbare Datei; HF-Zugang oder Quellversion prüfen für '+r.label);const catalog=await api('catalog');if((catalog.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===f.repo&&j.file===f.name&&j.revision===f.revision))continue;await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});count++;}if(el('components-message'))el('components-message').textContent='Dateien sind im Downloadplan. Nach Abschluss Bibliothek aktualisieren und Zuordnung speichern; passende Dateien werden vorausgewählt.';}
catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}
};}
el('component-source-open').onclick=()=>{const value=el('component-source').value.trim();if(value)components(profile,value);};
el('components-close').onclick=()=>el('profile-editor').replaceChildren();
el('components-refresh')?.addEventListener('click',()=>components(profile));
el('components-refresh')?.addEventListener('click',()=>components(profile,extraRepo));
el('components-form')?.addEventListener('submit',async event=>{event.preventDefault();const button=el('components-save');button.disabled=true;try{await api('profiles/components',{id:profile.id,revision:profile.revision,components:Object.fromEntries(new FormData(event.target))});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Komponenten dem Profil zugeordnet. Kein Modell gestartet.');}catch(error){if(el('components-message'))el('components-message').textContent=error.message;}finally{button.disabled=false;}});
root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const feedback=b.parentElement.querySelector('[data-download-feedback]');feedback.textContent='Download wird angefragt …';const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});feedback.textContent='In Download-Warteschlange aufgenommen.';if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){feedback.textContent=error.message;feedback.className='warning';if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
}catch(error){message(error.message);el('profile-editor').innerHTML='<section class="card"><p>Komponenten konnten nicht geladen werden. Öffne die Ansicht erneut.</p></section>';}
}
function gpuEditor(params){
const selected=params.gpu_devices||[],available=[...gpus];for(const uuid of selected)if(!available.some(g=>g.uuid===uuid))available.push({uuid,name:'Derzeit nicht verfügbar'});
const options=(value,empty)=>`<option value="">${empty}</option>`+available.map(g=>`<option value="${e(g.uuid)}" ${value===g.uuid?'selected':''}>${e(g.name)} · ${e(g.uuid)}</option>`).join('');
return `<h3>GPU-Auswahl und Verteilung</h3><div class="form-grid"><label>GPU-Ausführung<select name="gpu_offload"><option value="auto" ${(params.gpu_offload||'auto')==='auto'?'selected':''}>Automatisch · CPU-Auslagerung bei Platzmangel</option><option value="full" ${params.gpu_offload==='full'?'selected':''}>Vollständig auf GPU · sonst Start abbrechen</option></select></label><label>Zusätzliche GPU-Reserve<select name="gpu_reserve_mode">${[['auto','Automatisch · mindestens 512 MiB je GPU'],['manual','Manuell je GPU'],['none','Keine zusätzliche Reserve']].map(([v,t])=>`<option value="${v}" ${(params.gpu_reserve_mode||'auto')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>Reserve erste GPU (MiB)<input name="reserve_first" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[0]]??512}"></label><label>Reserve zweite GPU (MiB)<input name="reserve_second" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[1]]??512}"></label><label>Erste GPU · CUDA0<select name="gpu_first">${options(selected[0],'Automatische Auswahl')}</select></label><label>Zweite GPU · CUDA1<select name="gpu_second">${options(selected[1],'Keine zweite GPU')}</select></label><label>GPU-Split<select name="split_mode">${[['none','Keine Aufteilung'],['layer','Layer-Split'],['row','Row-Split']].map(([v,t])=>`<option value="${v}" ${(params.split_mode||'none')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>GPU-Verteilung (erste, zweite GPU)<input name="tensor_split" placeholder="z. B. 85,15" value="${e((params.tensor_split||[]).join(','))}"></label></div><p class="small">Manuelle Reserven gelten für die jeweils ausgewählte GPU. Ohne Reserve entfällt nur der zusätzliche GPU-Puffer; Modell, KV-Cache und die konservative Projektor-Prognose bleiben berücksichtigt. OOM bleibt möglich. Für einen Abbruch statt CPU-Auslagerung „Vollständig auf GPU“ wählen. Die Reihenfolge legt die logischen CUDA-Geräte des Profils fest. Für Medium: RTX 5080 zuerst, RTX 3060 danach, Layer-Split und 85,15. Leere Verteilung bedeutet automatisch. Dies ist keine Zusage, dass Modell und Kontext in den verfügbaren Speicher passen.</p>`;
return `<h3>Sprachmodell · GPU-Auswahl und Verteilung</h3><div class="form-grid"><label>GPU-Ausführung<select name="gpu_offload"><option value="auto" ${(params.gpu_offload||'auto')==='auto'?'selected':''}>Automatisch · CPU-Auslagerung bei Platzmangel</option><option value="full" ${params.gpu_offload==='full'?'selected':''}>Vollständig auf GPU · sonst Start abbrechen</option></select></label><label>Zusätzliche GPU-Reserve<select name="gpu_reserve_mode">${[['auto','Automatisch · mindestens 512 MiB je GPU'],['manual','Manuell je GPU'],['none','Keine zusätzliche Reserve']].map(([v,t])=>`<option value="${v}" ${(params.gpu_reserve_mode||'auto')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>Reserve erste GPU (MiB)<input name="reserve_first" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[0]]??512}"></label><label>Reserve zweite GPU (MiB)<input name="reserve_second" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[1]]??512}"></label><label>Erste GPU · CUDA0<select name="gpu_first">${options(selected[0],'Automatische Auswahl')}</select></label><label>Zweite GPU · CUDA1<select name="gpu_second">${options(selected[1],'Keine zweite GPU')}</select></label><label>Sprachmodell-Split<select name="split_mode">${[['none','Keine Aufteilung'],['layer','Layer-Split'],['row','Row-Split']].map(([v,t])=>`<option value="${v}" ${(params.split_mode||'none')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>GPU-Verteilung (erste, zweite GPU)<input name="tensor_split" placeholder="z. B. 85,15" value="${e((params.tensor_split||[]).join(','))}"></label></div><p class="small">Manuelle Reserven gelten für die jeweils ausgewählte GPU. Ohne Reserve entfällt nur der zusätzliche GPU-Puffer; Modell, KV-Cache und die konservative Projektor-Prognose bleiben berücksichtigt. OOM bleibt möglich. Für einen Abbruch statt CPU-Auslagerung „Vollständig auf GPU“ wählen. Die Reihenfolge legt die logischen CUDA-Geräte des Profils fest. Für Medium: RTX 5080 zuerst, RTX 3060 danach, Layer-Split und 85,15. Leere Verteilung bedeutet automatisch. Dies ist keine Zusage, dass Modell und Kontext in den verfügbaren Speicher passen.</p>`;
}
function bindProjectorDiscovery(){
const form=el('profile-form'),box=el('projector-discovery'),select=form.elements.vision_projector;
@@ -62,7 +64,7 @@ window.ProfilesUI=(()=>{
async function refreshLibrary(){
const c=await api('catalog');if(!box.isConnected)return;
const selected=select.value;entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));
select.innerHTML='<option value="">Kein Projektor zugeordnet · Vision aus</option>'+projectors.map(x=>`<option value="${e(x.id)}">${e(x.repo)} / ${e(x.file)}</option>`).join('');select.value=selected;
select.innerHTML='<option value="">Kein Projektor zugeordnet · Vision aus</option>'+projectors.map(x=>`<option value="${e(x.id)}">${e(x.download_source?.repo||x.repo)} / ${e(x.download_source?.file||x.file)}</option>`).join('');select.value=selected;
return c;
}
function showFiles(detail,target,n){
@@ -133,7 +135,7 @@ window.ProfilesUI=(()=>{
panelSequence++;
if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))};
if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;}
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" placeholder="z. B. Qwen3.8 27B MEDIUM" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`<h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.repo)} / ${e(x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
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" placeholder="z. B. Qwen3.8 27B MEDIUM" 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).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?`<h3>Penalties</h3><div class="form-grid">${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return `<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="any" value="${e(data.parameters[k]??defaultValue)}"></label>`;}).join('')}</div>`+gpuEditor(data.parameters)+`<p class="small">Penalties: Wiederholung 1,0 sowie Presence und Frequency 0 sind neutral. Wiederholung über 1 bremst wiederholte Tokens; Presence bewertet ihr Auftreten, Frequency ihre Häufigkeit. Profilwerte gelten für Testchat und API, sofern der Client keine eigenen Werte sendet.</p><h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.download_source?.repo||x.repo)} / ${e(x.download_source?.file||x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='<summary>Erweitert: CPU-Threads</summary><p>CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.</p>';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));}
if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();}
if(kind==='video')bindVideoDevices(data);
+90 -22
View File
@@ -5,9 +5,10 @@ import threading
import time
import uuid
from pathlib import Path
from catalog import file_role
SCHEMAS={
'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40),'mtp_tokens':(1,8,2),'mtp_min_p':(0,1,.05)},
'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40),'repeat_penalty':(0,2,1.0),'presence_penalty':(-2,2,0.0),'frequency_penalty':(-2,2,0.0),'mtp_tokens':(1,8,2),'mtp_min_p':(0,1,.05)},
'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)},
'stt':{},'music':{},'voice':{},
@@ -25,7 +26,12 @@ def video_parameters(params):
return params
CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu','cache_type_k':'q4_0','cache_type_v':'q4_0'}
CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05}
FLOAT_PARAMETERS=frozenset({'guidance','speed','temperature','top_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'})
CHAT_GENERATION_DEFAULTS={'temperature':.8,'top_p':.95,'top_k':40,'repeat_penalty':1.0,'presence_penalty':0.0,'frequency_penalty':0.0}
CHAT_SAMPLING={**CHAT_GENERATION_DEFAULTS,'mtp_tokens':2,'mtp_min_p':.05}
def generation_parameters(params):
return {key:params.get(key,default) for key,default in CHAT_GENERATION_DEFAULTS.items()}
def chat_parameters(params):
params={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**params}
@@ -37,7 +43,7 @@ def chat_parameters(params):
if params['cache_type_k'] not in ('f16','q8_0','q4_0') or params['cache_type_v'] not in ('f16','q8_0','q4_0'):raise ValueError('KV-Cache: nur f16, q8_0 oder q4_0 unterstützt.')
if type(params['mtp']) is not bool:raise ValueError('MTP muss an oder aus sein.')
if params['vision_projector'] is not None and (not isinstance(params['vision_projector'],str) or not re.fullmatch(r'[a-f0-9]{64}',params['vision_projector'])):raise ValueError('Ungültige Projektor-ID.')
if params['vision_device']!='cpu' and params['vision_device'] not in params['gpu_devices']:raise ValueError('Projektor-GPU muss in der GPU-Auswahl enthalten sein.')
if params['vision_device']!='cpu' and (not isinstance(params['vision_device'],str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',params['vision_device'])):raise ValueError('Projektor-Gerät muss CPU oder eine gültige GPU-UUID sein.')
devices=params['gpu_devices'];split=params['tensor_split']
if not isinstance(devices,list) or len(devices)>16 or any(not isinstance(v,str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',v) for v in devices) or len(set(devices))!=len(devices):raise ValueError('GPUs müssen als eindeutige, geordnete GPU-UUIDs angegeben werden.')
if params['split_mode'] not in ('none','layer','row'):raise ValueError('Ungültiger GPU-Split-Modus.')
@@ -58,14 +64,28 @@ FLUX_COMPONENTS={
'text_encoder':{'label':'Textencoder für FLUX.2 Klein 9B','repo':FLUX_COMPONENT_REPO,'files':['split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors']},
'vae':{'label':'VAE für FLUX.2','repo':FLUX_COMPONENT_REPO,'files':['split_files/vae/flux2-vae.safetensors']}
}
# One registry owns runtime, workflow, format, components and resource policy.
IMAGE_ADAPTERS={
'qwen_image_21':dict(label='Qwen Image 2.1',workflow='qwen',model_filename='model.gguf',suffix='.gguf',runtime='ComfyUI + ComfyUI-GGUF',reference_images=4,offload=False,components={k:{'repo':QWEN_REPO,**v} for k,v in QWEN_COMPONENTS.items()},sources={QWEN_REPO.lower(),'qwen/qwen-image-2.1','comfy-org/qwen-image-2.1'},architectures={'qwenimage21transformer2dmodel','qwen_image_2_1'}),
'flux2_klein_9b':dict(label='FLUX.2 Klein 9B',workflow='flux',model_filename='model.safetensors',suffix='.safetensors',runtime='ComfyUI',reference_images=4,offload=True,components=FLUX_COMPONENTS,sources={FLUX_REPO.lower(),'black-forest-labs/flux.2-klein-9b','comfy-org/flux2-klein-9b'},architectures={'flux2klein9btransformer2dmodel','flux2_klein_9b'}),
}
def image_adapter(model):
if not model:return None
filename=model.get('file','')
if not file_role(filename)['profile_eligible'] or '-of-' in filename:return None
meta=model.get('runtime_metadata') or {};architecture=str(meta.get('architecture') or '').lower()
lineage={str(x).lower() for x in meta.get('base_models',[]) if isinstance(x,str)}
repo=model.get('repo','').lower()
matches=[a for a in IMAGE_ADAPTERS.values() if filename.endswith(a['suffix']) and (repo in a['sources'] or architecture in a['architectures'] or bool(lineage & a['sources']))]
return matches[0] if len(matches)==1 else None
def image_recipe(model):
if model['repo']==QWEN_REPO and model['file'].endswith('.gguf'):return QWEN_COMPONENTS
if model['repo']==FLUX_REPO and model['file']=='Flux.2 Klein-9B_fp16_nsfw.safetensors':return FLUX_COMPONENTS
return None
adapter=image_adapter(model)
return adapter['components'] if adapter else None
def image_capabilities(model):
"""Only advertise reference editing for the verified Qwen Image 2.1 workflow."""
return {'reference_images':4 if model and model.get('repo')==QWEN_REPO and model.get('file','').endswith('.gguf') else 0}
adapter=image_adapter(model)
return {'reference_images':adapter['reference_images'] if adapter else 0}
LTX_REPO='Lightricks/LTX-2.5'
LTX_FILE='diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors'
@@ -78,10 +98,36 @@ LTX_COMPONENTS={
def video_recipe(model):
return LTX_COMPONENTS if model.get('kind')=='video' and model.get('repo')==LTX_REPO and model.get('file')==LTX_FILE else None
YUE2_REPO='audio-cpp/Yue2-3B-GGUF'
YUE2_COMPONENTS={
'vae':{'label':'YuE2-VAE · F16 empfohlen, F32 alternativ','files':['yue2-vae-f16.gguf','yue2-vae-f32.gguf']},
'model_config':{'label':'Modellkonfiguration','files':['sidecars/yue2-model-config.json']},
'generation_config':{'label':'Generierungskonfiguration','files':['sidecars/yue2-generation-config.json']},
'tokenizer':{'label':'Qwen-Tokenizer','files':['sidecars/yue2-qwen.tiktoken']},
'vae_config':{'label':'VAE-Konfiguration','files':['sidecars/yue2-vae-config.json']}
}
def music_recipe(model):
return YUE2_COMPONENTS if model.get('kind')=='music' and model.get('repo')==YUE2_REPO and model.get('file') in ('yue2-3b-bf16.gguf','yue2-3b-q8_0.gguf','yue2-3b-q4_0.gguf') else None
def component_recipe(model):
return video_recipe(model) or image_recipe(model)
return music_recipe(model) or video_recipe(model) or image_recipe(model)
MANUAL_ROLES={'manual_text_encoder':'Textencoder (ungeprüft)','manual_vae':'VAE (ungeprüft)','manual_tokenizer':'Tokenizer (ungeprüft)','manual_configuration':'Konfiguration (ungeprüft)','manual_projector':'Projektor (ungeprüft)','manual_auxiliary':'Weitere Komponente (ungeprüft)'}
def audio_package_recipe(model,source):
if not source or not model.get('repo','').startswith('audio-cpp/'):return None
for path in sorted(Path(source,'model_specs').glob('*.json')):
try:spec=json.loads(path.read_text())
except (ValueError,OSError):continue
for package in spec.get('packages',[]):
files=package.get('files',[])
if package.get('target_directory')!=model['repo'].split('/')[-1] or model['file'] not in files:continue
extras=[f for f in files if isinstance(f,str) and f!=model['file'] and not f.startswith('/') and '..' not in Path(f).parts]
return {f'runtime_file_{i}':dict(label='Laufzeit-Paket: '+f,files=[f]) for i,f in enumerate(extras)} or None
return None
class Profiles:
def _recipe(self,model):return component_recipe(model) or getattr(self,'runtime_recipe',lambda m:None)(model)
def __init__(self,path,catalog):
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
self.image_runtime_ready=lambda:False
@@ -99,9 +145,9 @@ class Profiles:
p['model']=self.catalog.entry(p['model_id'])
if p['kind']=='image':p['capabilities']=image_capabilities(p['model'])
p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.']
if p['kind']=='image' and (image_recipe(p['model']) or 'qwen-image-2.1' in p['model']['repo'].lower()):
if p['kind']=='image' and image_recipe(p['model']):
p['blockers']=['Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.']
for role,info in reversed(list((image_recipe(p['model']) or QWEN_COMPONENTS).items())):
for role,info in reversed(list(image_recipe(p['model']).items())):
try:self._component(p['model'],role,p.get('components',{}).get(role))
except ValueError:p['blockers'].insert(0,info['label']+' fehlt oder ist noch nicht zugeordnet.')
except ValueError as exc:p['model']=None;p['blockers']=[str(exc)]
@@ -111,14 +157,20 @@ class Profiles:
for role,info in video_recipe(p['model']).items():
try:self._component(p['model'],role,p.get('components',{}).get(role))
except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
if p.get('model') and music_recipe(p['model']):
for role,info in music_recipe(p['model']).items():
try:self._component(p['model'],role,p.get('components',{}).get(role))
except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
if p['kind']=='video' and self.video_blockers and p.get('model'):p['blockers']=self.video_blockers(p)
if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p)
if p['kind']=='music' and getattr(self,'music_blockers',None) and p.get('model'):p['blockers']=self.music_blockers(p)
if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p)
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p)
if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.')
if p['kind']=='image':
for task,repo in PROMPT_ENHANCER_REPOS.items():
if p['prompt_enhancer'].get(task)==repo and not self.prompt_enhancer_ready(task):p['blockers'].append('Prompt-Aufwerter '+task.upper()+' fehlt. Unter Bildgenerierung → Aufwerter installieren.')
if any(k in MANUAL_ROLES for k in p.get('components',{})):p['blockers'].append('Manuelle Zusatzkomponenten sind ungeprüft und noch nicht an den Worker angebunden.')
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}}
def save(self,data):
@@ -138,7 +190,7 @@ class Profiles:
if not isinstance(params,dict) or set(params)!=(set(SCHEMAS[kind]) | (set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(VIDEO_DEVICE_DEFAULTS) if kind=='video' else set())):raise ValueError('Unvollständige oder unbekannte Profilparameter.')
for key,(lo,hi,_) in SCHEMAS[kind].items():
value=params[key]
floating=key in ('guidance','speed','temperature','top_p','mtp_min_p')
floating=key in FLOAT_PARAMETERS
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.')
@@ -187,16 +239,20 @@ class Profiles:
return {'deleted':old['id']}
def _component(self,model,role,ident):
recipe=component_recipe(model)
if role in MANUAL_ROLES:
item=self.catalog.entry(ident)
if item['kind']!=model['kind'] or item['id']==model['id']:raise ValueError('Andere Zusatzdatei aus derselben Kategorie wählen.')
return item
recipe=self._recipe(model)
if not recipe or role not in recipe:raise ValueError('Für dieses Modell ist kein geprüftes Komponentenrezept hinterlegt.')
item=self.catalog.entry(ident)
if item['repo']!=recipe[role].get('repo',model['repo']) or item['kind']!=model['kind'] or item['file'] not in recipe[role]['files']:raise ValueError('Die Datei gehört nicht zu dieser Komponente des Modells.')
if video_recipe(model) and item['revision']!=model['revision']:raise ValueError('LTX-Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.')
if (video_recipe(model) or music_recipe(model)) and item['revision']!=model['revision']:raise ValueError('Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.')
return item
def components(self,model_id):
def components(self,model_id,extra_repo=None):
model=self.catalog.entry(model_id)
if not component_recipe(model):return dict(supported=False,requirements=[],message='Für dieses Modell ist noch kein Komponentenrezept hinterlegt. Keine automatische Zuordnung.')
recipe=component_recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
if extra_repo or not self._recipe(model):return self.manual_components(model,extra_repo)
recipe=self._recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video or music_recipe(model) else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
for role,info in recipe.items():
available=[]
for item in entries:
@@ -205,17 +261,29 @@ class Profiles:
except ValueError:pass
candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name]
requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates))
if music_recipe(model):return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='audio.cpp',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Passendes YuE2-GGUF-Paket: je eine VAE-Datei und die vier Zusatzdateien derselben Quellversion. F16-VAE ist die erste Downloadempfehlung. Vorhandene Dateien wiederverwenden, fehlende herunterladen und danach zuordnen. Die YuE2-Musikanbindung verwendet diese Dateien über audio.cpp. Speichern startet kein Modell; anschließend Musik → Testen verwenden.')
if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='ComfyUI · gemeinsame Bild- und Videolaufzeit',runtime_installed=bool(getattr(self,'video_runtime_ready',lambda:False)()),message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Vorhandene ComfyUI-Laufzeit wiederverwenden; Videomodell im Bereich Video aktivieren. Vollständige Dateien sind keine Speicherzusage.')
return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(),
message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.')
if not image_recipe(model):return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='audio.cpp · Paketdefinition',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Zusatzdateien aus der installierten Laufzeit-Paketdefinition. Weitere modellabhängige Pakete können erforderlich sein; dies ist keine vollständige Worker-Zusage.')
return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime=image_adapter(model)['runtime'],runtime_installed=self.image_runtime_ready(),
message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if image_adapter(model)['workflow']=='flux' else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.')
def manual_components(self,model,repo=None):
source=self.catalog.files(repo or model['repo'],None if repo else model['revision'])
entries=self.catalog.status()['entries'];requirements=[]
for role,label in MANUAL_ROLES.items():
match={'manual_text_encoder':('text_encoder',),'manual_vae':('vae',),'manual_projector':('vision_projector','audio_projector'),'manual_configuration':('configuration',),'manual_auxiliary':('auxiliary',),'manual_tokenizer':('configuration','auxiliary')}[role]
def matches(name):return name.endswith('.tiktoken') or 'tokenizer' in name.lower() if role=='manual_tokenizer' else file_role(name)['role'] in match
candidates=[dict(f,repo=source['repo'],revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for f in source['files'] if f['name']!=model['file'] and matches(f['name'])]
available=[x for x in entries if x['kind']==model['kind'] and x['id']!=model['id'] and matches(x['file'])]
requirements.append(dict(role=role,label=label,available=available,candidates=candidates,optional=True))
return dict(supported=True,manual=True,requirements=requirements,source=source['url'],license=source['license'],runtime='Siehe Laufzeiterkennung am Modell',runtime_installed=False,message='Kein vollständiges geprüftes Rezept für diese Auswahl. Diese Dateien sind Hinweise aus Dateinamen und keine bestätigten Pflichtkomponenten. Manuell ausgewählte Dateien werden als ungeprüft gespeichert und schalten keinen Worker frei.')
def assign(self,data):
if set(data)!={'id','revision','components'} or not isinstance(data['components'],dict):raise ValueError('Ungültige Komponentenzuordnung.')
with self.lock:
old=next((p for p in self.rows if p['id']==data['id']),None)
if not old or old['kind'] not in ('image','video') or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
if not old or old['kind'] not in SCHEMAS or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
model=self.catalog.entry(old['model_id'])
if set(data['components'])-set(component_recipe(model) or {}):raise ValueError('Unbekannte Komponente für dieses Modell.')
components={k:v for k,v in data['components'].items() if v}
if set(data['components'])-(set(self._recipe(model) or {})|set(MANUAL_ROLES)):raise ValueError('Unbekannte Komponente für dieses Modell.')
components={k:v for k,v in {**old.get('components',{}),**data['components']}.items() if v}
for role,ident in components.items():self._component(model,role,ident)
updated=dict(old,components=components,revision=old['revision']+1,updated_at=time.time())
rows=[updated if p is old else p for p in self.rows]
+4 -2
View File
@@ -1,8 +1,10 @@
const RuntimeUI=(()=>{
let preparedRevision='';
function prepareBuild(revision){preparedRevision=revision;}
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(path='',data){const r=await fetch('/api/v1/runtime'+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 html(){return `<div id="runtime-live"><div class="kicker">ATHENA / LLAMA.CPP</div><h1>llama.cpp &amp; Updates</h1><p>Offizielle Versionen getrennt bauen und prüfen. Ein Build lädt kein Modell und ändert keine produktiven Laufzeiten.</p><section class="card"><h2>Voraussetzungen</h2><button id="rt-check">Werkzeuge &amp; GPUs prüfen</button><div id="rt-prereq"></div></section><section class="card"><h2>Installation &amp; Build</h2><form id="rt-build"><label>Release oder vollständiger Commit<input name="revision" required pattern="b[0-9]{3,8}|v[0-9]{1,4}[.][0-9]{1,4}[.][0-9]{1,4}|[a-f0-9]{40}" placeholder="Unten Releases abfragen und auswählen"></label><label>Backend<select name="backend"><option>CUDA</option><option>CPU</option></select></label><label>Parallele Build-Jobs<select name="jobs"><option>1</option><option>2</option></select></label><p>GPU-Architekturen werden automatisch aus beiden Karten übernommen. Begrenzte Build-Last für den Parallelbetrieb.</p><button>llama.cpp herunterladen &amp; bauen</button></form><button id="rt-cancel" class="secondary">Laufenden Build abbrechen</button><p id="rt-message" role="status"></p><div id="rt-job"></div><details><summary>Build-Ausgabe (eigener Build)</summary><pre id="rt-log" style="max-height:320px;overflow:auto;white-space:pre-wrap"></pre></details></section><section class="card"><h2>Versionen &amp; Änderungen</h2><p>Das aktuelle reguläre Release steht zuerst. Nightlies enthalten neuere Änderungen, sind aber keine pauschale Empfehlung. Modell-, MTP- und Projektor-Kompatibilität vor einem Standardwechsel testen.</p><button id="rt-releases">Nach Updates suchen</button><div id="rt-releases-list"></div></section><section class="card"><h2>Geprüfte Builds</h2><div id="rt-builds"></div><button id="rt-rollback" class="secondary">Vorherigen Standard wiederherstellen</button><p>Als Standard auswählen startet keinen Prozess. Bestehende Router bleiben unverändert.</p></section><section class="card"><h2>Automatische Speicheranpassung</h2><p>Kein pauschales Reservefeld: Die Laufzeit soll Modell, gewünschten Kontext, Slots und den aktuell freien Speicher gemeinsam berücksichtigen. Unterstützte Builds verwenden llama.cpp <code>--fit on</code>; der Kontext wird ausdrücklich vorgegeben. Keine absichtlichen OOM-Tests auf den produktiv genutzten GPUs.</p><form id="rt-fit"><label>Chatprofil auswählen<select name="profile" id="rt-profiles"></select></label><label>Gewünschter Gesamtkontext<input name="context" type="number" min="512" max="2097152" value="160000" required></label><label>Slots<input name="slots" type="number" min="1" max="16" value="2" required></label><button>Auto-Einpassung berechnen</button></form><pre id="rt-fit-result" style="white-space:pre-wrap"></pre><p>Ein Build allein kann keine Modell-Layerzahl bestimmen. Die Berechnung verwendet llama-fit-params ohne Gewichtsallokation. Sie ist eine Prognose für das Textmodell und kein Lasttest. Vision-Projektor und MTP sind noch nicht eingerechnet. Ein Modellstart erfolgt nicht. Die Prognose verwendet aktuell freien VRAM; falls das gewählte Modell bereits läuft, belegt es diesen Speicher weiterhin. Vor einer realistischen Neuplanung das Deck-Modell bei Bedarf bewusst entladen.</p></section></div>`;}
function bind(){const root=document.querySelector('#runtime-live');if(!root)return;let running=false;
function bind(){const root=document.querySelector('#runtime-live');if(!root)return;let running=false;if(preparedRevision){root.querySelector('#rt-build [name=revision]').value=preparedRevision;preparedRevision='';}
const msg=t=>{if(root.isConnected)root.querySelector('#rt-message').textContent=t;};
async function status(){try{const s=await api();if(!root.isConnected)return;running=s.job?.state==='running';root.querySelector('#rt-job').textContent=s.job?`${s.job.revision} · ${s.job.state} · ${s.job.phase} ${s.job.error||''}`:'Noch kein Build gestartet.';root.querySelector('#rt-cancel').disabled=!running;root.querySelector('#rt-build button').disabled=running;root.querySelector('#rt-rollback').disabled=!s.previous;root.querySelector('#rt-builds').innerHTML=s.builds.map(b=>`<article><h3>${e(b.revision)} · ${e(b.backend)} ${s.active===b.id?'· STANDARD':''}</h3><p>Commit ${e(b.commit)} · GPU-Ziele ${e(b.architectures.join(', ')||'CPU')} · Auto-Fit ${b.fit_supported?'unterstützt':'nicht unterstützt'}</p><button data-build="${e(b.id)}" ${s.active===b.id?'disabled':''}>Als Standard auswählen</button></article>`).join('')||'<p>Noch kein geprüfter Build vorhanden.</p>';root.querySelectorAll('[data-build]').forEach(b=>b.onclick=()=>action('/activate',{build_id:b.dataset.build}));if(s.job){const log=await api('/log');if(root.isConnected)root.querySelector('#rt-log').textContent=log.text;}if(running)setTimeout(()=>{if(root.isConnected)status();},3000);}catch(err){msg(err.message);}}
async function action(path,data){try{await api(path,data);msg('Aktion angenommen.');await status();}catch(err){msg(err.message);}}
@@ -15,5 +17,5 @@ const RuntimeUI=(()=>{
root.querySelector('#rt-fit').onsubmit=async event=>{event.preventDefault();const b=event.target.querySelector('button');b.disabled=true;msg('Modellmetadaten und Speicherbedarf werden berechnet …');const d=Object.fromEntries(new FormData(event.target));d.context=Number(d.context);d.slots=Number(d.slots);try{const v=await api('/fit',d);if(root.isConnected)root.querySelector('#rt-fit-result').textContent=(v.success?'Prognose erfolgreich. Kein Modell geladen.':'Aktuell keine passende Einpassung ermittelt.')+(v.within_ram_limit===false?'\nNICHT STARTBAR in der Testinstanz: berechneter Host-RAM überschreitet deren Limit von '+v.ram_limit_mib+' MiB.':'')+'\nGPU-Layer (Prognose): '+(v.fitted?.gpu_layers??'nicht ermittelt')+'\nGPU-Verteilung: '+(v.fitted?.tensor_split??'ein Gerät / nicht ermittelt')+'\nAusgelassene belegte GPUs: '+v.excluded_gpus.join(', ')+'\nAutomatische Reserve (MiB): '+v.reserve_mib.join(', ')+'\n'+v.memory.map(m=>`${m.device}: Modell ${m.model_mib} MiB · Kontext ${m.context_mib} MiB · Rechenspeicher ${m.compute_mib} MiB`).join('\n')+'\n'+v.arguments+'\n'+v.details;msg('Berechnung abgeschlossen. Freier Speicher kann sich durch andere Dienste ändern.');}catch(err){msg(err.message);}finally{b.disabled=false;}};
prerequisites();status();
}
return {html,bind};
return {html,bind,prepareBuild};
})();
+34
View File
@@ -0,0 +1,34 @@
window.SeparatorUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let section='discover';
async function api(path,data){const response=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const value=await response.json();if(!response.ok)throw Error(value.error||'Anfrage fehlgeschlagen');return value;}
function sources(m){return `<p>Quelle: ${e(m.source?.publisher||'Nicht belegt')}</p><details><summary>Originalquellen und Prüfsummen</summary>${(m.source?.files||[]).map(f=>`<p class="hub-filename">${e(f.file)}<br>${f.urls.map(u=>`<a href="${e(u)}" target="_blank" rel="noopener">Downloadquelle</a>`).join(' · ')}<br><code>${e(f.sha256)}</code></p>`).join('')}</details>`;}
function rows(models,library){const list=models.filter(m=>!library||m.installed);return list.length?`<div class="detail-list">${list.map(m=>`<details class="detail-row"><summary class="detail-summary"><strong>${e(m.name)}</strong><span>${m.installed?'Bereit':'Nicht heruntergeladen'}</span><span>${m.source?.files?.length||0} Dateien</span></summary><div class="detail-body"><p class="hub-filename">${e(m.id)}</p>${sources(m)}<p>Vollständiges Modellpaket · einschließlich Konfiguration und benötigter Teilmodelle. Die Laufzeit wird wiederverwendet.</p>${m.installed?'<a class="link" href="#home">In der Steuerung Audio-Trennung starten →</a>':`<button data-sep-model="${e(m.id)}">Modellpaket herunterladen</button>`}</div></details>`).join('')}</div>`:'<section class="empty"><h2>Noch keine Modellpakete</h2><p>Unter Entdecken ein geprüftes Modellpaket herunterladen.</p></section>';}
function render(tool=false,force=false){if(!force&&document.querySelector('#separator-panel')?.dataset.tool===String(tool))return;
const tabs=[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],['test','Testen']];
document.querySelector('#view').innerHTML=`<div id="separator-panel" data-tool="${tool}"><div class="kicker">${tool?'ATHENA / MODELLVERWALTUNG':'EINSTELLUNGEN / LAUFZEITEN'}</div><h1>${tool?'Audio-Trennung':'Audio Separator'}</h1><p>${tool?'Gesang, Schlagzeug, Bass, Gitarre, Klavier oder die übrige Begleitung aus einer Aufnahme herauslösen.':'Native CUDA-Laufzeit für BS-Roformer, MelBand und Demucs. Einmal installieren und für passende Modellpakete wiederverwenden.'}</p>${tool?`<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${tabs.map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-sep-tab="${id}">${label}</button>`).join('')}</div>`:''}<p id="sep-message" role="status"></p><div role="tabpanel"><div id="sep-content"></div></div></div>`;
const root=document.querySelector('#separator-panel'),msg=root.querySelector('#sep-message'),content=root.querySelector('#sep-content');let pending=false,signature='';
async function action(path,data){pending=true;try{await api(path,data);msg.textContent=path==='separator-profiles/save'?'Profil gespeichert.':'Aktion gestartet.';}catch(error){msg.textContent=error.message;}finally{pending=false;signature='';poll();}}
function bind(s,t){
root.querySelectorAll('[data-sep-profile]').forEach(form=>{form.onsubmit=event=>{event.preventDefault();action('separator-profiles/save',{model:form.dataset.sepProfile,enabled:form.elements.enabled.checked,device:form.elements.device.value});};});
root.querySelectorAll('[data-sep-model]').forEach(b=>{b.disabled=pending||s.job?.state==='running'||!s.installed;b.onclick=()=>action('separator-runtime/download',{model:b.dataset.sepModel});});
const install=root.querySelector('#sep-install');if(install){install.disabled=pending||s.installed||s.job?.state==='running';install.onclick=()=>action('separator-runtime/install',{});}
const cancel=root.querySelector('#sep-cancel');if(cancel){cancel.disabled=s.job?.state!=='running';cancel.onclick=()=>action('separator-runtime/cancel',{});}
const stop=root.querySelector('#sep-test-cancel');if(stop){stop.disabled=t.job?.state!=='running';stop.onclick=()=>action('separator-tests/cancel',{});}
const form=root.querySelector('#sep-test');if(form){form.querySelector('button').disabled=pending||t.job?.state==='running'||!s.models.some(m=>m.installed);form.onsubmit=async event=>{event.preventDefault();pending=true;msg.textContent='Audio wird hochgeladen …';try{const response=await fetch('/api/v1/separator-tests/start',{method:'POST',headers:{'X-Athena-Deck':'1'},body:new FormData(form)}),value=await response.json();if(!response.ok)throw Error(value.error);msg.textContent='Trenntest gestartet.';}catch(error){msg.textContent=error.message;}finally{pending=false;signature='';poll();}};}
}
async function poll(){if(!root.isConnected)return;try{const [s,t]=await Promise.all([api('separator-runtime'),api('separator-tests')]);if(!root.isConnected)return;
const key=JSON.stringify([s,t,pending]);if(key!==signature&& (!root.querySelector('form')?.contains(document.activeElement)||t.job?.state==='running'||root.dataset.jobState!==String(t.job?.state))){signature=key;root.dataset.jobState=String(t.job?.state);
if(!tool){content.innerHTML=`<section class="card"><h2>Laufzeit</h2><p><strong>${s.installed?'Installiert':'Noch nicht installiert'}</strong> · Version ${e(s.version)}</p><button id="sep-install">Laufzeit installieren</button><button class="secondary" id="sep-cancel">Einrichtung abbrechen</button><p>Mindestens 15 GiB frei. Keine Änderung an Host-Treibern oder alten Diensten.</p><a class="link" href="#separation">Audio-Trennung öffnen →</a><p>${e(s.job?.phase||'')}</p>${s.job?.state==='running'?'<progress aria-label="Einrichtung läuft"></progress>':''}<details><summary>Installationsausgabe</summary><pre id="sep-log" style="max-height:240px;overflow:auto;white-space:pre-wrap"></pre></details></section>`;
const log=await api('separator-runtime/log');if(root.isConnected)root.querySelector('#sep-log').textContent=log.text;
}else if(section==='discover'||section==='library'){content.innerHTML=`${section==='discover'?'<p>Geprüfte Pakete mit allen benötigten Zusatzdateien. Downloads prüfen die Originalquellen per SHA-256.</p>':''}${!s.installed?'<p class="warning">Laufzeit fehlt. <a href="#separator-runtime">Audio Separator installieren →</a></p>':''}${rows(s.models,section==='library')}`;
}else if(section==='profiles'){content.innerHTML=`${t.hardware_error?`<p class="warning">${e(t.hardware_error)}</p>`:''}<p>Ein Profil pro Modellpaket. Aktivierte Profile sind für die Weboberfläche verfügbar. Gewichte laden bei Auftrag und werden danach entladen. Automatisch bevorzugt die RTX 5080, anschließend die RTX 3060; eine feste GPU wird niemals still gewechselt.</p><div class="detail-list">${s.models.map(m=>{const p=t.profiles[m.id];return `<details class="detail-row"><summary class="detail-summary"><strong>${e(m.name)}</strong><span>${p.enabled?'Aktiv':'Deaktiviert'}</span><span>${e(p.device==='auto'?'Automatisch · 5080 zuerst':t.devices.find(d=>d.uuid===p.device)?.name||'GPU nicht vorhanden')}</span></summary><div class="detail-body"><form data-sep-profile="${e(m.id)}"><label><input name="enabled" type="checkbox" ${p.enabled?'checked':''}> Für Audio-Trennung aktivieren</label><label>Ausführen auf<select name="device"><option value="auto" ${p.device==='auto'?'selected':''}>Automatisch · RTX 5080 zuerst</option>${t.devices.map(d=>`<option value="${e(d.uuid)}" ${p.device===d.uuid?'selected':''}>${e(d.name)}</option>`).join('')}${p.device!=='auto'&&!t.devices.some(d=>d.uuid===p.device)?`<option value="${e(p.device)}" selected>Gespeicherte GPU nicht vorhanden</option>`:''}</select></label><p>${m.installed?'Modellpaket bereit':'Modellpaket noch herunterladen'}</p><button ${t.job?.state==='running'?'disabled':''}>Profil speichern</button></form></div></details>`;}).join('')}</div>`;
}else if(section==='downloads'){content.innerHTML=s.job?`<section class="card"><span class="pill">${e(s.job.state)}</span><h2>Einrichtungsauftrag</h2><p>${e(s.job.phase)}</p>${s.job.state==='running'?'<progress aria-label="Download läuft"></progress><button id="sep-cancel" class="secondary">Abbrechen</button>':''}<p>Abgeschlossene Modellpakete findest du in der Bibliothek.</p></section>`:'<section class="empty"><h2>Keine Downloads</h2><p>Unter Entdecken ein Modellpaket auswählen.</p></section>';
}else{const j=t.job;content.innerHTML=`<section class="card"><h2>Audio-Trennung testen</h2><p>Kurzer PCM-WAV-Test: 16 Bit, Mono/Stereo, bis 30 Sekunden und 8 MiB. Für längere Dateien die Weboberfläche verwenden.</p><p><a class="link" href="${e(location.protocol+'//'+location.hostname+':8128/')}" target="_blank" rel="noopener">Audio-Trennoberfläche öffnen →</a> · <a href="#home">Audio-Trennung in der Steuerung starten →</a></p><p class="small">Gewichte laden bei Auftrag und werden anschließend entladen. Fremde Dienste bleiben unberührt.</p><form id="sep-test"><label>Modellpaket<select name="model">${s.models.filter(m=>m.installed&&t.profiles[m.id]?.enabled).map(m=>`<option value="${e(m.id)}">${e(m.name)}</option>`).join('')}</select></label><label>Audiodatei<input name="file" type="file" accept=".wav,audio/wav" required></label><button>Audio trennen</button><button type="button" class="secondary" id="sep-test-cancel">Abbrechen</button></form><div role="status">${j?`<p>${e(j.state)} · ${e(j.phase)} ${j.gpu?'· '+e(j.gpu):''} ${j.elapsed_seconds?e(j.elapsed_seconds)+' s':''}</p>${j.state==='running'?'<progress aria-label="Audio wird getrennt"></progress>':''}`:''}</div>${j?.state==='complete'?j.files.map(f=>`<p>${e(f.name)}</p><audio controls src="/api/v1/separator-tests/audio?id=${e(j.id)}&index=${f.index}"></audio><p><a href="/api/v1/separator-tests/audio?id=${e(j.id)}&index=${f.index}" download="${e(f.name)}">Spur herunterladen</a></p>`).join(''):''}</section>`;}
}
bind(s,t);
}catch(error){if(root.isConnected)msg.textContent=error.message;}clearTimeout(root.pollTimer);if(root.isConnected)root.pollTimer=setTimeout(poll,3000);}
root.querySelectorAll('[data-sep-tab]').forEach(b=>b.onclick=()=>{section=b.dataset.sepTab;render(tool,true);});poll();
}
return {render};
})();
+106
View File
@@ -0,0 +1,106 @@
"""Bounded WAV tests with coordinated GPU ownership and disposable input."""
import io,json,os,re,signal,subprocess,threading,time,uuid,wave
from pathlib import Path
from image_test import probe
from separator_runtime import MODELS
def validate_profiles(settings):
if not isinstance(settings,dict) or set(settings)-set(MODELS):raise ValueError('Unbekanntes Audio-Trennprofil.')
for value in settings.values():
if not isinstance(value,dict) or set(value)!={'enabled','device'} or type(value['enabled']) is not bool or not isinstance(value['device'],str) or value['device']!='auto' and not re.fullmatch(r'GPU-[a-fA-F0-9-]{16,64}',value['device']):raise ValueError('Ungültiges Audio-Trennprofil.')
return settings
class SeparatorTests:
def __init__(self,root,runtime,scheduler):
self.root=Path(root);self.runtime=runtime;self.scheduler=scheduler;self.lock=threading.RLock();self.job=None;self.process=None;self.cancel=threading.Event();self.thread=None
def profiles(self):
path=self.root.parent/'separator-profiles.json'
settings=validate_profiles(json.loads(path.read_text())) if path.exists() else {}
return {name:dict(settings.get(name,dict(enabled=True,device='auto'))) for name in MODELS}
def configure(self,data):
if not isinstance(data,dict) or set(data)!={'model','enabled','device'} or data['model'] not in MODELS:raise ValueError('Modell, Aktivierung und GPU erforderlich.')
validate_profiles({data['model']:{k:data[k] for k in ('enabled','device')}})
if data['device']!='auto' and not any(g['uuid']==data['device'] for g in probe()):raise ValueError('Gewählte GPU ist nicht vorhanden.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Zuerst den laufenden Trennauftrag beenden.')
settings=self.profiles();settings[data['model']]={k:data[k] for k in ('enabled','device')}
from auth import atomic_write
atomic_write(self.root.parent/'separator-profiles.json',settings)
return self.status()
def status(self):
try:devices=[dict(uuid=g['uuid'],name=g['name']) for g in probe()];hardware_error=None
except (OSError,ValueError,subprocess.SubprocessError):devices=[];hardware_error='GPU-Erkennung derzeit nicht verfügbar.'
with self.lock:return dict(job=dict(self.job) if self.job else None,profiles=self.profiles(),devices=devices,hardware_error=hardware_error)
def select_gpu(self,model):
settings=self.profiles()[model]
if not settings['enabled']:raise ValueError('Dieses Audio-Trennprofil ist deaktiviert. Unter Profile aktivieren.')
available=[g for g in probe() if not g['processes'] and g['free_mib']>4000]
if settings['device']!='auto':
gpu=next((g for g in available if g['uuid']==settings['device']),None)
if not gpu:raise ValueError('Die gewählte GPU ist nicht ausreichend frei. GPU-Auswahl im Profil prüfen; kein stiller Wechsel.')
return gpu
return next((g for term in ('RTX 5080','RTX 3060','') for g in available if term in g['name']),None)
def start(self,model,audio,target=None,full=False):
if isinstance(model,str) and model in MODELS and not self.profiles()[model]['enabled']:raise ValueError('Dieses Audio-Trennprofil ist deaktiviert. Unter Profile aktivieren.')
if full and self.scheduler.gpu_mode!='separator':raise ValueError('Bitte unter Steuerung Audio-Trennung starten.')
targets={'vocals':'model_bs_roformer_ep_317_sdr_12.9755.ckpt','drums':'htdemucs_ft.yaml','bass':'htdemucs_ft.yaml','guitar':'htdemucs_6s.yaml','piano':'htdemucs_6s.yaml','other':'htdemucs_6s.yaml'}
if target is not None and targets.get(target)!=model:raise ValueError('Zielspur und Modellpaket passen nicht zusammen.')
if not isinstance(model,str) or model not in MODELS or not self.runtime.status()['installed'] or not self.runtime.model_ready(model):raise ValueError('Laufzeit und Modellpaket zuerst unter Audio Separator einrichten.')
if not isinstance(audio,bytes) or not 44<=len(audio)<=(256 if full else 8)*1024**2:raise ValueError('WAV überschreitet die erlaubte Upload-Größe.')
try:
with wave.open(io.BytesIO(audio)) as wav:
if wav.getnchannels() not in (1,2) or wav.getsampwidth()!=2 or not 8000<=wav.getframerate()<=48000 or not 0<wav.getnframes()/wav.getframerate()<=(1200 if full else 30):raise ValueError('PCM-WAV, 16 Bit, Mono/Stereo, maximal 30 Sekunden erforderlich.')
if len(wav.readframes(wav.getnframes()))!=wav.getnframes()*wav.getnchannels()*2:raise ValueError('WAV-Datei ist unvollständig.')
except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei.') from None
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Trenntest läuft bereits.')
self.target=target;self.full=full;self.cancel.clear();self.root.mkdir(parents=True,exist_ok=True,mode=0o700);self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf GPU-Freigabe',started_at=time.time(),files=[])
self.thread=threading.Thread(target=self._run,args=(model,audio),daemon=True);self.thread.start();return dict(self.job)
def _run(self,model,audio):
release=None;directory=self.root/self.job['id'];state='failed';phase='Trennung fehlgeschlagen.'
try:
release=self.scheduler.image_reservation(kind='separator')
gpu=self.select_gpu(model)
if not gpu:raise ValueError('Keine ausreichend freie GPU. Fremde Dienste werden nicht beendet.')
directory.mkdir(mode=0o700);(directory/'input.wav').write_bytes(audio);diagnostics=(directory/'worker.tmp').open('wb')
with self.lock:
if self.cancel.is_set():raise InterruptedError()
self.job.update(phase='Modell laden und Audio trennen',gpu=gpu['name'])
self.process=subprocess.Popen([str(self.runtime.paths()[0]),str(Path(__file__).with_name('separator_worker.py')),str(self.runtime.root/'models'),model,str(directory/'input.wav'),str(directory),getattr(self,'target',None) or ''],stdout=subprocess.DEVNULL,stderr=diagnostics,start_new_session=True,env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid'],OMP_NUM_THREADS='6'))
# Drain diagnostics into a bounded temporary file rather than retaining user data.
deadline=time.monotonic()+(7200 if getattr(self,'full',False) else 600)
while self.process.poll() is None:
if self.cancel.wait(.2):raise InterruptedError()
if time.monotonic()>deadline:raise ValueError('Trennauftrag überschreitet das Zeitlimit.')
with self.lock:self.job['elapsed_seconds']=round(time.time()-self.job['started_at'])
diagnostics.close();error=(directory/'worker.tmp').read_bytes()[-65536:].decode(errors='replace')
if self.process.returncode:raise ValueError('GPU-Speicher reicht nicht (OOM).' if 'out of memory' in error.lower() else 'Audio Separator hat den Test abgebrochen. Modellpaket und Laufzeit prüfen.')
names=json.loads((directory/'results.json').read_text())
if not names or any(Path(n).name!=n or not n.endswith('.wav') or not (directory/n).is_file() for n in names):raise ValueError('Ungültige Ergebnisdateien.')
with self.lock:self.job['files']=[dict(index=i,name=n) for i,n in enumerate(names)]
(directory/'complete').touch();state='complete';phase='Spuren fertig · Modell entladen'
except InterruptedError:state='cancelled';phase='Trenntest abgebrochen'
except Exception as exc:phase=str(exc) if isinstance(exc,ValueError) else 'Trenntest fehlgeschlagen.'
finally:
process=self.process
if process and process.poll() is None:
try:os.killpg(process.pid,signal.SIGTERM);process.wait(5)
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
except ProcessLookupError:pass
if 'diagnostics' in locals():diagnostics.close()
(directory/'input.wav').unlink(missing_ok=True);(directory/'worker.tmp').unlink(missing_ok=True)
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time())
if release:release()
def stop(self):
self.cancel.set()
if self.thread:self.thread.join(7)
return {'cancellation_requested':True}
def audio(self,ident,index):
if not isinstance(ident,str) or len(ident)!=32 or any(c not in '0123456789abcdef' for c in ident):raise ValueError('Ungültige Auftrag-ID.')
directory=self.root/ident
if not (directory/'complete').is_file():raise ValueError('Auftrag nicht fertig.')
names=json.loads((directory/'results.json').read_text())
if type(index) is not int or not 0<=index<len(names) or Path(names[index]).name!=names[index]:raise ValueError('Ungültige Spur.')
path=directory/names[index]
if path.is_symlink() or path.resolve().parent!=directory.resolve():raise ValueError('Ungültiger Ergebnispfad.')
if path.stat().st_size>512*1024**2:raise ValueError('Spur zu groß.')
return path.read_bytes()
+108
View File
@@ -0,0 +1,108 @@
"""Owned audio-separator environment and explicitly supported model packages."""
import hashlib,json,os,shutil,sys,time
from pathlib import Path
from ltx_original_runtime import LTXOriginalRuntime
VERSION='0.47.0'
SOURCES=json.loads(Path(__file__).with_name('separator_sources.json').read_text())
def package_files(name):return [x['file'] for x in SOURCES[name]['files']]
def verify_package(directory,name):
for item in SOURCES[name]['files']:
path=Path(directory)/item['file']
if not path.is_file() or path.is_symlink():return False
digest=hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda:stream.read(1024**2),b''):digest.update(chunk)
if digest.hexdigest()!=item['sha256']:return False
return True
MODELS={
'vocals_mel_band_roformer.ckpt':('MelBand RoFormer · Gesang / Instrumental','vocals_mel_band_roformer.yaml'),
'model_bs_roformer_ep_317_sdr_12.9755.ckpt':('BS-RoFormer · Gesang / Instrumental','model_bs_roformer_ep_317_sdr_12.9755.yaml'),
'htdemucs_ft.yaml':('Demucs FT · Gesang / Schlagzeug / Bass / Rest','htdemucs_ft.yaml'),
'htdemucs_6s.yaml':('Demucs 6s · zusätzlich Gitarre / Piano','htdemucs_6s.yaml')}
class SeparatorRuntime(LTXOriginalRuntime):
name='Audio Separator'
def __init__(self,root):super().__init__(root,None)
def paths(self):
marker=self.root/'active.json'
if marker.is_file():
ident=json.loads(marker.read_text()).get('id','')
if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):
base=self.root/ident
if (base/'ready').is_file():return base/'python/bin/python',base
return self.root/'missing-python',self.root/'missing-runtime'
def status(self):
python,base=self.paths()
return dict(installed=python.is_file() and (base/'ready').is_file(),version=VERSION,required_disk_gib=15,job=dict(self.job) if self.job else None,models=[dict(id=name,name=label,configuration=config,source=SOURCES[name],installed=self.model_ready(name)) for name,(label,config) in MODELS.items()])
def model_ready(self,name):
if name not in MODELS:return False
directory=self.root/'models';marker=directory/(name+'.ready')
try:
recorded=json.loads(marker.read_text())
return recorded=={x['file']:x['sha256'] for x in SOURCES[name]['files']} and all((directory/f).is_file() and not (directory/f).is_symlink() and (directory/f).stat().st_size>0 for f in package_files(name))
except (OSError,ValueError):return False
def start(self):
if not shutil.which('ffmpeg'):raise ValueError('FFmpeg fehlt. Deck-Systemvoraussetzungen installieren.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Einrichtungsauftrag läuft bereits.')
self.requested_model=None
return super().start()
def download(self,name):
import threading,uuid
if not isinstance(name,str) or name not in MODELS:raise ValueError('Dieses Modellpaket ist nicht unterstützt.')
with self.lock:
if not self.status()['installed']:raise ValueError('Zuerst Audio Separator installieren.')
if self.job and self.job['state']=='running':raise ValueError('Einrichtungsauftrag läuft bereits.')
if shutil.disk_usage(self.root).free<3*1024**3:raise ValueError('Mindestens 3 GiB freier Speicher erforderlich.')
self.cancel.clear();self.requested_model=name;self.job=dict(id=uuid.uuid4().hex,state='running',phase='Modellpaket herunterladen',started_at=time.time());self._save()
self.worker=threading.Thread(target=self._run,daemon=True);self.worker.start();return self.status()
def _run(self):
base=self.root/self.job['id'];state='failed';phase='Einrichtung fehlgeschlagen.';model=getattr(self,'requested_model',None)
try:
if model:
directory=base;directory.mkdir(mode=0o700)
import urllib.request
for item in SOURCES[model]['files']:
self._phase('Modellpaket: '+item['file'])
succeeded=False
for url in item['urls']:
try:
digest=hashlib.sha256();size=0
with urllib.request.urlopen(url,timeout=60) as response,(directory/item['file']).open('wb') as out:
while chunk:=response.read(1024**2):
if self.cancel.is_set():raise InterruptedError()
size+=len(chunk)
if size>2*1024**3:raise ValueError('Quelldatei zu groß.')
digest.update(chunk);out.write(chunk)
if digest.hexdigest()!=item['sha256']:raise ValueError('Quelldatei stimmt nicht mit dem geprüften Rezept überein.')
succeeded=True;break
except InterruptedError:raise
except Exception:continue
if not succeeded:raise ValueError('Geprüfte Quelle nicht verfügbar: '+item['file'])
filenames=package_files(model)
if not verify_package(directory,model):raise ValueError('Modellpaket-Prüfsumme stimmt nicht.')
if self.cancel.is_set():raise InterruptedError()
destination=self.root/'models';destination.mkdir(exist_ok=True);hashes={}
for f in filenames:
digest=hashlib.sha256()
with (directory/f).open('rb') as source:
for chunk in iter(lambda:source.read(1024**2),b''):digest.update(chunk)
hashes[f]=digest.hexdigest();(directory/f).replace(destination/f)
(destination/(model+'.ready')).write_text(json.dumps(hashes))
state='complete';phase='Modell und passende Konfiguration heruntergeladen. Kein Modell geladen.'
else:
base.mkdir(mode=0o700);tmp=base/'tmp';tmp.mkdir()
env=dict(os.environ,TMPDIR=str(tmp),PIP_NO_INPUT='1',PIP_DISABLE_PIP_VERSION_CHECK='1')
python=base/'python/bin/python';self._phase('Eigene Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python'],env)
self._phase('CUDA-PyTorch herunterladen · mehrere GiB');self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchaudio==2.11.0','--index-url','https://download.pytorch.org/whl/cu128'],env)
self._phase('Audio Separator und GPU-Abhängigkeiten installieren');self._command([python,'-m','pip','install','--no-cache-dir',f'audio-separator[gpu]=={VERSION}','onnxruntime-gpu==1.22.0','audioread==3.1.0'],env)
self._phase('Paketkonsistenz und CUDA-Unterstützung prüfen');self._command([python,'-m','pip','check'],env)
self._command([python,'-c','import torch; from audio_separator.separator import Separator; assert torch.version.cuda'],env)
if self.cancel.is_set():raise InterruptedError()
(base/'ready').write_text(VERSION);marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json')
state='complete';phase='Audio Separator installiert. Jetzt Modellpaket auswählen.'
except InterruptedError:state='cancelled';phase='Einrichtung abgebrochen.'
except Exception as exc:phase=str(exc)
finally:
if model or state!='complete':shutil.rmtree(base,ignore_errors=True)
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
+113
View File
@@ -0,0 +1,113 @@
{
"vocals_mel_band_roformer.ckpt": {
"recipe_version": 1,
"publisher": "Kimberley Jensen / UVR",
"homepage": "https://github.com/nomadkaraoke/python-audio-separator",
"files": [
{
"file": "vocals_mel_band_roformer.ckpt",
"sha256": "87201f4d31afb5bc79993230fc49446918425574db48c01c405e44f365c7559e",
"urls": [
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/vocals_mel_band_roformer.ckpt",
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/vocals_mel_band_roformer.ckpt"
]
},
{
"file": "vocals_mel_band_roformer.yaml",
"sha256": "b958b29c8f7195f0d86bee6759a33980db675c4ecaf2fcaa80fa125828e6cd38",
"urls": [
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/mdx_model_data/mdx_c_configs/vocals_mel_band_roformer.yaml",
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/vocals_mel_band_roformer.yaml"
]
}
]
},
"model_bs_roformer_ep_317_sdr_12.9755.ckpt": {
"recipe_version": 1,
"publisher": "Viperx / UVR",
"homepage": "https://github.com/nomadkaraoke/python-audio-separator",
"files": [
{
"file": "model_bs_roformer_ep_317_sdr_12.9755.ckpt",
"sha256": "5b84f37e8d444c8cb30c79d77f613a41c05868ff9c9ac6c7049c00aefae115aa",
"urls": [
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/model_bs_roformer_ep_317_sdr_12.9755.ckpt",
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/model_bs_roformer_ep_317_sdr_12.9755.ckpt"
]
},
{
"file": "model_bs_roformer_ep_317_sdr_12.9755.yaml",
"sha256": "2bfdd16c656bd9519aba757cc4f8834b7ede675eb1e00ec4772d74ae1c41af7f",
"urls": [
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/mdx_model_data/mdx_c_configs/model_bs_roformer_ep_317_sdr_12.9755.yaml",
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/model_bs_roformer_ep_317_sdr_12.9755.yaml"
]
}
]
},
"htdemucs_ft.yaml": {
"recipe_version": 1,
"publisher": "Meta / Demucs",
"homepage": "https://github.com/facebookresearch/demucs",
"files": [
{
"file": "htdemucs_ft.yaml",
"sha256": "69470b8c1bbd674437b51bc9fb491327a10ab0396b702c93389b9cf750016346",
"urls": [
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/htdemucs_ft.yaml",
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/htdemucs_ft.yaml"
]
},
{
"file": "f7e0c4bc-ba3fe64a.th",
"sha256": "ba3fe64ae8ef66ac9a4857222ce48efbdc5eb3ad375cb79dd13debee5aaa4066",
"urls": [
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/f7e0c4bc-ba3fe64a.th"
]
},
{
"file": "d12395a8-e57c48e6.th",
"sha256": "e57c48e6b0e38af4f7118d7bd08c49f0a0c0edf7d09143bdd902ea0d237303e6",
"urls": [
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/d12395a8-e57c48e6.th"
]
},
{
"file": "92cfc3b6-ef3bcb9c.th",
"sha256": "ef3bcb9c8b40d14ae5d51b6db2587339cc12c6b77c0be151ce6d69002e087bf2",
"urls": [
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/92cfc3b6-ef3bcb9c.th"
]
},
{
"file": "04573f0d-f3cf25b2.th",
"sha256": "f3cf25b222c4eed7cd49dd8b2c9597d50c18bd154090f7b919cfa5f93cf22c49",
"urls": [
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/04573f0d-f3cf25b2.th"
]
}
]
},
"htdemucs_6s.yaml": {
"recipe_version": 1,
"publisher": "Meta / Demucs",
"homepage": "https://github.com/facebookresearch/demucs",
"files": [
{
"file": "htdemucs_6s.yaml",
"sha256": "207405151270af8fd81c2373c25d27950916682ac91dca7884a11ce13dad6f58",
"urls": [
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/htdemucs_6s.yaml",
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/htdemucs_6s.yaml"
]
},
{
"file": "5c90dfd2-34c22ccb.th",
"sha256": "34c22ccb381c6f9fdbf324f04e1e2fe21aaaf293f5ded163a162697ff9a02ddd",
"urls": [
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/5c90dfd2-34c22ccb.th"
]
}
]
}
}
+31
View File
@@ -0,0 +1,31 @@
"""One native separation process; exact pinned package, no arbitrary model code."""
import json,sys
from pathlib import Path
from audio_separator.separator import Separator
import torch
model_dir,model,input_file,output_dir=sys.argv[1:5]
target=sys.argv[5] if len(sys.argv)>5 else ''
from separator_runtime import verify_package
if not verify_package(model_dir,model):raise RuntimeError('Modellpaket-Prüfsumme stimmt nicht.')
if not torch.cuda.is_available():raise RuntimeError('CUDA nicht verfügbar; kein stiller CPU-Fallback.')
separator=Separator(model_file_dir=model_dir,output_dir=output_dir,output_format='WAV',use_soundfile=True,demucs_params={'segment_size':7,'shifts':2,'overlap':.25},mdxc_params={'segment_size':256,'override_model_segment_size':False,'batch_size':1,'overlap':2,'pitch_shift':0})
separator.load_model(model)
files=separator.separate(input_file)
root=Path(output_dir).resolve();names=[]
for name in files:
path=(root/name).resolve()
if path.parent!=root or not path.is_file() or path.suffix.lower()!='.wav':raise RuntimeError('Ungültige Ausgabe.')
names.append(path.name)
if not names:raise RuntimeError('Keine Spuren erzeugt.')
if target:
import subprocess
chosen=next((n for n in names if target in Path(n).stem.lower()),None)
if not chosen:raise RuntimeError('Zielspur fehlt.')
others=[root/n for n in names if n!=chosen]
if not others:raise RuntimeError('Restspuren fehlen.')
args=['ffmpeg','-hide_banner','-loglevel','error','-y']
for f in others:args+=['-i',str(f)]
args+=['-filter_complex',''.join(f'[{i}:a]' for i in range(len(others)))+f'amix=inputs={len(others)}:normalize=0:dropout_transition=0[rest]','-map','[rest]','-c:a','pcm_s16le',str(root/'rest.wav')]
subprocess.run(args,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=180)
names=[chosen,'rest.wav']
(root/'results.json').write_text(json.dumps(names))
+103 -11
View File
@@ -17,6 +17,8 @@ from profiles import Profiles
from capacity import assess, overview
from runtime import Runtime
from image_runtime import ImageRuntime
from separator_runtime import SeparatorRuntime
from separator import SeparatorTests
from image_test import ImageTests
from prompt_enhancer import PromptEnhancer
from docker_support import DockerSupport
@@ -99,6 +101,8 @@ class Server(ThreadingHTTPServer):
raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.')
self.worker=LlamaWorker(self.catalog.root.parent/'llama-worker',self.catalog,self.runtime)
self.scheduler=Scheduler(self.worker)
self.separator_runtime=SeparatorRuntime(self.catalog.root.parent/'separator-runtime')
self.separator_tests=SeparatorTests(self.catalog.root.parent/'separator-tests',self.separator_runtime,self.scheduler)
self.profiles.chat_blockers=self.worker.blockers
self.image_tests.acquire=self.scheduler.image_reservation
self.prompt_enhancer.acquire=self.scheduler.image_reservation
@@ -112,12 +116,23 @@ class Server(ThreadingHTTPServer):
self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler)
self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
def stop_gpu_work():
self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop()
self.separator_tests.stop();self.music.stop();self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop()
from video_original import VideoRuntimes
from ltx_original_runtime import LTXOriginalRuntime
from audio_cpp_runtime import AudioCppRuntime
self.audio_cpp_runtime=AudioCppRuntime(self.catalog.root.parent/'audio-cpp-runtime')
self.profiles.music_runtime_ready=lambda:self.audio_cpp_runtime.status()['installed']
from music import Music
self.music=Music(self.catalog.root.parent/'music-jobs',self.profiles,self.catalog,self.audio_cpp_runtime,self.scheduler)
self.profiles.music_blockers=self.music.blockers
self.endpoint.music=self.music
from profiles import audio_package_recipe
self.profiles.runtime_recipe=lambda model:audio_package_recipe(model,self.audio_cpp_runtime.paths()[1])
self.ltx_original_runtime=LTXOriginalRuntime(self.catalog.root.parent/'ltx-original-runtime',self.image_runtime)
self.video=VideoRuntimes(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime,original_runtime=self.ltx_original_runtime)
self.endpoint.video=self.video
self.video.separator_status=lambda:dict(self.separator_runtime.status(),worker=self.separator_tests.status())
self.video.music_profiles=lambda:[dict(id=p['id'],name=p['name']) for p in self.endpoint.rows() if p['kind']=='music' and p['enabled'] and p['runnable']]
self.profiles.video_runtime_ready=lambda:self.image_runtime.status().get("installed",False)
from dashboard_data import Dashboard
self.dashboard=Dashboard(self,self.catalog.root.parent)
@@ -173,7 +188,7 @@ class Handler(BaseHTTPRequestHandler):
return secrets.compare_digest(actual,record['api_token_hash'])
def token_route(self):
return self.command == 'GET' and self.path in ('/api/v1/status','/api/v1/hardware')
return (self.command == 'GET' and (self.path in ('/api/v1/status','/api/v1/hardware','/api/v1/separator-runtime','/api/v1/separator-tests') or urlsplit(self.path).path=='/api/v1/separator-tests/audio')) or (self.command=='POST' and self.path in ('/api/v1/separator-jobs/start','/api/v1/separator-tests/cancel'))
def session_token(self):
try:
@@ -277,9 +292,21 @@ class Handler(BaseHTTPRequestHandler):
allowed.update(filter(None,os.environ.get('DECK_ALLOWED_HOSTS','').split(',')))
return self.headers.get('Host') in allowed
def execution_setup(self, model, profile=None):
installed={'stt':bool(self.server.runtime.status().get('active')),'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False),'video':self.server.video.status()['runtime']['installed']}
return execution_assess(model,installed,profile)
def execution_runtime_snapshot(self):
# Installation checks need no video model/profile scan or worker status.
chat = bool(self.server.runtime.status().get('active'))
video_runtime = (self.server.video.original_runtime
if self.server.video.runtime_kind() == 'original'
else self.server.video.runtime)
return {'stt':chat,'chat':chat,
'image':self.server.image_runtime.status().get('installed',False),
'audio':self.server.tts_runtime.status().get('installed',False),
'video':video_runtime.status().get('installed',False),
'audio_cpp':self.server.audio_cpp_runtime.status()['installed'],
'music_worker':True,'separator':self.server.separator_runtime.status()['installed']}
def execution_setup(self, model, profile=None, installed=None):
return execution_assess(model, self.execution_runtime_snapshot() if installed is None else installed, profile)
def do_GET(self):
if not self.valid_host():
@@ -292,7 +319,7 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
routes = {'/ltx-original-ui.js':('ltx-original-ui.js','text/javascript'),'/backup-ui.js':('backup-ui.js','text/javascript'),'/dashboard-ui.js':('dashboard-ui.js','text/javascript'),'/dashboard.css':('dashboard.css','text/css'),'/themes.css':('themes.css','text/css'),'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('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'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
routes = {'/separator-ui.js':('separator-ui.js','text/javascript'),'/music-ui.js':('music-ui.js','text/javascript'),'/audio-cpp-ui.js':('audio-cpp-ui.js','text/javascript'),'/ltx-original-ui.js':('ltx-original-ui.js','text/javascript'),'/backup-ui.js':('backup-ui.js','text/javascript'),'/dashboard-ui.js':('dashboard-ui.js','text/javascript'),'/dashboard.css':('dashboard.css','text/css'),'/themes.css':('themes.css','text/css'),'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('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'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
if self.path in routes:
name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime)
@@ -311,6 +338,19 @@ class Handler(BaseHTTPRequestHandler):
if urlsplit(self.path).path == '/api/v1/tts/audio':
try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
except (OSError,ValueError):return self.respond({'error':'Audio nicht verfügbar.'},404)
if self.path == '/api/v1/music':return self.respond(self.server.music.status())
if urlsplit(self.path).path == '/api/v1/music/audio':
try:return self.respond(self.server.music.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
except (OSError,ValueError):return self.respond({'error':'Musik nicht verfügbar.'},404)
if self.path == '/api/v1/separator-runtime':return self.respond(self.server.separator_runtime.status())
if self.path == '/api/v1/separator-runtime/log':return self.respond(self.server.separator_runtime.log())
if self.path == '/api/v1/separator-tests':return self.respond(self.server.separator_tests.status())
if urlsplit(self.path).path == '/api/v1/separator-tests/audio':
try:
query=parse_qs(urlsplit(self.path).query);return self.respond(self.server.separator_tests.audio(query.get('id',[''])[0],int(query.get('index',['-1'])[0])),mime='audio/wav')
except (ValueError,OSError):return self.respond({'error':'Spur nicht verfügbar.'},404)
if self.path == '/api/v1/audio-cpp-runtime':return self.respond(self.server.audio_cpp_runtime.status())
if self.path == '/api/v1/audio-cpp-runtime/log':return self.respond(self.server.audio_cpp_runtime.log())
if self.path == '/api/v1/ltx-original-runtime':return self.respond(self.server.ltx_original_runtime.status())
if self.path == '/api/v1/ltx-original-runtime/log':return self.respond(self.server.ltx_original_runtime.log())
if self.path == '/api/v1/video':return self.respond(self.server.video.status())
@@ -321,12 +361,13 @@ class Handler(BaseHTTPRequestHandler):
try:return self.respond(self.server.image_tests.image(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='image/png')
except (OSError,ValueError):return self.respond({'error':'Bild nicht verfügbar.'},404)
if urlsplit(self.path).path == '/api/v1/profiles/components':
try:return self.respond(self.server.profiles.components(parse_qs(urlsplit(self.path).query).get('model_id',[''])[0]))
try:return self.respond(self.server.profiles.components(parse_qs(urlsplit(self.path).query).get('model_id',[''])[0],parse_qs(urlsplit(self.path).query).get('repo',[None])[0]))
except ValueError as exc:return self.respond({'error':str(exc)},400)
except Exception:return self.respond({'error':'Komponentenquelle nicht erreichbar. Bitte erneut versuchen.'},503)
if self.path == '/api/v1/profiles':
data=self.server.profiles.status()
for p in data['profiles']:p['execution']=self.execution_setup(p.get('model') or {},p)
installed=self.execution_runtime_snapshot()
for p in data['profiles']:p['execution']=self.execution_setup(p.get('model') or {},p,installed)
return self.respond(data)
if self.path == '/api/v1/huggingface':return self.respond(self.server.catalog.hub_auth.status())
if self.path == '/api/v1/hardware':
@@ -366,12 +407,21 @@ class Handler(BaseHTTPRequestHandler):
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']),execution=self.execution_setup(dict(repo=data['repo'],revision=data['revision'],file=f['name'],kind=q.get('kind',['chat'])[0],runtime_metadata=data.get('runtime_metadata',{})))) for f in data['files']]
installed=self.execution_runtime_snapshot()
data['files']=[dict(f,assessment=assess(f['size'],hardware,f['name']),execution=self.execution_setup(dict(repo=data['repo'],revision=data['revision'],file=f['name'],kind=q.get('kind',['chat'])[0],runtime_metadata=data.get('runtime_metadata',{})),installed=installed)) for f in data['files']]
from profiles import component_recipe
from catalog import file_role
for f in data['files']:
model=dict(repo=data['repo'],file=f['name'],kind=q.get('kind',['chat'])[0])
recipe=component_recipe(model) or self.server.profiles.runtime_recipe(model)
if recipe:f['execution']['components']=[r['label'] for r in recipe.values()]
elif f['execution']['state']!='component':f['execution']['suggested_components']=[x['name'] for x in data['files'] if file_role(x['name'])['role'] in ('text_encoder','vae','vision_projector','audio_projector')][:12]
return self.respond(data)
if path.path=='/api/v1/catalog':
data=self.server.catalog.status()
installed=self.execution_runtime_snapshot()
for entry in data['entries']:
entry['execution']=self.execution_setup(entry)
entry['execution']=self.execution_setup(entry,installed=installed)
entry['used_by_profiles']=self.server.profiles.references(entry['id'])
return self.respond(data)
except Exception as exc:
@@ -450,6 +500,13 @@ class Handler(BaseHTTPRequestHandler):
with self.server.auth_lock:
self.server.sessions.pop(self.session_token(),None)
return self.respond({'logged_out':True})
if self.path in ('/api/v1/docker/start','/api/v1/docker/stop','/api/v1/docker/delete'):
try:
data=self.read_json();action=self.path.rsplit('/',1)[1]
expected={'id','confirm'} if action=='delete' else {'id'}
if set(data)!=expected or (action=='delete' and data['confirm'] is not True):raise ValueError('Ungültige oder unbestätigte Containeraktion.')
return self.respond(self.server.docker.container_action(action,data['id']))
except (OSError,ValueError) as exc:return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht erreichbar.'},400)
if self.path == '/api/v1/docker/install':
try:
if self.read_json()!={'confirm':True}:raise ValueError('Die Docker-Erstinstallation muss ausdrücklich bestätigt werden.')
@@ -482,6 +539,33 @@ class Handler(BaseHTTPRequestHandler):
if data:raise ValueError('Keine Parameter erwartet.')
return self.respond(self.server.chat_tests.stop() if self.path.endswith('/cancel') else self.server.chat_tests.unload())
except ValueError as exc:return self.respond({'error':str(exc)},400)
if self.path == '/api/v1/separator-profiles/save':
try:return self.respond(self.server.separator_tests.configure(self.read_json()))
except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/separator-runtime/install','/api/v1/separator-runtime/cancel','/api/v1/separator-runtime/download'):
try:
data=self.read_json()
if self.path.endswith('/download'):
if set(data)!={'model'}:raise ValueError('Nur Modell-ID erwartet.')
return self.respond(self.server.separator_runtime.download(data['model']))
if data:raise ValueError('Keine Parameter erwartet.')
return self.respond(self.server.separator_runtime.stop() if self.path.endswith('/cancel') else self.server.separator_runtime.start())
except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/separator-tests/start','/api/v1/separator-jobs/start','/api/v1/separator-tests/cancel'):
try:
if self.path.endswith('/cancel'):
if self.read_json():raise ValueError('Keine Parameter erwartet.')
return self.respond(self.server.separator_tests.stop())
# Reuse bounded multipart parsing; separation permits stereo WAV.
fields,audio=read_upload(self,validate=False,max_audio=256*1024**2 if self.path=='/api/v1/separator-jobs/start' else 8*1024**2)
if not {'model'}<=set(fields) or set(fields)-{'model','target'}:raise ValueError('Nur Modell, Zielspur und WAV-Datei erwartet.')
return self.respond(self.server.separator_tests.start(fields['model'],audio,target=fields.get('target'),full=self.path=='/api/v1/separator-jobs/start'))
except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/audio-cpp-runtime/install','/api/v1/audio-cpp-runtime/cancel'):
try:
if self.read_json():raise ValueError('Keine Installationsparameter erwartet.')
return self.respond(self.server.audio_cpp_runtime.stop() if self.path.endswith('/cancel') else self.server.audio_cpp_runtime.start())
except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/ltx-original-runtime/install','/api/v1/ltx-original-runtime/cancel'):
try:
if self.read_json():raise ValueError('Keine Installationsparameter erwartet.')
@@ -493,7 +577,7 @@ class Handler(BaseHTTPRequestHandler):
data=self.read_json()
if self.path.endswith('/runtime') and set(data)=={'runtime'}:return self.respond(self.server.video.select_runtime(data['runtime']))
if self.path.endswith('/service') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id']))
if set(data)!={'mode'}:raise ValueError('Nur Modus llm oder video erwartet.')
if set(data)!={'mode'}:raise ValueError('Nur Modus llm, video, music oder separator erwartet.')
return self.respond(self.server.video.switch(data['mode']))
except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'):
@@ -517,6 +601,13 @@ class Handler(BaseHTTPRequestHandler):
return self.respond(self.server.stt.setup() if self.path.endswith('/setup') else self.server.stt.stop())
except ValueError as exc:return self.respond({'error':str(exc)},400)
except Exception:return self.respond({'error':'STT-Aktion fehlgeschlagen.'},503)
if self.path in ('/api/v1/music/start','/api/v1/music/cancel'):
try:
data=self.read_json()
if self.path.endswith('/cancel') and not data:return self.respond(self.server.music.stop())
if set(data)-{'profile_id','lyrics','style','seed','max_tokens','steps'} or not {'profile_id','lyrics','style'}<=set(data):raise ValueError('Ungültige Musikfelder.')
return self.respond(self.server.music.start(**data))
except ValueError as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/tts/install','/api/v1/tts/install-cancel','/api/v1/tts/assign','/api/v1/tts/start','/api/v1/tts/cancel'):
try:
data=self.read_json();t=self.server.tts_tests
@@ -658,6 +749,7 @@ def main():
server.tts_runtime.stop()
server.image_runtime.stop()
server.ltx_original_runtime.stop()
server.separator_tests.stop();server.separator_runtime.stop();server.music.stop();server.audio_cpp_runtime.stop()
server.prompt_enhancer.stop()
server.image_tests.stop()
server.runtime.stop()
+4 -4
View File
@@ -22,13 +22,13 @@ def validate_wav(audio):
if len(w.readframes(w.getnframes()))!=w.getnframes()*2:raise ValueError('WAV-Datei ist unvollständig.')
except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei.') from None
def read_upload(handler):
def read_upload(handler,validate=True,max_audio=MAX_AUDIO):
handler.connection.settimeout(30)
if handler.headers.get('Transfer-Encoding'):raise ValueError('Chunked Upload wird nicht unterstützt.')
try:length=int(handler.headers.get('Content-Length','0'))
except ValueError:raise ValueError('Ungültige Upload-Länge.') from None
content_type=handler.headers.get('Content-Type','')
if not 0<length<=MAX_AUDIO+65536 or not content_type.lower().startswith('multipart/form-data;'):raise ValueError('Multipart-Upload bis 8 MiB erforderlich.')
if not 0<length<=max_audio+65536 or not content_type.lower().startswith('multipart/form-data;'):raise ValueError('Multipart-Upload bis 8 MiB erforderlich.')
raw=handler.rfile.read(length)
if len(raw)!=length:raise ValueError('Upload unvollständig.')
message=BytesParser(policy=policy.default).parsebytes(b'Content-Type: '+content_type.encode()+b'\r\nMIME-Version: 1.0\r\n\r\n'+raw)
@@ -41,10 +41,10 @@ def read_upload(handler):
if audio is not None:raise ValueError('Nur eine Audiodatei erlaubt.')
audio=data
else:
if name not in ('model','profile_id','language','response_format') or name in fields or len(data)>256:raise ValueError('Ungültiges oder doppeltes Feld.')
if name not in ('model','profile_id','language','response_format','target') or name in fields or len(data)>256:raise ValueError('Ungültiges oder doppeltes Feld.')
try:fields[name]=data.decode('utf-8')
except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None
validate_wav(audio)
if validate:validate_wav(audio)
if fields.get('response_format','json')!='json':raise ValueError('Aktuell wird response_format=json unterstützt.')
return fields,audio
+31 -5
View File
@@ -1,22 +1,23 @@
/* Server-backed model management. Legacy browser drafts remain untouched. */
const Studio=(()=>{
let section='discover',category='';
const labels={chat:'Sprachmodelle / Chat',image:'Bildgenerierung',audio:'Text-to-Speech (TTS)',stt:'Spracherkennung (STT)',music:'Musik',voice:'Stimme / Voice',video:'Video'};
const audioDescriptions={audio:'Geschriebenen Text vorlesen lassen. Die Suche zeigt Text-to-Speech-Modelle.',stt:'Gesprochene Sprache in Text umwandeln. Die Suche zeigt Spracherkennungsmodelle.',music:'Musik und Klänge aus Text erzeugen. Die Hub-Kategorie Text-to-Audio umfasst neben Musik auch Geräusche und andere Audioerzeugung.',voice:'Vorhandenes Audio bearbeiten: Stimmumwandlung, Audiotrennung und weitere Audio-to-Audio-Modelle. Eigenständige Studio-Oberflächen gehören unter Weitere Dienste.'};
const labels={chat:'Chat & Sprachmodelle',image:'Bildgenerierung',audio:'Sprachausgabe (TTS)',stt:'Spracherkennung (STT)',music:'Musikgenerierung',voice:'Stimmwerkzeuge',video:'Videogenerierung'};
const audioDescriptions={audio:'Geschriebenen Text vorlesen lassen. Die Suche zeigt Text-to-Speech-Modelle.',stt:'Gesprochene Sprache in Text umwandeln. Die Suche zeigt Spracherkennungsmodelle.',music:'Musik und Klänge aus Text erzeugen. Die Hub-Kategorie Text-to-Audio umfasst neben Musik auch Geräusche und andere Audioerzeugung.',voice:'Vorhandenes Audio bearbeiten: Stimmumwandlung, Audio-Trennung und weitere Audio-to-Audio-Modelle. Eigenständige Studio-Oberflächen gehören unter Weitere Dienste.'};
function render(page,force=false,modelId=null){
if(page==='video'){VideoUI.runtime();return;}
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';}
if(modelId)section='profiles';
const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['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==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. 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>`;
const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${page==='voice'?'<p><a class="link" href="#separator-runtime">Audio Separator einrichten →</a> · <a class="link" href="#separation">Audio-Trennung →</a></p><p class="note">Audio-Trennung nutzt die unterstützten Pakete im Audio-Separator-Bereich. Andere Hub-Dateien und Voice-Profile benötigen weiterhin eine geprüfte Anbindung.</p>':''}${audioDescriptions[page]&&!['audio','stt','music','voice'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt','music'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[])].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==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='music'?MusicUI.html():['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. 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>`;
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
if(page==='stt-runtime'){STTUI.bind(true);return;}
if(page==='tts-runtime'){TTSUI.bind(true);return;}
if(page==='runtime'){RuntimeUI.bind();return;}
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
if(page==='runtimes')return;
if(page==='runtimes'){bindRuntimeOverview();return;}
else if(section==='setup'){if(page==='stt')STTUI.bind(true);else TTSUI.bind(true);}
else if(['test','running'].includes(section)&&page==='music')MusicUI.bind();
else if(['test','running'].includes(section)&&page==='stt')STTUI.bind();
else if(['test','running'].includes(section)&&page==='audio')TTSUI.bind();
else if(section==='auto')AutoTestUI.bind();
@@ -27,7 +28,32 @@ const Studio=(()=>{
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);});
}
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten werden einmal eingerichtet und für kompatible Modelle wiederverwendet. Entdecken, Bibliothek und Profile zeigen den passenden Einrichtungsweg; unbekannte Modelle werden als noch nicht unterstützt gekennzeichnet. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>Spracherkennung</h2><p>Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.</p><a class="link" href="#stt-runtime">STT einrichten →</a></section><section class="card"><h2>Text-to-Speech</h2><p>Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.</p><a class="link" href="#tts-runtime">TTS einrichten →</a></section><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section><section class="card"><h2>LTX Original</h2><p>Originales LTX-Desktop-Backend mit nativer LTX-API für LTX DeskWEB.</p><a class="link" href="#ltx-original-runtime">LTX Original einrichten →</a></section><section class="card"><h2>Docker</h2><p>Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.</p><a class="link" href="#docker-runtime">Docker öffnen →</a></section></div>`;}
const runtimeRows=[
['llama.cpp','Chat & Sprachmodelle','runtime','/api/v1/runtime','Offizielle Releases prüfen, Änderungen lesen, CPU-/CUDA-Build erstellen und geprüften Standard auswählen.'],
['ComfyUI + GGUF','Bilder & Video','image-runtime','/api/v1/image-runtime','Einmal installieren und für unterstützte Bild- und Videomodelle wiederverwenden. Gepinnte Versionen werden mit Deck gepflegt.'],
['Qwen3-TTS','Sprachausgabe','tts-runtime','/api/v1/tts','CUDA-Laufzeit für unterstützte Qwen3-TTS-Modelle. Die Einrichtung lädt auch das vorgesehene Stimmenmodell.'],
['Qwen3-ASR','Spracherkennung','stt-runtime','/api/v1/stt','Verwendet llama.cpp. Modell und Audio-Projektor werden in der Einrichtung zugeordnet.'],
['audio.cpp','Musik & Audio','audio-cpp-runtime','/api/v1/audio-cpp-runtime','CUDA-Build einmal erstellen. YuE2 ist angebunden; andere Modellfamilien brauchen eine geprüfte Worker-Anbindung.'],
['LTX Original','Video','ltx-original-runtime','/api/v1/ltx-original-runtime','Originales LTX-Backend für LTX DeskWEB. Modelle und Zusatzdateien bleiben in der Bibliothek.'],
['Docker Engine','Weitere Dienste','docker-runtime','/api/v1/docker','Optionale Container-Umgebung. Erstinstallation über den Systemhelfer; vorhandenes Docker wird nicht automatisch aktualisiert.'],
['Audio Separator','Audio-Trennung','separator-runtime','/api/v1/separator-runtime','Native CUDA-Laufzeit für BS-Roformer, MelBand und Demucs FT/6s. Vollständige Modellpakete mit überprüften Originalquellen und Prüfsummen.']
];
const runtimeEscape=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Einmal einrichten, für passende Modelle wiederverwenden. Dieser Katalog zeigt die in Deck unterstützten und vorgesehenen Laufzeiten. Zusatzdateien findest du beim Modell unter Einrichtung & Komponenten.</p><label>Laufzeit suchen<input id="runtime-search" type="search" placeholder="Name oder Bereich"></label><p id="runtime-catalog-message" role="status">Installationsstatus wird geprüft …</p><div class="detail-list">${runtimeRows.map(([name,category,route,path,description],i)=>`<details class="detail-row" data-runtime-row="${i}"><summary class="detail-summary"><strong>${name}</strong><span>${category}</span><span data-runtime-status>${path?'Wird geprüft …':'Noch nicht angebunden'}</span></summary><div class="detail-body"><p>${description}</p><p data-runtime-version></p>${route?`<a class="link" href="#${route}">${i===0?'Installation, Build & Updates öffnen': 'Einrichtung öffnen'} →</a>`:''}${i===0?'<p><button id="catalog-llama-updates">llama.cpp-Updates prüfen</button></p><div id="catalog-llama-releases"></div>':''}</div></details>`).join('')}</div>`;}
function bindRuntimeOverview(){
const root=document.querySelector('#studio[data-page="runtimes"]');if(!root)return;
const esc=runtimeEscape,message=root.querySelector('#runtime-catalog-message');
root.querySelector('#runtime-search').oninput=event=>{const query=event.target.value.trim().toLocaleLowerCase();root.querySelectorAll('[data-runtime-row]').forEach(row=>{const data=runtimeRows[Number(row.dataset.runtimeRow)];row.hidden=!data.slice(0,2).join(' ').toLocaleLowerCase().includes(query);});};
async function read(path){const response=await fetch(path);const data=await response.json();if(!response.ok)throw Error(data.error||'Status nicht erreichbar');return data;}
async function refresh(){const results=await Promise.allSettled(runtimeRows.map(async(row,i)=>{if(!row[3])return;const response=await read(row[3]),data=i===2?response.runtime:response;if(!root.isConnected)return;const el=root.querySelector(`[data-runtime-row="${i}"]`),active=i===0?data.builds?.find(b=>b.id===data.active):null;
const installed=i===0?Boolean(active):data.installed;
el.querySelector('[data-runtime-status]').textContent=data.job?.state==='running'?'Einrichtung läuft':installed===true?'Installiert':installed===false?'Einrichtung erforderlich':'Nicht ermittelbar';
el.querySelector('[data-runtime-version]').textContent=[active?`${active.revision} · ${active.backend}`:data.version||data.comfy_revision||data.desktop_revision||data.revision||'',data.job?`${data.job.state} · ${data.job.phase||''}`:'',i===0?'Neue Versionen werden nur auf Knopfdruck geprüft.':''].filter(Boolean).join(' · ');
}));if(!root.isConnected)return;results.forEach((result,i)=>{if(result.status==='rejected')root.querySelector(`[data-runtime-row="${i}"] [data-runtime-status]`).textContent='Status nicht erreichbar';});message.textContent=results.some(r=>r.status==='rejected')?'Einige Statusabfragen sind fehlgeschlagen. Einrichtung öffnen oder später erneut prüfen.':'Installationsstatus geprüft. Eine installierte Laufzeit benötigt weiterhin ein kompatibles Modell und vollständige Komponenten.';setTimeout(()=>{if(root.isConnected)refresh();},10000);}
root.querySelector('#catalog-llama-updates').onclick=async event=>{const button=event.currentTarget;button.disabled=true;message.textContent='Offizielle llama.cpp-Releases werden geprüft …';try{const data=await read('/api/v1/runtime/releases');if(!root.isConnected)return;root.querySelector('#catalog-llama-releases').innerHTML=data.releases.map(release=>`<article><h3>${esc(release.tag)} · ${release.prerelease?'Vorabversion':'Reguläres Release'}</h3><p>${esc(release.published)}</p><details><summary>Änderungen anzeigen</summary><pre style="white-space:pre-wrap">${esc(release.notes)}</pre></details><button data-catalog-release="${esc(release.tag)}">Build vorbereiten</button></article>`).join('');root.querySelectorAll('[data-catalog-release]').forEach(b=>b.onclick=()=>{RuntimeUI.prepareBuild(b.dataset.catalogRelease);location.hash='runtime';});message.textContent='Releases geprüft. Build vorbereiten übernimmt die Version; der Build startet erst auf deinen Klick.';}catch(error){if(root.isConnected)message.textContent=error.message;}finally{button.disabled=false;}};
refresh();
}
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF und das unterstützte FLUX.2-Klein-9B-Modell. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.</p><p>Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Bei großen FLUX-Gewichten wird zusätzlich System-RAM genutzt. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile};
+10
View File
@@ -38,3 +38,13 @@ nav a.nav-sub{padding-left:28px;font-size:12px;color:var(--deck-tone-a4bda8, #a4
.deck-preview{background:#101416}.deck-preview i{background:#181f21;border:1px solid #303a37}.deck-preview i:nth-child(2){border-top:5px solid #c5edaf}
.classic-preview{background:radial-gradient(circle at 15% -10%,#183049 0,transparent 70%),#070b11}.classic-preview i{background:linear-gradient(145deg,#111b28,#0a1018);border:1px solid #213044}.classic-preview i:nth-child(2){border-top:5px solid #45d7ff}
@media(max-width:700px){.theme-options{grid-template-columns:1fr}}
/* Compact, expandable detail lists share one interaction across model categories. */
.detail-list{display:grid;gap:8px;margin-top:20px}.detail-row{border:1px solid var(--deck-tone-303a37,#303a37);border-radius:9px;background:var(--deck-tone-181f21,#181f21);overflow:hidden}.detail-summary{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1.3fr) minmax(110px,.7fr) minmax(140px,1fr);gap:18px;align-items:center;padding:18px 18px 18px 38px;position:relative;list-style:none;line-height:1.5}.detail-summary::-webkit-details-marker{display:none}.detail-summary:before{content:'▸';position:absolute;left:16px;top:19px;color:var(--deck-tone-c5edaf,#c5edaf)}.detail-row[open]>.detail-summary:before{content:'▾'}.detail-summary:hover{background:var(--deck-tone-20292a,#20292a)}.detail-summary:focus-visible{outline:2px solid var(--deck-tone-c5edaf,#c5edaf);outline-offset:-3px}.detail-summary strong{font-size:14px;overflow-wrap:anywhere}.detail-summary span{font-size:12px;color:var(--deck-tone-97a79e,#97a79e);overflow-wrap:anywhere}.detail-summary small{display:block;margin-top:4px}.detail-body{padding:8px 22px 22px;border-top:1px solid var(--deck-tone-303a37,#303a37)}.detail-body>.card-actions{margin-top:16px}.detail-body .execution-setup{margin-top:12px;padding-top:12px}.detail-row .endpoint-choice{margin:0 8px 8px 0;font-size:12px}#studio[data-page=runtimes] .detail-summary{grid-template-columns:1fr 1fr 1fr}.download-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 20px;padding:18px;margin-top:8px;border-radius:9px}.download-row .card-top,.download-row progress,.download-row .warning{grid-column:1/-1}.download-row h3,.download-row p{margin:0}.download-row h3{font-size:14px;overflow-wrap:anywhere}.download-row .hub-filename{grid-column:1/-1}.download-row button{margin:0;font-size:12px}.download-row .card-top{display:flex;justify-content:space-between}.download-row .card-top strong{font-size:14px}.nav-heading{margin:16px 12px 4px}.eyebrow{margin-top:24px}aside{padding-top:24px}nav a{padding:9px 12px}nav .nav-heading:first-child{margin-top:0}.studio-tabs{flex-wrap:wrap}
@media(max-width:1000px){.detail-summary{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px 18px}}@media(max-width:600px){.detail-summary,#studio[data-page=runtimes] .detail-summary{grid-template-columns:1fr;gap:5px}.detail-body{padding:8px 16px 16px}.download-row{grid-template-columns:1fr}.download-row>*{grid-column:1!important}nav{grid-template-columns:1fr 1fr}nav .nav-heading{grid-column:1/-1}.card-actions{gap:6px}}
/* Control is a focused mode chooser; detailed API configuration lives in settings. */
.control-modes{display:flex;gap:12px;margin:24px 0 20px}.control-modes button{min-width:110px;margin:0;font-size:15px}.control-card{max-width:900px}.control-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.control-heading h2{margin:0;font-size:25px}.control-card>.card-actions{margin-top:18px}.control-api{border-top:1px solid var(--deck-tone-303a37,#303a37);margin-top:24px;padding-top:8px}.control-api h3{font-size:14px}.control-api>div{display:grid;grid-template-columns:130px minmax(0,1fr);gap:14px;margin:12px 0;font-size:13px}.control-api span{color:var(--deck-tone-97a79e,#97a79e)}.control-api code{overflow-wrap:anywhere}.control-details{border-top:1px solid var(--deck-tone-303a37,#303a37);padding-top:16px;margin-top:20px}.control-card .small{font-size:12px}@media(max-width:600px){.control-modes{gap:8px}.control-modes button{min-width:0;flex:1;padding:12px}.control-api>div{grid-template-columns:1fr;gap:4px}}
.library-group + .library-group { margin-top: 1.5rem; }
.library-group > h3 { margin: .5rem 0 .75rem; }
+44
View File
@@ -0,0 +1,44 @@
import json
from pathlib import Path
import tempfile
import time
import unittest
from unittest.mock import patch
from audio_cpp_runtime import AudioCppRuntime,REVISION
class AudioCppTests(unittest.TestCase):
def test_marker_cannot_escape_root(self):
with tempfile.TemporaryDirectory() as root:
Path(root,'active.json').write_text(json.dumps({'id':'../../outside'}))
self.assertFalse(AudioCppRuntime(root).status()['installed'])
def test_build_failure_is_not_published(self):
with tempfile.TemporaryDirectory() as root:
runtime=AudioCppRuntime(root);runtime.job=dict(id='a'*32,state='running')
with patch.object(runtime,'_command',side_effect=RuntimeError('synthetic failure')):runtime._run()
self.assertEqual(runtime.job['state'],'failed')
self.assertFalse(runtime.status()['installed'])
self.assertFalse(Path(root,'a'*32,'ready').exists())
def test_publish_only_after_cli_smoke_and_spec(self):
with tempfile.TemporaryDirectory() as root:
runtime=AudioCppRuntime(root);runtime.job=dict(id='a'*32,state='running');commands=[]
def command(args,env):
commands.append([str(a) for a in args])
base=Path(root,'a'*32)
if str(args[0])=='cmake' and '--build' in args:
(base/'source/model_specs').mkdir(parents=True)
(base/'source/model_specs/yue2.json').write_text('{}')
(base/'build/bin').mkdir(parents=True)
(base/'build/bin/audiocpp_cli').touch()
with patch.object(runtime,'_command',side_effect=command),patch('subprocess.check_output',return_value='12.0\n8.6\n'):runtime._run()
self.assertEqual(runtime.job['state'],'complete')
self.assertTrue(runtime.status()['installed'])
self.assertTrue(runtime.status()['adapter_ready'])
self.assertIn('-DCMAKE_CUDA_ARCHITECTURES=120;86',commands[3])
self.assertEqual(commands[-1][-1],'--help')
self.assertEqual(Path(root,'a'*32,'ready').read_text(),REVISION)
def test_missing_prerequisites_dont_start_thread(self):
with tempfile.TemporaryDirectory() as root:
runtime=AudioCppRuntime(root)
with patch('audio_cpp_runtime.sys.platform','linux'),patch('audio_cpp_runtime.shutil.which',return_value=None):
with self.assertRaisesRegex(ValueError,'Build-Werkzeuge fehlen'):runtime.start()
self.assertIsNone(runtime.worker)
+69 -2
View File
@@ -37,7 +37,7 @@ class RestoreTests(unittest.TestCase):
data=b'model-weights-not-in-backup';repo='test/model';revision='a'*40;filename='model.gguf';ident=hashlib.sha256((repo+revision+filename).encode()).hexdigest();p=self.root/'models'/ident;p.mkdir(parents=True)
(p/'model.gguf').write_bytes(data);entry=dict(repo=repo,revision=revision,file=filename,size=len(data),sha256=hashlib.sha256(data).hexdigest(),kind='chat',state='downloaded');(p/'entry.json').write_text(json.dumps(entry));return ident
def profile(self,ident):
params={k:v[2] for k,v in SCHEMAS['chat'].items()};params.update(CHAT_GPU_DEFAULTS);params.update(CHAT_SAMPLING)
params={k:v[2] for k,v in SCHEMAS['chat'].items()};params.update(CHAT_GPU_DEFAULTS);params.update(CHAT_SAMPLING);params.update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2)
self.server.profiles.save(dict(id=None,revision=0,name='Test Model',kind='chat',model_id=ident,parameters=params))
def wait(self):
for _ in range(100):
@@ -51,11 +51,19 @@ class RestoreTests(unittest.TestCase):
doc=open_backup(self.server.backup.export('backup secure password'),'backup secure password')
self.assertNotIn('PRIVATE-CHAT-LOG',json.dumps(doc));self.assertNotIn('model-weights-not-in-backup',json.dumps(doc));self.assertIn('history',doc);validate(doc)
self.assertEqual(doc['models'][0]['id'],ident);self.assertEqual(len(doc['settings']['profiles.json']),1)
def test_separator_restore_installs_runtime_and_redownloads_package(self):
from separator_runtime import MODELS
doc=self.server.backup.snapshot();name=next(iter(MODELS));doc['runtimes']['separator']={'installed':True,'models':[name]}
runtime=Mock();runtime.job=None;runtime.status.return_value={'installed':False,'job':{'state':'complete'},'version':'0.47.0','models':[]};runtime.model_ready.return_value=False
self.server.separator_runtime=runtime
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.assertFalse(summary['blockers'])
self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait()
self.assertEqual(job['state'],'complete',job);runtime.start.assert_called_once();runtime.download.assert_called_once_with(name)
def test_restore_reuses_verified_models_restores_profiles_and_keeps_login_when_selected(self):
ident=self.model();self.profile(ident);raw=self.server.backup.export('backup secure password');self.server.profiles.rows=[];(self.root/'profiles.json').write_text('[]')
current=self.server.credentials.read();summary=self.server.backup.inspect(raw,'backup secure password');self.assertFalse(summary['blockers'])
self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait()
self.assertEqual(job['state'],'complete',job);self.assertEqual(self.server.profiles.rows[0]['name'],'Test Model');self.assertEqual(self.server.credentials.read(),current);self.assertTrue(any(x['state']=='reused' for x in job['items']));self.assertEqual(len(list((self.root/'recovery').glob('before-*.adbackup'))),1)
self.assertEqual(job['state'],'complete',job);self.assertEqual(self.server.profiles.rows[0]['name'],'Test Model');self.assertEqual(self.server.profiles.rows[0]['parameters']['repeat_penalty'],1.15);self.assertEqual(self.server.credentials.read(),current);self.assertTrue(any(x['state']=='reused' for x in job['items']));self.assertEqual(len(list((self.root/'recovery').glob('before-*.adbackup'))),1)
def test_empty_target_downloads_pinned_model_then_restores(self):
ident=self.model();self.profile(ident);doc=self.server.backup.snapshot();data=(self.root/'models'/ident/'model.gguf').read_bytes()
import shutil
@@ -65,6 +73,50 @@ class RestoreTests(unittest.TestCase):
with patch.object(self.server.catalog,'start',side_effect=download):
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=True));job=self.wait()
self.assertEqual(job['state'],'complete',job);self.assertEqual(self.server.profiles.rows[0]['model_id'],ident)
def test_all_separator_packages_have_pinned_sources_and_restore(self):
from separator_runtime import SOURCES
doc=self.server.backup.snapshot(include_history=False)
doc['runtimes']['separator']={'installed':True,'models':list(SOURCES),'sources':copy.deepcopy(SOURCES)}
validate(doc)
runtime=Mock();runtime.job=None;runtime.status.return_value={'installed':True,'version':'0.47.0','job':{'state':'complete'},'models':[]};runtime.model_ready.return_value=False
self.server.separator_runtime=runtime
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password')
self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait()
self.assertEqual(job['state'],'complete',job)
self.assertEqual([c.args[0] for c in runtime.download.call_args_list],list(SOURCES))
bad=copy.deepcopy(doc);name=next(iter(SOURCES));bad['runtimes']['separator']['sources'][name]['recipe_version']=999
with self.assertRaises(ValueError):validate(bad)
def test_local_source_restore_preserves_profile_reference(self):
ident=self.model();self.profile(ident);doc=self.server.backup.snapshot(include_history=False)
model=doc['models'][0];data=(self.root/'models'/ident/'model.gguf').read_bytes()
model.update(repo='local/athena-router',revision=model['sha256'],source='local',download_source={'repo':'test/original','revision':'b'*40,'file':'original.gguf'})
import shutil
shutil.rmtree(self.root/'models'/ident)
source_id=hashlib.sha256(('test/original'+'b'*40+'original.gguf').encode()).hexdigest()
def download(repo,filename,revision,kind):
self.assertEqual((repo,filename,revision),('test/original','original.gguf','b'*40))
p=self.root/'models'/source_id;p.mkdir();(p/'model.gguf').write_bytes(data)
entry=dict(repo=repo,revision=revision,file=filename,size=len(data),sha256=model['sha256'],kind=kind,state='downloaded');(p/'entry.json').write_text(json.dumps(entry));self.server.catalog.job={'state':'complete'}
with patch.object(self.server.catalog,'start',side_effect=download):
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.assertFalse(summary['blockers'])
self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait()
self.assertEqual(job['state'],'complete',job)
self.assertEqual(self.server.profiles.rows[0]['model_id'],ident)
self.assertEqual(json.loads((self.root/'models'/ident/'entry.json').read_text())['download_source'],model['download_source'])
def test_buildable_local_container_does_not_warn_about_missing_source(self):
doc=self.server.backup.snapshot(include_history=False);c=ContainerTests().config()
c.update(name='athena-ladypoly',image='athena-ladypoly:3960afc-deck1',registry=None,network='host',build_recipe='ladypoly')
doc['services']=[c]
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password')
self.assertTrue(summary['services'][0]['restorable'])
self.assertFalse(any('Buildquelle fehlt' in w for w in summary['warnings']))
def test_separator_profile_settings_roundtrip(self):
from separator_runtime import MODELS
name=next(iter(MODELS));settings={name:dict(enabled=False,device='GPU-'+'a'*32)}
(self.root/'separator-profiles.json').write_text(json.dumps(settings))
doc=self.server.backup.snapshot(include_history=False);validate(doc)
(self.root/'separator-profiles.json').unlink();self.server.backup._apply_settings(doc)
self.assertEqual(self.server.separator_tests.profiles()[name],settings[name])
def test_rejects_unsafe_paths_and_bad_references_before_changes(self):
ident=self.model();self.profile(ident);doc=self.server.backup.snapshot(include_history=False)
for change in (lambda d:d['models'][0].update(file='../secret'),lambda d:d['settings']['profiles.json'][0].update(model_id='f'*64),lambda d:d.update(version=99)):
@@ -87,6 +139,21 @@ class ContainerTests(unittest.TestCase):
for change in (lambda x:x.update(name='other-ui'),lambda x:x['mounts'][0].update(deck_path='models'),lambda x:x['mounts'][0].update(deck_path='video/original-work'),lambda x:x['mounts'][0].update(target='/data/outputs')):
bad=copy.deepcopy(c);change(bad)
with self.assertRaises(ValueError):validate_container(bad)
def test_ladypoly_recipe_and_config_are_scoped(self):
c=self.config();c.update(name='athena-ladypoly',image='athena-ladypoly:3960afc-deck1',registry=None,network='host',build_recipe='ladypoly',mounts=[dict(index=0,target='/data',deck_path=None,read_only=False,was_file=False,files={'deck-connection.json':'e30='})]);validate_container(c)
for change in (lambda x:x.update(name='other-ui'),lambda x:x.update(image='untrusted:1'),lambda x:x['mounts'][0].update(target='/other')):
bad=copy.deepcopy(c);change(bad)
with self.assertRaises(ValueError):validate_container(bad)
def test_separator_restore_keeps_readonly_root_and_writable_tmpfs(self):
c=self.config();c.update(name='athena-separator-web',image='athena-separator-web:0.1.0',registry=None,network='host',ports={},build_recipe='separator-web',running=False)
with tempfile.TemporaryDirectory() as d:
root=Path(d);policy=root/'backup-policy.json';policy.write_text(json.dumps({'deck_state':d}))
calls=[]
with patch('deploy.docker_backup.subprocess.run',return_value=Mock(returncode=1)),patch('deploy.docker_backup.command',side_effect=lambda args,*extra,**kw:calls.append(args) or ''),patch('deploy.docker_backup.Path.stat',return_value=Mock(st_uid=0,st_mode=0o100600)):
result=restore_container(Mock(state=root),c)
self.assertEqual(result['state'],'complete')
create=next(args for args in calls if args[0]=='create')
self.assertIn('--read-only',create);self.assertIn('/tmp:rw,noexec,nosuid,size=1g,uid=65534,gid=65534',create)
def test_never_replaces_existing_foreign_container(self):
with tempfile.TemporaryDirectory() as d:
manager=Mock(state=Path(d));(Path(d)/'backup-policy.json').write_text(json.dumps({'deck_state':d}))
+5
View File
@@ -33,6 +33,11 @@ class ChatTestsTests(unittest.TestCase):
def test_answer_stream_and_no_prompt_in_status(self):
self.manager.start(self.request());job=self.finish();self.assertEqual(job['state'],'complete');self.assertEqual(job['answer'],'OK');self.assertNotIn('synthetic test',json.dumps(job));self.assertNotIn('messages',job)
self.assertTrue(self.worker.loaded);self.manager.unload();self.assertFalse(self.worker.loaded)
def test_profile_penalties_reach_test_worker(self):
self.profile['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2)
self.manager.start(self.request());self.assertEqual(self.finish()['state'],'complete')
body=json.loads(self.worker.conn.request.call_args.args[2])
self.assertEqual([body[k] for k in ('repeat_penalty','presence_penalty','frequency_penalty')],[1.15,.5,.2])
def test_memory_rejection_reaches_user_and_releases_lease(self):
self.worker.error='GPU-Split passt nicht in den Speicher.';self.manager.start(self.request());job=self.finish();self.assertEqual(job['state'],'failed');self.assertIn('Speicher',job['error']);self.assertEqual(self.manager.scheduler.active,0)
def test_queue_cancel_does_not_stop_other_request(self):
+17
View File
@@ -21,6 +21,23 @@ class DockerTests(unittest.TestCase):
manager=Manager(d)
for data in ({'action':'exec','command':'id'},{'action':'stop'},[],{'action':'install','args':['--privileged']}):
with self.assertRaises(ValueError):manager.dispatch(data)
def test_container_actions_require_current_labelled_full_id(self):
with tempfile.TemporaryDirectory() as d:
manager=Manager(d);ident='a'*64
with patch.object(manager,'inventory',return_value=[]),patch('deploy.docker_helper.run') as run:
with self.assertRaises(ValueError):manager.container_action(ident,'stop')
run.assert_not_called()
with patch.object(manager,'inventory',return_value=[dict(id=ident,state='running')]),patch.object(manager,'video_configs',return_value=[]),patch('deploy.docker_helper.run') as run:
with self.assertRaises(ValueError):manager.container_action(ident,'delete')
run.assert_not_called()
with patch.object(manager,'inventory',return_value=[dict(id=ident,state='exited')]),patch.object(manager,'video_configs',return_value=[]),patch('deploy.docker_helper.run',return_value=Mock(returncode=0)) as run:
for action,command in [('start',['start',ident]),('stop',['stop','--time','30',ident]),('delete',['rm',ident])]:
self.assertTrue(manager.dispatch({'action':'container-'+action,'service':ident})['changed'])
self.assertEqual(run.call_args.args[0],['/usr/bin/docker']+command)
with patch.object(manager,'inventory',return_value=[dict(id=ident,state='exited')]),patch.object(manager,'video_configs',return_value=[dict(container_id=ident)]),patch('deploy.docker_helper.run') as run:
with self.assertRaises(ValueError):manager.container_action(ident,'start')
run.assert_not_called()
def test_existing_docker_is_never_reinstalled(self):
with tempfile.TemporaryDirectory() as d,patch('deploy.docker_helper.release',return_value={'ID':'debian','VERSION_ID':'13'}),patch('deploy.docker_helper.Path.exists',return_value=True),patch('deploy.docker_helper.threading.Thread') as thread:
manager=Manager.__new__(Manager);manager.allow_install=True;manager.lock=__import__('threading').RLock();manager.job=None
+46
View File
@@ -61,6 +61,41 @@ class EndpointTests(unittest.TestCase):
def request(self,path='/v1/models',data=None,token='A'*32):
conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);headers={'Content-Type':'application/json','Authorization':'Bearer '+token}
conn.request('POST' if data is not None else 'GET',path,body=json.dumps(data) if data is not None else None,headers=headers);r=conn.getresponse();body=r.read();conn.close();return r.status,(body if data and data.get('stream') else json.loads(body))
def test_status_allows_video_callback_to_read_endpoint_from_other_thread(self):
finished=threading.Event();readers=[]
def video_status():
def read_profiles():
self.ep.rows();finished.set()
reader=threading.Thread(target=read_profiles,daemon=True);readers.append(reader);reader.start()
self.assertTrue(finished.wait(1),'Endpoint lock blocks concurrent video/profile status queries')
return {'service':{}}
self.ep.video=Mock();self.ep.video.status.side_effect=video_status
try:self.ep.status()
finally:
self.ep.video=None
for reader in readers:reader.join(2)
def test_music_discovery_and_authenticated_wav_route(self):
self.rows.append(dict(id='music',name='Music',kind='music',runnable=True,blockers=[],parameters={},updated_at=1))
self.ep.music=Mock();self.ep.music.start.return_value=dict(id='a'*32);self.ep.music.status.return_value=dict(job=dict(id='a'*32,state='complete'));self.ep.music.audio.return_value=b'RIFFsynthetic-WAV'
self.enable('music')
self.assertEqual(self.request('/v1/audio/music/models')[1]['data'][0]['id'],'Music')
self.assertNotIn('Music',[x['id'] for x in self.request()[1]['data']])
self.assertEqual(self.request('/v1/audio/music',dict(model='Music',lyrics='Synthetic',style='pop'))[0],503)
self.ep.scheduler.gpu_mode='music'
self.ep.video=Mock();self.ep.video.status.return_value={'service':{'ready':False}}
self.assertEqual(self.request('/v1/audio/music/models')[1]['data'][0]['id'],'Music')
self.assertEqual(self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='Synthetic')]))[0],503)
conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5)
conn.request('POST','/v1/audio/music',json.dumps(dict(model='Music',lyrics='Synthetic',style='pop')),{'Content-Type':'application/json','Authorization':'Bearer '+'A'*32})
response=conn.getresponse();self.assertEqual(response.status,200);self.assertEqual(response.getheader('Content-Type'),'audio/wav');self.assertEqual(response.read(),b'RIFFsynthetic-WAV');conn.close()
self.assertEqual(self.request('/v1/audio/music',dict(model='Music',lyrics='Synthetic',style='pop',unsupported=True))[0],400)
self.assertEqual(self.request('/v1/audio/music/models',token='bad')[0],401)
def test_separator_mode_rejects_chat_without_starting_worker_or_relay(self):
self.ep.scheduler.gpu_mode='separator';self.ep.video=Mock()
status,error=self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='Synthetic')]))
self.assertEqual(status,503);self.assertEqual(error['error']['code'],'separator_mode_active')
self.assertEqual(self.worker.started,[]);self.ep.video.relay.assert_not_called()
self.assertEqual(self.request('/v1/models')[0],503)
def enable(self,name):self.ep.enable(dict(id=name,enabled=True))
def test_explicit_publication_auth_rotation_and_missing_audio(self):
self.assertEqual(self.request()[1]['data'],[]);self.assertEqual(self.request(token='bad')[0],401)
@@ -174,6 +209,17 @@ class EndpointTests(unittest.TestCase):
self.assertEqual(status,200);self.assertEqual(data['model'],name)
self.assertEqual(self.worker.stopped,['alpha']);self.assertEqual(self.worker.requests[-1]['temperature'],.2)
self.assertEqual(self.ep.status()['counts']['llm']['enabled'],2)
def test_profile_penalties_and_explicit_client_override(self):
self.enable('alpha')
self.rows[0]['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2)
request=dict(model='alpha',messages=[dict(role='user',content='synthetic')])
self.assertEqual(self.request('/v1/chat/completions',request)[0],200)
sent=self.worker.requests[-1]
self.assertEqual([sent[k] for k in ('repeat_penalty','presence_penalty','frequency_penalty')],[1.15,.5,.2])
self.assertEqual(self.request('/v1/chat/completions',dict(request,presence_penalty=0,repeat_penalty=1))[0],200)
self.assertEqual(self.worker.requests[-1]['presence_penalty'],0)
self.assertEqual(self.worker.requests[-1]['repeat_penalty'],1)
self.assertEqual(self.request('/v1/chat/completions',dict(request,repeat_penalty=-1))[0],400)
def test_unknown_or_unsupported_request_does_not_load(self):
self.enable('alpha')
for req in [dict(model='unknown',messages=[{}]),dict(model='alpha',messages=[dict(content=[dict(type='image_url')])]),dict(model='alpha',messages=[{}],cache_file='/tmp/foo')]:
+18
View File
@@ -33,3 +33,21 @@ class ExecutionSetupTests(unittest.TestCase):
self.assertEqual(result['family'],'NewImagePipeline')
self.assertIn('Workflow',result['message'])
self.assertEqual(assess(dict(model,kind='audio'),{'image':True})['state'],'unsupported')
def test_every_category_has_explicit_unverified_candidate(self):
for kind in ('chat','image','audio','stt','music','voice','video'):
result=self.check(kind,'unknown/family','weights.safetensors')
self.assertTrue(result['candidates'])
self.assertIn('NICHT bestätigt',result['candidates'][0]['evidence'])
self.assertNotEqual(result['state'],'ready')
def test_yue2_gguf_recognition_does_not_claim_worker(self):
result=self.check('music','audio-cpp/Yue2-3B-GGUF','yue2-3b-q8_0.gguf',{'audio_cpp':True})
self.assertEqual(result['family'],'YuE2')
self.assertEqual(result['state'],'adapter_missing')
self.assertTrue(result['candidates'][0]['installed'])
self.assertEqual(result['candidates'][0]['route'],'#audio-cpp-runtime')
self.assertTrue(result['components'])
def test_component_never_offers_runtime_or_profile(self):
result=self.check('music','audio-cpp/Yue2-3B-GGUF','yue2-vae-f16.gguf')
self.assertEqual(result['state'],'component')
self.assertEqual(result['candidates'],[])
+31 -3
View File
@@ -19,11 +19,39 @@ class ImageTestTests(unittest.TestCase):
p=dict(width=512,height=512,steps=4,guidance=1)
graph=workflow('synthetic edit',p,42,references=['reference-1.png','reference-2.jpg'])
self.assertEqual(graph['4']['inputs']['vae'],['3',0])
self.assertEqual(graph['4']['inputs']['image_1'],['21',0])
self.assertEqual(graph['4']['inputs']['image_2'],['22',0])
self.assertEqual(graph['4']['inputs']['images.image_1'],['21',0])
self.assertEqual(graph['4']['inputs']['images.image_2'],['22',0])
self.assertEqual(graph['6']['inputs']['latent_image'],['4',2])
self.assertEqual(graph['21']['inputs']['image'],'reference-1.png')
with self.assertRaises(ValueError):workflow('test',p,1,'flux',['reference-1.png'])
self.assertNotIn('image_1',graph['4']['inputs'])
with self.assertRaises(ValueError):workflow('test',p,1,'unknown',['reference-1.png'])
def test_flux_references_feed_both_conditioning_branches(self):
p=dict(width=512,height=512,steps=4,guidance=1)
for count in (1,4):
graph=workflow('synthetic edit',p,42,'flux',[f'reference-{i}.png' for i in range(count)])
self.assertEqual(sum(n['class_type']=='ReferenceLatent' for n in graph.values()),count*2)
self.assertEqual(graph['10']['inputs']['positive'],[str(23+(count-1)*5),0])
self.assertEqual(graph['10']['inputs']['negative'],[str(24+(count-1)*5),0])
self.assertEqual(graph['22']['inputs']['vae'],['3',0])
self.assertEqual(graph['6']['inputs']['latent_image'],['5',0])
with self.assertRaises(ValueError):workflow('test',p,1,'flux',['r.png']*5)
def test_reference_capabilities_follow_supported_recipes(self):
from profiles import image_capabilities,FLUX_REPO,QWEN_REPO
self.assertEqual(image_capabilities(dict(repo=FLUX_REPO,file='Flux.2 Klein-9B_fp16_nsfw.safetensors'))['reference_images'],4)
self.assertEqual(image_capabilities(dict(repo=QWEN_REPO,file='model.gguf'))['reference_images'],4)
self.assertEqual(image_capabilities(dict(repo='unknown',file='model.safetensors'))['reference_images'],0)
def test_family_registry_reuses_workflow_from_lineage_not_filename(self):
from profiles import image_adapter,image_recipe,FLUX_COMPONENTS
m=dict(repo='someone/new-weights',file='custom.safetensors',runtime_metadata={'base_models':['black-forest-labs/FLUX.2-klein-9B']})
self.assertEqual(image_adapter(m)['workflow'],'flux')
self.assertEqual(image_recipe(m),FLUX_COMPONENTS)
m['runtime_metadata']={};m['file']='flux-qwen-looking-name.safetensors'
self.assertIsNone(image_adapter(m))
m['runtime_metadata']={'architecture':'Flux2Transformer2DModel'}
self.assertIsNone(image_adapter(m))
m['runtime_metadata']={'architecture':'qwen_image_2_1'};m['file']='new.gguf'
self.assertEqual(image_adapter(m)['workflow'],'qwen')
m['file']='vae/new.gguf';self.assertIsNone(image_adapter(m))
def test_reference_validation_enforces_profile_limit_and_signature(self):
import base64
png='data:image/png;base64,'+base64.b64encode(b'\x89PNG\r\n\x1a\nsynthetic').decode()
+2 -2
View File
@@ -11,7 +11,7 @@ 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)
self.entry=dict(runtime_metadata={'architecture':'qwen_image_2_1'},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))
@@ -39,7 +39,7 @@ class ManagementTests(unittest.TestCase):
req.update(id=saved['id'],revision=1)
self.profiles.save(req)
restarted=Profiles(self.root/'profiles.json',self.catalog)
self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',gpu_reserve_mode='auto',gpu_reserve_mib={},vision_projector=None,vision_device='cpu',cache_type_k='q4_0',cache_type_v='q4_0'))
self.assertEqual(restarted.status()['profiles'][0]['parameters'],dict(params,repeat_penalty=1.0,presence_penalty=0.0,frequency_penalty=0.0,mtp=False,mtp_tokens=2,mtp_min_p=.05,gpu_offload='auto',gpu_reserve_mode='auto',gpu_reserve_mib={},vision_projector=None,vision_device='cpu',cache_type_k='q4_0',cache_type_v='q4_0'))
for change in [dict(gpu_offload='unknown'),dict(mtp='yes'),dict(mtp_tokens=0),dict(mtp_tokens=True),dict(mtp_min_p=1.1),dict(gpu_devices=devices*2),dict(tensor_split=[85]),dict(split_mode='none'),dict(top_p=1.1),dict(temperature=float('nan')),dict(top_k=True),dict(tensor_split=[0,100]),dict(gpu_devices=['CUDA0','CUDA1'])]:
with self.subTest(change=change),self.assertRaises(ValueError):self.profiles.save(dict(req,revision=2,parameters=dict(params,**change)))
+53
View File
@@ -0,0 +1,53 @@
import tempfile,time,unittest
from pathlib import Path
from unittest.mock import Mock,patch
from music import Music
from profiles import YUE2_COMPONENTS,YUE2_REPO
class MusicTests(unittest.TestCase):
def make(self,root):
root=Path(root);catalog=Mock();catalog.root=root/'models';catalog.root.mkdir()
main=dict(id='main',repo=YUE2_REPO,file='yue2-3b-bf16.gguf',kind='music',size=1)
entries={role:dict(id=role,file=info['files'][0]) for role,info in YUE2_COMPONENTS.items()}
for entry in [main]+list(entries.values()):
(catalog.root/entry['id']).mkdir();(catalog.root/entry['id']/('model'+Path(entry['file']).suffix)).touch()
catalog.entry.side_effect=lambda id:entries[id]
p=dict(id='p',kind='music',runnable=True,model=main,components={role:role for role in entries})
profiles=Mock();profiles.status.return_value=dict(profiles=[p]);profiles._component.side_effect=lambda model,role,id:entries[id]
cli=root/'cli';cli.write_text('#!/usr/bin/env python3\nimport sys,wave\np=sys.argv[sys.argv.index("--out")+1]\nwith wave.open(p,"wb") as w:w.setparams((1,2,8000,0,"NONE","none"));w.writeframes(bytes(1600))\n');cli.chmod(0o755)
runtime=Mock();runtime.status.return_value=dict(installed=True);runtime.paths.return_value=(cli,root)
scheduler=Mock();scheduler.gpu_mode='music';release=Mock();scheduler.image_reservation.return_value=release
return Music(root/'jobs',profiles,catalog,runtime,scheduler),release,p
def wait(self,m):
for _ in range(80):
if m.status()['job']['state']!='running':return
time.sleep(.05)
self.fail('Test timeout')
def test_success_and_output_without_retaining_prompts(self):
with tempfile.TemporaryDirectory() as root:
m,release,p=self.make(root)
with patch('music.probe',return_value=[dict(name='NVIDIA RTX 5080',uuid='GPU-test',free_mib=16000,processes=0)]):
job=m.start('p','Synthetic lyrics','Synthetic pop',max_tokens=32,steps=1);self.wait(m)
self.assertEqual(m.status()['job']['state'],'complete');self.assertTrue(m.audio(job['id']).startswith(b'RIFF'));release.assert_called_once()
self.assertFalse((m.root/job['id']/'worker.tmp').exists());self.assertNotIn('lyrics',m.status()['job'])
def test_occupied_foreign_gpu_releases_lease(self):
with tempfile.TemporaryDirectory() as root:
m,release,p=self.make(root)
with patch('music.probe',return_value=[dict(name='NVIDIA RTX 5080',uuid='GPU-test',free_mib=16000,processes=1)]):m.start('p','Synthetic','pop');self.wait(m)
self.assertEqual(m.status()['job']['state'],'failed');self.assertIn('Fremde',m.status()['job']['phase']);release.assert_called_once()
def test_invalid_request_and_missing_components(self):
with tempfile.TemporaryDirectory() as root:
m,release,p=self.make(root)
with self.assertRaises(ValueError):m.start('p','Synthetic','pop',max_tokens=True)
m.profiles._component.side_effect=ValueError('missing');self.assertEqual(len(m.blockers(p)),5)
with self.assertRaises(ValueError):m.audio('../outside')
def test_cancel_terminates_owned_process_and_releases_gpu(self):
with tempfile.TemporaryDirectory() as root:
m,release,p=self.make(root);m.runtime.paths()[0].write_text('#!/usr/bin/env python3\nimport time;time.sleep(30)\n')
with patch('music.probe',return_value=[dict(name='NVIDIA RTX 5080',uuid='GPU-test',free_mib=16000,processes=0)]):
m.start('p','Synthetic','pop')
for _ in range(50):
if m.process:break
time.sleep(.02)
m.stop();self.wait(m)
self.assertEqual(m.status()['job']['state'],'cancelled');release.assert_called_once();self.assertFalse(m.status()['loaded'])
+47
View File
@@ -0,0 +1,47 @@
import tempfile,unittest
from pathlib import Path
from unittest.mock import Mock
from profiles import Profiles,YUE2_REPO,YUE2_COMPONENTS,music_recipe
from catalog import file_role
class MusicComponentsTests(unittest.TestCase):
def test_download_recipe_assignment_and_remaining_worker_block(self):
model=dict(id='main',kind='music',repo=YUE2_REPO,file='yue2-3b-bf16.gguf',revision='a'*40)
entries=[dict(id=role,kind='music',repo=YUE2_REPO,file=info['files'][0],revision='a'*40,size=100) for role,info in YUE2_COMPONENTS.items()]
lookup={e['id']:e for e in [model]+entries}
c=Mock();c.entry.side_effect=lambda id:lookup[id] if id in lookup else (_ for _ in ()).throw(ValueError('missing'))
c.status.return_value=dict(entries=entries)
c.files.return_value=dict(repo=YUE2_REPO,revision='a'*40,files=[dict(name=e['file'],size=100) for e in entries],gated=False,license='apache-2.0')
with tempfile.TemporaryDirectory() as d:
p=Profiles(Path(d)/'profiles.json',c);p.music_runtime_ready=lambda:True
p.rows=[dict(id='profile',kind='music',revision=1,name='YuE2',model_id='main',parameters={})]
r=p.components('main');self.assertEqual(len(r['requirements']),5);self.assertTrue(r['runtime_installed']);c.files.assert_called_once_with(YUE2_REPO,'a'*40)
self.assertTrue(all(len(x['available'])==1 for x in r['requirements']))
p.assign(dict(id='profile',revision=1,components={e['id']:e['id'] for e in entries}))
result=p.status()['profiles'][0];self.assertFalse(result['runnable']);self.assertEqual(len(result['blockers']),1)
entries[0]['revision']='b'*40
with self.assertRaises(ValueError):p._component(model,'vae','vae')
def test_components_cannot_be_main_models(self):
for info in YUE2_COMPONENTS.values():
for filename in info['files']:self.assertFalse(file_role(filename)['profile_eligible'])
self.assertIsNone(music_recipe(dict(kind='music',repo='other/Yue2',file='yue2-3b-bf16.gguf')))
def test_manual_setup_available_in_every_category_and_preserves_recipe(self):
for kind in ('chat','image','audio','stt','music','voice','video'):
model=dict(id='main',kind=kind,repo='unknown/model',file='weights.gguf',revision='a'*40)
component=dict(id='aux',kind=kind,repo='external/repo',file='vae.safetensors',revision='b'*40,size=1)
c=Mock();c.entry.side_effect=lambda id: {'main':model,'aux':component}[id]
c.status.return_value=dict(entries=[component]);c.files.return_value=dict(repo='unknown/model',revision='a'*40,files=[],gated=False,url='https://huggingface.co/unknown/model',license=None)
with tempfile.TemporaryDirectory() as d:
p=Profiles(Path(d)/'profiles.json',c);p.rows=[dict(id='p',kind=kind,revision=1,name='Test',model_id='main',parameters={})]
self.assertTrue(p.components('main')['manual'])
p.assign(dict(id='p',revision=1,components={'manual_vae':'aux'}))
self.assertEqual(p.rows[0]['components']['manual_vae'],'aux')
self.assertTrue(p.references('aux'))
with self.assertRaises(ValueError):p.assign(dict(id='p',revision=2,components={'arbitrary':'aux'}))
def test_audio_runtime_package_definition_matches_exact_repo_and_model(self):
import json
from profiles import audio_package_recipe
with tempfile.TemporaryDirectory() as d:
Path(d,'model_specs').mkdir();Path(d,'model_specs/test.json').write_text(json.dumps(dict(packages=[dict(target_directory='Example-GGUF',files=['model.gguf','config.json'])])))
model=dict(repo='audio-cpp/Example-GGUF',file='model.gguf')
self.assertEqual(audio_package_recipe(model,d)['runtime_file_0']['files'],['config.json'])
self.assertIsNone(audio_package_recipe(dict(model,repo='other/Example-GGUF'),d))
+46
View File
@@ -0,0 +1,46 @@
import copy
import json
from pathlib import Path
import tempfile
import unittest
from types import SimpleNamespace
from profiles import Profiles,SCHEMAS,chat_parameters
from api_compat import normalize_chat,CompatibilityError
from deploy.configure_llm_sampling import configure
class SamplingTests(unittest.TestCase):
def test_legacy_save_roundtrip_and_validation(self):
model=dict(id='m',kind='chat',profile_eligible=True,repo='test/chat',file='model.gguf')
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'profiles.json';catalog=SimpleNamespace(entry=lambda _:model)
profiles=Profiles(path,catalog)
params={k:v[2] for k,v in SCHEMAS['chat'].items() if not k.endswith('penalty')}
payload=dict(id=None,revision=0,name='Legacy',kind='chat',model_id='m',parameters=params)
saved=profiles.save(payload)
self.assertEqual(saved['parameters']['repeat_penalty'],1.0)
payload.update(id=saved['id'],revision=saved['revision'],parameters=saved['parameters'])
payload['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=-.2)
profiles.save(payload)
restored=Profiles(path,catalog).rows[0]
self.assertEqual(restored['parameters']['repeat_penalty'],1.15)
for field,value in [('repeat_penalty',-1),('presence_penalty',3),('frequency_penalty',float('nan')),('repeat_penalty',True)]:
bad=copy.deepcopy(payload);bad['revision']=restored['revision'];bad['parameters'][field]=value
with self.assertRaises(ValueError):profiles.save(bad)
self.assertEqual(json.loads(path.read_text())[0],restored)
def test_client_penalties_validation_and_no_mutation(self):
request=dict(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=-.2)
self.assertEqual(normalize_chat(request)[0],request)
for field,value in [('repeat_penalty',-1),('presence_penalty',3),('frequency_penalty',float('inf')),('repeat_penalty',True)]:
with self.assertRaises(CompatibilityError):normalize_chat({field:value})
def test_preset_application_preserves_unrelated_settings_and_is_idempotent(self):
rows=[dict(id='q',name='Qwen3.8 27B - Medium',kind='chat',model_id='q',revision=7,parameters=dict(context=160000,slots=1,batch=2048,ubatch=256,gpu_devices=['5080','3060'],tensor_split=[85,15],vision_device='3060',temperature=.2)),dict(id='i',name='Image',kind='image',model_id='i',parameters={}),dict(id='u',name='Unknown',kind='chat',model_id='u',parameters={})]
entries={'q':dict(repo='jpetrina/Qwen3.8-27B-IQ4_XS-pure-GGUF')}
result,report=configure(rows,entries)
self.assertEqual(result[0]['parameters']['temperature'],1)
self.assertEqual(result[0]['revision'],8)
for key in ('context','slots','batch','ubatch','gpu_devices','tensor_split','vision_device'):
self.assertEqual(result[0]['parameters'][key],rows[0]['parameters'][key])
self.assertEqual(result[1:],rows[1:]);self.assertEqual(rows[0]['parameters']['temperature'],.2)
self.assertEqual(configure(result,entries)[0],result)
expanded,_=configure(result,entries,True)
self.assertEqual(expanded[-2]['parameters']['tensor_split'],[86,14]);self.assertEqual(expanded[-1]['parameters']['vision_device'],'cpu')
+77
View File
@@ -0,0 +1,77 @@
import io,json,tempfile,unittest,wave
from pathlib import Path
from unittest.mock import Mock,patch
from separator_runtime import SeparatorRuntime,MODELS
from separator import SeparatorTests
def audio(channels=2,duration=.1):
data=io.BytesIO()
with wave.open(data,'wb') as wav:
wav.setnchannels(channels);wav.setsampwidth(2);wav.setframerate(44100);wav.writeframes(b'\x00'*(int(44100*duration)*channels*2))
return data.getvalue()
class Tests(unittest.TestCase):
def test_marker_cannot_escape_and_failed_install_not_published(self):
with tempfile.TemporaryDirectory() as root:
Path(root,'active.json').write_text(json.dumps({'id':'../../outside'}));runtime=SeparatorRuntime(root)
self.assertFalse(runtime.status()['installed']);runtime.job={'id':'a'*32,'state':'running'}
with patch.object(runtime,'_command',side_effect=RuntimeError('synthetic failure')):runtime._run()
self.assertEqual(runtime.job['state'],'failed');self.assertFalse(runtime.status()['installed'])
def test_model_requires_configuration_and_unknown_download_rejected(self):
with tempfile.TemporaryDirectory() as root:
runtime=SeparatorRuntime(root);models=Path(root,'models');models.mkdir();name=next(iter(MODELS));(models/name).write_bytes(b'weights')
self.assertFalse(runtime.model_ready(name));(models/MODELS[name][1]).write_text('model: {}');(models/(name+'.ready')).write_text('{}');self.assertFalse(runtime.model_ready(name))
from separator_runtime import SOURCES
(models/(name+'.ready')).write_text(json.dumps({f['file']:f['sha256'] for f in SOURCES[name]['files']}));self.assertTrue(runtime.model_ready(name))
with self.assertRaises(ValueError):runtime.download('../other')
with self.assertRaises(ValueError):runtime.download([])
def test_stereo_accepted_and_long_or_truncated_wav_rejected(self):
with tempfile.TemporaryDirectory() as root:
runtime=Mock();runtime.status.return_value={'installed':True};runtime.model_ready.return_value=True
worker=SeparatorTests(root,runtime,Mock());model=next(iter(MODELS))
with patch('separator.threading.Thread') as thread:
self.assertEqual(worker.start(model,audio())['state'],'running');thread.return_value.start.assert_called_once()
worker.job=None
for data in (audio(duration=31),audio()[:-2],b'not a wav'):
with self.assertRaises(ValueError):worker.start(model,data)
def test_busy_gpu_fails_without_spawning_and_releases_lease(self):
with tempfile.TemporaryDirectory() as root:
scheduler=Mock();release=Mock();scheduler.image_reservation.return_value=release
worker=SeparatorTests(root,Mock(),scheduler);worker.job={'id':'a'*32,'state':'running','started_at':0}
with patch('separator.probe',return_value=[{'name':'RTX 3060','processes':1,'free_mib':11000}]),patch('separator.subprocess.Popen') as process:worker._run(next(iter(MODELS)),audio())
self.assertEqual(worker.job['state'],'failed');process.assert_not_called();release.assert_called_once()
def test_success_publishes_only_finished_outputs_and_removes_input(self):
with tempfile.TemporaryDirectory() as root:
scheduler=Mock();release=Mock();scheduler.image_reservation.return_value=release
runtime=Mock();runtime.paths.return_value=(Path('/fake/python'),Path('/fake'));runtime.root=Path(root,'runtime')
worker=SeparatorTests(root,runtime,scheduler);worker.job={'id':'a'*32,'state':'running','started_at':0}
directory=Path(root,'a'*32)
def launch(*args,**kwargs):
(directory/'vocals.wav').write_bytes(audio());(directory/'results.json').write_text('["vocals.wav"]')
process=Mock();process.poll.return_value=0;process.returncode=0;return process
with patch('separator.probe',return_value=[{'name':'RTX 3060','uuid':'GPU-test','processes':0,'free_mib':11000}]),patch('separator.subprocess.Popen',side_effect=launch):worker._run(next(iter(MODELS)),audio())
self.assertEqual(worker.job['state'],'complete');self.assertEqual(worker.audio('a'*32,0),audio());self.assertFalse((directory/'input.wav').exists());self.assertFalse((directory/'worker.tmp').exists());release.assert_called_once()
def test_result_traversal_rejected(self):
with tempfile.TemporaryDirectory() as root:
worker=SeparatorTests(root,Mock(),Mock());directory=Path(root,'a'*32);directory.mkdir();(directory/'complete').touch();(directory/'results.json').write_text('["../../secret.wav"]')
with self.assertRaises(ValueError):worker.audio('a'*32,0)
def test_full_job_requires_separator_mode_before_thread_starts(self):
with tempfile.TemporaryDirectory() as root:
scheduler=Mock();scheduler.gpu_mode='llm'
worker=SeparatorTests(root,Mock(),scheduler)
with self.assertRaisesRegex(ValueError,'Steuerung'):worker.start(next(iter(MODELS)),audio(),full=True)
def test_profiles_prefer_5080_and_fixed_device_never_falls_back(self):
with tempfile.TemporaryDirectory() as root:
worker=SeparatorTests(Path(root)/'separator-tests',Mock(),Mock());model=next(iter(MODELS))
fast={'name':'RTX 5080','uuid':'GPU-'+'a'*32,'processes':0,'free_mib':12000}
slow={'name':'RTX 3060','uuid':'GPU-'+'b'*32,'processes':0,'free_mib':11000}
with patch('separator.probe',return_value=[slow,fast]):
self.assertEqual(worker.select_gpu(model)['uuid'],fast['uuid'])
worker.configure(dict(model=model,enabled=True,device=fast['uuid']))
with patch('separator.probe',return_value=[slow,dict(fast,processes=1)]):
with self.assertRaisesRegex(ValueError,'kein stiller Wechsel'):worker.select_gpu(model)
with patch('separator.probe',return_value=[fast]):worker.configure(dict(model=model,enabled=False,device='auto'))
restored=SeparatorTests(Path(root)/'separator-tests',Mock(),Mock())
with self.assertRaisesRegex(ValueError,'deaktiviert'):restored.start(model,audio())
if __name__=='__main__':unittest.main()
+19
View File
@@ -0,0 +1,19 @@
import hashlib,json,tempfile,unittest
from pathlib import Path
from unittest.mock import patch
from separator_runtime import SOURCES,MODELS,verify_package,package_files
class Tests(unittest.TestCase):
def test_demucs_ensemble_complete_and_sources_https(self):
self.assertEqual(set(SOURCES),set(MODELS))
self.assertEqual(len(package_files('htdemucs_ft.yaml')),5)
self.assertEqual(len(package_files('htdemucs_6s.yaml')),2)
for recipe in SOURCES.values():
for f in recipe['files']:
self.assertEqual(len(f['sha256']),64)
self.assertTrue(all(u.startswith('https://') for u in f['urls']))
def test_wrong_or_missing_weight_rejected(self):
with tempfile.TemporaryDirectory() as d:
recipe={'x':{'files':[{'file':'weight','sha256':hashlib.sha256(b'valid').hexdigest()}]}}
with patch.dict(SOURCES,recipe):
self.assertFalse(verify_package(d,'x'));Path(d,'weight').write_bytes(b'wrong');self.assertFalse(verify_package(d,'x'))
Path(d,'weight').write_bytes(b'valid');self.assertTrue(verify_package(d,'x'))
+38
View File
@@ -31,6 +31,44 @@ class Tests(unittest.TestCase):
def request(self,path,method='GET',headers=None):
req=urllib.request.Request(self.url+'/api/v1/'+path,method=method,headers={"Cookie":self.cookie,**(headers or {})})
with urllib.request.urlopen(req) as r:return json.load(r)
def test_catalog_checks_runtime_once_without_video_model_scan(self):
entries=[dict(id=str(i),repo='test/model',file='model.gguf',kind='chat',size=8) for i in range(12)]
with patch.object(self.server.catalog,'status',return_value=dict(entries=entries)), \
patch.object(self.server.runtime,'status',return_value={'active':True}) as runtime, \
patch.object(self.server.video,'status',side_effect=AssertionError('No video model scan needed')):
result=self.request('catalog')
self.assertEqual(len(result['entries']),12)
self.assertEqual(runtime.call_count,1)
self.assertTrue(all('execution' in row and 'used_by_profiles' in row for row in result['entries']))
def test_container_actions_require_admin_and_delete_confirmation(self):
ident='a'*64
with patch.object(self.server.docker,'container_action',return_value={'changed':True}) as action:
for operation in ('start','stop','delete'):
data={'id':ident,**({'confirm':True} if operation=='delete' else {})}
req=urllib.request.Request(self.url+'/api/v1/docker/'+operation,data=json.dumps(data).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
with urllib.request.urlopen(req) as response:self.assertTrue(json.load(response)['changed'])
action.assert_called_with(operation,ident)
count=action.call_count
req=urllib.request.Request(self.url+'/api/v1/docker/delete',data=json.dumps({'id':ident}).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
self.assertEqual(error.exception.code,400);error.exception.close()
req=urllib.request.Request(self.url+'/api/v1/docker/start',data=json.dumps({'id':ident}).encode(),headers={'X-Athena-Deck':'1','Content-Type':'application/json'})
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
self.assertEqual(error.exception.code,401);error.exception.close()
self.assertEqual(action.call_count,count)
def test_separator_runtime_is_admin_only_and_rejects_unknown_package(self):
status=self.request('separator-runtime')
self.assertFalse(status['installed']);self.assertEqual(len(status['models']),4)
self.assertTrue(all(m['source']['files'] for m in status['models']))
req=urllib.request.Request(self.url+'/api/v1/separator-runtime/download',data=b'{"model":"unknown"}',headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
self.assertEqual(error.exception.code,400);error.exception.close()
req=urllib.request.Request(self.url+'/api/v1/separator-runtime/install',data=b'{}',headers={'Authorization':'Bearer '+self.api_token,'X-Athena-Deck':'1','Content-Type':'application/json'})
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
self.assertEqual(error.exception.code,401);error.exception.close()
def test_demo_removed_from_status(self):
state=self.request('status')
self.assertNotIn('demo',state)
+25
View File
@@ -44,3 +44,28 @@ class VideoComfyTests(unittest.TestCase):
self.assertTrue(self.video.service_authenticated('Bearer synthetic-service-secret'))
self.assertFalse(self.video.service_authenticated('Bearer wrong'))
self.assertFalse(self.video.service_authenticated('Bearer ü'))
def test_music_switch_is_exclusive_and_returns_to_llm(self):
import threading
from types import SimpleNamespace
scheduler=SimpleNamespace(gpu_mode='llm',cv=threading.Condition(),active=0,tts_active=0,transition=False,key=('chat',))
self.video.scheduler=scheduler;self.video._stop=Mock();self.video._foreign_services=lambda:[]
self.video.music_profiles=lambda:[dict(id='music',name='YuE2')]
self.video.switch('music');self.video.thread.join(2)
self.assertEqual(scheduler.gpu_mode,'music');self.assertEqual(self.video.state,'ready');self.video.stop_owned.assert_called_once();self.video._stop.assert_called_once()
self.video.switch('llm');self.video.thread.join(2);self.assertEqual(scheduler.gpu_mode,'llm')
def test_music_requires_enabled_ready_profile(self):
with self.assertRaisesRegex(ValueError,'Musikprofil'):self.video.switch('music')
def test_separator_switch_drains_owned_workers_without_starting_video(self):
import threading
from types import SimpleNamespace
self.video.scheduler=SimpleNamespace(gpu_mode='llm',cv=threading.Condition(),active=0,tts_active=0,transition=False,key=('chat',))
self.video._stop=Mock();self.video._start=Mock();self.video._foreign_services=lambda:[]
self.video.separator_status=lambda:dict(installed=True,models=[dict(installed=True)])
self.video.switch('separator');self.video.thread.join(2)
self.assertEqual(self.video.scheduler.gpu_mode,'separator');self.video._start.assert_not_called();self.video.stop_owned.assert_called_once()
self.video.switch('llm');self.video.thread.join(2);self.assertEqual(self.video.scheduler.gpu_mode,'llm')
def test_separator_requires_ready_model(self):
self.video.separator_status=lambda:dict(installed=False,models=[])
with self.assertRaisesRegex(ValueError,'Audio-Separator'):self.video.switch('separator')
+15 -1
View File
@@ -17,8 +17,10 @@ class CapabilitiesTests(unittest.TestCase):
for value in (-1,32769,True,1.5):
with self.assertRaises(ValueError):chat_parameters({'gpu_devices':['GPU-a'],'gpu_reserve_mode':'manual','gpu_reserve_mib':{'GPU-a':value}})
with self.assertRaises(ValueError):chat_parameters({'gpu_reserve_mode':'manual'})
def test_projector_device_requires_selected_gpu(self):
def test_projector_device_validates_uuid_independently(self):
with self.assertRaises(ValueError):chat_parameters({'vision_device':'GPU-missing'})
uuid='GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b'
self.assertEqual(chat_parameters({'vision_device':uuid})['vision_device'],uuid)
class ProjectorLaunchTests(WorkerTests):
def test_cpu_and_gpu_projector_flags_and_reserve(self):
@@ -32,4 +34,16 @@ class ProjectorLaunchTests(WorkerTests):
if device=='cpu':self.assertIn('--no-mmproj-offload',args)
else:self.assertEqual(args[args.index('--mmproj-device')+1],'CUDA1');self.assertEqual(self.worker.memory_plan['gpus'][1]['required_mib'],7072)
self.worker.process=None
def test_projector_only_gpu_excluded_from_model_devices(self):
self.profile['parameters'].update(gpu_devices=['gpu-first'],split_mode='none',tensor_split=[],vision_projector='p',vision_device='gpu-second',gpu_offload='full')
self.worker.catalog.entry=lambda ident:dict(id='p',file='mmproj.gguf',size=1024**3,role='vision_projector') if ident=='p' else self.entry
process=Mock();process.poll.return_value=None;http=Mock();http.getresponse.return_value.status=200
with self.patches(),patch('inference.probe',return_value=self.gpus),patch('inference.cgroup_headroom',return_value=32*1024**3),patch.object(self.worker,'_fit_command',return_value='CUDA0 9000 1000 1000\nHost 512 0 256\n'),patch('inference.subprocess.Popen',return_value=process) as launch,patch('inference.http.client.HTTPConnection',return_value=http),patch('inference.threading.Thread'):
self.worker.ensure(self.profile)
args=launch.call_args.args[0]
self.assertEqual(args[args.index('--device')+1],'CUDA0')
self.assertEqual(args[args.index('--mmproj-device')+1],'CUDA1')
self.assertEqual(self.worker.memory_plan['gpus'][1]['required_mib'],3072)
self.assertEqual(launch.call_args.kwargs['env']['CUDA_VISIBLE_DEVICES'],'gpu-first,gpu-second')
self.worker.process=None
if __name__=='__main__':unittest.main()
+3 -3
View File
@@ -3,7 +3,7 @@ window.VideoUI=(()=>{
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 s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
function mode(root,s,profiles=[],port=null){
if(root.dataset.busy==='1')return;
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: ${s.runtime_kind==='original'?'originale LTX-API':'ComfyUI-API'}</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.service?.api_url?`<p>Backend: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>${s.service.ready&&port?`<a class="link" target="_blank" rel="noopener" href="${e(location.protocol+"//"+location.hostname+":"+(s.runtime_kind==='original'?8118:port)+"/")}">${s.runtime_kind==='original'?'LTX DeskWEB öffnen':'ComfyUI öffnen'} →</a>`:""}`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a><div class="video-message" role="status"></div></section>`;
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: ${s.runtime_kind==='original'?'originale LTX-API':'ComfyUI-API'}</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><button data-mode="music" ${!s.music_profiles?.length||s.mode==='music'||s.state==='switching'?'disabled':''}>Musik</button><h3>${e(s.mode==='music'?s.music_profiles?.map(p=>p.name).join(', '):s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='music'?'Musik bereit · Gewichte laden bei Anfrage':s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.mode!=='music'&&s.service?.api_url?`<p>Backend: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>${s.service.ready&&port?`<a class="link" target="_blank" rel="noopener" href="${e(location.protocol+"//"+location.hostname+":"+(s.runtime_kind==='original'?8118:port)+"/")}">${s.runtime_kind==='original'?'LTX DeskWEB öffnen':'ComfyUI öffnen'} →</a>`:""}`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a>${!s.music_profiles?.length?'<p>Für Musik zuerst unter <a href="#music">Musik → Profile</a> ein ausführbares Profil am API-Endpunkt freigeben.</p>':''}<div class="video-message" role="status"></div></section>`;
root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next,profiles,port);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}});
}
function services(root){
@@ -12,12 +12,12 @@ window.VideoUI=(()=>{
root.innerHTML=`<section class="card"><h2>Video-Laufzeit</h2><label>Laufzeit auswählen<select id="video-runtime-choice" ${s.mode!=='llm'?'disabled':''}><option value="comfy" ${s.runtime_kind!=='original'?'selected':''}>ComfyUI · gemeinsam mit Bildgenerierung</option><option value="original" ${s.runtime_kind==='original'?'selected':''}>LTX Original · LTX DeskWEB</option></select></label><p>${s.runtime?.installed?'Vorhanden · bereit':s.runtime_kind==='original'?'Nicht installiert · unter Einstellungen → LTX Original einrichten':'Nicht installiert · unter Einstellungen → Bildgenerierung einrichten'}</p><p>5080: Diffusionsmodell und VAE. 3060: Textencoder. Große BF16-Dateien nutzen zusätzlich CPU-Auslagerung. Gewichte laden erst bei einer Anfrage.</p><a href="#ltx-original-runtime">LTX Original installieren / Status →</a></section>${(s.models||[]).map(x=>`<section class="card"><h3>${e(x.model.file)}</h3><strong>${e(x.label)}</strong><button class="secondary" data-components-model="${e(x.model.id)}">Komponenten prüfen / nachladen</button>${x.blockers.map(b=>`<p class="warning">${e(b)}</p>`).join('')}<p>${Object.keys(x.components).length} Komponenten geprüft · gleiche Quellversion</p><button data-select="${e(x.model.id)}" ${s.mode!=='llm'||x.model.id===s.selected?'disabled':''}>${x.model.id===s.selected?'Aktives Videomodell':'Als Videomodell aktivieren'}</button>${x.model.id===s.selected&&s.mode==='llm'?'<button data-select="">Auswahl aufheben</button>':''}</section>`).join('')||'<p>Kein unterstütztes LTX-Modell in der Bibliothek. Unter Entdecken herunterladen.</p>'}<p class="video-message" role="status"></p>`;
root.querySelector('#video-runtime-choice').onchange=async event=>{root.dataset.busy='1';try{await api('video/runtime',{runtime:event.target.value});}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';poll();}};
root.querySelectorAll('[data-components-model]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';try{const data=await api('profiles/components?'+new URLSearchParams({model_id:b.dataset.componentsModel}));root.innerHTML=`<section class="card"><h2>LTX-Komponenten</h2><p>${e(data.message)}</p>${data.requirements.map((r,i)=>`<p><strong>${e(r.label)}</strong> · ${r.available.length?'Bereits heruntergeladen':r.candidates.length?'Fehlt':'In dieser Quellversion nicht gefunden'}</p>${!r.available.length?r.candidates.map((c,j)=>`<button data-download-component="${i}:${j}">${e(c.name)} herunterladen</button>`).join(''):''}`).join('')}<p class="video-message"></p><button data-close-components>Zurück</button></section>`;root.querySelector('[data-close-components]').onclick=()=>{root.dataset.busy='0';poll();};root.querySelectorAll('[data-download-component]').forEach(d=>d.onclick=async()=>{const [i,j]=d.dataset.downloadComponent.split(':');const f=data.requirements[i].candidates[j];d.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:'video'});root.querySelector('.video-message').textContent='Download eingeplant. Fortschritt unter Downloads. Nach Abschluss werden die Komponenten automatisch erkannt.';}catch(x){root.querySelector('.video-message').textContent=x.message;d.disabled=false;}});}catch(x){root.dataset.busy='0';root.querySelector('.video-message').textContent=x.message;}});
root.querySelectorAll('[data-select]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.dataset.select});root.querySelector('.video-message').textContent='Videomodell ausgewählt. Start über die Übersicht.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});
root.querySelectorAll('[data-select]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.dataset.select});root.querySelector('.video-message').textContent='Videomodell ausgewählt. Start über die Steuerung.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});
}}catch(x){root.textContent=x.message;}if(root.isConnected)setTimeout(poll,5000);}poll();
}
function runtime(){
const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;
view.innerHTML='<section id="video-service-page"><h1>Video</h1><p>LTX Original mit LTX DeskWEB oder die vorhandene ComfyUI-Laufzeit auswählen. Deck verwaltet Modelle, Komponenten und den GPU-Modus; Generierungsparameter gehören in die jeweilige Video-Oberfläche.</p><div class="studio-tabs"><button data-video-tab="runtime">Laufzeit / Aktives Modell</button><button data-video-tab="discover">Entdecken</button><button data-video-tab="library">Bibliothek</button><button data-video-tab="downloads">Downloads</button></div><div id="video-content"></div></section>';
view.innerHTML='<section id="video-service-page"><h1>Videogenerierung</h1><p>LTX Original mit LTX DeskWEB oder die vorhandene ComfyUI-Laufzeit auswählen. Deck verwaltet Modelle, Komponenten und den GPU-Modus; Generierungsparameter gehören in die jeweilige Video-Oberfläche.</p><div class="studio-tabs"><button data-video-tab="runtime">Laufzeit / Aktives Modell</button><button data-video-tab="discover">Entdecken</button><button data-video-tab="library">Bibliothek</button><button data-video-tab="downloads">Downloads</button></div><div id="video-content"></div></section>';
function show(tab){const root=view.querySelector('#video-content');if(tab==='runtime'){root.innerHTML='<div id="video-services"></div>';services(root.firstChild);}else{root.innerHTML=CatalogUI.html(tab==='library',tab==='downloads');CatalogUI.bind('video',tab==='library',tab==='downloads');}}
view.querySelectorAll('[data-video-tab]').forEach(b=>b.onclick=()=>show(b.dataset.videoTab));show('runtime');
}
+14 -5
View File
@@ -55,11 +55,13 @@ class VideoComfy:
self.state='failed';self.error='ComfyUI wurde unerwartet beendet. Auf LLM wechseln und Laufzeit erneut starten.'
rows=self.models();ident=self.selection.get('model_id');row=next((x for x in rows if x['model']['id']==ident),None)
service=dict(id=ident,name=row['model']['file'] if row else 'Kein Videomodell ausgewählt',running=running,ready=running and self.state=='ready',api_url='/ (ComfyUI auf dem gemeinsamen API-Port)',message='5080: Diffusion/VAE · 3060: Textencoder · große BF16-Gewichte nutzen CPU-Auslagerung.') if row else {}
return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status())
return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,music_profiles=getattr(self,'music_profiles',lambda:[])(),separator=getattr(self,'separator_status',lambda:{})(),services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status())
def switch(self,mode):
if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.')
if mode not in ('llm','video','music','separator'):raise ValueError('Modus muss llm, video, music oder separator sein.')
with self.lock:
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
if mode=='music' and not getattr(self,'music_profiles',lambda:[])():raise ValueError('Zuerst ein ausführbares Musikprofil am API-Endpunkt freigeben.')
if mode=='separator' and not any(m['installed'] for m in getattr(self,'separator_status',lambda:{'models':[]})().get('models',[])):raise ValueError('Zuerst Audio-Separator-Laufzeit und ein Modellpaket einrichten.')
if mode=='video':
row=next((x for x in self.models() if x['model']['id']==self.selection.get('model_id')),None)
if not row:raise ValueError('Zuerst Videomodell aktivieren.')
@@ -70,8 +72,13 @@ class VideoComfy:
return self.status()
def _switch(self,mode):
try:
if mode=='llm':
self._stop()
self.stop_owned();self._stop();deadline=time.monotonic()+60
with self.scheduler.cv:
while self.scheduler.active or self.scheduler.tts_active or self.scheduler.transition:
if time.monotonic()>deadline:raise ValueError('Deck-Aufträge noch aktiv.')
self.scheduler.cv.wait(.2)
self.scheduler.key=None
if mode in ('llm','music','separator'):
if any(x.get('running') is not False for x in self._foreign_services()):raise ValueError('Fremder Videodienst blockiert LLM-Modus.')
self.foreign_blocked=False
else:
@@ -86,7 +93,9 @@ class VideoComfy:
self.scheduler.key=None
self.switch_phase='Freie GPUs prüfen';self._start()
with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all()
self.state='ready' if mode=='video' else 'idle'
self.state='ready' if mode in ('video','music','separator') else 'idle'
if mode=='separator':self.switch_phase='Audio-Trennung bereit · Gewichte laden bei Auftrag'
if mode=='music':self.switch_phase='Musik bereit · Gewichte laden bei Anfrage'
except Exception as exc:
self._stop();self.error=str(exc);self.state='failed'
with self.scheduler.cv:self.scheduler.gpu_mode='video' if self.foreign_blocked else 'llm';self.scheduler.cv.notify_all()