From a7b30b62d24b9e8ecdec8e0e5c47529fe12c4937 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:42:29 +0200 Subject: [PATCH] Add profile presets and min-p controls, embeddings and VoxCPM workers --- README.md | 27 ++++++++++- VOXCPM2_COMPONENTS.md | 36 ++++++++++++-- app.js | 2 +- backup.py | 16 +++++++ catalog.py | 2 +- deploy/Dockerfile | 4 +- deploy/Dockerfile.app-update | 4 +- deploy/install.py | 4 +- embeddings.py | 73 +++++++++++++++++++++++++++++ endpoint.py | 53 +++++++++++++++------ execution_setup.py | 14 ++++-- index.html | 2 +- inference.py | 2 +- profiles-ui.js | 23 ++++++--- profiles.py | 56 ++++++++++++++++++---- server.py | 44 +++++++++++++++-- stt.py | 4 +- studio.js | 9 ++-- test_backup.py | 11 +++++ test_catalog.py | 11 +++++ test_embeddings.py | 28 +++++++++++ test_endpoint.py | 19 ++++++++ test_execution_setup.py | 2 +- test_profile_presets.py | 46 ++++++++++++++++++ test_voxcpm_components.py | 4 +- test_voxcpm_runtime.py | 2 +- test_voxcpm_worker.py | 49 +++++++++++++++++++ voice-ui.js | 12 +++++ voxcpm-ui.js | 2 +- voxcpm_runtime.py | 4 +- voxcpm_test.py | 91 ++++++++++++++++++++++++++++++++++++ voxcpm_worker.py | 23 +++++++++ 32 files changed, 615 insertions(+), 64 deletions(-) create mode 100644 embeddings.py create mode 100644 test_embeddings.py create mode 100644 test_profile_presets.py create mode 100644 test_voxcpm_worker.py create mode 100644 voice-ui.js create mode 100644 voxcpm_test.py create mode 100644 voxcpm_worker.py diff --git a/README.md b/README.md index 6e3953a..3800db1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ die frühere separate Hardware-Seite wurde entfernt. ## Navigation und Detailansichten Die **Übersicht** enthält Dashboard und Steuerung (Endpunkt und GPU-Modi). -**KI-Werkzeuge** trennt Chat & Sprachmodelle, Bildgenerierung, Musikgenerierung, +**KI-Werkzeuge** trennt Chat & Sprachmodelle, Embeddings, Bildgenerierung, Musikgenerierung, Sprachausgabe, Spracherkennung, Stimmwerkzeuge und Videogenerierung. 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 +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), 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 Varianten ist eine bewusste Einzelwahl nötig; ungeprüfte Quellen werden nicht 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. diff --git a/VOXCPM2_COMPONENTS.md b/VOXCPM2_COMPONENTS.md index 928eb1d..e3d32b6 100644 --- a/VOXCPM2_COMPONENTS.md +++ b/VOXCPM2_COMPONENTS.md @@ -16,8 +16,8 @@ nicht Bestandteil dieses Grundpakets. 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) -geprüft. Es ist keine Zusage getesteter Inferenz: **Der Deck-Generierungsworker ist noch nicht angebunden.** Eine vorhandene audio.cpp- oder -llama.cpp-Installation ersetzt diese Anbindung nicht. Das Profil bleibt gesperrt. +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. 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 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`. Backup exportiert den Installationswunsch und die Version; Restore installiert -diese Laufzeit erneut. **Eine installierte Laufzeit schaltet das Profil noch -nicht frei: Der Generierungsworker muss zusätzlich angebunden werden.** +diese Laufzeit erneut. Eine installierte Laufzeit allein genügt nicht: Die fünf Komponenten müssen dem Profil zugeordnet sein. + +## 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=`, `input=`, `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. diff --git a/app.js b/app.js index c91b649..8392895 100644 --- a/app.js +++ b/app.js @@ -28,7 +28,7 @@ if(['separator-runtime','separator-test','separation'].includes(page)){Separator if(page==='audio-cpp-runtime'){AudioCppUI.render();return;} if(page==='ltx-original-runtime'){LTXOriginalUI.render();return;} if(['video','video-runtime'].includes(page)){VideoUI.runtime();return;} -if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','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==='access'){await accessPage();return;} if(page==='appearance'){appearancePage();return;} diff --git a/backup.py b/backup.py index 24e433d..cf13207 100644 --- a/backup.py +++ b/backup.py @@ -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.') 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.') + 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) settings=doc.get('settings') if not isinstance(settings,dict) or set(settings)-set(SETTINGS):raise ValueError('Unbekannte Einstellungsdateien.') diff --git a/catalog.py b/catalog.py index 07a223c..71ac9bd 100644 --- a/catalog.py +++ b/catalog.py @@ -12,7 +12,7 @@ import urllib.parse import urllib.request 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): p=urllib.parse.urlsplit(url) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 21856d6..202f404 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -16,8 +16,8 @@ WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock COPY deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock -COPY 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 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 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 network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ diff --git a/deploy/Dockerfile.app-update b/deploy/Dockerfile.app-update index 9338fe3..667fdb8 100644 --- a/deploy/Dockerfile.app-update +++ b/deploy/Dockerfile.app-update @@ -6,8 +6,8 @@ WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock COPY deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock -COPY 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 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 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 network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ diff --git a/deploy/install.py b/deploy/install.py index 62c2699..d8f109d 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent 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): @@ -206,7 +206,7 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') stamp=str(time.time_ns()) - shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('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'] if was_running:run('docker','stop','--time','15',config['name']) run('docker','rename',config['name'],backup_name) diff --git a/embeddings.py b/embeddings.py new file mode 100644 index 0000000..99db79b --- /dev/null +++ b/embeddings.py @@ -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()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() diff --git a/endpoint.py b/endpoint.py index 7d4c147..3f76f21 100644 --- a/endpoint.py +++ b/endpoint.py @@ -40,7 +40,7 @@ def chat_upstream_error(status, raw): class Endpoint: def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port): - self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.stt=None;self.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.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) @@ -126,6 +126,7 @@ class Endpoint: with self.lock:pending=self.inflight if not pending and self.scheduler.unload_idle():break time.sleep(.1) + if self.embeddings:self.embeddings.stop() with self.lock:self.http=None;self.thread=None;self.state='stopped' def close(self): # Process shutdown does not change the user's autostart preference. @@ -133,6 +134,8 @@ class Endpoint: if http:http.shutdown();http.server_close() if self.music:self.music.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() self.images.stop();self.worker.stop() def allowed(self): @@ -203,8 +206,8 @@ class APIHandler(BaseHTTPRequestHandler): with ep.lock: if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping') ep.inflight+=1;admitted=True - if ep.scheduler.gpu_mode=='separator':raise APIError('Audio-Trennung aktiv. Für Chat, Bilder oder Musik die Betriebsart wechseln.',503,'separator_mode_active') - if ep.video and ep.scheduler.gpu_mode not in ('llm','music'): + if ep.scheduler.gpu_mode=='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') and self.path not in ('/v1/embeddings','/v1/embeddings/models'): if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching') video=ep.video.status() if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready') @@ -214,14 +217,17 @@ class APIHandler(BaseHTTPRequestHandler): if self.command=='GET' and self.path=='/v1/capabilities': from api_compat import 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'} - if self.command=='GET' and self.path in model_routes:return self.send(ep.model_list(model_routes[self.path])) + 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: + 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.video_route(ep):return 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=='music' and self.path!='/v1/audio/music':raise APIError('Musik-Modus aktiv. Für andere KI-Aufträge auf LLM wechseln.',503,'music_mode_active') - if ep.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active') + if 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 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 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/images/edits': from image_upload import read_image_upload @@ -232,7 +238,7 @@ class APIHandler(BaseHTTPRequestHandler): fields['n']=int(fields.get('n','1')) return self.image(ep,fields,images) except ValueError as exc:raise APIError(str(exc)) from None - if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech','/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.') try:length=int(self.headers.get('Content-Length','0')) 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())) except (ValueError,UnicodeError):raise APIError('Ungültiges JSON.') from None 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/audio/music':return self.music_generation(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 set(data)-{'model','input','voice','response_format','speed','language'}:raise APIError('Nicht unterstützte TTS-Felder.') if data.get('response_format','wav')!='wav':raise APIError('Derzeit wird nur WAV unterstützt; response_format=wav setzen.') - profile=ep.find_profile(data.get('model'),'audio') - try:job=ep.tts.start(profile['id'],data.get('input'),speaker=data.get('voice','Ryan'),language=data.get('language','Auto'),speed=data.get('speed'),wait=True) - except ValueError as exc:raise APIError(str(exc)) from None + from profiles import voice_recipe + 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 end=time.monotonic()+620 while time.monotonic()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): 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) diff --git a/execution_setup.py b/execution_setup.py index 0d3181e..265da62 100644 --- a/execution_setup.py +++ b/execution_setup.py @@ -12,6 +12,13 @@ def _assess(model, installed, profile=None): 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.') 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): 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)']) @@ -22,7 +29,7 @@ def _assess(model, installed, profile=None): result['family']='Qwen3-TTS CustomVoice' result.update(runtime='Qwen3-TTS CustomVoice',route='#tts-runtime',components=['Vollständiges Modellpaket und Sprach-Tokenizer (im Einrichtungsassistenten enthalten)']) 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'): 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')): @@ -59,8 +66,9 @@ def _assess_candidates(model, installed, profile=None): def add(id,label,key,route,reason,maintained=True): result['candidates'].append(dict(id=id,label=label,installed=bool(installed.get(key)),route=route,installation_available=maintained,evidence=reason)) if 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.' - add('voxcpm','VoxCPM · Python/PyTorch','voxcpm','#voxcpm-runtime','Originale VoxCPM-Laufzeit · GUI-Installer verfügbar; Generierungsworker noch nicht angebunden',True) + 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 · 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'): 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'): diff --git a/index.html b/index.html index 80671d9..865007c 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
+Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
diff --git a/inference.py b/inference.py index 3b509a7..844d348 100644 --- a/inference.py +++ b/inference.py @@ -240,7 +240,7 @@ class LlamaWorker: if cancel():raise InferenceError('Modellstart abgebrochen.') if plan_only:return 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'] 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] diff --git a/profiles-ui.js b/profiles-ui.js index 79c312c..f9883c9 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -1,6 +1,6 @@ window.ProfilesUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=()=>'

