Add isolated Qwen3 TTS runtime, GUI setup and speech endpoint

This commit is contained in:
Mikei386
2026-09-28 23:51:56 +02:00
parent 52b1116bdb
commit 88e0edcc59
22 changed files with 413 additions and 24 deletions
+6 -2
View File
@@ -46,7 +46,7 @@ Alle Inferenzrouten benötigen `Authorization: Bearer <Deck-API-Token>`.
| GET | `/health` | Listener-Health, ebenfalls authentifiziert | | GET | `/health` | Listener-Health, ebenfalls authentifiziert |
| POST | `/v1/chat/completions` | Textchat, JSON und SSE-Streaming, Sampling-Defaults aus dem Profil | | POST | `/v1/chat/completions` | Textchat, JSON und SSE-Streaming, Sampling-Defaults aus dem Profil |
| POST | `/v1/images/generations` | Qwen-Image-2.1-Rezept, n=1, `b64_json` | | POST | `/v1/images/generations` | Qwen-Image-2.1-Rezept, n=1, `b64_json` |
| POST | `/v1/audio/speech` | 501: noch kein eigener TTS-Worker | | POST | `/v1/audio/speech` | Qwen3-TTS CustomVoice, WAV-Ausgabe |
| POST | `/v1/audio/transcriptions` | 501: noch kein eigener STT-Worker | | POST | `/v1/audio/transcriptions` | 501: noch kein eigener STT-Worker |
`model` ist immer der **API-Profilname**, nicht der GGUF-Dateiname. Bildaufträge `model` ist immer der **API-Profilname**, nicht der GGUF-Dateiname. Bildaufträge
@@ -55,7 +55,7 @@ verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizi
eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind
deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs. deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs.
Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings, Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings,
Vision-Eingaben, Bildbearbeitung, Video oder Audio-Worker. TTS/STT benötigen keinen Vision-Eingaben, Bildbearbeitung, Video oder STT-/Musik-/Voice-Worker. TTS/STT benötigen keinen
eigenen Port, sondern eigene Routen und Worker hinter demselben Listener. eigenen Port, sondern eigene Routen und Worker hinter demselben Listener.
Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides
@@ -158,3 +158,7 @@ oder Zeitlimit als mögliche Ursachen. Bestehende Anwendungslogs werden nicht ge
Der interne Chat-Test liefert `job.timings` (Prefill/Generierung: Token, Millisekunden und Token/s) und `job.usage` aus der finalen llama.cpp-Streamantwort. Fehlende Messwerte bleiben leer, insbesondere bei Abbruch. Prefill zählt berechnete Token, usage die gesamte Eingabe inklusive wiederverwendetem Kontext. Modellladen und Warteschlange sind nicht Teil der Token/s. Der interne Chat-Test liefert `job.timings` (Prefill/Generierung: Token, Millisekunden und Token/s) und `job.usage` aus der finalen llama.cpp-Streamantwort. Fehlende Messwerte bleiben leer, insbesondere bei Abbruch. Prefill zählt berechnete Token, usage die gesamte Eingabe inklusive wiederverwendetem Kontext. Modellladen und Warteschlange sind nicht Teil der Token/s.
Profilfreigaben: mehrere Chatprofile, höchstens ein Bildprofil. Die Auswahl eines Bildprofils entfernt atomar alle anderen Bildfreigaben; laufende Antworten werden dadurch nicht abgebrochen. Der Haken in der Profilkarte zeigt die gespeicherte Freigabe, nicht den Ladezustand. Beim Laden alter Konfigurationen mit mehreren Bildfreigaben bleibt die erste gespeicherte Bild-ID ausgewählt. Profilfreigaben: mehrere Chatprofile, höchstens ein Bildprofil. Die Auswahl eines Bildprofils entfernt atomar alle anderen Bildfreigaben; laufende Antworten werden dadurch nicht abgebrochen. Der Haken in der Profilkarte zeigt die gespeicherte Freigabe, nicht den Ladezustand. Beim Laden alter Konfigurationen mit mehreren Bildfreigaben bleibt die erste gespeicherte Bild-ID ausgewählt.
### Sprachausgabe
`POST /v1/audio/speech` verwendet ein ausdrücklich freigegebenes TTS-Profil als `model`, `input` (1–1000 Zeichen), optional `voice` (Standard Ryan), `language` (Standard German), `speed` (0,25–4; sonst Profilwert) und `response_format: "wav"`. Antwort: WAV-Datei, kein JSON. MP3 und Streaming sind noch nicht unterstützt. TTS teilt sich die Reservierung mit Chat und Bildern; fremde GPU-Prozesse werden nicht beendet. Das Modell wird nach jeder Ausgabe entladen.
+4
View File
@@ -204,3 +204,7 @@ für eine vorhandene Deck-Installation ergänzen. Alle neuen Ports werden vor de
Update auf Kollisionen geprüft. Die GUI erlaubt den Wechsel innerhalb des Bereichs Update auf Kollisionen geprüft. Die GUI erlaubt den Wechsel innerhalb des Bereichs
bei gestopptem Endpunkt. Nativer Betrieb kennt diese Docker-Beschränkung nicht. bei gestopptem Endpunkt. Nativer Betrieb kennt diese Docker-Beschränkung nicht.
Betrieb, Authentifizierung und SSH-Tunnel: [ENDPOINT.md](ENDPOINT.md). Betrieb, Authentifizierung und SSH-Tunnel: [ENDPOINT.md](ENDPOINT.md).
### Optionale TTS-Laufzeit
Nach der Grundinstallation wird Qwen3-TTS über Einstellungen → Laufzeiten → Text-to-Speech installiert. Der Installer liefert den Installationscode und die Versionsbindung mit; die große CUDA-Umgebung und Modellgewichte werden erst auf Klick im eigenen Deck-Zustand geladen. Voraussetzung ist ein funktionierender NVIDIA-Treiber mit GPU-Zugriff für Deck. Es werden keine System-Python-Pakete ersetzt. Aktuell wird für die Ausführung eine freie RTX 3060 vorausgesetzt.
+2 -2
View File
@@ -17,7 +17,7 @@ Katalog, Datei-Downloads, Bibliothek, serverseitig gespeicherte Profile und
llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden llama.cpp-Buildverwaltung sind live. Der Download-Reiter erlaubt das Ausblenden
abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose. konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose.
Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. Audio und Video sind noch nicht angebunden. Sprachmodelle besitzen außerdem einen internen Testchat mit Abbruch und Speicherdiagnose. Details: [Modellverwaltung](STUDIO.md). Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. Qwen3-TTS CustomVoice ist über eine eigene CUDA-Laufzeit ausführbar; STT, Musik, Voice und Video bleiben vorbereitet. Sprachmodelle besitzen außerdem einen internen Testchat mit Abbruch und Speicherdiagnose. Details: [Modellverwaltung](STUDIO.md).
## Zugang und API-Token ## Zugang und API-Token
@@ -69,7 +69,7 @@ Keine Historie und kein Hintergrund-Collector bei geschlossener Hardware-Seite.
- `image_test.py`: eigene ComfyUI-Aufträge, gemeinsam mit dem Router koordiniert. - `image_test.py`: eigene ComfyUI-Aufträge, gemeinsam mit dem Router koordiniert.
- `endpoint-ui.js`: Übersicht, Port und Profilfreigaben. - `endpoint-ui.js`: Übersicht, Port und Profilfreigaben.
Sprachmodelle und Qwen-Image-Profile sind ausführbar. Audio-/Video-Laufzeiten Sprachmodelle und Qwen-Image-Profile sind ausführbar. Qwen3-TTS CustomVoice ist ebenfalls ausführbar; übrige Audio-/Video-Laufzeiten
bleiben vorbereitet; weitere Docker-Dienste werden nur mit Deck-Labels angezeigt. bleiben vorbereitet; weitere Docker-Dienste werden nur mit Deck-Labels angezeigt.
Der native systemd-Installer ist noch nicht vollständig; aktuell gilt der isolierte Der native systemd-Installer ist noch nicht vollständig; aktuell gilt der isolierte
Docker-Testinstaller. Keine Migration oder Steuerung des bisherigen Routers. Docker-Testinstaller. Keine Migration oder Steuerung des bisherigen Routers.
+13 -1
View File
@@ -222,4 +222,16 @@ Verifikation auf Athena: eigene ungespeicherte Testprofilkopie, 512×512, 4 Schr
Audio ist in Text-to-Speech (`audio`, bestehende Dateien und Links bleiben erhalten), Spracherkennung (`stt`), Musik (`music`) und Stimme / Voice (`voice`) aufgeteilt. Jede Kategorie hat eigene Suchergebnisse, Bibliothek, Downloads und vorbereitende Profile. Hub-Filter: text-to-speech, automatic-speech-recognition, text-to-audio, audio-to-audio. Musik umfasst gemäß Hub-Task auch Geräuscherzeugung; Voice umfasst auch Audiotrennung. Direkte Repository-Links umgehen wie bisher den Suchfilter. Audio ist in Text-to-Speech (`audio`, bestehende Dateien und Links bleiben erhalten), Spracherkennung (`stt`), Musik (`music`) und Stimme / Voice (`voice`) aufgeteilt. Jede Kategorie hat eigene Suchergebnisse, Bibliothek, Downloads und vorbereitende Profile. Hub-Filter: text-to-speech, automatic-speech-recognition, text-to-audio, audio-to-audio. Musik umfasst gemäß Hub-Task auch Geräuscherzeugung; Voice umfasst auch Audiotrennung. Direkte Repository-Links umgehen wie bisher den Suchfilter.
Neue Downloads behalten die gewählte Kategorie. Alte Audio-Dateien bleiben bei TTS; es erfolgt keine ungesicherte automatische Umklassifizierung. Noch keine Audio-Laufzeit oder Endpunktfreigabe; die neuen Profile speichern zunächst nur Modell und Namen. Eigenständige Studio-Oberflächen bleiben Weitere Dienste. Neue Downloads behalten die gewählte Kategorie. Alte Audio-Dateien bleiben bei TTS; es erfolgt keine ungesicherte automatische Umklassifizierung. TTS unterstützt jetzt die unten beschriebene CustomVoice-Laufzeit; STT, Musik und Voice speichern zunächst nur Modell und Namen. Eigenständige Studio-Oberflächen bleiben Weitere Dienste.
### Qwen3-TTS einrichten und testen
Unter **TTS → Einrichten** oder **Einstellungen → Laufzeiten → Text-to-Speech** installiert der Button eine eigene Python-Umgebung, CUDA-PyTorch und das vollständige offizielle Qwen3-TTS-12Hz-1.7B-CustomVoice inklusive Sprach-Tokenizer. Versionen und Modellrevision sind festgelegt (`deploy/tts-requirements.lock`, `tts_runtime.py`). Mindestens 25 GiB freier Datenträgerplatz werden vorausgesetzt. Bestehende Laufzeiten bleiben unverändert. Das Modell erscheint anschließend in der TTS-Bibliothek.
Ein bestehendes MLX-/Base-Profil kann mit **CUDA-Modell diesem Profil zuordnen** ausdrücklich auf CustomVoice umgestellt werden. Name und Geschwindigkeit bleiben erhalten, die alte Datei wird nicht gelöscht. Alternativ ein neues Profil aus der neuen Bibliotheksdatei erstellen. MLX-Dateien sind für Apple; Base-Referenzstimmen und VoiceDesign sind in dieser ersten Version nicht ausführbar.
Unter **TTS → Testen** Profil, Stimme und Sprache wählen, Text eingeben und erzeugen. Der Player spielt die WAV-Ausgabe ab; Download und Abbruch sind verfügbar. Die freie RTX 3060 benötigt mindestens 7 GiB VRAM, Deck mindestens 8 GiB RAM-Spielraum. Der Worker läuft offline, nutzt ausschließlich diese GPU und wird nach jedem Auftrag beendet. Die 5080 ist für diesen TTS-Worker noch nicht auswählbar. Geschwindigkeit wird dem Profil entnommen.
Text wird nur über stdin übertragen und nicht gespeichert. WAV-Dateien bleiben unter `tts-tests/<job-id>/result.wav` im privaten Deck-Zustand; es gibt noch keine automatische Aufräumfunktion. Audio ist nur nach Anmeldung abrufbar. Die API-Freigabe erfolgt wie bei anderen Profilen ausdrücklich über den Endpunkt-Schalter.
Interne authentifizierte API: `GET /api/v1/tts`, `GET /api/v1/tts/audio?id=…`, `POST /api/v1/tts/install`, `/install-cancel`, `/assign` (`id`, `revision`), `/start` (`profile_id`, `text`, optional `speaker`, `language`) und `/cancel`.
+1 -1
View File
@@ -8,7 +8,7 @@ const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</stron
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`; const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;} if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
if(page==='access'){await accessPage();return;} if(page==='access'){await accessPage();return;}
if(page==='network'){await networkPage();return;} if(page==='network'){await networkPage();return;}
if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return; if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return;
+2
View File
@@ -41,6 +41,8 @@ def file_role(filename):
name=filename.lower();parts=PurePosixPath(name).parts name=filename.lower();parts=PurePosixPath(name).parts
if re.search(r'(?:^|[/_-])mmproj(?:[/_.-]|$)',name): if re.search(r'(?:^|[/_-])mmproj(?:[/_.-]|$)',name):
role,label='vision_projector','Vision-Projektor' role,label='vision_projector','Vision-Projektor'
elif 'speech_tokenizer' in parts:
role,label='auxiliary','Sprach-Tokenizer'
elif any(p in ('text_encoder','text_encoders') for p in parts) or re.search(r'(?:^|[/_-])(?:text.encoder|qwen3vl|t5xxl|clip_l|clip_g)(?:[/_.-]|$)',name): elif any(p in ('text_encoder','text_encoders') for p in parts) or re.search(r'(?:^|[/_-])(?:text.encoder|qwen3vl|t5xxl|clip_l|clip_g)(?:[/_.-]|$)',name):
role,label='text_encoder','Textencoder' role,label='text_encoder','Textencoder'
elif 'vae' in parts or re.search(r'(?:^|[/_-])vae(?:[/_.-]|$)',name): elif 'vae' in parts or re.search(r'(?:^|[/_-])vae(?:[/_.-]|$)',name):
+3 -2
View File
@@ -12,8 +12,9 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock && /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
WORKDIR /app WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py /app/ COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY 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 profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py /app/
COPY tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
+2 -2
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['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','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.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 = ['deploy/tts-requirements.lock','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','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.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): def run(*args, check=True, interactive=False):
@@ -198,7 +198,7 @@ def update(base, rollback=False, force=False, api_ports=None):
backup_name=config['name']+'-previous' backup_name=config['name']+'-previous'
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
stamp=str(time.time_ns()) stamp=str(time.time_ns())
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','router-verification-*')) shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','router-verification-*'))
was_running=previous['State']['Running'] was_running=previous['State']['Running']
if was_running:run('docker','stop','--time','15',config['name']) if was_running:run('docker','stop','--time','15',config['name'])
run('docker','rename',config['name'],backup_name) run('docker','rename',config['name'],backup_name)
+106
View File
@@ -0,0 +1,106 @@
accelerate==1.12.0
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
brotli==1.2.0
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.1
click==8.5.0
cloudpickle==3.1.2
cuda-bindings==12.9.7
cuda-pathfinder==1.6.0
cuda-toolkit==12.8.1
decorator==5.3.1
einops==0.8.2
fastapi==0.141.1
filelock==3.32.3
flatbuffers==25.12.19
fsspec==2026.7.0
gradio==6.17.3
gradio_client==2.5.0
groovy==0.1.2
h11==0.16.0
hf-gradio==0.4.1
hf-xet==1.6.0
httpcore==1.0.9
httpx==0.28.1
huggingface_hub==0.36.2
idna==3.20
Jinja2==3.1.6
joblib==1.6.0
lazy-loader==0.6
librosa==1.0.0
llvmlite==0.49.0
markdown-it-py==4.2.0
MarkupSafe==3.0.3
mdurl==0.1.2
mpmath==1.3.0
msgpack==1.2.2
narwhals==2.26.0
networkx==3.6.1
numba==0.67.0
numpy==2.5.3
nvidia-cublas-cu12==12.8.4.1
nvidia-cuda-cupti-cu12==12.8.90
nvidia-cuda-nvrtc-cu12==12.8.93
nvidia-cuda-runtime-cu12==12.8.90
nvidia-cudnn-cu12==9.19.0.56
nvidia-cufft-cu12==11.3.3.83
nvidia-cufile-cu12==1.13.1.3
nvidia-curand-cu12==10.3.9.90
nvidia-cusolver-cu12==11.7.3.90
nvidia-cusparse-cu12==12.5.8.93
nvidia-cusparselt-cu12==0.7.1
nvidia-nccl-cu12==2.28.9
nvidia-nvjitlink-cu12==12.8.93
nvidia-nvshmem-cu12==3.4.5
nvidia-nvtx-cu12==12.8.90
onnxruntime==1.30.0
orjson==3.12.0
packaging==26.3
pandas==3.0.6
pillow==12.3.0
platformdirs==4.12.1
pooch==1.9.0
protobuf==7.36.2
psutil==7.2.2
pycparser==3.0
pydantic==2.13.5
pydantic_core==2.46.5
pydub==0.25.1
Pygments==2.21.0
python-dateutil==2.9.0.post0
python-multipart==0.0.32
pytz==2026.4
PyYAML==6.0.3
qwen-tts==0.1.1
regex==2026.9.10
requests==2.34.2
rich==15.0.0
safehttpx==0.1.7
safetensors==0.8.0
scikit-learn==1.9.1
scipy==1.18.1
semantic-version==2.10.0
setuptools==78.1.0
shellingham==1.5.4
six==1.17.0
soundfile==0.14.0
sox==1.5.0
soxr==1.1.0
starlette==1.7.0
sympy==1.14.0
threadpoolctl==3.7.0
tokenizers==0.22.2
tomlkit==0.14.0
torch==2.11.0+cu128
torchaudio==2.11.0+cu128
tqdm==4.70.1
transformers==4.57.3
triton==3.6.0
typer==0.27.2
typing-inspection==0.4.4
typing_extensions==4.16.0
urllib3==2.8.0
uvicorn==0.54.0
+22 -5
View File
@@ -17,7 +17,7 @@ class APIError(ValueError):
class Endpoint: class Endpoint:
def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port): 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.credentials=credentials self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=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.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.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) self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
@@ -36,9 +36,9 @@ class Endpoint:
return [dict(p,enabled=p['id'] in enabled) for p in rows] return [dict(p,enabled=p['id'] in enabled) for p in rows]
def status(self): def status(self):
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={} rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
for key,kind in [('llm','chat'),('image','image'),('tts','tts'),('stt','stt')]: for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt')]:
subset=[p for p in rows if p['kind']==kind] 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(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image')) 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()['job'] and self.tts.status()['job']['state']=='running') if kind=='audio' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None))
scheduler_status=self.scheduler.status() scheduler_status=self.scheduler.status()
with self.lock:return dict(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) with self.lock:return dict(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): def configure(self,data):
@@ -99,6 +99,7 @@ class Endpoint:
# Process shutdown does not change the user's autostart preference. # Process shutdown does not change the user's autostart preference.
with self.lock:self.state='stopping';http=self.http with self.lock:self.state='stopping';http=self.http
if http:http.shutdown();http.server_close() if http:http.shutdown();http.server_close()
if self.tts:self.tts.stop()
self.images.stop();self.worker.stop() self.images.stop();self.worker.stop()
def allowed(self): def allowed(self):
with self.lock:return self.state=='running' with self.lock:return self.state=='running'
@@ -151,8 +152,8 @@ class APIHandler(BaseHTTPRequestHandler):
if self.command=='GET' and self.path=='/v1/models':return self.send(ep.model_list()) if self.command=='GET' and self.path=='/v1/models':return self.send(ep.model_list())
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'}) if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
if self.command!='POST':raise APIError('Route nicht gefunden.',404) if self.command!='POST':raise APIError('Route nicht gefunden.',404)
if self.path in ('/v1/audio/speech','/v1/audio/transcriptions'):raise APIError('Für TTS/STT ist noch keine Deck-Laufzeit eingerichtet.',501,'not_implemented') if self.path=='/v1/audio/transcriptions':raise APIError('Für TTS/STT ist noch keine Deck-Laufzeit eingerichtet.',501,'not_implemented')
if self.path not in ('/v1/chat/completions','/v1/images/generations'):raise APIError('Route nicht implementiert.',404) if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech'):raise APIError('Route nicht implementiert.',404)
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.') if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
try:length=int(self.headers.get('Content-Length','0')) try:length=int(self.headers.get('Content-Length','0'))
except ValueError:raise APIError('Ungültige Content-Length.') from None except ValueError:raise APIError('Ungültige Content-Length.') from None
@@ -161,6 +162,7 @@ class APIHandler(BaseHTTPRequestHandler):
except (ValueError,UnicodeError):raise APIError('Ungültiges JSON.') from None except (ValueError,UnicodeError):raise APIError('Ungültiges JSON.') from None
if not isinstance(data,dict):raise APIError('JSON-Objekt erforderlich.') 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/chat/completions':return self.chat(ep,data)
if self.path=='/v1/audio/speech':return self.speech(ep,data)
return self.image(ep,data) return self.image(ep,data)
except (APIError,InferenceError) as exc: except (APIError,InferenceError) as exc:
if isinstance(exc,InferenceError): if isinstance(exc,InferenceError):
@@ -172,6 +174,21 @@ class APIHandler(BaseHTTPRequestHandler):
self.close_connection=True self.close_connection=True
if admitted: if admitted:
with ep.lock:ep.inflight-=1 with ep.lock:ep.inflight-=1
def speech(self,ep,data):
if not ep.tts:raise APIError('TTS-Laufzeit nicht eingerichtet.',501)
if set(data)-{'model','input','voice','response_format','speed','language'}:raise APIError('Nicht unterstützte TTS-Felder.')
if data.get('response_format','wav')!='wav':raise APIError('Derzeit wird nur WAV unterstützt; response_format=wav setzen.')
profile=ep.find_profile(data.get('model'),'audio')
try:job=ep.tts.start(profile['id'],data.get('input'),speaker=data.get('voice','Ryan'),language=data.get('language','Auto'),speed=data.get('speed'),wait=True)
except ValueError as exc:raise APIError(str(exc)) from None
end=time.monotonic()+620
while time.monotonic()<end:
current=ep.tts.status()['job']
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503)
try:body=ep.tts.audio(job['id'])
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 chat(self,ep,data): def chat(self,ep,data):
supported={'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'} supported={'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'}
if set(data)-supported:raise APIError('Nicht unterstützte Chat-Felder: '+', '.join(sorted(set(data)-supported))) if set(data)-supported:raise APIError('Nicht unterstützte Chat-Felder: '+', '.join(sorted(set(data)-supported)))
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/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="/studio.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><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><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><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="#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"><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="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><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><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><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="#tts-runtime">Text-to-Speech</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>
+1 -1
View File
@@ -12,7 +12,7 @@ import sys
NAME = 'athena-deck-network' NAME = 'athena-deck-network'
BASE = Path('/opt/athena-deck') BASE = Path('/opt/athena-deck')
FILES = {'auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} FILES = {'deploy/tts-requirements.lock','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','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
def run(*args, **kwargs): def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, timeout=600, **kwargs) return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
+1 -1
View File
@@ -8,7 +8,7 @@ window.ProfilesUI=(()=>{
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; 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'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);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; async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);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(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><details><summary>${p.runnable?'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>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</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').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(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><details><summary>${p.runnable?'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>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</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'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`); el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':kind==='audio'?'Eingerichtete TTS-Profile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':'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('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}}); 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('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-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-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
+2 -1
View File
@@ -55,7 +55,7 @@ class Profiles:
def __init__(self,path,catalog): def __init__(self,path,catalog):
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
self.image_runtime_ready=lambda:False self.image_runtime_ready=lambda:False
self.chat_blockers=None self.chat_blockers=None;self.tts_blockers=None
self.rows=json.loads(self.path.read_text()) if self.path.exists() else [] self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
def status(self): def status(self):
with self.lock: with self.lock:
@@ -73,6 +73,7 @@ class Profiles:
except ValueError as exc:p['model']=None;p['blockers']=[str(exc)] except ValueError as exc:p['model']=None;p['blockers']=[str(exc)]
if p.get('model') and image_recipe(p['model']) and self.image_runtime_ready(): if p.get('model') and image_recipe(p['model']) and self.image_runtime_ready():
p['blockers']=[b for b in p['blockers'] if not b.startswith('Eine eigene Bildlaufzeit')] p['blockers']=[b for b in p['blockers'] if not b.startswith('Eine eigene Bildlaufzeit')]
if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p)
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_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' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.')
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured' p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
+28 -2
View File
@@ -7,6 +7,8 @@ import secrets
from http.cookies import SimpleCookie, CookieError from http.cookies import SimpleCookie, CookieError
from auth import CredentialStore, verify_password from auth import CredentialStore, verify_password
from catalog import Catalog from catalog import Catalog
from tts_runtime import TTSRuntime
from tts_test import TTSTests
from profiles import Profiles from profiles import Profiles
from capacity import assess, overview from capacity import assess, overview
from runtime import Runtime from runtime import Runtime
@@ -70,6 +72,9 @@ class Server(ThreadingHTTPServer):
self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime") self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime")
self.image_tests.runtime = self.image_runtime self.image_tests.runtime = self.image_runtime
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"] self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
self.tts_runtime=TTSRuntime(self.catalog.root.parent/'tts-runtime')
self.tts_tests=TTSTests(self.catalog.root.parent/'tts-tests',self.profiles,self.tts_runtime)
self.profiles.tts_blockers=self.tts_tests.blockers
self.docker = DockerSupport() self.docker = DockerSupport()
self.hardware = HardwareProvider() self.hardware = HardwareProvider()
self.started = time.time() self.started = time.time()
@@ -86,7 +91,9 @@ class Server(ThreadingHTTPServer):
self.scheduler=Scheduler(self.worker) self.scheduler=Scheduler(self.worker)
self.profiles.chat_blockers=self.worker.blockers self.profiles.chat_blockers=self.worker.blockers
self.image_tests.acquire=self.scheduler.image_reservation self.image_tests.acquire=self.scheduler.image_reservation
self.tts_tests.acquire=self.scheduler.image_reservation
self.endpoint=Endpoint(self.catalog.root.parent,self.profiles,self.worker,self.scheduler,self.image_tests,self.credentials,self.server_port) self.endpoint=Endpoint(self.catalog.root.parent,self.profiles,self.worker,self.scheduler,self.image_tests,self.credentials,self.server_port)
self.endpoint.tts=self.tts_tests
if self.endpoint.config['autostart']: if self.endpoint.config['autostart']:
try:self.endpoint.start() try:self.endpoint.start()
except ValueError as exc:self.endpoint.error=str(exc) except ValueError as exc:self.endpoint.error=str(exc)
@@ -243,7 +250,7 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401) return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/': if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
routes = {'/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 = {'/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: if self.path in routes:
name, mime = routes[self.path] name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime) return self.respond((ROOT/name).read_bytes(), mime=mime)
@@ -255,6 +262,10 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/chat-tests':return self.respond(self.server.chat_tests.status()) if self.path == '/api/v1/chat-tests':return self.respond(self.server.chat_tests.status())
if self.path == '/api/v1/endpoint':return self.respond(self.server.endpoint.status()) if self.path == '/api/v1/endpoint':return self.respond(self.server.endpoint.status())
if self.path == '/api/v1/docker':return self.respond(self.server.docker.status()) if self.path == '/api/v1/docker':return self.respond(self.server.docker.status())
if self.path == '/api/v1/tts':return self.respond(self.server.tts_tests.status())
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/image-runtime':return self.respond(self.server.image_runtime.status()) if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status())
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status()) if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
if urlsplit(self.path).path == '/api/v1/image-tests/image': if urlsplit(self.path).path == '/api/v1/image-tests/image':
@@ -364,6 +375,17 @@ class Handler(BaseHTTPRequestHandler):
return self.respond(ep.start() if self.path.endswith('/start') else ep.stop()) return self.respond(ep.start() if self.path.endswith('/start') else ep.stop())
except ValueError as exc:return self.respond({'error':str(exc)},400) except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Endpunkt konnte nicht konfiguriert werden.'},503) except OSError:return self.respond({'error':'Endpunkt konnte nicht konfiguriert werden.'},503)
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
if self.path.endswith('/install') and not data:return self.respond(self.server.tts_runtime.start())
if self.path.endswith('/install-cancel') and not data:return self.respond(self.server.tts_runtime.stop())
if self.path.endswith('/assign') and set(data)=={'id','revision'}:return self.respond(t.assign(data['id'],data['revision']))
if self.path.endswith('/start') and set(data)=={'profile_id','text','speaker','language'}:return self.respond(t.start(**data))
if self.path.endswith('/cancel') and not data:return self.respond(t.stop())
raise ValueError('Ungültige TTS-Anfrage.')
except ValueError as exc:return self.respond({'error':str(exc)},400)
except Exception:return self.respond({'error':'TTS-Aktion fehlgeschlagen.'},503)
if self.path in ('/api/v1/image-runtime/install','/api/v1/image-runtime/cancel'): if self.path in ('/api/v1/image-runtime/install','/api/v1/image-runtime/cancel'):
try: try:
if self.read_json():raise ValueError('Keine Parameter erwartet.') if self.read_json():raise ValueError('Keine Parameter erwartet.')
@@ -389,7 +411,9 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/profiles/delete': if self.path == '/api/v1/profiles/delete':
try: try:
data=self.read_json() data=self.read_json()
with self.server.image_tests.lock: with self.server.image_tests.lock, self.server.tts_tests.lock:
tts_job=self.server.tts_tests.job
if tts_job and tts_job.get("state")=="running" and tts_job.get("profile_id")==data.get("id"):raise ValueError("Dieses TTS-Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.")
job=self.server.image_tests.job job=self.server.image_tests.job
if job and job.get('state')=='running' and job.get('profile_id')==data.get('id'):raise ValueError('Dieses Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.') if job and job.get('state')=='running' and job.get('profile_id')==data.get('id'):raise ValueError('Dieses Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.')
return self.respond(self.server.profiles.delete(data)) return self.respond(self.server.profiles.delete(data))
@@ -447,6 +471,8 @@ def main():
server.auto_tests.stop() server.auto_tests.stop()
server.chat_tests.stop() server.chat_tests.stop()
server.endpoint.close() server.endpoint.close()
server.tts_tests.stop()
server.tts_runtime.stop()
server.image_runtime.stop() server.image_runtime.stop()
server.image_tests.stop() server.image_tests.stop()
server.runtime.stop() server.runtime.stop()
+6 -3
View File
@@ -7,13 +7,16 @@ const Studio=(()=>{
if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';} if(category!==page){category=page;section='discover';}
if(modelId)section='profiles'; if(modelId)section='profiles';
const body=['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]?'<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'],...(['image','chat'].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==='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==='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]&&page!=='audio'?'<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'],...(page==='audio'?[['setup','Einrichten']]:[]),...(['image','chat','audio'].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'?TTSUI.html(true):['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>`; document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;} if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
if(page==='tts-runtime'){TTSUI.bind(true);return;}
if(page==='runtime'){RuntimeUI.bind();return;} if(page==='runtime'){RuntimeUI.bind();return;}
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;} if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
if(page==='runtimes')return; if(page==='runtimes')return;
if(section==='auto')AutoTestUI.bind(); if(section==='setup')TTSUI.bind(true);
else if(['test','running'].includes(section)&&page==='audio')TTSUI.bind();
else if(section==='auto')AutoTestUI.bind();
else if(section==='test'&&page==='chat')ChatTestUI.bind(); else if(section==='test'&&page==='chat')ChatTestUI.bind();
else if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running'); else if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running');
else if(section==='running'&&page==='chat')EndpointUI.bindRunning(); else if(section==='running'&&page==='chat')EndpointUI.bindRunning();
@@ -21,7 +24,7 @@ const Studio=(()=>{
else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads'); else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads');
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);}); document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
} }
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><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>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>`;} function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><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>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>`;}
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 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);} function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile}; return {render,openProfile};
+9
View File
@@ -55,6 +55,15 @@ class EndpointTests(unittest.TestCase):
with self.assertRaises(ValueError):self.enable('audio') with self.assertRaises(ValueError):self.enable('audio')
self.assertEqual(self.request('/v1/audio/speech',{})[0],501) self.assertEqual(self.request('/v1/audio/speech',{})[0],501)
self.record['api_token_hash']=hashlib.sha256(b'B'*32).hexdigest();self.assertEqual(self.request()[0],401);self.assertEqual(self.request(token='B'*32)[0],200) self.record['api_token_hash']=hashlib.sha256(b'B'*32).hexdigest();self.assertEqual(self.request()[0],401);self.assertEqual(self.request(token='B'*32)[0],200)
def test_tts_endpoint_returns_wav_and_requires_publication(self):
self.rows[-1].update(runnable=True,blockers=[])
self.ep.tts=Mock();self.ep.tts.start.return_value={'id':'a'*32};self.ep.tts.status.return_value={'job':{'id':'a'*32,'state':'complete'}};self.ep.tts.audio.return_value=b'RIFF-test-WAVE'
self.assertEqual(self.request('/v1/audio/speech',{'model':'audio','input':'hello'})[0],404)
self.enable('audio')
self.assertEqual(self.request('/v1/audio/speech',{'model':'audio','input':'hello','response_format':'mp3'})[0],400)
c=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);c.request('POST','/v1/audio/speech',json.dumps({'model':'audio','input':'hello','voice':'Ryan','response_format':'wav'}),{'Content-Type':'application/json','Authorization':'Bearer '+'A'*32});r=c.getresponse()
self.assertEqual(r.status,200);self.assertEqual(r.getheader('Content-Type'),'audio/wav');self.assertEqual(r.read(),b'RIFF-test-WAVE');c.close()
self.assertEqual(self.ep.tts.start.call_args.kwargs['speaker'],'Ryan')
def test_single_image_selection_preserves_multiple_llms(self): def test_single_image_selection_preserves_multiple_llms(self):
self.rows.append(dict(self.rows[2],id='image2',name='image2')) self.rows.append(dict(self.rows[2],id='image2',name='image2'))
self.enable('alpha');self.enable('beta');self.enable('image');self.enable('image2') self.enable('alpha');self.enable('beta');self.enable('image');self.enable('image2')
+24
View File
@@ -0,0 +1,24 @@
import tempfile,unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from tts_test import TTSTests
from tts_runtime import TTSRuntime,REPO,REVISION
class TTSTestsValidation(unittest.TestCase):
def test_validation_and_reservation_release(self):
with tempfile.TemporaryDirectory() as d:
t=TTSTests(d,SimpleNamespace(status=lambda:{'profiles':[]}),TTSRuntime(Path(d)/'runtime'));release=Mock();t.acquire=Mock(return_value=release)
for kwargs in ({'text':''},{'text':'ok','speaker':'unknown'},{'text':'ok','language':'invalid'},{'text':'ok','speed':9}):
with self.assertRaises(ValueError):t.start('p',**kwargs)
t.acquire.assert_not_called()
with self.assertRaises(ValueError):t.start('p','valid')
release.assert_called_once()
def test_audio_path_and_model_format(self):
with tempfile.TemporaryDirectory() as d:
r=TTSRuntime(Path(d)/'runtime');t=TTSTests(d,None,r)
for ident in ('../secret','a'*32):
with self.assertRaises(ValueError):t.audio(ident)
self.assertTrue(t.blockers({'model':{'repo':'mlx-community/example','file':'model.safetensors'}}))
self.assertTrue(t.blockers({'model':{'repo':REPO,'revision':REVISION,'file':'speech_tokenizer/model.safetensors'}}))
self.assertFalse(r.status()['installed'])
if __name__=='__main__':unittest.main()
+17
View File
@@ -0,0 +1,17 @@
window.TTSUI=(()=>{
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||'TTS-Anfrage fehlgeschlagen');return x;}
function html(setup=false){return `<section id="tts-panel" class="card"><h2>${setup?'Qwen3-TTS einrichten':'Sprache ausprobieren'}</h2><p id="tts-status" role="status"></p><p id="tts-error" role="alert"></p>${setup?'<p>Die Einrichtung installiert eine eigene CUDA-Laufzeit und das offizielle Qwen3-TTS 1.7B CustomVoice mit eingebauten Stimmen. Dafür werden mehrere GB heruntergeladen. MLX-Dateien sind für Apple-Geräte; vorhandene Dateien werden nicht gelöscht.</p><button id="tts-install">TTS-Laufzeit und Modell einrichten</button><button class="secondary" id="tts-install-cancel">Einrichtung abbrechen</button><h3>Vorhandenes TTS-Profil verbinden</h3><p>Ersetzt im ausgewählten Profil die Modellzuordnung durch die eingerichtete CUDA-Variante. Der Profilname bleibt erhalten.</p><select id="tts-profile"></select><button id="tts-assign">CUDA-Modell diesem Profil zuordnen</button>':'<form id="tts-form"><label>Profil<select id="tts-profile" required></select></label><div class="form-grid"><label>Stimme<select id="tts-speaker"></select></label><label>Sprache<select id="tts-language"></select></label></div><label>Text<textarea id="tts-text" rows="5" maxlength="1000" required placeholder="Was soll vorgelesen werden?"></textarea></label><button id="tts-generate">Sprache erzeugen</button><button type="button" id="tts-cancel" class="secondary">Abbrechen</button></form><div id="tts-result"></div><p>Ausführung auf der RTX 3060. Andere Deck-Modellaufträge werden koordiniert; fremde Dienste werden nicht gestoppt. Text wird nicht dauerhaft gespeichert. Ergebnisdateien bleiben auf Athena.</p>'}</section>`;}
function bind(setup=false){const root=document.querySelector('#tts-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let state,profiles=[],busy=false,lastResult='';const error=x=>{if(root.isConnected)el('tts-error').textContent=x;};
async function refresh(){try{state=await api('tts');if(!root.isConnected)return;const rt=state.runtime,j=state.job;el('tts-status').textContent=setup?(rt.job?.phase||(rt.installed?'TTS ist eingerichtet.':'Noch nicht eingerichtet.')):(j?.phase||'Profil wählen und Text eingeben.');
if(setup){el('tts-install').disabled=busy||rt.installed||rt.job?.state==='running';el('tts-install-cancel').disabled=rt.job?.state!=='running';el('tts-assign').disabled=busy||!rt.installed||!profiles.length;}
else{el('tts-generate').disabled=busy||j?.state==='running'||!profiles.length||!rt.installed;el('tts-cancel').disabled=j?.state!=='running';if(j?.state==='complete'&&lastResult!==j.id){lastResult=j.id;el('tts-result').innerHTML=`<h3>Ergebnis</h3><audio controls src="/api/v1/tts/audio?id=${e(j.id)}"></audio><p><a class="link" download="athena-sprache.wav" href="/api/v1/tts/audio?id=${e(j.id)}">WAV herunterladen</a></p>`;}}
}catch(err){error(err.message);}finally{if(root.isConnected)setTimeout(refresh,2000);}}
async function load(){const p=await api('profiles');if(!root.isConnected)return;profiles=p.profiles.filter(p=>p.kind==='audio'&&(setup||p.runnable));el('tts-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst ein TTS-Profil anlegen und unter Einrichten verbinden</option>';}
async function action(path,data={}){busy=true;error('');try{await api('tts/'+path,data);if(path==='assign'){await load();error('Profil verbunden. Jetzt den Reiter Testen öffnen.');}}catch(err){error(err.message);}finally{busy=false;}}
if(setup){el('tts-install').onclick=()=>action('install');el('tts-install-cancel').onclick=()=>action('install-cancel');el('tts-assign').onclick=()=>{const p=profiles.find(p=>p.id===el('tts-profile').value);if(p)action('assign',{id:p.id,revision:p.revision});};}
else{api('tts').then(s=>{if(!root.isConnected)return;el('tts-speaker').innerHTML=s.runtime.speakers.map(v=>`<option ${v==='Ryan'?'selected':''}>${e(v)}</option>`).join('');el('tts-language').innerHTML=s.runtime.languages.map(v=>`<option ${v==='German'?'selected':''}>${e(v)}</option>`).join('');}).catch(x=>error(x.message));el('tts-form').onsubmit=ev=>{ev.preventDefault();action('start',{profile_id:el('tts-profile').value,text:el('tts-text').value,speaker:el('tts-speaker').value,language:el('tts-language').value});};el('tts-cancel').onclick=()=>action('cancel');}
load().catch(x=>error(x.message));refresh();
}
return {html,bind};
})();
+62
View File
@@ -0,0 +1,62 @@
"""Own CUDA environment and pinned official CustomVoice model, installable via GUI."""
import json,sys,time,os,uuid,shutil,threading,hashlib
from pathlib import Path
from image_runtime import ImageRuntime
REPO='Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice'
REVISION='0c0e3051f131929182e2c023b9537f8b1c68adfe'
SPEAKERS=['Vivian','Serena','Uncle_Fu','Dylan','Eric','Ryan','Aiden','Ono_Anna','Sohee']
LANGUAGES=['Auto','German','English','Chinese','Japanese','Korean','French','Russian','Portuguese','Spanish','Italian']
class TTSRuntime(ImageRuntime):
def paths(self):
marker=self.root/'active.json'
if marker.exists():
ident=json.loads(marker.read_text()).get('id','')
if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):return self.root/ident/'python/bin/python',self.root/ident/'model'
return self.root/'missing/python',self.root/'missing/model'
def status(self):
with self.lock:
python,model=self.paths()
return dict(installed=python.is_file() and (model/'ready').exists(),job=dict(self.job) if self.job else None,repo=REPO,revision=REVISION,speakers=SPEAKERS,languages=LANGUAGES)
def ensure_library(self):
if not self.status()['installed']:raise ValueError('Zuerst TTS einrichten.')
_,model=self.paths();ident=hashlib.sha256((REPO+REVISION+'model.safetensors').encode()).hexdigest();target=self.root.parent/'models'/ident;target.mkdir(parents=True,exist_ok=True,mode=0o700)
if (target/'entry.json').is_file() and (target/'model.safetensors').is_file() and json.loads((target/'entry.json').read_text()).get('sha256'):return ident
dest=target/'model.safetensors'
if not dest.exists():dest.symlink_to(model/'model.safetensors')
digest=hashlib.sha256()
with (model/'model.safetensors').open('rb') as source:
for block in iter(lambda:source.read(1024*1024),b''):digest.update(block)
entry=dict(repo=REPO,revision=REVISION,file='model.safetensors',size=(model/'model.safetensors').stat().st_size,kind='audio',downloaded_at=time.time(),sha256=digest.hexdigest(),upstream_hash_verified=False,state='downloaded',runtime_ready=True)
tmp=target/'entry.tmp';tmp.write_text(json.dumps(entry));tmp.replace(target/'entry.json');return ident
def start(self):
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Installation läuft bereits.')
if self.status()['installed']:self.ensure_library();return self.status()
if sys.platform!='linux':raise ValueError('Die TTS-Laufzeit benötigt Debian/Linux mit NVIDIA-CUDA.')
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
if shutil.disk_usage(self.root).free<25*1024**3:raise ValueError('Mindestens 25 GiB freier Speicher erforderlich.')
self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='TTS-Einrichtung vorbereitet',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'];python=base/'python/bin/python';model=base/'model';state='failed';phase='Einrichtung fehlgeschlagen'
try:
base.mkdir(mode=0o700)
self._phase('Eigene TTS-Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python'])
self._phase('CUDA-PyTorch installieren');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'])
self._phase('Qwen-TTS 0.1.1 installieren');self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/tts-requirements.lock','qwen-tts==0.1.1'])
self._command([python,'-m','pip','check'])
self._phase('Offizielles CUDA-Modell und Sprach-Tokenizer laden · rund 4,5 GB')
code='from huggingface_hub import snapshot_download; snapshot_download(repo_id='+repr(REPO)+',revision='+repr(REVISION)+',local_dir='+repr(str(model))+',allow_patterns=["*.json","*.txt","*.safetensors"],max_workers=2)'
self._command([python,'-c',code])
self._phase('Dateien und Laufzeit prüfen');self._command([python,'-c','from qwen_tts import Qwen3TTSModel; import torch,soundfile; assert torch.version.cuda'])
for name in ['model.safetensors','config.json','merges.txt','vocab.json','tokenizer_config.json','speech_tokenizer/model.safetensors']:
if not (model/name).is_file():raise RuntimeError('Modelldatei fehlt: '+name)
if self.cancel.is_set():raise InterruptedError()
(model/'ready').write_text(REVISION)
marker=self.root/'active.tmp';marker.write_text(json.dumps(dict(id=base.name)));marker.replace(self.root/'active.json')
self.ensure_library()
state='complete';phase='TTS bereit · CUDA-Modell mit eingebauten Stimmen installiert'
except InterruptedError:state='cancelled';phase='TTS-Einrichtung abgebrochen'
except Exception as exc:phase=str(exc) if isinstance(exc,RuntimeError) else 'TTS-Einrichtung fehlgeschlagen. Speicher und Internetverbindung prüfen.'
finally:
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
+80
View File
@@ -0,0 +1,80 @@
"""Serial own TTS workers with shared model reservation, WAV results and cancellation."""
import json,os,signal,subprocess,threading,time,uuid,hashlib
from pathlib import Path
from tts_runtime import REPO,REVISION,SPEAKERS,LANGUAGES
from image_test import probe,cgroup_headroom
class TTSTests:
def __init__(self,root,profiles,runtime):
self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.lock=threading.RLock();self.job=None;self.process=None;self.cancel=threading.Event();self.acquire=lambda wait=False:lambda:None
def status(self):
with self.lock:return dict(job=dict(self.job) if self.job else None,runtime=self.runtime.status())
def blockers(self,p):
if p['model']['repo']!=REPO or p['model']['file']!='model.safetensors' or p['model'].get('revision')!=REVISION:return ['Dieses Modell ist nicht CUDA-CustomVoice. Unter TTS → Einrichten die passende Variante installieren und dem Profil zuordnen.']
return [] if self.runtime.status()['installed'] else ['TTS-Laufzeit unter TTS → Einrichten installieren.']
def assign(self,ident,revision):
if not self.runtime.status()['installed']:raise ValueError('Zuerst TTS einrichten.')
entry_id=self.runtime.ensure_library()
p=next((p for p in self.profiles.status()['profiles'] if p['id']==ident),None)
if not p or p['kind']!='audio':raise ValueError('TTS-Profil nicht gefunden.')
return self.profiles.save(dict(id=ident,revision=revision,name=p['name'],kind='audio',model_id=entry_id,parameters=p['parameters']))
def start(self,profile_id,text,speaker='Ryan',language='German',wait=False,speed=None):
if not isinstance(text,str) or not 1<=len(text.strip())<=1000:raise ValueError('Text mit 1–1000 Zeichen erforderlich.')
if speaker not in SPEAKERS or language not in LANGUAGES:raise ValueError('Ungültige Stimme oder Sprache.')
if speed is not None and (type(speed) not in (int,float) or not .25<=speed<=4):raise ValueError('Geschwindigkeit muss zwischen 0,25 und 4 liegen.')
release=self.acquire(wait)
try:
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('TTS-Auftrag läuft bereits.')
p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='audio'),None)
if not p or not p['runnable']:raise ValueError('TTS-Profil noch nicht eingerichtet.')
gpu=next((g for g in probe() if 'RTX 3060' in g['name'] and not g['processes'] and g['free_mib']>7000),None)
if not gpu:raise ValueError('Die RTX 3060 muss frei sein und mindestens 7 GiB freien Speicher haben.')
headroom=cgroup_headroom()
if headroom is not None and headroom<8*1024**3:raise ValueError('Zu wenig freier Deck-RAM für TTS.')
self.cancel.clear();ident=uuid.uuid4().hex;self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
self.job=dict(id=ident,state='running',phase='Sprachmodell wird geladen',profile_id=profile_id,started_at=time.time())
threading.Thread(target=self._run,args=(ident,text,speaker,language,speed if speed is not None else p['parameters']['speed'],gpu,release),daemon=True).start();return dict(self.job)
except Exception:release();raise
def _run(self,ident,text,speaker,language,speed,gpu,release):
directory=self.root/ident;process=None;state='failed';phase='TTS-Ausführung fehlgeschlagen'
try:
directory.mkdir(mode=0o700);python,model=self.runtime.paths()
env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','LLAMA_'))};env.update(CUDA_VISIBLE_DEVICES=gpu['uuid'],HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='2',HOME=str(directory))
with self.lock:
if self.cancel.is_set():raise InterruptedError()
process=subprocess.Popen([str(python),str(Path(__file__).with_name('tts_worker.py'))],stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True,env=env);self.process=process;self.job['phase']='Sprache wird auf der RTX 3060 erzeugt'
process.stdin.write(json.dumps(dict(model=str(model),output=str(directory),text=text,speaker=speaker,language=language,speed=speed)).encode());process.stdin.close()
end=time.monotonic()+600
while process.poll() is None:
if self.cancel.wait(1):raise InterruptedError()
if time.monotonic()>end:raise ValueError('Zeitlimit der Sprachgenerierung erreicht.')
current=next((g for g in probe() if g['uuid']==gpu['uuid']),None)
if not current or current['processes']>1:raise ValueError('Die reservierte GPU wurde anderweitig belegt.')
result=json.loads((directory/'result.json').read_text()) if (directory/'result.json').exists() else dict(ok=False,error='Worker beendet: möglicher Speichermangel oder Laufzeitfehler.')
if self.cancel.is_set():raise InterruptedError()
if not result['ok']:raise ValueError(result['error'])
wav=directory/'result.wav'
if not wav.exists() or wav.stat().st_size>32*1024**2:raise ValueError('Ungültige Audioausgabe.')
state='complete';phase='Sprache fertig · Modell entladen'
except InterruptedError:state='cancelled';phase='Sprachgenerierung abgebrochen'
except Exception as exc:phase=str(exc) if isinstance(exc,ValueError) else 'Sprachgenerierung fehlgeschlagen. Laufzeit und Ressourcen prüfen.'
finally:
if process and process.poll() is None:
try:
os.killpg(process.pid,signal.SIGTERM)
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
except ProcessLookupError:pass
if state=='complete':(directory/'complete').touch()
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time())
release()
def stop(self):
self.cancel.set()
with self.lock:process=self.process
if process and process.poll() is None:
try:os.killpg(process.pid,signal.SIGTERM)
except ProcessLookupError:pass
return dict(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) or not (self.root/ident/'complete').exists():raise ValueError('Audio noch nicht verfügbar.')
return (self.root/ident/'result.wav').read_bytes()
+21
View File
@@ -0,0 +1,21 @@
"""One isolated offline Qwen-TTS request; stdin is never persisted."""
import json,sys
from pathlib import Path
def main():
request=json.load(sys.stdin);root=Path(request['output'])
try:
import torch,soundfile as sf
from qwen_tts import Qwen3TTSModel
model=Qwen3TTSModel.from_pretrained(request['model'],device_map='cuda:0',dtype=torch.bfloat16,attn_implementation='sdpa',local_files_only=True)
waves,rate=model.generate_custom_voice(text=request['text'],language=request['language'],speaker=request['speaker'],max_new_tokens=1024)
wave=waves[0]
if request['speed']!=1:
import librosa
wave=librosa.effects.time_stretch(wave,rate=request['speed'])
sf.write(root/'result.wav',wave,rate,subtype='PCM_16')
(root/'result.json').write_text(json.dumps(dict(ok=True,sample_rate=rate,duration=len(wave)/rate)))
except Exception as exc:
(root/'result.json').write_text(json.dumps(dict(ok=False,error='CUDA-Speicher reicht nicht aus.' if 'outofmemory' in type(exc).__name__.lower() or 'out of memory' in str(exc).lower() else 'TTS-Ausführung fehlgeschlagen: '+type(exc).__name__)))
sys.exit(1)
if __name__=='__main__':main()