Add profile presets and min-p controls, embeddings and VoxCPM workers
This commit is contained in:
1 parent
74dd2ed8ba
commit
a7b30b62d2
32 files changed
+615
-64
No files matched your search
@@ -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.
|
||||
+32
-4
@@ -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=<Profilname>`, `input=<Text>`, `voice=default`, `response_format=wav`,
|
||||
`speed=1`. Referenz-Stimmklonen verwendet derzeit den internen Multipart-Endpunkt.
|
||||
Backups sichern Profile, Komponenten und die wiederherstellbare Laufzeitversion,
|
||||
keine Testtexte oder Audiodateien.
|
||||
@@ -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;}
|
||||
|
||||
@@ -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.')
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+2
-2
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""CPU-only EmbeddingGemma worker; independent of GPU mode and chat leases."""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
REPO='ggml-org/embeddinggemma-300m-qat-q8_0-GGUF'
|
||||
FILE='embeddinggemma-300m-qat-Q8_0.gguf'
|
||||
def supported(model):
|
||||
return bool(model and model.get('kind')=='embeddings' and model.get('repo')==REPO and model.get('file')==FILE)
|
||||
|
||||
class Embeddings:
|
||||
def __init__(self,root,catalog,worker):
|
||||
self.root=Path(root);self.catalog=catalog;self.worker=worker;self.lock=threading.RLock();self.process=None;self.identity=None;self.port=None;self.key=None
|
||||
def blockers(self,p):
|
||||
if not supported(p.get('model')):return ['Noch kein geprüftes Embedding-Rezept für diese Datei. Unterstützt: EmbeddingGemma 300M QAT Q8_0.']
|
||||
try:self.worker.build()
|
||||
except ValueError as exc:return [str(exc)]
|
||||
return []
|
||||
def stop(self):
|
||||
with self.lock:
|
||||
process=self.process;self.process=None;self.identity=None
|
||||
if process and process.poll() is None:
|
||||
os.killpg(process.pid,signal.SIGTERM)
|
||||
try:process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
|
||||
(self.root/'key').unlink(missing_ok=True)
|
||||
def ensure(self,p):
|
||||
identity=(p['id'],p['revision'],self.worker.runtime.status()['active'])
|
||||
if self.process and self.process.poll() is None and self.identity==identity:return
|
||||
self.stop();errors=self.blockers(p)
|
||||
if errors:raise ValueError(errors[0])
|
||||
entry=self.catalog.entry(p['model_id']);model=self.catalog.root/entry['id']/'model.gguf';directory=self.worker.build()
|
||||
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
with socket.socket() as sock:sock.bind(('127.0.0.1',0));self.port=sock.getsockname()[1]
|
||||
self.key=secrets.token_urlsafe(32);keyfile=self.root/'key'
|
||||
fd=os.open(keyfile,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
||||
with os.fdopen(fd,'w') as out:out.write(self.key+'\n')
|
||||
env=dict(os.environ,CUDA_VISIBLE_DEVICES='')
|
||||
args=[str(directory/'build/bin/llama-server'),'--model',str(model),'--alias',p['name'],'--embedding','--pooling','mean','--ctx-size','4096','--batch-size','4096','--ubatch-size','1024','--parallel','4','--threads','8','--threads-batch','8','--n-gpu-layers','0','--host','127.0.0.1','--port',str(self.port),'--api-key-file',str(keyfile),'--no-ui']
|
||||
self.process=subprocess.Popen(args,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
|
||||
try:
|
||||
deadline=time.monotonic()+120
|
||||
while time.monotonic()<deadline:
|
||||
if self.process.poll() is not None:raise ValueError('EmbeddingGemma konnte nicht gestartet werden. llama.cpp-Build und Modell prüfen.')
|
||||
conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=2)
|
||||
try:
|
||||
conn.request('GET','/health',headers={'Authorization':'Bearer '+self.key})
|
||||
if conn.getresponse().status==200:self.identity=identity;keyfile.unlink(missing_ok=True);return
|
||||
except OSError:pass
|
||||
finally:conn.close()
|
||||
time.sleep(.25)
|
||||
raise ValueError('Embedding-Start hat das Zeitlimit überschritten.')
|
||||
except Exception:self.stop();raise
|
||||
def generate(self,p,data):
|
||||
with self.lock:
|
||||
self.ensure(p);conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=120)
|
||||
try:
|
||||
conn.request('POST','/v1/embeddings',body=json.dumps(data).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+self.key})
|
||||
response=conn.getresponse();raw=response.read(4*1024*1024+1)
|
||||
if response.status!=200:raise ValueError('Embedding-Anfrage abgelehnt. Textlänge und Eingabe prüfen (maximal ca. 1024 Token je Text).')
|
||||
if len(raw)>4*1024*1024:raise ValueError('Embedding-Antwort zu groß.')
|
||||
result=json.loads(raw)
|
||||
if not result.get('data'):raise ValueError('Keine Embedding-Vektoren zurückgegeben.')
|
||||
return result
|
||||
except (OSError,ValueError):raise ValueError('Embedding-Anfrage fehlgeschlagen. Eingabelänge und Laufzeit prüfen.') from None
|
||||
finally:conn.close()
|
||||
+39
-14
@@ -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()<end:
|
||||
current=ep.tts.status()['job']
|
||||
current=backend.status()['job']
|
||||
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503)
|
||||
try:body=ep.tts.audio(job['id'])
|
||||
try:body=backend.audio(job['id'])
|
||||
except (ValueError,OSError):time.sleep(.25);continue
|
||||
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);return
|
||||
raise APIError('TTS-Zeitlimit überschritten.',504)
|
||||
def embedding(self,ep,data):
|
||||
if not ep.embeddings:raise APIError('Embedding-Worker nicht eingerichtet.',501)
|
||||
if set(data)-{'model','input','encoding_format','user'}:raise APIError('Nicht unterstützte Embedding-Felder.')
|
||||
if data.get('encoding_format','float')!='float':raise APIError('Nur encoding_format=float unterstützt.')
|
||||
value=data.get('input');texts=[value] if isinstance(value,str) else value
|
||||
if not isinstance(texts,list) or not 1<=len(texts)<=32 or any(not isinstance(t,str) or not t.strip() or len(t)>16000 for t in texts):raise APIError('input: 1–32 nichtleere Texte, jeweils maximal 16000 Zeichen. Kontextgrenze wird zusätzlich von llama.cpp geprüft.')
|
||||
profile=ep.find_profile(data.get('model'),'embeddings')
|
||||
try:return self.send(ep.embeddings.generate(profile,{k:v for k,v in data.items() if k!='user'}))
|
||||
except ValueError as exc:raise APIError(str(exc)) from None
|
||||
|
||||
def music_generation(self,ep,data):
|
||||
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)
|
||||
|
||||
+11
-3
@@ -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'):
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/dashboard.css"><link rel="stylesheet" href="/themes.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/voxcpm-ui.js" defer></script><script src="/studio.js" defer></script><script src="/separator-ui.js" defer></script><script src="/music-ui.js" defer></script><script src="/audio-cpp-ui.js" defer></script><script src="/ltx-original-ui.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/dashboard-ui.js" defer></script><script src="/backup-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#dashboard"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><p class="nav-heading">Übersicht</p><a href="#dashboard">Dashboard</a><a href="#home">Steuerung</a><p class="nav-heading">KI-Werkzeuge</p><a href="#chat">Chat & Sprachmodelle</a><a href="#image">Bildgenerierung</a><a href="#music">Musikgenerierung</a><a href="#separation">Audio-Trennung</a><a href="#audio">Sprachausgabe (TTS)</a><a href="#stt">Spracherkennung (STT)</a><a href="#voice">Stimmwerkzeuge</a><a href="#video">Videogenerierung</a><p class="nav-heading">Anwendungen</p><a href="#services">Weitere Dienste</a><p class="nav-heading">Einstellungen</p><a href="#runtimes">Laufzeiten</a><a href="#endpoint">Endpunkt</a><a href="#network">Netzwerk</a><a href="#access">Zugang & API-Token</a><a href="#appearance">Darstellung</a><a href="#backup">Backup & Wiederherstellung</a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/dashboard.css"><link rel="stylesheet" href="/themes.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/voxcpm-ui.js" defer></script><script src="/voice-ui.js" defer></script><script src="/studio.js" defer></script><script src="/separator-ui.js" defer></script><script src="/music-ui.js" defer></script><script src="/audio-cpp-ui.js" defer></script><script src="/ltx-original-ui.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/dashboard-ui.js" defer></script><script src="/backup-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#dashboard"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><p class="nav-heading">Übersicht</p><a href="#dashboard">Dashboard</a><a href="#home">Steuerung</a><p class="nav-heading">KI-Werkzeuge</p><a href="#chat">Chat & Sprachmodelle</a><a href="#embeddings">Embeddings</a><a href="#image">Bildgenerierung</a><a href="#music">Musikgenerierung</a><a href="#separation">Audio-Trennung</a><a href="#audio">Sprachausgabe (TTS)</a><a href="#stt">Spracherkennung (STT)</a><a href="#voice">Stimmwerkzeuge</a><a href="#video">Videogenerierung</a><p class="nav-heading">Anwendungen</p><a href="#services">Weitere Dienste</a><p class="nav-heading">Einstellungen</p><a href="#runtimes">Laufzeiten</a><a href="#endpoint">Endpunkt</a><a href="#network">Netzwerk</a><a href="#access">Zugang & API-Token</a><a href="#appearance">Darstellung</a><a href="#backup">Backup & Wiederherstellung</a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+17
-6
@@ -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=()=>'<section id="live-profiles"><div class="section-heading"><div><h2>Deine Profile</h2><p>Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.</p></div><button id="profile-new">Neues Profil</button></div><p id="profile-message" role="status"></p><div id="profile-list" class="profile-list"></div><div id="profile-editor"></div></section>';
|
||||
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
|
||||
function bind(kind,modelId){
|
||||
@@ -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=>`<details class="detail-row" data-row-id="${e(p.id)}" ${openRows.has(p.id)?'open':''}><summary class="detail-summary"><strong>${e(p.name)}</strong><span class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</span><span>${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}</span><span>${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}<small>${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}</small></span></summary><div class="detail-body"><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button><button class="secondary" data-components="${p.id}">Einrichtung & Komponenten</button>${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></div></details>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
||||
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Steuerung lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':kind==='music'?'Gib ein eingerichtetes Musikprofil frei, um es in der Steuerung verwenden zu können.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
|
||||
el('profile-list').innerHTML=rows.map(p=>`<details class="detail-row" data-row-id="${e(p.id)}" ${openRows.has(p.id)?'open':''}><summary class="detail-summary"><strong>${e(p.name)}</strong><span class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</span><span>${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}</span><span>${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}<small>${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}</small></span></summary><div class="detail-body"><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-presets="${p.id}">Presets (${(p.presets||[]).length})</button><button class="secondary" data-delete="${p.id}">Löschen</button><button class="secondary" data-components="${p.id}">Einrichtung & Komponenten</button>${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></div></details>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
||||
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Steuerung lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':kind==='music'?'Gib ein eingerichtetes Musikprofil frei, um es in der Steuerung verwenden zu können.':kind==='voice'?'Eingerichtete VoxCPM2-Profile können Sprache erzeugen und am Sprach-API-Endpunkt freigegeben werden. Stimmklonen mit WAV-Referenz steht unter Testen.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
|
||||
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Steuerung lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
|
||||
root.querySelectorAll('[data-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=`<section class="card editor"><h2>Presets · ${e(profile.name)}</h2><p>Gespeicherte Profil-Einstellungen sichern. Laden öffnet den Editor; erst Profil speichern übernimmt die Werte. Komponenten-Zuordnungen und API-Freigabe bleiben unverändert. Client-Einstellungen werden hier nicht gesichert.</p><form id="preset-form"><label>Name<input name="name" maxlength="64" required placeholder="Vor dem Experiment"></label><button>Aktuell gespeicherte Einstellungen sichern</button></form><div>${(profile.presets||[]).map(x=>`<div class="card-actions"><strong>${e(x.name)}</strong><span>${e(new Date(x.created_at*1000).toLocaleString())}</span><button data-preset-load="${e(x.id)}">In Editor laden</button><button class="secondary" data-preset-delete="${e(x.id)}">Löschen</button></div>`).join('')||'<p>Noch keine Presets gespeichert.</p>'}</div><button class="secondary" id="preset-close">Schließen</button></section>`;
|
||||
el('preset-close').onclick=()=>el('profile-editor').replaceChildren();
|
||||
async function mutate(data){await api('profiles/preset',{id:profile.id,revision:profile.revision,...data});await load();const current=rows.find(x=>x.id===profile.id);if(current)presets(current);}
|
||||
el('preset-form').onsubmit=async event=>{event.preventDefault();const b=event.target.querySelector('button');b.disabled=true;try{await mutate({action:'save',name:new FormData(event.target).get('name')});message('Preset gespeichert.');}catch(error){message(error.message);b.disabled=false;}};
|
||||
root.querySelectorAll('[data-preset-load]').forEach(b=>b.onclick=()=>{const x=profile.presets.find(x=>x.id===b.dataset.presetLoad);if(x.model_id!==profile.model_id){message('Dieses Preset gehört zu einer anderen Modelldatei. Zuerst diese im Profil auswählen.');return;}editor({...profile,parameters:structuredClone(x.parameters)});message(`Preset „${x.name}“ im Editor. Prüfen und Profil speichern.`);});
|
||||
root.querySelectorAll('[data-preset-delete]').forEach(b=>b.onclick=async()=>{if(!confirm('Dieses gespeicherte Preset löschen?'))return;b.disabled=true;try{await mutate({action:'delete',preset_id:b.dataset.presetDelete});}catch(error){message(error.message);b.disabled=false;}});
|
||||
}
|
||||
async function enhancers(profile){
|
||||
const sequence=++panelSequence,selected=profile.prompt_enhancer||{t2i:null,i2i:null,device:'auto'};
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><h2>Prompt-Aufwerter · ${e(profile.name)}</h2><p>Optional pro Bildprofil. Text-zu-Bild und Bearbeitung mit Referenzbildern verwenden getrennte offizielle Qwen-Modelle. Andere Bildfamilien können sie nur nach eigener Prüfung sinnvoll nutzen.</p><form id="enhancer-form"><div class="form-grid"><label>Text-zu-Bild<select name="t2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-T2I" ${selected.t2i?'selected':''}>Qwen Image 2.1 PE-T2I</option></select></label><label>Bildbearbeitung mit Referenzen<select name="i2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-I2I" ${selected.i2i?'selected':''}>Qwen Image 2.1 PE-I2I</option></select></label><label>Ausführungsgerät<select name="device">${[['auto','Automatisch · 5080, dann 3060, nötigenfalls RAM'],['5080','RTX 5080 · nötigenfalls RAM'],['3060','RTX 3060 · nötigenfalls RAM'],['cpu','Nur CPU · sehr langsam']].map(([v,t])=>`<option value="${v}" ${selected.device===v?'selected':''}>${t}</option>`).join('')}</select></label></div><p>Der Aufwerter wird vor der Bildpipeline geladen und danach vollständig entladen. Beide PE-Modelle benötigen je etwa 18,8 GB Download. Die Wahl einer einzelnen GPU garantiert keinen rein dortigen Betrieb; CPU-Auslagerung kann erforderlich sein. Die Bildauflösung bleibt im Profil gespeichert; ein Seitenverhältnis aus der PE-Antwort ist nur ein Vorschlag.</p><button>Profil-Einstellung speichern</button><button type="button" class="secondary" id="enhancer-close">Schließen</button></form><h3>Offizielle Modelle installieren</h3><div id="enhancer-models"></div><p id="enhancer-message" role="status"></p></section>`;
|
||||
@@ -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=`<section class="card editor"><div class="card-top"><h2>Komponenten · ${e(profile.name)}</h2><button class="secondary" id="components-close">Schließen</button></div>${CatalogUI.executionHTML(profile.execution)}<p>${e(data.message)}</p><details><summary>Andere Komponentenquelle auswählen (ungeprüft)</summary><label>Hugging-Face-Link oder Repository<input id="component-source" placeholder="https://huggingface.co/Organisation/Modell"></label><button id="component-source-open" type="button">Dateien anzeigen</button></details>${data.supported?`<p>Quelle: <a class="link" href="${e(data.source)}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> · ${e(data.license||'Lizenz nicht angegeben')}</p>${data.runtime_route?`<p><a class="link" href="${e(data.runtime_route)}">${data.runtime_installed?'Laufzeit ansehen':'Passende Laufzeit installieren'} →</a></p>`:''}<p>Laufzeit: ${e(data.runtime)} · ${data.manual?'Zuordnung ungeprüft':data.runtime_installed?'installiert':'noch nicht eingerichtet'}</p>${!data.manual?'<button type="button" id="components-download-all">Fehlende Pflichtdateien herunterladen</button>':'<p>Nur benötigte Dateien einzeln auswählen; diese Liste ist kein automatischer Downloadplan.</p>'}<form id="components-form">${data.requirements.map(r=>`<section class="card component-card"><h3>${e(r.label)}</h3><label>Datei aus der Bibliothek<select name="${r.role}"><option value="">Noch nicht zugeordnet</option>${r.available.map(f=>`<option value="${f.id}" ${(profile.components?.[r.role]===f.id||(!data.manual&&!profile.components?.[r.role]&&r.available.length===1))?'selected':''}>${e(f.file)} · ${(f.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${r.candidates.map((f,i)=>`<div class="note"><p data-download-feedback="${r.role}:${i}" role="status"></p><p class="hub-filename">${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}</p>${f.gated&&!f.auth_configured?'<p>Hugging-Face-Zugang unter <a href="#access">Einstellungen → Zugang</a> hinterlegen und Modellfreigabe prüfen.</p>':''}<button type="button" class="secondary" data-component-download="${r.role}:${i}" ${(f.gated&&!f.auth_configured)||r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'disabled':''}>${r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'Bereits in Bibliothek':'Datei herunterladen'}</button></div>`).join('')||'<p>Keine passende Quelldatei verfügbar.</p>'}</section>`).join('')}<div class="card-actions"><button id="components-save">Zuordnung auf Athena speichern</button><button type="button" class="secondary" id="components-refresh">Bibliothek aktualisieren</button></div><p class="small">Lade je eine Datei pro benötigter Komponente. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.</p></form>`:''}<p id="components-message" role="status"></p></section>`;
|
||||
if(data.no_components){const form=el('components-form');form?.replaceChildren();el('components-download-all')?.remove();el('component-source-open')?.closest('details')?.remove();}
|
||||
const missing=data.requirements?.filter(r=>!r.available.length&&!r.optional)||[];
|
||||
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=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" placeholder="z. B. Qwen3.8 27B MEDIUM" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?`<h3>Penalties</h3><div class="form-grid">${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return `<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="any" value="${e(data.parameters[k]??defaultValue)}"></label>`;}).join('')}</div>`+gpuEditor(data.parameters)+`<p class="small">Penalties: Wiederholung 1,0 sowie Presence und Frequency 0 sind neutral. Wiederholung über 1 bremst wiederholte Tokens; Presence bewertet ihr Auftreten, Frequency ihre Häufigkeit. Profilwerte gelten für Testchat und API, sofern der Client keine eigenen Werte sendet.</p><h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.download_source?.repo||x.repo)} / ${e(x.download_source?.file||x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" placeholder="z. B. Qwen3.8 27B MEDIUM" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).filter(([k])=>!k.endsWith('penalty')).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','min_p','mtp_min_p','repeat_penalty','presence_penalty','frequency_penalty'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?`<h3>Penalties</h3><div class="form-grid">${['repeat_penalty','presence_penalty','frequency_penalty'].map(k=>{const [min,max,defaultValue]=schema[k];return `<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="any" value="${e(data.parameters[k]??defaultValue)}"></label>`;}).join('')}</div>`+gpuEditor(data.parameters)+`<p class="small">Penalties: Wiederholung 1,0 sowie Presence und Frequency 0 sind neutral. Wiederholung über 1 bremst wiederholte Tokens; Presence bewertet ihr Auftreten, Frequency ihre Häufigkeit. Profilwerte gelten für Testchat und API, sofern der Client keine eigenen Werte sendet.</p><h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.download_source?.repo||x.repo)} / ${e(x.download_source?.file||x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
||||
if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='<summary>Erweitert: CPU-Threads</summary><p>CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.</p>';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));}
|
||||
if(kind==='chat'){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);
|
||||
|
||||
+48
-8
@@ -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.')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${page==='voice'?'<p><a class="link" href="#separator-runtime">Audio Separator einrichten →</a> · <a class="link" href="#separation">Audio-Trennung →</a></p><p class="note">Audio-Trennung nutzt die unterstützten Pakete im Audio-Separator-Bereich. Andere Hub-Dateien und Voice-Profile benötigen weiterhin eine geprüfte Anbindung.</p>':''}${audioDescriptions[page]&&!['audio','stt','music','voice'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt','music'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[])].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='music'?MusicUI.html():['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
||||
const body=page==='voxcpm-runtime'?VoxCPMUI.html():page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${page==='voice'?'<p><a class="link" href="#separator-runtime">Audio Separator einrichten →</a> · <a class="link" href="#separation">Audio-Trennung →</a></p><p class="note">Audio-Trennung nutzt die unterstützten Pakete im Audio-Separator-Bereich. Andere Hub-Dateien und Voice-Profile benötigen weiterhin eine geprüfte Anbindung.</p>':''}${audioDescriptions[page]&&!['audio','stt','music','voice','embeddings'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt','music','voice'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[])].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='music'?MusicUI.html():['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='test'&&page==='voice'?VoiceUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
||||
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
||||
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]));
|
||||
|
||||
@@ -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'}}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')}))
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import io,tempfile,unittest,wave
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock,patch
|
||||
from voxcpm_test import VoxCPMTests,validate_reference
|
||||
class VoiceTests(unittest.TestCase):
|
||||
def test_reference_limits(self):
|
||||
for data in (b'',b'not audio',b'x'*(8*1024**2+1)):
|
||||
with self.assertRaises(ValueError):validate_reference(data)
|
||||
def wav(seconds):
|
||||
b=io.BytesIO()
|
||||
with wave.open(b,'wb') as w:w.setnchannels(1);w.setsampwidth(2);w.setframerate(16000);w.writeframes(b'\0\0'*int(seconds*16000))
|
||||
return b.getvalue()
|
||||
validate_reference(wav(2))
|
||||
with self.assertRaises(ValueError):validate_reference(wav(31))
|
||||
def test_not_installed_and_components_reported_separately(self):
|
||||
profiles=Mock();profiles._component.side_effect=ValueError('missing');runtime=Mock();runtime.status.return_value={'installed':False}
|
||||
t=VoxCPMTests('/tmp/no-voice',profiles,runtime);p=dict(model=dict(kind='voice',repo='openbmb/VoxCPM2',file='model.safetensors'),components={})
|
||||
self.assertEqual(len(t.blockers(p)),6)
|
||||
runtime.status.return_value={'installed':True};self.assertEqual(len(t.blockers(p)),5)
|
||||
def test_missing_gpu_releases_reservation_and_records_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p=dict(id='test',model=dict(kind='voice',repo='openbmb/VoxCPM2',file='model.safetensors'),components={})
|
||||
profiles=Mock();profiles.status.return_value={'profiles':[p]};runtime=Mock();runtime.status.return_value={'installed':True}
|
||||
t=VoxCPMTests(d,profiles,runtime);release=Mock();t.acquire=Mock(return_value=release)
|
||||
with patch('voxcpm_test.probe',return_value=[]):
|
||||
with self.assertRaisesRegex(ValueError,'VRAM'):t.start('test','synthetic test')
|
||||
release.assert_called_once();self.assertEqual(t.job['state'],'failed')
|
||||
self.assertEqual(list(Path(d).iterdir()),[])
|
||||
def test_invalid_options_rejected_before_gpu_reservation(self):
|
||||
t=VoxCPMTests('/tmp/no-voice',Mock(),Mock());t.acquire=Mock()
|
||||
for args in (dict(text=''),dict(text='x',device='cpu'),dict(text='x',steps=0),dict(text='x',seed=-1)):
|
||||
with self.assertRaises(ValueError):t.start('id',**args)
|
||||
t.acquire.assert_not_called()
|
||||
|
||||
def test_worker_completion_failure_and_cancellation_cleanup(self):
|
||||
for outcome in ('complete','failed','cancelled'):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
t=VoxCPMTests(d,Mock(),Mock());ident='a'*32;t.job=dict(id=ident,state='running');t.stage=lambda p,root:(root/'model').mkdir();t.runtime.paths.return_value=(Path('/fake-python'),None)
|
||||
root=Path(d)/ident;release=Mock()
|
||||
def launch(*args,**kwargs):
|
||||
(root/'result.json').write_text('{"ok":false,"error":"synthetic OOM"}' if outcome=='failed' else '{"ok":true,"duration":1,"sample_rate":16000}')
|
||||
if outcome=='complete':
|
||||
with wave.open(str(root/'result.wav'),'wb') as w:w.setnchannels(1);w.setsampwidth(2);w.setframerate(16000);w.writeframes(b'\0\0'*16000)
|
||||
process=Mock();process.poll.return_value=0;return process
|
||||
if outcome=='cancelled':t.cancel.set()
|
||||
with patch('voxcpm_test.subprocess.Popen',side_effect=launch):t._run_voice({},ident,dict(text='synthetic'),b'reference',{'uuid':'test'},release)
|
||||
self.assertEqual(t.job['state'],outcome);release.assert_called_once()
|
||||
self.assertFalse((root/'reference.wav').exists());self.assertFalse((root/'model').exists());self.assertFalse(t.status()['loaded'])
|
||||
self.assertEqual((root/'complete').exists(),outcome=='complete')
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
window.VoiceUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function html(){return `<section id="voice-panel" class="card"><h2>Stimme ausprobieren · VoxCPM2</h2><p>Text erzeugt Sprache. Mit optionaler WAV-Referenz übernimmt VoxCPM2 deren Stimme. Nur eingerichtete VoxCPM2-Profile stehen zur Auswahl.</p><p id="voice-status" role="status"></p><progress id="voice-progress" hidden aria-label="Stimmgenerierung"></progress><p id="voice-error" role="alert"></p><form id="voice-form"><label>Profil<select name="profile_id" id="voice-profile" required></select></label><label>Text<textarea name="text" rows="5" maxlength="1000" required></textarea></label><label>Referenzstimme (optional, PCM-16-WAV, 1–30 Sekunden, bis 8 MiB)<input name="file" type="file" accept="audio/wav,.wav"></label><div class="form-grid"><label>GPU<select name="device"><option value="5080">RTX 5080</option><option value="3060">RTX 3060</option></select></label><label>Guidance<input name="cfg" type="number" min="0.5" max="5" step="0.1" value="2"></label><label>Schritte<input name="steps" type="number" min="1" max="50" value="10"></label><label>Seed<input name="seed" type="number" min="0" max="2147483647" value="42"></label></div><button id="voice-start">Stimme erzeugen</button><button id="voice-cancel" type="button" class="secondary" disabled>Abbrechen</button></form><div id="voice-result"></div><p class="small">Im LLM-Modus verfügbar. Deck entlädt sein LLM vor der Stimmgenerierung und lädt es bei der nächsten Chat-Anfrage wieder. VoxCPM2 wird nach dem Auftrag entladen. Referenzdatei wird nach dem Auftrag gelöscht; Text wird nicht gespeichert.</p></section>`;}
|
||||
function bind(){const root=document.querySelector('#voice-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let busy=false,lastResult='',ready=false;
|
||||
async function api(path='',body){const r=await fetch('/api/v1/voice'+path,body===undefined?{}:{method:'POST',headers:body instanceof FormData?{'X-Athena-Deck':'1'}:{'X-Athena-Deck':'1','Content-Type':'application/json'},body:body instanceof FormData?body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw Error(d.error||'Stimmgenerierung fehlgeschlagen');return d;}
|
||||
async function refresh(){try{const d=await api();if(!root.isConnected)return;const j=d.job;el('voice-status').textContent=(j?.phase||'Profil wählen und Text eingeben.')+(j?.gpu?' · '+j.gpu:'');el('voice-progress').hidden=j?.state!=='running';el('voice-start').disabled=busy||!ready||j?.state==='running';el('voice-cancel').disabled=j?.state!=='running';if(j?.state==='complete'&&j.id!==lastResult){lastResult=j.id;el('voice-result').innerHTML=`<h3>Ergebnis · ${Number(j.duration).toFixed(1)} Sekunden</h3><audio controls src="/api/v1/voice/audio?id=${e(j.id)}"></audio><p><a class="link" download="athena-stimme.wav" href="/api/v1/voice/audio?id=${e(j.id)}">WAV herunterladen</a></p>`;}}catch(err){if(root.isConnected)el('voice-error').textContent=err.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}}
|
||||
fetch('/api/v1/profiles').then(async r=>{if(!r.ok)throw Error('Profile konnten nicht geladen werden.');return r.json();}).then(d=>{if(!root.isConnected)return;const rows=d.profiles.filter(p=>p.runnable&&p.model?.repo==='openbmb/VoxCPM2');ready=!!rows.length;el('voice-profile').innerHTML=rows.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">VoxCPM2-Profil unter Profile → Einrichtung & Komponenten vervollständigen</option>';}).catch(err=>{if(root.isConnected)el('voice-error').textContent=err.message;});
|
||||
el('voice-form').onsubmit=async event=>{event.preventDefault();busy=true;el('voice-start').disabled=true;el('voice-error').textContent='';el('voice-status').textContent='Auftrag wird gestartet …';try{const data=new FormData(event.target);if(!data.get('file')?.size)data.delete('file');await api('/start',data);}catch(err){el('voice-error').textContent=err.message;}finally{busy=false;}};
|
||||
el('voice-cancel').onclick=async()=>{try{await api('/cancel',{});}catch(err){el('voice-error').textContent=err.message;}};refresh();
|
||||
}
|
||||
return {html,bind};
|
||||
})();
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
const VoxCPMUI=(()=>{
|
||||
const e=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function html(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>VoxCPM</h1><section class="card" id="voxcpm-panel"><h2>VoxCPM2 einrichten</h2><p>Diese Modellfamilie benötigt die originale VoxCPM-Python-Laufzeit. Eine eigene Umgebung schützt bestehende Laufzeiten; vorhandene CUDA-Pakete werden nach Möglichkeit wiederverwendet.</p><p id="voxcpm-status" role="status">Status wird geprüft …</p><p id="voxcpm-details"></p><progress id="voxcpm-progress" hidden aria-label="Laufzeitinstallation"></progress><div class="card-actions"><button id="voxcpm-install">Passende Laufzeit installieren</button><button id="voxcpm-cancel" class="secondary" disabled>Installation abbrechen</button></div><p class="note">Dieser Schritt installiert die Laufzeit. Er lädt keine Modellgewichte und startet kein Modell. Die Deck-Anbindung für Generierung und Stimmklonen fehlt noch; das Profil bleibt deshalb gesperrt.</p><details><summary>Installationsausgabe</summary><button id="voxcpm-log" class="secondary">Ausgabe aktualisieren</button><pre id="voxcpm-output" style="white-space:pre-wrap"></pre></details><p id="voxcpm-error" role="alert"></p><a class="link" href="#voice">Zurück zu Stimmwerkzeugen →</a></section>`;}
|
||||
function html(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>VoxCPM</h1><section class="card" id="voxcpm-panel"><h2>VoxCPM2 einrichten</h2><p>Diese Modellfamilie benötigt die originale VoxCPM-Python-Laufzeit. Eine eigene Umgebung schützt bestehende Laufzeiten; vorhandene CUDA-Pakete werden nach Möglichkeit wiederverwendet.</p><p id="voxcpm-status" role="status">Status wird geprüft …</p><p id="voxcpm-details"></p><progress id="voxcpm-progress" hidden aria-label="Laufzeitinstallation"></progress><div class="card-actions"><button id="voxcpm-install">Passende Laufzeit installieren</button><button id="voxcpm-cancel" class="secondary" disabled>Installation abbrechen</button></div><p class="note">Dieser Schritt installiert die Laufzeit. Er lädt keine Modellgewichte und startet kein Modell. Nach Zuordnung der Modellkomponenten unter Stimmwerkzeuge → Testen Sprache erzeugen oder eine Referenzstimme verwenden.</p><details><summary>Installationsausgabe</summary><button id="voxcpm-log" class="secondary">Ausgabe aktualisieren</button><pre id="voxcpm-output" style="white-space:pre-wrap"></pre></details><p id="voxcpm-error" role="alert"></p><a class="link" href="#voice">Zurück zu Stimmwerkzeugen →</a></section>`;}
|
||||
function bind(){const root=document.querySelector('#voxcpm-panel');if(!root)return;const el=id=>root.querySelector('#'+id);let busy=false;
|
||||
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);}}
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""VoxCPM2 speech and reference-voice generation, serialized by Deck GPU scheduler."""
|
||||
import io,json,os,shutil,signal,subprocess,threading,time,uuid,wave
|
||||
from pathlib import Path
|
||||
from tts_test import TTSTests
|
||||
from image_test import probe,cgroup_headroom
|
||||
from profiles import voice_recipe
|
||||
WORKER=Path(__file__).with_name('voxcpm_worker.py')
|
||||
|
||||
def validate_reference(audio):
|
||||
if not isinstance(audio,bytes) or not 0<len(audio)<=8*1024**2:raise ValueError('Referenz als WAV bis 8 MiB hochladen.')
|
||||
try:
|
||||
with wave.open(io.BytesIO(audio)) as w:
|
||||
if w.getnchannels() not in (1,2) or w.getsampwidth()!=2 or not 8000<=w.getframerate()<=96000 or not 1<=w.getnframes()/w.getframerate()<=30:raise ValueError('Referenz: PCM-16-WAV, mono/stereo, 1–30 Sekunden erforderlich.')
|
||||
except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei. PCM-16-WAV verwenden.') from None
|
||||
|
||||
class VoxCPMTests(TTSTests):
|
||||
def blockers(self,p):
|
||||
if not voice_recipe(p['model']):return ['Diese Stimm-Modellfamilie wird noch nicht unterstützt.']
|
||||
errors=[]
|
||||
if not self.runtime.status()['installed']:errors.append('VoxCPM-Laufzeit unter Laufzeiten → VoxCPM installieren.')
|
||||
for role,info in voice_recipe(p['model']).items():
|
||||
try:self.profiles._component(p['model'],role,p.get('components',{}).get(role))
|
||||
except ValueError:errors.append(info['label']+' fehlt oder ist noch nicht zugeordnet. Unter Einrichtung & Komponenten ergänzen.')
|
||||
return errors
|
||||
def stage(self,p,root):
|
||||
model=root/'model';model.mkdir();entries=[p['model']]+[self.profiles._component(p['model'],r,p.get('components',{}).get(r)) for r in voice_recipe(p['model'])]
|
||||
for entry in entries:
|
||||
src=self.profiles.catalog.root/entry['id']/('model'+Path(entry['file']).suffix)
|
||||
(model/Path(entry['file']).name).symlink_to(src.resolve())
|
||||
def start(self,profile_id,text,device='5080',cfg=2.0,steps=10,seed=42,reference=None,wait=False):
|
||||
if not isinstance(text,str) or not 1<=len(text.strip())<=1000:raise ValueError('Text mit 1–1000 Zeichen erforderlich.')
|
||||
if device not in ('5080','3060'):raise ValueError('RTX 5080 oder RTX 3060 wählen.')
|
||||
if type(cfg) not in (int,float) or not .5<=cfg<=5 or type(steps)!=int or not 1<=steps<=50 or type(seed)!=int or not 0<=seed<=2147483647:raise ValueError('Ungültige Guidance, Schritte oder Seed.')
|
||||
if reference is not None:validate_reference(reference)
|
||||
p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id),None)
|
||||
if not p or not p['model'] or not voice_recipe(p['model']):raise ValueError('VoxCPM2-Profil nicht gefunden.')
|
||||
errors=self.blockers(p)
|
||||
if errors:raise ValueError(' '.join(errors))
|
||||
# Claim local job before scheduler admission to prevent races between two callers.
|
||||
with self.lock:
|
||||
if self.job and self.job['state']=='running':raise ValueError('Stimmgenerierung läuft bereits.')
|
||||
self.cancel.clear();ident=uuid.uuid4().hex;self.job=dict(id=ident,state='running',phase='GPU wird reserviert',profile_id=profile_id,started_at=time.time())
|
||||
release=None
|
||||
try:
|
||||
release=self.acquire(wait)
|
||||
gpu=next((g for g in probe() if 'RTX '+device in g['name'] and not g['processes'] and g['free_mib']>=6500),None)
|
||||
if not gpu:raise ValueError('RTX '+device+' benötigt mindestens 6,5 GiB freien VRAM und darf nicht fremd belegt sein.')
|
||||
headroom=cgroup_headroom()
|
||||
if headroom is not None and headroom<8*1024**3:raise ValueError('Mindestens 8 GiB freier Deck-Systemspeicher erforderlich.')
|
||||
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
self.job.update(phase='VoxCPM2 wird geladen',gpu=gpu['name']);self.gpu_uuid=gpu['uuid']
|
||||
threading.Thread(target=self._run_voice,args=(p,ident,dict(text=text,cfg=cfg,steps=steps,seed=seed),reference,gpu,release),daemon=True).start();return dict(self.job)
|
||||
except Exception as exc:
|
||||
if release:release()
|
||||
with self.lock:self.job.update(state='failed',phase=str(exc),finished_at=time.time())
|
||||
raise
|
||||
def _run_voice(self,p,ident,request,reference,gpu,release):
|
||||
root=self.root/ident;state='failed';phase='VoxCPM2-Ausführung fehlgeschlagen'
|
||||
try:
|
||||
root.mkdir(mode=0o700);self.stage(p,root)
|
||||
if reference is not None:(root/'reference.wav').write_bytes(reference)
|
||||
env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','LLAMA_','DECK_'))};env.update(CUDA_VISIBLE_DEVICES=gpu['uuid'],HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='4',HOME=str(root))
|
||||
with self.lock:
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
self.process=subprocess.Popen([str(self.runtime.paths()[0]),str(WORKER),str(root)],stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env,start_new_session=True,text=True)
|
||||
process=self.process
|
||||
process.stdin.write(json.dumps(request));process.stdin.close();process.stdin=None;deadline=time.monotonic()+600
|
||||
while process.poll() is None:
|
||||
if self.cancel.wait(.25):raise InterruptedError()
|
||||
if time.monotonic()>deadline:raise ValueError('Zeitlimit für VoxCPM2 erreicht.')
|
||||
if (root/'phase').exists():
|
||||
with self.lock:self.job['phase']=(root/'phase').read_text()
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
if not (root/'result.json').exists():raise ValueError('VoxCPM2-Worker beendet ohne Ergebnis. Speicher oder Laufzeit prüfen.')
|
||||
result=json.loads((root/'result.json').read_text())
|
||||
if not result['ok']:raise ValueError(result['error'])
|
||||
output=root/'result.wav'
|
||||
with wave.open(str(output)) as w:
|
||||
if w.getnframes()==0 or output.stat().st_size>32*1024**2:raise ValueError('Ungültige Audioausgabe.')
|
||||
state='complete';phase='Stimme fertig · Modell entladen'
|
||||
with self.lock:self.job.update(duration=result['duration'],sample_rate=result['sample_rate'])
|
||||
except InterruptedError:state='cancelled';phase='Stimmgenerierung abgebrochen · Modell entladen'
|
||||
except Exception as exc:
|
||||
if self.cancel.is_set():state='cancelled';phase='Stimmgenerierung abgebrochen · Modell entladen'
|
||||
else:phase=str(exc) if isinstance(exc,ValueError) else 'VoxCPM2-Ausführung fehlgeschlagen ('+type(exc).__name__+').'
|
||||
finally:
|
||||
self.unload_idle();shutil.rmtree(root/'model',ignore_errors=True)
|
||||
for name in ('reference.wav','phase'):(root/name).unlink(missing_ok=True)
|
||||
if state=='complete':(root/'complete').touch()
|
||||
with self.lock:self.job.update(state=state,phase=phase,finished_at=time.time())
|
||||
release()
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user