Deine Profile

Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.

'; 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){ @@ -8,15 +8,25 @@ window.ProfilesUI=(()=>{ 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; const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId)); - el('profile-list').innerHTML=rows.map(p=>`
${e(p.name)}${e(p.model?.file||'Modelldatei nicht verfügbar')}${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}

${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(' · ')}

${kind==='image'?`

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')}

`:''}${CatalogUI.executionHTML(p.execution)}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; - el('profile-list').insertAdjacentHTML('afterbegin',`

${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.

`); + el('profile-list').innerHTML=rows.map(p=>`
${e(p.name)}${e(p.model?.file||'Modelldatei nicht verfügbar')}${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}

${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(' · ')}

${kind==='image'?`

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')}

`:''}${CatalogUI.executionHTML(p.execution)}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + el('profile-list').insertAdjacentHTML('afterbegin',`

${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.

`); root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Steuerung lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}}); root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}}); root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components))); root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers))); + 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-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);}} + async function presets(profile){ + panelSequence++; + el('profile-editor').innerHTML=`

Presets · ${e(profile.name)}

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.

${(profile.presets||[]).map(x=>`
${e(x.name)}${e(new Date(x.created_at*1000).toLocaleString())}
`).join('')||'

Noch keine Presets gespeichert.

'}
`; + 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){ const sequence=++panelSequence,selected=profile.prompt_enhancer||{t2i:null,i2i:null,device:'auto'}; el('profile-editor').innerHTML=`

Prompt-Aufwerter · ${e(profile.name)}

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.

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.

Offizielle Modelle installieren

`; @@ -38,13 +48,14 @@ window.ProfilesUI=(()=>{ try{ 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=`

Komponenten · ${e(profile.name)}

${CatalogUI.executionHTML(profile.execution)}

${e(data.message)}

Andere Komponentenquelle auswählen (ungeprüft)
${data.supported?`

Quelle: Modellkarte und Lizenz ↗ · ${e(data.license||'Lizenz nicht angegeben')}

${data.runtime_route?`

${data.runtime_installed?'Laufzeit ansehen':'Passende Laufzeit installieren'} →

`:''}

Laufzeit: ${e(data.runtime)} · ${data.manual?'Zuordnung ungeprüft':data.runtime_installed?'installiert':'noch nicht eingerichtet'}

${!data.manual?'':'

Nur benötigte Dateien einzeln auswählen; diese Liste ist kein automatischer Downloadplan.

'}
${data.requirements.map(r=>`

${e(r.label)}

${r.candidates.map((f,i)=>`

${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}

${f.gated&&!f.auth_configured?'

Hugging-Face-Zugang unter Einstellungen → Zugang hinterlegen und Modellfreigabe prüfen.

':''}
`).join('')||'

Keine passende Quelldatei verfügbar.

'}
`).join('')}

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.

`:''}

`; + 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)||[]; if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{ const b=el('components-download-all');b.disabled=true;let count=0; try{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;} };} - 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-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;}}); @@ -135,7 +146,7 @@ window.ProfilesUI=(()=>{ panelSequence++; if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))}; if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;} - el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='video'?videoDevices(data):''}${kind==='chat'?`

Penalties

${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return ``;}).join('')}
`+gpuEditor(data.parameters)+`

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.

KV-Cache

${["k","v"].map(axis=>``).join("")}

q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.

Vision-Projektor

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.

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.

`:''}

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.':''}

`; + el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='video'?videoDevices(data):''}${kind==='chat'?`

Penalties

${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return ``;}).join('')}
`+gpuEditor(data.parameters)+`

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.

KV-Cache

${["k","v"].map(axis=>``).join("")}

q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.

Vision-Projektor

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.

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.

`:''}

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.':''}

`; if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));} if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();} if(kind==='video')bindVideoDevices(data); diff --git a/profiles.py b/profiles.py index 41ead60..3391d05 100644 --- a/profiles.py +++ b/profiles.py @@ -8,10 +8,10 @@ from pathlib import Path from catalog import file_role SCHEMAS={ - 'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40),'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)}, '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_DEVICE_DEFAULTS={'video_device':'auto','text_encoder_device':'same'} @@ -26,8 +26,8 @@ def video_parameters(params): return params CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu','cache_type_k':'q4_0','cache_type_v':'q4_0'} -FLOAT_PARAMETERS=frozenset({'guidance','speed','temperature','top_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'}) -CHAT_GENERATION_DEFAULTS={'temperature':.8,'top_p':.95,'top_k':40,'repeat_penalty':1.0,'presence_penalty':0.0,'frequency_penalty':0.0} +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,'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} def generation_parameters(params): @@ -145,6 +145,17 @@ class Profiles: self.prompt_enhancer_ready=lambda task:False 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 [] + # 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): with self.lock: rows=json.loads(json.dumps(self.rows)) @@ -156,6 +167,7 @@ class Profiles: p['model']=self.catalog.entry(p['model_id']) if p['kind']=='image':p['capabilities']=image_capabilities(p['model']) p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.'] + if p['kind']=='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']): 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())): @@ -173,7 +185,7 @@ class Profiles: try:self._component(p['model'],role,p.get('components',{}).get(role)) except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.') if p.get('model') and 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(): try:self._component(p['model'],role,p.get('components',{}).get(role)) 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']=='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']=='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']=='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': @@ -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 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 {} - 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] self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700) temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows return row + 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): with self.lock: 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')) - 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): 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 def components(self,model_id,extra_repo=None): 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) 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(): @@ -277,7 +317,7 @@ class Profiles: except ValueError:pass candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name] requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates)) - if 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 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.') diff --git a/server.py b/server.py index 3ea9d7e..ef78990 100644 --- a/server.py +++ b/server.py @@ -14,6 +14,7 @@ from tts_runtime import TTSRuntime from stt import STT,read_upload from audio_policy import AudioPolicy from tts_test import TTSTests +from voxcpm_test import VoxCPMTests from profiles import Profiles from capacity import assess, overview from runtime import Runtime @@ -104,8 +105,14 @@ class Server(ThreadingHTTPServer): raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.') self.worker=LlamaWorker(self.catalog.root.parent/'llama-worker',self.catalog,self.runtime) self.scheduler=Scheduler(self.worker) + self.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_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.image_tests.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.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.embeddings=self.embeddings self.endpoint.tts=self.tts_tests + self.endpoint.voxcpm=self.voxcpm_tests self.endpoint.stt=self.stt self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler) self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler) def stop_gpu_work(): - self.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 ltx_original_runtime import LTXOriginalRuntime from audio_cpp_runtime import AudioCppRuntime @@ -306,7 +315,7 @@ class Handler(BaseHTTPRequestHandler): video_runtime = (self.server.video.original_runtime if self.server.video.runtime_kind() == 'original' 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), 'audio':self.server.tts_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) if not self.authenticated() and self.path == '/': 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: name, mime = routes[self.path] 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/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/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/audio-policy':return self.respond(self.server.audio_policy.status()) 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.') return self.respond(self.server.music.start(**data)) except ValueError as exc:return self.respond({'error':str(exc)},400) + if self.path in ('/api/v1/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'): try: data=self.read_json();t=self.server.tts_tests @@ -683,7 +711,9 @@ class Handler(BaseHTTPRequestHandler): if self.path == '/api/v1/profiles/delete': try: 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 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 @@ -696,6 +726,10 @@ class Handler(BaseHTTPRequestHandler): return self.respond(result) except ValueError as exc:return self.respond({'error':str(exc)},400) 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': try:return self.respond(self.server.profiles.save(self.read_json())) except ValueError as exc:return self.respond({'error':str(exc)},400) @@ -760,7 +794,7 @@ def main(): server.video.close() server.endpoint.close() server.stt.stop() - server.tts_tests.stop() + server.voxcpm_tests.stop();server.tts_tests.stop() server.tts_runtime.stop();server.voxcpm_runtime.stop() server.image_runtime.stop() server.ltx_original_runtime.stop() diff --git a/stt.py b/stt.py index 9d93d59..ec2400c 100644 --- a/stt.py +++ b/stt.py @@ -22,7 +22,7 @@ def validate_wav(audio): if len(w.readframes(w.getnframes()))!=w.getnframes()*2:raise ValueError('WAV-Datei ist unvollständig.') except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei.') from None -def read_upload(handler,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) if handler.headers.get('Transfer-Encoding'):raise ValueError('Chunked Upload wird nicht unterstützt.') 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.') audio=data 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') except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None if validate:validate_wav(audio) diff --git a/studio.js b/studio.js index db29cd1..8088505 100644 --- a/studio.js +++ b/studio.js @@ -1,17 +1,18 @@ /* Server-backed model management. Legacy browser drafts remain untouched. */ const Studio=(()=>{ let section='discover',category=''; - const labels={chat:'Chat & Sprachmodelle',image:'Bildgenerierung',audio:'Sprachausgabe (TTS)',stt:'Spracherkennung (STT)',music:'Musikgenerierung',voice:'Stimmwerkzeuge',video:'Videogenerierung'}; - const audioDescriptions={audio:'Geschriebenen Text vorlesen lassen. Die Suche zeigt Text-to-Speech-Modelle.',stt:'Gesprochene Sprache in Text umwandeln. Die Suche zeigt Spracherkennungsmodelle.',music:'Musik und Klänge aus Text erzeugen. Die Hub-Kategorie Text-to-Audio umfasst neben Musik auch Geräusche und andere Audioerzeugung.',voice:'Vorhandenes Audio bearbeiten: Stimmumwandlung, Audio-Trennung und weitere Audio-to-Audio-Modelle. Eigenständige Studio-Oberflächen gehören unter Weitere Dienste.'}; + const labels={embeddings:'Embeddings',chat:'Chat & Sprachmodelle',image:'Bildgenerierung',audio:'Sprachausgabe (TTS)',stt:'Spracherkennung (STT)',music:'Musikgenerierung',voice:'Stimmwerkzeuge',video:'Videogenerierung'}; + 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){ if(page==='video'){VideoUI.runtime();return;} if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(category!==page){category=page;section='discover';} if(modelId)section='profiles'; - const body=page==='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():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}

${page==='voice'?'

Audio Separator einrichten → · Audio-Trennung →

Audio-Trennung nutzt die unterstützten Pakete im Audio-Separator-Bereich. Andere Hub-Dateien und Voice-Profile benötigen weiterhin eine geprüfte Anbindung.

':''}${audioDescriptions[page]&&!['audio','stt','music','voice'].includes(page)?'

Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.

':''}
${[['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])=>``).join('')}
${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'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; + 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():`
ATHENA / MODELLVERWALTUNG

${labels[page]}

${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}

${page==='voice'?'

Audio Separator einrichten → · Audio-Trennung →

Audio-Trennung nutzt die unterstützten Pakete im Audio-Separator-Bereich. Andere Hub-Dateien und Voice-Profile benötigen weiterhin eine geprüfte Anbindung.

':''}${audioDescriptions[page]&&!['audio','stt','music','voice','embeddings'].includes(page)?'

Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.

':''}
${[['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])=>``).join('')}
${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'?'

Keine von Deck gestarteten Modelle

Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.

llama.cpp-Builds verwalten →
':CatalogUI.html(section==='library',section==='downloads')}
`; document.querySelector('#view').innerHTML=`
${body}
`; if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;} if(page==='stt-runtime'){STTUI.bind(true);return;} + if(page==='voice'&§ion==='test'){VoiceUI.bind();return;} if(page==='voxcpm-runtime'){VoxCPMUI.bind();return;} if(page==='tts-runtime'){TTSUI.bind(true);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.'], ['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.'], - ['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.'] ]; const runtimeEscape=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); diff --git a/test_backup.py b/test_backup.py index cad34f1..aec0a14 100644 --- a/test_backup.py +++ b/test_backup.py @@ -51,6 +51,17 @@ class RestoreTests(unittest.TestCase): doc=open_backup(self.server.backup.export('backup secure password'),'backup secure password') self.assertNotIn('PRIVATE-CHAT-LOG',json.dumps(doc));self.assertNotIn('model-weights-not-in-backup',json.dumps(doc));self.assertIn('history',doc);validate(doc) self.assertEqual(doc['models'][0]['id'],ident);self.assertEqual(len(doc['settings']['profiles.json']),1) + def test_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): from voxcpm_runtime import VERSION runtime=Mock();runtime.status.return_value={'installed':True,'version':VERSION,'job':{'state':'complete'}} diff --git a/test_catalog.py b/test_catalog.py index 20173a9..ff33e9d 100644 --- a/test_catalog.py +++ b/test_catalog.py @@ -7,6 +7,17 @@ from unittest.mock import patch from catalog import Catalog, safe_url, repo_id, is_derived_model, runtime_metadata 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): hint=runtime_metadata(dict(pipeline_tag='text-to-image',library_name='diffusers',config={'architectures':['NewImagePipeline']},tags=['image-to-image'])) self.assertEqual(hint['architecture'],'NewImagePipeline') diff --git a/test_embeddings.py b/test_embeddings.py new file mode 100644 index 0000000..3d0df00 --- /dev/null +++ b/test_embeddings.py @@ -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')})) diff --git a/test_endpoint.py b/test_endpoint.py index c3adfdd..b2a12c8 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -103,6 +103,17 @@ class EndpointTests(unittest.TestCase): with self.assertRaises(ValueError):self.enable('audio') 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) + 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): self.rows[-1].update(runnable=True,blockers=[]) 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(self.worker.stopped,['alpha']);self.assertEqual(self.worker.requests[-1]['temperature'],.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): self.enable('alpha') self.rows[0]['parameters'].update(repeat_penalty=1.15,presence_penalty=.5,frequency_penalty=.2) diff --git a/test_execution_setup.py b/test_execution_setup.py index d43f5f2..c977acd 100644 --- a/test_execution_setup.py +++ b/test_execution_setup.py @@ -57,7 +57,7 @@ class ExecutionSetupTests(unittest.TestCase): result=assess(model,installed) self.assertEqual(result['setup_steps'][1]['state'],'complete') 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') 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)) diff --git a/test_profile_presets.py b/test_profile_presets.py new file mode 100644 index 0000000..0b3a904 --- /dev/null +++ b/test_profile_presets.py @@ -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() diff --git a/test_voxcpm_components.py b/test_voxcpm_components.py index d27330a..91c2d49 100644 --- a/test_voxcpm_components.py +++ b/test_voxcpm_components.py @@ -13,7 +13,7 @@ class VoxCPMComponentsTests(unittest.TestCase): 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={})] 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})) 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 @@ -31,4 +31,4 @@ class VoxCPMComponentsTests(unittest.TestCase): 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): 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') diff --git a/test_voxcpm_runtime.py b/test_voxcpm_runtime.py index be19b12..681c897 100644 --- a/test_voxcpm_runtime.py +++ b/test_voxcpm_runtime.py @@ -8,7 +8,7 @@ class VoxCPMRuntimeTests(unittest.TestCase): 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/'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): for fail in (False,True): with tempfile.TemporaryDirectory() as d: diff --git a/test_voxcpm_worker.py b/test_voxcpm_worker.py new file mode 100644 index 0000000..430d5d2 --- /dev/null +++ b/test_voxcpm_worker.py @@ -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') diff --git a/voice-ui.js b/voice-ui.js new file mode 100644 index 0000000..3a09e1a --- /dev/null +++ b/voice-ui.js @@ -0,0 +1,12 @@ +window.VoiceUI=(()=>{ + const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + function html(){return `

Stimme ausprobieren · VoxCPM2

Text erzeugt Sprache. Mit optionaler WAV-Referenz übernimmt VoxCPM2 deren Stimme. Nur eingerichtete VoxCPM2-Profile stehen zur Auswahl.

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.

`;} + 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=`

Ergebnis · ${Number(j.duration).toFixed(1)} Sekunden

WAV herunterladen

`;}}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=>``).join('')||'';}).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}; +})(); diff --git a/voxcpm-ui.js b/voxcpm-ui.js index 1a1e889..42ea702 100644 --- a/voxcpm-ui.js +++ b/voxcpm-ui.js @@ -1,6 +1,6 @@ const VoxCPMUI=(()=>{ const e=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - function html(){return `
EINSTELLUNGEN / LAUFZEITEN

VoxCPM

VoxCPM2 einrichten

Diese Modellfamilie benötigt die originale VoxCPM-Python-Laufzeit. Eine eigene Umgebung schützt bestehende Laufzeiten; vorhandene CUDA-Pakete werden nach Möglichkeit wiederverwendet.

Status wird geprüft …

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.

Installationsausgabe
Zurück zu Stimmwerkzeugen →
`;} + function html(){return `
EINSTELLUNGEN / LAUFZEITEN

VoxCPM

VoxCPM2 einrichten

Diese Modellfamilie benötigt die originale VoxCPM-Python-Laufzeit. Eine eigene Umgebung schützt bestehende Laufzeiten; vorhandene CUDA-Pakete werden nach Möglichkeit wiederverwendet.

Status wird geprüft …

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.

Installationsausgabe
Zurück zu Stimmwerkzeugen →
`;} 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 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);}} diff --git a/voxcpm_runtime.py b/voxcpm_runtime.py index 2859f9f..3292996 100644 --- a/voxcpm_runtime.py +++ b/voxcpm_runtime.py @@ -15,7 +15,7 @@ class VoxCPMRuntime(LTXOriginalRuntime): return self.root/'missing-python',self.root/'missing-runtime' def status(self): 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): if not shutil.which('ffmpeg'):raise ValueError('FFmpeg fehlt. Systemvoraussetzungen über den Deck-Installer einrichten.') 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) 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') - 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 Exception as exc:phase=str(exc) finally: diff --git a/voxcpm_test.py b/voxcpm_test.py new file mode 100644 index 0000000..0c7da07 --- /dev/null +++ b/voxcpm_test.py @@ -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=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() diff --git a/voxcpm_worker.py b/voxcpm_worker.py new file mode 100644 index 0000000..8b09ac8 --- /dev/null +++ b/voxcpm_worker.py @@ -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())