Add profile presets and min-p controls, embeddings and VoxCPM workers

This commit is contained in:
Mikei386 committed 2026-10-02 18:42:29 +02:00
1 parent 74dd2ed8ba
commit a7b30b62d2
32 files changed
+614 -63

No files matched your search

+26 -1
View File
@@ -10,7 +10,7 @@ die frühere separate Hardware-Seite wurde entfernt.
## Navigation und Detailansichten ## Navigation und Detailansichten
Die **Übersicht** enthält Dashboard und Steuerung (Endpunkt und GPU-Modi). Die **Übersicht** enthält Dashboard und Steuerung (Endpunkt und GPU-Modi).
**KI-Werkzeuge** trennt Chat & Sprachmodelle, Bildgenerierung, Musikgenerierung, **KI-Werkzeuge** trennt Chat & Sprachmodelle, Embeddings, Bildgenerierung, Musikgenerierung,
Sprachausgabe, Spracherkennung, Stimmwerkzeuge und Videogenerierung. Sprachausgabe, Spracherkennung, Stimmwerkzeuge und Videogenerierung.
Zusätzliche Oberflächen stehen unter **Anwendungen → Weitere Dienste**. Zusätzliche Oberflächen stehen unter **Anwendungen → Weitere Dienste**.
@@ -39,6 +39,23 @@ Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht veränder
## Neu: Modellverwaltung und llama.cpp-Einstellungen ## Neu: Modellverwaltung und llama.cpp-Einstellungen
Profile besitzen einen **Presets**-Button: bis zu 50 benannte Einstellungsstände
pro Profil speichern, in den Bearbeitungsdialog laden oder löschen. Gesichert
werden die aktuell gespeicherten Profilparameter, einschließlich GPU-Verteilung,
Kontext, Sampling und Vision-Projektor. Ungespeicherte Formulareingaben,
Komponenten-Zuordnungen, Prompt-Aufwerter und Client-Einstellungen sind nicht
Teil eines Presets. Erst „Profil auf Athena speichern“ übernimmt geladene Werte;
ein Preset startet kein Modell und ändert keine API-Freigabe. Presets gehören
zur jeweiligen Modelldatei, werden mit Backup/Restore gesichert und halten
referenzierte Modell-/Projektordateien gegen Löschung geschützt. Die interne
API `POST /api/v1/profiles/preset` benötigt `id`, `revision`, `action` sowie bei
`save` einen `name`, bei `delete` eine `preset_id`. Veraltete Revisionen werden
abgelehnt.
Min-p ist in LLM-Profilen editierbar (0–1, Standard 0 = deaktiviert).
Der Wert gilt für llama.cpp, den Testchat und API-Anfragen; explizite
Client-Vorgaben haben Vorrang.
LLM-Profile bieten editierbare [Sampling-Werte und Penalties](LLM_SAMPLING.md), 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. die im Testchat und am API-Endpunkt verwendet und mit den Profilen gesichert werden.
@@ -270,3 +287,11 @@ gesperrt. Der Komponentenbereich bietet vorhandene passende Dateien vorausgewäh
an und einen Sammeldownload fehlender Pflichtdateien. Bei mehreren verfügbaren an und einen Sammeldownload fehlender Pflichtdateien. Bei mehreren verfügbaren
Varianten ist eine bewusste Einzelwahl nötig; ungeprüfte Quellen werden nicht Varianten ist eine bewusste Einzelwahl nötig; ungeprüfte Quellen werden nicht
automatisch installiert. Speichern und Downloads starten keine Inferenz. automatisch installiert. Speichern und Downloads starten keine Inferenz.
## Embeddings
Unter **KI-Werkzeuge → Embeddings** können Feature-Extraction-Modelle gesucht, heruntergeladen und als eigene Profile gespeichert werden. Sie erscheinen nicht in der Chat-Modellliste. Bibliothek, Downloads und Backup/Restore verwenden die gemeinsame Modellverwaltung.
EmbeddingGemma 300M QAT Q8_0 (`ggml-org/embeddinggemma-300m-qat-q8_0-GGUF`, `embeddinggemma-300m-qat-Q8_0.gguf`) ist ausführbar. Tokenizer und Konfiguration sind in der GGUF-Datei enthalten; keine separaten Pflichtkomponenten. Vorhandenes llama.cpp wird auf CPU genutzt (0 GPU-Layer, CUDA-Geräte deaktiviert), unabhängig vom GPU-Modus. Einstellungen entsprechen dem alten Dienst: mean pooling, Kontext 4096 insgesamt, vier Slots, Batch 4096, Microbatch 1024, acht CPU-Threads. Der Worker bleibt nach Anfragen geladen und wird beim Endpunkt-Stopp beendet.
Profil speichern und am Endpunkt freigeben. `POST /v1/embeddings` am gleichen authentifizierten API-Port wie Chat erwartet `model` (freigegebener Profilname) und `input` (Text oder bis zu 32 Texte). Unterstützt wird `encoding_format=float`; maximal etwa 1024 Token je Text, von llama.cpp geprüft. `GET /v1/embeddings/models` listet ausschließlich freigegebene Embedding-Profile. `/v1/models` bleibt die Chat-Liste. Andere Embedding-Familien sind ausdrücklich ungeprüft. Modellquellen und Profile werden über den bestehenden Backup/Restore-Pfad wiederhergestellt; der CPU-Prozess selbst wird bei Bedarf neu gestartet.
+32 -4
View File
@@ -16,8 +16,8 @@ nicht Bestandteil dieses Grundpakets.
Das Dateirezept ist anhand des [offiziellen Modells](https://huggingface.co/openbmb/VoxCPM2) Das Dateirezept ist anhand des [offiziellen Modells](https://huggingface.co/openbmb/VoxCPM2)
und des [originalen Loaders](https://github.com/OpenBMB/VoxCPM/blob/main/src/voxcpm/model/voxcpm2.py) und des [originalen Loaders](https://github.com/OpenBMB/VoxCPM/blob/main/src/voxcpm/model/voxcpm2.py)
geprüft. Es ist keine Zusage getesteter Inferenz: **Der Deck-Generierungsworker ist noch nicht angebunden.** Eine vorhandene audio.cpp- oder geprüft. Es ist keine Zusage getesteter Inferenz: **VoxCPM2 wird durch den eigenen Deck-Worker ausgeführt.** Eine vorhandene audio.cpp- oder
llama.cpp-Installation ersetzt diese Anbindung nicht. Das Profil bleibt gesperrt. llama.cpp-Installation ersetzt diese Anbindung nicht. Ein Profil wird erst nach installierter Laufzeit und vollständiger Komponenten-Zuordnung ausführbar.
Der Katalog erlaubt `.pth` ausschließlich für die exakt benannte offizielle Der Katalog erlaubt `.pth` ausschließlich für die exakt benannte offizielle
Audio-VAE-Datei dieses Repositorys. Der Download führt keine Datei aus. Das spätere Audio-VAE-Datei dieses Repositorys. Der Download führt keine Datei aus. Das spätere
@@ -41,5 +41,33 @@ Bildlaufzeit werden lesend wiederverwendet. Versionen stehen in
`deploy/voxcpm-requirements.lock`. `deploy/voxcpm-requirements.lock`.
Backup exportiert den Installationswunsch und die Version; Restore installiert Backup exportiert den Installationswunsch und die Version; Restore installiert
diese Laufzeit erneut. **Eine installierte Laufzeit schaltet das Profil noch diese Laufzeit erneut. Eine installierte Laufzeit allein genügt nicht: Die fünf Komponenten müssen dem Profil zugeordnet sein.
nicht frei: Der Generierungsworker muss zusätzlich angebunden werden.**
## Generierung und Stimmklonen
Unter **Stimmwerkzeuge → Testen**: VoxCPM2-Profil wählen, Text eingeben, GPU
(RTX 5080 oder RTX 3060), Guidance, Schritte und Seed einstellen. Optional eine
PCM-16-WAV-Referenz mit 1–30 Sekunden und höchstens 8 MiB hochladen.
Die Referenz wird nach dem Auftrag gelöscht; der Text wird nur über stdin
weitergegeben. Bibliotheksgewichte werden in einem temporären Paket verlinkt,
nicht kopiert. Der Worker lädt ausschließlich lokale Dateien, ohne Denoiser
oder zusätzliche Downloads, auf die ausdrücklich ausgewählte CUDA-GPU.
Im LLM-Modus reserviert Deck die GPUs exklusiv, entlädt sein LLM/TTS und
beendet VoxCPM2 nach Abschluss, Abbruch oder Fehler. Andere Dienste werden
nicht gestoppt. Bei nächster Chat-Anfrage wird das LLM wieder geladen.
Interne API (Oberflächen-Anmeldung und Same-Origin-Steuerheader erforderlich):
- `GET /api/v1/voice`: Job und Laufzeitstatus.
- `POST /api/v1/voice/start`: JSON mit `profile_id`, `text`, optional `device`
(`5080`/`3060`), `cfg`, `steps`, `seed`; alternativ Multipart mit denselben
Feldern und optional `file` als WAV-Referenz.
- `POST /api/v1/voice/cancel`: leeres JSON.
- `GET /api/v1/voice/audio?id=...`: fertiges WAV.
Ein unter Profile freigegebenes VoxCPM2-Profil steht zusätzlich unter
`GET /v1/audio/speech/models` und `POST /v1/audio/speech` bereit:
`model=<Profilname>`, `input=<Text>`, `voice=default`, `response_format=wav`,
`speed=1`. Referenz-Stimmklonen verwendet derzeit den internen Multipart-Endpunkt.
Backups sichern Profile, Komponenten und die wiederherstellbare Laufzeitversion,
keine Testtexte oder Audiodateien.
+1 -1
View File
@@ -28,7 +28,7 @@ if(['separator-runtime','separator-test','separation'].includes(page)){Separator
if(page==='audio-cpp-runtime'){AudioCppUI.render();return;} if(page==='audio-cpp-runtime'){AudioCppUI.render();return;}
if(page==='ltx-original-runtime'){LTXOriginalUI.render();return;} if(page==='ltx-original-runtime'){LTXOriginalUI.render();return;}
if(['video','video-runtime'].includes(page)){VideoUI.runtime();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','voxcpm-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;} if(['embeddings','chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','voxcpm-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
if(page==='backup'){BackupUI.render();return;} if(page==='backup'){BackupUI.render();return;}
if(page==='access'){await accessPage();return;} if(page==='access'){await accessPage();return;}
if(page==='appearance'){appearancePage();return;} if(page==='appearance'){appearancePage();return;}
+16
View File
@@ -52,6 +52,22 @@ def validate(doc):
if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Ungültige Bildgröße.') if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Ungültige Bildgröße.')
components=p.get('components',{}) components=p.get('components',{})
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.') 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.')
presets=p.get('presets',[])
if not isinstance(presets,list) or len(presets)>50:raise ValueError('Ungültige Presets im Backup.')
preset_ids=set();preset_names=set()
for preset in presets:
if not isinstance(preset,dict) or set(preset)!={'id','name','created_at','model_id','parameters'}:raise ValueError('Ungültiges Preset.')
pn=preset['name'];pi=preset['id'];pp=preset['parameters']
if not isinstance(pn,str) or not 1<=len(pn)<=64 or any(ord(c)<32 for c in pn) or not isinstance(pi,str) or not re.fullmatch('[a-f0-9]{32}',pi) or pi in preset_ids or pn in preset_names or preset['model_id'] not in ids or type(preset['created_at']) not in (int,float):raise ValueError('Ungültige Preset-Metadaten.')
if not isinstance(pp,dict):raise ValueError('Ungültige Preset-Parameter.')
pp=chat_parameters(pp) if kind=='chat' else pp
if set(pp)!=(set(SCHEMAS[kind])|(set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(pp)&{'video_device','text_encoder_device'} if kind=='video' else set())):raise ValueError('Unbekannte Preset-Parameter.')
for key,(lo,hi,_) in SCHEMAS[kind].items():
value=pp[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 Preset-Parameter: '+key)
if kind=='chat' and (pp['ubatch']>pp['batch'] or pp.get('vision_projector') and pp['vision_projector'] not in ids):raise ValueError('Ungültige Preset-Projektorzuordnung.')
if kind in ('image','video') and (pp['width']%64 or pp['height']%64):raise ValueError('Ungültige Preset-Bildgröße.')
preset_ids.add(pi);preset_names.add(pn)
pids.add(p['id']);names.add(name) pids.add(p['id']);names.add(name)
settings=doc.get('settings') settings=doc.get('settings')
if not isinstance(settings,dict) or set(settings)-set(SETTINGS):raise ValueError('Unbekannte Einstellungsdateien.') if not isinstance(settings,dict) or set(settings)-set(SETTINGS):raise ValueError('Unbekannte Einstellungsdateien.')
+1 -1
View File
@@ -12,7 +12,7 @@ import urllib.parse
import urllib.request import urllib.request
import urllib.error import urllib.error
KINDS={'chat':'text-generation','image':'text-to-image','audio':'text-to-speech','stt':'automatic-speech-recognition','music':'text-to-audio','voice':'audio-to-audio','video':'text-to-video'} KINDS={'embeddings':'feature-extraction','chat':'text-generation','image':'text-to-image','audio':'text-to-speech','stt':'automatic-speech-recognition','music':'text-to-audio','voice':'audio-to-audio','video':'text-to-video'}
def safe_url(url): def safe_url(url):
p=urllib.parse.urlsplit(url) p=urllib.parse.urlsplit(url)
+2 -2
View File
@@ -16,8 +16,8 @@ WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-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 deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock
COPY voxcpm_runtime.py 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 embeddings.py voxcpm_test.py voxcpm_worker.py voxcpm_runtime.py 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 voxcpm-ui.js 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 voice-ui.js voxcpm-ui.js 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 deploy/docker_backup.py /app/deploy/docker_backup.py
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 \
+2 -2
View File
@@ -6,8 +6,8 @@ WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-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 deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock
COPY voxcpm_runtime.py 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 embeddings.py voxcpm_test.py voxcpm_worker.py voxcpm_runtime.py 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 voxcpm-ui.js 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 voice-ui.js voxcpm-ui.js 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 deploy/docker_backup.py /app/deploy/docker_backup.py
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 \
+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 = ['voxcpm_runtime.py','voxcpm-ui.js','deploy/voxcpm-requirements.lock','network/native.py','deploy/setup_network_helper.py','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'] FILES = ['embeddings.py','voxcpm_test.py','voxcpm_worker.py','voice-ui.js','voxcpm_runtime.py','voxcpm-ui.js','deploy/voxcpm-requirements.lock','network/native.py','deploy/setup_network_helper.py','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): def run(*args, check=True, interactive=False):
@@ -206,7 +206,7 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals
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('music-verification','music-test-temp','music-jobs','separator-runtime','separator-tests','voxcpm-runtime','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-*')) shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('music-verification','music-test-temp','music-jobs','separator-runtime','separator-tests','voxcpm-runtime','audio-cpp-runtime','ltx-original-runtime','original-work','tts-runtime','tts-tests','voice-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'] 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)
+73
View File
@@ -0,0 +1,73 @@
"""CPU-only EmbeddingGemma worker; independent of GPU mode and chat leases."""
import http.client
import json
import os
from pathlib import Path
import secrets
import signal
import socket
import subprocess
import threading
import time
REPO='ggml-org/embeddinggemma-300m-qat-q8_0-GGUF'
FILE='embeddinggemma-300m-qat-Q8_0.gguf'
def supported(model):
return bool(model and model.get('kind')=='embeddings' and model.get('repo')==REPO and model.get('file')==FILE)
class Embeddings:
def __init__(self,root,catalog,worker):
self.root=Path(root);self.catalog=catalog;self.worker=worker;self.lock=threading.RLock();self.process=None;self.identity=None;self.port=None;self.key=None
def blockers(self,p):
if not supported(p.get('model')):return ['Noch kein geprüftes Embedding-Rezept für diese Datei. Unterstützt: EmbeddingGemma 300M QAT Q8_0.']
try:self.worker.build()
except ValueError as exc:return [str(exc)]
return []
def stop(self):
with self.lock:
process=self.process;self.process=None;self.identity=None
if process and process.poll() is None:
os.killpg(process.pid,signal.SIGTERM)
try:process.wait(timeout=10)
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
(self.root/'key').unlink(missing_ok=True)
def ensure(self,p):
identity=(p['id'],p['revision'],self.worker.runtime.status()['active'])
if self.process and self.process.poll() is None and self.identity==identity:return
self.stop();errors=self.blockers(p)
if errors:raise ValueError(errors[0])
entry=self.catalog.entry(p['model_id']);model=self.catalog.root/entry['id']/'model.gguf';directory=self.worker.build()
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
with socket.socket() as sock:sock.bind(('127.0.0.1',0));self.port=sock.getsockname()[1]
self.key=secrets.token_urlsafe(32);keyfile=self.root/'key'
fd=os.open(keyfile,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
with os.fdopen(fd,'w') as out:out.write(self.key+'\n')
env=dict(os.environ,CUDA_VISIBLE_DEVICES='')
args=[str(directory/'build/bin/llama-server'),'--model',str(model),'--alias',p['name'],'--embedding','--pooling','mean','--ctx-size','4096','--batch-size','4096','--ubatch-size','1024','--parallel','4','--threads','8','--threads-batch','8','--n-gpu-layers','0','--host','127.0.0.1','--port',str(self.port),'--api-key-file',str(keyfile),'--no-ui']
self.process=subprocess.Popen(args,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
try:
deadline=time.monotonic()+120
while time.monotonic()<deadline:
if self.process.poll() is not None:raise ValueError('EmbeddingGemma konnte nicht gestartet werden. llama.cpp-Build und Modell prüfen.')
conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=2)
try:
conn.request('GET','/health',headers={'Authorization':'Bearer '+self.key})
if conn.getresponse().status==200:self.identity=identity;keyfile.unlink(missing_ok=True);return
except OSError:pass
finally:conn.close()
time.sleep(.25)
raise ValueError('Embedding-Start hat das Zeitlimit überschritten.')
except Exception:self.stop();raise
def generate(self,p,data):
with self.lock:
self.ensure(p);conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=120)
try:
conn.request('POST','/v1/embeddings',body=json.dumps(data).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+self.key})
response=conn.getresponse();raw=response.read(4*1024*1024+1)
if response.status!=200:raise ValueError('Embedding-Anfrage abgelehnt. Textlänge und Eingabe prüfen (maximal ca. 1024 Token je Text).')
if len(raw)>4*1024*1024:raise ValueError('Embedding-Antwort zu groß.')
result=json.loads(raw)
if not result.get('data'):raise ValueError('Keine Embedding-Vektoren zurückgegeben.')
return result
except (OSError,ValueError):raise ValueError('Embedding-Anfrage fehlgeschlagen. Eingabelänge und Laufzeit prüfen.') from None
finally:conn.close()
+38 -13
View File
@@ -40,7 +40,7 @@ def chat_upstream_error(status, raw):
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.tts=None;self.stt=None;self.music=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.voxcpm=None;self.embeddings=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.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)
@@ -126,6 +126,7 @@ class Endpoint:
with self.lock:pending=self.inflight with self.lock:pending=self.inflight
if not pending and self.scheduler.unload_idle():break if not pending and self.scheduler.unload_idle():break
time.sleep(.1) time.sleep(.1)
if self.embeddings:self.embeddings.stop()
with self.lock:self.http=None;self.thread=None;self.state='stopped' with self.lock:self.http=None;self.thread=None;self.state='stopped'
def close(self): def close(self):
# Process shutdown does not change the user's autostart preference. # Process shutdown does not change the user's autostart preference.
@@ -133,6 +134,8 @@ class Endpoint:
if http:http.shutdown();http.server_close() if http:http.shutdown();http.server_close()
if self.music:self.music.stop() if self.music:self.music.stop()
if self.tts:self.tts.stop() if self.tts:self.tts.stop()
if self.embeddings:self.embeddings.stop()
if self.voxcpm:self.voxcpm.stop()
if self.stt:self.stt.stop() if self.stt:self.stt.stop()
self.images.stop();self.worker.stop() self.images.stop();self.worker.stop()
def allowed(self): def allowed(self):
@@ -203,8 +206,8 @@ class APIHandler(BaseHTTPRequestHandler):
with ep.lock: with ep.lock:
if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping') if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping')
ep.inflight+=1;admitted=True ep.inflight+=1;admitted=True
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=='separator' and self.path not in ('/v1/embeddings','/v1/embeddings/models'):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.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path not in ('/v1/embeddings','/v1/embeddings/models'):
if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching') if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching')
video=ep.video.status() video=ep.video.status()
if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready') if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready')
@@ -214,14 +217,17 @@ class APIHandler(BaseHTTPRequestHandler):
if self.command=='GET' and self.path=='/v1/capabilities': if self.command=='GET' and self.path=='/v1/capabilities':
from api_compat import capabilities from api_compat import capabilities
return self.send(capabilities()) return self.send(capabilities())
model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt','/v1/audio/music/models':'music'} model_routes={'/v1/embeddings/models':'embeddings','/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 in model_routes:
listing=ep.model_list(model_routes[self.path])
if self.path=='/v1/audio/speech/models':listing['data']+=ep.model_list('voice')['data']
return self.send(listing)
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.video_route(ep):return if self.video_route(ep):return
if self.command!='POST':raise APIError('Route nicht gefunden.',404) if self.command!='POST':raise APIError('Route nicht gefunden.',404)
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=='separator' and self.path not in ('/v1/embeddings','/v1/embeddings/models'):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.scheduler.gpu_mode=='music' and self.path not in ('/v1/audio/music','/v1/embeddings'):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 ep.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path not in ('/v1/audio/transcriptions','/v1/embeddings'):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/audio/transcriptions':return self.transcription(ep)
if self.path=='/v1/images/edits': if self.path=='/v1/images/edits':
from image_upload import read_image_upload from image_upload import read_image_upload
@@ -232,7 +238,7 @@ class APIHandler(BaseHTTPRequestHandler):
fields['n']=int(fields.get('n','1')) fields['n']=int(fields.get('n','1'))
return self.image(ep,fields,images) return self.image(ep,fields,images)
except ValueError as exc:raise APIError(str(exc)) from None except ValueError as exc:raise APIError(str(exc)) from None
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.path not in ('/v1/embeddings','/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.') 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
@@ -240,6 +246,7 @@ class APIHandler(BaseHTTPRequestHandler):
try:data=json.loads(self.rfile.read(length),parse_constant=lambda _:(_ for _ in ()).throw(ValueError())) try:data=json.loads(self.rfile.read(length),parse_constant=lambda _:(_ for _ in ()).throw(ValueError()))
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/embeddings':return self.embedding(ep,data)
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/music':return self.music_generation(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) if self.path=='/v1/audio/speech':return self.speech(ep,data)
@@ -275,17 +282,35 @@ class APIHandler(BaseHTTPRequestHandler):
if not ep.tts:raise APIError('TTS-Laufzeit nicht eingerichtet.',501) 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 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.') 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') from profiles import voice_recipe
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) voice=next((p for p in ep.rows() if p.get('model') and voice_recipe(p['model']) and p['name']==data.get('model') and p['enabled']),None)
if voice:
profile=ep.find_profile(data.get('model'),voice['kind']);backend=ep.voxcpm
if data.get('voice','default')!='default' or data.get('speed',1)!=1:raise APIError('VoxCPM2: voice=default und speed=1 verwenden; Referenzstimme im Deck-Testbereich.')
try:job=backend.start(profile['id'],data.get('input'),wait=True)
except ValueError as exc:raise APIError(str(exc)) from None
else:
profile=ep.find_profile(data.get('model'),'audio');backend=ep.tts
try:job=backend.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 except ValueError as exc:raise APIError(str(exc)) from None
end=time.monotonic()+620 end=time.monotonic()+620
while time.monotonic()<end: while time.monotonic()<end:
current=ep.tts.status()['job'] current=backend.status()['job']
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503) 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']) try:body=backend.audio(job['id'])
except (ValueError,OSError):time.sleep(.25);continue 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 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) raise APIError('TTS-Zeitlimit überschritten.',504)
def embedding(self,ep,data):
if not ep.embeddings:raise APIError('Embedding-Worker nicht eingerichtet.',501)
if set(data)-{'model','input','encoding_format','user'}:raise APIError('Nicht unterstützte Embedding-Felder.')
if data.get('encoding_format','float')!='float':raise APIError('Nur encoding_format=float unterstützt.')
value=data.get('input');texts=[value] if isinstance(value,str) else value
if not isinstance(texts,list) or not 1<=len(texts)<=32 or any(not isinstance(t,str) or not t.strip() or len(t)>16000 for t in texts):raise APIError('input: 1–32 nichtleere Texte, jeweils maximal 16000 Zeichen. Kontextgrenze wird zusätzlich von llama.cpp geprüft.')
profile=ep.find_profile(data.get('model'),'embeddings')
try:return self.send(ep.embeddings.generate(profile,{k:v for k,v in data.items() if k!='user'}))
except ValueError as exc:raise APIError(str(exc)) from None
def music_generation(self,ep,data): 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 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 not ep.music:raise APIError('Musikworker nicht eingerichtet.',501)
+11 -3
View File
@@ -12,6 +12,13 @@ def _assess(model, installed, profile=None):
if not file_role(filename)['profile_eligible']: if not file_role(filename)['profile_eligible']:
result.update(state='component',label='Zusatzdatei',message='Diese Datei wird einem kompatiblen Modellprofil zugeordnet und benötigt keine eigene Laufzeit.') result.update(state='component',label='Zusatzdatei',message='Diese Datei wird einem kompatiblen Modellprofil zugeordnet und benötigt keine eigene Laufzeit.')
return result return result
if kind=='embeddings':
from embeddings import supported
if supported(model):
result.update(runtime='llama.cpp · CPU',route='#runtime',family='EmbeddingGemma 300M QAT Q8_0',components=[],state='setup',label='EmbeddingGemma erkannt',installed=bool(installed.get('embeddings')),message='Tokenizer und Konfiguration sind in der GGUF-Datei enthalten. Keine Zusatzdateien nötig. Die vorhandene llama.cpp-Laufzeit wird ohne GPU-Layer verwendet; API: /v1/embeddings.')
if profile:result.update(state='ready' if profile.get('runnable') else 'profile_incomplete',label='Bereit · CPU' if profile.get('runnable') else 'Laufzeit prüfen',blockers=profile.get('blockers',[]))
else:result.update(message='Für diese Datei fehlt noch ein geprüftes Embedding-Rezept. Unterstützt wird EmbeddingGemma 300M QAT Q8_0; keine erfundenen Zusatzkomponenten.')
return result
if kind=='stt' and stt_supported(model): if kind=='stt' and stt_supported(model):
result['family']='Qwen3-ASR 0.6B' 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)']) result.update(runtime='llama.cpp · Qwen3-ASR auf CPU',route='#stt-runtime',components=['Passender Audio-Projektor (unter Einrichten herunterladen)'])
@@ -22,7 +29,7 @@ def _assess(model, installed, profile=None):
result['family']='Qwen3-TTS CustomVoice' result['family']='Qwen3-TTS CustomVoice'
result.update(runtime='Qwen3-TTS CustomVoice',route='#tts-runtime',components=['Vollständiges Modellpaket und Sprach-Tokenizer (im Einrichtungsassistenten enthalten)']) result.update(runtime='Qwen3-TTS CustomVoice',route='#tts-runtime',components=['Vollständiges Modellpaket und Sprach-Tokenizer (im Einrichtungsassistenten enthalten)'])
elif voice_recipe(model): elif voice_recipe(model):
result.update(family='VoxCPM2',evidence='Offizielles OpenBMB-Modellpaket',state='adapter_missing',label='VoxCPM2 erkannt · Ausführung noch nicht angebunden',runtime=None,route='#voxcpm-runtime',components=[x['label'] for x in voice_recipe(model).values()],message='Die heruntergeladenen Dateien bilden das Modellpaket. Für die Ausführung fehlt noch die VoxCPM-Python/PyTorch-Laufzeit samt Deck-Worker. audio.cpp und llama.cpp führen diese Safetensors-Datei nicht aus. Textencoder und Vision-Projektor sind nicht separat erforderlich. Unter Komponenten werden die tatsächlich benötigten Dateien angeboten.') result.update(family='VoxCPM2',evidence='Offizielles OpenBMB-Modellpaket',state='setup',label='VoxCPM2 erkannt · Komponenten prüfen',runtime=None,route='#voxcpm-runtime',components=[x['label'] for x in voice_recipe(model).values()],message='Die heruntergeladenen Dateien bilden das Modellpaket. Für die Ausführung fehlt noch die VoxCPM-Python/PyTorch-Laufzeit samt Deck-Worker. audio.cpp und llama.cpp führen diese Safetensors-Datei nicht aus. Textencoder und Vision-Projektor sind nicht separat erforderlich. Unter Komponenten werden die tatsächlich benötigten Dateien angeboten.')
elif kind=='chat' and filename.lower().endswith('.gguf'): elif kind=='chat' and filename.lower().endswith('.gguf'):
result.update(runtime='llama.cpp',route='#runtime',state='check',label='Kompatibilität beim Laden prüfen',message='llama.cpp ist der mögliche Ausführungsweg für dieses Chat-GGUF. Architektur und gewählte Optionen müssen vom installierten Build unterstützt werden. Der Testchat prüft die tatsächliche Ausführung.') result.update(runtime='llama.cpp',route='#runtime',state='check',label='Kompatibilität beim Laden prüfen',message='llama.cpp ist der mögliche Ausführungsweg für dieses Chat-GGUF. Architektur und gewählte Optionen müssen vom installierten Build unterstützt werden. Der Testchat prüft die tatsächliche Ausführung.')
elif kind=='image' and filename.lower().endswith(('.safetensors','.gguf')) and (meta.get('pipeline_tag') in ('text-to-image','image-to-image') or meta.get('library_name') in ('diffusers','comfyui')): elif kind=='image' and filename.lower().endswith(('.safetensors','.gguf')) and (meta.get('pipeline_tag') in ('text-to-image','image-to-image') or meta.get('library_name') in ('diffusers','comfyui')):
@@ -59,8 +66,9 @@ def _assess_candidates(model, installed, profile=None):
def add(id,label,key,route,reason,maintained=True): 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)) result['candidates'].append(dict(id=id,label=label,installed=bool(installed.get(key)),route=route,installation_available=maintained,evidence=reason))
if voice_recipe(model): if voice_recipe(model):
result['installed']=bool(installed.get('voxcpm'));result['message']='VoxCPM-Laufzeit '+('bereits installiert' if result['installed'] else 'fehlt: unter Ausführung einrichten installieren')+'. Die Modellkomponenten werden separat zugeordnet. Der Deck-Worker für Generierung fehlt noch.' result['installed']=bool(installed.get('voxcpm'));result['message']='VoxCPM-Laufzeit '+('bereits installiert' if result['installed'] else 'fehlt: unter Ausführung einrichten installieren')+'. Komponenten zuordnen, danach unter Stimmwerkzeuge → Testen Sprache erzeugen oder Stimme klonen.'
add('voxcpm','VoxCPM · Python/PyTorch','voxcpm','#voxcpm-runtime','Originale VoxCPM-Laufzeit · GUI-Installer verfügbar; Generierungsworker noch nicht angebunden',True) add('voxcpm','VoxCPM · Python/PyTorch','voxcpm','#voxcpm-runtime','Originale VoxCPM-Laufzeit · Sprachausgabe und Stimmklonen',True)
if profile:result.update(state='ready' if profile.get('runnable') else 'profile_incomplete',blockers=profile.get('blockers',[]),label='VoxCPM2 bereit' if profile.get('runnable') else 'VoxCPM2 einrichten')
if result.get('runtime'): if result.get('runtime'):
add('current',result['runtime'],kind,result['route'],'Vorhandene Deck-Zuordnung') 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'): if kind=='music' and repo=='audio-cpp/yue2-3b-gguf' and name.startswith('yue2-3b-') and name.endswith('.gguf'):
+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="/voxcpm-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> <!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="/voxcpm-ui.js" defer></script><script src="/voice-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="#embeddings">Embeddings</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>
+1 -1
View File
@@ -240,7 +240,7 @@ class LlamaWorker:
if cancel():raise InferenceError('Modellstart abgebrochen.') if cancel():raise InferenceError('Modellstart abgebrochen.')
if plan_only:return if plan_only:return
with self.lock:self.phase='Modell wird geladen' with self.lock:self.phase='Modell wird geladen'
launch=common+extra+vision_args+['--gpu-layers',str(layers),'--fit','off','--kv-unified','--threads',str(params['threads']),'--load-mode','none','--host','127.0.0.1','--alias',profile['name'],'--metrics','--no-webui','--log-disable'] launch=common+extra+vision_args+['--min-p',str(params.get('min_p',0.0)),'--gpu-layers',str(layers),'--fit','off','--kv-unified','--threads',str(params['threads']),'--load-mode','none','--host','127.0.0.1','--alias',profile['name'],'--metrics','--no-webui','--log-disable']
if mtp:launch+=['--spec-type','draft-mtp','--spec-draft-n-max',str(params.get('mtp_tokens',2)),'--spec-draft-p-min',str(params.get('mtp_min_p',.05)),'--spec-draft-type-k','f16','--spec-draft-type-v','f16'] if mtp:launch+=['--spec-type','draft-mtp','--spec-draft-n-max',str(params.get('mtp_tokens',2)),'--spec-draft-p-min',str(params.get('mtp_min_p',.05)),'--spec-draft-type-k','f16','--spec-draft-type-v','f16']
self.root.mkdir(parents=True,exist_ok=True,mode=0o700) self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
+17 -6
View File
@@ -1,6 +1,6 @@
window.ProfilesUI=(()=>{ window.ProfilesUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 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',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 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',min_p:'Min-p (0 = aus)',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>'; 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;} 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){ function bind(kind,modelId){
@@ -8,15 +8,25 @@ 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'),['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; 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;
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId)); 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').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-presets="${p.id}">Presets (${(p.presets||[]).length})</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>`); 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.':kind==='voice'?'Eingerichtete VoxCPM2-Profile können Sprache erzeugen und am Sprach-API-Endpunkt freigegeben werden. Stimmklonen mit WAV-Referenz steht unter Testen.':'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-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-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)));
root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers))); root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers)));
root.querySelectorAll('[data-presets]').forEach(b=>b.onclick=()=>presets(rows.find(p=>p.id===b.dataset.presets)));
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit))); root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});}); root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,presets:[],id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});});
}catch(error){message(error.message);}} }catch(error){message(error.message);}}
async function presets(profile){
panelSequence++;
el('profile-editor').innerHTML=`<section class="card editor"><h2>Presets · ${e(profile.name)}</h2><p>Gespeicherte Profil-Einstellungen sichern. Laden öffnet den Editor; erst Profil speichern übernimmt die Werte. Komponenten-Zuordnungen und API-Freigabe bleiben unverändert. Client-Einstellungen werden hier nicht gesichert.</p><form id="preset-form"><label>Name<input name="name" maxlength="64" required placeholder="Vor dem Experiment"></label><button>Aktuell gespeicherte Einstellungen sichern</button></form><div>${(profile.presets||[]).map(x=>`<div class="card-actions"><strong>${e(x.name)}</strong><span>${e(new Date(x.created_at*1000).toLocaleString())}</span><button data-preset-load="${e(x.id)}">In Editor laden</button><button class="secondary" data-preset-delete="${e(x.id)}">Löschen</button></div>`).join('')||'<p>Noch keine Presets gespeichert.</p>'}</div><button class="secondary" id="preset-close">Schließen</button></section>`;
el('preset-close').onclick=()=>el('profile-editor').replaceChildren();
async function mutate(data){await api('profiles/preset',{id:profile.id,revision:profile.revision,...data});await load();const current=rows.find(x=>x.id===profile.id);if(current)presets(current);}
el('preset-form').onsubmit=async event=>{event.preventDefault();const b=event.target.querySelector('button');b.disabled=true;try{await mutate({action:'save',name:new FormData(event.target).get('name')});message('Preset gespeichert.');}catch(error){message(error.message);b.disabled=false;}};
root.querySelectorAll('[data-preset-load]').forEach(b=>b.onclick=()=>{const x=profile.presets.find(x=>x.id===b.dataset.presetLoad);if(x.model_id!==profile.model_id){message('Dieses Preset gehört zu einer anderen Modelldatei. Zuerst diese im Profil auswählen.');return;}editor({...profile,parameters:structuredClone(x.parameters)});message(`Preset „${x.name}“ im Editor. Prüfen und Profil speichern.`);});
root.querySelectorAll('[data-preset-delete]').forEach(b=>b.onclick=async()=>{if(!confirm('Dieses gespeicherte Preset löschen?'))return;b.disabled=true;try{await mutate({action:'delete',preset_id:b.dataset.presetDelete});}catch(error){message(error.message);b.disabled=false;}});
}
async function enhancers(profile){ async function enhancers(profile){
const sequence=++panelSequence,selected=profile.prompt_enhancer||{t2i:null,i2i:null,device:'auto'}; const sequence=++panelSequence,selected=profile.prompt_enhancer||{t2i:null,i2i:null,device:'auto'};
el('profile-editor').innerHTML=`<section class="card editor"><h2>Prompt-Aufwerter · ${e(profile.name)}</h2><p>Optional pro Bildprofil. Text-zu-Bild und Bearbeitung mit Referenzbildern verwenden getrennte offizielle Qwen-Modelle. Andere Bildfamilien können sie nur nach eigener Prüfung sinnvoll nutzen.</p><form id="enhancer-form"><div class="form-grid"><label>Text-zu-Bild<select name="t2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-T2I" ${selected.t2i?'selected':''}>Qwen Image 2.1 PE-T2I</option></select></label><label>Bildbearbeitung mit Referenzen<select name="i2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-I2I" ${selected.i2i?'selected':''}>Qwen Image 2.1 PE-I2I</option></select></label><label>Ausführungsgerät<select name="device">${[['auto','Automatisch · 5080, dann 3060, nötigenfalls RAM'],['5080','RTX 5080 · nötigenfalls RAM'],['3060','RTX 3060 · nötigenfalls RAM'],['cpu','Nur CPU · sehr langsam']].map(([v,t])=>`<option value="${v}" ${selected.device===v?'selected':''}>${t}</option>`).join('')}</select></label></div><p>Der Aufwerter wird vor der Bildpipeline geladen und danach vollständig entladen. Beide PE-Modelle benötigen je etwa 18,8 GB Download. Die Wahl einer einzelnen GPU garantiert keinen rein dortigen Betrieb; CPU-Auslagerung kann erforderlich sein. Die Bildauflösung bleibt im Profil gespeichert; ein Seitenverhältnis aus der PE-Antwort ist nur ein Vorschlag.</p><button>Profil-Einstellung speichern</button><button type="button" class="secondary" id="enhancer-close">Schließen</button></form><h3>Offizielle Modelle installieren</h3><div id="enhancer-models"></div><p id="enhancer-message" role="status"></p></section>`; el('profile-editor').innerHTML=`<section class="card editor"><h2>Prompt-Aufwerter · ${e(profile.name)}</h2><p>Optional pro Bildprofil. Text-zu-Bild und Bearbeitung mit Referenzbildern verwenden getrennte offizielle Qwen-Modelle. Andere Bildfamilien können sie nur nach eigener Prüfung sinnvoll nutzen.</p><form id="enhancer-form"><div class="form-grid"><label>Text-zu-Bild<select name="t2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-T2I" ${selected.t2i?'selected':''}>Qwen Image 2.1 PE-T2I</option></select></label><label>Bildbearbeitung mit Referenzen<select name="i2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-I2I" ${selected.i2i?'selected':''}>Qwen Image 2.1 PE-I2I</option></select></label><label>Ausführungsgerät<select name="device">${[['auto','Automatisch · 5080, dann 3060, nötigenfalls RAM'],['5080','RTX 5080 · nötigenfalls RAM'],['3060','RTX 3060 · nötigenfalls RAM'],['cpu','Nur CPU · sehr langsam']].map(([v,t])=>`<option value="${v}" ${selected.device===v?'selected':''}>${t}</option>`).join('')}</select></label></div><p>Der Aufwerter wird vor der Bildpipeline geladen und danach vollständig entladen. Beide PE-Modelle benötigen je etwa 18,8 GB Download. Die Wahl einer einzelnen GPU garantiert keinen rein dortigen Betrieb; CPU-Auslagerung kann erforderlich sein. Die Bildauflösung bleibt im Profil gespeichert; ein Seitenverhältnis aus der PE-Antwort ist nur ein Vorschlag.</p><button>Profil-Einstellung speichern</button><button type="button" class="secondary" id="enhancer-close">Schließen</button></form><h3>Offizielle Modelle installieren</h3><div id="enhancer-models"></div><p id="enhancer-message" role="status"></p></section>`;
@@ -38,13 +48,14 @@ window.ProfilesUI=(()=>{
try{ try{
const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id,...(extraRepo?{repo:extraRepo}:{})}));if(!root.isConnected||sequence!==panelSequence)return; 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>${CatalogUI.executionHTML(profile.execution)}<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>${data.runtime_route?`<p><a class="link" href="${e(data.runtime_route)}">${data.runtime_installed?'Laufzeit ansehen':'Passende Laufzeit installieren'} →</a></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">Fehlende Pflichtdateien 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>`; 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>${CatalogUI.executionHTML(profile.execution)}<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>${data.runtime_route?`<p><a class="link" href="${e(data.runtime_route)}">${data.runtime_installed?'Laufzeit ansehen':'Passende Laufzeit installieren'} →</a></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">Fehlende Pflichtdateien 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>`;
if(data.no_components){const form=el('components-form');form?.replaceChildren();el('components-download-all')?.remove();el('component-source-open')?.closest('details')?.remove();}
const missing=data.requirements?.filter(r=>!r.available.length&&!r.optional)||[]; const missing=data.requirements?.filter(r=>!r.available.length&&!r.optional)||[];
if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{ 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; const b=el('components-download-all');b.disabled=true;let count=0;
try{const plan=missing.map(r=>{const choices=r.candidates.filter(f=>!f.gated||f.auth_configured);if(choices.length>1)throw Error('Bitte Variante für '+r.label+' einzeln auswählen. Es wurde noch kein Download gestartet.');const f=choices[0];if(!f)throw Error('Downloadplan unvollständig: '+r.label+'. Quelle oder HF-Zugang prüfen. Es wurde noch kein Download gestartet.');return f;});for(const f of plan){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.';} try{const plan=missing.map(r=>{const choices=r.candidates.filter(f=>!f.gated||f.auth_configured);if(choices.length>1)throw Error('Bitte Variante für '+r.label+' einzeln auswählen. Es wurde noch kein Download gestartet.');const f=choices[0];if(!f)throw Error('Downloadplan unvollständig: '+r.label+'. Quelle oder HF-Zugang prüfen. Es wurde noch kein Download gestartet.');return f;});for(const f of plan){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=(count?count+' Datei(en) bereits im Downloadplan. ':'')+error.message;b.disabled=false;} catch(error){if(el('components-message'))el('components-message').textContent=(count?count+' Datei(en) bereits im Downloadplan. ':'')+error.message;b.disabled=false;}
};} };}
el('component-source-open').onclick=()=>{const value=el('component-source').value.trim();if(value)components(profile,value);}; if(el('component-source-open'))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-close').onclick=()=>el('profile-editor').replaceChildren();
el('components-refresh')?.addEventListener('click',()=>components(profile,extraRepo)); 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;}}); 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;}});
@@ -135,7 +146,7 @@ window.ProfilesUI=(()=>{
panelSequence++; 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(!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;} 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).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>`; 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','min_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'){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==='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); if(kind==='video')bindVideoDevices(data);
+48 -8
View File
@@ -8,10 +8,10 @@ from pathlib import Path
from catalog import file_role from catalog import file_role
SCHEMAS={ 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),'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)}, '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),'min_p':(0,1,0.0),'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)}, '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)}, 'audio':{'speed':(.25,4,1)},
'stt':{},'music':{},'voice':{}, 'stt':{},'music':{},'voice':{},'embeddings':{},
'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)} 'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)}
} }
VIDEO_DEVICE_DEFAULTS={'video_device':'auto','text_encoder_device':'same'} VIDEO_DEVICE_DEFAULTS={'video_device':'auto','text_encoder_device':'same'}
@@ -26,8 +26,8 @@ def video_parameters(params):
return 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_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'}
FLOAT_PARAMETERS=frozenset({'guidance','speed','temperature','top_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'}) FLOAT_PARAMETERS=frozenset({'guidance','speed','temperature','top_p','min_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_GENERATION_DEFAULTS={'temperature':.8,'top_p':.95,'top_k':40,'min_p':0.0,'repeat_penalty':1.0,'presence_penalty':0.0,'frequency_penalty':0.0}
CHAT_SAMPLING={**CHAT_GENERATION_DEFAULTS,'mtp_tokens':2,'mtp_min_p':.05} CHAT_SAMPLING={**CHAT_GENERATION_DEFAULTS,'mtp_tokens':2,'mtp_min_p':.05}
def generation_parameters(params): def generation_parameters(params):
@@ -145,6 +145,17 @@ class Profiles:
self.prompt_enhancer_ready=lambda task:False self.prompt_enhancer_ready=lambda task:False
self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_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 []
# First baselines may be captured while a pre-Preset server still runs.
# Merge on startup so its old in-memory saves cannot discard that baseline.
pending=self.path.with_name('profile-presets-pending.json')
if pending.exists():
for item in json.loads(pending.read_text()):
row=next((p for p in self.rows if p['id']==item['profile_id']),None)
if row is not None and not any(x['name']==item['preset']['name'] for x in row.get('presets',[])):
row.setdefault('presets',[]).append(item['preset'])
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(self.rows));temp.replace(self.path)
pending.unlink()
def status(self): def status(self):
with self.lock: with self.lock:
rows=json.loads(json.dumps(self.rows)) rows=json.loads(json.dumps(self.rows))
@@ -156,6 +167,7 @@ class Profiles:
p['model']=self.catalog.entry(p['model_id']) p['model']=self.catalog.entry(p['model_id'])
if p['kind']=='image':p['capabilities']=image_capabilities(p['model']) if p['kind']=='image':p['capabilities']=image_capabilities(p['model'])
p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.'] p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.']
if p['kind']=='embeddings':p['blockers']=['Embedding-Worker noch nicht angebunden. Dieses Profil erzeugt Vektoren und wird nicht als Chat-Modell angeboten.']
if p['kind']=='image' and image_recipe(p['model']): if p['kind']=='image' and image_recipe(p['model']):
p['blockers']=['Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.'] 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']).items())): for role,info in reversed(list(image_recipe(p['model']).items())):
@@ -173,7 +185,7 @@ class Profiles:
try:self._component(p['model'],role,p.get('components',{}).get(role)) try:self._component(p['model'],role,p.get('components',{}).get(role))
except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.') except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
if p.get('model') and voice_recipe(p['model']): if p.get('model') and voice_recipe(p['model']):
p['blockers']=['VoxCPM2-Ausführung fehlt: Die VoxCPM-Laufzeit und der Deck-Worker sind noch nicht angebunden.'] p['blockers']=[] if getattr(self,'voxcpm_runtime_ready',lambda:False)() else ['VoxCPM-Laufzeit unter Laufzeiten → VoxCPM installieren.']
for role,info in voice_recipe(p['model']).items(): for role,info in voice_recipe(p['model']).items():
try:self._component(p['model'],role,p.get('components',{}).get(role)) try:self._component(p['model'],role,p.get('components',{}).get(role))
except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.') except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
@@ -181,6 +193,7 @@ class Profiles:
if p['kind']=='audio' and self.tts_blockers and p.get('model') and not voice_recipe(p['model']):p['blockers']=self.tts_blockers(p) if p['kind']=='audio' and self.tts_blockers and p.get('model') and not voice_recipe(p['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']=='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']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p)
if p['kind']=='embeddings' and getattr(self,'embedding_blockers',None) and p.get('model'):p['blockers']=self.embedding_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.')
if p['kind']=='image': if p['kind']=='image':
@@ -219,17 +232,41 @@ class Profiles:
if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.') if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.')
if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.') if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.')
components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {} components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {}
row=dict(components=components,prompt_enhancer=existing.get('prompt_enhancer',dict(PROMPT_ENHANCER_DEFAULTS)) if existing and existing['model_id']==model['id'] and kind=='image' else dict(PROMPT_ENHANCER_DEFAULTS) if kind=='image' else None,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time()) row=dict(presets=existing.get('presets',[]) if existing else [],components=components,prompt_enhancer=existing.get('prompt_enhancer',dict(PROMPT_ENHANCER_DEFAULTS)) if existing and existing['model_id']==model['id'] and kind=='image' else dict(PROMPT_ENHANCER_DEFAULTS) if kind=='image' else None,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time())
rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row] rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row]
self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700) self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700)
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
return row return row
def preset(self,data):
if not isinstance(data,dict) or data.get('action') not in ('save','delete'):raise ValueError('Ungültige Preset-Aktion.')
required={'id','revision','action','name' if data['action']=='save' else 'preset_id'}
if set(data)!=required:raise ValueError('Ungültige Preset-Felder.')
with self.lock:
old=next((p for p in self.rows if p['id']==data['id']),None)
if not old or old['revision']!=data['revision']:raise ValueError('Profil geändert. Ansicht neu laden.')
presets=json.loads(json.dumps(old.get('presets',[])))
if data['action']=='save':
name=data['name']
if not isinstance(name,str) or not 1<=len(name.strip())<=64 or any(ord(c)<32 for c in name):raise ValueError('Preset-Name: 1–64 Zeichen.')
name=name.strip()
if any(x['name']==name for x in presets):raise ValueError('Preset-Name bereits vergeben.')
if len(presets)>=50:raise ValueError('Maximal 50 Presets pro Profil.')
presets.append(dict(id=uuid.uuid4().hex,name=name,created_at=time.time(),model_id=old['model_id'],parameters=json.loads(json.dumps(old['parameters']))))
else:
if not any(x['id']==data['preset_id'] for x in presets):raise ValueError('Preset nicht gefunden.')
presets=[x for x in presets if x['id']!=data['preset_id']]
updated=dict(old,presets=presets,revision=old['revision']+1,updated_at=time.time())
rows=[updated if p is old else p for p in self.rows]
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
return updated
def references(self,model_id): def references(self,model_id):
with self.lock: with self.lock:
return [dict(id=p['id'],name=p['name'],kind=p['kind']) for p in self.rows return [dict(id=p['id'],name=p['name'],kind=p['kind']) for p in self.rows
if model_id in (p.get('model_id'),p.get('parameters',{}).get('vision_projector')) if model_id in (p.get('model_id'),p.get('parameters',{}).get('vision_projector'))
or model_id in (p.get('components') or {}).values()] or model_id in (p.get('components') or {}).values()
or any(model_id in (x.get('model_id'),x.get('parameters',{}).get('vision_projector')) for x in p.get('presets',[]))]
def configure_prompt_enhancer(self,data): def configure_prompt_enhancer(self,data):
if not isinstance(data,dict) or set(data)!={'id','revision','prompt_enhancer'}:raise ValueError('Ungültige Prompt-Aufwerter-Einstellung.') if not isinstance(data,dict) or set(data)!={'id','revision','prompt_enhancer'}:raise ValueError('Ungültige Prompt-Aufwerter-Einstellung.')
@@ -267,6 +304,9 @@ class Profiles:
return item return item
def components(self,model_id,extra_repo=None): def components(self,model_id,extra_repo=None):
model=self.catalog.entry(model_id) model=self.catalog.entry(model_id)
from embeddings import supported
if supported(model):return dict(supported=True,requirements=[],source='https://huggingface.co/'+model['repo'],license='gemma',runtime='llama.cpp · CPU · EmbeddingGemma',runtime_route='#runtime',runtime_installed=not bool(getattr(self,'embedding_blockers',lambda p:['Laufzeit prüfen'])({'model':model})),no_components=True,message='Alles enthalten: Tokenizer und Konfiguration liegen in der GGUF-Datei. Kein separater Textencoder, VAE oder Projektor nötig. Die vorhandene llama.cpp-Laufzeit wird auf CPU verwendet. Profil speichern und am API-Endpunkt freigeben; Vektoren über /v1/embeddings abrufen.')
if model.get('kind')=='embeddings':return dict(supported=False,requirements=[],message='Für diese Embedding-Datei fehlt ein geprüftes Rezept. Zusatzdateien werden nicht pauschal verlangt. Unterstützt: ggml-org/embeddinggemma-300m-qat-q8_0-GGUF.')
if extra_repo or not self._recipe(model):return self.manual_components(model,extra_repo) 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) or voice_recipe(model) else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[] recipe=self._recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video or music_recipe(model) or voice_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(): for role,info in recipe.items():
@@ -277,7 +317,7 @@ class Profiles:
except ValueError:pass 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] 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)) requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates))
if voice_recipe(model):return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='VoxCPM · eigene Python/PyTorch-Laufzeit',runtime_installed=bool(getattr(self,'voxcpm_runtime_ready',lambda:False)()),runtime_route='#voxcpm-runtime',worker_available=False,message='VoxCPM2 benötigt genau diese fünf Zusatzdateien derselben Quellversion. Ein separater Textencoder und ein Vision-Projektor werden nicht benötigt. tokenizer.json und tokenizer_config.json gehören zusammen, sie sind keine doppelten Modelle. Vorhandene Dateien werden wiederverwendet. Die passende Laufzeit kann unter Einstellungen → Laufzeiten → VoxCPM installiert werden. Der Deck-Worker fehlt noch; ein vollständiges Dateipaket allein kann noch nicht gestartet werden.') if voice_recipe(model):return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='VoxCPM · eigene Python/PyTorch-Laufzeit',runtime_installed=bool(getattr(self,'voxcpm_runtime_ready',lambda:False)()),runtime_route='#voxcpm-runtime',worker_available=True,message='VoxCPM2 benötigt genau diese fünf Zusatzdateien derselben Quellversion. Ein separater Textencoder und ein Vision-Projektor werden nicht benötigt. tokenizer.json und tokenizer_config.json gehören zusammen, sie sind keine doppelten Modelle. Vorhandene Dateien werden wiederverwendet. Die passende Laufzeit kann unter Einstellungen → Laufzeiten → VoxCPM installiert werden. Nach Zuordnung dieser Dateien unter Stimmwerkzeuge → Testen Sprache erzeugen oder eine WAV-Referenz für Stimmklonen verwenden.')
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_route='#audio-cpp-runtime',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 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_route='#audio-cpp-runtime',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_route='#image-runtime',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.') 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_route='#image-runtime',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.')
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.') 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.')
+39 -5
View File
@@ -14,6 +14,7 @@ from tts_runtime import TTSRuntime
from stt import STT,read_upload from stt import STT,read_upload
from audio_policy import AudioPolicy from audio_policy import AudioPolicy
from tts_test import TTSTests from tts_test import TTSTests
from voxcpm_test import VoxCPMTests
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
@@ -104,8 +105,14 @@ class Server(ThreadingHTTPServer):
raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.') raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.')
self.worker=LlamaWorker(self.catalog.root.parent/'llama-worker',self.catalog,self.runtime) self.worker=LlamaWorker(self.catalog.root.parent/'llama-worker',self.catalog,self.runtime)
self.scheduler=Scheduler(self.worker) self.scheduler=Scheduler(self.worker)
self.voxcpm_tests=VoxCPMTests(self.catalog.root.parent/'voice-tests',self.profiles,self.voxcpm_runtime)
self.voxcpm_tests.acquire=lambda wait=False:self.scheduler.image_reservation(wait,kind='voice')
self.separator_runtime=SeparatorRuntime(self.catalog.root.parent/'separator-runtime') 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.separator_tests=SeparatorTests(self.catalog.root.parent/'separator-tests',self.separator_runtime,self.scheduler)
from embeddings import Embeddings
import tempfile
self.embeddings=Embeddings(tempfile.mkdtemp(prefix='deck-embeddings-'),self.catalog,self.worker)
self.profiles.embedding_blockers=self.embeddings.blockers
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.prompt_enhancer.acquire=self.scheduler.image_reservation self.prompt_enhancer.acquire=self.scheduler.image_reservation
@@ -114,12 +121,14 @@ class Server(ThreadingHTTPServer):
self.stt=STT(self.profiles,self.catalog,self.runtime) self.stt=STT(self.profiles,self.catalog,self.runtime)
self.profiles.stt_blockers=self.stt.blockers self.profiles.stt_blockers=self.stt.blockers
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.embeddings=self.embeddings
self.endpoint.tts=self.tts_tests self.endpoint.tts=self.tts_tests
self.endpoint.voxcpm=self.voxcpm_tests
self.endpoint.stt=self.stt self.endpoint.stt=self.stt
self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler) 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) self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
def stop_gpu_work(): def stop_gpu_work():
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() self.voxcpm_tests.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 video_original import VideoRuntimes
from ltx_original_runtime import LTXOriginalRuntime from ltx_original_runtime import LTXOriginalRuntime
from audio_cpp_runtime import AudioCppRuntime from audio_cpp_runtime import AudioCppRuntime
@@ -306,7 +315,7 @@ class Handler(BaseHTTPRequestHandler):
video_runtime = (self.server.video.original_runtime video_runtime = (self.server.video.original_runtime
if self.server.video.runtime_kind() == 'original' if self.server.video.runtime_kind() == 'original'
else self.server.video.runtime) else self.server.video.runtime)
return {'stt':chat,'chat':chat, return {'embeddings':chat,'stt':chat,'chat':chat,
'image':self.server.image_runtime.status().get('installed',False), 'image':self.server.image_runtime.status().get('installed',False),
'audio':self.server.tts_runtime.status().get('installed',False), 'audio':self.server.tts_runtime.status().get('installed',False),
'video':video_runtime.status().get('installed',False), 'video':video_runtime.status().get('installed',False),
@@ -327,7 +336,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 = {'/voxcpm-ui.js':('voxcpm-ui.js','text/javascript'),'/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')} routes = {'/voice-ui.js':('voice-ui.js','text/javascript'),'/voxcpm-ui.js':('voxcpm-ui.js','text/javascript'),'/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: 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)
@@ -341,6 +350,10 @@ class Handler(BaseHTTPRequestHandler):
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/stt':return self.respond(self.server.stt.status()) if self.path == '/api/v1/stt':return self.respond(self.server.stt.status())
if self.path == '/api/v1/voice':return self.respond(self.server.voxcpm_tests.status())
if urlsplit(self.path).path == '/api/v1/voice/audio':
try:return self.respond(self.server.voxcpm_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400)
if self.path == '/api/v1/tts':return self.respond(self.server.tts_tests.status()) if self.path == '/api/v1/tts':return self.respond(self.server.tts_tests.status())
if self.path == '/api/v1/audio-policy':return self.respond(self.server.audio_policy.status()) if self.path == '/api/v1/audio-policy':return self.respond(self.server.audio_policy.status())
if urlsplit(self.path).path == '/api/v1/tts/audio': if urlsplit(self.path).path == '/api/v1/tts/audio':
@@ -623,6 +636,21 @@ class Handler(BaseHTTPRequestHandler):
if set(data)-{'profile_id','lyrics','style','seed','max_tokens','steps'} or not {'profile_id','lyrics','style'}<=set(data):raise ValueError('Ungültige Musikfelder.') 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)) return self.respond(self.server.music.start(**data))
except ValueError as exc:return self.respond({'error':str(exc)},400) except ValueError as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/voice/start','/api/v1/voice/cancel'):
try:
if self.path.endswith('/cancel'):
if self.read_json():raise ValueError('Keine Abbruchparameter erwartet.')
return self.respond(self.server.voxcpm_tests.stop())
if self.headers.get('Content-Type','').startswith('multipart/form-data'):
fields,audio=read_upload(self,validate=False,allowed_fields=('profile_id','text','device','cfg','steps','seed'),max_field=4096)
fields['reference']=audio
for key in ('steps','seed'):
if key in fields:fields[key]=int(fields[key])
if 'cfg' in fields:fields['cfg']=float(fields['cfg'])
else:fields=self.read_json()
if set(fields)-{'profile_id','text','device','cfg','steps','seed','reference'} or not {'profile_id','text'}<=set(fields):raise ValueError('Ungültige VoxCPM2-Anfrage.')
return self.respond(self.server.voxcpm_tests.start(**fields))
except (ValueError,OSError) 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'): 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: try:
data=self.read_json();t=self.server.tts_tests data=self.read_json();t=self.server.tts_tests
@@ -683,7 +711,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, self.server.tts_tests.lock, self.server.stt.lock: with self.server.image_tests.lock, self.server.tts_tests.lock, self.server.stt.lock, self.server.voxcpm_tests.lock:
voice_job=self.server.voxcpm_tests.job
if voice_job and voice_job.get('state')=='running' and voice_job.get('profile_id')==data.get('id'):raise ValueError('Stimmprofil wird gerade ausgeführt. Zuerst Auftrag abbrechen.')
stt_job=self.server.stt.job stt_job=self.server.stt.job
if stt_job and stt_job.get("state")=="running" and stt_job.get("profile_id")==data.get("id"):raise ValueError("Dieses STT-Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.") if stt_job and stt_job.get("state")=="running" and stt_job.get("profile_id")==data.get("id"):raise ValueError("Dieses STT-Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.")
tts_job=self.server.tts_tests.job tts_job=self.server.tts_tests.job
@@ -696,6 +726,10 @@ class Handler(BaseHTTPRequestHandler):
return self.respond(result) return self.respond(result)
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':'Profil konnte nicht gelöscht werden.'},503) except OSError:return self.respond({'error':'Profil konnte nicht gelöscht werden.'},503)
if self.path == '/api/v1/profiles/preset':
try:return self.respond(self.server.profiles.preset(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Preset konnte nicht gespeichert werden.'},503)
if self.path == '/api/v1/profiles/save': if self.path == '/api/v1/profiles/save':
try:return self.respond(self.server.profiles.save(self.read_json())) try:return self.respond(self.server.profiles.save(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400) except ValueError as exc:return self.respond({'error':str(exc)},400)
@@ -760,7 +794,7 @@ def main():
server.video.close() server.video.close()
server.endpoint.close() server.endpoint.close()
server.stt.stop() server.stt.stop()
server.tts_tests.stop() server.voxcpm_tests.stop();server.tts_tests.stop()
server.tts_runtime.stop();server.voxcpm_runtime.stop() server.tts_runtime.stop();server.voxcpm_runtime.stop()
server.image_runtime.stop() server.image_runtime.stop()
server.ltx_original_runtime.stop() server.ltx_original_runtime.stop()
+2 -2
View File
@@ -22,7 +22,7 @@ def validate_wav(audio):
if len(w.readframes(w.getnframes()))!=w.getnframes()*2:raise ValueError('WAV-Datei ist unvollständig.') 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 except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei.') from None
def read_upload(handler,validate=True,max_audio=MAX_AUDIO): def read_upload(handler,validate=True,max_audio=MAX_AUDIO,allowed_fields=None,max_field=256):
handler.connection.settimeout(30) handler.connection.settimeout(30)
if handler.headers.get('Transfer-Encoding'):raise ValueError('Chunked Upload wird nicht unterstützt.') if handler.headers.get('Transfer-Encoding'):raise ValueError('Chunked Upload wird nicht unterstützt.')
try:length=int(handler.headers.get('Content-Length','0')) try:length=int(handler.headers.get('Content-Length','0'))
@@ -41,7 +41,7 @@ def read_upload(handler,validate=True,max_audio=MAX_AUDIO):
if audio is not None:raise ValueError('Nur eine Audiodatei erlaubt.') if audio is not None:raise ValueError('Nur eine Audiodatei erlaubt.')
audio=data audio=data
else: else:
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.') if name not in (allowed_fields or ('model','profile_id','language','response_format','target')) or name in fields or len(data)>max_field:raise ValueError('Ungültiges oder doppeltes Feld.')
try:fields[name]=data.decode('utf-8') try:fields[name]=data.decode('utf-8')
except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None
if validate:validate_wav(audio) if validate:validate_wav(audio)
+5 -4
View File
@@ -1,17 +1,18 @@
/* Server-backed model management. Legacy browser drafts remain untouched. */ /* Server-backed model management. Legacy browser drafts remain untouched. */
const Studio=(()=>{ const Studio=(()=>{
let section='discover',category=''; let section='discover',category='';
const labels={chat:'Chat & Sprachmodelle',image:'Bildgenerierung',audio:'Sprachausgabe (TTS)',stt:'Spracherkennung (STT)',music:'Musikgenerierung',voice:'Stimmwerkzeuge',video:'Videogenerierung'}; const labels={embeddings:'Embeddings',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.'}; const audioDescriptions={embeddings:'Text in Vektoren umwandeln – für Gedächtnis, Suche und Dokumente. Entdecken, Downloads und Profile sind verfügbar. EmbeddingGemma 300M QAT Q8_0 verwendet die vorhandene llama.cpp-Laufzeit auf CPU. Profil am Endpunkt freigeben und über /v1/embeddings nutzen. Chat bleibt getrennt.',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){ function render(page,force=false,modelId=null){
if(page==='video'){VideoUI.runtime();return;} if(page==='video'){VideoUI.runtime();return;}
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=page==='voxcpm-runtime'?VoxCPMUI.html():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>`; const body=page==='voxcpm-runtime'?VoxCPMUI.html():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','embeddings'].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','voice'].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==='test'&&page==='voice'?VoiceUI.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==='stt-runtime'){STTUI.bind(true);return;} if(page==='stt-runtime'){STTUI.bind(true);return;}
if(page==='voice'&&section==='test'){VoiceUI.bind();return;}
if(page==='voxcpm-runtime'){VoxCPMUI.bind();return;} if(page==='voxcpm-runtime'){VoxCPMUI.bind();return;}
if(page==='tts-runtime'){TTSUI.bind(true);return;} if(page==='tts-runtime'){TTSUI.bind(true);return;}
if(page==='runtime'){RuntimeUI.bind();return;} if(page==='runtime'){RuntimeUI.bind();return;}
@@ -37,7 +38,7 @@ const Studio=(()=>{
['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.'], ['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.'], ['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.'], ['Docker Engine','Weitere Dienste','docker-runtime','/api/v1/docker','Optionale Container-Umgebung. Erstinstallation über den Systemhelfer; vorhandenes Docker wird nicht automatisch aktualisiert.'],
['VoxCPM','Stimmwerkzeuge','voxcpm-runtime','/api/v1/voxcpm-runtime','Originale VoxCPM-Python-Laufzeit installieren. Vorhandene CUDA-Pakete wiederverwenden; Modellkomponenten separat. Deck-Generierungsworker noch nicht angebunden.'], ['VoxCPM','Stimmwerkzeuge','voxcpm-runtime','/api/v1/voxcpm-runtime','Originale VoxCPM-Python-Laufzeit installieren. Vorhandene CUDA-Pakete wiederverwenden; Modellkomponenten separat. Sprachausgabe und Stimmklonen unter Stimmwerkzeuge testen.'],
['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.'] ['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])); const runtimeEscape=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+11
View File
@@ -51,6 +51,17 @@ class RestoreTests(unittest.TestCase):
doc=open_backup(self.server.backup.export('backup secure password'),'backup secure password') 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.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) self.assertEqual(doc['models'][0]['id'],ident);self.assertEqual(len(doc['settings']['profiles.json']),1)
def test_profile_presets_roundtrip_and_invalid_reference_rejected(self):
ident=self.model();self.profile(ident);p=self.server.profiles.rows[0]
saved=self.server.profiles.preset(dict(id=p['id'],revision=p['revision'],action='save',name='Vor dem Test'))
raw=self.server.backup.export('backup secure password');doc=open_backup(raw,'backup secure password');validate(doc)
bad=copy.deepcopy(doc);bad['settings']['profiles.json'][0]['presets'][0]['model_id']='f'*64
with self.assertRaises(ValueError):validate(bad)
self.server.profiles.rows=[];(self.root/'profiles.json').write_text('[]')
summary=self.server.backup.inspect(raw,'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(self.server.profiles.rows[0]['presets'],saved['presets'])
def test_voxcpm_runtime_export_and_restore(self): def test_voxcpm_runtime_export_and_restore(self):
from voxcpm_runtime import VERSION from voxcpm_runtime import VERSION
runtime=Mock();runtime.status.return_value={'installed':True,'version':VERSION,'job':{'state':'complete'}} runtime=Mock();runtime.status.return_value={'installed':True,'version':VERSION,'job':{'state':'complete'}}
+11
View File
@@ -7,6 +7,17 @@ from unittest.mock import patch
from catalog import Catalog, safe_url, repo_id, is_derived_model, runtime_metadata from catalog import Catalog, safe_url, repo_id, is_derived_model, runtime_metadata
class CatalogTests(unittest.TestCase): class CatalogTests(unittest.TestCase):
def test_embeddings_search_has_own_hub_category(self):
from urllib.parse import urlsplit,parse_qs
from profiles import SCHEMAS
from execution_setup import assess
with tempfile.TemporaryDirectory() as d,patch('catalog.metadata',return_value=[]) as fetch:
Catalog(d).search('embedding','embeddings')
self.assertEqual(parse_qs(urlsplit(fetch.call_args.args[0]).query)['filter'],['feature-extraction'])
self.assertIn('embeddings',SCHEMAS)
result=assess(dict(kind='embeddings',repo='owner/model',file='model.gguf'),{})
self.assertNotEqual(result['state'],'ready')
def test_bounded_hub_runtime_metadata(self): def test_bounded_hub_runtime_metadata(self):
hint=runtime_metadata(dict(pipeline_tag='text-to-image',library_name='diffusers',config={'architectures':['NewImagePipeline']},tags=['image-to-image'])) hint=runtime_metadata(dict(pipeline_tag='text-to-image',library_name='diffusers',config={'architectures':['NewImagePipeline']},tags=['image-to-image']))
self.assertEqual(hint['architecture'],'NewImagePipeline') self.assertEqual(hint['architecture'],'NewImagePipeline')
+28
View File
@@ -0,0 +1,28 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from embeddings import Embeddings,REPO,FILE,supported
from profiles import Profiles
from execution_setup import assess
class EmbeddingTests(unittest.TestCase):
def test_recipe_is_exact_and_does_not_invent_components(self):
model=dict(kind='embeddings',repo=REPO,file=FILE,id='a'*64)
self.assertTrue(supported(model));self.assertFalse(supported(dict(model,file='other.gguf')))
with tempfile.TemporaryDirectory() as d:
catalog=Mock();catalog.entry.return_value=model
p=Profiles(Path(d)/'profiles.json',catalog)
p.embedding_blockers=lambda _:[]
result=p.components(model['id'])
self.assertTrue(result['no_components']);self.assertEqual(result['requirements'],[])
self.assertTrue(result['runtime_installed']);catalog.files.assert_not_called()
result=assess(model,{'embeddings':True},dict(runnable=True,blockers=[]))
self.assertEqual(result['state'],'ready');self.assertEqual(result['components'],[])
def test_runtime_failure_is_actionable(self):
with tempfile.TemporaryDirectory() as d:
worker=Mock();worker.build.side_effect=ValueError('Build fehlt')
e=Embeddings(d,Mock(),worker)
self.assertEqual(e.blockers({'model':dict(kind='embeddings',repo=REPO,file=FILE)}),['Build fehlt'])
self.assertTrue(e.blockers({'model':dict(kind='embeddings',repo='unknown/model',file='model.gguf')}))
+19
View File
@@ -103,6 +103,17 @@ 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_embeddings_are_separate_and_work_in_video_mode(self):
self.rows.append(dict(id='embed',name='embeddinggemma',kind='embeddings',revision=1,runnable=True,blockers=[],parameters={},updated_at=1))
self.enable('embed');self.ep.embeddings=Mock()
self.ep.embeddings.generate.return_value={'object':'list','data':[{'index':0,'embedding':[.1,.2]}]}
self.assertEqual(self.request()[1]['data'],[])
self.assertEqual(self.request('/v1/embeddings/models')[1]['data'][0]['id'],'embeddinggemma')
self.ep.scheduler.gpu_mode='video';self.ep.video=Mock()
self.assertEqual(self.request('/v1/embeddings',{'model':'embeddinggemma','input':'synthetic test'})[0],200)
self.assertEqual(self.request('/v1/embeddings',{'model':'embeddinggemma','input':[]})[0],400)
self.assertEqual(self.request('/v1/embeddings',{'model':'embeddinggemma','input':'test'},token='bad')[0],401)
self.ep.video=None
def test_model_lists_separate_modalities(self): def test_model_lists_separate_modalities(self):
self.rows[-1].update(runnable=True,blockers=[]) self.rows[-1].update(runnable=True,blockers=[])
self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={},updated_at=1)) self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={},updated_at=1))
@@ -223,6 +234,14 @@ class EndpointTests(unittest.TestCase):
self.assertEqual(status,200);self.assertEqual(data['model'],name) 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.worker.stopped,['alpha']);self.assertEqual(self.worker.requests[-1]['temperature'],.2)
self.assertEqual(self.ep.status()['counts']['llm']['enabled'],2) self.assertEqual(self.ep.status()['counts']['llm']['enabled'],2)
def test_profile_min_p_and_client_override(self):
self.enable('alpha');self.rows[0]['parameters']['min_p']=0.0
request=dict(model='alpha',messages=[dict(role='user',content='synthetic')])
self.assertEqual(self.request('/v1/chat/completions',request)[0],200)
self.assertEqual(self.worker.requests[-1]['min_p'],0.0)
self.assertEqual(self.request('/v1/chat/completions',dict(request,min_p=.1))[0],200)
self.assertEqual(self.worker.requests[-1]['min_p'],.1)
self.assertEqual(self.request('/v1/chat/completions',dict(request,min_p=-.1))[0],400)
def test_profile_penalties_and_explicit_client_override(self): def test_profile_penalties_and_explicit_client_override(self):
self.enable('alpha') self.enable('alpha')
self.rows[0]['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2) self.rows[0]['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2)
+1 -1
View File
@@ -57,7 +57,7 @@ class ExecutionSetupTests(unittest.TestCase):
result=assess(model,installed) result=assess(model,installed)
self.assertEqual(result['setup_steps'][1]['state'],'complete') self.assertEqual(result['setup_steps'][1]['state'],'complete')
self.assertIn('wiederverwendet',result['setup_steps'][1]['message']) self.assertIn('wiederverwendet',result['setup_steps'][1]['message'])
self.assertEqual(result['setup_steps'][-1]['state'],'blocked') self.assertEqual(result['setup_steps'][-1]['state'],'action')
unknown=self.check('voice','unknown/model','model.safetensors') unknown=self.check('voice','unknown/model','model.safetensors')
self.assertTrue(all(s['state']=='blocked' for s in unknown['setup_steps'])) self.assertTrue(all(s['state']=='blocked' for s in unknown['setup_steps']))
ready=assess(dict(kind='image',repo=QWEN_REPO,file='model.gguf'),{'image':True},dict(runnable=True)) ready=assess(dict(kind='image',repo=QWEN_REPO,file='model.gguf'),{'image':True},dict(runnable=True))
+46
View File
@@ -0,0 +1,46 @@
import json,tempfile,unittest
from pathlib import Path
from profiles import Profiles,SCHEMAS
class Catalog:
def entry(self,ident):
if ident not in ('model','other'):raise ValueError('Datei fehlt')
return dict(id=ident,repo='example/model',kind='image',profile_eligible=True,file='model.safetensors')
class PresetTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.path=Path(self.tmp.name)/'profiles.json';self.p=Profiles(self.path,Catalog())
self.row=self.p.save(dict(id=None,revision=0,name='Image Test',kind='image',model_id='model',parameters={k:v[2] for k,v in SCHEMAS['image'].items()}))
def tearDown(self):self.tmp.cleanup()
def save_preset(self,name='Vorher',revision=None):
return self.p.preset(dict(id=self.row['id'],revision=revision or self.p.rows[0]['revision'],action='save',name=name))
def test_snapshot_survives_edit_and_reload_and_pins_old_model(self):
row=self.save_preset();saved=json.loads(json.dumps(row['presets'][0]))
params=dict(row['parameters'],steps=4)
row=self.p.save(dict(id=row['id'],revision=row['revision'],name=row['name'],kind='image',model_id='other',parameters=params))
self.assertEqual(row['presets'][0],saved);self.assertEqual(saved['parameters']['steps'],25)
self.assertEqual(Profiles(self.path,Catalog()).rows[0]['presets'],[saved])
self.assertTrue(self.p.references('model'))
self.p.preset(dict(id=row['id'],revision=row['revision'],action='delete',preset_id=saved['id']))
self.assertEqual(self.p.references('model'),[])
def test_stale_duplicate_invalid_and_unknown_delete_rejected(self):
row=self.save_preset()
with self.assertRaises(ValueError):self.save_preset('Stale',1)
with self.assertRaises(ValueError):self.save_preset()
with self.assertRaises(ValueError):self.save_preset(' ')
with self.assertRaises(ValueError):self.p.preset(dict(id=row['id'],revision=row['revision'],action='delete',preset_id='missing'))
self.assertEqual(len(self.p.rows[0]['presets']),1)
def test_pending_baseline_survives_legacy_save_and_merges_once(self):
row=self.save_preset();preset=row['presets'][0]
self.path.with_name('profile-presets-pending.json').write_text(json.dumps([dict(profile_id=row['id'],preset=preset)]))
row.pop('presets');self.path.write_text(json.dumps([row]))
loaded=Profiles(self.path,Catalog())
self.assertEqual(loaded.rows[0]['presets'],[preset])
self.assertFalse(self.path.with_name('profile-presets-pending.json').exists())
self.assertEqual(Profiles(self.path,Catalog()).rows[0]['presets'],[preset])
def test_new_duplicate_profile_has_no_presets(self):
self.save_preset()
row=self.p.save(dict(id=None,revision=0,name='Another Image',kind='image',model_id='model',parameters=self.row['parameters']))
self.assertEqual(row['presets'],[])
if __name__=='__main__':unittest.main()
+2 -2
View File
@@ -13,7 +13,7 @@ class VoxCPMComponentsTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
p=Profiles(Path(d)/'profiles.json',c);p.rows=[dict(id='p',revision=1,name='Vox',kind='voice',model_id='main',parameters={})] p=Profiles(Path(d)/'profiles.json',c);p.rows=[dict(id='p',revision=1,name='Vox',kind='voice',model_id='main',parameters={})]
data=p.components('main');c.files.assert_called_once_with(model['repo'],model['revision']) data=p.components('main');c.files.assert_called_once_with(model['repo'],model['revision'])
self.assertEqual(len(data['requirements']),5);self.assertFalse(data['worker_available']);self.assertTrue(all(len(x['available'])==1 for x in data['requirements'])) self.assertEqual(len(data['requirements']),5);self.assertTrue(data['worker_available']);self.assertTrue(all(len(x['available'])==1 for x in data['requirements']))
p.assign(dict(id='p',revision=1,components={x['id']:x['id'] for x in entries})) p.assign(dict(id='p',revision=1,components={x['id']:x['id'] for x in entries}))
status=p.status()['profiles'][0];self.assertFalse(status['runnable']);self.assertEqual(len(status['blockers']),1);self.assertIn('VoxCPM',status['blockers'][0]) status=p.status()['profiles'][0];self.assertFalse(status['runnable']);self.assertEqual(len(status['blockers']),1);self.assertIn('VoxCPM',status['blockers'][0])
entries[0]['revision']='b'*40 entries[0]['revision']='b'*40
@@ -31,4 +31,4 @@ class VoxCPMComponentsTests(unittest.TestCase):
self.assertEqual([f['name'] for f in c.files('other/VoxCPM2')['files']],['model.safetensors']) self.assertEqual([f['name'] for f in c.files('other/VoxCPM2')['files']],['model.safetensors'])
def test_execution_does_not_claim_installed_audio_cpp_can_run_vox(self): def test_execution_does_not_claim_installed_audio_cpp_can_run_vox(self):
result=assess(dict(kind='voice',repo='openbmb/VoxCPM2',file='model.safetensors'),dict(audio_cpp=True)) result=assess(dict(kind='voice',repo='openbmb/VoxCPM2',file='model.safetensors'),dict(audio_cpp=True))
self.assertEqual(result['family'],'VoxCPM2');self.assertEqual(result['state'],'adapter_missing');self.assertEqual(result['candidates'][0]['id'],'voxcpm');self.assertTrue(result['candidates'][0]['installation_available']);self.assertEqual(result['candidates'][0]['route'],'#voxcpm-runtime') self.assertEqual(result['family'],'VoxCPM2');self.assertEqual(result['state'],'setup');self.assertEqual(result['candidates'][0]['id'],'voxcpm');self.assertTrue(result['candidates'][0]['installation_available']);self.assertEqual(result['candidates'][0]['route'],'#voxcpm-runtime')
+1 -1
View File
@@ -8,7 +8,7 @@ class VoxCPMRuntimeTests(unittest.TestCase):
r=VoxCPMRuntime(d,None);self.assertFalse(r.status()['installed']) r=VoxCPMRuntime(d,None);self.assertFalse(r.status()['installed'])
base=Path(d)/('a'*32);(base/'python/bin').mkdir(parents=True);(base/'python/bin/python').touch();(Path(d)/'active.json').write_text(json.dumps(dict(id=base.name))) base=Path(d)/('a'*32);(base/'python/bin').mkdir(parents=True);(base/'python/bin/python').touch();(Path(d)/'active.json').write_text(json.dumps(dict(id=base.name)))
(base/'ready').write_text('wrong');self.assertFalse(r.status()['installed']) (base/'ready').write_text('wrong');self.assertFalse(r.status()['installed'])
(base/'ready').write_text(VERSION);self.assertTrue(r.status()['installed']);self.assertFalse(r.status()['worker_available']) (base/'ready').write_text(VERSION);self.assertTrue(r.status()['installed']);self.assertTrue(r.status()['worker_available'])
def test_success_and_failure_do_not_touch_shared_runtime_or_load_weights(self): def test_success_and_failure_do_not_touch_shared_runtime_or_load_weights(self):
for fail in (False,True): for fail in (False,True):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
+49
View File
@@ -0,0 +1,49 @@
import io,tempfile,unittest,wave
from pathlib import Path
from unittest.mock import Mock,patch
from voxcpm_test import VoxCPMTests,validate_reference
class VoiceTests(unittest.TestCase):
def test_reference_limits(self):
for data in (b'',b'not audio',b'x'*(8*1024**2+1)):
with self.assertRaises(ValueError):validate_reference(data)
def wav(seconds):
b=io.BytesIO()
with wave.open(b,'wb') as w:w.setnchannels(1);w.setsampwidth(2);w.setframerate(16000);w.writeframes(b'\0\0'*int(seconds*16000))
return b.getvalue()
validate_reference(wav(2))
with self.assertRaises(ValueError):validate_reference(wav(31))
def test_not_installed_and_components_reported_separately(self):
profiles=Mock();profiles._component.side_effect=ValueError('missing');runtime=Mock();runtime.status.return_value={'installed':False}
t=VoxCPMTests('/tmp/no-voice',profiles,runtime);p=dict(model=dict(kind='voice',repo='openbmb/VoxCPM2',file='model.safetensors'),components={})
self.assertEqual(len(t.blockers(p)),6)
runtime.status.return_value={'installed':True};self.assertEqual(len(t.blockers(p)),5)
def test_missing_gpu_releases_reservation_and_records_failure(self):
with tempfile.TemporaryDirectory() as d:
p=dict(id='test',model=dict(kind='voice',repo='openbmb/VoxCPM2',file='model.safetensors'),components={})
profiles=Mock();profiles.status.return_value={'profiles':[p]};runtime=Mock();runtime.status.return_value={'installed':True}
t=VoxCPMTests(d,profiles,runtime);release=Mock();t.acquire=Mock(return_value=release)
with patch('voxcpm_test.probe',return_value=[]):
with self.assertRaisesRegex(ValueError,'VRAM'):t.start('test','synthetic test')
release.assert_called_once();self.assertEqual(t.job['state'],'failed')
self.assertEqual(list(Path(d).iterdir()),[])
def test_invalid_options_rejected_before_gpu_reservation(self):
t=VoxCPMTests('/tmp/no-voice',Mock(),Mock());t.acquire=Mock()
for args in (dict(text=''),dict(text='x',device='cpu'),dict(text='x',steps=0),dict(text='x',seed=-1)):
with self.assertRaises(ValueError):t.start('id',**args)
t.acquire.assert_not_called()
def test_worker_completion_failure_and_cancellation_cleanup(self):
for outcome in ('complete','failed','cancelled'):
with tempfile.TemporaryDirectory() as d:
t=VoxCPMTests(d,Mock(),Mock());ident='a'*32;t.job=dict(id=ident,state='running');t.stage=lambda p,root:(root/'model').mkdir();t.runtime.paths.return_value=(Path('/fake-python'),None)
root=Path(d)/ident;release=Mock()
def launch(*args,**kwargs):
(root/'result.json').write_text('{"ok":false,"error":"synthetic OOM"}' if outcome=='failed' else '{"ok":true,"duration":1,"sample_rate":16000}')
if outcome=='complete':
with wave.open(str(root/'result.wav'),'wb') as w:w.setnchannels(1);w.setsampwidth(2);w.setframerate(16000);w.writeframes(b'\0\0'*16000)
process=Mock();process.poll.return_value=0;return process
if outcome=='cancelled':t.cancel.set()
with patch('voxcpm_test.subprocess.Popen',side_effect=launch):t._run_voice({},ident,dict(text='synthetic'),b'reference',{'uuid':'test'},release)
self.assertEqual(t.job['state'],outcome);release.assert_called_once()
self.assertFalse((root/'reference.wav').exists());self.assertFalse((root/'model').exists());self.assertFalse(t.status()['loaded'])
self.assertEqual((root/'complete').exists(),outcome=='complete')
+12
View File
@@ -0,0 +1,12 @@
window.VoiceUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function html(){return `<section id="voice-panel" class="card"><h2>Stimme ausprobieren · VoxCPM2</h2><p>Text erzeugt Sprache. Mit optionaler WAV-Referenz übernimmt VoxCPM2 deren Stimme. Nur eingerichtete VoxCPM2-Profile stehen zur Auswahl.</p><p id="voice-status" role="status"></p><progress id="voice-progress" hidden aria-label="Stimmgenerierung"></progress><p id="voice-error" role="alert"></p><form id="voice-form"><label>Profil<select name="profile_id" id="voice-profile" required></select></label><label>Text<textarea name="text" rows="5" maxlength="1000" required></textarea></label><label>Referenzstimme (optional, PCM-16-WAV, 1–30 Sekunden, bis 8 MiB)<input name="file" type="file" accept="audio/wav,.wav"></label><div class="form-grid"><label>GPU<select name="device"><option value="5080">RTX 5080</option><option value="3060">RTX 3060</option></select></label><label>Guidance<input name="cfg" type="number" min="0.5" max="5" step="0.1" value="2"></label><label>Schritte<input name="steps" type="number" min="1" max="50" value="10"></label><label>Seed<input name="seed" type="number" min="0" max="2147483647" value="42"></label></div><button id="voice-start">Stimme erzeugen</button><button id="voice-cancel" type="button" class="secondary" disabled>Abbrechen</button></form><div id="voice-result"></div><p class="small">Im LLM-Modus verfügbar. Deck entlädt sein LLM vor der Stimmgenerierung und lädt es bei der nächsten Chat-Anfrage wieder. VoxCPM2 wird nach dem Auftrag entladen. Referenzdatei wird nach dem Auftrag gelöscht; Text wird nicht gespeichert.</p></section>`;}
function bind(){const root=document.querySelector('#voice-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let busy=false,lastResult='',ready=false;
async function api(path='',body){const r=await fetch('/api/v1/voice'+path,body===undefined?{}:{method:'POST',headers:body instanceof FormData?{'X-Athena-Deck':'1'}:{'X-Athena-Deck':'1','Content-Type':'application/json'},body:body instanceof FormData?body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw Error(d.error||'Stimmgenerierung fehlgeschlagen');return d;}
async function refresh(){try{const d=await api();if(!root.isConnected)return;const j=d.job;el('voice-status').textContent=(j?.phase||'Profil wählen und Text eingeben.')+(j?.gpu?' · '+j.gpu:'');el('voice-progress').hidden=j?.state!=='running';el('voice-start').disabled=busy||!ready||j?.state==='running';el('voice-cancel').disabled=j?.state!=='running';if(j?.state==='complete'&&j.id!==lastResult){lastResult=j.id;el('voice-result').innerHTML=`<h3>Ergebnis · ${Number(j.duration).toFixed(1)} Sekunden</h3><audio controls src="/api/v1/voice/audio?id=${e(j.id)}"></audio><p><a class="link" download="athena-stimme.wav" href="/api/v1/voice/audio?id=${e(j.id)}">WAV herunterladen</a></p>`;}}catch(err){if(root.isConnected)el('voice-error').textContent=err.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}}
fetch('/api/v1/profiles').then(async r=>{if(!r.ok)throw Error('Profile konnten nicht geladen werden.');return r.json();}).then(d=>{if(!root.isConnected)return;const rows=d.profiles.filter(p=>p.runnable&&p.model?.repo==='openbmb/VoxCPM2');ready=!!rows.length;el('voice-profile').innerHTML=rows.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">VoxCPM2-Profil unter Profile → Einrichtung & Komponenten vervollständigen</option>';}).catch(err=>{if(root.isConnected)el('voice-error').textContent=err.message;});
el('voice-form').onsubmit=async event=>{event.preventDefault();busy=true;el('voice-start').disabled=true;el('voice-error').textContent='';el('voice-status').textContent='Auftrag wird gestartet …';try{const data=new FormData(event.target);if(!data.get('file')?.size)data.delete('file');await api('/start',data);}catch(err){el('voice-error').textContent=err.message;}finally{busy=false;}};
el('voice-cancel').onclick=async()=>{try{await api('/cancel',{});}catch(err){el('voice-error').textContent=err.message;}};refresh();
}
return {html,bind};
})();
+1 -1
View File
@@ -1,6 +1,6 @@
const VoxCPMUI=(()=>{ const VoxCPMUI=(()=>{
const e=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const e=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function html(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>VoxCPM</h1><section class="card" id="voxcpm-panel"><h2>VoxCPM2 einrichten</h2><p>Diese Modellfamilie benötigt die originale VoxCPM-Python-Laufzeit. Eine eigene Umgebung schützt bestehende Laufzeiten; vorhandene CUDA-Pakete werden nach Möglichkeit wiederverwendet.</p><p id="voxcpm-status" role="status">Status wird geprüft …</p><p id="voxcpm-details"></p><progress id="voxcpm-progress" hidden aria-label="Laufzeitinstallation"></progress><div class="card-actions"><button id="voxcpm-install">Passende Laufzeit installieren</button><button id="voxcpm-cancel" class="secondary" disabled>Installation abbrechen</button></div><p class="note">Dieser Schritt installiert die Laufzeit. Er lädt keine Modellgewichte und startet kein Modell. Die Deck-Anbindung für Generierung und Stimmklonen fehlt noch; das Profil bleibt deshalb gesperrt.</p><details><summary>Installationsausgabe</summary><button id="voxcpm-log" class="secondary">Ausgabe aktualisieren</button><pre id="voxcpm-output" style="white-space:pre-wrap"></pre></details><p id="voxcpm-error" role="alert"></p><a class="link" href="#voice">Zurück zu Stimmwerkzeugen →</a></section>`;} function html(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>VoxCPM</h1><section class="card" id="voxcpm-panel"><h2>VoxCPM2 einrichten</h2><p>Diese Modellfamilie benötigt die originale VoxCPM-Python-Laufzeit. Eine eigene Umgebung schützt bestehende Laufzeiten; vorhandene CUDA-Pakete werden nach Möglichkeit wiederverwendet.</p><p id="voxcpm-status" role="status">Status wird geprüft …</p><p id="voxcpm-details"></p><progress id="voxcpm-progress" hidden aria-label="Laufzeitinstallation"></progress><div class="card-actions"><button id="voxcpm-install">Passende Laufzeit installieren</button><button id="voxcpm-cancel" class="secondary" disabled>Installation abbrechen</button></div><p class="note">Dieser Schritt installiert die Laufzeit. Er lädt keine Modellgewichte und startet kein Modell. Nach Zuordnung der Modellkomponenten unter Stimmwerkzeuge → Testen Sprache erzeugen oder eine Referenzstimme verwenden.</p><details><summary>Installationsausgabe</summary><button id="voxcpm-log" class="secondary">Ausgabe aktualisieren</button><pre id="voxcpm-output" style="white-space:pre-wrap"></pre></details><p id="voxcpm-error" role="alert"></p><a class="link" href="#voice">Zurück zu Stimmwerkzeugen →</a></section>`;}
function bind(){const root=document.querySelector('#voxcpm-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let busy=false; function bind(){const root=document.querySelector('#voxcpm-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let busy=false;
async function api(path='',body){const r=await fetch('/api/v1/voxcpm-runtime'+path,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw Error(d.error||'Einrichtung fehlgeschlagen');return d;} async function api(path='',body){const r=await fetch('/api/v1/voxcpm-runtime'+path,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw Error(d.error||'Einrichtung fehlgeschlagen');return d;}
async function refresh(){try{const d=await api();if(!root.isConnected)return;el('voxcpm-status').textContent=d.job?.phase||(d.installed?'VoxCPM-Laufzeit installiert.':'Passende VoxCPM-Laufzeit fehlt.');el('voxcpm-details').textContent=`Version ${d.version} · mindestens ${d.required_disk_gib} GiB freier Speicher · ${d.shared_cuda?'CUDA-Pakete können wiederverwendet werden':'CUDA-Pakete werden mitinstalliert'}`;el('voxcpm-progress').hidden=d.job?.state!=='running';el('voxcpm-install').disabled=busy||d.installed||d.job?.state==='running';el('voxcpm-cancel').disabled=busy||d.job?.state!=='running';}catch(err){if(root.isConnected)el('voxcpm-error').textContent=err.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}} async function refresh(){try{const d=await api();if(!root.isConnected)return;el('voxcpm-status').textContent=d.job?.phase||(d.installed?'VoxCPM-Laufzeit installiert.':'Passende VoxCPM-Laufzeit fehlt.');el('voxcpm-details').textContent=`Version ${d.version} · mindestens ${d.required_disk_gib} GiB freier Speicher · ${d.shared_cuda?'CUDA-Pakete können wiederverwendet werden':'CUDA-Pakete werden mitinstalliert'}`;el('voxcpm-progress').hidden=d.job?.state!=='running';el('voxcpm-install').disabled=busy||d.installed||d.job?.state==='running';el('voxcpm-cancel').disabled=busy||d.job?.state!=='running';}catch(err){if(root.isConnected)el('voxcpm-error').textContent=err.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}}
+2 -2
View File
@@ -15,7 +15,7 @@ class VoxCPMRuntime(LTXOriginalRuntime):
return self.root/'missing-python',self.root/'missing-runtime' return self.root/'missing-python',self.root/'missing-runtime'
def status(self): def status(self):
python,base=self.paths();shared=self.image_runtime.paths()[0].is_file() if self.image_runtime else False python,base=self.paths();shared=self.image_runtime.paths()[0].is_file() if self.image_runtime else False
return dict(installed=python.is_file() and (base/'ready').is_file(),version=VERSION,job=dict(self.job) if self.job else None,required_disk_gib=8 if shared else 25,shared_cuda=shared,worker_available=False,origin='OpenBMB/VoxCPM · offizielles Python-Paket',message='Laufzeitinstallation lädt keine Gewichte. Für die Generierung fehlt noch der Deck-Worker.') return dict(installed=python.is_file() and (base/'ready').is_file(),version=VERSION,job=dict(self.job) if self.job else None,required_disk_gib=8 if shared else 25,shared_cuda=shared,worker_available=True,origin='OpenBMB/VoxCPM · offizielles Python-Paket',message='Laufzeitinstallation lädt keine Gewichte. Generierung und Stimmklonen unter Stimmwerkzeuge → Testen.')
def start(self): def start(self):
if not shutil.which('ffmpeg'):raise ValueError('FFmpeg fehlt. Systemvoraussetzungen über den Deck-Installer einrichten.') if not shutil.which('ffmpeg'):raise ValueError('FFmpeg fehlt. Systemvoraussetzungen über den Deck-Installer einrichten.')
return super().start() return super().start()
@@ -43,7 +43,7 @@ class VoxCPMRuntime(LTXOriginalRuntime):
self._command([python,'-c','import torch,soundfile;from voxcpm import VoxCPM;from voxcpm.model.voxcpm2 import VoxCPM2Model;assert torch.version.cuda;assert callable(VoxCPM.from_pretrained)'],env) self._command([python,'-c','import torch,soundfile;from voxcpm import VoxCPM;from voxcpm.model.voxcpm2 import VoxCPM2Model;assert torch.version.cuda;assert callable(VoxCPM.from_pretrained)'],env)
if self.cancel.is_set():raise InterruptedError() if self.cancel.is_set():raise InterruptedError()
(base/'ready').write_text(VERSION);marker=self.root/'active.tmp';marker.write_text(json.dumps(dict(id=base.name)));marker.replace(self.root/'active.json') (base/'ready').write_text(VERSION);marker=self.root/'active.tmp';marker.write_text(json.dumps(dict(id=base.name)));marker.replace(self.root/'active.json')
state='complete';phase='VoxCPM-Laufzeit installiert · noch kein Deck-Worker und kein Modell geladen' state='complete';phase='VoxCPM-Laufzeit installiert · kein Modell geladen · unter Stimmwerkzeuge testen'
except InterruptedError:state='cancelled';phase='VoxCPM-Einrichtung abgebrochen.' except InterruptedError:state='cancelled';phase='VoxCPM-Einrichtung abgebrochen.'
except Exception as exc:phase=str(exc) except Exception as exc:phase=str(exc)
finally: finally:
+91
View File
@@ -0,0 +1,91 @@
"""VoxCPM2 speech and reference-voice generation, serialized by Deck GPU scheduler."""
import io,json,os,shutil,signal,subprocess,threading,time,uuid,wave
from pathlib import Path
from tts_test import TTSTests
from image_test import probe,cgroup_headroom
from profiles import voice_recipe
WORKER=Path(__file__).with_name('voxcpm_worker.py')
def validate_reference(audio):
if not isinstance(audio,bytes) or not 0<len(audio)<=8*1024**2:raise ValueError('Referenz als WAV bis 8 MiB hochladen.')
try:
with wave.open(io.BytesIO(audio)) as w:
if w.getnchannels() not in (1,2) or w.getsampwidth()!=2 or not 8000<=w.getframerate()<=96000 or not 1<=w.getnframes()/w.getframerate()<=30:raise ValueError('Referenz: PCM-16-WAV, mono/stereo, 1–30 Sekunden erforderlich.')
except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei. PCM-16-WAV verwenden.') from None
class VoxCPMTests(TTSTests):
def blockers(self,p):
if not voice_recipe(p['model']):return ['Diese Stimm-Modellfamilie wird noch nicht unterstützt.']
errors=[]
if not self.runtime.status()['installed']:errors.append('VoxCPM-Laufzeit unter Laufzeiten → VoxCPM installieren.')
for role,info in voice_recipe(p['model']).items():
try:self.profiles._component(p['model'],role,p.get('components',{}).get(role))
except ValueError:errors.append(info['label']+' fehlt oder ist noch nicht zugeordnet. Unter Einrichtung & Komponenten ergänzen.')
return errors
def stage(self,p,root):
model=root/'model';model.mkdir();entries=[p['model']]+[self.profiles._component(p['model'],r,p.get('components',{}).get(r)) for r in voice_recipe(p['model'])]
for entry in entries:
src=self.profiles.catalog.root/entry['id']/('model'+Path(entry['file']).suffix)
(model/Path(entry['file']).name).symlink_to(src.resolve())
def start(self,profile_id,text,device='5080',cfg=2.0,steps=10,seed=42,reference=None,wait=False):
if not isinstance(text,str) or not 1<=len(text.strip())<=1000:raise ValueError('Text mit 1–1000 Zeichen erforderlich.')
if device not in ('5080','3060'):raise ValueError('RTX 5080 oder RTX 3060 wählen.')
if type(cfg) not in (int,float) or not .5<=cfg<=5 or type(steps)!=int or not 1<=steps<=50 or type(seed)!=int or not 0<=seed<=2147483647:raise ValueError('Ungültige Guidance, Schritte oder Seed.')
if reference is not None:validate_reference(reference)
p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id),None)
if not p or not p['model'] or not voice_recipe(p['model']):raise ValueError('VoxCPM2-Profil nicht gefunden.')
errors=self.blockers(p)
if errors:raise ValueError(' '.join(errors))
# Claim local job before scheduler admission to prevent races between two callers.
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Stimmgenerierung läuft bereits.')
self.cancel.clear();ident=uuid.uuid4().hex;self.job=dict(id=ident,state='running',phase='GPU wird reserviert',profile_id=profile_id,started_at=time.time())
release=None
try:
release=self.acquire(wait)
gpu=next((g for g in probe() if 'RTX '+device in g['name'] and not g['processes'] and g['free_mib']>=6500),None)
if not gpu:raise ValueError('RTX '+device+' benötigt mindestens 6,5 GiB freien VRAM und darf nicht fremd belegt sein.')
headroom=cgroup_headroom()
if headroom is not None and headroom<8*1024**3:raise ValueError('Mindestens 8 GiB freier Deck-Systemspeicher erforderlich.')
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
self.job.update(phase='VoxCPM2 wird geladen',gpu=gpu['name']);self.gpu_uuid=gpu['uuid']
threading.Thread(target=self._run_voice,args=(p,ident,dict(text=text,cfg=cfg,steps=steps,seed=seed),reference,gpu,release),daemon=True).start();return dict(self.job)
except Exception as exc:
if release:release()
with self.lock:self.job.update(state='failed',phase=str(exc),finished_at=time.time())
raise
def _run_voice(self,p,ident,request,reference,gpu,release):
root=self.root/ident;state='failed';phase='VoxCPM2-Ausführung fehlgeschlagen'
try:
root.mkdir(mode=0o700);self.stage(p,root)
if reference is not None:(root/'reference.wav').write_bytes(reference)
env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','LLAMA_','DECK_'))};env.update(CUDA_VISIBLE_DEVICES=gpu['uuid'],HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='4',HOME=str(root))
with self.lock:
if self.cancel.is_set():raise InterruptedError()
self.process=subprocess.Popen([str(self.runtime.paths()[0]),str(WORKER),str(root)],stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env,start_new_session=True,text=True)
process=self.process
process.stdin.write(json.dumps(request));process.stdin.close();process.stdin=None;deadline=time.monotonic()+600
while process.poll() is None:
if self.cancel.wait(.25):raise InterruptedError()
if time.monotonic()>deadline:raise ValueError('Zeitlimit für VoxCPM2 erreicht.')
if (root/'phase').exists():
with self.lock:self.job['phase']=(root/'phase').read_text()
if self.cancel.is_set():raise InterruptedError()
if not (root/'result.json').exists():raise ValueError('VoxCPM2-Worker beendet ohne Ergebnis. Speicher oder Laufzeit prüfen.')
result=json.loads((root/'result.json').read_text())
if not result['ok']:raise ValueError(result['error'])
output=root/'result.wav'
with wave.open(str(output)) as w:
if w.getnframes()==0 or output.stat().st_size>32*1024**2:raise ValueError('Ungültige Audioausgabe.')
state='complete';phase='Stimme fertig · Modell entladen'
with self.lock:self.job.update(duration=result['duration'],sample_rate=result['sample_rate'])
except InterruptedError:state='cancelled';phase='Stimmgenerierung abgebrochen · Modell entladen'
except Exception as exc:
if self.cancel.is_set():state='cancelled';phase='Stimmgenerierung abgebrochen · Modell entladen'
else:phase=str(exc) if isinstance(exc,ValueError) else 'VoxCPM2-Ausführung fehlgeschlagen ('+type(exc).__name__+').'
finally:
self.unload_idle();shutil.rmtree(root/'model',ignore_errors=True)
for name in ('reference.wav','phase'):(root/name).unlink(missing_ok=True)
if state=='complete':(root/'complete').touch()
with self.lock:self.job.update(state=state,phase=phase,finished_at=time.time())
release()
+23
View File
@@ -0,0 +1,23 @@
"""Offline one-request VoxCPM2 worker. Never persists prompt text or logs media."""
import contextlib,json,sys
from pathlib import Path
def main():
root=Path(sys.argv[1]);request=json.loads(sys.stdin.read());phase='Modell laden'
try:
# Libraries print progress and occasionally text; discard rather than record it.
with open('/dev/null','w') as sink,contextlib.redirect_stdout(sink),contextlib.redirect_stderr(sink):
import torch,soundfile as sf
from voxcpm import VoxCPM
model=VoxCPM.from_pretrained(str(root/'model'),load_denoiser=False,local_files_only=True,optimize=False,device='cuda:0')
(root/'phase').write_text('Stimme wird erzeugt');phase='Stimme erzeugen'
wave=model.generate(text=request['text'],reference_wav_path=str(root/'reference.wav') if (root/'reference.wav').exists() else None,cfg_value=request['cfg'],inference_timesteps=request['steps'],max_len=2048,normalize=False,denoise=False,seed=request['seed'],retry_badcase_max_times=1)
rate=model.tts_model.sample_rate
sf.write(root/'result.wav',wave,rate,subtype='PCM_16')
result=dict(ok=True,sample_rate=rate,duration=len(wave)/rate)
except Exception as exc:
oom='outofmemory' in type(exc).__name__.lower() or 'out of memory' in str(exc).lower()
result=dict(ok=False,error='GPU-Speicher reicht nicht aus. Andere Deck-Modelle entladen oder andere GPU wählen.' if oom else 'VoxCPM2: '+phase+' fehlgeschlagen ('+type(exc).__name__+'). Laufzeit und Komponenten prüfen.')
(root/'result.json').write_text(json.dumps(result))
return 0 if result['ok'] else 1
if __name__=='__main__':sys.exit(main())