19 Commits
Author SHA1 Message Date
Mikei386 da10f6b48d Use qwen3-asr throughout speech integrations 2026-09-25 21:46:43 +02:00
Mikei386 8a323e5b9e Use Qwen3-ASR as production speech recognizer 2026-09-25 21:31:33 +02:00
Mikei386 6378b50086 Enable CPU vision projector for Ultra profile 2026-09-24 05:09:10 +02:00
Mikei386 33c04150e3 Raise Athena dictation limit to three minutes 2026-09-21 16:30:47 +02:00
Mikei386 46e5bbdf7f Stream long OpenClaw dictation in segments 2026-09-21 16:06:15 +02:00
Mikei386 e577c55489 Prune portable backup slot before export 2026-09-21 15:20:40 +02:00
Mikei386 bb06545956 Document production image prompt enhancement 2026-09-21 15:13:18 +02:00
Mikei386 fd3f3f5979 Resolve uploaded image references for prompt enhancement 2026-09-21 15:03:11 +02:00
Mikei386 5106d0d2ed Integrate Qwen image prompt enhancers 2026-09-21 14:51:29 +02:00
Mikei386 b832157226 Document Qwen image prompt enhancer test 2026-09-21 14:27:14 +02:00
Mikei386 57309f3722 Document OpenClaw Qwen reference image support 2026-09-21 11:43:42 +02:00
Mikei386 1aec6cd4ac Normalize OpenAI image form fields 2026-09-21 11:38:28 +02:00
Mikei386 a65affe30a Accept OpenAI multipart image edits 2026-09-21 11:35:52 +02:00
Mikei386 9509d8ce29 Document OpenClaw Qwen image routing 2026-09-21 11:30:55 +02:00
Mikei386 c903952522 Document Qwen production validation 2026-09-21 11:01:41 +02:00
Mikei386 ff20307d15 Normalize Qwen reference image inputs 2026-09-21 10:57:26 +02:00
Mikei386 7407611e3e Define unprivileged Qwen image runtime user 2026-09-21 10:51:48 +02:00
Mikei386 5f190fd2de Run Qwen image worker as router volume owner 2026-09-21 10:47:54 +02:00
Mikei386 5de4ac4b25 Make Qwen Image 2.1 the production image worker 2026-09-21 10:41:32 +02:00
49 changed files with 1971 additions and 575 deletions

No files matched your search

+7 -3
View File
@@ -4,6 +4,8 @@ Stand: **20. September 2026**, auf Athena geprüft. Die fünf Textprofil-Images
tragen **llama.cpp b29c606** (0.4.1). tragen **llama.cpp b29c606** (0.4.1).
Der [geprüfte Live-Stand](docs/LIVE_STATE.md) beschreibt Profile, GPUs und die Der [geprüfte Live-Stand](docs/LIVE_STATE.md) beschreibt Profile, GPUs und die
Abweichung zwischen dem bereitgestellten Stack und dem Git-Checkout. Abweichung zwischen dem bereitgestellten Stack und dem Git-Checkout.
Seit dem 24. September verarbeitet auch Ultra Bilder; sein Vision-Projektor
läuft auf der CPU. [Änderung und Test](docs/ULTRA_CPU_VISION_20260924.md).
Der [Updatebericht vom 15. September](docs/UPDATE_AUDIT_20260915.md) Der [Updatebericht vom 15. September](docs/UPDATE_AUDIT_20260915.md)
enthält die aktuellen Build- und Testbelege. Der ältere enthält die aktuellen Build- und Testbelege. Der ältere
[b10930-Bericht](docs/LLAMA_B10930_UPDATE_20260912.md) dokumentiert einen [b10930-Bericht](docs/LLAMA_B10930_UPDATE_20260912.md) dokumentiert einen
@@ -20,9 +22,9 @@ Sie betreibt:
- llama.cpp mit genau einem aktiven Qwen-Profil, - llama.cpp mit genau einem aktiven Qwen-Profil,
- den OpenAI-kompatiblen Profile Router, - den OpenAI-kompatiblen Profile Router,
- FLUX.2 Klein 9B FP8 Beta für Textbilder und Referenzbild-Bearbeitung, - Qwen-Image-2.1 INT8 für Textbilder und Referenzbild-Bearbeitung,
- Qwen3-TTS und TTS-Gateway für Sprache, - Qwen3-TTS und TTS-Gateway für Sprache,
- Whisper.cpp und die WebRTC-Brücke für OpenClaw Talk, - Qwen3-ASR auf der CPU und die WebRTC-Brücke für OpenClaw Talk,
- EmbeddingGemma auf der CPU für OpenClaws semantische Memory-Suche, - EmbeddingGemma auf der CPU für OpenClaws semantische Memory-Suche,
- die GPU-lose Mikes-Applio-UI als gesonderten Checkout, - die GPU-lose Mikes-Applio-UI als gesonderten Checkout,
- das Athena-Dashboard, - das Athena-Dashboard,
@@ -90,9 +92,11 @@ nicht direkt. Kein automatischer Host-Neustart ist vorgesehen.
- Fast: kurze, interaktive Aufgaben - Fast: kurze, interaktive Aufgaben
- Medium/Large/Ultra: steigende Kontextgrößen desselben lokalen Qwen-Modells - Medium/Large/Ultra: steigende Kontextgrößen desselben lokalen Qwen-Modells
- Uncensored: separates lokales Profil - Uncensored: separates lokales Profil
- FLUX.2 Klein 9B FP8 Beta: Bildgenerierung und Editing; Qwen wird dafür kurz entladen und danach - Qwen-Image-2.1 INT8: Bildgenerierung und Editing auf der RTX 5080; das Textmodell wird dafür kurz entladen und danach
automatisch wiederhergestellt automatisch wiederhergestellt
- Qwen3-TTS 1.7B: RTX 3060; kein Piper-Fallback - Qwen3-TTS 1.7B: RTX 3060; kein Piper-Fallback
- Qwen3-ASR 0.6B Q8: CPU, hinter dem OpenAI-kompatiblen
Transkriptionsendpunkt mit dem Modellnamen `qwen3-asr`.
- EmbeddingGemma 300M Q8: CPU, OpenAI-kompatibel auf Port 8082; kein GPU-Zugriff - EmbeddingGemma 300M Q8: CPU, OpenAI-kompatibel auf Port 8082; kein GPU-Zugriff
Die geprüften Live-Werte stehen in [docs/LIVE_STATE.md](docs/LIVE_STATE.md). Die geprüften Live-Werte stehen in [docs/LIVE_STATE.md](docs/LIVE_STATE.md).
+32 -17
View File
@@ -1,6 +1,6 @@
# Athena AI # Athena AI
Stand: **20. September 2026**, auf Athena geprüft. Die Textprofil-Images verwenden Stand: **21. September 2026**, auf Athena geprüft. Die Textprofil-Images verwenden
**llama.cpp 0.4.1** (`b29c606`). **llama.cpp 0.4.1** (`b29c606`).
Der [geprüfte Live-Stand](docs/LIVE_STATE.md) beschreibt Profile, GPUs und die Der [geprüfte Live-Stand](docs/LIVE_STATE.md) beschreibt Profile, GPUs und die
Abweichung zwischen dem bereitgestellten Stack und dem Git-Checkout. Abweichung zwischen dem bereitgestellten Stack und dem Git-Checkout.
@@ -21,11 +21,12 @@ dokumentieren den ausgerollten Stand, reduzierte Statuslatenzen und Tests.
- genau ein aktives llama.cpp-Profil: Fast, Medium, Large, Ultra oder Uncensored - genau ein aktives llama.cpp-Profil: Fast, Medium, Large, Ultra oder Uncensored
- Profile Router auf Port 8081 - Profile Router auf Port 8081
- FLUX.2 Klein 9B FP8 Beta: Transformer/VAE auf RTX 5080, Textencoder auf RTX 3060 - Qwen-Image-2.1 INT8 als produktiver Bildworker auf der RTX 5080
- Qwen-Image-2.1 als isolierter, standardmäßig gestoppter Vergleichsworker - offizielle Qwen-Image-2.1 Prompt-Enhancer T2I und I2I als kurzlebige
([Testablauf](docs/QWEN_IMAGE_21_TEST.md)); nicht produktiv an den Router angebunden Q5-Worker auf der RTX 3060
- FLUX.2 Klein 9B FP8 Beta als gestoppter Rückfallcontainer
- Qwen3-TTS 1.7B auf der RTX 3060 hinter dem TTS-Gateway; kein Piper-Fallback - Qwen3-TTS 1.7B auf der RTX 3060 hinter dem TTS-Gateway; kein Piper-Fallback
- Whisper.cpp `small` auf der CPU für lokale deutsche Spracherkennung - Qwen3-ASR 0.6B Q8 auf der CPU für lokale deutsche Spracherkennung
- EmbeddingGemma 300M Q8 auf der CPU für OpenClaws hybride Memory-Suche - EmbeddingGemma 300M Q8 auf der CPU für OpenClaws hybride Memory-Suche
- Live-Dashboard mit 21 Tagen Detailhistorie für GPUs und Slot-Kontextbelegung auf Port 8099 - Live-Dashboard mit 21 Tagen Detailhistorie für GPUs und Slot-Kontextbelegung auf Port 8099
- Portainer CE als optionale Container-Ansicht auf Port 9443 - Portainer CE als optionale Container-Ansicht auf Port 9443
@@ -44,15 +45,20 @@ dokumentieren den ausgerollten Stand, reduzierte Statuslatenzen und Tests.
Hermes nutzt Athenas Router unter `http://192.168.1.212:8081/v1`. Ein MCPHub Hermes nutzt Athenas Router unter `http://192.168.1.212:8081/v1`. Ein MCPHub
ist nicht mehr Bestandteil der produktiven Architektur. ist nicht mehr Bestandteil der produktiven Architektur.
## Verifizierte Bildauflösung auf dem Live-System ## Produktiver Bildpfad
Der am 12. September geprüfte Live-Worker verwendet **FLUX.2 Klein 9B FP8 Bildanfragen an den Router verwenden seit dem 21. September
Beta**. Die frühere 4B-Angabe war veraltet. Im Auflösungstest **Qwen-Image-2.1 INT8** über gepinntes ComfyUI. Der Worker läuft mit Low-VRAM
war **1024 × 1024 erfolgreich**, während **1280 × 1280 einen CUDA-OOM** auf auf der RTX 5080, 25 Schritten, CFG 1 und kann bis zu vier Referenzbilder
der RTX 5080 auslöste. Höhere Auflösungen sind damit nicht freigegeben; verarbeiten. Vor dem Rendern schreibt der passende offizielle Q5-Prompt-
Zwischenwerte wurden nicht getestet. Die produktive 1024-Begrenzung bleibt Enhancer den kurzen Text automatisch um: PE-T2I für reine Textaufträge und
bestehen. Messwerte, Testbedingungen und die Korrektur früherer optimistischer PE-I2I für Referenzbilder. Er läuft dafür vorübergehend auf der RTX 3060.
Schätzungen stehen in [Auflösungstest vom 12. September](docs/FLUX_RESOLUTION_TEST_20260912.md). OpenClaw benötigt weder ein neues Werkzeug noch besondere Promptregeln. Das
aktive Textprofil und TTS werden für den Auftrag angehalten und anschließend
wiederhergestellt. Der bisherige FLUX.2-Worker und seine
Gewichte bleiben als gestoppter, explizit allowlist-beschränkter Rückfallpfad
erhalten. Reproduzierbarer Stand und Rückschaltung:
[Qwen-Image-2.1](docs/QWEN_IMAGE_21.md).
## Installation des Repository-Stands ## Installation des Repository-Stands
@@ -136,15 +142,24 @@ Prefills konkurrieren weiterhin um Rechenleistung und den gemeinsamen KV-Pool.
- Athena-Dashboard: `http://192.168.1.212:8099` - Athena-Dashboard: `http://192.168.1.212:8099`
Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über Der Router stellt Sprache OpenAI-kompatibel bereit: Sprachausgabe über
`/v1/audio/speech` und Spracherkennung über `/v1/audio/transcriptions`. Das `/v1/audio/speech` und Spracherkennung über `/v1/audio/transcriptions`.
Whisper-Modell liegt persistent im Docker-Volume `whisper-data`; Audiodaten Spracherkennung nutzt Qwen3-ASR-0.6B Q8 auf der CPU; das Modell liegt unter
werden lokal auf Athena verarbeitet. Für OpenClaw Talk liegt der lokale `/data/models/qwen3-asr-0.6b-q8`. Der Adapter liefert reinen Text unter
`qwen3-asr`; OpenClaw und die Voice-Brücke verwenden denselben Modellnamen.
Whisper-Container, Image und
Modellvolume sind entfernt. Audiodaten werden lokal auf Athena verarbeitet.
Für OpenClaw Talk liegt der lokale
Realtime-Provider unter Realtime-Provider unter
[`integrations/openclaw-athena-talk`](integrations/openclaw-athena-talk). Er [`integrations/openclaw-athena-talk`](integrations/openclaw-athena-talk). Er
verbindet Mikrofon → Athena Whisper → normalen OpenClaw-Agenten → aktives verbindet Mikrofon → Athena STT → normalen OpenClaw-Agenten → aktives
Athena-TTS, sodass Modell, Werkzeuge und Memory auch im Sprachmodus erhalten Athena-TTS, sodass Modell, Werkzeuge und Memory auch im Sprachmodus erhalten
bleiben. Die Installation landet in OpenClaws persistentem Datenverzeichnis bleiben. Die Installation landet in OpenClaws persistentem Datenverzeichnis
und bleibt deshalb bei normalen Container-Updates bestehen. und bleibt deshalb bei normalen Container-Updates bestehen.
Die separate Diktierfunktion verarbeitet seit Plugin-Version 1.3.0 längere
Aufnahmen bereits während des Sprechens in überlappenden Sechs-Sekunden-
Abschnitten. Beim Loslassen bleibt nur der kurze Rest für OpenClaws festes
Fünf-Sekunden-Abschlussfenster. Dafür wurden weder OpenClaw selbst verändert
noch ein weiterer Container angelegt.
OpenClaw wird über den Provider **llama.cpp → Existing llama-server** mit OpenClaw wird über den Provider **llama.cpp → Existing llama-server** mit
`http://192.168.1.212:8081/v1` verbunden. Der Router beantwortet sowohl `http://192.168.1.212:8081/v1` verbunden. Der Router beantwortet sowohl
+267 -106
View File
@@ -394,9 +394,8 @@ services:
- --spec-draft-type-v - --spec-draft-type-v
- f16 - f16
# Text-only maximum-context profile. This exact IQ4_XS-pure / 256K / 80:20 # Maximum-context profile. Keep the vision projector on CPU so images work
# combination completed the 220K fill test on RTX 5080 + RTX 3060. # without consuming the tightly budgeted GPU memory of the 256K context.
# Deliberately no vision projector: Ultra prioritizes maximum usable context.
llama-ultra: llama-ultra:
<<: *llama-common <<: *llama-common
container_name: mike-ai-llama-ultra container_name: mike-ai-llama-ultra
@@ -408,6 +407,9 @@ services:
command: command:
- --model - --model
- "/models/${ULTRA_MODEL_FILE:?ULTRA_MODEL_FILE is required}" - "/models/${ULTRA_MODEL_FILE:?ULTRA_MODEL_FILE is required}"
- --mmproj
- "/models/${VISION_PROJECTOR_FILE:?VISION_PROJECTOR_FILE is required}"
- --no-mmproj-offload
- --alias - --alias
- qwen-ultra - qwen-ultra
- --ctx-size - --ctx-size
@@ -572,7 +574,9 @@ services:
ALLOWED_PROFILES: fast,medium,large,ultra,uncensored ALLOWED_PROFILES: fast,medium,large,ultra,uncensored
IMAGE_WORKER: image IMAGE_WORKER: image
RESTORE_WORKER: restore RESTORE_WORKER: restore
QWEN_IMAGE_TEST_WORKER: qwen-image-2.1-test IMAGE_PROMPT_I2I_WORKER: image-prompt-i2i
IMAGE_PROMPT_T2I_WORKER: image-prompt-t2i
FLUX_STANDBY_WORKER: flux-standby
TTS_WORKER: qwen3 TTS_WORKER: qwen3
MUSIC_WORKER: acestep MUSIC_WORKER: acestep
YUE2_WORKER: yue2 YUE2_WORKER: yue2
@@ -602,6 +606,8 @@ services:
volumes: volumes:
- ./router/router_profiles.json:/etc/mike-ai/router-profiles.json:ro - ./router/router_profiles.json:/etc/mike-ai/router-profiles.json:ro
- ./config/global-system-policy.txt:/etc/mike-ai/global-system-policy.txt:ro - ./config/global-system-policy.txt:/etc/mike-ai/global-system-policy.txt:ro
- "${QWEN_IMAGE_PE_I2I_MODEL_DIR:-/data/models/qwen-image-2.1-pe-i2i-q5}/system_prompt.txt:/etc/mike-ai/qwen-image-pe-i2i-system-prompt.txt:ro"
- "${QWEN_IMAGE_PE_T2I_MODEL_DIR:-/data/models/qwen-image-2.1-pe-t2i-q5}/system_prompt.txt:/etc/mike-ai/qwen-image-pe-t2i-system-prompt.txt:ro"
- router-state:/var/lib/mike-ai-profile-router - router-state:/var/lib/mike-ai-profile-router
- router-images:/data/images - router-images:/data/images
environment: environment:
@@ -631,7 +637,13 @@ services:
IMAGE_DIR: /data/images IMAGE_DIR: /data/images
IMAGE_WORKER_URL: http://image-worker:8086 IMAGE_WORKER_URL: http://image-worker:8086
IMAGE_WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}" IMAGE_WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
IMAGE_MODEL_NAME: FLUX.2-klein-9B-fp8-beta IMAGE_PROMPT_I2I_URL: http://image-prompt-enhancer-i2i:8080
IMAGE_PROMPT_T2I_URL: http://image-prompt-enhancer-t2i:8080
IMAGE_PROMPT_I2I_SYSTEM_FILE: /etc/mike-ai/qwen-image-pe-i2i-system-prompt.txt
IMAGE_PROMPT_T2I_SYSTEM_FILE: /etc/mike-ai/qwen-image-pe-t2i-system-prompt.txt
IMAGE_PROMPT_ENHANCER_TIMEOUT: "180"
IMAGE_MODEL_NAME: Qwen-Image-2.1-int8
IMAGE_INFERENCE_STEPS: "25"
CHAT_IMAGE_ALLOW_REMOTE_URLS: "false" CHAT_IMAGE_ALLOW_REMOTE_URLS: "false"
ENABLE_IMAGE_GENERATION: "true" ENABLE_IMAGE_GENERATION: "true"
ENABLE_TTS: "true" ENABLE_TTS: "true"
@@ -646,7 +658,7 @@ services:
MUSIC_START_TIMEOUT: "600" MUSIC_START_TIMEOUT: "600"
VOICE_CHANGE_START_TIMEOUT: "600" VOICE_CHANGE_START_TIMEOUT: "600"
APPLIO_START_TIMEOUT: "900" APPLIO_START_TIMEOUT: "900"
STT_WORKER_URL: http://whisper:8084 STT_WORKER_URL: http://qwen-asr-worker:8084
STT_TIMEOUT: "300" STT_TIMEOUT: "300"
networks: [frontend, control, inference] networks: [frontend, control, inference]
security_opt: ["no-new-privileges:true"] security_opt: ["no-new-privileges:true"]
@@ -669,10 +681,193 @@ services:
condition: service_healthy condition: service_healthy
tts-gateway: tts-gateway:
condition: service_healthy condition: service_healthy
whisper: qwen-asr-worker:
condition: service_healthy condition: service_healthy
image-worker: image-worker:
build:
context: platform/docker/qwen-image-worker
args:
COMFYUI_COMMIT: b0f4b7b294ce482a2e071d9d762c133d38c7aa07
image: mike-ai/qwen-image-worker:local
container_name: mike-ai-image-worker
restart: "no"
profiles: [image]
labels:
com.mike-ai.image-worker: image
user: "10002:10002"
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["${QWEN_IMAGE_21_GPU:-1}"]
capabilities: [gpu]
read_only: true
tmpfs:
- /tmp:size=4g,mode=1777,uid=10002,gid=10002
- /opt/ComfyUI/user:size=64m,mode=0755,uid=10002,gid=10002
- /opt/ComfyUI/temp:size=4g,mode=0755,uid=10002,gid=10002
- /opt/ComfyUI/input:size=512m,mode=0755,uid=10002,gid=10002
volumes:
- "${QWEN_IMAGE_21_MODEL_DIR:-/data/models/Qwen-Image-2.1-ComfyUI}:/opt/ComfyUI/models:ro"
- router-images:/data/images
environment:
NVIDIA_DRIVER_CAPABILITIES: compute,utility
PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True
WORKER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
IMAGE_DIR: /data/images
networks: [inference]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8086/health', timeout=2)"]
interval: 5s
timeout: 3s
retries: 90
start_period: 10s
image-prompt-enhancer-i2i:
image: mike-ai/llama.cpp:b10930
container_name: mike-ai-image-prompt-enhancer-i2i
restart: "no"
profiles: [image]
labels:
com.mike-ai.image-worker: image-prompt-i2i
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["${QWEN_IMAGE_PE_GPU:-0}"]
capabilities: [gpu]
read_only: true
tmpfs: ["/tmp:size=512m,mode=1777"]
volumes:
- "${QWEN_IMAGE_PE_I2I_MODEL_DIR:-/data/models/qwen-image-2.1-pe-i2i-q5}:/models:ro"
command:
- --model
- /models/Qwen-Image-2.1-PE-I2I.Q5_K_M.gguf
- --mmproj
- /models/Qwen-Image-2.1-PE-I2I.mmproj-bf16.gguf
- --mmproj-offload
- --mmproj-device
- CUDA0
- --alias
- qwen-image-pe-i2i
- --ctx-size
- "16384"
- --flash-attn
- "on"
- --cache-type-k
- q8_0
- --cache-type-v
- q8_0
- --threads
- "6"
- --threads-batch
- "6"
- --batch-size
- "1024"
- --ubatch-size
- "128"
- --parallel
- "1"
- --jinja
- --reasoning
- auto
- --host
- 0.0.0.0
- --port
- "8080"
- --metrics
- --n-gpu-layers
- all
- --device
- CUDA0
- --split-mode
- none
- --no-ui
networks: [inference]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
healthcheck:
test: [CMD, curl, -fsS, "http://127.0.0.1:8080/health"]
interval: 5s
timeout: 3s
retries: 40
start_period: 10s
image-prompt-enhancer-t2i:
image: mike-ai/llama.cpp:b10930
container_name: mike-ai-image-prompt-enhancer-t2i
restart: "no"
profiles: [image]
labels:
com.mike-ai.image-worker: image-prompt-t2i
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["${QWEN_IMAGE_PE_GPU:-0}"]
capabilities: [gpu]
read_only: true
tmpfs: ["/tmp:size=512m,mode=1777"]
volumes:
- "${QWEN_IMAGE_PE_T2I_MODEL_DIR:-/data/models/qwen-image-2.1-pe-t2i-q5}:/models:ro"
command:
- --model
- /models/Qwen-Image-2.1-PE-T2I.Q5_K_M.gguf
- --alias
- qwen-image-pe-t2i
- --ctx-size
- "16384"
- --flash-attn
- "on"
- --cache-type-k
- q8_0
- --cache-type-v
- q8_0
- --threads
- "6"
- --threads-batch
- "6"
- --batch-size
- "1024"
- --ubatch-size
- "128"
- --parallel
- "1"
- --jinja
- --reasoning
- auto
- --host
- 0.0.0.0
- --port
- "8080"
- --metrics
- --n-gpu-layers
- all
- --device
- CUDA0
- --split-mode
- none
- --no-ui
networks: [inference]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
healthcheck:
test: [CMD, curl, -fsS, "http://127.0.0.1:8080/health"]
interval: 5s
timeout: 3s
retries: 40
start_period: 10s
# Previous production image model, retained as a stopped rollback target.
# It is outside the normal router path and can only be started through the
# controller's allowlisted flux-standby endpoint.
flux-image-worker:
build: build:
context: platform/docker/image-worker context: platform/docker/image-worker
args: args:
@@ -681,11 +876,11 @@ services:
ACCELERATE_VERSION: ${ACCELERATE_VERSION:-1.14.0} ACCELERATE_VERSION: ${ACCELERATE_VERSION:-1.14.0}
HF_HUB_VERSION: ${HF_HUB_VERSION:-1.28.0} HF_HUB_VERSION: ${HF_HUB_VERSION:-1.28.0}
image: mike-ai/image-worker:local image: mike-ai/image-worker:local
container_name: mike-ai-image-worker container_name: mike-ai-flux-image-worker
restart: "no" restart: "no"
profiles: [image] profiles: [flux-standby]
labels: labels:
com.mike-ai.image-worker: image com.mike-ai.image-worker: flux-standby
gpus: all gpus: all
read_only: true read_only: true
tmpfs: ["/tmp:size=1g,mode=1777"] tmpfs: ["/tmp:size=1g,mode=1777"]
@@ -709,76 +904,6 @@ services:
timeout: 3s timeout: 3s
retries: 12 retries: 12
# Isolated evaluation target. It is created in a stopped state by the
# preparation script and can only be started through the controller's
# allowlisted, GPU-exclusive test endpoint.
qwen-image-21-test:
build:
context: platform/docker/qwen-image-21-test
args:
COMFYUI_COMMIT: b0f4b7b294ce482a2e071d9d762c133d38c7aa07
image: mike-ai/qwen-image-2.1-test:local
container_name: mike-ai-qwen-image-2.1-test
restart: "no"
profiles: [qwen-image-test]
labels:
com.mike-ai.image-worker: qwen-image-2.1-test
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["${QWEN_IMAGE_21_GPU:-1}"]
capabilities: [gpu]
read_only: true
tmpfs:
- /tmp:size=4g,mode=1777
- /opt/ComfyUI/user:size=64m,mode=0755
- /opt/ComfyUI/temp:size=4g,mode=1777
volumes:
- "${QWEN_IMAGE_21_MODEL_DIR:-/data/models/Qwen-Image-2.1-ComfyUI}:/opt/ComfyUI/models:ro"
- "${QWEN_IMAGE_21_OUTPUT_DIR:-/data/qwen-image-2.1-test-output}:/opt/ComfyUI/output"
environment:
NVIDIA_DRIVER_CAPABILITIES: compute,utility
PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True
command:
- python
- main.py
- --listen
- 0.0.0.0
- --port
- "8188"
- --lowvram
- --preview-method
- none
networks: [inference]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8188/system_stats', timeout=2)"]
interval: 5s
timeout: 3s
retries: 60
start_period: 10s
qwen-image-21-test-runner:
image: python:3.12-slim
profiles: [qwen-image-test]
read_only: true
tmpfs: ["/tmp:size=32m,mode=1777"]
volumes:
- ./scripts/qwen-image-21-test.py:/opt/test/run.py:ro
- "${QWEN_IMAGE_21_OUTPUT_DIR:-/data/qwen-image-2.1-test-output}:/output"
environment:
CONTROLLER_URL: http://profile-controller:8090
CONTROLLER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}"
COMFY_URL: http://qwen-image-21-test:8188
OUTPUT_DIR: /output
command: [python, /opt/test/run.py]
networks: [control, inference]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
qwen3-tts: qwen3-tts:
image: ${QWEN3_TTS_IMAGE:-ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98} image: ${QWEN3_TTS_IMAGE:-ghcr.io/malaiwah/qwen3-tts-server:latest@sha256:b363a01d08b1bbecbfc3ca6f585368fae2cfdc591f9ecca6643738369f9a9d98}
container_name: mike-ai-qwen3-tts container_name: mike-ai-qwen3-tts
@@ -851,40 +976,77 @@ services:
retries: 12 retries: 12
start_period: 10s start_period: 10s
whisper: qwen-asr:
build: image: ${LLAMA_CPU_IMAGE:-mike-ai/llama.cpp-cpu:local}
context: . container_name: mike-ai-qwen-asr
dockerfile: platform/docker/whisper/Dockerfile
args:
WHISPER_CPP_VERSION: ${WHISPER_CPP_VERSION:-v1.9.1}
image: mike-ai/whisper:local
container_name: mike-ai-whisper
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp:size=2g,mode=1777 - /tmp:size=256m,mode=1777
volumes: volumes:
- whisper-data:/models - "${QWEN_ASR_MODEL_DIR:-/data/models/qwen3-asr-0.6b-q8}:/models:ro"
environment: command:
WHISPER_HOST: 0.0.0.0 - --model
WHISPER_PORT: "8084" - /models/Qwen3-ASR-0.6B-Q8_0.gguf
WHISPER_CLI: /opt/whisper.cpp/build/bin/whisper-cli - --mmproj
WHISPER_MODEL: /models/ggml-small.bin - /models/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf
WHISPER_MODEL_URL: ${WHISPER_MODEL_URL:-https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin} - --no-mmproj-offload
WHISPER_SERVER_PORT: "8085" - --n-gpu-layers
WHISPER_THREADS: ${WHISPER_THREADS:-8} - "0"
WHISPER_LANGUAGE: ${WHISPER_LANGUAGE:-de} - --alias
networks: [frontend, inference] - qwen3-asr-0.6b
- --ctx-size
- "4096"
- --threads
- "6"
- --parallel
- "1"
- --host
- 0.0.0.0
- --port
- "8080"
- --no-ui
- --fit
- "off"
cpus: 6
mem_limit: 6g
networks: [inference]
security_opt: ["no-new-privileges:true"] security_opt: ["no-new-privileges:true"]
cap_drop: [ALL] cap_drop: [ALL]
# The entrypoint supervises whisper-server after dropping it to uid 10004.
cap_add: [CHOWN, SETUID, SETGID, KILL]
healthcheck: healthcheck:
test: [CMD, curl, -fsS, "http://127.0.0.1:8084/status"] test: [CMD, curl, -fsS, "http://127.0.0.1:8080/health"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 90 retries: 12
start_period: 20m start_period: 30s
qwen-asr-worker:
build:
context: .
dockerfile: platform/docker/qwen-asr-worker/Dockerfile
image: mike-ai/qwen-asr-worker:local
container_name: mike-ai-qwen-asr-worker
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:size=256m,mode=1777
environment:
QWEN_ASR_HOST: 0.0.0.0
QWEN_ASR_PORT: "8084"
QWEN_ASR_LANGUAGE: de
QWEN_ASR_SERVER_URL: http://qwen-asr:8080
networks: [inference]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
depends_on:
qwen-asr:
condition: service_healthy
healthcheck:
test: [CMD, python, -c, "import json,urllib.request; assert json.load(urllib.request.urlopen('http://127.0.0.1:8084/status', timeout=2))['ready']"]
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
llama-dashboard: llama-dashboard:
build: ./platform/llama-dashboard build: ./platform/llama-dashboard
@@ -999,7 +1161,6 @@ networks:
name: mike-ai-tools-egress name: mike-ai-tools-egress
volumes: volumes:
whisper-data:
router-state: router-state:
router-images: router-images:
portainer-data: portainer-data:
+2 -2
View File
@@ -47,9 +47,9 @@
"model_env": "ULTRA_MODEL_FILE", "model_env": "ULTRA_MODEL_FILE",
"model_family": "Qwen3.8-27B IQ4 XS Pure", "model_family": "Qwen3.8-27B IQ4 XS Pure",
"gpu_split": "80:20", "gpu_split": "80:20",
"vision": false, "vision": true,
"mtp": 2, "mtp": 2,
"description": "Maximaler Textkontext; bewusst ohne Vision-Projektor." "description": "Maximaler Kontext mit Vision-Projektor auf der CPU."
}, },
{ {
"id": "uncensored", "id": "uncensored",
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Mock-STT-Worker für lokale Tests. """Mock-STT-Worker für lokale Tests.
Simuliert den Whisper-STT-Worker: Simuliert den Qwen3-ASR-Adapter:
GET /status → ready: true GET /status → ready: true
POST /transcribe → liefert festes Transkript POST /transcribe → liefert festes Transkript
+8 -8
View File
@@ -220,7 +220,7 @@ def main() -> None:
# Test 1: Multipart + Content-Length (bestehender Pfad) # Test 1: Multipart + Content-Length (bestehender Pfad)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 1: Multipart + Content-Length") print("Test 1: Multipart + Content-Length")
mp = build_multipart({"model": "whisper-1", "language": "de"}, mp = build_multipart({"model": "qwen3-asr", "language": "de"},
file_data=fake_webm) file_data=fake_webm)
status, body = http_request( status, body = http_request(
"POST", PORTS["router"], "/v1/audio/transcriptions", "POST", PORTS["router"], "/v1/audio/transcriptions",
@@ -235,7 +235,7 @@ def main() -> None:
# Test 2: Multipart + Transfer-Encoding chunked (einfach) # Test 2: Multipart + Transfer-Encoding chunked (einfach)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 2: Multipart + chunked (einfach)") print("Test 2: Multipart + chunked (einfach)")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
chunked = build_chunked_body([mp]) chunked = build_chunked_body([mp])
raw = ( raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n" b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
@@ -254,7 +254,7 @@ def main() -> None:
# Test 3: Mehrere unterschiedlich große Chunks # Test 3: Mehrere unterschiedlich große Chunks
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 3: Mehrere unterschiedlich große Chunks") print("Test 3: Mehrere unterschiedlich große Chunks")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
# In 5 Chunks aufteilen (unterschiedlich groß) # In 5 Chunks aufteilen (unterschiedlich groß)
chunks = [] chunks = []
sizes = [10, 50, 7, 100, 33] sizes = [10, 50, 7, 100, 33]
@@ -283,7 +283,7 @@ def main() -> None:
# Test 4: Boundary über Chunk-Grenzen verteilt # Test 4: Boundary über Chunk-Grenzen verteilt
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 4: Boundary über Chunk-Grenzen verteilt") print("Test 4: Boundary über Chunk-Grenzen verteilt")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
# Boundary-String finden und Chunk-Grenze genau dorthin setzen # Boundary-String finden und Chunk-Grenze genau dorthin setzen
boundary_str = b"--testboundary123" boundary_str = b"--testboundary123"
idx = mp.find(boundary_str, 10) # zweite Boundary (vor file) idx = mp.find(boundary_str, 10) # zweite Boundary (vor file)
@@ -310,7 +310,7 @@ def main() -> None:
# Test 5: Chunk Extensions # Test 5: Chunk Extensions
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 5: Chunk Extensions") print("Test 5: Chunk Extensions")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
chunks_ext = [ chunks_ext = [
(mp[:20], "ext1=value1"), (mp[:20], "ext1=value1"),
(mp[20:60], None), (mp[20:60], None),
@@ -335,7 +335,7 @@ def main() -> None:
# hier explizit mit Trailer) # hier explizit mit Trailer)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
print("Test 6: 0-Chunk mit Trailer") print("Test 6: 0-Chunk mit Trailer")
mp = build_multipart({"model": "whisper-1"}, file_data=fake_webm) mp = build_multipart({"model": "qwen3-asr"}, file_data=fake_webm)
chunked = build_chunked_body([mp]) chunked = build_chunked_body([mp])
# Trailer hinzufügen # Trailer hinzufügen
chunked_with_trailer = chunked.replace( chunked_with_trailer = chunked.replace(
@@ -376,7 +376,7 @@ def main() -> None:
print("Test 8: Uploadgrößenlimit") print("Test 8: Uploadgrößenlimit")
# MAX_UPLOAD_SIZE = 1 MB, also 2 MB senden # MAX_UPLOAD_SIZE = 1 MB, also 2 MB senden
big_data = b"A" * (2 * 1024 * 1024) big_data = b"A" * (2 * 1024 * 1024)
mp = build_multipart({"model": "whisper-1"}, file_data=big_data) mp = build_multipart({"model": "qwen3-asr"}, file_data=big_data)
chunked = build_chunked_body([mp[:1024 * 1024], mp[1024 * 1024:]]) chunked = build_chunked_body([mp[:1024 * 1024], mp[1024 * 1024:]])
raw = ( raw = (
b"POST /v1/audio/transcriptions HTTP/1.1\r\n" b"POST /v1/audio/transcriptions HTTP/1.1\r\n"
@@ -402,7 +402,7 @@ def main() -> None:
+ b"WEBM_OPUS_AUDIO_DATA" * 100) + b"WEBM_OPUS_AUDIO_DATA" * 100)
boundary = "950bd961b24c4a32801e31b128c85e09" boundary = "950bd961b24c4a32801e31b128c85e09"
mp = build_multipart( mp = build_multipart(
{"model": "whisper-1", "language": "de"}, {"model": "qwen3-asr", "language": "de"},
file_data=webm_data, file_data=webm_data,
filename="recording.webm", filename="recording.webm",
boundary=boundary, boundary=boundary,
+1 -1
View File
@@ -364,7 +364,7 @@ png = open('/tmp/test_dl.png', 'rb').read()
print(json.dumps({ print(json.dumps({
'prompt': 'Behalte die Person bei und ändere nur den Hintergrund', 'prompt': 'Behalte die Person bei und ändere nur den Hintergrund',
'size': '1024x1024', 'size': '1024x1024',
'steps': 4, 'steps': 25,
'guidance': 1.0, 'guidance': 1.0,
'response_format': 'b64_json', 'response_format': 'b64_json',
'image_b64': base64.b64encode(png).decode(), 'image_b64': base64.b64encode(png).decode(),
+12 -12
View File
@@ -71,13 +71,13 @@ def test_quoted_boundary():
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW" boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
webm = b"\x1a\x45\xdf\xa3" + b"\x00\x01\x02\x03\xff\xfe\xfd" * 50 webm = b"\x1a\x45\xdf\xa3" + b"\x00\x01\x02\x03\xff\xfe\xfd" * 50
body, ct = build( body, ct = build(
[("model", "whisper-1", None), ("file", webm, "t.webm")], [("model", "qwen3-asr", None), ("file", webm, "t.webm")],
boundary, quoted=True, boundary, quoted=True,
) )
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch" assert fd == webm, "file_data mismatch"
assert fn == "t.webm", f"filename mismatch: {fn!r}" assert fn == "t.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
print(" quoted boundary: OK") print(" quoted boundary: OK")
@@ -109,7 +109,7 @@ def test_openwebui_style():
f"\r\n--{boundary}\r\n" f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="model"\r\n' f'Content-Disposition: form-data; name="model"\r\n'
f"\r\n" f"\r\n"
f"whisper-1\r\n" f"qwen3-asr\r\n"
f"--{boundary}\r\n" f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="temperature"\r\n' f'Content-Disposition: form-data; name="temperature"\r\n'
f"\r\n" f"\r\n"
@@ -119,7 +119,7 @@ def test_openwebui_style():
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch" assert fd == webm, "file_data mismatch"
assert fn == "rec.webm", f"filename mismatch: {fn!r}" assert fn == "rec.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
assert fl["temperature"] == "0.0", f"temperature mismatch: {fl!r}" assert fl["temperature"] == "0.0", f"temperature mismatch: {fl!r}"
print(" Open-WebUI-artig: OK") print(" Open-WebUI-artig: OK")
@@ -128,13 +128,13 @@ def test_file_before_model():
"""File-Feld vor model-Feld.""" """File-Feld vor model-Feld."""
boundary = "boundary123" boundary = "boundary123"
body, ct = build( body, ct = build(
[("file", b"DATA", "f.wav"), ("model", "whisper-1", None)], [("file", b"DATA", "f.wav"), ("model", "qwen3-asr", None)],
boundary, quoted=False, boundary, quoted=False,
) )
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == b"DATA", "file_data mismatch" assert fd == b"DATA", "file_data mismatch"
assert fn == "f.wav", f"filename mismatch: {fn!r}" assert fn == "f.wav", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
print(" File vor model: OK") print(" File vor model: OK")
@@ -142,13 +142,13 @@ def test_file_after_model():
"""model-Feld vor File-Feld.""" """model-Feld vor File-Feld."""
boundary = "boundary456" boundary = "boundary456"
body, ct = build( body, ct = build(
[("model", "whisper-1", None), ("file", b"DATA", "g.wav")], [("model", "qwen3-asr", None), ("file", b"DATA", "g.wav")],
boundary, quoted=False, boundary, quoted=False,
) )
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == b"DATA", "file_data mismatch" assert fd == b"DATA", "file_data mismatch"
assert fn == "g.wav", f"filename mismatch: {fn!r}" assert fn == "g.wav", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
print(" File nach model: OK") print(" File nach model: OK")
@@ -182,13 +182,13 @@ def test_extra_headers_ignored():
f"\r\n--{boundary}\r\n" f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="model"\r\n' f'Content-Disposition: form-data; name="model"\r\n'
f"\r\n" f"\r\n"
f"whisper-1\r\n" f"qwen3-asr\r\n"
f"--{boundary}--\r\n" f"--{boundary}--\r\n"
).encode() ).encode()
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch" assert fd == webm, "file_data mismatch"
assert fn == "x.webm", f"filename mismatch: {fn!r}" assert fn == "x.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
print(" Extra-Header ignoriert: OK") print(" Extra-Header ignoriert: OK")
@@ -198,7 +198,7 @@ def test_all_fields():
body, ct = build( body, ct = build(
[ [
("file", b"AUDIO", "a.webm"), ("file", b"AUDIO", "a.webm"),
("model", "whisper-1", None), ("model", "qwen3-asr", None),
("language", "de", None), ("language", "de", None),
("prompt", "Kontext", None), ("prompt", "Kontext", None),
("response_format", "verbose_json", None), ("response_format", "verbose_json", None),
@@ -209,7 +209,7 @@ def test_all_fields():
fd, fn, fl = parse_multipart(body, ct) fd, fn, fl = parse_multipart(body, ct)
assert fd == b"AUDIO", "file_data mismatch" assert fd == b"AUDIO", "file_data mismatch"
assert fn == "a.webm", f"filename mismatch: {fn!r}" assert fn == "a.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}" assert fl["model"] == "qwen3-asr", f"model mismatch: {fl!r}"
assert fl["language"] == "de", f"language mismatch: {fl!r}" assert fl["language"] == "de", f"language mismatch: {fl!r}"
assert fl["prompt"] == "Kontext", f"prompt mismatch: {fl!r}" assert fl["prompt"] == "Kontext", f"prompt mismatch: {fl!r}"
assert fl["response_format"] == "verbose_json", f"response_format mismatch: {fl!r}" assert fl["response_format"] == "verbose_json", f"response_format mismatch: {fl!r}"
+16 -16
View File
@@ -23,7 +23,7 @@ def item(profile, state="exited"):
def image_item(state="exited"): def image_item(state="exited"):
return {"Id": "id-flux", "State": state, return {"Id": "id-qwen-image", "State": state,
"Labels": {controller.IMAGE_LABEL_KEY: controller.IMAGE_WORKER}} "Labels": {controller.IMAGE_LABEL_KEY: controller.IMAGE_WORKER}}
@@ -32,9 +32,9 @@ def restore_item(state="exited"):
"Labels": {controller.IMAGE_LABEL_KEY: controller.RESTORE_WORKER}} "Labels": {controller.IMAGE_LABEL_KEY: controller.RESTORE_WORKER}}
def qwen_image_test_item(state="exited"): def flux_standby_item(state="exited"):
return {"Id": "id-qwen-image-test", "State": state, return {"Id": "id-flux-standby", "State": state,
"Labels": {controller.IMAGE_LABEL_KEY: controller.QWEN_IMAGE_TEST_WORKER}} "Labels": {controller.IMAGE_LABEL_KEY: controller.FLUX_STANDBY_WORKER}}
def tts_item(state="running"): def tts_item(state="running"):
@@ -97,7 +97,7 @@ class ProfileControllerTests(unittest.TestCase):
self.assertEqual(result, {"music_worker": "acestep", "state": "running"}) self.assertEqual(result, {"music_worker": "acestep", "state": "running"})
self.assertEqual(calls, [ self.assertEqual(calls, [
("POST", "/containers/id-ultra/stop?t=120"), ("POST", "/containers/id-ultra/stop?t=120"),
("POST", "/containers/id-flux/stop?t=20"), ("POST", "/containers/id-qwen-image/stop?t=20"),
("POST", "/containers/id-tts/stop?t=30"), ("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-music/start"), ("POST", "/containers/id-music/start"),
]) ])
@@ -158,10 +158,10 @@ class ProfileControllerTests(unittest.TestCase):
self.assertEqual(calls, [ self.assertEqual(calls, [
("POST", "/containers/id-medium/stop?t=120"), ("POST", "/containers/id-medium/stop?t=120"),
("POST", "/containers/id-tts/stop?t=30"), ("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-flux/start"), ("POST", "/containers/id-qwen-image/start"),
]) ])
def test_restore_start_stops_flux_and_starts_restore(self): def test_restore_start_stops_qwen_image_and_starts_restore(self):
profiles = {name: item(name) for name in controller.ALLOWED} profiles = {name: item(name) for name in controller.ALLOWED}
calls = [] calls = []
@@ -178,11 +178,11 @@ class ProfileControllerTests(unittest.TestCase):
controller.set_image_worker(True, controller.RESTORE_WORKER) controller.set_image_worker(True, controller.RESTORE_WORKER)
self.assertEqual(calls, [ self.assertEqual(calls, [
("POST", "/containers/id-tts/stop?t=30"), ("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-flux/stop?t=20"), ("POST", "/containers/id-qwen-image/stop?t=20"),
("POST", "/containers/id-restore/start"), ("POST", "/containers/id-restore/start"),
]) ])
def test_qwen_image_test_is_allowlisted_and_exclusive(self): def test_flux_standby_is_allowlisted_and_exclusive(self):
profiles = {name: item(name) for name in controller.ALLOWED} profiles = {name: item(name) for name in controller.ALLOWED}
profiles["medium"] = item("medium", "running") profiles["medium"] = item("medium", "running")
calls = [] calls = []
@@ -192,21 +192,21 @@ class ProfileControllerTests(unittest.TestCase):
return 204, b"" return 204, b""
with patch.object(controller, "containers", return_value=profiles), \ with patch.object(controller, "containers", return_value=profiles), \
patch.object(controller, "image_container", return_value=qwen_image_test_item()), \ patch.object(controller, "image_container", return_value=flux_standby_item()), \
patch.object(controller, "image_containers", return_value=[ patch.object(controller, "image_containers", return_value=[
image_item("running"), restore_item(), qwen_image_test_item()]), \ image_item("running"), restore_item(), flux_standby_item()]), \
patch.object(controller, "tts_container", return_value=tts_item()), \ patch.object(controller, "tts_container", return_value=tts_item()), \
patch.object(controller, "docker_request", side_effect=request): patch.object(controller, "docker_request", side_effect=request):
result = controller.set_image_worker( result = controller.set_image_worker(
True, controller.QWEN_IMAGE_TEST_WORKER) True, controller.FLUX_STANDBY_WORKER)
self.assertEqual(result, { self.assertEqual(result, {
"image_worker": "qwen-image-2.1-test", "state": "running"}) "image_worker": "flux-standby", "state": "running"})
self.assertEqual(calls, [ self.assertEqual(calls, [
("POST", "/containers/id-medium/stop?t=120"), ("POST", "/containers/id-medium/stop?t=120"),
("POST", "/containers/id-tts/stop?t=30"), ("POST", "/containers/id-tts/stop?t=30"),
("POST", "/containers/id-flux/stop?t=20"), ("POST", "/containers/id-qwen-image/stop?t=20"),
("POST", "/containers/id-qwen-image-test/start"), ("POST", "/containers/id-flux-standby/start"),
]) ])
def test_profile_activation_stops_image_worker_first(self): def test_profile_activation_stops_image_worker_first(self):
@@ -224,7 +224,7 @@ class ProfileControllerTests(unittest.TestCase):
patch.object(controller, "docker_request", side_effect=request): patch.object(controller, "docker_request", side_effect=request):
controller.activate("fast") controller.activate("fast")
self.assertEqual(calls, [ self.assertEqual(calls, [
("POST", "/containers/id-flux/stop?t=120"), ("POST", "/containers/id-qwen-image/stop?t=120"),
("POST", "/containers/id-fast/start"), ("POST", "/containers/id-fast/start"),
]) ])
+47
View File
@@ -0,0 +1,47 @@
import importlib.util
import os
import pathlib
import unittest
os.environ.setdefault("WORKER_TOKEN", "x" * 48)
PATH = (pathlib.Path(__file__).parents[1]
/ "platform/docker/qwen-image-worker/qwen_image_worker.py")
SPEC = importlib.util.spec_from_file_location("qwen_image_worker", PATH)
worker = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(worker)
class QwenImageWorkerTests(unittest.TestCase):
def test_text_to_image_uses_empty_latent(self):
workflow = worker.make_workflow("test", 7, 25, 1024, 1024, [], "job")
self.assertEqual(workflow["6"]["inputs"]["latent_image"], ["5", 0])
self.assertIn("5", workflow)
self.assertNotIn("vae", workflow["4"]["inputs"])
def test_edit_uses_reference_latent_and_qwen_dynamic_inputs(self):
workflow = worker.make_workflow(
"edit <image1>", 7, 25, 1024, 1024,
["job-ref-1.png", "job-ref-2.jpg"], "job")
encode = workflow["4"]["inputs"]
self.assertEqual(workflow["6"]["inputs"]["latent_image"], ["4", 2])
self.assertEqual(encode["vae"], ["3", 0])
self.assertEqual(encode["images.image_1"], ["9", 0])
self.assertEqual(encode["images.image_2"], ["10", 0])
self.assertNotIn("5", workflow)
def test_router_contract_is_fixed_to_production_parameters(self):
request = {
"prompt": "test", "filename": "result.png", "width": 1024,
"height": 1024, "steps": 25, "guidance": 1.0, "seed": 42,
}
self.assertEqual(worker.validate_request(request)[1:],
("result.png", 1024, 1024, 25, 1.0, 42))
request["steps"] = 4
with self.assertRaisesRegex(ValueError, "steps=25"):
worker.validate_request(request)
if __name__ == "__main__":
unittest.main()
+21
View File
@@ -3,6 +3,7 @@ import json
import os import os
import sys import sys
import threading import threading
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -13,6 +14,26 @@ import ai_profile_router as router
class CoordinationTests(unittest.TestCase): class CoordinationTests(unittest.TestCase):
def test_prompt_enhancer_accepts_plain_and_fenced_json(self):
plain = router._parse_prompt_enhancer_result(
'{"rewritten_prompt":"new scene","wh_ratio":"3:2"}')
fenced = router._parse_prompt_enhancer_result(
'```json\n{"rewritten_prompt":"portrait","wh_ratio":"3:4"}\n```')
self.assertEqual(plain['rewritten_prompt'], 'new scene')
self.assertEqual(fenced['wh_ratio'], '3:4')
def test_prompt_enhancer_rejects_missing_rewrite(self):
with self.assertRaisesRegex(RuntimeError, 'rewritten_prompt'):
router._parse_prompt_enhancer_result('{"wh_ratio":"1:1"}')
def test_image_data_url_resolves_volume_relative_reference(self):
with tempfile.TemporaryDirectory() as directory, \
patch.object(router, 'IMAGE_DIR', directory):
path = Path(directory) / '.edit-reference.ref'
path.write_bytes(b'\x89PNG\r\n\x1a\ncontent')
result = router._image_data_url(path.name)
self.assertTrue(result.startswith('data:image/png;base64,'))
def test_mode_uses_one_consistent_controller_snapshot(self): def test_mode_uses_one_consistent_controller_snapshot(self):
with patch.object(router, 'PROFILE_CONTROL_URL', 'http://controller'), \ with patch.object(router, 'PROFILE_CONTROL_URL', 'http://controller'), \
patch.object(router, '_profile_controller_request', return_value={ patch.object(router, '_profile_controller_request', return_value={
+47
View File
@@ -24,6 +24,7 @@ from router_support import ( # noqa: E402
load_profile_registry, load_profile_registry,
) )
from ai_profile_router import ( # noqa: E402 from ai_profile_router import ( # noqa: E402
Handler,
STATE, STATE,
_cap_chat_generation, _cap_chat_generation,
_context_matches, _context_matches,
@@ -141,6 +142,52 @@ class ChatImageInputTests(unittest.TestCase):
self.assertEqual(request, normalized) self.assertEqual(request, normalized)
class ImageEditMultipartTests(unittest.TestCase):
def test_openai_image_array_upload_keeps_all_references(self) -> None:
boundary = "OpenClawImageBoundary"
parts = [
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="model"\r\n\r\n'
"Qwen-Image-2.1-int8\r\n"
).encode(),
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="prompt"\r\n\r\n'
"Nur die Farbe ändern\r\n"
).encode(),
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="n"\r\n\r\n'
"1\r\n"
).encode(),
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="image[]"; filename="a.png"\r\n'
"Content-Type: image/png\r\n\r\n"
).encode() + b"PNG-A\r\n",
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="image[]"; filename="b.png"\r\n'
"Content-Type: image/png\r\n\r\n"
).encode() + b"PNG-B\r\n",
f"--{boundary}--\r\n".encode(),
]
handler = object.__new__(Handler)
files, fields = handler._parse_multipart_parts(
b"".join(parts), f"multipart/form-data; boundary={boundary}")
self.assertEqual(fields["model"], "Qwen-Image-2.1-int8")
self.assertEqual(fields["prompt"], "Nur die Farbe ändern")
normalized = handler._normalize_image_multipart_fields(fields)
self.assertEqual(normalized["n"], 1)
self.assertEqual(
files,
[("image[]", "a.png", b"PNG-A"),
("image[]", "b.png", b"PNG-B")],
)
class LlamaCppReasoningTests(unittest.TestCase): class LlamaCppReasoningTests(unittest.TestCase):
def test_disabled_values_really_disable_thinking(self) -> None: def test_disabled_values_really_disable_thinking(self) -> None:
for effort in (None, "none", "off", "disabled", False): for effort in (None, "none", "off", "disabled", False):
+4 -3
View File
@@ -12,7 +12,7 @@ flowchart LR
H -->|OpenAI API| R[Profile Router<br/>Athena :8081] H -->|OpenAI API| R[Profile Router<br/>Athena :8081]
R --> P[Profile Controller] R --> P[Profile Controller]
P --> Q[genau ein llama.cpp-Profil<br/>Qwen Fast / Medium / Large / Ultra / Uncensored] P --> Q[genau ein llama.cpp-Profil<br/>Qwen Fast / Medium / Large / Ultra / Uncensored]
R --> I[FLUX.2 Klein 9B FP8 Beta<br/>RTX 5080, Text + Editing] R --> I[Qwen-Image-2.1 INT8<br/>RTX 5080, Text + Editing]
R --> T[Qwen3-TTS 1.7B RTX 3060<br/>TTS-Gateway] R --> T[Qwen3-TTS 1.7B RTX 3060<br/>TTS-Gateway]
R --> STT[Whisper.cpp small<br/>CPU, lokale Spracherkennung] R --> STT[Whisper.cpp small<br/>CPU, lokale Spracherkennung]
@@ -32,8 +32,9 @@ flowchart LR
K[Backup alle 5 Stunden] --> DATA[/data und /etc/mike-ai] K[Backup alle 5 Stunden] --> DATA[/data und /etc/mike-ai]
``` ```
Stand: 20. September 2026. Versionen und Abweichungen: [Live-Stand](LIVE_STATE.md). Stand: 21. September 2026. Versionen und Abweichungen: [Live-Stand](LIVE_STATE.md).
FLUX nutzt beide GPUs und pausiert dafür LLM und Qwen3-TTS. Dashboard und Qwen Image nutzt die RTX 5080 und pausiert dafür LLM und Qwen3-TTS. FLUX
bleibt als gestoppter Rückfallworker vorhanden. Dashboard und
Portainer besitzen eigene Netzwerk-Namespaces in `mike-ai_frontend`; der Router Portainer besitzen eigene Netzwerk-Namespaces in `mike-ai_frontend`; der Router
läuft in `mike-ai_control`. Der Gateway vermittelt den privaten Zugriff. läuft in `mike-ai_control`. Der Gateway vermittelt den privaten Zugriff.
Zusätzliche Musik-, Audio-, Voice- und 3D-Container stehen im Zusätzliche Musik-, Audio-, Voice- und 3D-Container stehen im
+12 -7
View File
@@ -2,7 +2,7 @@
Stand: 21. September 2026 Stand: 21. September 2026
Athena hat 31 reguläre Docker-Container. Nicht jeder Container enthält Athena hat 33 reguläre Docker-Container. Nicht jeder Container enthält
ein KI-Modell: Router, Oberflächen, Netzwerk, Steuerung und Sicherung sind ein KI-Modell: Router, Oberflächen, Netzwerk, Steuerung und Sicherung sind
gewöhnliche Dienste. Die rechenintensiven GPU-Worker werden absichtlich nur bei gewöhnliche Dienste. Die rechenintensiven GPU-Worker werden absichtlich nur bei
Bedarf gestartet. Ein Container im Zustand `Created` oder `Exited (0)` ist daher Bedarf gestartet. Ein Container im Zustand `Created` oder `Exited (0)` ist daher
@@ -14,12 +14,15 @@ nicht automatisch ein ungenutzter Rest.
| `mike-ai-embedding` | EmbeddingGemma 300M QAT Q8 | Dauerhafter CPU-only-Endpunkt für OpenClaws semantische und hybride Memory-Suche; teilt ausschließlich den privaten Netzwerk-Namespace des WireGuard-Gateways. | | `mike-ai-embedding` | EmbeddingGemma 300M QAT Q8 | Dauerhafter CPU-only-Endpunkt für OpenClaws semantische und hybride Memory-Suche; teilt ausschließlich den privaten Netzwerk-Namespace des WireGuard-Gateways. |
| `mike-ai-bonsai2-ab` | Bonsai-2-Vergleichsmodell | Gestoppter, reproduzierbar dokumentierter A/B-Testcontainer; kein Produktivprofil. | | `mike-ai-bonsai2-ab` | Bonsai-2-Vergleichsmodell | Gestoppter, reproduzierbar dokumentierter A/B-Testcontainer; kein Produktivprofil. |
| `mike-ai-applio-studio` | Applio/RVC; Stimmenmodelle werden nutzerseitig ergänzt | Vollständige RVC-Oberfläche für Inferenz, Modellverwaltung und Training auf der RTX 5080. Für eine Konvertierung ist ein importiertes oder trainiertes `.pth`-Modell nötig; eine Referenzaufnahme allein reicht nicht. | | `mike-ai-applio-studio` | Applio/RVC; Stimmenmodelle werden nutzerseitig ergänzt | Vollständige RVC-Oberfläche für Inferenz, Modellverwaltung und Training auf der RTX 5080. Für eine Konvertierung ist ein importiertes oder trainiertes `.pth`-Modell nötig; eine Referenzaufnahme allein reicht nicht. |
| `mike-ai-image-worker` | FLUX.2 Klein 9B FP8, Qwen3-8B NF4 Textencoder und VAE | Erzeugt und bearbeitet Bilder transaktional; nutzt während eines Auftrags RTX 5080 und RTX 3060. | | `mike-ai-image-worker` | Qwen-Image-2.1 INT8, Qwen3-VL-8B INT8 und VAE | Produktiver Bildworker; erzeugt und bearbeitet Bilder transaktional auf der RTX 5080. |
| `mike-ai-image-prompt-enhancer-t2i` | Qwen-Image-2.1 PE-T2I Q5_K_M | Kurzlebiger offizieller Prompt-Aufbereiter für reine Textaufträge auf der RTX 3060; außerhalb eines Bildauftrags gestoppt. |
| `mike-ai-image-prompt-enhancer-i2i` | Qwen-Image-2.1 PE-I2I Q5_K_M mit BF16-MMProj | Kurzlebiger offizieller Prompt-Aufbereiter für bis zu vier Referenzbilder auf der RTX 3060; außerhalb eines Bildauftrags gestoppt. |
| `mike-ai-flux-image-worker` | FLUX.2 Klein 9B FP8, Qwen3-8B NF4 Textencoder und VAE | Gestoppter Rückfallworker; wird vom normalen Router-Bildpfad nicht gestartet. |
| `mike-ai-llama-dashboard` | kein Modell | Zeigt Telemetrie, Profile, GPU-Nutzung, Slot-Kontextbelegung mit Verlauf und Betriebsarten an und bietet die Modusumschaltung. | | `mike-ai-llama-dashboard` | kein Modell | Zeigt Telemetrie, Profile, GPU-Nutzung, Slot-Kontextbelegung mit Verlauf und Betriebsarten an und bietet die Modusumschaltung. |
| `mike-ai-llama-fast` | Qwen3.8-27B `IQ4-MIX`, Qwen-MMProj BF16 | Schnelles Q4-Text-/Vision-Profil mit 76.800 Token Kontext. | | `mike-ai-llama-fast` | Qwen3.8-27B `IQ4-MIX`, Qwen-MMProj BF16 | Schnelles Q4-Text-/Vision-Profil mit 76.800 Token Kontext. |
| `mike-ai-llama-large` | Qwen3.8-27B `IQ4_XS-pure`, Qwen-MMProj BF16 | Q4-Text-/Vision-Profil mit 192.000 Token Kontext und Verteilung auf beide GPUs. | | `mike-ai-llama-large` | Qwen3.8-27B `IQ4_XS-pure`, Qwen-MMProj BF16 | Q4-Text-/Vision-Profil mit 192.000 Token Kontext und Verteilung auf beide GPUs. |
| `mike-ai-llama-medium` | Qwen3.8-27B `IQ4_XS-pure`, Qwen-MMProj BF16 | Standard-Q4-Text-/Vision-Profil mit gemeinsamem 160.000-Token-KV-Pool, aktuell zwei Slots und Verteilung auf beide GPUs. | | `mike-ai-llama-medium` | Qwen3.8-27B `IQ4_XS-pure`, Qwen-MMProj BF16 | Standard-Q4-Text-/Vision-Profil mit gemeinsamem 160.000-Token-KV-Pool, aktuell zwei Slots und Verteilung auf beide GPUs. |
| `mike-ai-llama-ultra` | Qwen3.8-27B `IQ4_XS-pure`, ohne Vision-Projektor | Maximales Langkontextprofil mit 262.144 Token Kontext und Verteilung auf beide GPUs. | | `mike-ai-llama-ultra` | Qwen3.8-27B `IQ4_XS-pure`, Qwen-MMProj BF16 auf CPU | Text-/Vision-Profil mit 262.144 Token Kontext und Verteilung des Textmodells auf beide GPUs. |
| `mike-ai-llama-uncensored` | Qwen3.8-27B Abliterated `Q4_K_M`, eigener MMProj F16 | Spezialprofil mit 80.000 Token Kontext und gelockerten Modellgrenzen. | | `mike-ai-llama-uncensored` | Qwen3.8-27B Abliterated `Q4_K_M`, eigener MMProj F16 | Spezialprofil mit 80.000 Token Kontext und gelockerten Modellgrenzen. |
| `mike-ai-ltx2-studio` | LTX-Video-Backend | GPU-Worker für lokale Videogenerierung; beim Abgleich gestoppt. | | `mike-ai-ltx2-studio` | LTX-Video-Backend | GPU-Worker für lokale Videogenerierung; beim Abgleich gestoppt. |
| `mike-ai-mcp-athena-operator` | kein Modell | Stellt Hermes begrenzte Werkzeuge zum Prüfen, Ändern, Testen, Sichern und Versionieren von Athena bereit. | | `mike-ai-mcp-athena-operator` | kein Modell | Stellt Hermes begrenzte Werkzeuge zum Prüfen, Ändern, Testen, Sichern und Versionieren von Athena bereit. |
@@ -29,13 +32,14 @@ nicht automatisch ein ungenutzter Rest.
| `mike-ai-portainer` | kein Modell; Portainer CE | Optionale Docker-Verwaltungsoberfläche. | | `mike-ai-portainer` | kein Modell; Portainer CE | Optionale Docker-Verwaltungsoberfläche. |
| `mike-ai-profile-controller` | kein Modell | Startet und stoppt ausschließlich freigegebene Modellprofile und Spezialworker in einer sicheren Reihenfolge. | | `mike-ai-profile-controller` | kein Modell | Startet und stoppt ausschließlich freigegebene Modellprofile und Spezialworker in einer sicheren Reihenfolge. |
| `mike-ai-qwen-cron-test` | kleines Qwen-Testmodell | Gestoppter CPU-/Cron-Worker-Versuch; nicht produktiv eingesetzt. | | `mike-ai-qwen-cron-test` | kleines Qwen-Testmodell | Gestoppter CPU-/Cron-Worker-Versuch; nicht produktiv eingesetzt. |
| `mike-ai-realtime-voice` | kein Modell | Laufende, gesunde WebRTC-Brücke für OpenClaw Talk; verbindet über den privaten WireGuard-Pfad Athena Whisper, OpenClaw-Agent und Qwen3-TTS ohne Profilwechsel. | | `mike-ai-realtime-voice` | kein Modell | Laufende, gesunde WebRTC-Brücke für OpenClaw Talk; verbindet über den privaten WireGuard-Pfad Athena Qwen3-ASR, OpenClaw-Agent und Qwen3-TTS ohne Profilwechsel. |
| `mike-ai-qwen3-tts` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base`, Stimme Serena | Hochwertige deutsche Sprachausgabe auf der RTX 3060 im LLM-Betrieb. | | `mike-ai-qwen3-tts` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base`, Stimme Serena | Hochwertige deutsche Sprachausgabe auf der RTX 3060 im LLM-Betrieb. |
| `mike-ai-router` | kein eigenes Modell | Einzige OpenAI-kompatible Modelladresse; koordiniert Profile, Bildaufträge, Sprache und Betriebsarten. | | `mike-ai-router` | kein eigenes Modell | Einzige OpenAI-kompatible Modelladresse; koordiniert Profile, Bildaufträge, Sprache und Betriebsarten. |
| `mike-ai-stem-separator` | BS-RoFormer Viperx 1297, `htdemucs_ft`, `htdemucs_6s`, `MossFormer2_SE_48K` | Trennt Gesang, Instrumente oder Sprache/Hintergrundgeräusche im exklusiven Separationsmodus. | | `mike-ai-stem-separator` | BS-RoFormer Viperx 1297, `htdemucs_ft`, `htdemucs_6s`, `MossFormer2_SE_48K` | Trennt Gesang, Instrumente oder Sprache/Hintergrundgeräusche im exklusiven Separationsmodus. |
| `mike-ai-tts-gateway` | kein eigenes Modell | Normalisiert Text, konvertiert Ausgabeformate und stellt Qwen3-TTS sowie natives PCM-Streaming über eine stabile interne API bereit. | | `mike-ai-tts-gateway` | kein eigenes Modell | Normalisiert Text, konvertiert Ausgabeformate und stellt Qwen3-TTS sowie natives PCM-Streaming über eine stabile interne API bereit. |
| `mike-ai-voice-studio` | `k2-fsa/OmniVoice` 0.2.1 mit Whisper-ASR | Erzeugt Text-to-Speech mit einer Referenzstimme; kein Audio-to-Audio-Voice-Changer. | | `mike-ai-voice-studio` | `k2-fsa/OmniVoice` 0.2.1 mit Whisper-ASR | Erzeugt Text-to-Speech mit einer Referenzstimme; kein Audio-to-Audio-Voice-Changer. |
| `mike-ai-whisper` | Whisper.cpp 1.9.4, `ggml-small` | Lokale deutsche Spracherkennung auf der CPU über `/v1/audio/transcriptions`. | | `mike-ai-qwen-asr` | Qwen3-ASR 0.6B Q8 | CPU-Inferenz für lokale deutsche Spracherkennung. |
| `mike-ai-qwen-asr-worker` | kein eigenes Modell | Audio-Adapter für `/v1/audio/transcriptions` mit dem Modellnamen `qwen3-asr`. |
| `mike-ai-wireguard-gateway` | kein Modell | Veröffentlicht Dashboard und Fachoberflächen ausschließlich über den privaten WireGuard-Pfad. | | `mike-ai-wireguard-gateway` | kein Modell | Veröffentlicht Dashboard und Fachoberflächen ausschließlich über den privaten WireGuard-Pfad. |
| `mike-ai-xvc-studio` | `chenxie95/X-VC`, GLM-4-Voice-Tokenizer und optional Resemble Enhance | Wandelt eine vorhandene Sprachaufnahme anhand einer Referenzstimme in Audio zu Audio um; gibt das native 16-kHz-Ergebnis und optional eine neural restaurierte 44,1-kHz-Fassung aus. | | `mike-ai-xvc-studio` | `chenxie95/X-VC`, GLM-4-Voice-Tokenizer und optional Resemble Enhance | Wandelt eine vorhandene Sprachaufnahme anhand einer Referenzstimme in Audio zu Audio um; gibt das native 16-kHz-Ergebnis und optional eine neural restaurierte 44,1-kHz-Fassung aus. |
| `mike-ai-yue2-playground` | Image `mike-ai/yue2:3b-0.1.6` | Vorhandener, gestoppter Playground; in diesem Abgleich nicht funktional getestet. | | `mike-ai-yue2-playground` | Image `mike-ai/yue2:3b-0.1.6` | Vorhandener, gestoppter Playground; in diesem Abgleich nicht funktional getestet. |
@@ -44,7 +48,8 @@ nicht automatisch ein ungenutzter Rest.
Alle fünf llama-Container verwenden llama.cpp 0.4.1 (`b29c606`). Medium lief Alle fünf llama-Container verwenden llama.cpp 0.4.1 (`b29c606`). Medium lief
beim Abgleich gesund mit zwei Slots; die anderen vier Textprofile waren beim Abgleich gesund mit zwei Slots; die anderen vier Textprofile waren
planmäßig nicht aktiv. Der Image-Worker war ebenfalls gestoppt. Die planmäßig nicht aktiv. Der Image-Worker und beide Prompt-Enhancer waren
ebenfalls gestoppt. Die
Infrastruktur und die Musik-/Applio-Oberflächen liefen. Diese Zustände ändern Infrastruktur und die Musik-/Applio-Oberflächen liefen. Diese Zustände ändern
sich mit der Nutzung. sich mit der Nutzung.
Die Detailbeschreibungen der Spezialmodelle stammen aus der bestehenden Die Detailbeschreibungen der Spezialmodelle stammen aus der bestehenden
@@ -55,4 +60,4 @@ sämtliche Modellgewichte oder Funktionen neu.
Gestoppte Container sind nicht automatisch Testreste. Vor einer Entfernung Gestoppte Container sind nicht automatisch Testreste. Vor einer Entfernung
Compose-Zuordnung, Mounts und Controller-Verweise prüfen. Die vorhandenen Compose-Zuordnung, Mounts und Controller-Verweise prüfen. Die vorhandenen
Spezialcontainer wurden durch den FLUX-Auflösungstest weder angelegt noch entfernt. Die historischen FLUX-Tests sind keine Aussage über den produktiven Qwen-Pfad.
+5 -4
View File
@@ -1,6 +1,6 @@
# Aktuelle Laufzeitnotizen # Aktuelle Laufzeitnotizen
Stand: 20. September 2026. Stand: 21. September 2026.
Der verbindliche Abgleich steht im [geprüften Live-Stand](LIVE_STATE.md). Der verbindliche Abgleich steht im [geprüften Live-Stand](LIVE_STATE.md).
Produktiv läuft **llama.cpp 0.4.1** auf Commit `b29c606`, zuvor b10930. Produktiv läuft **llama.cpp 0.4.1** auf Commit `b29c606`, zuvor b10930.
@@ -18,9 +18,10 @@ Funktionsproben, Vergleichsmessungen und gesicherter Rückfallstand.
[Update-Audit vom 15. September](UPDATE_AUDIT_20260915.md): aktualisierte [Update-Audit vom 15. September](UPDATE_AUDIT_20260915.md): aktualisierte
Komponenten, unveränderte aktuelle Komponenten, Aufräumarbeiten und Rollback. Komponenten, unveränderte aktuelle Komponenten, Aufräumarbeiten und Rollback.
FLUX verwendet **9B FP8 Beta** mit Textencoder auf der RTX 3060. Der produktive Bildpfad verwendet **Qwen-Image-2.1 INT8** mit ComfyUI auf der
[Auflösungstest](FLUX_RESOLUTION_TEST_20260912.md): 1024 erfolgreich, RTX 5080. FLUX 9B FP8 und seine Gewichte bleiben als gestoppter Rückfallpfad.
1280 CUDA-OOM; produktive Begrenzung weiterhin 1024. [Qwen-Betriebsdoku](QWEN_IMAGE_21.md); der historische
[FLUX-Auflösungstest](FLUX_RESOLUTION_TEST_20260912.md) gilt nur für FLUX.
Sprachausgabe: Qwen3-TTS 1.7B ohne Piper. Spracherkennung: Whisper.cpp 1.9.4 Sprachausgabe: Qwen3-TTS 1.7B ohne Piper. Spracherkennung: Whisper.cpp 1.9.4
mit dem Modell `small` auf der CPU. Applio basiert auf dem stabilen Release 3.6.4. mit dem Modell `small` auf der CPU. Applio basiert auf dem stabilen Release 3.6.4.
+34 -10
View File
@@ -1,9 +1,24 @@
# Geprüfter Live-Stand auf Athena # Geprüfter Live-Stand auf Athena
Nachtrag vom 25. September 2026: Die produktive Spracherkennung läuft über
Qwen3-ASR 0.6B Q8 auf der CPU (`mike-ai-qwen-asr` und
`mike-ai-qwen-asr-worker`). Der Router bietet nur `qwen3-asr` als STT-Modell
an; OpenClaw und die Voice-Brücke verwenden denselben Namen. Eine M4A-Aufnahme
wurde über `/v1/audio/transcriptions` geprüft. Whisper-Container,
Images und Modellvolume wurden entfernt. Das aktive Ultra-Profil wurde bei
dieser Umstellung nicht gewechselt. Die Angaben zu Whisper weiter unten
beschreiben den historischen Stand vom 21. September.
Nachtrag vom 24. September 2026: Ultra verarbeitet nun Bilder mit einem
CPU-seitigen BF16-Vision-Projektor. Der Stand und der Funktionstest sind in
[Ultra-Vision mit CPU-Projektor](ULTRA_CPU_VISION_20260924.md) dokumentiert.
Die folgende Tabelle bildet weiterhin den historischen Stand vom 21. September ab.
Stand: **21. September 2026**. Quelle: lesender SSH-Abgleich von Docker, Stand: **21. September 2026**. Quelle: lesender SSH-Abgleich von Docker,
Compose-Dateien, Git-Inhalten, Images, Health-Endpunkten und Backup-Timern. Compose-Dateien, Git-Inhalten, Images, Health-Endpunkten und Backup-Timern.
Containerzustände sind Momentaufnahmen; der Controller darf Profile danach Containerzustände sind Momentaufnahmen; der Controller darf Profile danach
umschalten. Es wurde für diesen Abgleich kein Dienst neu gestartet. umschalten. Für die Prompt-Enhancer-Integration wurden Router und Controller
gezielt neu gebaut und beide Bildpfade transaktional geprüft.
Aktueller Abschluss: [Test- und Änderungsübersicht](ATHENA_SESSION_20260920.md). Aktueller Abschluss: [Test- und Änderungsübersicht](ATHENA_SESSION_20260920.md).
Die folgenden MTP-/Microbatch-Werte enthalten die abschließenden Übernahmen. Die folgenden MTP-/Microbatch-Werte enthalten die abschließenden Übernahmen.
@@ -39,10 +54,14 @@ nicht mehr den aktuellen Containerzustand.
## Weitere Dienste ## Weitere Dienste
- FLUX.2 Klein 9B FP8 Beta: Transformer/VAE auf 5080, Qwen3-8B-NF4- - Qwen-Image-2.1 INT8 läuft produktiv über gepinntes ComfyUI mit Low-VRAM auf
Textencoder auf 3060. Produktiv freigegeben sind höchstens 1024 × 1024, der RTX 5080. Der Router verwendet 25 Schritte, Guidance 1 und bis zu vier
vier Schritte, Guidance 1, vier Referenzbilder und 128 Encoder-Tokens. Referenzbilder. Vor jedem Bild startet er automatisch den passenden
1280 × 1280 lief im Test in CUDA-OOM. offiziellen Q5-Prompt-Enhancer: PE-T2I für reine Textaufträge oder PE-I2I
für Referenzbilder. Der Enhancer läuft kurzzeitig auf der RTX 3060, wird vor
dem Rendern wieder gestoppt und speichert Rohprompt sowie Rewrite im
Bild-Sidecar. FLUX.2 Klein 9B FP8 bleibt mit seinen Gewichten als
gestoppter, explizit allowlist-beschränkter Rückfallcontainer erhalten.
- Qwen3-TTS 1.7B auf 3060 hinter dem TTS-Gateway; kein Piper-Fallback. - Qwen3-TTS 1.7B auf 3060 hinter dem TTS-Gateway; kein Piper-Fallback.
- Whisper.cpp `ggml-small` auf CPU über `/v1/audio/transcriptions`. - Whisper.cpp `ggml-small` auf CPU über `/v1/audio/transcriptions`.
- `mike-ai-embedding` stellt EmbeddingGemma 300M Q8 CPU-only über den privaten - `mike-ai-embedding` stellt EmbeddingGemma 300M Q8 CPU-only über den privaten
@@ -59,7 +78,12 @@ nicht mehr den aktuellen Containerzustand.
Er braucht keine GPU und keinen Profilwechsel. Das Plugin liegt in Er braucht keine GPU und keinen Profilwechsel. Das Plugin liegt in
`integrations/openclaw-athena-talk` und läuft auf Unraid, nicht auf Athena. `integrations/openclaw-athena-talk` und läuft auf Unraid, nicht auf Athena.
Diktat und hochgeladene M4A-Sprachnachrichten nutzen den separaten Diktat und hochgeladene M4A-Sprachnachrichten nutzen den separaten
Transkriptionspfad des Plugins. OpenClaw liefert den fertigen Agententext Transkriptionspfad des Plugins. Plugin 1.3.0 zerlegt längere Browser-Diktate
während der Aufnahme in überlappende Sechs-Sekunden-Abschnitte und hält so
den Abschluss innerhalb von OpenClaws festem Fünf-Sekunden-Fenster. OpenClaw
selbst wurde dafür nicht gepatcht. Die lokale Sicherheitsgrenze
`maxSpeechSeconds` steht produktiv auf 180 Sekunden. OpenClaw liefert den
fertigen Agententext
an die Brücke; das Sprechen beginnt daher erst nach Abschluss der an die Brücke; das Sprechen beginnt daher erst nach Abschluss der
Agentenantwort. Details und Grenzen stehen in der [Voice-Doku](../services/athena-realtime-voice/README.md). Agentenantwort. Details und Grenzen stehen in der [Voice-Doku](../services/athena-realtime-voice/README.md).
- `mike-ai-mikes-applio-ui` läuft gesund und ohne GPU. Die Quelle liegt im - `mike-ai-mikes-applio-ui` läuft gesund und ohne GPU. Die Quelle liegt im
@@ -68,7 +92,7 @@ nicht mehr den aktuellen Containerzustand.
trotzdem Status, Modelle und vorbereitende Aufgaben anzeigen; eine echte trotzdem Status, Modelle und vorbereitende Aufgaben anzeigen; eine echte
Konvertierung verlangt den Applio-Modus. Konvertierung verlangt den Applio-Modus.
Der vollständige Bestand der **31** angelegten Docker-Container steht im Der vollständige Bestand der **33** angelegten Docker-Container steht im
[Container-Inventar](CONTAINER_INVENTORY.md). `Created` oder `Exited` bei [Container-Inventar](CONTAINER_INVENTORY.md). `Created` oder `Exited` bei
Spezialworkern bedeutet nicht automatisch einen zu entfernenden Testrest. Spezialworkern bedeutet nicht automatisch einen zu entfernenden Testrest.
@@ -104,7 +128,7 @@ externe Restic-Timer zwar ebenfalls aktiviert, aber ohne seine erforderliche
`/etc/mike-ai/disaster-backup.env` **nicht** als funktionierende externe `/etc/mike-ai/disaster-backup.env` **nicht** als funktionierende externe
Sicherung zu werten. Details und Restore-Grenzen: [Backup](RECOVERY.md). Sicherung zu werten. Details und Restore-Grenzen: [Backup](RECOVERY.md).
Geprüft wurden aktuelle Container- und Quellenstände sowie ausgewählte Geprüft wurden aktuelle Container- und Quellenstände, Health-Endpunkte sowie
Health-Endpunkte. Keine erneuten Bild-, Langkontext-, Trainings- oder Text-zu-Bild und Referenzbildbearbeitung mit den beiden Prompt-Enhancern. Keine
Restore-Tests; ältere Ergebnisse sind im [Update-Audit](UPDATE_AUDIT_20260915.md) erneuten Langkontext-, Trainings- oder Restore-Tests; ältere Ergebnisse sind im [Update-Audit](UPDATE_AUDIT_20260915.md)
und [FLUX-Auflösungstest](FLUX_RESOLUTION_TEST_20260912.md) dokumentiert. und [FLUX-Auflösungstest](FLUX_RESOLUTION_TEST_20260912.md) dokumentiert.
+231
View File
@@ -0,0 +1,231 @@
# Qwen-Image-2.1 auf Athena
Stand: 21. September 2026. Qwen-Image-2.1 ist der produktive Bildworker des
Routers. FLUX.2 Klein 9B FP8 bleibt als gestoppter Rückfallcontainer erhalten.
## Gepinnter Stand
- ComfyUI: Commit `b0f4b7b294ce482a2e071d9d762c133d38c7aa07`
- Modell-Repository: `Comfy-Org/Qwen-Image-2.1`, Revision
`ace0edeb3791a594ddfa36ed5f41a178a394e921`
- Transformer: `qwen_image_2.1_int8_convrot.safetensors`
(`cb74113cb03faecd79611b01fd7fd642f0aa60d6f0b95086abee214d75eaa57d`)
- Textencoder: `qwen3vl_8b_int8_convrot.safetensors`
(`8bfd0f6e12abf2d2d697ecc888e5e90b0d6741d6708f05799f53afa560452e8f`)
- VAE: `qwen_image_2.1_vae_bf16.safetensors`
(`bb21f7473051e1ac368515dd3f2e15cd44d7a11748ee8823e1ddca3e4876b7c9`)
- Pipeline: 25 Schritte, CFG 1, Euler/Simple, `--lowvram`
- GPU: ausschließlich Athena-GPU 1, die RTX 5080 mit 16 GB
Die Gewichte liegen außerhalb von Git unter
`/data/models/Qwen-Image-2.1-ComfyUI`. Der Adapter
`platform/docker/qwen-image-worker/qwen_image_worker.py` stellt vor ComfyUI
die bestehende private Worker-API auf Port 8086 bereit. Der öffentliche
Routervertrag bleibt damit `/v1/images/generations` und `/v1/images/edits`.
Der Container läuft wie der Router mit UID/GID `10002:10002`, damit beide das
gemeinsame Bild-Volume ohne erweiterte Linux-Capabilities verwenden können.
## Lebenszyklus
Ein Bildauftrag läuft transaktional:
1. Der Router merkt sich das aktive Textprofil.
2. Der Profile Controller stoppt LLM, TTS und andere GPU-Spezialworker.
3. Bei einem Textauftrag startet `mike-ai-image-prompt-enhancer-t2i`, bei
Referenzbildern `mike-ai-image-prompt-enhancer-i2i` auf der RTX 3060.
4. Der offizielle Qwen-Enhancer schreibt den knappen Benutzertext um und wird
anschließend vollständig gestoppt.
5. `mike-ai-image-worker` startet Qwen Image auf der RTX 5080 und erzeugt das
Bild oder bearbeitet bis zu vier Referenzbilder.
6. Der Qwen-Worker wird vollständig gestoppt.
7. Das vorherige Textprofil und Qwen3-TTS werden wiederhergestellt.
Der Container ist außerhalb eines Auftrags gestoppt. Das ist der Sollzustand.
## Reproduzierbare Vorbereitung
```bash
cd /opt/mike-ai/stack
sudo ./scripts/prepare-qwen-image-21.sh
```
Das Skript lädt fehlende Dateien mit festen SHA-256-Prüfsummen, baut den
produktiven Qwen-Worker und legt Qwen, beide Prompt-Enhancer sowie FLUX
gestoppt an. Es lädt dabei kein Modell in den VRAM.
## Funktionsprobe über den echten Routerweg
```bash
curl -sS http://127.0.0.1:8081/v1/images/generations \
-H "Authorization: Bearer $ROUTER_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"model":"Qwen-Image-2.1-int8",
"prompt":"Fotorealistisches rotes Haus bei Tageslicht",
"size":"1024x1024",
"steps":25,
"guidance":1.0,
"response_format":"url"
}'
```
Nach dem Lauf müssen `mike-ai-image-worker`, beide Prompt-Enhancer und
`mike-ai-flux-image-worker` gestoppt sein und das vorherige LLM-Profil wieder
gesund laufen.
## OpenClaw 2026.9.5
OpenClaws eingebautes Werkzeug `image_generate` verwendet den separaten
Medienmodell-Eintrag. Er muss auf den OpenAI-kompatiblen Athena-Router zeigen:
```bash
openclaw config set agents.defaults.mediaModels.image.primary \
openai/Qwen-Image-2.1-int8
openclaw config set agents.defaults.mediaModels.image.fallbacks '[]' \
--strict-json
```
Der Provider `models.providers.openai.baseUrl` bleibt
`http://192.168.1.212:8081/v1`. `openclaw models set-image` ist hierfür nicht
der richtige Befehl: Er schreibt `agents.defaults.imageModel`, nicht den von
`image_generate` verwendeten Medienmodell-Eintrag. Ein alter Wert unter
`agents.defaults.imageModel` wird mit folgendem Befehl entfernt:
```bash
openclaw config unset agents.defaults.imageModel
```
Die leere Fallbackliste verhindert, dass OpenClaw bei einem lokalen Fehler
unbemerkt auf `openai/gpt-image-2` ausweicht.
OpenClaw überträgt Referenzbilder an `/v1/images/edits` als
OpenAI-kompatibles Multipart-Formular mit dem Feld `image[]`. Der Router
akzeptiert dort ein bis vier Referenzbilder und normalisiert numerische
Formularwerte wie `n` und `steps`. Der ältere JSON-/Base64-Weg bleibt für
bestehende Clients erhalten.
Beim ersten Wechsel von FLUX auf Qwen funktionierten Referenzbilder deshalb
nicht: Der neue Qwen-Pfad verstand zunächst nur den älteren JSON-/Base64-Weg.
Zusätzlich war anfangs `agents.defaults.imageModel` gesetzt, obwohl
OpenClaws `image_generate` den Eintrag unter
`agents.defaults.mediaModels.image` verwendet. Das waren Lücken der neuen
Qwen-Integration. Der zuvor produktive FLUX-Pfad war davon nicht betroffen.
## Produktionsvalidierung vom 21. September 2026
Beide öffentlichen Routerpfade wurden auf Athena mit dem produktiven Stack
geprüft:
- Text zu Bild: gültiges PNG mit 1024×1024 Pixeln, 25 Schritte,
`Qwen-Image-2.1-int8`; 48,9 Sekunden reine Bildpipeline und 71,6 Sekunden
für den vollständigen transaktionalen Routerlauf.
- Bildbearbeitung: gültiges PNG mit 1024×1024 Pixeln aus einem Referenzbild,
25 Schritte, `Qwen-Image-2.1-int8`; 76,3 Sekunden für den vollständigen
Routerlauf.
Nach beiden Aufträgen waren Qwen und FLUX gestoppt. `mike-ai-llama-medium`,
Qwen3-TTS, Router und Profile Controller liefen anschließend wieder gesund.
Damit sind Erzeugung, Referenzbildübergabe und Wiederherstellung des zuvor
aktiven Textprofils über den echten Routerweg bestätigt.
Zusätzlich wurde `image_generate` am 21. September 2026 über einen isolierten
OpenClaw-`agent exec`-Lauf geprüft. OpenClaw rief
`openai/Qwen-Image-2.1-int8` einmal auf; Athena erzeugte das PNG und stellte
Medium sowie Qwen3-TTS nach 72,5 Sekunden wieder gesund her.
Auch die Referenzbildbearbeitung wurde anschließend über das echte
OpenClaw-Werkzeug `image_generate` geprüft. OpenClaw übergab ein Referenzbild
als `image[]`; der Router erzeugte mit `Qwen-Image-2.1-int8` ein gültiges
PNG mit 1024×1024 Pixeln und meldete im Sidecar `mode: image-edit` sowie
`reference_images: 1`. Medium und Qwen3-TTS liefen nach dem Auftrag wieder
gesund, der Bildworker wurde erwartungsgemäß gestoppt.
## FLUX-Rückfallpfad
FLUX verwendet weiterhin das Image `mike-ai/image-worker:local`, die
bestehenden Modellverzeichnisse und den Container
`mike-ai-flux-image-worker`. Er hat das Controller-Label `flux-standby` und
kann deshalb nicht durch einen normalen Router-Bildauftrag gestartet werden.
Eine manuelle Diagnose ist nur über den allowlist-beschränkten Controllerpfad
`/workers/flux-standby/start` möglich. Für eine dauerhafte Rückschaltung müssen
Service-DNS, Modellname und Schrittzahl gemeinsam in Compose auf FLUX gesetzt
und anschließend Router und Controller neu ausgerollt werden. Keine dieser
Änderungen erfolgt automatisch.
Historische FLUX-Messwerte und Grenzen stehen in
[FLUX_9B_BETA.md](FLUX_9B_BETA.md) und
[FLUX_RESOLUTION_TEST_20260912.md](FLUX_RESOLUTION_TEST_20260912.md).
## Offizielle Prompt-Enhancer im produktiven Router
Qwen veröffentlicht zwei getrennte, auf Qwen3.5-VL 9B nachtrainierte
Prompt-Enhancer. Sie sind kein Bestandteil der Qwen-Image-Gewichte. Athena
startet automatisch die zum Auftrag passende Variante; OpenClaw ruft weiterhin
nur sein normales Werkzeug `image_generate` mit dem unveränderten Modellnamen
`openai/Qwen-Image-2.1-int8` auf.
- `mike-ai-image-prompt-enhancer-t2i` bereitet reine Textaufträge auf.
- `mike-ai-image-prompt-enhancer-i2i` wertet ein bis vier Referenzbilder
gemeinsam aus und trennt Identität, gewünschte Änderungen und neue Szene.
- Beide verwenden getrennte offizielle Systemprompts, `Q5_K_M`, llama.cpp
Build 10930, 16.384 Token Kontext und höchstens 2.048 Denktokens.
- Sie laufen ausschließlich und nacheinander auf der RTX 3060. Nach dem Rewrite
werden sie gestoppt; Qwen-Image rendert anschließend auf der RTX 5080.
- Falls der Client keine Größe vorgibt, übernimmt der Router das vom Enhancer
vorgeschlagene Seitenverhältnis und bildet es auf eine erlaubte Auflösung ab.
Eine ausdrücklich angegebene gültige Größe bleibt unverändert.
- Das Sidecar jedes PNG enthält `original_prompt`, den tatsächlich verwendeten
`prompt` und Modell, Quantisierung, Laufzeit und Verhältnis unter
`prompt_enhancer`. Verdeckte Denktokens werden nicht gespeichert.
- Ein fehlgeschlagener Rewrite bricht den Bildauftrag sichtbar ab. Der Router
sendet in diesem Fall keinen unaufbereiteten Ersatzprompt an Qwen-Image.
### Gepinnte Dateien
PE-I2I stammt aus `Qwen/Qwen-Image-2.1-PE-I2I`, Revision
`72927bc08afc99b7888ceb7d7d51a12db3700bbd`, und dem GGUF-Repository
`prithivMLmods/Qwen-Image-2.1-PE-I2I-GGUF`, Revision
`55b9c1a326599e142d59bcad8715d5601ccf8daa`. Die Dateien liegen unter
`/data/models/qwen-image-2.1-pe-i2i-q5`:
| Datei | SHA-256 |
|---|---|
| `Qwen-Image-2.1-PE-I2I.Q5_K_M.gguf` | `cb71f71fe5fe5938570d65a7c403fbcb1a9065dff9dd9a021f58aec42785b84e` |
| `Qwen-Image-2.1-PE-I2I.mmproj-bf16.gguf` | `8dedb71dbc3092dc47de9108ad373d68a12854e2527d59bd9399601738f3bce1` |
| `system_prompt.txt` | `e378fea686a1431581ba4c654d332ae96adad633f144ae738ec8ce9c4fd66439` |
PE-T2I stammt aus `Qwen/Qwen-Image-2.1-PE-T2I`, Revision
`f3ed7985c788ad75b3ab7223e0c4c51e2a43545b`, und dem GGUF-Repository
`prithivMLmods/Qwen-Image-2.1-PE-T2I-GGUF`, Revision
`e18d4a3e0830ab157770738b16830e6fcf5f57d4`. Die Dateien liegen unter
`/data/models/qwen-image-2.1-pe-t2i-q5`:
| Datei | SHA-256 |
|---|---|
| `Qwen-Image-2.1-PE-T2I.Q5_K_M.gguf` | `749f5652fd6e8b760ca860091f7a5bffa254a7954203ae5c9bcdf5f93197702f` |
| `system_prompt.txt` | `a77c9a06c59b120741141d9514b95682bb8761d02bec49ca61def7b2b3d9fb99` |
Der zuvor geprüfte PE-I2I-FP8-Checkpoint ist 13,54 GB groß und passt mit
Aktivierungs- und Laufzeitspeicher nicht in die 12-GB-RTX-3060. CPU-Offload war
unpraktisch langsam. Er wurde verworfen und entfernt. Der Q5-I2I-Worker belegt
etwa 7.167 MiB VRAM und ist deshalb der produktive Stand.
### Abnahme vom 21. September 2026
Der vollständige öffentliche Routerweg wurde nach der Integration zweimal
geprüft:
- Ein kurzer deutscher Textprompt wurde von PE-T2I in 36,6 Sekunden in einen
strukturierten englischen Produktionsprompt umgeschrieben. Qwen-Image
erzeugte das 1024×1024-PNG anschließend in 49,0 Sekunden.
- Ein hochgeladenes Referenzbild wurde von PE-I2I in 65,9 Sekunden aufbereitet.
Der Rewrite verlangte ausdrücklich eine neue Szene, neue Pose und Kleidung
sowie das Entfernen der alten Gegenstände. Qwen-Image erzeugte in 111,3
Sekunden ein neues 1024×1536-PNG: genau eine Person in einer Blumenwiese an
einer Autobahn, ohne Badewanne, Gitarre oder Gummiente.
Nach beiden Aufträgen waren der Bildworker und beide Enhancer gestoppt. Medium
und Qwen3-TTS liefen wieder gesund. Der erste I2I-Versuch deckte eine relative
Pfadauflösung bei temporären Multipart-Uploads auf; der korrigierte Pfad ist mit
einem eigenen Regressionstest abgedeckt. Insgesamt laufen 96 schnelle
GPU-freie Release-Tests.
-60
View File
@@ -1,60 +0,0 @@
# Qwen-Image-2.1 – isolierter Test
Stand: 21. September 2026. Dieser Pfad dient nur dem Vergleich mit dem
produktiven FLUX.2-Worker. Er ersetzt FLUX nicht und ist nicht an den Router
angebunden.
## Reproduzierbarer Stand
- ComfyUI: Commit `b0f4b7b294ce482a2e071d9d762c133d38c7aa07`
- Modell-Repository: `Comfy-Org/Qwen-Image-2.1`, Revision
`ace0edeb3791a594ddfa36ed5f41a178a394e921`
- Transformer: `qwen_image_2.1_int8_convrot.safetensors` (7.256.783.064 Byte)
- Textencoder: `qwen3vl_8b_int8_convrot.safetensors` (9.350.798.360 Byte)
- VAE: `qwen_image_2.1_vae_bf16.safetensors` (675.509.688 Byte)
- erster Vergleich: 1024 × 1024, 25 Schritte, CFG 1, Euler/Simple
Die Gewichte sind die von Qwen verlinkte offizielle ComfyUI-Aufbereitung. Der
Worker verwendet `--lowvram` auf der RTX 5080. So bleibt der Test mit 16 GB
VRAM möglich; der Preis ist CPU-Offload und damit eine längere Laufzeit.
## Vorbereitung ohne GPU-Last
```bash
cd /opt/mike-ai/stack
sudo ./scripts/prepare-qwen-image-21-test.sh
```
Das Skript prüft feste SHA-256-Summen, baut das Image, aktualisiert den
Profile Controller und erstellt den Testcontainer **gestoppt**. Es lädt kein
Modell in die GPU.
## Einmaliger Test nach ausdrücklichem „Go“
```bash
cd /opt/mike-ai/stack
docker compose --env-file /etc/mike-ai/stack.env \
--profile qwen-image-test run --rm qwen-image-21-test-runner \
python /opt/test/run.py 'HIER DEN VEREINBARTEN PROMPT EINSETZEN'
```
Der Runner merkt sich das aktive LLM-Profil, startet den Qwen-Worker über den
allowlist-beschränkten Controller, erzeugt genau ein Bild und stellt danach
auch bei einem Fehler das vorherige Profil wieder her. Das Ergebnis liegt in
`/data/qwen-image-2.1-test-output/`. FLUX-Konfiguration und FLUX-Gewichte
werden nicht verändert.
## Vollständig entfernen
```bash
cd /opt/mike-ai/stack
docker compose --env-file /etc/mike-ai/stack.env \
--profile qwen-image-test rm -sf qwen-image-21-test
docker image rm mike-ai/qwen-image-2.1-test:local
rm -rf /data/models/Qwen-Image-2.1-ComfyUI \
/data/qwen-image-2.1-test-output
```
Anschließend kann die Controller-Erweiterung bei Bedarf aus Git zurückgenommen
und nur `profile-controller` neu gebaut werden. Die Entfernung ist nicht Teil
der Vorbereitung und wird niemals automatisch ausgeführt.
+21 -2
View File
@@ -16,13 +16,25 @@ eingeschlossen: Im Archiv `athena-2026-09-16T13-02-56.tar.gz` wurden sowohl
`compose.yaml` als auch `services/athena-realtime-voice/server.py` geprüft. `compose.yaml` als auch `services/athena-realtime-voice/server.py` geprüft.
Das OpenClaw-Plugin auf Unraid liegt außerhalb dieses Athena-Backups; seine Das OpenClaw-Plugin auf Unraid liegt außerhalb dieses Athena-Backups; seine
Quelle ist im Git-Repository unter `integrations/openclaw-athena-talk` erfasst. Quelle ist im Git-Repository unter `integrations/openclaw-athena-talk` erfasst.
Die produktiv installierte Version 1.3.0 liegt zusätzlich im persistenten
OpenClaw-Appdata. Vor ihrer Installation wurde die bisherige Version als
`/mnt/nvme-storage/appdata/OpenClaw/config/plugin-backups/athena-talk-1.2.1-before-streaming.tar.gz`
gesichert. Für eine Neuinstallation ist der im Git dokumentierte Build mit
`openclaw plugins install <paket.tgz> --force --accept-capabilities` zu
installieren; eine Änderung an OpenClaw-Core-Dateien ist nicht erforderlich.
Piper-Daten sind kein aktueller Sicherungsbestand. Modellgewichte unter Piper-Daten sind kein aktueller Sicherungsbestand. Modellgewichte unter
`/data/models` und das reproduzierbare Whisper-Volume gehören nicht zu diesen `/data/models`, einschließlich Qwen3-ASR unter
Backup-Mounts. Ein Backup ausschließlich auf `/data` schützt nicht vor deren Ausfall. `/data/models/qwen3-asr-0.6b-q8`, gehören nicht zu diesen Backup-Mounts.
Das frühere Whisper-Volume wurde am 25. September 2026 entfernt.
Ein Backup ausschließlich auf `/data` schützt nicht vor einem Ausfall der Datenplatte.
Das gilt auch für das reproduzierbare EmbeddingGemma-Gewicht unter Das gilt auch für das reproduzierbare EmbeddingGemma-Gewicht unter
`/data/models/embeddinggemma`; URL und SHA-256 stehen in `/data/models/embeddinggemma`; URL und SHA-256 stehen in
`config/install.env.example`, sodass der Installer es erneut laden und prüfen kann. `config/install.env.example`, sodass der Installer es erneut laden und prüfen kann.
Auch die Qwen-Image-Gewichte und beide Prompt-Enhancer liegen unter
`/data/models` und damit außerhalb des regulären Volume-Backups. Das Skript
`scripts/prepare-qwen-image-21.sh` lädt sämtliche gepinnten Dateien anhand
fester SHA-256-Prüfsummen erneut und legt Bildworker sowie Enhancer gestoppt an.
Die verschlüsselten Notfallpakete über `athena-export-backup.timer` laufen Die verschlüsselten Notfallpakete über `athena-export-backup.timer` laufen
ebenfalls im Fünf-Stunden-Takt; beim Abgleich war der Timer aktiv und der ebenfalls im Fünf-Stunden-Takt; beim Abgleich war der Timer aktiv und der
@@ -30,6 +42,13 @@ letzte Lauf am 16. September 2026 um 13:50 Uhr verzeichnet. Sie
liegen unter `/data/emergency-backups`. Schutz vor Datenplattenausfall setzt liegen unter `/data/emergency-backups`. Schutz vor Datenplattenausfall setzt
eine außerhalb Athenas aufbewahrte Kopie voraus. eine außerhalb Athenas aufbewahrte Kopie voraus.
Die lokale Rotation hält fünf verschlüsselte Generationen. Da ein Paket rund
46 GB umfasst, gibt das Exportscript bei bereits erreichter Aufbewahrungszahl
vor dem Schreiben genau den ältesten Slot frei. Ohne diese Reihenfolge hätte
der Lauf am 21. September bei nur noch 17 GB freiem Speicher die neue Datei
nicht mehr vollständig schreiben können. Scheitert der neue Lauf danach,
bleiben weiterhin vier gültige Generationen erhalten.
Die externe Restic-Sicherung über `athena-disaster-backup.timer` ist derzeit Die externe Restic-Sicherung über `athena-disaster-backup.timer` ist derzeit
nicht eingerichtet: Der Timer ist zwar aktiviert, aber nicht eingerichtet: Der Timer ist zwar aktiviert, aber
`/etc/mike-ai/disaster-backup.env` fehlt. Bis ein externes `/etc/mike-ai/disaster-backup.env` fehlt. Bis ein externes
+2 -2
View File
@@ -9,7 +9,7 @@ Standardprofil: **medium** · globales Ausgabelimit: **8192 Token**
| fast | `qwen-fast` | 76,800 | 1 | Qwen3.8-27B IQ4 Mix | 5080 only | ja | 2 | | fast | `qwen-fast` | 76,800 | 1 | Qwen3.8-27B IQ4 Mix | 5080 only | ja | 2 |
| medium | `qwen-medium` | 160,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 85:15 | ja | 2 | | medium | `qwen-medium` | 160,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 85:15 | ja | 2 |
| large | `qwen-large` | 192,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 86:14 | ja | 2 | | large | `qwen-large` | 192,000 | 1 | Qwen3.8-27B IQ4 XS Pure | 86:14 | ja | 2 |
| ultra | `qwen-ultra` | 262,144 | 1 | Qwen3.8-27B IQ4 XS Pure | 80:20 | nein | 2 | | ultra | `qwen-ultra` | 262,144 | 1 | Qwen3.8-27B IQ4 XS Pure | 80:20 | ja | 2 |
| uncensored | `qwen-uncensored` | 80,000 | 1 | Qwen3.8-27B Abliterated Q4_K_M | 90:10 | ja | 2 | | uncensored | `qwen-uncensored` | 80,000 | 1 | Qwen3.8-27B Abliterated Q4_K_M | 90:10 | ja | 2 |
## Zweck ## Zweck
@@ -17,5 +17,5 @@ Standardprofil: **medium** · globales Ausgabelimit: **8192 Token**
- **fast**: Schnelles Profil für kurze Chats und zügige Werkzeugaufgaben. - **fast**: Schnelles Profil für kurze Chats und zügige Werkzeugaufgaben.
- **medium**: Ausgewogenes Standardprofil für Alltag und lange agentische Aufgaben. - **medium**: Ausgewogenes Standardprofil für Alltag und lange agentische Aufgaben.
- **large**: Großes Profil für umfangreiche Dokumente und lange technische Arbeiten. - **large**: Großes Profil für umfangreiche Dokumente und lange technische Arbeiten.
- **ultra**: Maximaler Textkontext; bewusst ohne Vision-Projektor. - **ultra**: Maximaler Kontext mit Vision-Projektor auf der CPU.
- **uncensored**: Weniger restriktives Spezialprofil; Werkzeugrechte bleiben unverändert. - **uncensored**: Weniger restriktives Spezialprofil; Werkzeugrechte bleiben unverändert.
+5 -2
View File
@@ -1,6 +1,6 @@
# Register getesteter Modelle # Register getesteter Modelle
Stand: 20. September 2026 Stand: 21. September 2026
Dieses Dokument ist die zentrale Sperrliste gegen doppelte Modelltests. Vor Dieses Dokument ist die zentrale Sperrliste gegen doppelte Modelltests. Vor
jedem Download müssen Repository, Dateiname, Basismodell, Fine-Tune und jedem Download müssen Repository, Dateiname, Basismodell, Fine-Tune und
@@ -53,7 +53,10 @@ Titelgenerierung und Kontextkompression in Hermes.
| Datum | Modell | Ergebnis | Status / Entscheidung | Beleg | | Datum | Modell | Ergebnis | Status / Entscheidung | Beleg |
|---|---|---|---|---| |---|---|---|---|---|
| bis 07.09.2026 | FLUX.2 Klein 4B | funktional, aber schwächere räumliche und motivische Konsistenz | **ersetzt** durch 9B FP8 | [FLUX_9B_BETA.md](FLUX_9B_BETA.md) | | bis 07.09.2026 | FLUX.2 Klein 4B | funktional, aber schwächere räumliche und motivische Konsistenz | **ersetzt** durch 9B FP8 | [FLUX_9B_BETA.md](FLUX_9B_BETA.md) |
| 07.09.2026 | FLUX.2 Klein 9B FP8 | bessere Prompttreue; produktiver Zwei-GPU-Pfad, derzeit auf 1024 × 1024 begrenzt | **produktiv als Beta** | [FLUX_9B_BETA.md](FLUX_9B_BETA.md) | | 07.09.2026 | FLUX.2 Klein 9B FP8 | bessere Prompttreue als 4B; produktiver Zwei-GPU-Pfad, auf 1024 × 1024 begrenzt | **Standby seit 21.09.2026** | [FLUX_9B_BETA.md](FLUX_9B_BETA.md) |
| 21.09.2026 | Qwen-Image-2.1 INT8 | im direkten Vergleich fotorealistischer, bessere Textdarstellung und Prompttreue; 1024 × 1024 bei 25 Schritten in rund 49 s Pipeline-Lauf | **produktiv** | [QWEN_IMAGE_21.md](QWEN_IMAGE_21.md) |
| 21.09.2026 | Qwen-Image-2.1 PE-I2I, FP8 und Q5_K_M | FP8 passt nicht vollständig in 12 GB und ist mit CPU-Offload unpraktisch langsam; Q5_K_M samt BF16-MMProj belegt 7.167 MiB auf der RTX 3060. Der produktive Router-Rewrite mit einem Referenzbild dauerte 65,9 s und führte zu einer vollständig neuen Szene | **Q5_K_M produktiv**, FP8 verworfen | [QWEN_IMAGE_21.md](QWEN_IMAGE_21.md#offizielle-prompt-enhancer-im-produktiven-router) |
| 21.09.2026 | Qwen-Image-2.1 PE-T2I Q5_K_M | automatischer Rewrite eines knappen deutschen Textprompts in 36,6 s; nachfolgende Bildgenerierung erfolgreich | **produktiv** für Text-zu-Bild | [QWEN_IMAGE_21.md](QWEN_IMAGE_21.md#offizielle-prompt-enhancer-im-produktiven-router) |
| 08.09.2026 | HYPIR-SD2 | glättete oder erfand Details und veränderte kleine Strukturen | **verworfen** | [IMAGE_RESTORATION.md](IMAGE_RESTORATION.md) | | 08.09.2026 | HYPIR-SD2 | glättete oder erfand Details und veränderte kleine Strukturen | **verworfen** | [IMAGE_RESTORATION.md](IMAGE_RESTORATION.md) |
| 08.09.2026 | SeedVR2 7B FP8 | bewahrte Identität besser als HYPIR, brachte beim realen unscharfen Foto aber kaum nutzbare Details zurück | **verworfen** | [IMAGE_RESTORATION.md](IMAGE_RESTORATION.md) | | 08.09.2026 | SeedVR2 7B FP8 | bewahrte Identität besser als HYPIR, brachte beim realen unscharfen Foto aber kaum nutzbare Details zurück | **verworfen** | [IMAGE_RESTORATION.md](IMAGE_RESTORATION.md) |
+23
View File
@@ -0,0 +1,23 @@
# Ultra-Vision mit CPU-Projektor
Stand: 24. September 2026. Ultra verwendet weiterhin Qwen3.8-27B IQ4_XS Pure
mit 262.144 Token Kontext. Der BF16-Vision-Projektor wird mit `--mmproj`
geladen und durch `--no-mmproj-offload` auf der CPU gehalten. So kann Ultra
Bilder verarbeiten, ohne den knapp bemessenen GPU-Speicher des 256K-Profils
zusätzlich mit dem Projektor zu belegen. Router und Profilmatrix melden Ultra
als vision-fähig.
Der frühere text-only-Modus war die Ursache dafür, dass der Router Bildanfragen
an `qwen-ultra` abwies. Ein Test über den Router nach dem Deployment lieferte
HTTP 200 für ein einzelnes synthetisches Farbbild (`Red`) und für fünf
synthetische Bilder (`5`). Der Ultra-Start lud den multimodalen Projektor.
Gemessen wurden 11,47 Sekunden bis zur Betriebsbereitschaft und 1,66 bzw.
0,9 Sekunden für die beiden kleinen Testanfragen. Große Screenshots und lange
Kontexte wurden damit nicht vermessen; deren Laufzeit kann deutlich höher
sein. Nach dem Test wurde Medium wieder aktiviert, Ultra ist gestoppt.
Vor der Änderung wurden die drei produktiven Dateien auf Athena unter
`/var/tmp/ultra-vision-cpu-20260924/` gesichert. Ein Rückbau muss Compose,
Router-Profilregister und Profilmatrix gemeinsam auf den vorigen Stand setzen
und den Router neu laden. Das Verzeichnis liegt nur temporär auf dem Host;
die dauerhafte Versionierung erfolgt im Git-Repository.
+32 -16
View File
@@ -5,7 +5,7 @@ existing Athena speech stack. An experimental browser WebRTC path is available
through the separate `services/athena-realtime-voice` service: through the separate `services/athena-realtime-voice` service:
1. local VAD collects a spoken utterance, 1. local VAD collects a spoken utterance,
2. Athena Whisper transcribes it, 2. Athena Qwen3-ASR transcribes it,
3. OpenClaw's normal agent-consult path answers with its configured model and 3. OpenClaw's normal agent-consult path answers with its configured model and
tools, tools,
4. Athena Qwen3-TTS returns PCM audio to the Talk client. 4. Athena Qwen3-TTS returns PCM audio to the Talk client.
@@ -43,7 +43,7 @@ Recommended `talk.realtime` configuration:
"vadThreshold": 0.018, "vadThreshold": 0.018,
"silenceDurationMs": 750, "silenceDurationMs": 750,
"prefixPaddingMs": 300, "prefixPaddingMs": 300,
"maxSpeechSeconds": 45 "maxSpeechSeconds": 180
} }
} }
} }
@@ -60,19 +60,35 @@ openclaw plugins install . --force --accept-capabilities
openclaw plugins inspect athena-talk --runtime --json openclaw plugins inspect athena-talk --runtime --json
``` ```
Version 1.2.1 also registers **Athena Whisper (Diktieren)** as a separate Version 1.3.1 sends `qwen3-asr` throughout dictation and Talk. The plugin
registers **Athena Qwen3-ASR (Diktieren)** as a separate
realtime transcription provider through OpenClaw's official plugin API. In the realtime transcription provider through OpenClaw's official plugin API. In the
browser composer, hold the microphone for dictation, then release it to send browser composer, hold the microphone for dictation, then release it to send
the 8 kHz G.711 audio through the Gateway. The plugin converts it to PCM WAV the 8 kHz G.711 audio through the Gateway. Short recordings are converted to
and calls the same Athena `/audio/transcriptions` endpoint used by Talk. The PCM WAV and sent to Athena's existing `/audio/transcriptions` endpoint in one
transcribed text is returned to the composer; this path does not invoke the request. Longer recordings are split while the user is still speaking into
agent or TTS. The transcription provider reuses `talk.realtime.providers.athena-talk` six-second windows with 0.5 seconds of overlap. The plugin sends these windows
and the configured model provider for its URL/key. If that model provider has sequentially to the persistent Qwen3-ASR service, removes duplicated overlap
no key, it reuses `tts.providers.openai.apiKey` only when the TTS and STT URLs words, and caches finished
have the same origin. No second credential is needed. In `talk.catalog`, it segments until recording stops. Only the short final tail then remains inside
appears under `transcription.providers`. OpenClaw OpenClaw's fixed five-second final-drain window. Each STT request is capped
currently gives a transcription provider five seconds to return its final text at 4.5 seconds.
after recording stops; the plugin caps its Whisper request at 4.5 seconds.
This is incremental pre-transcription over OpenClaw's official transcription
provider API. Qwen3-ASR still receives complete short WAV segments; it is
not a native token-streaming STT protocol. No OpenClaw core file was patched.
The transcribed text is
returned to the composer; this path does not invoke the agent or TTS. The
provider reuses `talk.realtime.providers.athena-talk` and the configured model
provider for its URL/key. If that model provider has no key, it reuses
`tts.providers.openai.apiKey` only when the TTS and STT URLs have the same
origin. No second credential is needed. In `talk.catalog`, it appears under
`transcription.providers`.
The production provider allows up to 180 seconds per recording. This limit is
a local safety cap shared by dictation and Talk, not an OpenClaw or Qwen3-ASR
restriction. Incremental segmentation keeps long dictation bounded while it
is being recorded.
### Voice-note file attachments ### Voice-note file attachments
@@ -80,7 +96,7 @@ An M4A voice note uploaded as a chat attachment does **not** use the realtime
dictation provider above. OpenClaw processes it through its built-in dictation provider above. OpenClaw processes it through its built-in
`tools.media.audio` path. On the Unraid installation, automatic provider `tools.media.audio` path. On the Unraid installation, automatic provider
selection hit `SsrFBlockedError` for the private Athena address. Configure the selection hit `SsrFBlockedError` for the private Athena address. Configure the
existing OpenAI-compatible provider and select Whisper explicitly: existing OpenAI-compatible provider with the `qwen3-asr` model:
```json5 ```json5
{ {
@@ -97,7 +113,7 @@ existing OpenAI-compatible provider and select Whisper explicitly:
models: [ models: [
{ {
provider: "openai", provider: "openai",
model: "whisper-1", model: "qwen3-asr",
baseUrl: "http://192.168.1.212:8081/v1", baseUrl: "http://192.168.1.212:8081/v1",
capabilities: ["audio"], capabilities: ["audio"],
}, },
@@ -112,7 +128,7 @@ OpenClaw 2026.9.4 accepts `request.allowPrivateNetwork` under
settings hot-reload without restarting the Gateway. The existing settings hot-reload without restarting the Gateway. The existing
`ATHENA_ROUTER_API_KEY` SecretRef is reused; do not add another literal key. `ATHENA_ROUTER_API_KEY` SecretRef is reused; do not add another literal key.
On 2026-09-16, OpenClaw's official audio module transcribed the affected M4A On 2026-09-16, OpenClaw's official audio module transcribed the affected M4A
through Athena Whisper (127 characters returned). This confirms the endpoint through Athena's then-active Whisper service (127 characters returned). This confirms the endpoint
and file format; a fresh attachment in the chat is still needed to verify the and file format; a fresh attachment in the chat is still needed to verify the
full message-to-transcript flow. full message-to-transcript flow.
+100 -33
View File
@@ -4,6 +4,10 @@ const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 };
const BROWSER_OFFER_PATH = "/plugins/athena-talk/realtime/calls"; const BROWSER_OFFER_PATH = "/plugins/athena-talk/realtime/calls";
const BROWSER_KEY_PATH = "/plugins/athena-talk/realtime/public-key"; const BROWSER_KEY_PATH = "/plugins/athena-talk/realtime/public-key";
const MAX_OFFER_BYTES = 64 * 1024; const MAX_OFFER_BYTES = 64 * 1024;
const DICTATION_SAMPLE_RATE_HZ = 8000;
const DICTATION_SEGMENT_BYTES = DICTATION_SAMPLE_RATE_HZ * 6;
const DICTATION_OVERLAP_BYTES = DICTATION_SAMPLE_RATE_HZ / 2;
const DICTATION_REQUEST_TIMEOUT_MS = 4500;
const browserKeys = generateKeyPairSync("ed25519"); const browserKeys = generateKeyPairSync("ed25519");
const publicKeyPem = browserKeys.publicKey.export({ format: "pem", type: "spki" }).toString(); const publicKeyPem = browserKeys.publicKey.export({ format: "pem", type: "spki" }).toString();
function base64url(value) { function base64url(value) {
@@ -134,7 +138,7 @@ function wavFromPcm16(pcm, sampleRate = 24000) {
return Buffer.concat([header, pcm]); return Buffer.concat([header, pcm]);
} }
// The browser transcription relay sends 8 kHz G.711 mu-law, while Athena's // The browser transcription relay sends 8 kHz G.711 mu-law, while Athena's
// existing Whisper endpoint accepts PCM WAV uploads. // transcription endpoint accepts PCM WAV uploads.
function wavFromMulaw8k(audio) { function wavFromMulaw8k(audio) {
const pcm = Buffer.allocUnsafe(audio.length * 2); const pcm = Buffer.allocUnsafe(audio.length * 2);
for (let i = 0; i < audio.length; i += 1) { for (let i = 0; i < audio.length; i += 1) {
@@ -149,13 +153,38 @@ function resolveTranscriptionConfig(cfg, rawConfig) {
const talkProvider = record(record(talkConfig.providers)["athena-talk"]); const talkProvider = record(record(talkConfig.providers)["athena-talk"]);
return resolveConfig({ cfg, providerConfig: { ...talkProvider, ...record(rawConfig) } }); return resolveConfig({ cfg, providerConfig: { ...talkProvider, ...record(rawConfig) } });
} }
function comparableWord(value) {
return value.toLocaleLowerCase("de-DE").replace(/[^\p{L}\p{N}]+/gu, "");
}
function removeTranscriptOverlap(previous, current) {
const priorWords = previous.trim().split(/\s+/).filter(Boolean);
const currentWords = current.trim().split(/\s+/).filter(Boolean);
const maximum = Math.min(12, priorWords.length, currentWords.length);
for (let count = maximum; count >= 1; count -= 1) {
const left = priorWords.slice(-count).map(comparableWord);
const right = currentWords.slice(0, count).map(comparableWord);
if (!left.every((word, index) => word && word === right[index]))
continue;
// A single short word is too ambiguous to remove safely. Longer words are
// sufficient because the audio overlap is only half a second.
if (count === 1 && left[0].length < 5)
continue;
return currentWords.slice(count).join(" ");
}
return currentWords.join(" ");
}
class AthenaTranscriptionSession { class AthenaTranscriptionSession {
req; req;
config; config;
connected = false; connected = false;
closed = false; closed = false;
audio = []; audio = [];
bytes = 0; bufferedBytes = 0;
totalBytes = 0;
processing = Promise.resolve();
completedTranscripts = [];
emittedTranscripts = 0;
processingError = null;
constructor(req, config) { constructor(req, config) {
this.req = req; this.req = req;
this.config = config; this.config = config;
@@ -165,50 +194,88 @@ class AthenaTranscriptionSession {
sendAudio(audio) { sendAudio(audio) {
if (!this.isConnected() || audio.length === 0) if (!this.isConnected() || audio.length === 0)
return; return;
if (this.bytes === 0) if (this.totalBytes === 0)
this.req.onSpeechStart?.(); this.req.onSpeechStart?.();
const maxBytes = this.config.maxSpeechSeconds * 8000; const maxBytes = this.config.maxSpeechSeconds * 8000;
if (this.bytes + audio.length > maxBytes) { if (this.totalBytes + audio.length > maxBytes) {
this.req.onError?.(new Error(`Athena dictation is limited to ${this.config.maxSpeechSeconds} seconds`)); this.req.onError?.(new Error(`Athena dictation is limited to ${this.config.maxSpeechSeconds} seconds`));
this.close(); this.close();
return; return;
} }
this.audio.push(Buffer.from(audio)); this.audio.push(Buffer.from(audio));
this.bytes += audio.length; this.bufferedBytes += audio.length;
this.totalBytes += audio.length;
while (this.bufferedBytes >= DICTATION_SEGMENT_BYTES) {
this.queueFullSegment();
}
} }
close() { close() {
if (this.closed) if (this.closed)
return; return;
this.closed = true; this.closed = true;
this.connected = false; this.connected = false;
if (!this.bytes) if (!this.totalBytes)
return; return;
const audio = Buffer.concat(this.audio); this.emitCompletedTranscripts();
this.audio.length = 0; const tail = Buffer.concat(this.audio);
void this.transcribe(audio); this.audio = [];
this.bufferedBytes = 0;
if (tail.length > DICTATION_OVERLAP_BYTES || this.completedTranscripts.length === 0) {
this.queueTranscription(tail);
}
void this.processing.finally(() => {
this.emitCompletedTranscripts();
if (this.processingError && this.completedTranscripts.length === 0) {
this.req.onError?.(this.processingError);
}
});
} }
async transcribe(audio) { queueFullSegment() {
try { const buffered = Buffer.concat(this.audio);
const form = new FormData(); const segment = Buffer.from(buffered.subarray(0, DICTATION_SEGMENT_BYTES));
form.append("file", new Blob([Uint8Array.from(wavFromMulaw8k(audio))], { type: "audio/wav" }), "dictation.wav"); const retained = Buffer.from(buffered.subarray(DICTATION_SEGMENT_BYTES - DICTATION_OVERLAP_BYTES));
form.append("model", "whisper-1"); this.audio = retained.length ? [retained] : [];
form.append("language", this.config.language); this.bufferedBytes = retained.length;
const response = await fetch(`${this.config.baseUrl}/audio/transcriptions`, { this.queueTranscription(segment);
method: "POST", }
headers: this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {}, queueTranscription(audio) {
body: form, if (!audio.length)
signal: AbortSignal.timeout(4500), return;
}); this.processing = this.processing.then(async () => {
if (!response.ok) const previous = this.completedTranscripts.join(" ");
throw new Error(`Athena STT failed (HTTP ${response.status})`); const text = await this.transcribe(audio, previous.slice(-240));
const text = String(record(await response.json()).text || "").trim(); const novel = removeTranscriptOverlap(previous, text);
if (text) if (novel)
this.req.onTranscript?.(text); this.completedTranscripts.push(novel);
} if (this.closed)
catch (error) { this.emitCompletedTranscripts();
this.req.onError?.(error instanceof Error ? error : new Error(String(error))); }).catch((error) => {
this.processingError = error instanceof Error ? error : new Error(String(error));
});
}
emitCompletedTranscripts() {
while (this.emittedTranscripts < this.completedTranscripts.length) {
this.req.onTranscript?.(this.completedTranscripts[this.emittedTranscripts]);
this.emittedTranscripts += 1;
} }
} }
async transcribe(audio, prompt) {
const form = new FormData();
form.append("file", new Blob([Uint8Array.from(wavFromMulaw8k(audio))], { type: "audio/wav" }), "dictation.wav");
form.append("model", "qwen3-asr");
form.append("language", this.config.language);
if (prompt)
form.append("prompt", prompt);
const response = await fetch(`${this.config.baseUrl}/audio/transcriptions`, {
method: "POST",
headers: this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {},
body: form,
signal: AbortSignal.timeout(DICTATION_REQUEST_TIMEOUT_MS),
});
if (!response.ok)
throw new Error(`Athena STT failed (HTTP ${response.status})`);
return String(record(await response.json()).text || "").trim();
}
} }
function pcmRms(pcm) { function pcmRms(pcm) {
if (pcm.length < 2) if (pcm.length < 2)
@@ -404,7 +471,7 @@ class AthenaTalkBridge {
const form = new FormData(); const form = new FormData();
const wavBytes = Uint8Array.from(wavFromPcm16(pcm)); const wavBytes = Uint8Array.from(wavFromPcm16(pcm));
form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav"); form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav");
form.append("model", "whisper-1"); form.append("model", "qwen3-asr");
form.append("language", this.cfg.language); form.append("language", this.cfg.language);
const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, { const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, {
method: "POST", method: "POST",
@@ -500,9 +567,9 @@ export default definePluginEntry({
register(api) { register(api) {
api.registerRealtimeTranscriptionProvider({ api.registerRealtimeTranscriptionProvider({
id: "athena-talk", id: "athena-talk",
label: "Athena Whisper (Diktieren)", label: "Athena Qwen3-ASR (Diktieren)",
defaultModel: "whisper-1", defaultModel: "qwen3-asr",
models: ["whisper-1"], models: ["qwen3-asr"],
autoSelectOrder: 1, autoSelectOrder: 1,
resolveConfig: ({ cfg, rawConfig }) => resolveTranscriptionConfig(cfg, rawConfig), resolveConfig: ({ cfg, rawConfig }) => resolveTranscriptionConfig(cfg, rawConfig),
isConfigured: ({ providerConfig }) => Boolean(record(providerConfig).baseUrl), isConfigured: ({ providerConfig }) => Boolean(record(providerConfig).baseUrl),
+99 -31
View File
@@ -20,6 +20,10 @@ type ProviderConfig = {
const BROWSER_OFFER_PATH = "/plugins/athena-talk/realtime/calls"; const BROWSER_OFFER_PATH = "/plugins/athena-talk/realtime/calls";
const BROWSER_KEY_PATH = "/plugins/athena-talk/realtime/public-key"; const BROWSER_KEY_PATH = "/plugins/athena-talk/realtime/public-key";
const MAX_OFFER_BYTES = 64 * 1024; const MAX_OFFER_BYTES = 64 * 1024;
const DICTATION_SAMPLE_RATE_HZ = 8000;
const DICTATION_SEGMENT_BYTES = DICTATION_SAMPLE_RATE_HZ * 6;
const DICTATION_OVERLAP_BYTES = DICTATION_SAMPLE_RATE_HZ / 2;
const DICTATION_REQUEST_TIMEOUT_MS = 4500;
const browserKeys = generateKeyPairSync("ed25519"); const browserKeys = generateKeyPairSync("ed25519");
const publicKeyPem = browserKeys.publicKey.export({ format: "pem", type: "spki" }).toString(); const publicKeyPem = browserKeys.publicKey.export({ format: "pem", type: "spki" }).toString();
@@ -154,7 +158,7 @@ function wavFromPcm16(pcm: Buffer, sampleRate = 24000): Buffer {
} }
// The browser transcription relay sends 8 kHz G.711 mu-law, while Athena's // The browser transcription relay sends 8 kHz G.711 mu-law, while Athena's
// existing Whisper endpoint accepts PCM WAV uploads. // transcription endpoint accepts PCM WAV uploads.
function wavFromMulaw8k(audio: Buffer): Buffer { function wavFromMulaw8k(audio: Buffer): Buffer {
const pcm = Buffer.allocUnsafe(audio.length * 2); const pcm = Buffer.allocUnsafe(audio.length * 2);
for (let i = 0; i < audio.length; i += 1) { for (let i = 0; i < audio.length; i += 1) {
@@ -171,11 +175,36 @@ function resolveTranscriptionConfig(cfg: unknown, rawConfig: unknown): Required<
return resolveConfig({ cfg, providerConfig: { ...talkProvider, ...record(rawConfig) } }); return resolveConfig({ cfg, providerConfig: { ...talkProvider, ...record(rawConfig) } });
} }
function comparableWord(value: string): string {
return value.toLocaleLowerCase("de-DE").replace(/[^\p{L}\p{N}]+/gu, "");
}
function removeTranscriptOverlap(previous: string, current: string): string {
const priorWords = previous.trim().split(/\s+/).filter(Boolean);
const currentWords = current.trim().split(/\s+/).filter(Boolean);
const maximum = Math.min(12, priorWords.length, currentWords.length);
for (let count = maximum; count >= 1; count -= 1) {
const left = priorWords.slice(-count).map(comparableWord);
const right = currentWords.slice(0, count).map(comparableWord);
if (!left.every((word, index) => word && word === right[index])) continue;
// A single short word is too ambiguous to remove safely. Longer words are
// sufficient because the audio overlap is only half a second.
if (count === 1 && left[0].length < 5) continue;
return currentWords.slice(count).join(" ");
}
return currentWords.join(" ");
}
class AthenaTranscriptionSession { class AthenaTranscriptionSession {
private connected = false; private connected = false;
private closed = false; private closed = false;
private readonly audio: Buffer[] = []; private audio: Buffer[] = [];
private bytes = 0; private bufferedBytes = 0;
private totalBytes = 0;
private processing: Promise<void> = Promise.resolve();
private readonly completedTranscripts: string[] = [];
private emittedTranscripts = 0;
private processingError: Error | null = null;
constructor( constructor(
private readonly req: { private readonly req: {
@@ -193,46 +222,85 @@ class AthenaTranscriptionSession {
sendAudio(audio: Buffer): void { sendAudio(audio: Buffer): void {
if (!this.isConnected() || audio.length === 0) return; if (!this.isConnected() || audio.length === 0) return;
if (this.bytes === 0) this.req.onSpeechStart?.(); if (this.totalBytes === 0) this.req.onSpeechStart?.();
const maxBytes = this.config.maxSpeechSeconds * 8000; const maxBytes = this.config.maxSpeechSeconds * 8000;
if (this.bytes + audio.length > maxBytes) { if (this.totalBytes + audio.length > maxBytes) {
this.req.onError?.(new Error(`Athena dictation is limited to ${this.config.maxSpeechSeconds} seconds`)); this.req.onError?.(new Error(`Athena dictation is limited to ${this.config.maxSpeechSeconds} seconds`));
this.close(); this.close();
return; return;
} }
this.audio.push(Buffer.from(audio)); this.audio.push(Buffer.from(audio));
this.bytes += audio.length; this.bufferedBytes += audio.length;
this.totalBytes += audio.length;
while (this.bufferedBytes >= DICTATION_SEGMENT_BYTES) {
this.queueFullSegment();
}
} }
close(): void { close(): void {
if (this.closed) return; if (this.closed) return;
this.closed = true; this.closed = true;
this.connected = false; this.connected = false;
if (!this.bytes) return; if (!this.totalBytes) return;
const audio = Buffer.concat(this.audio); this.emitCompletedTranscripts();
this.audio.length = 0; const tail = Buffer.concat(this.audio);
void this.transcribe(audio); this.audio = [];
this.bufferedBytes = 0;
if (tail.length > DICTATION_OVERLAP_BYTES || this.completedTranscripts.length === 0) {
this.queueTranscription(tail);
}
void this.processing.finally(() => {
this.emitCompletedTranscripts();
if (this.processingError && this.completedTranscripts.length === 0) {
this.req.onError?.(this.processingError);
}
});
} }
private async transcribe(audio: Buffer): Promise<void> { private queueFullSegment(): void {
try { const buffered = Buffer.concat(this.audio);
const form = new FormData(); const segment = Buffer.from(buffered.subarray(0, DICTATION_SEGMENT_BYTES));
form.append("file", new Blob([Uint8Array.from(wavFromMulaw8k(audio))], { type: "audio/wav" }), "dictation.wav"); const retained = Buffer.from(buffered.subarray(DICTATION_SEGMENT_BYTES - DICTATION_OVERLAP_BYTES));
form.append("model", "whisper-1"); this.audio = retained.length ? [retained] : [];
form.append("language", this.config.language); this.bufferedBytes = retained.length;
const response = await fetch(`${this.config.baseUrl}/audio/transcriptions`, { this.queueTranscription(segment);
method: "POST", }
headers: this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {},
body: form, private queueTranscription(audio: Buffer): void {
signal: AbortSignal.timeout(4500), if (!audio.length) return;
}); this.processing = this.processing.then(async () => {
if (!response.ok) throw new Error(`Athena STT failed (HTTP ${response.status})`); const previous = this.completedTranscripts.join(" ");
const text = String(record(await response.json()).text || "").trim(); const text = await this.transcribe(audio, previous.slice(-240));
if (text) this.req.onTranscript?.(text); const novel = removeTranscriptOverlap(previous, text);
} catch (error) { if (novel) this.completedTranscripts.push(novel);
this.req.onError?.(error instanceof Error ? error : new Error(String(error))); if (this.closed) this.emitCompletedTranscripts();
}).catch((error: unknown) => {
this.processingError = error instanceof Error ? error : new Error(String(error));
});
}
private emitCompletedTranscripts(): void {
while (this.emittedTranscripts < this.completedTranscripts.length) {
this.req.onTranscript?.(this.completedTranscripts[this.emittedTranscripts]);
this.emittedTranscripts += 1;
} }
} }
private async transcribe(audio: Buffer, prompt: string): Promise<string> {
const form = new FormData();
form.append("file", new Blob([Uint8Array.from(wavFromMulaw8k(audio))], { type: "audio/wav" }), "dictation.wav");
form.append("model", "qwen3-asr");
form.append("language", this.config.language);
if (prompt) form.append("prompt", prompt);
const response = await fetch(`${this.config.baseUrl}/audio/transcriptions`, {
method: "POST",
headers: this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {},
body: form,
signal: AbortSignal.timeout(DICTATION_REQUEST_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`Athena STT failed (HTTP ${response.status})`);
return String(record(await response.json()).text || "").trim();
}
} }
function pcmRms(pcm: Buffer): number { function pcmRms(pcm: Buffer): number {
@@ -422,7 +490,7 @@ class AthenaTalkBridge {
const form = new FormData(); const form = new FormData();
const wavBytes = Uint8Array.from(wavFromPcm16(pcm)); const wavBytes = Uint8Array.from(wavFromPcm16(pcm));
form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav"); form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav");
form.append("model", "whisper-1"); form.append("model", "qwen3-asr");
form.append("language", this.cfg.language); form.append("language", this.cfg.language);
const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, { const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, {
method: "POST", method: "POST",
@@ -510,9 +578,9 @@ export default definePluginEntry({
register(api) { register(api) {
api.registerRealtimeTranscriptionProvider({ api.registerRealtimeTranscriptionProvider({
id: "athena-talk", id: "athena-talk",
label: "Athena Whisper (Diktieren)", label: "Athena Qwen3-ASR (Diktieren)",
defaultModel: "whisper-1", defaultModel: "qwen3-asr",
models: ["whisper-1"], models: ["qwen3-asr"],
autoSelectOrder: 1, autoSelectOrder: 1,
resolveConfig: ({ cfg, rawConfig }) => resolveTranscriptionConfig(cfg, rawConfig), resolveConfig: ({ cfg, rawConfig }) => resolveTranscriptionConfig(cfg, rawConfig),
isConfigured: ({ providerConfig }) => Boolean(record(providerConfig).baseUrl), isConfigured: ({ providerConfig }) => Boolean(record(providerConfig).baseUrl),
@@ -1,7 +1,7 @@
{ {
"id": "athena-talk", "id": "athena-talk",
"name": "Athena Local Talk", "name": "Athena Local Talk",
"description": "Private OpenClaw Talk provider using Athena Whisper and Qwen3-TTS.", "description": "Private OpenClaw Talk provider using Athena Qwen3-ASR and Qwen3-TTS.",
"activation": { "activation": {
"onStartup": true "onStartup": true
}, },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.2.1", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.2.1", "version": "1.3.1",
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"openclaw": "2026.9.4", "openclaw": "2026.9.4",
@@ -1,8 +1,8 @@
{ {
"name": "@casaderoll/openclaw-athena-talk", "name": "@casaderoll/openclaw-athena-talk",
"version": "1.2.1", "version": "1.3.1",
"private": true, "private": true,
"description": "Local OpenClaw Talk provider backed by Athena Whisper and Qwen3-TTS", "description": "Local OpenClaw Talk provider backed by Athena Qwen3-ASR and Qwen3-TTS",
"type": "module", "type": "module",
"files": [ "files": [
"dist", "dist",
@@ -3,7 +3,15 @@ import { createServer } from "node:http";
import { test } from "node:test"; import { test } from "node:test";
import plugin from "./dist/index.js"; import plugin from "./dist/index.js";
test("dictation registers separately and sends G.711 audio to Athena Whisper", async () => { async function waitFor(predicate, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error("timed out waiting for condition");
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
test("dictation registers separately and sends G.711 audio to Athena Qwen3-ASR", async () => {
let transcription; let transcription;
plugin.register({ plugin.register({
registerRealtimeTranscriptionProvider: (value) => { transcription = value; }, registerRealtimeTranscriptionProvider: (value) => { transcription = value; },
@@ -29,7 +37,7 @@ test("dictation registers separately and sends G.711 audio to Athena Whisper", a
assert.equal(wav.readUInt16LE(34), 16); assert.equal(wav.readUInt16LE(34), 16);
assert.equal(wav.length, 48); assert.equal(wav.length, 48);
assert.equal(form.get("language"), "de"); assert.equal(form.get("language"), "de");
assert.equal(form.get("model"), "whisper-1"); assert.equal(form.get("model"), "qwen3-asr");
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ text: "Hallo Athena" })); res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ text: "Hallo Athena" }));
}); });
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
@@ -79,3 +87,73 @@ test("dictation reuses only a TTS key for the same Athena origin", () => {
cfg: withTts("http://other:8081/v1"), rawConfig: {}, cfg: withTts("http://other:8081/v1"), rawConfig: {},
}).apiKey, ""); }).apiKey, "");
}); });
test("long dictation is transcribed incrementally before the recording closes", async () => {
let transcription;
plugin.register({
registerRealtimeTranscriptionProvider: (value) => { transcription = value; },
registerRealtimeVoiceProvider: () => {},
registerHttpRoute: () => {},
});
const answers = [
"Dies ist ein langer Abschnitt",
"langer Abschnitt mit einer Fortsetzung",
"einer Fortsetzung und einem Ende.",
];
let uploads = 0;
const server = createServer(async (req, res) => {
const index = uploads++;
const form = await new Request("http://localhost", {
method: "POST",
headers: { "Content-Type": req.headers["content-type"] },
body: req,
duplex: "half",
}).formData();
const wav = Buffer.from(await form.get("file").arrayBuffer());
assert.equal(wav.readUInt32LE(24), 8000);
if (index > 0) assert.ok(String(form.get("prompt") || "").length > 0);
res.writeHead(200, { "Content-Type": "application/json" })
.end(JSON.stringify({ text: answers[index] }));
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const cfg = { talk: { realtime: { providers: { "athena-talk": {
baseUrl: `http://127.0.0.1:${server.address().port}/v1`, language: "de",
} } } } };
const providerConfig = transcription.resolveConfig({ cfg, rawConfig: {} });
const transcripts = [];
const errors = [];
try {
const session = transcription.createSession({
cfg,
providerConfig,
onTranscript: (text) => transcripts.push(text),
onError: (error) => errors.push(error),
});
await session.connect();
// Six seconds start the first request while dictation is still active.
session.sendAudio(Buffer.alloc(48_000, 0xff));
await waitFor(() => uploads === 1);
assert.deepEqual(transcripts, []);
// Another 5.5 seconds form the next overlapping segment. The remaining
// 2 seconds are finalized only when the user stops dictation.
session.sendAudio(Buffer.alloc(44_000, 0xff));
await waitFor(() => uploads === 2);
session.sendAudio(Buffer.alloc(16_000, 0xff));
session.close();
await waitFor(() => transcripts.length === 3);
assert.deepEqual(transcripts, [
"Dies ist ein langer Abschnitt",
"mit einer Fortsetzung",
"und einem Ende.",
]);
assert.equal(uploads, 3);
assert.deepEqual(errors, []);
} finally {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
});
+2 -1
View File
@@ -52,7 +52,8 @@ case "$command" in
[[ $# -eq 0 ]] || { echo "core akzeptiert keine weiteren Services" >&2; exit 2; } [[ $# -eq 0 ]] || { echo "core akzeptiert keine weiteren Services" >&2; exit 2; }
run "$ROOT_DIR/platform/mcp/install-tools.sh" run "$ROOT_DIR/platform/mcp/install-tools.sh"
run "${compose[@]}" up -d --build \ run "${compose[@]}" up -d --build \
wireguard-gateway embedding qwen3-tts tts-gateway profile-controller router llama-dashboard portainer backup wireguard-gateway embedding qwen3-tts tts-gateway profile-controller \
qwen-asr qwen-asr-worker router llama-dashboard portainer backup
else else
run "${compose[@]}" up -d --build --no-deps "$@" run "${compose[@]}" up -d --build --no-deps "$@"
fi fi
+16
View File
@@ -37,6 +37,22 @@ target="$OUTPUT_DIR/$name"
list=$(mktemp /tmp/athena-export-list.XXXXXX) list=$(mktemp /tmp/athena-export-list.XXXXXX)
trap 'rm -f "$list" "$partial"' EXIT trap 'rm -f "$list" "$partial"' EXIT
# The encrypted export is roughly the size of its predecessor. Keeping all
# generations until after writing the next one requires one extra archive of
# free space and can deadlock a full backup disk. Release exactly one slot
# before writing when the configured retention is already reached. If the new
# export fails, KEEP-1 valid generations remain available.
mapfile -t existing < <(find "$OUTPUT_DIR" -maxdepth 1 -type f \
-name 'athena-portable-*.tar.zst.age' -printf '%T@ %p\n' \
| sort -rn | cut -d' ' -f2-)
while ((${#existing[@]} >= KEEP)); do
last_index=$((${#existing[@]} - 1))
oldest=${existing[$last_index]}
rm -f -- "$oldest" "$oldest.sha256"
unset 'existing[last_index]'
existing=("${existing[@]}")
done
add_path() { add_path() {
local path=${1#/} local path=${1#/}
[[ ! -e /$path ]] || printf '%s\0' "$path" >>"$list" [[ ! -e /$path ]] || printf '%s\0' "$path" >>"$list"
@@ -27,8 +27,12 @@ LABEL_KEY = "com.mike-ai.llama-profile"
IMAGE_LABEL_KEY = "com.mike-ai.image-worker" IMAGE_LABEL_KEY = "com.mike-ai.image-worker"
IMAGE_WORKER = os.environ.get("IMAGE_WORKER", "image") IMAGE_WORKER = os.environ.get("IMAGE_WORKER", "image")
RESTORE_WORKER = os.environ.get("RESTORE_WORKER", "restore") RESTORE_WORKER = os.environ.get("RESTORE_WORKER", "restore")
QWEN_IMAGE_TEST_WORKER = os.environ.get( IMAGE_PROMPT_I2I_WORKER = os.environ.get(
"QWEN_IMAGE_TEST_WORKER", "qwen-image-2.1-test").strip() "IMAGE_PROMPT_I2I_WORKER", "image-prompt-i2i").strip()
IMAGE_PROMPT_T2I_WORKER = os.environ.get(
"IMAGE_PROMPT_T2I_WORKER", "image-prompt-t2i").strip()
FLUX_STANDBY_WORKER = os.environ.get(
"FLUX_STANDBY_WORKER", "flux-standby").strip()
TTS_LABEL_KEY = "com.mike-ai.tts-worker" TTS_LABEL_KEY = "com.mike-ai.tts-worker"
TTS_WORKER = os.environ.get("TTS_WORKER", "qwen3") TTS_WORKER = os.environ.get("TTS_WORKER", "qwen3")
MUSIC_LABEL_KEY = "com.mike-ai.music-worker" MUSIC_LABEL_KEY = "com.mike-ai.music-worker"
@@ -102,9 +106,14 @@ def image_container(kind: str = IMAGE_WORKER) -> dict:
def image_containers() -> list[dict]: def image_containers() -> list[dict]:
"""All allowlisted GPU workers that must never overlap an LLM.""" """All allowlisted GPU workers that must never overlap an LLM."""
allowed = {IMAGE_WORKER, RESTORE_WORKER} allowed = {
if QWEN_IMAGE_TEST_WORKER: IMAGE_WORKER,
allowed.add(QWEN_IMAGE_TEST_WORKER) RESTORE_WORKER,
IMAGE_PROMPT_I2I_WORKER,
IMAGE_PROMPT_T2I_WORKER,
}
if FLUX_STANDBY_WORKER:
allowed.add(FLUX_STANDBY_WORKER)
return [item for item in labelled_containers(IMAGE_LABEL_KEY) return [item for item in labelled_containers(IMAGE_LABEL_KEY)
if item.get("Labels", {}).get(IMAGE_LABEL_KEY) in allowed] if item.get("Labels", {}).get(IMAGE_LABEL_KEY) in allowed]
@@ -363,7 +372,13 @@ def stop_inference() -> dict:
def set_image_worker(running: bool, kind: str = IMAGE_WORKER) -> dict: def set_image_worker(running: bool, kind: str = IMAGE_WORKER) -> dict:
if kind not in {IMAGE_WORKER, RESTORE_WORKER, QWEN_IMAGE_TEST_WORKER}: if kind not in {
IMAGE_WORKER,
RESTORE_WORKER,
FLUX_STANDBY_WORKER,
IMAGE_PROMPT_I2I_WORKER,
IMAGE_PROMPT_T2I_WORKER,
}:
raise ValueError("worker is not allowlisted") raise ValueError("worker is not allowlisted")
with LOCK: with LOCK:
item = image_container(kind) item = image_container(kind)
@@ -371,8 +386,8 @@ def set_image_worker(running: bool, kind: str = IMAGE_WORKER) -> dict:
# The image worker may never overlap a llama profile on the 5080. # The image worker may never overlap a llama profile on the 5080.
for profile_item in containers().values(): for profile_item in containers().values():
stop_container(profile_item) stop_container(profile_item)
# The 9B beta text encoder temporarily borrows the RTX 3060 from # Image workers are GPU-exclusive. The FLUX standby also borrows
# Qwen3-TTS. TTS is unavailable during this exclusive GPU phase. # the RTX 3060, while Qwen Image runs only on the RTX 5080.
stop_container(tts_container(), timeout=30) stop_container(tts_container(), timeout=30)
stop_music_if_configured() stop_music_if_configured()
stop_yue2_if_configured() stop_yue2_if_configured()
@@ -799,10 +814,14 @@ class Handler(BaseHTTPRequestHandler):
worker_paths = { worker_paths = {
"/workers/image/start": (IMAGE_WORKER, True), "/workers/image/start": (IMAGE_WORKER, True),
"/workers/image/stop": (IMAGE_WORKER, False), "/workers/image/stop": (IMAGE_WORKER, False),
"/workers/image-prompt-i2i/start": (IMAGE_PROMPT_I2I_WORKER, True),
"/workers/image-prompt-i2i/stop": (IMAGE_PROMPT_I2I_WORKER, False),
"/workers/image-prompt-t2i/start": (IMAGE_PROMPT_T2I_WORKER, True),
"/workers/image-prompt-t2i/stop": (IMAGE_PROMPT_T2I_WORKER, False),
"/workers/restore/start": (RESTORE_WORKER, True), "/workers/restore/start": (RESTORE_WORKER, True),
"/workers/restore/stop": (RESTORE_WORKER, False), "/workers/restore/stop": (RESTORE_WORKER, False),
"/workers/qwen-image-test/start": (QWEN_IMAGE_TEST_WORKER, True), "/workers/flux-standby/start": (FLUX_STANDBY_WORKER, True),
"/workers/qwen-image-test/stop": (QWEN_IMAGE_TEST_WORKER, False), "/workers/flux-standby/stop": (FLUX_STANDBY_WORKER, False),
} }
if self.path in worker_paths: if self.path in worker_paths:
try: try:
@@ -0,0 +1,17 @@
FROM python:3.13.7-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10005 --home-dir /nonexistent --shell /usr/sbin/nologin stt
WORKDIR /app
COPY router/qwen_asr_worker.py /app/qwen_asr_worker.py
ENV QWEN_ASR_HOST=0.0.0.0 \
QWEN_ASR_PORT=8084 \
QWEN_ASR_LANGUAGE=de
USER 10005:10005
EXPOSE 8084
ENTRYPOINT ["python", "/app/qwen_asr_worker.py"]
@@ -10,7 +10,12 @@ RUN test -n "$COMFYUI_COMMIT" \
&& python -m pip install --break-system-packages --no-cache-dir \ && python -m pip install --break-system-packages --no-cache-dir \
-r /opt/ComfyUI/requirements.txt \ -r /opt/ComfyUI/requirements.txt \
&& apt-get purge -y --auto-remove git \ && apt-get purge -y --auto-remove git \
&& groupadd --gid 10002 qwen-image \
&& useradd --uid 10002 --gid 10002 --no-create-home \
--home-dir /nonexistent --shell /usr/sbin/nologin qwen-image \
&& rm -rf /var/lib/apt/lists/* /root/.cache && rm -rf /var/lib/apt/lists/* /root/.cache
WORKDIR /opt/ComfyUI WORKDIR /opt/ComfyUI
EXPOSE 8188 COPY qwen_image_worker.py /opt/qwen-image-worker.py
EXPOSE 8086
CMD ["python", "/opt/qwen-image-worker.py"]
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Router-compatible Qwen-Image-2.1 worker backed by pinned ComfyUI."""
from __future__ import annotations
import json
import os
import random
import shutil
import signal
import subprocess
import threading
import time
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
HOST = os.environ.get("WORKER_HOST", "0.0.0.0")
PORT = int(os.environ.get("WORKER_PORT", "8086"))
TOKEN = os.environ.get("WORKER_TOKEN", "").strip()
COMFY = "http://127.0.0.1:8188"
COMFY_DIR = Path("/opt/ComfyUI")
INPUT_DIR = COMFY_DIR / "input"
OUTPUT_DIR = Path(os.environ.get("IMAGE_DIR", "/data/images")).resolve()
MODEL = "Qwen-Image-2.1-int8"
GENERATION_LOCK = threading.Lock()
READY = threading.Event()
ACTIVE = False
COMFY_PROCESS: subprocess.Popen | None = None
if len(TOKEN) < 32:
raise RuntimeError("WORKER_TOKEN is missing or too short")
def request_json(path: str, payload: dict | None = None,
timeout: float = 30) -> dict:
body = None if payload is None else json.dumps(payload).encode()
headers = {"Content-Type": "application/json"} if body is not None else {}
method = "POST" if body is not None else "GET"
request = urllib.request.Request(COMFY + path, data=body,
headers=headers, method=method)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.load(response)
def wait_for_comfy() -> None:
for _ in range(300):
if COMFY_PROCESS is not None and COMFY_PROCESS.poll() is not None:
return
try:
request_json("/system_stats", timeout=2)
READY.set()
return
except Exception:
time.sleep(1)
def start_comfy() -> None:
global COMFY_PROCESS
INPUT_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
command = [
"python", "main.py", "--listen", "127.0.0.1", "--port", "8188",
"--lowvram", "--preview-method", "none",
"--input-directory", str(INPUT_DIR),
"--output-directory", str(OUTPUT_DIR),
]
COMFY_PROCESS = subprocess.Popen(command, cwd=COMFY_DIR)
threading.Thread(target=wait_for_comfy, daemon=True).start()
def stop(*_: object) -> None:
if COMFY_PROCESS is not None and COMFY_PROCESS.poll() is None:
COMFY_PROCESS.terminate()
try:
COMFY_PROCESS.wait(timeout=15)
except subprocess.TimeoutExpired:
COMFY_PROCESS.kill()
raise SystemExit(0)
def validate_request(data: dict) -> tuple[str, str, int, int, int, float, int]:
prompt = data.get("prompt")
filename = data.get("filename")
if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 8000:
raise ValueError("invalid prompt")
if (not isinstance(filename, str) or Path(filename).name != filename
or not filename.endswith(".png")):
raise ValueError("invalid filename")
width = int(data.get("width", 1024))
height = int(data.get("height", 1024))
if width % 32 or height % 32 or not 512 <= width <= 2048 or not 512 <= height <= 2048:
raise ValueError("width and height must be multiples of 32 between 512 and 2048")
steps = int(data.get("steps", 25))
guidance = float(data.get("guidance", 1.0))
if steps != 25 or guidance != 1.0:
raise ValueError("Qwen-Image-2.1 requires steps=25 and guidance=1.0")
seed = data.get("seed")
seed = random.randrange(2**32) if seed is None else int(seed)
if not 0 <= seed <= 2**32 - 1:
raise ValueError("invalid seed")
return prompt.strip(), filename, width, height, steps, guidance, seed
def make_workflow(prompt: str, seed: int, steps: int, width: int, height: int,
references: list[str], prefix: str) -> dict:
encode_inputs: dict = {
"clip": ["2", 0], "prompt": prompt, "negative_prompt": "",
"resolution": max(width, height),
}
workflow: dict = {
"1": {"class_type": "UNETLoader", "inputs": {
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
"weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"}},
"4": {"class_type": "TextEncodeQwenImage21", "inputs": encode_inputs},
"6": {"class_type": "KSampler", "inputs": {
"model": ["1", 0], "positive": ["4", 0], "negative": ["4", 1],
"latent_image": ["4", 2] if references else ["5", 0],
"seed": seed, "steps": steps, "cfg": 1.0,
"sampler_name": "euler", "scheduler": "simple", "denoise": 1.0}},
"7": {"class_type": "VAEDecode", "inputs": {
"samples": ["6", 0], "vae": ["3", 0]}},
"8": {"class_type": "SaveImage", "inputs": {
"filename_prefix": prefix, "images": ["7", 0]}},
}
if references:
encode_inputs["vae"] = ["3", 0]
for index, name in enumerate(references, 1):
node = str(8 + index)
workflow[node] = {"class_type": "LoadImage", "inputs": {"image": name}}
# ComfyUI V3 dynamic inputs use the parent path as a prefix. The
# normalizer turns images.image_N back into the execute() dict.
encode_inputs[f"images.image_{index}"] = [node, 0]
else:
workflow["5"] = {"class_type": "EmptyLatentImage", "inputs": {
"width": width, "height": height, "batch_size": 1}}
return workflow
def wait_result(prompt_id: str, timeout: int = 1800) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
history = request_json(f"/history/{prompt_id}", timeout=10)
if prompt_id in history:
result = history[prompt_id]
status = result.get("status", {})
if status.get("status_str") == "error" or not status.get("completed", True):
raise RuntimeError("ComfyUI generation failed: " + json.dumps(status))
return result
time.sleep(1)
raise TimeoutError("Qwen image generation timed out")
def prepare_references(data: dict, job: str) -> list[str]:
source_files = data.get("source_files") or []
if not isinstance(source_files, list) or len(source_files) > 4:
raise ValueError("invalid source image list")
copied: list[str] = []
for index, name in enumerate(source_files, 1):
if not isinstance(name, str) or Path(name).name != name:
raise ValueError("invalid source image filename")
source = (OUTPUT_DIR / name).resolve()
if source.parent != OUTPUT_DIR or not source.is_file():
raise ValueError("source image not found")
with source.open("rb") as stream:
header = stream.read(16)
if header.startswith(b"\x89PNG\r\n\x1a\n"):
suffix = ".png"
elif header.startswith(b"\xff\xd8\xff"):
suffix = ".jpg"
elif header.startswith((b"RIFF",)) and header[8:12] == b"WEBP":
suffix = ".webp"
else:
raise ValueError("unsupported source image format")
target_name = f"{job}-ref-{index}{suffix}"
shutil.copyfile(source, INPUT_DIR / target_name)
copied.append(target_name)
return copied
def generate(data: dict) -> dict:
global ACTIVE
with GENERATION_LOCK:
ACTIVE = True
started = time.monotonic()
copied: list[str] = []
try:
prompt, filename, width, height, steps, _, seed = validate_request(data)
job = "router-" + uuid.uuid4().hex
copied = prepare_references(data, job)
queued = request_json("/prompt", {"prompt": make_workflow(
prompt, seed, steps, width, height, copied, job),
"client_id": "athena-image-router"})
result = wait_result(queued["prompt_id"])
images = [image for output in result.get("outputs", {}).values()
for image in output.get("images", [])]
if not images:
raise RuntimeError("generation completed without an image")
image = images[0]
generated = (OUTPUT_DIR / image.get("subfolder", "") /
image["filename"]).resolve()
if OUTPUT_DIR not in generated.parents or not generated.is_file():
raise RuntimeError("ComfyUI returned an invalid output path")
target = (OUTPUT_DIR / filename).resolve()
if target.parent != OUTPUT_DIR:
raise RuntimeError("invalid output path")
generated.replace(target)
return {"status": "ok", "filename": filename, "seed": seed,
"seconds": round(time.monotonic() - started, 3),
"model": MODEL}
finally:
for name in copied:
try:
(INPUT_DIR / name).unlink()
except FileNotFoundError:
pass
ACTIVE = False
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args: object) -> None:
print(f"[qwen-image-2.1] {self.client_address[0]} {fmt % args}", flush=True)
def reply(self, status: int, payload: dict) -> None:
body = json.dumps(payload, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
if self.path != "/health":
self.reply(404, {"error": "not found"})
elif not READY.is_set():
self.reply(503, {"status": "starting", "model": MODEL})
else:
self.reply(200, {"status": "ok", "model_loaded": ACTIVE,
"model": MODEL})
def do_POST(self) -> None: # noqa: N802
if self.headers.get("Authorization", "") != f"Bearer {TOKEN}":
self.reply(401, {"error": "unauthorized"})
return
if self.path != "/generate":
self.reply(404, {"error": "not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length < 2 or length > 32768:
raise ValueError("invalid request size")
self.reply(200, generate(json.loads(self.rfile.read(length))))
except Exception as exc:
print(f"[qwen-image-2.1] generation failed: {type(exc).__name__}: "
f"{str(exc)[:1000]}", flush=True)
self.reply(500, {"status": "error", "message": str(exc)})
if __name__ == "__main__":
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
start_comfy()
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
+2 -2
View File
@@ -133,8 +133,8 @@ display:
language: "de" language: "de"
show_reasoning: false show_reasoning: false
# Speech input stays local and private. A fixed German hint avoids Whisper # Speech input stays local and private. The fixed German hint helps with
# interpreting short utterances as English while retaining the fast base model. # short utterances and product names.
stt: stt:
enabled: true enabled: true
provider: "local" provider: "local"
@@ -32,7 +32,7 @@ gateway, UI, CPU-STT, backup and operator containers may remain active.
- Fast: Qwen3.8-27B IQ4-MIX, 76,800 tokens. - Fast: Qwen3.8-27B IQ4-MIX, 76,800 tokens.
- Medium: Qwen3.8-27B IQ4_XS-pure, 160,000 tokens, vision. - Medium: Qwen3.8-27B IQ4_XS-pure, 160,000 tokens, vision.
- Large: the same Q4 model, 192,000 tokens, vision. - Large: the same Q4 model, 192,000 tokens, vision.
- Ultra: the same Q4 model, 262,144 tokens, no vision projector. - Ultra: the same Q4 model, 262,144 tokens, vision projector on CPU.
- Uncensored: Abliterated Q4_K_M, 80,000 tokens, vision. - Uncensored: Abliterated Q4_K_M, 80,000 tokens, vision.
Medium, Large, Ultra and Beta distribute their runtime across both GPUs. Do not Medium, Large, Ultra and Beta distribute their runtime across both GPUs. Do not
+3 -3
View File
@@ -16,10 +16,10 @@ nach Standardbenchmark, Tool-Calling-Test und Kontexttest übernommen.
- Fast: Qwen3.8-27B IQ4-MIX mit MTP2 - Fast: Qwen3.8-27B IQ4-MIX mit MTP2
- Medium und Large: Qwen3.8-27B IQ4_XS Pure mit MTP3 - Medium und Large: Qwen3.8-27B IQ4_XS Pure mit MTP3
- Ultra: Qwen3.8-27B IQ4_XS Pure mit MTP2 und maximalem Textkontext - Ultra: Qwen3.8-27B IQ4_XS Pure mit MTP2, maximalem Kontext und Vision-Projektor auf CPU
- Uncensored: Blackfrost Qwen3.8-27B Abliterated Q4_K_M mit MTP2 - Uncensored: Blackfrost Qwen3.8-27B Abliterated Q4_K_M mit MTP2
- Fast, Medium, Large und Uncensored: integrierte Vision; der jeweilige - Alle fünf Profile: integrierte Vision. Bei Fast, Medium, Large und
Projektor liegt vollständig auf der RTX 3060 Uncensored liegt der Projektor auf der RTX 3060; bei Ultra auf der CPU.
Die produktive Runtime ist seit dem 12. September 2026 auf llama.cpp Die produktive Runtime ist seit dem 12. September 2026 auf llama.cpp
**Build 10930**, Commit `56381e407c0ccfb3a6f71e668a27a901001d22ce`, **Build 10930**, Commit `56381e407c0ccfb3a6f71e668a27a901001d22ce`,
+1 -1
View File
@@ -3,4 +3,4 @@ Description=Legacy native Qwen Ultra 256K profile (Docker is the production path
[Service] [Service]
ExecStart= ExecStart=
ExecStart=/opt/mike-ai/llama.cpp/build/bin/llama-server --model /opt/mike-ai/models/qwen3.8-27b-iq4-xs-pure/qwen3.8-27b-IQ4_XS-pure.gguf --alias qwen-ultra --ctx-size 262144 --flash-attn on --cache-type-k q4_0 --cache-type-v q4_0 --cache-prompt --cache-reuse 256 --cache-ram 8192 --threads 6 --threads-batch 6 --batch-size 64 --ubatch-size 32 --parallel 1 --jinja --reasoning auto --host 127.0.0.1 --port 8080 --metrics --fit off --n-gpu-layers all --load-mode none --temperature 0.2 --top-p 0.8 --top-k 20 --device CUDA0,CUDA1 --main-gpu 0 --split-mode layer --tensor-split 80,20 --spec-type draft-mtp --spec-draft-n-max 2 --spec-draft-type-k f16 --spec-draft-type-v f16 ExecStart=/opt/mike-ai/llama.cpp/build/bin/llama-server --model /opt/mike-ai/models/qwen3.8-27b-iq4-xs-pure/qwen3.8-27b-IQ4_XS-pure.gguf --mmproj /opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf --no-mmproj-offload --alias qwen-ultra --ctx-size 262144 --flash-attn on --cache-type-k q4_0 --cache-type-v q4_0 --cache-prompt --cache-reuse 256 --cache-ram 8192 --threads 6 --threads-batch 6 --batch-size 64 --ubatch-size 32 --parallel 1 --jinja --reasoning auto --host 127.0.0.1 --port 8080 --metrics --fit off --n-gpu-layers all --load-mode none --temperature 0.2 --top-p 0.8 --top-k 20 --device CUDA0,CUDA1 --main-gpu 0 --split-mode layer --tensor-split 80,20 --spec-type draft-mtp --spec-draft-n-max 2 --spec-draft-type-k f16 --spec-draft-type-v f16
+275 -32
View File
@@ -19,7 +19,7 @@ Kommandos: POST /fast, /medium, /large, /ultra,
/uncensored /uncensored
GET /status (Zustand) GET /status (Zustand)
Bildgenerierung und Editing (FLUX.2 Klein 9B FP8 beta): Bildgenerierung und Editing (Qwen-Image-2.1 INT8):
POST /v1/images/generations (OpenAI-kompatibel) POST /v1/images/generations (OpenAI-kompatibel)
POST /v1/images/edits (lokal, Referenzbilder) POST /v1/images/edits (lokal, Referenzbilder)
GET /images (Liste) GET /images (Liste)
@@ -29,18 +29,17 @@ Sprachausgabe (Qwen3-TTS auf RTX 3060):
POST /v1/audio/speech (OpenAI-kompatibel) POST /v1/audio/speech (OpenAI-kompatibel)
GET /v1/audio/voices (verfügbare Stimmen) GET /v1/audio/voices (verfügbare Stimmen)
Spracherkennung (whisper.cpp, deutsch, CPU-only): Spracherkennung (Qwen3-ASR, deutsch, CPU-only):
POST /v1/audio/transcriptions (OpenAI-kompatibel) POST /v1/audio/transcriptions (OpenAI-kompatibel)
GET /v1/audio/models (verfügbare Audio-Modelle) GET /v1/audio/models (verfügbare Audio-Modelle)
Der TTS-Worker (mike-ai-xtts.service) und der STT-Worker TTS und Qwen3-ASR laufen als separate, langlebige Dienste.
(mike-ai-whisper.service) laufen als separate, langlebige Prozesse.
Der Router leitet /v1/audio/speech und /v1/audio/transcriptions Der Router leitet /v1/audio/speech und /v1/audio/transcriptions
per HTTP an die Worker weiter. per HTTP an die Worker weiter.
Der Router agiert als Modell-Orchestrator: vor der Generierung wird Der Router agiert als Modell-Orchestrator: vor der Generierung wird
llama.cpp und Qwen3-TTS gestoppt, der Bild-Worker lädt FLUX.2 und den llama.cpp und Qwen3-TTS gestoppt, der Bild-Worker lädt Qwen-Image und den
Text-Encoder auf getrennte GPUs, generiert/bearbeitet und entlädt Textencoder auf die RTX 5080, generiert/bearbeitet und entlädt
das Modell wieder; danach wird das vorherige Qwen-Profil wiederher- das Modell wieder; danach wird das vorherige Qwen-Profil wiederher-
gestellt und erst dann geantwortet (try/finally – Qwen wird auch bei gestellt und erst dann geantwortet (try/finally – Qwen wird auch bei
Fehlgeschlagener Generierung wiederhergestellt). Fehlgeschlagener Generierung wiederhergestellt).
@@ -137,7 +136,7 @@ DEFAULT_REASONING_EFFORT = os.environ.get(
GLOBAL_SYSTEM_POLICY_FILE = os.environ.get( GLOBAL_SYSTEM_POLICY_FILE = os.environ.get(
"GLOBAL_SYSTEM_POLICY_FILE", "").strip() "GLOBAL_SYSTEM_POLICY_FILE", "").strip()
# --- Bildgenerierung und Referenzbild-Bearbeitung (FLUX.2 Klein 9B FP8) --- # --- Bildgenerierung und Referenzbild-Bearbeitung (Qwen-Image-2.1 INT8) ---
LLAMA_SERVICE = os.environ.get("LLAMA_SERVICE", "mike-ai-llama-ui.service") LLAMA_SERVICE = os.environ.get("LLAMA_SERVICE", "mike-ai-llama-ui.service")
SYSTEMCTL_BIN = os.environ.get("SYSTEMCTL_BIN", "systemctl") SYSTEMCTL_BIN = os.environ.get("SYSTEMCTL_BIN", "systemctl")
IMAGE_WORKER = os.environ.get( IMAGE_WORKER = os.environ.get(
@@ -146,8 +145,21 @@ IMAGE_PYTHON = os.environ.get(
"IMAGE_PYTHON", "/opt/mike-ai/ai-profile-router/venv/bin/python") "IMAGE_PYTHON", "/opt/mike-ai/ai-profile-router/venv/bin/python")
IMAGE_WORKER_URL = os.environ.get("IMAGE_WORKER_URL", "").rstrip("/") IMAGE_WORKER_URL = os.environ.get("IMAGE_WORKER_URL", "").rstrip("/")
IMAGE_WORKER_TOKEN = os.environ.get("IMAGE_WORKER_TOKEN", "").strip() IMAGE_WORKER_TOKEN = os.environ.get("IMAGE_WORKER_TOKEN", "").strip()
IMAGE_PROMPT_I2I_URL = os.environ.get(
"IMAGE_PROMPT_I2I_URL", "").rstrip("/")
IMAGE_PROMPT_T2I_URL = os.environ.get(
"IMAGE_PROMPT_T2I_URL", "").rstrip("/")
IMAGE_PROMPT_I2I_SYSTEM_FILE = os.environ.get(
"IMAGE_PROMPT_I2I_SYSTEM_FILE",
"/etc/mike-ai/qwen-image-pe-i2i-system-prompt.txt")
IMAGE_PROMPT_T2I_SYSTEM_FILE = os.environ.get(
"IMAGE_PROMPT_T2I_SYSTEM_FILE",
"/etc/mike-ai/qwen-image-pe-t2i-system-prompt.txt")
IMAGE_PROMPT_ENHANCER_TIMEOUT = float(os.environ.get(
"IMAGE_PROMPT_ENHANCER_TIMEOUT", "180"))
IMAGE_MODEL_NAME = os.environ.get( IMAGE_MODEL_NAME = os.environ.get(
"IMAGE_MODEL_NAME", "FLUX.2-klein-9B-fp8-beta") "IMAGE_MODEL_NAME", "Qwen-Image-2.1-int8")
IMAGE_INFERENCE_STEPS = int(os.environ.get("IMAGE_INFERENCE_STEPS", "25"))
IMAGE_DIR = os.environ.get( IMAGE_DIR = os.environ.get(
"IMAGE_DIR", "/opt/mike-ai/ai-profile-router/images") "IMAGE_DIR", "/opt/mike-ai/ai-profile-router/images")
IMAGE_WORKER_LOG = os.environ.get( IMAGE_WORKER_LOG = os.environ.get(
@@ -175,11 +187,22 @@ IMAGE_SIZES = {
"1920x1088": (1920, 1088), "1920x1088": (1920, 1088),
"1088x1920": (1088, 1920), "1088x1920": (1088, 1920),
} }
# Das destillierte FLUX.2 Klein 9B ist auf vier Schritte ausgelegt. # Der produktive Qwen-Image-2.1-Workflow ist auf 25 Schritte festgelegt.
IMAGE_QUALITY = {"standard": 4, "high": 4} IMAGE_QUALITY = {"standard": IMAGE_INFERENCE_STEPS,
"high": IMAGE_INFERENCE_STEPS}
IMAGE_DEFAULT_QUALITY = "standard" IMAGE_DEFAULT_QUALITY = "standard"
IMAGE_MAX_N = 4 IMAGE_MAX_N = 4
IMAGE_RATIO_SIZES = {
"1:1": (1024, 1024),
"3:2": (1536, 1024),
"2:3": (1024, 1536),
"4:3": (1536, 1024),
"3:4": (1024, 1536),
"16:9": (1920, 1088),
"9:16": (1088, 1920),
}
# --- Sprachausgabe (Qwen3-TTS über das interne Normalisierungs-Gateway) --- # --- Sprachausgabe (Qwen3-TTS über das interne Normalisierungs-Gateway) ---
TTS_WORKER_URL = os.environ.get("TTS_WORKER_URL", "http://127.0.0.1:8085") TTS_WORKER_URL = os.environ.get("TTS_WORKER_URL", "http://127.0.0.1:8085")
TTS_TIMEOUT = float(os.environ.get("TTS_TIMEOUT", "300")) # s, pro Synthese TTS_TIMEOUT = float(os.environ.get("TTS_TIMEOUT", "300")) # s, pro Synthese
@@ -192,11 +215,11 @@ TTS_DEFAULT_VOICE = os.environ.get(
TTS_FORMATS = ("mp3", "wav", "pcm") TTS_FORMATS = ("mp3", "wav", "pcm")
TTS_DEFAULT_FORMAT = "mp3" TTS_DEFAULT_FORMAT = "mp3"
# --- Spracherkennung (whisper.cpp, deutsch, CPU-only) --- # --- Spracherkennung (Qwen3-ASR, deutsch, CPU-only) ---
STT_WORKER_URL = os.environ.get("STT_WORKER_URL", "http://127.0.0.1:8084") STT_WORKER_URL = os.environ.get("STT_WORKER_URL", "http://127.0.0.1:8084")
STT_TIMEOUT = float(os.environ.get("STT_TIMEOUT", "120")) # s, pro Transkription STT_TIMEOUT = float(os.environ.get("STT_TIMEOUT", "120")) # s, pro Transkription
STT_CONNECT_TIMEOUT = float(os.environ.get("STT_CONNECT_TIMEOUT", "5")) STT_CONNECT_TIMEOUT = float(os.environ.get("STT_CONNECT_TIMEOUT", "5"))
STT_MODEL = "whisper-1" # virtuelles Modell für /v1/audio/transcriptions STT_MODEL = "qwen3-asr"
# Maximale Upload-Größe (Bytes) – verhindert unbegrenzten RAM-Verbrauch. # Maximale Upload-Größe (Bytes) – verhindert unbegrenzten RAM-Verbrauch.
# 50 MB ist für Audio-Dateien (WebM/Opus, WAV, MP3) mehr als ausreichend. # 50 MB ist für Audio-Dateien (WebM/Opus, WAV, MP3) mehr als ausreichend.
@@ -1134,7 +1157,7 @@ def switch_profile(profile: str, implicit: bool = False) -> dict:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Bildgenerierung und Editing (FLUX.2 Klein 9B FP8) # Bildgenerierung und Editing (Qwen-Image-2.1 INT8)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class _Worker: class _Worker:
@@ -1384,11 +1407,151 @@ def _restore_qwen(profile: str) -> None:
RUNTIME.save(last_profile=profile, phase="idle") RUNTIME.save(last_profile=profile, phase="idle")
def _image_data_url(path: str) -> str:
"""Read one already validated local reference image as a data URL."""
image_root = os.path.abspath(IMAGE_DIR)
resolved = (os.path.abspath(path) if os.path.isabs(path)
else os.path.abspath(os.path.join(image_root, path)))
if os.path.commonpath((image_root, resolved)) != image_root:
raise RuntimeError("Referenzbild liegt außerhalb des Bildverzeichnisses")
with open(resolved, "rb") as handle:
data = handle.read()
if data.startswith(b"\x89PNG\r\n\x1a\n"):
mime = "image/png"
elif data.startswith(b"\xff\xd8\xff"):
mime = "image/jpeg"
elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
mime = "image/webp"
else:
raise RuntimeError(f"Referenzbild hat ein unbekanntes Format: {path}")
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
def _parse_prompt_enhancer_result(content: object) -> dict:
if not isinstance(content, str) or not content.strip():
raise RuntimeError("Prompt-Enhancer lieferte keine Antwort")
text = content.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r"\s*```$", "", text)
try:
result = json.loads(text)
except ValueError:
start, end = text.find("{"), text.rfind("}")
if start < 0 or end <= start:
raise RuntimeError("Prompt-Enhancer lieferte kein JSON")
try:
result = json.loads(text[start:end + 1])
except ValueError as exc:
raise RuntimeError("Prompt-Enhancer lieferte ungültiges JSON") from exc
if not isinstance(result, dict):
raise RuntimeError("Prompt-Enhancer lieferte kein JSON-Objekt")
rewritten = result.get("rewritten_prompt")
if not isinstance(rewritten, str) or not rewritten.strip():
raise RuntimeError("Prompt-Enhancer lieferte keinen rewritten_prompt")
if len(rewritten) > 8000:
raise RuntimeError("Aufbereiteter Bildprompt ist länger als 8000 Zeichen")
result["rewritten_prompt"] = rewritten.strip()
return result
def _enhance_image_prompt(prompt: str, source_files: list[str]) -> tuple[str, dict]:
"""Run the official Qwen Image 2.1 prompt enhancer on the RTX 3060."""
editing = bool(source_files)
kind = "image-prompt-i2i" if editing else "image-prompt-t2i"
url = IMAGE_PROMPT_I2I_URL if editing else IMAGE_PROMPT_T2I_URL
system_file = (IMAGE_PROMPT_I2I_SYSTEM_FILE if editing
else IMAGE_PROMPT_T2I_SYSTEM_FILE)
model = "qwen-image-pe-i2i" if editing else "qwen-image-pe-t2i"
if not PROFILE_CONTROL_URL or not url:
raise RuntimeError("Qwen-Image-Prompt-Enhancer ist nicht konfiguriert")
try:
with open(system_file, encoding="utf-8") as handle:
system_prompt = handle.read().strip()
except OSError as exc:
raise RuntimeError(f"Systemprompt des Prompt-Enhancers fehlt: {exc}") from exc
started = time.monotonic()
_profile_controller_request("POST", f"/workers/{kind}/start")
try:
deadline = time.monotonic() + IMAGE_START_TIMEOUT
while True:
try:
with urllib.request.urlopen(url + "/health", timeout=3) as response:
health = json.load(response)
if health.get("status") == "ok":
break
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
pass
if time.monotonic() >= deadline:
raise RuntimeError("Prompt-Enhancer hat nicht gestartet")
time.sleep(1)
if editing:
user_content: object = [
{"type": "image_url", "image_url": {"url": _image_data_url(path)}}
for path in source_files
]
user_content.append({"type": "text", "text": prompt})
else:
user_content = prompt
request_body = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"temperature": 1.0,
"top_p": 0.95,
"top_k": 20,
"presence_penalty": 0.0 if editing else 1.5,
"max_tokens": 4096,
"thinking_budget_tokens": 2048,
"chat_template_kwargs": {
"enable_thinking": True,
"reasoning_effort": "low",
},
}
req = urllib.request.Request(
url + "/v1/chat/completions",
data=json.dumps(request_body).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(
req, timeout=IMAGE_PROMPT_ENHANCER_TIMEOUT) as response:
payload = json.load(response)
except urllib.error.HTTPError as exc:
detail = exc.read(4096).decode(errors="replace")
raise RuntimeError(
f"Prompt-Enhancer HTTP {exc.code}: {detail[-500:]}") from exc
try:
content = payload["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise RuntimeError("Prompt-Enhancer-Antwort ist unvollständig") from exc
result = _parse_prompt_enhancer_result(content)
metadata = {
"model": model,
"quantization": "Q5_K_M",
"seconds": round(time.monotonic() - started, 3),
"wh_ratio": result.get("wh_ratio"),
"ratio_follow": result.get("ratio_follow"),
}
return result["rewritten_prompt"], metadata
finally:
try:
_profile_controller_request("POST", f"/workers/{kind}/stop")
except Exception as exc:
log.warning("Prompt-Enhancer ließ sich nicht stoppen: %s", exc)
def generate_image(prompt: str, width: int, height: int, steps: int, def generate_image(prompt: str, width: int, height: int, steps: int,
guidance: float, seed: int | None, n: int, guidance: float, seed: int | None, n: int,
quality: str = "standard", quality: str = "standard",
source_files: list[str] | None = None, source_files: list[str] | None = None,
model: str = IMAGE_MODEL_NAME, model: str = IMAGE_MODEL_NAME,
size_explicit: bool = False,
) -> tuple[list[str], str | None]: ) -> tuple[list[str], str | None]:
"""Orchestriert die Bildgenerierung inkl. Qwen-Hotswap. """Orchestriert die Bildgenerierung inkl. Qwen-Hotswap.
@@ -1409,6 +1572,8 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
warning: str | None = None warning: str | None = None
img.last_error = None img.last_error = None
img.current_model = model img.current_model = model
original_prompt = prompt
prompt_enhancer: dict | None = None
# Qwen wird gestoppt → für Chats nicht verfügbar (die warten). # Qwen wird gestoppt → für Chats nicht verfügbar (die warten).
_set_qwen_unavailable(True) _set_qwen_unavailable(True)
try: try:
@@ -1431,11 +1596,26 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
f"(Exit {proc.returncode}): {out[-500:]}") f"(Exit {proc.returncode}): {out[-500:]}")
_wait_upstream_down(time.monotonic() + 60) _wait_upstream_down(time.monotonic() + 60)
# 2) Worker starten (Modell wird beim ersten generate geladen). # 2) Den knappen Benutzerprompt mit dem offiziellen Qwen-
# Prompt-Enhancer auf der RTX 3060 in einen belastbaren
# Produktionsprompt umschreiben. Danach wird der Enhancer wieder
# beendet, bevor der eigentliche Bildworker startet.
img.phase = "enhancing-prompt"
prompt, prompt_enhancer = _enhance_image_prompt(
original_prompt, source_files or [])
ratio = prompt_enhancer.get("wh_ratio")
if not size_explicit and isinstance(ratio, str):
width, height = IMAGE_RATIO_SIZES.get(
ratio.strip(), (width, height))
log.info("Bildprompt aufbereitet (%s, %.1f s, Verhältnis %s)",
prompt_enhancer["model"],
prompt_enhancer["seconds"], ratio)
# 3) Worker starten (Modell wird beim ersten generate geladen).
img.phase = "loading-image" img.phase = "loading-image"
worker = _worker() worker = _worker()
# 3) Generieren. # 4) Generieren.
for i in range(n): for i in range(n):
img.phase = "generating" img.phase = "generating"
filename = time.strftime("%Y%m%d-%H%M%S") + \ filename = time.strftime("%Y%m%d-%H%M%S") + \
@@ -1463,6 +1643,8 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
# Metadaten speichern (Sidecar-JSON). # Metadaten speichern (Sidecar-JSON).
meta = { meta = {
"prompt": prompt, "prompt": prompt,
"original_prompt": original_prompt,
"prompt_enhancer": prompt_enhancer,
"seed": seed, "seed": seed,
"width": width, "width": width,
"height": height, "height": height,
@@ -1491,7 +1673,7 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
if removed: if removed:
log.info("Bild-Retention: %d alte Bilder entfernt", len(removed)) log.info("Bild-Retention: %d alte Bilder entfernt", len(removed))
# 4) Worker vollständig beenden (VRAM + CUDA-Kontext freigeben). # 5) Worker vollständig beenden (VRAM + CUDA-Kontext freigeben).
img.phase = "unloading-image" img.phase = "unloading-image"
worker.stop() worker.stop()
img.worker = None img.worker = None
@@ -1508,7 +1690,7 @@ def generate_image(prompt: str, width: int, height: int, steps: int,
img.worker = None img.worker = None
raise raise
finally: finally:
# 5) Qwen immer wiederherstellen. # 6) Qwen immer wiederherstellen.
img.phase = "restoring-qwen" img.phase = "restoring-qwen"
try: try:
_restore_qwen(profile) _restore_qwen(profile)
@@ -2221,7 +2403,26 @@ class Handler(BaseHTTPRequestHandler):
self._image_request(data, []) self._image_request(data, [])
def _image_edit(self) -> None: def _image_edit(self) -> None:
"""Edit with local image bytes supplied by the private Hermes plugin.""" """Edit with OpenAI multipart uploads or legacy JSON/base64 input."""
content_type = self.headers.get("Content-Type", "")
if "multipart/form-data" in content_type:
try:
body = self._read_body()
files, data = self._parse_multipart_parts(body, content_type)
data = self._normalize_image_multipart_fields(data)
except ValueError as exc:
self._send_error(400, str(exc),
"invalid_request_error", "invalid_multipart")
return
images = [payload for name, _filename, payload in files
if name in {"image", "image[]"}]
if not images:
self._send_error(400, "Referenzbild fehlt",
"invalid_request_error", "missing_image")
return
self._image_edit_bytes(data, images)
return
data = self._read_image_request() data = self._read_image_request()
if data is None: if data is None:
return return
@@ -2247,7 +2448,7 @@ class Handler(BaseHTTPRequestHandler):
"invalid_request_error", "too_many_images") "invalid_request_error", "too_many_images")
return return
source_files: list[str] = [] decoded: list[bytes] = []
try: try:
for item in encoded: for item in encoded:
if item.startswith("data:"): if item.startswith("data:"):
@@ -2258,6 +2459,25 @@ class Handler(BaseHTTPRequestHandler):
raw = base64.b64decode(item, validate=True) raw = base64.b64decode(item, validate=True)
except Exception as exc: except Exception as exc:
raise ValueError("ungültige Base64-Bilddaten") from exc raise ValueError("ungültige Base64-Bilddaten") from exc
decoded.append(raw)
self._image_edit_bytes(data, decoded)
except ValueError as exc:
self._send_error(400, str(exc),
"invalid_request_error", "invalid_image")
def _image_edit_bytes(self, data: dict, images: list[bytes]) -> None:
if len(images) > 4:
self._send_error(400, "höchstens vier Referenzbilder erlaubt",
"invalid_request_error", "too_many_images")
return
if not images:
self._send_error(400, "Referenzbild fehlt",
"invalid_request_error", "missing_image")
return
source_files: list[str] = []
try:
for raw in images:
if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES: if not raw or len(raw) > CHAT_IMAGE_MAX_BYTES:
raise ValueError( raise ValueError(
f"Referenzbild muss 1..{CHAT_IMAGE_MAX_BYTES} Bytes groß sein") f"Referenzbild muss 1..{CHAT_IMAGE_MAX_BYTES} Bytes groß sein")
@@ -2318,6 +2538,7 @@ class Handler(BaseHTTPRequestHandler):
return return
# Größe # Größe
size_explicit = "size" in data
size = data.get("size", "1024x1024") size = data.get("size", "1024x1024")
if size not in IMAGE_SIZES: if size not in IMAGE_SIZES:
self._send_error( self._send_error(
@@ -2342,8 +2563,10 @@ class Handler(BaseHTTPRequestHandler):
return return
steps = data.get("steps", IMAGE_QUALITY[quality]) steps = data.get("steps", IMAGE_QUALITY[quality])
if (not isinstance(steps, int) or isinstance(steps, bool) if (not isinstance(steps, int) or isinstance(steps, bool)
or steps != 4): or steps != IMAGE_INFERENCE_STEPS):
self._send_error(400, f"{IMAGE_MODEL_NAME} erfordert 'steps'=4", self._send_error(
400, f"{IMAGE_MODEL_NAME} erfordert "
f"'steps'={IMAGE_INFERENCE_STEPS}",
"invalid_request_error", "invalid_steps") "invalid_request_error", "invalid_steps")
return return
guidance = data.get("guidance", 1.0) guidance = data.get("guidance", 1.0)
@@ -2383,7 +2606,7 @@ class Handler(BaseHTTPRequestHandler):
try: try:
results, warning = generate_image( results, warning = generate_image(
prompt.strip(), width, height, steps, guidance, seed, n, prompt.strip(), width, height, steps, guidance, seed, n,
quality, source_files, model) quality, source_files, model, size_explicit)
except (ValueError, RuntimeError) as e: except (ValueError, RuntimeError) as e:
self._send_error(503, str(e), "server_error", "image_generation_failed") self._send_error(503, str(e), "server_error", "image_generation_failed")
return return
@@ -2622,7 +2845,7 @@ class Handler(BaseHTTPRequestHandler):
models.append({ models.append({
"id": STT_MODEL, "id": STT_MODEL,
"object": "model", "object": "model",
"owned_by": "whisper.cpp", "owned_by": "qwen3-asr",
"type": "transcription", "type": "transcription",
}) })
if tts.get("ready"): if tts.get("ready"):
@@ -2648,9 +2871,9 @@ class Handler(BaseHTTPRequestHandler):
# ---------- STT (Spracherkennung) ---------- # ---------- STT (Spracherkennung) ----------
def _parse_multipart(self, data: bytes, content_type: str def _parse_multipart_parts(self, data: bytes, content_type: str
) -> tuple[bytes, str, dict]: ) -> tuple[list[tuple[str, str, bytes]], dict]:
"""Parst multipart/form-data. Liefert (file_data, filename, fields). """Parst alle Datei- und Textteile eines multipart/form-data-Body.
Nutzt email.parser.BytesParser (Standardbibliothek) für robustes Nutzt email.parser.BytesParser (Standardbibliothek) für robustes
MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige
@@ -2663,9 +2886,8 @@ class Handler(BaseHTTPRequestHandler):
if not msg.is_multipart(): if not msg.is_multipart():
raise ValueError("Kein multipart/form-data") raise ValueError("Kein multipart/form-data")
file_data = b"" files: list[tuple[str, str, bytes]] = []
filename = "" fields: dict[str, str] = {}
fields = {}
for part in msg.get_payload(): for part in msg.get_payload():
disposition = part.get("Content-Disposition", "") disposition = part.get("Content-Disposition", "")
@@ -2686,12 +2908,33 @@ class Handler(BaseHTTPRequestHandler):
if part_filename is not None: if part_filename is not None:
# Dateifeld (binär, nicht dekodieren) # Dateifeld (binär, nicht dekodieren)
file_data = payload files.append((name, part_filename or "", payload))
filename = part_filename or ""
else: else:
# Textfeld # Textfeld
fields[name] = payload.decode("utf-8", errors="replace") fields[name] = payload.decode("utf-8", errors="replace")
return files, fields
@staticmethod
def _normalize_image_multipart_fields(fields: dict[str, str]) -> dict:
"""Konvertiert OpenAI-Formularzahlen in die JSON-API-Typen."""
normalized: dict = dict(fields)
for key in ("n", "steps"):
if key not in normalized:
continue
try:
normalized[key] = int(normalized[key])
except (TypeError, ValueError) as exc:
raise ValueError(f"'{key}' muss eine Ganzzahl sein") from exc
return normalized
def _parse_multipart(self, data: bytes, content_type: str
) -> tuple[bytes, str, dict]:
"""Kompatibler Einzeldatei-Wrapper für den STT-Pfad."""
files, fields = self._parse_multipart_parts(data, content_type)
if not files:
return b"", "", fields
_name, filename, file_data = files[-1]
return file_data, filename, fields return file_data, filename, fields
def _transcribe(self) -> None: def _transcribe(self) -> None:
@@ -2725,7 +2968,7 @@ class Handler(BaseHTTPRequestHandler):
# Modell-Validierung # Modell-Validierung
model = fields.get("model", STT_MODEL) model = fields.get("model", STT_MODEL)
if model not in (STT_MODEL, "whisper"): if model != STT_MODEL:
self._send_error(400, f"unbekanntes Modell: {model!r} " self._send_error(400, f"unbekanntes Modell: {model!r} "
f"(erwartet: {STT_MODEL})", f"(erwartet: {STT_MODEL})",
"invalid_request_error", "unknown_model") "invalid_request_error", "unknown_model")
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""OpenAI-router STT adapter for the persistent, CPU-only Qwen3-ASR server."""
import json
import logging
import os
import subprocess
import tempfile
import time
import urllib.request
import uuid
from email import policy
from email.parser import BytesParser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = os.environ.get("QWEN_ASR_HOST", "0.0.0.0")
PORT = int(os.environ.get("QWEN_ASR_PORT", "8084"))
SERVER_URL = os.environ.get("QWEN_ASR_SERVER_URL", "http://qwen-asr:8080").rstrip("/")
LANGUAGE = os.environ.get("QWEN_ASR_LANGUAGE", "de")
MAX_BODY_BYTES = 25 * 1024 * 1024
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("qwen-asr-worker")
def clean_transcript(value: str) -> str:
"""llama.cpp may include a Qwen task marker before the spoken words."""
if "<asr_text>" in value:
value = value.split("<asr_text>", 1)[1]
return value.replace("<|endoftext|>", "").strip()
def transcribe(audio: bytes, filename: str, language: str) -> dict:
suffix = os.path.splitext(filename)[1].lower() or ".wav"
with tempfile.TemporaryDirectory(prefix="qwen_asr_") as directory:
source = os.path.join(directory, "input" + suffix)
wav = os.path.join(directory, "audio.wav")
with open(source, "wb") as handle:
handle.write(audio)
result = subprocess.run(
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
"-i", source, "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav],
capture_output=True, text=True, timeout=30,
)
if result.returncode:
raise ValueError("Audio konnte nicht gelesen werden: " + result.stderr[-300:])
with open(wav, "rb") as handle:
pcm = handle.read()
boundary = "athena-qwen-asr-" + uuid.uuid4().hex
body = b"".join([
f"--{boundary}\r\n".encode(),
b'Content-Disposition: form-data; name="file"; filename="audio.wav"\r\n',
b"Content-Type: audio/wav\r\n\r\n", pcm, b"\r\n",
f"--{boundary}\r\n".encode(),
b'Content-Disposition: form-data; name="model"\r\n\r\n',
b"qwen3-asr-0.6b\r\n",
f"--{boundary}\r\n".encode(),
b'Content-Disposition: form-data; name="language"\r\n\r\n',
language.encode(), b"\r\n",
f"--{boundary}--\r\n".encode(),
])
request = urllib.request.Request(
SERVER_URL + "/v1/audio/transcriptions", data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
method="POST",
)
started = time.monotonic()
with urllib.request.urlopen(request, timeout=60) as response:
payload = json.loads(response.read())
if not isinstance(payload, dict) or not isinstance(payload.get("text"), str):
raise RuntimeError("Qwen3-ASR returned no transcription")
text = clean_transcript(payload["text"])
elapsed = int((time.monotonic() - started) * 1000)
log.info("Qwen3-ASR transcribed %d characters in %d ms", len(text), elapsed)
return {"text": text, "language": language, "duration_ms": elapsed,
"engine": "qwen3-asr-0.6b"}
def parse_audio(body: bytes, content_type: str) -> tuple[bytes, str, str]:
if "multipart/form-data" not in content_type.lower():
return body, "audio.wav", LANGUAGE
message = BytesParser(policy=policy.default).parsebytes(
b"MIME-Version: 1.0\r\nContent-Type: " + content_type.encode() +
b"\r\n\r\n" + body
)
if not message.is_multipart():
raise ValueError("Invalid multipart upload")
audio = b""
filename = "audio.wav"
language = LANGUAGE
for part in message.iter_parts():
name = part.get_param("name", header="content-disposition")
if name == "file":
audio = part.get_payload(decode=True) or b""
filename = os.path.basename(part.get_filename() or filename)
elif name == "language":
language = (part.get_payload(decode=True) or b"").decode("utf-8").strip()
return audio, filename, language if language and language != "auto" else LANGUAGE
class Handler(BaseHTTPRequestHandler):
def send_json(self, status: int, data: dict) -> None:
body = json.dumps(data, ensure_ascii=False).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if self.path != "/status":
self.send_json(404, {"error": "not found"})
return
try:
with urllib.request.urlopen(SERVER_URL + "/health", timeout=2) as response:
ready = response.status == 200
except Exception:
ready = False
self.send_json(200, {"ready": ready, "model": "qwen3-asr-0.6b",
"engine": "qwen3-asr", "language": LANGUAGE})
def do_POST(self) -> None:
if self.path != "/transcribe":
self.send_json(404, {"error": "not found"})
return
try:
size = int(self.headers.get("Content-Length", "0"))
if not 0 < size <= MAX_BODY_BYTES:
self.send_json(413, {"error": "Invalid audio size"})
return
audio, filename, language = parse_audio(
self.rfile.read(size), self.headers.get("Content-Type", "")
)
if not audio:
raise ValueError("Missing audio file")
self.send_json(200, transcribe(audio, filename, language))
except ValueError as exc:
self.send_json(400, {"error": str(exc)})
except Exception:
log.exception("Transcription failed")
self.send_json(503, {"error": "Qwen3-ASR unavailable"})
if __name__ == "__main__":
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
+1 -1
View File
@@ -18,7 +18,7 @@
"ultra": { "ultra": {
"context": 262144, "context": 262144,
"model_alias": "qwen-ultra", "model_alias": "qwen-ultra",
"vision": false "vision": true
}, },
"uncensored": { "uncensored": {
"context": 80000, "context": 80000,
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Download pinned official weights, build the isolated worker and leave it stopped.
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE=${STACK_ENV:-/etc/mike-ai/stack.env}
MODEL_DIR=${QWEN_IMAGE_21_MODEL_DIR:-/data/models/Qwen-Image-2.1-ComfyUI}
OUTPUT_DIR=${QWEN_IMAGE_21_OUTPUT_DIR:-/data/qwen-image-2.1-test-output}
BASE=https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/ace0edeb3791a594ddfa36ed5f41a178a394e921
download() {
local relative=$1 expected=$2 target="$MODEL_DIR/$1"
install -d -m 0755 "$(dirname "$target")"
if [[ -f $target ]] && echo "$expected $target" | sha256sum -c --status; then
echo "OK: $relative"
return
fi
curl -fL --retry 5 --retry-all-errors -C - -o "$target.part" "$BASE/$relative"
echo "$expected $target.part" | sha256sum -c --status
mv "$target.part" "$target"
}
download diffusion_models/qwen_image_2.1_int8_convrot.safetensors cb74113cb03faecd79611b01fd7fd642f0aa60d6f0b95086abee214d75eaa57d
download text_encoders/qwen3vl_8b_int8_convrot.safetensors 8bfd0f6e12abf2d2d697ecc888e5e90b0d6741d6708f05799f53afa560452e8f
download vae/qwen_image_2.1_vae_bf16.safetensors bb21f7473051e1ac368515dd3f2e15cd44d7a11748ee8823e1ddca3e4876b7c9
install -d -m 0755 "$OUTPUT_DIR"
compose=(docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/compose.yaml" --profile qwen-image-test)
"${compose[@]}" pull qwen-image-21-test-runner
"${compose[@]}" build qwen-image-21-test
"${compose[@]}" up -d --build --no-deps profile-controller
"${compose[@]}" create qwen-image-21-test
"${compose[@]}" stop qwen-image-21-test
state=$(docker inspect -f '{{.State.Status}}' mike-ai-qwen-image-2.1-test)
[[ $state == exited || $state == created ]]
echo "Qwen-Image-2.1 test is prepared and stopped. No GPU model was loaded."
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Download pinned official weights and prepare the production worker stopped.
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE=${STACK_ENV:-/etc/mike-ai/stack.env}
MODEL_DIR=${QWEN_IMAGE_21_MODEL_DIR:-/data/models/Qwen-Image-2.1-ComfyUI}
PE_I2I_DIR=${QWEN_IMAGE_PE_I2I_MODEL_DIR:-/data/models/qwen-image-2.1-pe-i2i-q5}
PE_T2I_DIR=${QWEN_IMAGE_PE_T2I_MODEL_DIR:-/data/models/qwen-image-2.1-pe-t2i-q5}
BASE=https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/ace0edeb3791a594ddfa36ed5f41a178a394e921
download() {
local relative=$1 expected=$2 target="$MODEL_DIR/$1"
install -d -m 0755 "$(dirname "$target")"
if [[ -f $target ]] && echo "$expected $target" | sha256sum -c --status; then
echo "OK: $relative"
return
fi
curl -fL --retry 5 --retry-all-errors -C - -o "$target.part" "$BASE/$relative"
echo "$expected $target.part" | sha256sum -c --status
mv "$target.part" "$target"
}
download_url() {
local url=$1 target=$2 expected=$3
install -d -m 0755 "$(dirname "$target")"
if [[ -f $target ]] && echo "$expected $target" | sha256sum -c --status; then
echo "OK: $target"
return
fi
curl -fL --retry 5 --retry-all-errors -C - -o "$target.part" "$url"
echo "$expected $target.part" | sha256sum -c --status
mv "$target.part" "$target"
}
download diffusion_models/qwen_image_2.1_int8_convrot.safetensors cb74113cb03faecd79611b01fd7fd642f0aa60d6f0b95086abee214d75eaa57d
download text_encoders/qwen3vl_8b_int8_convrot.safetensors 8bfd0f6e12abf2d2d697ecc888e5e90b0d6741d6708f05799f53afa560452e8f
download vae/qwen_image_2.1_vae_bf16.safetensors bb21f7473051e1ac368515dd3f2e15cd44d7a11748ee8823e1ddca3e4876b7c9
# Official Qwen Image 2.1 Prompt Enhancers, quantized to Q5_K_M for the
# 12-GiB RTX 3060. I2I uses the separate multimodal projector; T2I does not.
download_url \
https://huggingface.co/prithivMLmods/Qwen-Image-2.1-PE-I2I-GGUF/resolve/55b9c1a326599e142d59bcad8715d5601ccf8daa/Qwen-Image-2.1-PE-I2I.Q5_K_M.gguf \
"$PE_I2I_DIR/Qwen-Image-2.1-PE-I2I.Q5_K_M.gguf" \
cb71f71fe5fe5938570d65a7c403fbcb1a9065dff9dd9a021f58aec42785b84e
download_url \
https://huggingface.co/prithivMLmods/Qwen-Image-2.1-PE-I2I-GGUF/resolve/55b9c1a326599e142d59bcad8715d5601ccf8daa/Qwen-Image-2.1-PE-I2I.mmproj-bf16.gguf \
"$PE_I2I_DIR/Qwen-Image-2.1-PE-I2I.mmproj-bf16.gguf" \
8dedb71dbc3092dc47de9108ad373d68a12854e2527d59bd9399601738f3bce1
download_url \
https://huggingface.co/Qwen/Qwen-Image-2.1-PE-I2I/resolve/72927bc08afc99b7888ceb7d7d51a12db3700bbd/system_prompt.txt \
"$PE_I2I_DIR/system_prompt.txt" \
e378fea686a1431581ba4c654d332ae96adad633f144ae738ec8ce9c4fd66439
download_url \
https://huggingface.co/prithivMLmods/Qwen-Image-2.1-PE-T2I-GGUF/resolve/e18d4a3e0830ab157770738b16830e6fcf5f57d4/Qwen-Image-2.1-PE-T2I.Q5_K_M.gguf \
"$PE_T2I_DIR/Qwen-Image-2.1-PE-T2I.Q5_K_M.gguf" \
749f5652fd6e8b760ca860091f7a5bffa254a7954203ae5c9bcdf5f93197702f
download_url \
https://huggingface.co/Qwen/Qwen-Image-2.1-PE-T2I/resolve/f3ed7985c788ad75b3ab7223e0c4c51e2a43545b/system_prompt.txt \
"$PE_T2I_DIR/system_prompt.txt" \
a77c9a06c59b120741141d9514b95682bb8761d02bec49ca61def7b2b3d9fb99
compose=(docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/compose.yaml" --profile image --profile flux-standby)
"${compose[@]}" build image-worker
"${compose[@]}" up -d --build --no-deps profile-controller
"${compose[@]}" create image-worker image-prompt-enhancer-i2i \
image-prompt-enhancer-t2i flux-image-worker
"${compose[@]}" stop image-worker image-prompt-enhancer-i2i \
image-prompt-enhancer-t2i flux-image-worker
state=$(docker inspect -f '{{.State.Status}}' mike-ai-image-worker)
[[ $state == exited || $state == created ]]
echo "Qwen-Image-2.1, both prompt enhancers and the FLUX standby are prepared and stopped."
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env python3
"""One-shot Qwen-Image-2.1 evaluation with guaranteed profile restoration."""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import random
import time
import urllib.parse
import urllib.request
CONTROLLER = os.environ.get("CONTROLLER_URL", "http://profile-controller:8090")
TOKEN = os.environ["CONTROLLER_TOKEN"]
COMFY = os.environ.get("COMFY_URL", "http://qwen-image-21-test:8188")
OUTPUT = pathlib.Path(os.environ.get("OUTPUT_DIR", "/output"))
def request_json(url: str, *, payload: dict | None = None,
authenticated: bool = False, timeout: float = 30) -> dict:
body = None if payload is None else json.dumps(payload).encode()
headers = {"Content-Type": "application/json"}
if authenticated:
headers["Authorization"] = f"Bearer {TOKEN}"
method = "POST" if payload is not None else "GET"
req = urllib.request.Request(url, data=body, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as response:
return json.load(response)
def controller(path: str, *, post: bool = False) -> dict:
return request_json(CONTROLLER + path, payload={} if post else None,
authenticated=True, timeout=900)
def wait_comfy(timeout: int = 900) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
request_json(COMFY + "/system_stats", timeout=3)
return
except Exception:
time.sleep(2)
raise TimeoutError("Qwen-Image worker did not become ready")
def workflow(prompt: str, seed: int, steps: int, width: int, height: int) -> dict:
return {
"1": {"class_type": "UNETLoader", "inputs": {
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
"weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"}},
"4": {"class_type": "TextEncodeQwenImage21", "inputs": {
"clip": ["2", 0], "prompt": prompt, "negative_prompt": "",
"resolution": max(width, height)}},
"5": {"class_type": "EmptyLatentImage", "inputs": {
"width": width, "height": height, "batch_size": 1}},
"6": {"class_type": "KSampler", "inputs": {
"model": ["1", 0], "positive": ["4", 0], "negative": ["4", 1],
"latent_image": ["5", 0], "seed": seed, "steps": steps,
"cfg": 1.0, "sampler_name": "euler", "scheduler": "simple",
"denoise": 1.0}},
"7": {"class_type": "VAEDecode", "inputs": {
"samples": ["6", 0], "vae": ["3", 0]}},
"8": {"class_type": "SaveImage", "inputs": {
"filename_prefix": "qwen-image-2.1-test", "images": ["7", 0]}},
}
def wait_result(prompt_id: str, timeout: int = 3600) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
history = request_json(f"{COMFY}/history/{prompt_id}", timeout=10)
if prompt_id in history:
result = history[prompt_id]
status = result.get("status", {})
if status.get("status_str") == "error" or not status.get("completed", True):
raise RuntimeError("generation failed: " + json.dumps(status))
return result
time.sleep(2)
raise TimeoutError("Qwen-Image generation timed out")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("prompt")
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--steps", type=int, default=25)
parser.add_argument("--width", type=int, default=1024)
parser.add_argument("--height", type=int, default=1024)
args = parser.parse_args()
if args.width % 32 or args.height % 32:
parser.error("width and height must be multiples of 32")
seed = args.seed if args.seed is not None else random.randrange(2**53)
previous = controller("/profiles/status").get("active_profile")
started = time.monotonic()
try:
controller("/workers/qwen-image-test/start", post=True)
wait_comfy()
queued = request_json(COMFY + "/prompt", payload={
"prompt": workflow(args.prompt, seed, args.steps, args.width, args.height),
"client_id": "athena-qwen-image-21-test"}, timeout=30)
prompt_id = queued["prompt_id"]
result = wait_result(prompt_id)
images = []
for node in result.get("outputs", {}).values():
images.extend(node.get("images", []))
if not images:
raise RuntimeError("generation completed without an image")
image = images[0]
query = urllib.parse.urlencode({
"filename": image["filename"], "subfolder": image.get("subfolder", ""),
"type": image.get("type", "output")})
target = OUTPUT / image["filename"]
target.parent.mkdir(parents=True, exist_ok=True)
with urllib.request.urlopen(COMFY + "/view?" + query, timeout=120) as src:
target.write_bytes(src.read())
print(json.dumps({"status": "ok", "file": str(target), "seed": seed,
"seconds": round(time.monotonic() - started, 1)}, indent=2))
finally:
try:
controller("/workers/qwen-image-test/stop", post=True)
finally:
if previous:
controller(f"/profiles/{previous}/activate", post=True)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -47,7 +47,7 @@ def main() -> int:
body = ( body = (
f"--{boundary}\r\n" f"--{boundary}\r\n"
'Content-Disposition: form-data; name="model"\r\n\r\n' 'Content-Disposition: form-data; name="model"\r\n\r\n'
"whisper-1\r\n" "qwen3-asr\r\n"
f"--{boundary}\r\n" f"--{boundary}\r\n"
'Content-Disposition: form-data; name="language"\r\n\r\n' 'Content-Disposition: form-data; name="language"\r\n\r\n'
"de\r\n" "de\r\n"
+9 -5
View File
@@ -1,7 +1,7 @@
# Athena realtime voice bridge # Athena realtime voice bridge
This independent service lets OpenClaw's existing browser Talk UI use Athena This independent service lets OpenClaw's existing browser Talk UI use Athena
Whisper, OpenClaw's agent, and Athena Qwen3-TTS through the browser's supported Qwen3-ASR, OpenClaw's agent, and Athena Qwen3-TTS through the browser's supported
OpenAI-style WebRTC transport. It does not modify OpenClaw or switch an Athena OpenAI-style WebRTC transport. It does not modify OpenClaw or switch an Athena
profile. The existing `gateway-relay` path remains available. profile. The existing `gateway-relay` path remains available.
@@ -69,7 +69,7 @@ cd /opt/mike-ai/stack
docker compose --env-file /etc/mike-ai/stack.env up -d --no-deps --build realtime-voice docker compose --env-file /etc/mike-ai/stack.env up -d --no-deps --build realtime-voice
``` ```
OpenClaw 2026.9.4 uses the `athena-talk` plugin version 1.2.1 with OpenClaw uses the `athena-talk` plugin version 1.3.0 with
`talk.realtime.transport` set to `webrtc` and `talk.realtime.transport` set to `webrtc` and
`talk.realtime.providers.athena-talk.realtimeUpstreamUrl` set to `talk.realtime.providers.athena-talk.realtimeUpstreamUrl` set to
`http://192.168.1.212:8090/v1/realtime/calls`. On the Mac, turn off `http://192.168.1.212:8090/v1/realtime/calls`. On the Mac, turn off
@@ -90,10 +90,14 @@ python3.11 -m venv .venv
``` ```
The synthetic microphone test passed over the actual OpenClaw HTTPS offer The synthetic microphone test passed over the actual OpenClaw HTTPS offer
route and WireGuard media path with production Whisper and Qwen3-TTS on route and WireGuard media path with production Qwen3-ASR and Qwen3-TTS on
2026-09-16. Subsequent real browser Talk sessions successfully transcribed 2026-09-16. Subsequent real browser Talk sessions successfully transcribed
and answered multiple user turns. Browser dictation through the same plugin and answered multiple user turns. Browser dictation uses a separate plugin
also produced text after a correction to its separate transcription path. path. Since version 1.3.0, recordings longer than six seconds are
pre-transcribed incrementally in overlapping short windows while the
microphone remains active. This keeps the final tail inside OpenClaw's
five-second completion window. Talk through this WebRTC service still ends
and transcribes one utterance at a time.
The first user report of a second turn becoming stuck was addressed by The first user report of a second turn becoming stuck was addressed by
matching conversation item and predecessor IDs; the later two-turn test matching conversation item and predecessor IDs; the later two-turn test
passed. This is still half-duplex and needs a private route for WebRTC media. passed. This is still half-duplex and needs a private route for WebRTC media.
+1 -1
View File
@@ -281,7 +281,7 @@ class RealtimeSession:
try: try:
form = FormData() form = FormData()
form.add_field("file", make_wav(pcm), filename="talk.wav", content_type="audio/wav") form.add_field("file", make_wav(pcm), filename="talk.wav", content_type="audio/wav")
form.add_field("model", "whisper-1") form.add_field("model", "qwen3-asr")
form.add_field("language", os.environ.get("STT_LANGUAGE", "de")) form.add_field("language", os.environ.get("STT_LANGUAGE", "de"))
async with self.http.post( async with self.http.post(
os.environ["ATHENA_API_BASE_URL"].rstrip("/") + "/audio/transcriptions", os.environ["ATHENA_API_BASE_URL"].rstrip("/") + "/audio/transcriptions",
+4 -1
View File
@@ -27,6 +27,8 @@ for name in \
mike-ai-wireguard-gateway \ mike-ai-wireguard-gateway \
mike-ai-profile-controller \ mike-ai-profile-controller \
mike-ai-embedding \ mike-ai-embedding \
mike-ai-qwen-asr \
mike-ai-qwen-asr-worker \
mike-ai-router \ mike-ai-router \
mike-ai-qwen3-tts \ mike-ai-qwen3-tts \
mike-ai-tts-gateway \ mike-ai-tts-gateway \
@@ -70,7 +72,8 @@ for legacy in \
mike-ai-mcp-deemix \ mike-ai-mcp-deemix \
mike-ai-mcp-github \ mike-ai-mcp-github \
mike-ai-mcp-homeassistant \ mike-ai-mcp-homeassistant \
mike-ai-mcp-navidrome; do mike-ai-mcp-navidrome \
mike-ai-whisper; do
[[ -z $(docker inspect -f '{{.Name}}' "$legacy" 2>/dev/null || true) ]] || \ [[ -z $(docker inspect -f '{{.Name}}' "$legacy" 2>/dev/null || true) ]] || \
fail "Altlast existiert noch: $legacy" fail "Altlast existiert noch: $legacy"
done done